Unverified Commit 2151e0cf authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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

parent 3d792932
Loading
Loading
Loading
Loading
+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
@@ -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",
+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
@@ -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 −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();
  });
});
+111 −73
Changes for panel/src/pages/Dashboard.tsx: 111 added lines, 73 removed lines.
Original line number Diff line number Diff line
@@ -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>

                    {/* 集群内自建镜像 */}