feat(admin): 提示词控制面页 + 概览/状态页重做 + 接入 vitest

- 新增「提示词」页(/prompts):接 /api/v1/prompts,建版本/激活热下发/
  对比激活版行级 diff/撤销,挂到配置组
- 概览页重做为系统控制塔:吃 admin/overview(全平台口径)——平台任务/
  评测/用户规模/服务在线 + 模型路由态 + 提示词覆盖态 + 健康拓扑 + 30s自刷
- 服务状态页重做:中性克制风 + 请求链路做成深色实时数据管道(流动光点/
  节点辉光) + MCP 工具改能力域配色小卡片
- 接入 vitest(此前零测试):41 单测(api 控制面/lib diff/路由派生)
- api.ts 增 statsOverview/adminOverview/prompt 控制面四接口 + groupPrompts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-02 08:59:40 +08:00
parent e2104efc64
commit 30e80c0eed
13 changed files with 3825 additions and 470 deletions
+269
View File
@@ -0,0 +1,269 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import {
setToken,
getToken,
clearToken,
login,
me,
listModels,
saveModel,
gatewayOnline,
groupPrompts,
listPrompts,
createPromptVersion,
activatePrompt,
deactivatePrompt,
type PromptVersion,
} from "./api";
// 构造一个 fetch 响应桩。
function res(body: unknown, init?: { ok?: boolean; status?: number }): Response {
return {
ok: init?.ok ?? true,
status: init?.status ?? 200,
json: async () => body,
} as unknown as Response;
}
const fetchMock = vi.fn();
beforeEach(() => {
clearToken();
localStorage.clear();
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("令牌持久化", () => {
it("setToken 写入内存并持久化到 localStorage", () => {
setToken("abc");
expect(getToken()).toBe("abc");
expect(localStorage.getItem("sdx_admin_token")).toBe("abc");
});
it("clearToken 清空内存与 localStorage", () => {
setToken("abc");
clearToken();
expect(getToken()).toBe("");
expect(localStorage.getItem("sdx_admin_token")).toBeNull();
});
});
describe("authHeaders(经由带鉴权的请求观测)", () => {
it("有令牌时带上 Bearer 头", async () => {
setToken("tok123");
fetchMock.mockResolvedValue(res({ models: [] }));
await listModels("chat");
const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record<string, string>;
expect(headers.Authorization).toBe("Bearer tok123");
});
it("无令牌时不带 Authorization 头", async () => {
fetchMock.mockResolvedValue(res({ models: [] }));
await listModels("chat");
const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record<string, string>;
expect(headers.Authorization).toBeUndefined();
});
it("写请求带 JSON Content-Type", async () => {
setToken("tok");
fetchMock.mockResolvedValue(res({ id: "1" }));
await saveModel({ kind: "chat", provider: "openai", base_url: "u", api_key: "k", model: "m" });
const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record<string, string>;
expect(headers["Content-Type"]).toBe("application/json");
});
});
describe("guard401 清令牌并广播登出", () => {
it("401 时清令牌并派发 sdx:logout 事件", async () => {
setToken("stale");
const onLogout = vi.fn();
window.addEventListener("sdx:logout", onLogout);
fetchMock.mockResolvedValue(res({}, { ok: false, status: 401 }));
await expect(listModels("chat")).rejects.toThrow();
expect(getToken()).toBe("");
expect(onLogout).toHaveBeenCalledOnce();
window.removeEventListener("sdx:logout", onLogout);
});
it("403(已登录非管理员)照常抛错,但不清令牌/不登出", async () => {
setToken("validuser");
const onLogout = vi.fn();
window.addEventListener("sdx:logout", onLogout);
fetchMock.mockResolvedValue(res({}, { ok: false, status: 403 }));
await expect(listModels("chat")).rejects.toThrow();
expect(getToken()).toBe("validuser"); // 令牌保留
expect(onLogout).not.toHaveBeenCalled();
window.removeEventListener("sdx:logout", onLogout);
});
});
describe("login", () => {
it("成功时存令牌并返回用户", async () => {
fetchMock.mockResolvedValue(
res({ token: "t1", user: { id: "u1", email: "a@b.c" } }),
);
const user = await login("a@b.c", "pw");
expect(user.id).toBe("u1");
expect(getToken()).toBe("t1");
});
it("失败时抛出后端 error 文案且不存令牌", async () => {
fetchMock.mockResolvedValue(res({ error: "密码错误" }, { ok: false, status: 401 }));
await expect(login("a@b.c", "bad")).rejects.toThrow("密码错误");
expect(getToken()).toBe("");
});
it("无 error 字段时回退到状态码文案", async () => {
fetchMock.mockResolvedValue(res({}, { ok: false, status: 500 }));
await expect(login("a@b.c", "pw")).rejects.toThrow("500");
});
});
describe("me", () => {
it("无令牌时直接返回 null(不发请求)", async () => {
const u = await me();
expect(u).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it("响应非 ok 时清令牌并返回 null", async () => {
setToken("expired");
fetchMock.mockResolvedValue(res({}, { ok: false, status: 401 }));
expect(await me()).toBeNull();
expect(getToken()).toBe("");
});
it("成功时返回用户", async () => {
setToken("ok");
fetchMock.mockResolvedValue(res({ user: { id: "u1", email: "a@b.c" } }));
expect(await me()).toEqual({ id: "u1", email: "a@b.c" });
});
});
describe("gatewayOnline", () => {
it("healthz ok 时为 true", async () => {
fetchMock.mockResolvedValue(res(null, { ok: true }));
expect(await gatewayOnline()).toBe(true);
});
it("healthz 非 ok 时为 false", async () => {
fetchMock.mockResolvedValue(res(null, { ok: false, status: 503 }));
expect(await gatewayOnline()).toBe(false);
});
it("网络异常时吞掉错误并返回 false", async () => {
fetchMock.mockRejectedValue(new Error("ECONNREFUSED"));
expect(await gatewayOnline()).toBe(false);
});
});
// —— Prompt 控制面 ——
function pv(key: string, version: number, active = false): PromptVersion {
return { key, version, active, note: "", content: `c${version}` };
}
describe("groupPrompts(控制面分组:纯函数)", () => {
it("保持 keys 注册顺序,未建版本的 key 也成组(active=null", () => {
const gs = groupPrompts({ keys: ["a", "b", "c"], versions: [pv("b", 1)] });
expect(gs.map((g) => g.key)).toEqual(["a", "b", "c"]);
expect(gs[0]).toMatchObject({ key: "a", versions: [], active: null });
expect(gs[1].versions).toHaveLength(1);
});
it("每组版本号倒序,挑出激活版", () => {
const gs = groupPrompts({ keys: ["k"], versions: [pv("k", 1), pv("k", 3, true), pv("k", 2)] });
expect(gs[0].versions.map((v) => v.version)).toEqual([3, 2, 1]);
expect(gs[0].active?.version).toBe(3);
});
it("无激活版时 active 为 null(回退代码默认)", () => {
const gs = groupPrompts({ keys: ["k"], versions: [pv("k", 1), pv("k", 2)] });
expect(gs[0].active).toBeNull();
});
it("DB 出现 Known 未列的 key 也容错展示(追加在已知之后)", () => {
const gs = groupPrompts({ keys: ["known"], versions: [pv("orphan", 1, true)] });
expect(gs.map((g) => g.key)).toEqual(["known", "orphan"]);
expect(gs[1].active?.version).toBe(1);
});
});
describe("listPrompts", () => {
it("缺字段时回退为空数组(不崩)", async () => {
setToken("t");
fetchMock.mockResolvedValue(res({}));
expect(await listPrompts()).toEqual({ keys: [], versions: [] });
});
it("非 ok 抛错", async () => {
setToken("t");
fetchMock.mockResolvedValue(res({}, { ok: false, status: 502 }));
await expect(listPrompts()).rejects.toThrow("502");
});
it("命中 /api/v1/prompts(不在 /admin 前缀下)", async () => {
setToken("t");
fetchMock.mockResolvedValue(res({ keys: [], versions: [] }));
await listPrompts();
expect(fetchMock.mock.calls[0][0]).toMatch(/\/api\/v1\/prompts$/);
});
});
describe("createPromptVersion", () => {
it("成功返回新版本号", async () => {
setToken("t");
fetchMock.mockResolvedValue(res({ key: "k", version: 4 }));
expect(await createPromptVersion("k", "body", "note")).toBe(4);
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string);
expect(body).toEqual({ key: "k", content: "body", note: "note" });
});
it("失败抛后端 error 文案", async () => {
setToken("t");
fetchMock.mockResolvedValue(res({ error: "key/content required" }, { ok: false, status: 400 }));
await expect(createPromptVersion("k", "", "")).rejects.toThrow("key/content required");
});
});
describe("activatePrompt", () => {
it("成功无异常", async () => {
setToken("t");
fetchMock.mockResolvedValue(res({ activated: true }));
await expect(activatePrompt("k", 2)).resolves.toBeUndefined();
});
it("已激活但广播失败(warn) → 当作错误抛出提醒运维", async () => {
setToken("t");
fetchMock.mockResolvedValue(res({ warn: "已激活但广播失败: nats down" }));
await expect(activatePrompt("k", 2)).rejects.toThrow("广播失败");
});
it("HTTP 失败抛 error", async () => {
setToken("t");
fetchMock.mockResolvedValue(res({ error: "key/version required" }, { ok: false, status: 400 }));
await expect(activatePrompt("k", 0)).rejects.toThrow("key/version required");
});
});
describe("deactivatePrompt", () => {
it("成功无异常并带上 key", async () => {
setToken("t");
fetchMock.mockResolvedValue(res({ deactivated: true }));
await deactivatePrompt("k");
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string);
expect(body).toEqual({ key: "k" });
});
it("失败抛错", async () => {
setToken("t");
fetchMock.mockResolvedValue(res({ error: "boom" }, { ok: false, status: 502 }));
await expect(deactivatePrompt("k")).rejects.toThrow("boom");
});
});