import { useEffect, useMemo, useState, type ReactNode } from "react"; import { adminUsage, grantCredits, type UsageReport, type UsageTenantSum, type UsageDay } from "../api"; import { BillingRules } from "../components/BillingRules"; // 管理端「用量 & 计费」= 计费闭环一页:顶部配「规则」(单价/积分权重/汇率),下方看「结果」(用量观测)。 // 规则→扣费→观测:改规则即对后续任务生效,用量观测(读 /admin/usage,系统级跨租户)即其结果。 // 金额/积分均为微单位(×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 UsagePage() { const [days, setDays] = useState(30); const [tenant, setTenant] = useState(""); // "" = 全平台 const [report, setReport] = useState(null); const [tenantOpts, setTenantOpts] = useState([]); // 下拉选项(来自全平台口径) const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [err, setErr] = useState(""); 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(() => { void load(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [days, tenant]); const series = useMemo(() => (report ? fillDays(report.trend, days) : []), [report, days]); const [granting, setGranting] = useState(""); const doGrant = async (tenantId: string, name: string) => { const s = window.prompt(`给「${name}」充值积分(正=充值,负=扣减/校正)`, "100"); if (s === null) return; const n = Number(s); if (!n) return; setGranting(tenantId); try { await grantCredits(tenantId, n, "admin 手动充值"); await load(); } catch (e) { window.alert((e as Error).message); } finally { setGranting(""); } }; if (loading) return
加载用量数据中…
; if (err) return
用量加载失败:{err}
; if (!report) return null; const t = report.totals; const selectedName = tenant ? tenantOpts.find((x) => x.tenant_id === tenant)?.name ?? tenant : ""; return (
{/* 配置端:计费规则(改规则即对后续任务生效) */} void load()} /> {/* 观测端:用量结果 */}

用量观测

按规则折算后的实际消耗
{/* 顶栏:租户筛选 + 区间 + 刷新 */}
{[7, 30].map((d) => ( ))}
{mmdd(report.from)} ~ {mmdd(report.to)} · {tenant ? "单租户口径" : "全平台口径"}
{/* KPI 卡片 */}
{tenant ? ( = 0 ? "emerald" : "rose"} value={credits(report.balance_micro ?? 0)} sub={selectedName || "该租户"} /> ) : ( )}
{/* 积分消耗趋势(按天) */}

积分消耗趋势

每日 credits · 悬停看明细
{/* 全平台:各租户用量排行 */} {!tenant && (

各租户用量排行

按积分消耗
{tenantOpts.length === 0 ? (
区间内暂无用量
) : (
{tenantOpts.map((r) => ( ))}
租户 积分消耗 Token 成本 运行数 当前余额 操作
{credits(r.credits_micro)} {int(r.total_tok)} {money(r.cost_micros)} {int(r.task_count)} = 0 ? "text-gray-800" : "text-rose-500"}`}>{credits(r.balance_micro)}
)}
)}
); } const TONE: Record = { 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 (
{label}
{value}
{sub &&
{sub}
}
); } 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
区间内暂无用量
; } return (
{series.map((d) => { const h = (d.credits_micro / max) * 100; return (
0 ? 4 : 0, h)}%` }} /> {/* tooltip */}
{mmdd(d.day)} · {credits(d.credits_micro)} 积分 · {int(d.task_count)} 次
); })}
); }