fix(panel): 首页绑定状态查询失败只降级绑定卡片并可单独重试,身份改用 useTier,镜像数读不到显示横杠

This commit is contained in:
Lemon-miaow committed 2026-09-25 10:56:13 +08:00
1 parent 3d79293218
commit 2151e0cf92
4 files changed
+275 -73

No files matched your search

@@ -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",
@@ -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": "集群自建镜像",
+160
View File
@@ -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<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();
});
});
+111 -73
View File
@@ -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 (
<>
<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");
@@ -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 */}
<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>
@@ -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>
@@ -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>
{/* 集群内自建镜像 */}