feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen #1
@@ -365,3 +365,43 @@ export async function deactivatePrompt(key: string): Promise<void> {
|
|||||||
throw new Error(d.error ?? `deactivate failed: ${res.status}`);
|
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<UsageReport> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<UsageReport | null>(null);
|
||||||
|
const [tenantOpts, setTenantOpts] = useState<UsageTenantSum[]>([]); // 下拉选项(来自全平台口径)
|
||||||
|
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 <div className="text-sm text-gray-400">加载用量数据中…</div>;
|
||||||
|
if (err) return <div className="text-sm text-rose-500">用量加载失败:{err}</div>;
|
||||||
|
if (!report) return null;
|
||||||
|
|
||||||
|
const t = report.totals;
|
||||||
|
const selectedName = tenant ? tenantOpts.find((x) => x.tenant_id === tenant)?.name ?? tenant : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 顶栏:租户筛选 + 区间 + 刷新 */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={tenant}
|
||||||
|
onChange={(e) => setTenant(e.target.value)}
|
||||||
|
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-700 shadow-sm focus:border-violet-400 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">全平台(所有租户)</option>
|
||||||
|
{tenantOpts.map((o) => (
|
||||||
|
<option key={o.tenant_id} value={o.tenant_id}>
|
||||||
|
{o.name || o.tenant_id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="flex overflow-hidden rounded-lg border border-gray-200 text-xs">
|
||||||
|
{[7, 30].map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
onClick={() => setDays(d)}
|
||||||
|
className={`px-3 py-1.5 ${days === d ? "bg-violet-600 text-white" : "bg-white text-gray-500 hover:bg-gray-50"}`}
|
||||||
|
>
|
||||||
|
近 {d} 天
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||||
|
<span>
|
||||||
|
{mmdd(report.from)} ~ {mmdd(report.to)} · {tenant ? "单租户口径" : "全平台口径"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => void load()}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="flex items-center gap-1 rounded border border-gray-200 px-2.5 py-1 text-gray-500 hover:bg-gray-50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M23 4v6h-6 M1 20v-6h6 M3.51 9a9 9 0 0 1 14.85-3.36L23 10 M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||||||
|
</svg>
|
||||||
|
{refreshing ? "刷新中" : "刷新"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI 卡片 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
|
<Metric label="积分消耗" tone="violet" value={credits(t.credits_micro)} sub={`区间合计 · ${int(t.task_count)} 次运行`} />
|
||||||
|
<Metric label="Token 用量" tone="cyan" value={int(t.total_tok)} sub="prompt + completion(估算)" />
|
||||||
|
<Metric label="估算成本" tone="amber" value={money(t.cost_micros)} sub="按定价折算(配置币种)" />
|
||||||
|
{tenant ? (
|
||||||
|
<Metric label="当前积分余额" tone={(report.balance_micro ?? 0) >= 0 ? "emerald" : "rose"} value={credits(report.balance_micro ?? 0)} sub={selectedName || "该租户"} />
|
||||||
|
) : (
|
||||||
|
<Metric label="活跃租户" tone="emerald" value={int(tenantOpts.length)} sub="区间内有用量的租户数" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 积分消耗趋势(按天) */}
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700">积分消耗趋势</h4>
|
||||||
|
<span className="text-[11px] text-gray-400">每日 credits · 悬停看明细</span>
|
||||||
|
</div>
|
||||||
|
<TrendBars series={series} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 全平台:各租户用量排行 */}
|
||||||
|
{!tenant && (
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700">各租户用量排行</h4>
|
||||||
|
<span className="rounded bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700">按积分消耗</span>
|
||||||
|
</div>
|
||||||
|
{tenantOpts.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-xs text-gray-400">区间内暂无用量</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||||||
|
<th className="py-2 pr-3 font-medium">租户</th>
|
||||||
|
<th className="py-2 pr-3 text-right font-medium">积分消耗</th>
|
||||||
|
<th className="py-2 pr-3 text-right font-medium">Token</th>
|
||||||
|
<th className="py-2 pr-3 text-right font-medium">成本</th>
|
||||||
|
<th className="py-2 pr-3 text-right font-medium">运行数</th>
|
||||||
|
<th className="py-2 text-right font-medium">当前余额</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tenantOpts.map((r) => (
|
||||||
|
<tr key={r.tenant_id} className="border-b border-gray-50 last:border-0">
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<button onClick={() => setTenant(r.tenant_id)} className="font-medium text-violet-600 hover:underline">
|
||||||
|
{r.name || r.tenant_id}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-right tabular-nums text-gray-800">{credits(r.credits_micro)}</td>
|
||||||
|
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{int(r.total_tok)}</td>
|
||||||
|
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{money(r.cost_micros)}</td>
|
||||||
|
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{int(r.task_count)}</td>
|
||||||
|
<td className={`py-2 text-right tabular-nums ${r.balance_micro >= 0 ? "text-gray-800" : "text-rose-500"}`}>{credits(r.balance_micro)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONE: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
|
||||||
|
<div className="text-xs text-gray-400">{label}</div>
|
||||||
|
<div className={`mt-1 text-2xl font-semibold tabular-nums ${TONE[tone] ?? "text-gray-800"}`}>{value}</div>
|
||||||
|
{sub && <div className="mt-1 text-[11px] text-gray-400">{sub}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div className="py-8 text-center text-xs text-gray-400">区间内暂无用量</div>;
|
||||||
|
}
|
||||||
|
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 h-full flex-1 flex-col items-center justify-end">
|
||||||
|
<div
|
||||||
|
className="w-full rounded-t bg-violet-400 transition-colors group-hover:bg-violet-600"
|
||||||
|
style={{ height: `${Math.max(d.credits_micro > 0 ? 4 : 0, h)}%` }}
|
||||||
|
/>
|
||||||
|
{/* tooltip */}
|
||||||
|
<div className="pointer-events-none absolute bottom-full mb-1 hidden whitespace-nowrap rounded bg-gray-800 px-2 py-1 text-[10px] text-white group-hover:block">
|
||||||
|
{mmdd(d.day)} · {credits(d.credits_micro)} 积分 · {int(d.task_count)} 次
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { Soon } from "./components/Soon";
|
|||||||
|
|
||||||
|
|
||||||
const DashboardPage = lazy(() => import("./pages/DashboardPage").then((m) => ({ default: m.DashboardPage })));
|
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 ModelsPage = lazy(() => import("./pages/ModelsPage").then((m) => ({ default: m.ModelsPage })));
|
||||||
const DatasourcesPage = lazy(() => import("./pages/DatasourcesPage").then((m) => ({ default: m.DatasourcesPage })));
|
const DatasourcesPage = lazy(() => import("./pages/DatasourcesPage").then((m) => ({ default: m.DatasourcesPage })));
|
||||||
const PricingPage = lazy(() => import("./pages/PricingPage").then((m) => ({ default: m.PricingPage })));
|
const PricingPage = lazy(() => import("./pages/PricingPage").then((m) => ({ default: m.PricingPage })));
|
||||||
@@ -32,6 +33,13 @@ export const routes: RouteDef[] = [
|
|||||||
ready: true,
|
ready: true,
|
||||||
element: <DashboardPage />,
|
element: <DashboardPage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/usage",
|
||||||
|
label: "用量 & 计费",
|
||||||
|
group: "分析",
|
||||||
|
ready: true,
|
||||||
|
element: <UsagePage />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/models",
|
path: "/models",
|
||||||
label: "模型",
|
label: "模型",
|
||||||
|
|||||||
Reference in New Issue
Block a user