diff --git a/sundynix-desktop/frontend/src/App.tsx b/sundynix-desktop/frontend/src/App.tsx index 15353d0..9869748 100644 --- a/sundynix-desktop/frontend/src/App.tsx +++ b/sundynix-desktop/frontend/src/App.tsx @@ -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%)" }} /> - setCmdOpen(true)} /> + setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
@@ -255,6 +256,8 @@ export default function App() { ) : view === "memory" ? ( + ) : view === "usage" ? ( + ) : ( )} diff --git a/sundynix-desktop/frontend/src/lib/api.ts b/sundynix-desktop/frontend/src/lib/api.ts index ffaf16f..2d8b47b 100644 --- a/sundynix-desktop/frontend/src/lib/api.ts +++ b/sundynix-desktop/frontend/src/lib/api.ts @@ -139,6 +139,48 @@ export async function tenantCurrent(): Promise { }; } +// ---- 我的用量明细(当前用户自己租户:余额 + 按天趋势 + 最近消耗)---- +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 { + 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; + 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 }> { diff --git a/sundynix-desktop/frontend/src/shell/LeftNav.tsx b/sundynix-desktop/frontend/src/shell/LeftNav.tsx index 98beac8..943aed1 100644 --- a/sundynix-desktop/frontend/src/shell/LeftNav.tsx +++ b/sundynix-desktop/frontend/src/shell/LeftNav.tsx @@ -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" }, ]; diff --git a/sundynix-desktop/frontend/src/shell/TopBar.tsx b/sundynix-desktop/frontend/src/shell/TopBar.tsx index 946efa2..3bfafb0 100644 --- a/sundynix-desktop/frontend/src/shell/TopBar.tsx +++ b/sundynix-desktop/frontend/src/shell/TopBar.tsx @@ -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 ( - {empty ? "余额不足" : credits.toLocaleString("zh-CN", { maximumFractionDigits: credits < 100 ? 1 : 0 })} - + ); } // 顶栏:品牌 · 垂直切换 · 健康灯 · 积分余额 · 登录用户 + 登出(深色 + 毛玻璃)。 -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;
- {tenant?.tenant && } + {tenant?.tenant && } + ))} +
+ + + + + {/* 余额 hero + KPI */} +
+ +
+ 积分余额 +
+
+ {empty ? "余额不足" : credits(data.balance_micro)} +
+
+ {data.credit_enforce ? "余额≤0 将无法提交任务" : "当前不拦截,仅计量"} +
+
+ + + +
+ + {/* 消耗趋势 */} + + + + + {/* 最近消耗 */} + + {data.recent.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {data.recent.map((r) => ( + + + + + + + + ))} + +
任务模型Token积分成本
{r.task_id}{r.model || "—"}{int(r.total_tok)}{credits(r.credits_micro)}{money(r.cost_micros)}
+
+ )} +
+ + ); +} + +function Metric({ icon: Icon, label, value, sub, accent }: { icon: typeof Coins; label: string; value: string; sub: string; accent: string }) { + return ( + +
+ {label} +
+
{value}
+
{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)}%` }} /> +
+ ); + })} +
+ ); +}