fix(panel): 版本徽标读取服务端构建信息并标出开发版,配置加载失败时显示提示条
This commit is contained in:
9 files changed
+287
-9
No files matched your search
@@ -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} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user