Unverified Commit 1a8cccf2 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(panel): 版本徽标读取服务端构建信息并标出开发版,配置加载失败时显示提示条

parent 34ea7337
Loading
Loading
Loading
Loading
+5 −1
Changes for panel/dev/mockApi.ts: 5 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -2176,7 +2176,11 @@ export function mockApiPlugin(): Plugin {
        const parts = url.pathname.split("/").filter(Boolean);

        if (method === "GET" && url.pathname === "/config.json") {
          sendJSON(res, 200, { apiBase: API_BASE, rootDomain: ROOT_DOMAIN });
          sendJSON(res, 200, {
            apiBase: API_BASE,
            rootDomain: ROOT_DOMAIN,
            build: { version: "v0.4.0+g1a2b3c4", release: "v0.4.0", commit: "1a2b3c4", dev: true },
          });
          return;
        }
        if (parts[0] !== "api" || parts[1] !== "v1") {
+5 −4
Changes for panel/src/components/AppShell.tsx: 5 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -11,6 +11,7 @@ import { Select, SelectContent, SelectItem } from "@/components/ui/select";
import { useTheme } from "@/lib/theme";
import { api, CONNECTION_EVENT, isConnectionLost } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { ConfigBanner, VersionBadge } from "@/components/RuntimeStatus";
import { ErrorBoundary } from "@/components/ErrorBoundary";

function SectionGroup({
@@ -225,6 +226,7 @@ function MobileNav({ sections }: { sections: NavSection[] }) {
          <div className="mt-auto border-t border-border/50 pt-3">
            {/* The strip's sign-out button grows to a thumb-sized target below md. */}
            <UserStrip />
            <VersionBadge className="mt-2 flex justify-center" />
          </div>
        </DialogPrimitive.Content>
      </DialogPrimitive.Portal>
@@ -264,14 +266,12 @@ export function AppShell() {

        <div className="mt-auto flex flex-col gap-2 border-t border-border/50 pt-2.5">
          {/* 1. Toggles & Meta */}
          <div className="flex items-center justify-between px-1">
          <div className="flex items-center justify-between gap-2 px-1">
            <div className="flex items-center gap-1.5">
              <LangToggle />
              <ThemeToggle />
            </div>
            <span className="text-[10px] font-mono text-muted-foreground/40 select-none">
              Felis v0.1.0
            </span>
            <VersionBadge />
          </div>

          {/* 2. Profile Card */}
@@ -286,6 +286,7 @@ export function AppShell() {

      <div className="flex min-w-0 flex-1 flex-col h-full overflow-y-auto">
        <ConnectionBanner />
        <ConfigBanner />
        <header className="flex h-14 shrink-0 items-center justify-between border-b border-border px-4 md:hidden">
          <div className="flex items-center gap-2">
            <MobileNav sections={sections} />
+2 −0
Changes for panel/src/components/AuthLayout.tsx: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { type ReactNode, useEffect } from "react";
import { Cat } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ConfigBanner } from "@/components/RuntimeStatus";

// AuthLayout is the chrome for the pre-app auth surfaces (login, forced change).
// These live OUTSIDE AppShell — there is no nav, no tier context to honor yet —
@@ -23,6 +24,7 @@ export function AuthLayout({
  return (
    <div className="flex min-h-screen flex-col items-center justify-center bg-background px-4 py-12">
      <div className="w-full max-w-sm space-y-6">
        <ConfigBanner className="rounded-md border" />
        <div className="flex flex-col items-center gap-2 text-center">
          <Cat className="h-9 w-9 text-primary" />
          <h1 className="text-xl font-semibold tracking-tight">{title}</h1>
+59 −0
Changes for panel/src/components/RuntimeStatus.test.tsx: 59 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 { fireEvent, render, screen } from "@testing-library/react";
import i18next from "i18next";
import type { RuntimeConfig } from "@/lib/config";
import { ConfigBanner, VersionBadge } from "./RuntimeStatus";

const state = vi.hoisted(() => ({ cfg: null as RuntimeConfig | null }));
vi.mock("@/lib/hooks", () => ({ useConfig: () => state.cfg }));

const base: RuntimeConfig = { apiBase: "/api/v1", rootDomain: "mc.example" };

beforeEach(() => {
  state.cfg = null;
});

describe("VersionBadge", () => {
  it("shows the release of a clean build, with the full stamp as its tooltip", () => {
    state.cfg = { ...base, build: { version: "v1.2.0", release: "v1.2.0", dev: false } };
    const { container } = render(<VersionBadge />);
    expect(container.textContent).toBe("v1.2.0");
    expect(container.firstElementChild?.getAttribute("title")).toBe("v1.2.0");
  });

  it("marks a dev build and shows its commit", () => {
    state.cfg = { ...base, build: { version: "v1.2.0+g1a2b3c4", release: "v1.2.0", commit: "1a2b3c4", dev: true } };
    render(<VersionBadge />);
    expect(screen.getByText("v1.2.0+1a2b3c4")).toBeTruthy();
    expect(screen.getByText(i18next.t("common:build_dev"))).toBeTruthy();
  });

  it("shows only the name without a build stamp", () => {
    state.cfg = base;
    const { container } = render(<VersionBadge />);
    expect(container.textContent).toBe("Felis");
  });
});

describe("ConfigBanner", () => {
  it("stays hidden while the config loads and once it loaded", () => {
    const { container, rerender } = render(<ConfigBanner />);
    expect(container.textContent).toBe("");
    state.cfg = base;
    rerender(<ConfigBanner />);
    expect(screen.queryByRole("alert")).toBeNull();
  });

  it("warns when the panel runs on fallback defaults, and reloads on request", () => {
    state.cfg = { ...base, fallback: true };
    const reload = vi.fn();
    Object.defineProperty(window, "location", { value: { ...window.location, reload }, configurable: true });
    render(<ConfigBanner />);

    const alert = screen.getByRole("alert");
    expect(alert.textContent).toContain(i18next.t("common:config_unavailable"));
    fireEvent.click(screen.getByRole("button", { name: i18next.t("common:reload_page") }));
    expect(reload).toHaveBeenCalled();
  });
});
+58 −0
Changes for panel/src/components/RuntimeStatus.tsx: 58 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { AlertTriangle } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { versionLabel } from "@/lib/config";
import { useConfig } from "@/lib/hooks";
import { cn } from "@/lib/utils";

// VersionBadge shows what the server actually runs, from the build stamp in
// /config.json: the release for a clean build, release+commit and a dev mark
// otherwise. It sits under the brand in a narrow sidebar, so it is the version
// alone, cut short with an ellipsis if need be; the full stamp is in the
// tooltip. Without a stamp it shows the product name.
export function VersionBadge({ className }: { className?: string }) {
  const { t } = useTranslation("common");
  const build = useConfig()?.build;
  return (
    <span
      className={cn(
        "inline-flex min-w-0 items-center gap-1 whitespace-nowrap font-mono text-[10px] text-muted-foreground/60",
        className,
      )}
      title={build?.version}
    >
      <span className="min-w-0 select-all truncate">{build ? versionLabel(build) : t("brand_name")}</span>
      {build?.dev && (
        <span className="shrink-0 rounded bg-amber-500/15 px-1 text-[9px] font-semibold uppercase text-amber-700 dark:text-amber-300">
          {t("build_dev")}
        </span>
      )}
    </span>
  );
}

// ConfigBanner says when /config.json could not be read: the panel then runs
// on build-time defaults, so server addresses and console links it shows may
// be wrong. It does not block anything; a reload fetches the file again.
export function ConfigBanner({ className }: { className?: string }) {
  const { t } = useTranslation("common");
  const cfg = useConfig();
  if (!cfg?.fallback) return null;
  return (
    <div
      role="alert"
      className={cn(
        "flex flex-wrap items-center justify-between gap-2 border-amber-500/30 bg-amber-500/10 px-4 py-2 text-sm text-amber-800 dark:text-amber-200",
        className ?? "border-b",
      )}
    >
      <span className="flex min-w-0 items-center gap-2">
        <AlertTriangle className="h-4 w-4 shrink-0" />
        {t("config_unavailable")}
      </span>
      <Button size="sm" variant="outline" onClick={() => window.location.reload()}>
        {t("reload_page")}
      </Button>
    </div>
  );
}
Loading