From 54de95aa5391c64cd833fbe7a27acc48ab1ff6fd Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 7 Jul 2026 12:38:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=94=A8=E9=87=8F=20&=20?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9=E9=A1=B5=20=E2=80=94=E2=80=94=20SaaS=20P2=20?= =?UTF-8?q?=E8=AE=A1=E9=87=8F=E8=A7=82=E6=B5=8B=E9=9D=A2=EF=BC=88=E8=AF=BB?= =?UTF-8?q?=20/admin/usage=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理端「分析」组新增页:全平台 / 单租户的 token · 积分 · 成本口径。 - api.adminUsage():拉 /admin/usage(含 trend / totals / tenants 排行 / 单租户余额); 微单位(×10⁻⁶)前端 ÷1e6 展示。 - UsagePage:租户下拉 + 近7/30天切换;KPI 卡(积分/Token/成本/余额或活跃租户); 积分消耗按天条形趋势(补零连续);全平台口径下各租户排行表(点名下钻单租户)。 - routes 注册 /usage(HashRouter)。 live 验证(preview):全平台两租户排行 + KPI + 趋势条渲染正确;点租户下钻→ 单租户口径 + 当前余额卡;数值与后端自洽;无 console 错误;tsc + 41 vitest 全过。 Co-Authored-By: Claude Opus 4.8 --- sundynix-admin/src/api.ts | 40 +++++ sundynix-admin/src/pages/UsagePage.tsx | 232 +++++++++++++++++++++++++ sundynix-admin/src/routes.tsx | 8 + 3 files changed, 280 insertions(+) create mode 100644 sundynix-admin/src/pages/UsagePage.tsx diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index 88d8b7c..6ebb1eb 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -365,3 +365,43 @@ export async function deactivatePrompt(key: string): Promise { throw new Error(d.error ?? `deactivate failed: ${res.status}`); } } + +// —— 用量 / 计费(SaaS P2 计量:token / 积分 / 成本,按租户)—— +// 金额与积分均为「微单位」(×10⁻⁶):展示时 ÷1e6。走 /admin/usage(系统级,跨租户)。 +export interface UsageDay { + day: string; // YYYYMMDD + total_tok: number; + credits_micro: number; + cost_micros: number; + task_count: number; +} +export interface UsageTenantSum { + tenant_id: string; + name: string; + total_tok: number; + credits_micro: number; + cost_micros: number; + task_count: number; + balance_micro: number; +} +export interface UsageReport { + from: string; + to: string; + tenant: string; // 空 = 全平台口径 + trend: UsageDay[]; + totals: { total_tok: number; credits_micro: number; cost_micros: number; task_count: number }; + balance_micro?: number; // 仅单租户口径 + tenants?: UsageTenantSum[]; // 仅全平台口径:各租户排行 +} + +export async function adminUsage(params?: { tenant?: string; from?: string; to?: string }): Promise { + const q = new URLSearchParams(); + if (params?.tenant) q.set("tenant", params.tenant); + if (params?.from) q.set("from", params.from); + if (params?.to) q.set("to", params.to); + const qs = q.toString(); + const res = guard(await fetch(`${ADMIN}/usage${qs ? "?" + qs : ""}`, { headers: authHeaders() })); + if (!res.ok) throw new Error(`usage failed: ${res.status}`); + const d = (await res.json()) as UsageReport; + return { ...d, trend: d.trend ?? [], tenants: d.tenants ?? [] }; // Go 空切片可能序列化为 null +} diff --git a/sundynix-admin/src/pages/UsagePage.tsx b/sundynix-admin/src/pages/UsagePage.tsx new file mode 100644 index 0000000..1cc588a --- /dev/null +++ b/sundynix-admin/src/pages/UsagePage.tsx @@ -0,0 +1,232 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { adminUsage, type UsageReport, type UsageTenantSum, type UsageDay } from "../api"; + +// 管理端「用量 & 计费」= SaaS P2 计量的观测面:全平台 / 单租户的 token · 积分 · 成本口径。 +// 数据来自 /api/v1/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(null); + const [tenantOpts, setTenantOpts] = useState([]); // 下拉选项(来自全平台口径) + 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]); + + if (loading) return
加载用量数据中…
; + if (err) return
用量加载失败:{err}
; + if (!report) return null; + + const t = report.totals; + const selectedName = tenant ? tenantOpts.find((x) => x.tenant_id === tenant)?.name ?? tenant : ""; + + return ( +
+ {/* 顶栏:租户筛选 + 区间 + 刷新 */} +
+
+ +
+ {[7, 30].map((d) => ( + + ))} +
+
+
+ + {mmdd(report.from)} ~ {mmdd(report.to)} · {tenant ? "单租户口径" : "全平台口径"} + + +
+
+ + {/* KPI 卡片 */} +
+ + + + {tenant ? ( + = 0 ? "emerald" : "rose"} value={credits(report.balance_micro ?? 0)} sub={selectedName || "该租户"} /> + ) : ( + + )} +
+ + {/* 积分消耗趋势(按天) */} +
+
+

积分消耗趋势

+ 每日 credits · 悬停看明细 +
+ +
+ + {/* 全平台:各租户用量排行 */} + {!tenant && ( +
+
+

各租户用量排行

+ 按积分消耗 +
+ {tenantOpts.length === 0 ? ( +
区间内暂无用量
+ ) : ( +
+ + + + + + + + + + + + + {tenantOpts.map((r) => ( + + + + + + + + + ))} + +
租户积分消耗Token成本运行数当前余额
+ + {credits(r.credits_micro)}{int(r.total_tok)}{money(r.cost_micros)}{int(r.task_count)}= 0 ? "text-gray-800" : "text-rose-500"}`}>{credits(r.balance_micro)}
+
+ )} +
+ )} +
+ ); +} + +const TONE: Record = { + 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 ( +
+
{label}
+
{value}
+ {sub &&
{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)}%` }} + /> + {/* tooltip */} +
+ {mmdd(d.day)} · {credits(d.credits_micro)} 积分 · {int(d.task_count)} 次 +
+
+ ); + })} +
+ ); +} diff --git a/sundynix-admin/src/routes.tsx b/sundynix-admin/src/routes.tsx index 05cfd44..d26b71b 100644 --- a/sundynix-admin/src/routes.tsx +++ b/sundynix-admin/src/routes.tsx @@ -6,6 +6,7 @@ import { Soon } from "./components/Soon"; const DashboardPage = lazy(() => import("./pages/DashboardPage").then((m) => ({ default: m.DashboardPage }))); +const UsagePage = lazy(() => import("./pages/UsagePage").then((m) => ({ default: m.UsagePage }))); const ModelsPage = lazy(() => import("./pages/ModelsPage").then((m) => ({ default: m.ModelsPage }))); const DatasourcesPage = lazy(() => import("./pages/DatasourcesPage").then((m) => ({ default: m.DatasourcesPage }))); const PricingPage = lazy(() => import("./pages/PricingPage").then((m) => ({ default: m.PricingPage }))); @@ -32,6 +33,13 @@ export const routes: RouteDef[] = [ ready: true, element: , }, + { + path: "/usage", + label: "用量 & 计费", + group: "分析", + ready: true, + element: , + }, { path: "/models", label: "模型",