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(null); const [tenantOpts, setTenantOpts] = useState([]); // 下拉选项(来自全平台口径) const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [err, setErr] = useState(""); // 发放积分 Modal const [grantTenant, setGrantTenant] = useState(null); const [allTenants, setAllTenants] = 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(() => { // 预载租户列表用于发放积分 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
加载用量数据中…
; 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 (
{/* 发放积分 Modal */} setGrantTenant(null)} onDone={() => 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)} 次
); })}
); }