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:
@@ -9,6 +9,7 @@ import { MemoryView } from "./views/MemoryView";
|
||||
import { KbView } from "./views/KbView";
|
||||
import { ReportView } from "./views/ReportView";
|
||||
import { RunsView } from "./views/RunsView";
|
||||
import { UsageView } from "./views/UsageView";
|
||||
import { Home } from "./views/Home";
|
||||
import { Placeholder } from "./views/Placeholder";
|
||||
import { CommandPalette, type Command } from "./components/CommandPalette";
|
||||
@@ -238,7 +239,7 @@ export default function App() {
|
||||
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }}
|
||||
/>
|
||||
<UpdateBanner />
|
||||
<TopBar user={user} tenant={tenant} onLogout={onLogout} onCommand={() => setCmdOpen(true)} />
|
||||
<TopBar user={user} tenant={tenant} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
|
||||
<ApprovalBar run={run} />
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<LeftNav active={view} onSelect={setView} />
|
||||
@@ -255,6 +256,8 @@ export default function App() {
|
||||
<RunsView run={run} />
|
||||
) : view === "memory" ? (
|
||||
<MemoryView identity={identity} />
|
||||
) : view === "usage" ? (
|
||||
<UsageView />
|
||||
) : (
|
||||
<Placeholder {...(PLACEHOLDERS[view] ?? { title: "模块", desc: "规划中。" })} />
|
||||
)}
|
||||
|
||||
@@ -139,6 +139,48 @@ export async function tenantCurrent(): Promise<TenantCtx | null> {
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 我的用量明细(当前用户自己租户:余额 + 按天趋势 + 最近消耗)----
|
||||
export interface UsageDay {
|
||||
day: string; // YYYYMMDD
|
||||
total_tok: number;
|
||||
credits_micro: number;
|
||||
cost_micros: number;
|
||||
task_count: number;
|
||||
}
|
||||
export interface UsageRecent {
|
||||
task_id: string;
|
||||
model: string;
|
||||
total_tok: number;
|
||||
credits_micro: number;
|
||||
cost_micros: number;
|
||||
currency: string;
|
||||
ts: number;
|
||||
}
|
||||
export interface MyUsage {
|
||||
from: string;
|
||||
to: string;
|
||||
balance_micro: number;
|
||||
credit_enforce: boolean;
|
||||
trend: UsageDay[];
|
||||
totals: { total_tok: number; credits_micro: number; cost_micros: number; task_count: number };
|
||||
recent: UsageRecent[];
|
||||
}
|
||||
|
||||
export async function myUsage(days = 30): Promise<MyUsage> {
|
||||
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/usage?days=${days}`, { headers: bearer() }));
|
||||
if (!res.ok) throw new Error(`usage failed: ${res.status}`);
|
||||
const d = (await res.json()) as Partial<MyUsage>;
|
||||
return {
|
||||
from: d.from ?? "",
|
||||
to: d.to ?? "",
|
||||
balance_micro: d.balance_micro ?? 0,
|
||||
credit_enforce: !!d.credit_enforce,
|
||||
trend: d.trend ?? [],
|
||||
totals: d.totals ?? { total_tok: 0, credits_micro: 0, cost_micros: 0, task_count: 0 },
|
||||
recent: d.recent ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
// taskStatus: GET /api/v1/tasks/:id —— 任务生命周期状态(submitted/running/waiting/done/failed/timeout/rejected)。
|
||||
// 轮询它来可靠捕获 waiting(审批中断)—— 不依赖易抢跑的实时 exec 事件。
|
||||
export async function taskStatus(taskId: string): Promise<{ status: string; detail: string }> {
|
||||
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
Bookmark,
|
||||
Boxes,
|
||||
Settings,
|
||||
Coins,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../ui";
|
||||
|
||||
export type ViewKey = "home" | "studio" | "kb" | "report" | "runs" | "memory" | "market" | "admin";
|
||||
export type ViewKey = "home" | "studio" | "kb" | "report" | "runs" | "memory" | "usage" | "market" | "admin";
|
||||
|
||||
interface Item {
|
||||
key: ViewKey;
|
||||
@@ -28,6 +29,7 @@ const ITEMS: Item[] = [
|
||||
{ key: "report", label: "报告", icon: FileText, group: "BUILD", ready: true },
|
||||
{ key: "runs", label: "运行", icon: Activity, group: "RUN", ready: true },
|
||||
{ key: "memory", label: "记忆", icon: Bookmark, group: "MANAGE", ready: true },
|
||||
{ key: "usage", label: "用量", icon: Coins, group: "MANAGE", ready: true },
|
||||
{ key: "market", label: "市场", icon: Boxes, group: "MANAGE" },
|
||||
{ key: "admin", label: "管理", icon: Settings, group: "MANAGE" },
|
||||
];
|
||||
|
||||
@@ -20,16 +20,17 @@ function Light({ on, label }: { on: boolean; label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 积分余额芯片:让用户在主产品里随时看到自己租户剩多少积分。
|
||||
// 积分余额芯片:让用户在主产品里随时看到自己租户剩多少积分。点击进「用量」页。
|
||||
// 硬拦截开启且余额≤0 → 红色「余额不足」;偏低 → 橙;否则常态。
|
||||
function CreditChip({ tenant }: { tenant: TenantCtx }) {
|
||||
function CreditChip({ tenant, onClick }: { tenant: TenantCtx; onClick?: () => void }) {
|
||||
const credits = tenant.credit_balance_micro / 1_000_000;
|
||||
const empty = tenant.credit_enforce && credits <= 0;
|
||||
const low = !empty && credits > 0 && credits < 10;
|
||||
const tone = empty ? "border-danger/50 text-danger" : low ? "border-warn/50 text-warn" : "border-line text-slate-300";
|
||||
return (
|
||||
<span
|
||||
className={cn("flex items-center gap-1.5 rounded-md border bg-ink-800 px-2.5 py-1 text-xs", tone)}
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn("flex items-center gap-1.5 rounded-md border bg-ink-800 px-2.5 py-1 text-xs transition hover:border-ink-600", tone)}
|
||||
title={
|
||||
`租户 ${tenant.tenant?.name ?? ""} · 积分余额 ${credits.toLocaleString("zh-CN", { maximumFractionDigits: 2 })}` +
|
||||
(tenant.credit_enforce ? "(余额≤0 将无法提交任务,请联系管理员充值)" : "(当前不拦截,仅计量)")
|
||||
@@ -37,12 +38,12 @@ function CreditChip({ tenant }: { tenant: TenantCtx }) {
|
||||
>
|
||||
<Coins className="h-3.5 w-3.5 opacity-70" />
|
||||
{empty ? "余额不足" : credits.toLocaleString("zh-CN", { maximumFractionDigits: credits < 100 ? 1 : 0 })}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// 顶栏:品牌 · 垂直切换 · 健康灯 · 积分余额 · 登录用户 + 登出(深色 + 毛玻璃)。
|
||||
export function TopBar({ user, tenant, onLogout, onCommand }: { user: AuthUser; tenant?: TenantCtx | null; onLogout: () => void; onCommand?: () => void }) {
|
||||
export function TopBar({ user, tenant, onLogout, onCommand, onOpenUsage }: { user: AuthUser; tenant?: TenantCtx | null; onLogout: () => void; onCommand?: () => void; onOpenUsage?: () => void }) {
|
||||
const h = useHealth();
|
||||
const { theme, toggle } = useTheme();
|
||||
return (
|
||||
@@ -85,7 +86,7 @@ export function TopBar({ user, tenant, onLogout, onCommand }: { user: AuthUser;
|
||||
<Light on={h.neo4j} label="Neo4j" />
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2" style={NODRAG}>
|
||||
{tenant?.tenant && <CreditChip tenant={tenant} />}
|
||||
{tenant?.tenant && <CreditChip tenant={tenant} onClick={onOpenUsage} />}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="flex items-center rounded-md border border-line bg-ink-800 px-2 py-1 text-slate-400 transition hover:border-ink-600 hover:text-slate-200"
|
||||
|
||||
@@ -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