Files
sundynix-agentix/sundynix-admin/src/components/UsageSection.tsx
T
Blizzard 79b110afd0 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>
2026-07-20 11:17:54 +08:00

279 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}