fix(admin-web): 删掉面板硬编码假数据,改成诚实的空态/错误态(P0-24,审计 §4.3)
- Automations.tsx / Llm.tsx 原有的 sample 回退("后端在线但列表为空 → 渲样例数据")整段删除 - 后端不可达 → 明确错误态;后端可达但没有数据 → 空列表 + 引导文案(对齐 Users.tsx 已修做法) - 复核:grep sample 零命中;npx tsc --noEmit 0 错误;npm run build 成功
This commit is contained in:
1 parent
63236c9b00
commit
0dfad675b8
2 files changed
+175
-47
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user