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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user