fix(admin-web): 删掉面板硬编码假数据,改成诚实的空态/错误态(P0-24,审计 §4.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- Automations.tsx / Llm.tsx 原有的 sample 回退("后端在线但列表为空 → 渲样例数据")整段删除
- 后端不可达 → 明确错误态;后端可达但没有数据 → 空列表 + 引导文案(对齐 Users.tsx 已修做法)
- 复核:grep sample 零命中;npx tsc --noEmit 0 错误;npm run build 成功
This commit is contained in:
AskaEth committed 2026-10-04 12:30:38 +08:00
1 parent 63236c9b00
commit 0dfad675b8
2 files changed
+175 -47

No files matched your search

+99 -28
View File
@@ -10,20 +10,101 @@ import {
import type { AdminAutomationRecord } from "../../lib/types";
import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
import { Button, Surface, Switch, Tag } from "../../components/primitives";
import { Button, EmptyState, Surface, Switch, Tag } from "../../components/primitives";
import { Icon } from "../../components/Icon";
const sample: AdminAutomationRecord[] = [
{ id: "r1", name: "睡前总结", kind: "reminder", cron: "0 22 * * *", enabled: true },
{ id: "r2", name: "晨间问候", kind: "cron", cron: "0 8 * * *", enabled: true },
{ id: "r3", name: "设备待机提醒", kind: "queue", cron: "—", enabled: false },
];
interface AutomationRowProps {
r: AdminAutomationRecord;
busy: boolean;
onRun: (id: string, label: string) => void;
onDelete: (id: string, label: string) => void;
}
/** 一条自动化任务行:字段全部来自接口返回值,动作直接打在真实端点上。 */
function AutomationRow({ r, busy, onRun, onDelete }: AutomationRowProps): React.ReactNode {
return (
<div className="flex items-center gap-3 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2.5 text-sm">
<span className="font-medium">{r.name}</span>
<Tag tone="neutral">{r.kind}</Tag>
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{r.cron || "—"}</span>
<span className="ml-auto flex items-center gap-2">
<Switch checked={r.enabled} disabled onChange={() => {}} label="启用" />
<Button size="sm" variant="ghost" disabled={busy} onClick={() => onRun(r.id, r.name)}>
触发
</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => onDelete(r.id, r.name)}>
删除
</Button>
</span>
</div>
);
}
interface AutomationListProps {
rows: AdminAutomationRecord[];
loading: boolean;
error: string | null;
/** 后端明确表示未提供该端点(404/501)—— 与「请求失败」「真为空」是三种状态。 */
unimplemented: boolean;
busy: boolean;
onRun: (id: string, label: string) => void;
onDelete: (id: string, label: string) => void;
}
/** 列表区:把「加载中 / 端点未实现 / 请求失败 / 真为空 / 有数据」分开渲染。
* 每种状态都只反映接口的真实结果,绝不回退到示例条目。 */
function AutomationList({
rows,
loading,
error,
unimplemented,
busy,
onRun,
onDelete,
}: AutomationListProps): React.ReactNode {
if (loading) {
return <p className="py-8 text-center text-xs text-[var(--mu-text-lo)]">加载中…</p>;
}
if (rows.length === 0) {
if (unimplemented) {
return (
<Notice tone="warn">
后端未提供 GET /api/v1/admin/automations(404/501):列表读不到。本页只渲染接口返回的真实数据,不显示任何示例条目。
</Notice>
);
}
if (error) {
return (
<Notice tone="err">
加载自动化列表失败(后端不可达或接口报错):{error} —— 这是错误态,不是空列表。
</Notice>
);
}
return (
<EmptyState
icon={<Icon name="clock" size={32} />}
title="还没有自动化任务"
hint="用上方表单新增第一条提醒 / cron / 队列事件。"
/>
);
}
return (
<div className="space-y-3">
{error ? <Notice tone="err">刷新失败,下方仍是上次成功读取的数据:{error}</Notice> : null}
{rows.map((r) => (
<AutomationRow key={r.id} r={r} busy={busy} onRun={onRun} onDelete={onDelete} />
))}
</div>
);
}
export function AutomationsModule(): React.ReactNode {
const { data, error, reload } = useAsyncData(adminListAutomations, []);
const { data, error, loading, reload } = useAsyncData(adminListAutomations, []);
const log = useAuditStore((s) => s.log);
const backend = !isNotImplemented(error);
const rows = backend && data && data.length > 0 ? data : sample;
// 端点未实现 / 请求失败必须分开:前者是 404/501,后者是断连或 5xx。
const unimplemented = isNotImplemented(error);
// 只渲染接口真的返回过的数据;没有数据就是空列表,绝不拿样例顶替。
const rows = data ?? [];
const [name, setName] = useState("");
const [kind, setKind] = useState("cron");
@@ -93,9 +174,6 @@ export function AutomationsModule(): React.ReactNode {
/>
{msg ? <Notice tone={msg.includes("失败") ? "err" : "ok"}>{msg}</Notice> : null}
{backend && !error ? <Notice tone="ok">后端已返回自动化配置(数据以接口为准)。</Notice> : null}
{error && !backend ? <Notice tone="warn">后端未提供 /api/v1/admin/automations,以下为示例壳(M10 提供真实数据)。</Notice> : null}
{error && backend ? <Notice tone="err">{error}</Notice> : null}
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">提醒 / CRON / 队列事件</h3>
@@ -117,22 +195,15 @@ export function AutomationsModule(): React.ReactNode {
</label>
</div>
{rows.map((r) => (
<div key={r.id} className="flex items-center gap-3 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2.5 text-sm">
<span className="font-medium">{r.name}</span>
<Tag tone="neutral">{r.kind}</Tag>
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{r.cron || "—"}</span>
<span className="ml-auto flex items-center gap-2">
<Switch checked={r.enabled} disabled onChange={() => {}} label="启用" />
<Button size="sm" variant="ghost" disabled={busy} onClick={() => run(r.id, r.name)}>
触发
</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => del(r.id, r.name)}>
删除
</Button>
</span>
</div>
))}
<AutomationList
rows={rows}
loading={loading}
error={error}
unimplemented={unimplemented}
busy={busy}
onRun={run}
onDelete={del}
/>
</Surface>
<Surface className="space-y-3">
+76 -19
View File
@@ -1,20 +1,14 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminGetLlm, adminTestLlm, isNotImplemented } from "../../lib/api";
import type { AdminLlmProvider, AdminLlmConfig } from "../../lib/types";
import type { AdminLlmProvider } from "../../lib/types";
import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
import { Button, EmptyState, Surface, Tag } from "../../components/primitives";
import { Icon } from "../../components/Icon";
import { LlmProviderForm } from "./llm/LlmProviderForm";
const sample: AdminLlmConfig = {
providers: [
{ id: "p1", name: "主模型", provider: "openai", baseUrl: "https://api.example.com/v1", model: "example-ultra", multimodal: true, enabled: true, maxTokens: 8192 },
{ id: "p2", name: "备选模型", provider: "openai", baseUrl: "https://api.backup.example.com/v1", model: "example-fast", multimodal: false, enabled: true },
],
};
/** One provider group row: identity + current token budget + test/edit actions. */
/** 一个供应商分组行:身份 + 当前 token 预算 + 测试/编辑动作。 */
function ProviderRow({
p,
busy,
@@ -48,11 +42,71 @@ function ProviderRow({
);
}
interface ProviderListProps {
providers: AdminLlmProvider[];
loading: boolean;
error: string | null;
/** 后端明确表示未提供该端点(404/501)—— 与「请求失败」「真为空」是三种状态。 */
unimplemented: boolean;
busy: boolean;
onTest: (p: AdminLlmProvider) => void;
onEdit: (p: AdminLlmProvider) => void;
}
/** 供应商列表:把「加载中 / 端点未实现 / 请求失败 / 真为空 / 有数据」分开渲染。
* 每种状态都只反映接口的真实结果,绝不回退到示例供应商。 */
function ProviderList({
providers,
loading,
error,
unimplemented,
busy,
onTest,
onEdit,
}: ProviderListProps): React.ReactNode {
if (loading) {
return <p className="py-8 text-center text-xs text-[var(--mu-text-lo)]">加载中…</p>;
}
if (providers.length === 0) {
if (unimplemented) {
return (
<Notice tone="warn">
后端未提供 GET /api/v1/admin/llm(404/501):配置读不到。本页只渲染接口返回的真实数据,不显示任何示例供应商。
</Notice>
);
}
if (error) {
return (
<Notice tone="err">
加载 LLM 配置失败(后端不可达或接口报错):{error} —— 这是错误态,不是空配置。
</Notice>
);
}
return (
<EmptyState
icon={<Icon name="robot" size={32} />}
title="还没有配置 LLM 供应商"
hint="用下方表单新增第一个供应商 / 模型分组。"
/>
);
}
return (
<div className="space-y-3">
{error ? <Notice tone="err">刷新失败,下方仍是上次成功读取的配置:{error}</Notice> : null}
{providers.map((r) => (
<ProviderRow key={r.id} p={r} busy={busy} onTest={onTest} onEdit={onEdit} />
))}
</div>
);
}
export function LlmModule(): React.ReactNode {
const { data, error, reload } = useAsyncData(adminGetLlm, []);
const { data, error, loading, reload } = useAsyncData(adminGetLlm, []);
const log = useAuditStore((s) => s.log);
const backend = !isNotImplemented(error);
const providers = backend && data ? data.providers : sample.providers;
// 端点未实现 / 请求失败必须分开:前者是 404/501,后者是断连或 5xx。
const unimplemented = isNotImplemented(error);
// 只渲染接口真的返回过的供应商;没有就是空列表,绝不拿示例配置顶替。
const providers = data?.providers ?? [];
const [editing, setEditing] = useState<AdminLlmProvider | null>(null);
const [busy, setBusy] = useState(false);
@@ -83,17 +137,20 @@ export function LlmModule(): React.ReactNode {
subtitle="供应商 + 模型配置(base/key/多模态检测/token 预算) + 测试(真实调用) + fallback + 按角色路由 + 用量"
/>
{backend && !error ? <Notice tone="ok">后端已返回 LLM 配置(数据以接口为准)。</Notice> : null}
{error && !backend ? <Notice tone="warn">后端未提供 /api/v1/admin/llm,以下为示例壳(M10 提供真实数据)。</Notice> : null}
{error && backend ? <Notice tone="err">{error}</Notice> : null}
{fail ? <Notice tone="err">{fail}</Notice> : null}
{result ? <Notice tone="ok">{result}</Notice> : null}
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">供应商 / 模型</h3>
{providers.map((r) => (
<ProviderRow key={r.id} p={r} busy={busy} onTest={test} onEdit={setEditing} />
))}
<ProviderList
providers={providers}
loading={loading}
error={error}
unimplemented={unimplemented}
busy={busy}
onTest={test}
onEdit={setEditing}
/>
</Surface>
<LlmProviderForm