From 2151e0cf92bf32a997e2c4082d9cdd8f38a9bee6 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Fri, 25 Sep 2026 10:56:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(panel):=20=E9=A6=96=E9=A1=B5=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E7=8A=B6=E6=80=81=E6=9F=A5=E8=AF=A2=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=8F=AA=E9=99=8D=E7=BA=A7=E7=BB=91=E5=AE=9A=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=B9=B6=E5=8F=AF=E5=8D=95=E7=8B=AC=E9=87=8D=E8=AF=95=EF=BC=8C?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E6=94=B9=E7=94=A8=20useTier=EF=BC=8C?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E6=95=B0=E8=AF=BB=E4=B8=8D=E5=88=B0=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E6=A8=AA=E6=9D=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- panel/src/i18n/resources/en-US/dashboard.json | 2 + panel/src/i18n/resources/zh-CN/dashboard.json | 2 + panel/src/pages/Dashboard.test.tsx | 160 +++++++++++++++ panel/src/pages/Dashboard.tsx | 184 +++++++++++------- 4 files changed, 275 insertions(+), 73 deletions(-) create mode 100644 panel/src/pages/Dashboard.test.tsx diff --git a/panel/src/i18n/resources/en-US/dashboard.json b/panel/src/i18n/resources/en-US/dashboard.json index a8f26ef..94dd2fb 100644 --- a/panel/src/i18n/resources/en-US/dashboard.json +++ b/panel/src/i18n/resources/en-US/dashboard.json @@ -16,6 +16,8 @@ "account_unlinked_title": "Account Unlinked", "account_unlinked_desc": "You must link a Minecraft character to claim or operate servers. Go to Account to bind now.", "go_link": "Bind Now", + "link_status_checking": "Checking whether a game character is linked…", + "link_status_failed": "Link status unavailable", "system_specs": "Specs & Metrics", "spec_images_total": "Whitelisted Images", "spec_images_built": "Built Locally", diff --git a/panel/src/i18n/resources/zh-CN/dashboard.json b/panel/src/i18n/resources/zh-CN/dashboard.json index 758e50a..c4fb622 100644 --- a/panel/src/i18n/resources/zh-CN/dashboard.json +++ b/panel/src/i18n/resources/zh-CN/dashboard.json @@ -16,6 +16,8 @@ "account_unlinked_title": "未关联游戏角色", "account_unlinked_desc": "您需要关联 Minecraft 角色才能认领或操作服务器。请立即前往个人中心绑定。", "go_link": "前往绑定", + "link_status_checking": "正在确认是否已关联游戏角色…", + "link_status_failed": "暂时无法获取绑定状态", "system_specs": "系统实例与镜像指标", "spec_images_total": "支持镜像总数", "spec_images_built": "集群自建镜像", diff --git a/panel/src/pages/Dashboard.test.tsx b/panel/src/pages/Dashboard.test.tsx new file mode 100644 index 0000000..0b0c677 --- /dev/null +++ b/panel/src/pages/Dashboard.test.tsx @@ -0,0 +1,160 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import i18next from "i18next"; +import type { MyServerView, WhitelistImage } from "@/lib/types"; +import { Dashboard } from "./Dashboard"; + +const tier = vi.hoisted(() => ({ isAdmin: false })); +const calls = vi.hoisted(() => ({ + myServers: vi.fn(), + linkStatus: vi.fn(), + listImages: vi.fn(), + me: vi.fn(), +})); + +vi.mock("@/lib/tier", () => ({ useTier: () => tier })); +vi.mock("@/lib/webgl", () => ({ webglAvailable: () => false })); +vi.mock("@/lib/config", () => ({ + loadConfig: () => Promise.resolve({ apiBase: "/api/v1", rootDomain: "example.test" }), +})); +vi.mock("@/lib/api", async (importActual) => { + const actual = await importActual(); + return { ...actual, api: { ...actual.api, ...calls } }; +}); + +const t = (key: string) => i18next.t(key, { ns: "dashboard" }); +const failure = (message: string) => ({ status: 409, code: "test_failure", message }); +const never = () => new Promise(() => {}); + +const lobby: MyServerView = { + name: "lobby", + subdomain: "lobby", + owned: true, + claimable: false, + phase: "Running", + playersOnline: 3, + playersMax: 20, +}; + +function renderDashboard() { + return render( + + + , + ); +} + +// The number shown under one of the admin spec labels. +function specValue(label: string) { + const cell = screen.getByText(t(label)).closest("div.space-y-1"); + return cell?.lastElementChild?.textContent; +} + +beforeEach(() => { + tier.isAdmin = false; + for (const fn of Object.values(calls)) fn.mockReset(); + calls.myServers.mockResolvedValue([lobby]); + calls.linkStatus.mockResolvedValue({ linked: true }); + calls.listImages.mockResolvedValue([]); + calls.me.mockImplementation(never); +}); + +describe("Dashboard", () => { + it("keeps the fleet on screen when link status fails, and retries only the link card", async () => { + calls.linkStatus.mockRejectedValueOnce(failure("link backend down")); + renderDashboard(); + + const alert = await screen.findByRole("alert"); + expect(within(alert).getByText(t("link_status_failed"))).toBeTruthy(); + expect(within(alert).getByText("link backend down")).toBeTruthy(); + expect(screen.getByText(t("stat_servers"))).toBeTruthy(); + expect(screen.queryByText(t("account_unlinked_title"))).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: i18next.t("common:try_again") })); + + expect(await screen.findByText(t("account_linked_title"))).toBeTruthy(); + expect(screen.queryByRole("alert")).toBeNull(); + expect(calls.linkStatus).toHaveBeenCalledTimes(2); + expect(calls.myServers).toHaveBeenCalledTimes(1); + }); + + it("retries the server list from the page error", async () => { + calls.myServers.mockRejectedValueOnce(failure("servers unavailable")); + renderDashboard(); + + expect(await screen.findByText("servers unavailable")).toBeTruthy(); + expect(screen.queryByText(t("stat_servers"))).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: i18next.t("common:try_again") })); + + expect(await screen.findByText(t("stat_servers"))).toBeTruthy(); + expect(calls.myServers).toHaveBeenCalledTimes(2); + }); + + it("says it is checking while link status loads, without the unlinked warning", async () => { + calls.linkStatus.mockImplementation(never); + renderDashboard(); + + expect(await screen.findByText(t("link_status_checking"))).toBeTruthy(); + expect(screen.queryByText(t("account_unlinked_title"))).toBeNull(); + expect(screen.queryByText(t("go_link"))).toBeNull(); + }); + + it("warns an unlinked user once link status says so", async () => { + calls.linkStatus.mockResolvedValue({ linked: false }); + renderDashboard(); + + expect(await screen.findByText(t("account_unlinked_title"))).toBeTruthy(); + expect(screen.queryByText(t("link_status_checking"))).toBeNull(); + }); + + it("takes admin from the tier and counts the image whitelist", async () => { + tier.isAdmin = true; + const images: WhitelistImage[] = [ + { image_ref: "a", enabled: true, source: "built", added_by: "o", added_at: "" }, + { image_ref: "b", enabled: true, source: "external", added_by: "o", added_at: "" }, + { image_ref: "c", enabled: true, source: "external", added_by: "o", added_at: "" }, + ]; + calls.listImages.mockResolvedValue(images); + renderDashboard(); + + await waitFor(() => expect(specValue("spec_images_total")).toBe("3")); + expect(specValue("spec_images_built")).toBe("1"); + expect(specValue("spec_images_external")).toBe("2"); + expect(calls.me).not.toHaveBeenCalled(); + }); + + it("shows a dash for image counts it could not read, instead of zero", async () => { + tier.isAdmin = true; + calls.listImages.mockRejectedValue(failure("forbidden")); + renderDashboard(); + + await screen.findByText(t("spec_images_total")); + await waitFor(() => expect(calls.listImages).toHaveBeenCalled()); + // Let the rejection land: a loading whitelist shows a dash too. + await act(() => new Promise((resolve) => setTimeout(resolve, 10))); + expect(specValue("spec_images_total")).toBe("—"); + expect(specValue("spec_images_built")).toBe("—"); + expect(specValue("spec_images_external")).toBe("—"); + expect(screen.getByText(t("stat_servers"))).toBeTruthy(); + }); + + it("shows a dash while the whitelist is still loading", async () => { + tier.isAdmin = true; + calls.listImages.mockImplementation(never); + renderDashboard(); + + await screen.findByText(t("spec_images_total")); + expect(specValue("spec_images_total")).toBe("—"); + expect(specValue("spec_images_built")).toBe("—"); + }); + + it("does not list images for a user", async () => { + renderDashboard(); + + await screen.findByText(t("welcome_title")); + expect(calls.listImages).not.toHaveBeenCalled(); + }); +}); diff --git a/panel/src/pages/Dashboard.tsx b/panel/src/pages/Dashboard.tsx index 17a59f6..295fbe2 100644 --- a/panel/src/pages/Dashboard.tsx +++ b/panel/src/pages/Dashboard.tsx @@ -13,6 +13,8 @@ import { Box, Hand, LayoutDashboard, + Loader2, + RefreshCw, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -22,8 +24,9 @@ import { StatCard } from "@/components/StatCard"; import { PageHeader } from "@/components/PageHeader"; import { ErrorBoundary } from "@/components/ErrorBoundary"; import { FleetGrid } from "@/components/FleetGrid"; -import { api } from "@/lib/api"; -import { useAsync, useConfig } from "@/lib/hooks"; +import { api, humanizeError } from "@/lib/api"; +import { useAsync, useConfig, type AsyncState } from "@/lib/hooks"; +import { useTier } from "@/lib/tier"; import { lazyWithReload } from "@/lib/chunk"; import { webglAvailable } from "@/lib/webgl"; import type { Phase, MyServerView, WhitelistImage } from "@/lib/types"; @@ -38,67 +41,139 @@ const VoxelFleet = lazyWithReload(() => export function Dashboard() { const cfg = useConfig(); - const { data: servers, error: serversErr, loading: serversLoading, reload: reloadServers } = useAsync(() => api.myServers(), []); - const { data: linkData, error: linkErr, loading: linkLoading } = useAsync(() => api.linkStatus(), []); - const { data: identity, error: meErr, loading: meLoading } = useAsync(() => api.me(), []); - - // 仅对管理员加载镜像白名单列表,防止普通用户请求 403 报错 - const { data: imagesData } = useAsync(() => { - if (identity?.is_admin) { - return api.listImages(); - } - return Promise.resolve([]); - }, [identity]); + const { isAdmin } = useTier(); + const servers = useAsync(() => api.myServers(), []); + const link = useAsync(() => api.linkStatus(), []); + // Only admins may list the image whitelist (a user would get a 403). + const images = useAsync(() => (isAdmin ? api.listImages() : Promise.resolve([])), [isAdmin]); const { t } = useTranslation("dashboard"); const counts = useMemo(() => { - const list = servers ?? []; + const list = servers.data ?? []; const by = (p: Phase) => list.filter((s) => s.phase === p).length; return { total: list.length, running: by("Running"), players: list.reduce((n, s) => n + (s.playersOnline ?? 0), 0), }; - }, [servers]); - - const loading = serversLoading || linkLoading || meLoading; - const error = serversErr || linkErr || meErr; + }, [servers.data]); + // The page stands on the server list alone. Link status and the image + // counts are side cards: when they fail only their card degrades, and each + // offers its own retry. return ( <> - {loading && !servers ? ( + {servers.loading && !servers.data ? ( - ) : error ? ( - + ) : servers.error ? ( + ) : !cfg ? ( ) : ( )} ); } +function LinkCard({ link }: { link: AsyncState<{ linked: boolean }> }) { + const { t } = useTranslation("dashboard"); + + if (link.error) { + return ( + <> +
+
+ + {t("link_status_failed")} +
+

{humanizeError(link.error)}

+
+ + + ); + } + + if (!link.data) { + return ( +
+ + {t("link_status_checking")} +
+ ); + } + + if (link.data.linked) { + return ( + <> +
+
+ + {t("account_linked_title")} +
+

+ {t("account_linked_desc")} +

+
+ + + + + ); + } + + return ( + <> +
+
+ + {t("account_unlinked_title")} +
+

+ {t("account_unlinked_desc")} +

+
+ + + + + ); +} + function FleetView({ servers, counts, - linkStatus, + link, images, isAdmin, }: { servers: MyServerView[]; counts: { total: number; running: number; players: number }; - linkStatus?: { linked: boolean }; - images: WhitelistImage[]; + link: AsyncState<{ linked: boolean }>; + /** null while the whitelist loads or when it cannot be read: its counts show a dash. */ + images: WhitelistImage[] | null; isAdmin: boolean; }) { const { t } = useTranslation("dashboard"); @@ -140,14 +215,11 @@ function FleetView({ : 0; // 2. 自建与外部镜像真实统计 - const builtImagesCount = useMemo(() => - images.filter((img) => img.source === "built" || !img.source).length, - [images] - ); - const externalImagesCount = useMemo(() => - images.filter((img) => img.source === "external").length, - [images] - ); + const imageCount = (keep: (img: WhitelistImage) => boolean) => + images ? images.filter(keep).length : "—"; + const totalImagesCount = imageCount(() => true); + const builtImagesCount = imageCount((img) => img.source === "built" || !img.source); + const externalImagesCount = imageCount((img) => img.source === "external"); // 3. 我拥有及可认领服务器真实统计 const ownedServersCount = useMemo(() => @@ -200,41 +272,7 @@ function FleetView({ {/* 1. 游戏角色绑定 Banner */} - {linkStatus?.linked ? ( - <> -
-
- - {t("account_linked_title")} -
-

- {t("account_linked_desc")} -

-
- - - - - ) : ( - <> -
-
- - {t("account_unlinked_title")} -
-

- {t("account_unlinked_desc")} -

-
- - - - - )} +
@@ -291,7 +329,7 @@ function FleetView({
- {images.length} + {totalImagesCount} {t("images_count")}{t("images_security_note")}
@@ -313,7 +351,7 @@ function FleetView({ {t("spec_images_total")} -
{images.length}
+
{totalImagesCount}
{/* 集群内自建镜像 */}