Files
sundynix-agentix/sundynix-admin/src/pages/UsagePage.tsx
T
Blizzard b46382817c feat(admin): 计费页加「余额硬拦截」开关 + 各租户「充值」按钮
闭合积分环的配置/操作端,接到 P4 后端:
- 计费规则区:加「余额硬拦截」开关(credit_enforce),与汇率合成一个「保存规则」。
- 用量观测·租户排行:每行加「充值」按钮(正=充值、负=校正),调 /admin/credits/grant,
  充值后即时刷新余额。
- api:getBillingConfig/saveBillingConfig 带 credit_enforce;新增 grantCredits。

live 验证(preview):开关渲染+可存;点公司A「充值 10」→余额 5.92→15.92、账本落对租户;
tsc + 41 vitest 全过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 13:53:52 +08:00

271 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, 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<UsageReport | null>(null);
const [tenantOpts, setTenantOpts] = useState<UsageTenantSum[]>([]); // 下拉选项(来自全平台口径)
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 <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">
{/* 配置端:计费规则(改规则即对后续任务生效) */}
<BillingRules onSaved={() => 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={() => void doGrant(r.tenant_id, r.name || r.tenant_id)}
disabled={granting === r.tenant_id}
className="rounded border border-violet-200 px-2.5 py-1 text-xs text-violet-600 hover:bg-violet-50 disabled:opacity-40"
>
{granting === r.tenant_id ? "…" : "充值"}
</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>
);
}