Loading panel/src/i18n/resources/en-US/dashboard.json +2 −0 Changes for panel/src/i18n/resources/en-US/dashboard.json: 2 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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", Loading panel/src/i18n/resources/zh-CN/dashboard.json +2 −0 Changes for panel/src/i18n/resources/zh-CN/dashboard.json: 2 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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": "集群自建镜像", Loading panel/src/pages/Dashboard.test.tsx 0 → 100644 +160 −0 Changes for panel/src/pages/Dashboard.test.tsx: 160 added lines, 0 removed lines. Original line number Diff line number Diff line // @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<typeof import("@/lib/api")>(); 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<never>(() => {}); const lobby: MyServerView = { name: "lobby", subdomain: "lobby", owned: true, claimable: false, phase: "Running", playersOnline: 3, playersMax: 20, }; function renderDashboard() { return render( <MemoryRouter> <Dashboard /> </MemoryRouter>, ); } // 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(); }); }); panel/src/pages/Dashboard.tsx +111 −73 Changes for panel/src/pages/Dashboard.tsx: 111 added lines, 73 removed lines. Original line number Diff line number Diff line Loading @@ -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"; Loading @@ -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"; Loading @@ -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 ( <> <PageHeader icon={LayoutDashboard} title={t("title")} subtitle={t("subtitle")} /> {loading && !servers ? ( {servers.loading && !servers.data ? ( <Loading /> ) : error ? ( <ErrorState error={error} onRetry={reloadServers} /> ) : servers.error ? ( <ErrorState error={servers.error} onRetry={servers.reload} /> ) : !cfg ? ( <Loading /> ) : ( <FleetView servers={servers ?? []} servers={servers.data ?? []} counts={counts} linkStatus={linkData || undefined} images={imagesData || []} isAdmin={!!identity?.is_admin} link={link} images={images.error ? null : images.data} isAdmin={isAdmin} /> )} </> ); } function LinkCard({ link }: { link: AsyncState<{ linked: boolean }> }) { const { t } = useTranslation("dashboard"); if (link.error) { return ( <> <div role="alert" className="space-y-1 max-w-xl"> <div className="flex items-center gap-2 text-muted-foreground font-semibold text-sm"> <AlertTriangle className="h-5 w-5 shrink-0" /> <span>{t("link_status_failed")}</span> </div> <p className="text-xs text-muted-foreground leading-relaxed">{humanizeError(link.error)}</p> </div> <Button variant="outline" size="sm" className="shrink-0 w-full sm:w-auto text-xs gap-1.5" onClick={link.reload} disabled={link.loading} > <RefreshCw className={link.loading ? "h-3 w-3 animate-spin" : "h-3 w-3"} /> {t("common:try_again")} </Button> </> ); } if (!link.data) { return ( <div className="flex items-center gap-2 text-sm text-muted-foreground"> <Loader2 className="h-4 w-4 animate-spin" /> {t("link_status_checking")} </div> ); } if (link.data.linked) { return ( <> <div className="space-y-1 max-w-xl"> <div className="flex items-center gap-2 text-green-600 dark:text-green-400 font-semibold text-sm"> <CheckCircle className="h-5 w-5 shrink-0" /> <span>{t("account_linked_title")}</span> </div> <p className="text-xs text-muted-foreground leading-relaxed"> {t("account_linked_desc")} </p> </div> <Link to="/account" className="shrink-0 w-full sm:w-auto"> <Button variant="outline" size="sm" className="w-full text-xs"> {t("manage_game_character")} </Button> </Link> </> ); } return ( <> <div className="space-y-1 max-w-xl"> <div className="flex items-center gap-2 text-amber-600 dark:text-amber-500 font-semibold text-sm"> <AlertTriangle className="h-5 w-5 shrink-0 animate-bounce" /> <span>{t("account_unlinked_title")}</span> </div> <p className="text-xs text-muted-foreground leading-relaxed"> {t("account_unlinked_desc")} </p> </div> <Link to="/account" className="shrink-0 w-full sm:w-auto"> <Button variant="default" size="sm" className="w-full text-xs"> {t("go_link")} <ArrowRight className="h-3 w-3 ml-1" /> </Button> </Link> </> ); } 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"); Loading Loading @@ -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(() => Loading Loading @@ -200,41 +272,7 @@ function FleetView({ {/* 1. 游戏角色绑定 Banner */} <Card className="overflow-hidden"> <CardContent className="p-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> {linkStatus?.linked ? ( <> <div className="space-y-1 max-w-xl"> <div className="flex items-center gap-2 text-green-600 dark:text-green-400 font-semibold text-sm"> <CheckCircle className="h-5 w-5 shrink-0" /> <span>{t("account_linked_title")}</span> </div> <p className="text-xs text-muted-foreground leading-relaxed"> {t("account_linked_desc")} </p> </div> <Link to="/account" className="shrink-0 w-full sm:w-auto"> <Button variant="outline" size="sm" className="w-full text-xs"> {t("manage_game_character")} </Button> </Link> </> ) : ( <> <div className="space-y-1 max-w-xl"> <div className="flex items-center gap-2 text-amber-600 dark:text-amber-500 font-semibold text-sm"> <AlertTriangle className="h-5 w-5 shrink-0 animate-bounce" /> <span>{t("account_unlinked_title")}</span> </div> <p className="text-xs text-muted-foreground leading-relaxed"> {t("account_unlinked_desc")} </p> </div> <Link to="/account" className="shrink-0 w-full sm:w-auto"> <Button variant="default" size="sm" className="w-full text-xs"> {t("go_link")} <ArrowRight className="h-3 w-3 ml-1" /> </Button> </Link> </> )} <LinkCard link={link} /> </CardContent> </Card> Loading Loading @@ -291,7 +329,7 @@ function FleetView({ <div className="flex items-center gap-3 p-3 rounded-lg border bg-accent/5 text-xs text-muted-foreground"> <ShieldCheck className="h-4.5 w-4.5 text-primary shrink-0" /> <div> <span className="font-semibold text-foreground mr-1">{images.length}</span> <span className="font-semibold text-foreground mr-1">{totalImagesCount}</span> {t("images_count")}{t("images_security_note")} </div> </div> Loading @@ -313,7 +351,7 @@ function FleetView({ <ShieldCheck className="h-3.5 w-3.5 text-primary" /> <span className="text-[11px] font-medium">{t("spec_images_total")}</span> </div> <div className="text-xl font-bold font-mono text-foreground leading-none mt-1.5">{images.length}</div> <div className="text-xl font-bold font-mono text-foreground leading-none mt-1.5">{totalImagesCount}</div> </div> {/* 集群内自建镜像 */} Loading Loading
panel/src/i18n/resources/en-US/dashboard.json +2 −0 Changes for panel/src/i18n/resources/en-US/dashboard.json: 2 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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", Loading
panel/src/i18n/resources/zh-CN/dashboard.json +2 −0 Changes for panel/src/i18n/resources/zh-CN/dashboard.json: 2 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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": "集群自建镜像", Loading
panel/src/pages/Dashboard.test.tsx 0 → 100644 +160 −0 Changes for panel/src/pages/Dashboard.test.tsx: 160 added lines, 0 removed lines. Original line number Diff line number Diff line // @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<typeof import("@/lib/api")>(); 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<never>(() => {}); const lobby: MyServerView = { name: "lobby", subdomain: "lobby", owned: true, claimable: false, phase: "Running", playersOnline: 3, playersMax: 20, }; function renderDashboard() { return render( <MemoryRouter> <Dashboard /> </MemoryRouter>, ); } // 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(); }); });
panel/src/pages/Dashboard.tsx +111 −73 Changes for panel/src/pages/Dashboard.tsx: 111 added lines, 73 removed lines. Original line number Diff line number Diff line Loading @@ -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"; Loading @@ -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"; Loading @@ -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 ( <> <PageHeader icon={LayoutDashboard} title={t("title")} subtitle={t("subtitle")} /> {loading && !servers ? ( {servers.loading && !servers.data ? ( <Loading /> ) : error ? ( <ErrorState error={error} onRetry={reloadServers} /> ) : servers.error ? ( <ErrorState error={servers.error} onRetry={servers.reload} /> ) : !cfg ? ( <Loading /> ) : ( <FleetView servers={servers ?? []} servers={servers.data ?? []} counts={counts} linkStatus={linkData || undefined} images={imagesData || []} isAdmin={!!identity?.is_admin} link={link} images={images.error ? null : images.data} isAdmin={isAdmin} /> )} </> ); } function LinkCard({ link }: { link: AsyncState<{ linked: boolean }> }) { const { t } = useTranslation("dashboard"); if (link.error) { return ( <> <div role="alert" className="space-y-1 max-w-xl"> <div className="flex items-center gap-2 text-muted-foreground font-semibold text-sm"> <AlertTriangle className="h-5 w-5 shrink-0" /> <span>{t("link_status_failed")}</span> </div> <p className="text-xs text-muted-foreground leading-relaxed">{humanizeError(link.error)}</p> </div> <Button variant="outline" size="sm" className="shrink-0 w-full sm:w-auto text-xs gap-1.5" onClick={link.reload} disabled={link.loading} > <RefreshCw className={link.loading ? "h-3 w-3 animate-spin" : "h-3 w-3"} /> {t("common:try_again")} </Button> </> ); } if (!link.data) { return ( <div className="flex items-center gap-2 text-sm text-muted-foreground"> <Loader2 className="h-4 w-4 animate-spin" /> {t("link_status_checking")} </div> ); } if (link.data.linked) { return ( <> <div className="space-y-1 max-w-xl"> <div className="flex items-center gap-2 text-green-600 dark:text-green-400 font-semibold text-sm"> <CheckCircle className="h-5 w-5 shrink-0" /> <span>{t("account_linked_title")}</span> </div> <p className="text-xs text-muted-foreground leading-relaxed"> {t("account_linked_desc")} </p> </div> <Link to="/account" className="shrink-0 w-full sm:w-auto"> <Button variant="outline" size="sm" className="w-full text-xs"> {t("manage_game_character")} </Button> </Link> </> ); } return ( <> <div className="space-y-1 max-w-xl"> <div className="flex items-center gap-2 text-amber-600 dark:text-amber-500 font-semibold text-sm"> <AlertTriangle className="h-5 w-5 shrink-0 animate-bounce" /> <span>{t("account_unlinked_title")}</span> </div> <p className="text-xs text-muted-foreground leading-relaxed"> {t("account_unlinked_desc")} </p> </div> <Link to="/account" className="shrink-0 w-full sm:w-auto"> <Button variant="default" size="sm" className="w-full text-xs"> {t("go_link")} <ArrowRight className="h-3 w-3 ml-1" /> </Button> </Link> </> ); } 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"); Loading Loading @@ -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(() => Loading Loading @@ -200,41 +272,7 @@ function FleetView({ {/* 1. 游戏角色绑定 Banner */} <Card className="overflow-hidden"> <CardContent className="p-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> {linkStatus?.linked ? ( <> <div className="space-y-1 max-w-xl"> <div className="flex items-center gap-2 text-green-600 dark:text-green-400 font-semibold text-sm"> <CheckCircle className="h-5 w-5 shrink-0" /> <span>{t("account_linked_title")}</span> </div> <p className="text-xs text-muted-foreground leading-relaxed"> {t("account_linked_desc")} </p> </div> <Link to="/account" className="shrink-0 w-full sm:w-auto"> <Button variant="outline" size="sm" className="w-full text-xs"> {t("manage_game_character")} </Button> </Link> </> ) : ( <> <div className="space-y-1 max-w-xl"> <div className="flex items-center gap-2 text-amber-600 dark:text-amber-500 font-semibold text-sm"> <AlertTriangle className="h-5 w-5 shrink-0 animate-bounce" /> <span>{t("account_unlinked_title")}</span> </div> <p className="text-xs text-muted-foreground leading-relaxed"> {t("account_unlinked_desc")} </p> </div> <Link to="/account" className="shrink-0 w-full sm:w-auto"> <Button variant="default" size="sm" className="w-full text-xs"> {t("go_link")} <ArrowRight className="h-3 w-3 ml-1" /> </Button> </Link> </> )} <LinkCard link={link} /> </CardContent> </Card> Loading Loading @@ -291,7 +329,7 @@ function FleetView({ <div className="flex items-center gap-3 p-3 rounded-lg border bg-accent/5 text-xs text-muted-foreground"> <ShieldCheck className="h-4.5 w-4.5 text-primary shrink-0" /> <div> <span className="font-semibold text-foreground mr-1">{images.length}</span> <span className="font-semibold text-foreground mr-1">{totalImagesCount}</span> {t("images_count")}{t("images_security_note")} </div> </div> Loading @@ -313,7 +351,7 @@ function FleetView({ <ShieldCheck className="h-3.5 w-3.5 text-primary" /> <span className="text-[11px] font-medium">{t("spec_images_total")}</span> </div> <div className="text-xl font-bold font-mono text-foreground leading-none mt-1.5">{images.length}</div> <div className="text-xl font-bold font-mono text-foreground leading-none mt-1.5">{totalImagesCount}</div> </div> {/* 集群内自建镜像 */} Loading