fix(panel): 首页绑定状态查询失败只降级绑定卡片并可单独重试,身份改用 useTier,镜像数读不到显示横杠
This commit is contained in:
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": "集群自建镜像",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
{/* 集群内自建镜像 */}
|
||||
|
||||
Reference in new issue
Block a user