feat(desktop): 「用量」视图 —— 我的积分余额/消耗趋势/最近消耗(P3 续)

给终端用户一个自己的计量观测面(守边界:只读自己租户):
- 新增 UsageView(左导航 MANAGE 组「用量」,Coins 图标):余额 hero 卡
  (硬拦截+余额≤0→红/偏低→橙)、区间 KPI(积分/Token/成本/运行数)、
  积分消耗按天趋势条形图(近7/近30天可切)、最近消耗明细表(任务/模型/token/积分/成本)。
  读 /api/v1/me/usage。
- 顶栏积分余额芯片改为可点,进「用量」页(onOpenUsage → setView)。

live 验证(preview):视图显示 公司A 余额 15.92 + KPI + 趋势bar(落在实际消耗日) +
最近消耗 3 行真实数据;tsc + 48 测试全过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-08 09:31:12 +08:00
parent f6ea03b34b
commit a878e2dc89
5 changed files with 241 additions and 9 deletions
@@ -0,0 +1,184 @@
import { useEffect, useMemo, useState } from "react";
import { Coins, RefreshCw, Wallet, Cpu, Receipt, Activity } from "lucide-react";
import { myUsage, type MyUsage, type UsageDay } from "../lib/api";
import { Card, Panel, EmptyState, cn } from "../ui";
// 桌面端「用量」= 用户自己租户的计量观测:积分余额 + 消耗趋势 + 最近消耗。
// 数据来自 /api/v1/me/usage(面向用户口径,只看自己租户)。积分/金额为微单位(÷1e6)。
const MICRO = 1_000_000;
const credits = (m: number) => (m / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
const money = (m: number) => (m / MICRO).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const int = (n: number) => n.toLocaleString("zh-CN");
const mmdd = (s: string) => `${s.slice(4, 6)}-${s.slice(6, 8)}`;
function ymd(d: Date): string {
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
}
function fillDays(trend: UsageDay[], days: number): UsageDay[] {
const by = 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(by.get(key) ?? { day: key, total_tok: 0, credits_micro: 0, cost_micros: 0, task_count: 0 });
}
return out;
}
export function UsageView() {
const [days, setDays] = useState(30);
const [data, setData] = useState<MyUsage | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [err, setErr] = useState("");
const load = async () => {
setRefreshing(true);
try {
setData(await myUsage(days));
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]);
const series = useMemo(() => (data ? fillDays(data.trend, days) : []), [data, days]);
if (loading) return <div className="p-6 text-sm text-slate-500"></div>;
if (err) return <div className="p-6 text-sm text-danger">{err}</div>;
if (!data) return null;
const bal = data.balance_micro / MICRO;
const empty = data.credit_enforce && bal <= 0;
const low = !empty && bal > 0 && bal < 10;
const t = data.totals;
return (
<div className="flex h-full flex-col gap-4 overflow-y-auto p-5">
{/* 顶栏 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-100"> & </h2>
<p className="text-xs text-slate-500">
{mmdd(data.from)} ~ {mmdd(data.to)} ·
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex overflow-hidden rounded-md border border-line text-xs">
{[7, 30].map((d) => (
<button
key={d}
onClick={() => setDays(d)}
className={cn("px-3 py-1.5", days === d ? "bg-brand text-white" : "bg-ink-850 text-slate-400 hover:text-slate-200")}
>
{d}
</button>
))}
</div>
<button
onClick={() => void load()}
disabled={refreshing}
className="flex items-center gap-1 rounded-md border border-line bg-ink-850 px-2.5 py-1.5 text-xs text-slate-400 hover:text-slate-200 disabled:opacity-40"
>
<RefreshCw className={cn("h-3.5 w-3.5", refreshing && "animate-spin")} />
</button>
</div>
</div>
{/* 余额 hero + KPI */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
<Card className={cn("p-4", empty ? "border-danger/40" : low ? "border-warn/40" : "")}>
<div className="flex items-center gap-1.5 text-xs text-slate-500">
<Wallet className="h-3.5 w-3.5" />
</div>
<div className={cn("mt-1 text-3xl font-semibold tabular-nums", empty ? "text-danger" : low ? "text-warn" : "text-brand")}>
{empty ? "余额不足" : credits(data.balance_micro)}
</div>
<div className="mt-1 text-[11px] text-slate-500">
{data.credit_enforce ? "余额≤0 将无法提交任务" : "当前不拦截,仅计量"}
</div>
</Card>
<Metric icon={Coins} label="积分消耗" value={credits(t.credits_micro)} sub={`区间合计 · ${int(t.task_count)} 次运行`} accent="text-accent" />
<Metric icon={Cpu} label="Token 用量" value={int(t.total_tok)} sub="prompt + completion(估算)" accent="text-slate-100" />
<Metric icon={Receipt} label="估算成本" value={money(t.cost_micros)} sub="按定价折算(配置币种)" accent="text-slate-100" />
</div>
{/* 消耗趋势 */}
<Panel title="积分消耗趋势" icon={Activity} className="min-h-[200px]">
<TrendBars series={series} />
</Panel>
{/* 最近消耗 */}
<Panel title="最近消耗" icon={Coins} className="min-h-[160px]">
{data.recent.length === 0 ? (
<EmptyState icon={Coins} title="暂无消耗记录" />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line text-left text-[11px] uppercase tracking-wide text-slate-500">
<th className="py-2 pr-3 font-medium"></th>
<th className="pr-3 font-medium"></th>
<th className="pr-3 text-right font-medium">Token</th>
<th className="pr-3 text-right font-medium"></th>
<th className="text-right font-medium"></th>
</tr>
</thead>
<tbody>
{data.recent.map((r) => (
<tr key={r.task_id} className="border-b border-line/50 last:border-0">
<td className="py-2 pr-3 font-mono text-xs text-slate-300">{r.task_id}</td>
<td className="pr-3 text-xs text-slate-400">{r.model || "—"}</td>
<td className="pr-3 text-right tabular-nums text-slate-400">{int(r.total_tok)}</td>
<td className="pr-3 text-right tabular-nums text-accent">{credits(r.credits_micro)}</td>
<td className="text-right tabular-nums text-slate-400">{money(r.cost_micros)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Panel>
</div>
);
}
function Metric({ icon: Icon, label, value, sub, accent }: { icon: typeof Coins; label: string; value: string; sub: string; accent: string }) {
return (
<Card className="p-4">
<div className="flex items-center gap-1.5 text-xs text-slate-500">
<Icon className="h-3.5 w-3.5" /> {label}
</div>
<div className={cn("mt-1 text-2xl font-semibold tabular-nums", accent)}>{value}</div>
<div className="mt-1 text-[11px] text-slate-500">{sub}</div>
</Card>
);
}
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 <EmptyState icon={Activity} title="区间内暂无消耗" />;
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 flex-1 flex-col items-center justify-end" title={`${mmdd(d.day)} · ${credits(d.credits_micro)} 积分 · ${int(d.task_count)}`}>
<div className="w-full rounded-t bg-brand/70 transition-colors group-hover:bg-brand" style={{ height: `${Math.max(d.credits_micro > 0 ? 4 : 0, h)}%` }} />
</div>
);
})}
</div>
);
}