feat(admin): 数据源页转 RAG 运维台 + 支付/模型菜单重组 + 概览升级为仪表盘

后端:
- 新增 POST /admin/kb/search:管理端跨租户检索,支持 mode 指定单路
  (vector/fulltext/graph/hybrid),不走 scopedKB(否则会被强制锁到调用者
  自己的 space,跨租户排障就没法做了)
- KB 清单补 space_id(检索键是 <space_id>/<name>,缺它前端拼不出 key)

前端:
- 数据源&RAG 页补「检索试验台」:同一 query 并排跑生产链路 + 四路诊断,
  召回不准时能直接定位是向量/分词/图谱哪一环挂了
- 支付拆成「配置 / 订单与对账」两个子页,挂到运维 > 支付 下;
  导航支持二级菜单(NavParent 命中子路由自动展开)
- SettingsPage → ModelConfigPage「模型配置」,模型参数与计费规则合一
- 概览 → 仪表盘:并入计费与用量(UsagePage → UsageSection),
  去掉系统健康拓扑(与服务状态页重复,同一份 /admin/status 数据)
- 全局隐藏滚动条(保留滚动)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-20 11:17:54 +08:00
parent ac4fd00e28
commit 79b110afd0
16 changed files with 577 additions and 237 deletions
@@ -0,0 +1,178 @@
import { useState } from "react";
import { adminKbSearch, type DatasourceKB, type KbHit, type SearchMode } from "../api";
// 检索试验台:对同一个 query 同时跑「生产链路」与「三路 + RRF 融合」,并排看各自召回。
// 用途:线上召回不准时定位是哪一环 —— 向量路空=embedding/切块问题;全文路空=分词/索引问题;
// 图谱路空=Neo4j 未起或未抽三元组;融合有而生产为空=rerank 把结果滤掉了。
//
// ⚠️ 分数不可跨路比较:向量是余弦相似度(0~1),RRF 是 1/(k+rank) 的融合分(通常 <0.05)。
const ROUTES: Array<{ mode: SearchMode; label: string; hint: string }> = [
{ mode: "vector", label: "向量", hint: "Milvus · 语义相似" },
{ mode: "fulltext", label: "全文", hint: "Bleve · 关键词倒排" },
{ mode: "graph", label: "图谱", hint: "Neo4j · 实体关系" },
{ mode: "hybrid", label: "RRF 融合", hint: "三路融合 · 不含 rerank" },
];
type Results = Partial<Record<string, { hits: KbHit[]; err?: string }>>;
export function RetrievalBench({ kbs }: { kbs: DatasourceKB[] }) {
const withDocs = kbs.filter((k) => k.doc_count > 0);
const [kbKey, setKbKey] = useState("");
const [q, setQ] = useState("");
const [topK, setTopK] = useState(5);
const [res, setRes] = useState<Results>({});
const [running, setRunning] = useState(false);
const [ran, setRan] = useState(false);
const run = async () => {
if (!kbKey || !q.trim()) return;
setRunning(true);
setRan(true);
// 生产链路 + 四路诊断并行跑,各自独立成败(一路挂不影响其它路展示)。
const modes: SearchMode[] = ["", ...ROUTES.map((r) => r.mode)];
const settled = await Promise.all(
modes.map(async (m) => {
try {
return [m, { hits: await adminKbSearch(kbKey, q.trim(), topK, m) }] as const;
} catch (e) {
return [m, { hits: [], err: (e as Error).message }] as const;
}
}),
);
setRes(Object.fromEntries(settled));
setRunning(false);
};
return (
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-1 flex items-center gap-2">
<h4 className="text-sm font-semibold text-gray-700"></h4>
<span className="text-[11px] text-gray-400"> query </span>
</div>
{/* 查询条件 */}
<div className="mt-4 flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1">
<span className="text-[11px] text-gray-400"></span>
<select
value={kbKey}
onChange={(e) => setKbKey(e.target.value)}
className="w-64 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-700 focus:border-violet-400 focus:outline-none"
>
<option value=""></option>
{withDocs.map((k) => (
<option key={k.id} value={`${k.space_id}/${k.name}`}>
{k.name} · {k.tenant_name || k.tenant_id} ({k.doc_count} )
</option>
))}
</select>
</label>
<label className="flex flex-1 flex-col gap-1" style={{ minWidth: 220 }}>
<span className="text-[11px] text-gray-400"></span>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && void run()}
placeholder="输入一个真实用户会问的问题…"
className="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-700 focus:border-violet-400 focus:outline-none"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] text-gray-400">topK</span>
<input
type="number"
min={1}
max={20}
value={topK}
onChange={(e) => setTopK(Math.max(1, Math.min(20, Number(e.target.value) || 5)))}
className="w-20 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-700 focus:border-violet-400 focus:outline-none"
/>
</label>
<button
onClick={() => void run()}
disabled={running || !kbKey || !q.trim()}
className="rounded-lg bg-violet-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-violet-700 disabled:opacity-40"
>
{running ? "检索中…" : "跑一遍"}
</button>
</div>
{withDocs.length === 0 && (
<p className="mt-3 text-xs text-amber-600"></p>
)}
{ran && (
<div className="mt-5 space-y-4">
{/* 生产链路:用户实际拿到的结果 */}
<RouteCard
label="生产链路"
hint="混合检索 + rerank —— Agent 实际拿到的就是这个"
data={res[""]}
highlight
/>
{/* 诊断四路 */}
<div>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-gray-300">
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{ROUTES.map((r) => (
<RouteCard key={r.mode} label={r.label} hint={r.hint} data={res[r.mode]} />
))}
</div>
</div>
</div>
)}
</div>
);
}
function RouteCard({
label,
hint,
data,
highlight,
}: {
label: string;
hint: string;
data?: { hits: KbHit[]; err?: string };
highlight?: boolean;
}) {
const hits = data?.hits ?? [];
return (
<div className={`rounded-lg border p-3 ${highlight ? "border-violet-200 bg-violet-50/40" : "border-gray-100 bg-gray-50/40"}`}>
<div className="flex items-baseline justify-between gap-2">
<span className="text-xs font-semibold text-gray-700">{label}</span>
<span className={`text-[11px] tabular-nums ${hits.length ? "text-emerald-600" : "text-gray-400"}`}>
{hits.length}
</span>
</div>
<div className="mt-0.5 text-[10px] text-gray-400">{hint}</div>
{data?.err ? (
<p className="mt-2 text-[11px] text-rose-500">{data.err}</p>
) : hits.length === 0 ? (
<p className="mt-2 text-[11px] text-gray-400"></p>
) : (
<ol className="mt-2 space-y-1.5">
{hits.map((h, i) => (
<li key={i} className="text-[11px] leading-relaxed">
<div className="flex items-baseline gap-1.5">
<span className="shrink-0 text-gray-300">#{i + 1}</span>
<span className="shrink-0 tabular-nums text-violet-600">{h.score.toFixed(4)}</span>
</div>
<p className="mt-0.5 line-clamp-3 text-gray-600" title={h.text}>
{h.text}
</p>
</li>
))}
</ol>
)}
</div>
);
}
@@ -0,0 +1,278 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { adminUsage, listTenants, type TenantRow, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
import { GrantCreditsModal } from "./GrantCreditsModal";
// 仪表盘的「计费与用量」区块:全平台(或单租户)的积分消耗/Token/成本 + 趋势 + 租户排行。
// 自包含取数与筛选,直接塞进仪表盘即可,不需要父级传参。
// 金额/积分均为微单位(×10⁻⁶),展示时 ÷1e6。
const MICRO = 1_000_000;
const credits = (micro: number) => (micro / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
const money = (micro: number) => (micro / MICRO).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const int = (n: number) => n.toLocaleString("zh-CN");
// YYYYMMDD(本地)。
function ymd(d: Date): string {
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
}
function rangeFor(days: number): { from: string; to: string } {
const now = new Date();
const from = new Date(now);
from.setDate(now.getDate() - (days - 1));
return { from: ymd(from), to: ymd(now) };
}
// 把稀疏的 trend 补齐成连续日序列(缺的天补 0),便于成条形图。
function fillDays(trend: UsageDay[], days: number): UsageDay[] {
const byDay = new Map(trend.map((d) => [d.day, d]));
const out: UsageDay[] = [];
const now = new Date();
for (let i = days - 1; i >= 0; i--) {
const d = new Date(now);
d.setDate(now.getDate() - i);
const key = ymd(d);
out.push(byDay.get(key) ?? { day: key, total_tok: 0, credits_micro: 0, cost_micros: 0, task_count: 0 });
}
return out;
}
const mmdd = (ymdStr: string) => `${ymdStr.slice(4, 6)}-${ymdStr.slice(6, 8)}`;
export function UsageSection() {
const [days, setDays] = useState(30);
const [tenant, setTenant] = useState(""); // "" = 全平台
const [report, setReport] = useState<UsageReport | null>(null);
const [tenantOpts, setTenantOpts] = useState<UsageTenantSum[]>([]); // 下拉选项(来自全平台口径)
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [err, setErr] = useState("");
// 发放积分 Modal
const [grantTenant, setGrantTenant] = useState<TenantRow | null>(null);
const [allTenants, setAllTenants] = useState<TenantRow[]>([]);
const load = async () => {
setRefreshing(true);
try {
const { from, to } = rangeFor(days);
const r = await adminUsage({ tenant: tenant || undefined, from, to });
setReport(r);
if (!tenant && r.tenants) setTenantOpts(r.tenants); // 全平台口径顺带刷新下拉
setErr("");
} catch (e) {
setErr((e as Error).message);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
// 预载租户列表用于发放积分 Modal
listTenants().then(setAllTenants).catch(() => {});
}, []);
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [days, tenant]);
const series = useMemo(() => (report ? fillDays(report.trend, days) : []), [report, days]);
const openGrant = (r: UsageTenantSum) => {
const found = allTenants.find((t) => t.id === r.tenant_id);
if (found) {
setGrantTenant(found);
} else {
// 如果全载列表还没到,直接用 UsageTenantSum 构造一个临时对象
setGrantTenant({
id: r.tenant_id, name: r.name, slug: "", plan: "free",
status: "active", credit_balance_micro: r.balance_micro,
shared_billing: false, members: 0,
});
}
};
if (loading) return <div className="text-sm text-gray-400"></div>;
if (err) return <div className="text-sm text-rose-500">{err}</div>;
if (!report) return null;
const t = report.totals;
const selectedName = tenant ? tenantOpts.find((x) => x.tenant_id === tenant)?.name ?? tenant : "";
return (
<div className="space-y-6">
{/* 发放积分 Modal */}
<GrantCreditsModal tenant={grantTenant} onClose={() => setGrantTenant(null)} onDone={() => void load()} />
{/* 观测端:用量结果 */}
<div className="flex items-center gap-2 pt-1">
<h3 className="text-sm font-semibold text-gray-700"></h3>
<span className="text-[11px] text-gray-400"></span>
</div>
{/* 顶栏:租户筛选 + 区间 + 刷新 */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<select
value={tenant}
onChange={(e) => setTenant(e.target.value)}
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-700 shadow-sm focus:border-violet-400 focus:outline-none"
>
<option value=""></option>
{tenantOpts.map((o) => (
<option key={o.tenant_id} value={o.tenant_id}>
{o.name || o.tenant_id}
</option>
))}
</select>
<div className="flex overflow-hidden rounded-lg border border-gray-200 text-xs">
{[7, 30].map((d) => (
<button
key={d}
onClick={() => setDays(d)}
className={`px-3 py-1.5 ${days === d ? "bg-violet-600 text-white" : "bg-white text-gray-500 hover:bg-gray-50"}`}
>
{d}
</button>
))}
</div>
</div>
<div className="flex items-center gap-3 text-xs text-gray-400">
<span>
{mmdd(report.from)} ~ {mmdd(report.to)} · {tenant ? "单租户口径" : "全平台口径"}
</span>
<button
onClick={() => void load()}
disabled={refreshing}
className="flex items-center gap-1 rounded border border-gray-200 px-2.5 py-1 text-gray-500 hover:bg-gray-50 disabled:opacity-40"
>
<svg viewBox="0 0 24 24" className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 4v6h-6 M1 20v-6h6 M3.51 9a9 9 0 0 1 14.85-3.36L23 10 M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
{refreshing ? "刷新中" : "刷新"}
</button>
</div>
</div>
{/* KPI 卡片 */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Metric label="积分消耗" tone="violet" value={credits(t.credits_micro)} sub={`区间合计 · ${int(t.task_count)} 次运行`} />
<Metric label="Token 用量" tone="cyan" value={int(t.total_tok)} sub="prompt + completion(估算)" />
<Metric label="估算成本" tone="amber" value={money(t.cost_micros)} sub="按定价折算(配置币种)" />
{tenant ? (
<Metric label="当前积分余额" tone={(report.balance_micro ?? 0) >= 0 ? "emerald" : "rose"} value={credits(report.balance_micro ?? 0)} sub={selectedName || "该租户"} />
) : (
<Metric label="活跃租户" tone="emerald" value={int(tenantOpts.length)} sub="区间内有用量的租户数" />
)}
</div>
{/* 积分消耗趋势(按天) */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-700"></h4>
<span className="text-[11px] text-gray-400"> credits · </span>
</div>
<TrendBars series={series} />
</div>
{/* 全平台:各租户用量排行 */}
{!tenant && (
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-700"></h4>
<span className="rounded bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700"></span>
</div>
{tenantOpts.length === 0 ? (
<div className="py-6 text-center text-xs text-gray-400"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium">Token</th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{tenantOpts.map((r) => (
<tr key={r.tenant_id} className="border-b border-gray-50 last:border-0">
<td className="py-2 pr-3">
<button onClick={() => setTenant(r.tenant_id)} className="font-medium text-violet-600 hover:underline">
{r.name || r.tenant_id}
</button>
</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-800">{credits(r.credits_micro)}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{int(r.total_tok)}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{money(r.cost_micros)}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{int(r.task_count)}</td>
<td className={`py-2 pr-3 text-right tabular-nums ${r.balance_micro >= 0 ? "text-gray-800" : "text-rose-500"}`}>{credits(r.balance_micro)}</td>
<td className="py-2 text-right">
<button
onClick={() => openGrant(r)}
className="rounded border border-violet-200 px-2.5 py-1 text-xs text-violet-600 hover:bg-violet-50"
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}
const TONE: Record<string, string> = {
violet: "text-violet-600",
cyan: "text-cyan-600",
amber: "text-amber-600",
emerald: "text-emerald-600",
rose: "text-rose-500",
};
function Metric({ label, value, sub, tone }: { label: string; value: ReactNode; sub?: ReactNode; tone: string }) {
return (
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
<div className="text-xs text-gray-400">{label}</div>
<div className={`mt-1 text-2xl font-semibold tabular-nums ${TONE[tone] ?? "text-gray-800"}`}>{value}</div>
{sub && <div className="mt-1 text-[11px] text-gray-400">{sub}</div>}
</div>
);
}
function TrendBars({ series }: { series: UsageDay[] }) {
const max = Math.max(1, ...series.map((d) => d.credits_micro));
if (series.every((d) => d.credits_micro === 0)) {
return <div className="py-8 text-center text-xs text-gray-400"></div>;
}
return (
<div className="flex h-40 items-end gap-1">
{series.map((d) => {
const h = (d.credits_micro / max) * 100;
return (
<div key={d.day} className="group relative flex h-full flex-1 flex-col items-center justify-end">
<div
className="w-full rounded-t bg-violet-400 transition-colors group-hover:bg-violet-600"
style={{ height: `${Math.max(d.credits_micro > 0 ? 4 : 0, h)}%` }}
/>
{/* tooltip */}
<div className="pointer-events-none absolute bottom-full mb-1 hidden whitespace-nowrap rounded bg-gray-800 px-2 py-1 text-[10px] text-white group-hover:block">
{mmdd(d.day)} · {credits(d.credits_micro)} · {int(d.task_count)}
</div>
</div>
);
})}
</div>
);
}