feat(admin): 计费 & 用量合成一页 —— 顶部配规则、下方看结果(闭环)

按"配置和观测放一个页面"收口:删掉重复的「计价 & 预算」页(其预算区块是 mock),
把真·计价配置并入「计费 & 用量」,形成 规则→扣费→观测 一页闭环。
- 新组件 BillingRules:全局 token→积分汇率 + 每模型 单价/币种/积分权重(读写
  /admin/pricing + /admin/billing-config),改完对后续任务实时生效。
- UsagePage:顶部「计费规则」(配置端) + 下方「用量观测」(按规则折算后的实际消耗)。
- 删 PricingPage + /pricing 路由;api 加 credit_weight / getBillingConfig / saveBillingConfig。

live 验证(preview):改汇率保存→持久化→新任务按新汇率扣→用量观测反映;
tsc + 41 vitest 全过;无 PricingPage 残引。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-07 13:26:33 +08:00
parent e2fc2d366c
commit 11d321f218
5 changed files with 228 additions and 291 deletions
-280
View File
@@ -1,280 +0,0 @@
import { useEffect, useState } from "react";
import { listModels, listPricing, savePricing, type Model, type Pricing } from "../api";
// 每个模型一行的本地编辑态。
interface Row {
model: Model;
inPer1k: string;
outPer1k: string;
currency: string;
dirty: boolean;
saving: boolean;
msg: string;
}
// Mock 用户每日预算控制
const INITIAL_BUDGETS = [
{ id: "usr_101", name: "Alice (管理组)", code: "acme-law", dailyLimit: 150000, currentUsed: 34200, action: "alert" },
{ id: "usr_102", name: "Bob", code: "beta-tech", dailyLimit: 50000, currentUsed: 49500, action: "block" },
{ id: "usr_103", name: "David", code: "beta-tech", dailyLimit: 100000, currentUsed: 12000, action: "alert" },
{ id: "usr_104", name: "Eva", code: "medi-trust", dailyLimit: 80000, currentUsed: 0, action: "block" }
];
export function PricingPage() {
const [rows, setRows] = useState<Row[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
// 预算控制相关状态
const [budgets, setBudgets] = useState(INITIAL_BUDGETS);
const [globalTaskLimit, setGlobalTaskLimit] = useState(200000); // 单次任务 Token 硬上限
const load = async () => {
setLoading(true);
setErr("");
try {
const [chat, emb, pricing] = await Promise.all([listModels("chat"), listModels("embedding"), listPricing()]);
const byID = new Map<string, Pricing>(pricing.map((p) => [p.model_id, p]));
const mk = (m: Model): Row => {
const p = byID.get(m.id);
return {
model: m,
inPer1k: p ? String(p.input_per_1k) : "",
outPer1k: p ? String(p.output_per_1k) : "",
currency: p?.currency || "CNY",
dirty: false,
saving: false,
msg: "",
};
};
setRows([...chat.map(mk), ...emb.map(mk)]);
} catch (e) {
setErr((e as Error).message);
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, []);
const patch = (id: string, p: Partial<Row>) => setRows((rs) => rs.map((r) => (r.model.id === id ? { ...r, ...p, dirty: true, msg: "" } : r)));
const save = async (r: Row) => {
setRows((rs) => rs.map((x) => (x.model.id === r.model.id ? { ...x, saving: true, msg: "" } : x)));
try {
await savePricing({
model_id: r.model.id,
input_per_1k: Number(r.inPer1k) || 0,
output_per_1k: Number(r.outPer1k) || 0,
currency: r.currency || "CNY",
});
setRows((rs) => rs.map((x) => (x.model.id === r.model.id ? { ...x, saving: false, dirty: false, msg: "✓ 已保存" } : x)));
} catch (e) {
setRows((rs) => rs.map((x) => (x.model.id === r.model.id ? { ...x, saving: false, msg: (e as Error).message } : x)));
}
};
// 修改用户预算上限
const handleBudgetLimitChange = (id: string, val: number) => {
setBudgets((prev) => prev.map((u) => u.id === id ? { ...u, dailyLimit: val } : u));
};
// 切换超限处置方式
const handleActionChange = (id: string, act: string) => {
setBudgets((prev) => prev.map((u) => u.id === id ? { ...u, action: act } : u));
};
if (loading) return <div className="text-sm text-gray-400"></div>;
if (err) return <div className="text-sm text-rose-600">{err}</div>;
// Top 消费者条形图数据计算
const barChartWidth = 280;
const maxUsed = Math.max(...budgets.map((b) => b.currentUsed));
return (
<div className="space-y-8">
{/* 1. 计价配置 */}
<section className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<h3 className="text-sm font-semibold text-gray-700 mb-2"> (Token )</h3>
<p className="mb-4 text-xs text-gray-400">/ 1K token 使</p>
{rows.length === 0 ? (
<div className="text-sm text-gray-400"></div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-gray-400">
<th className="py-2"></th>
<th></th>
<th> / 1K</th>
<th> / 1K</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.model.id} className="border-t">
<td className="py-2">
<div className="font-semibold text-gray-800">{r.model.model}</div>
<div className="text-[11px] text-gray-400">{r.model.provider}</div>
</td>
<td className="text-gray-500 text-xs">{r.model.kind}</td>
<td>
<input
className="w-24 rounded border px-2 py-1 focus:border-violet-500 focus:outline-none text-xs"
type="number"
step="0.0001"
min="0"
value={r.inPer1k}
onChange={(e) => patch(r.model.id, { inPer1k: e.target.value })}
placeholder="0"
/>
</td>
<td>
<input
className="w-24 rounded border px-2 py-1 focus:border-violet-500 focus:outline-none text-xs"
type="number"
step="0.0001"
min="0"
value={r.outPer1k}
onChange={(e) => patch(r.model.id, { outPer1k: e.target.value })}
placeholder="0"
/>
</td>
<td>
<select
className="rounded border px-2 py-1 focus:border-violet-500 focus:outline-none text-xs bg-white text-gray-700"
value={r.currency}
onChange={(e) => patch(r.model.id, { currency: e.target.value })}
>
<option value="CNY">CNY</option>
<option value="USD">USD</option>
</select>
</td>
<td className="text-right">
<button
className="rounded bg-violet-600 px-3 py-1 text-xs text-white hover:bg-violet-700 disabled:opacity-40"
disabled={!r.dirty || r.saving}
onClick={() => save(r)}
>
{r.saving ? "保存中…" : "保存"}
</button>
{r.msg && <span className={`ml-2 text-[11px] ${r.msg.startsWith("✓") ? "text-emerald-600" : "text-rose-600"}`}>{r.msg}</span>}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
{/* 2. 成本护栏与 Token 预算 */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* 左侧:预算分配表单 */}
<section className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm lg:col-span-2">
<div className="mb-4 flex items-center justify-between border-b pb-3">
<div>
<h3 className="text-sm font-semibold text-gray-700">Token </h3>
<p className="text-[11px] text-gray-400"> Token Harness </p>
</div>
{/* 全局任务上限配置 */}
<div className="text-right">
<label className="text-[10px] text-gray-400 block"> (TASK_LIMIT)</label>
<input
type="number"
className="mt-1 w-28 rounded border px-2 py-1 text-xs font-mono text-right focus:border-violet-500 focus:outline-none bg-gray-50"
value={globalTaskLimit}
onChange={(e) => setGlobalTaskLimit(Number(e.target.value))}
/>
</div>
</div>
<table className="w-full text-xs text-left">
<thead>
<tr className="text-gray-400 border-b pb-1">
<th className="py-2"></th>
<th></th>
<th></th>
<th> (Daily Limit)</th>
<th className="text-right"></th>
</tr>
</thead>
<tbody>
{budgets.map((b) => (
<tr key={b.id} className="border-t">
<td className="py-2 font-medium text-gray-800">{b.name}</td>
<td className="text-gray-500">{b.code}</td>
<td>
<div className="flex items-center gap-2">
<div className="h-1.5 w-16 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${b.currentUsed / b.dailyLimit >= 0.9 ? "bg-rose-500" : "bg-violet-600"}`}
style={{ width: `${Math.min(100, (b.currentUsed / b.dailyLimit) * 100)}%` }}
/>
</div>
<span className="text-[10px] font-semibold text-gray-400">{(b.currentUsed / b.dailyLimit * 100).toFixed(0)}%</span>
</div>
</td>
<td>
<input
type="number"
step="5000"
className="w-24 rounded border px-2 py-1 focus:border-violet-500 focus:outline-none font-mono"
value={b.dailyLimit}
onChange={(e) => handleBudgetLimitChange(b.id, Number(e.target.value))}
/>
</td>
<td className="text-right">
<select
className="rounded border px-2 py-1 text-[10px] bg-white text-gray-700 cursor-pointer focus:border-violet-500 focus:outline-none"
value={b.action}
onChange={(e) => handleActionChange(b.id, e.target.value)}
>
<option value="alert"> </option>
<option value="block">🚫 </option>
</select>
</td>
</tr>
))}
</tbody>
</table>
</section>
{/* 右侧:Top 消费大户排行 */}
<section className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm flex flex-col">
<div className="mb-4">
<h3 className="text-sm font-semibold text-gray-700"></h3>
<p className="text-[11px] text-gray-400"> Token </p>
</div>
<div className="flex-1 flex flex-col justify-center gap-4 py-2">
{budgets.map((b) => {
const pct = maxUsed > 0 ? (b.currentUsed / maxUsed) * 100 : 0;
return (
<div key={b.id} className="text-xs">
<div className="flex justify-between text-gray-500 mb-1">
<span className="font-medium">{b.name}</span>
<span className="font-mono text-gray-400">{b.currentUsed.toLocaleString()} tokens</span>
</div>
{/* SVG 进度柱 */}
<div className="relative h-6 rounded bg-gray-50/50 border border-gray-100 overflow-hidden">
<div
className="h-full bg-violet-600/10 border-r-2 border-violet-600 transition-all"
style={{ width: `${pct}%` }}
/>
<span className="absolute inset-y-0 left-2 flex items-center text-[9px] text-violet-700 font-bold">
{pct.toFixed(0)}%
</span>
</div>
</div>
);
})}
</div>
</section>
</div>
</div>
);
}
+13 -2
View File
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { adminUsage, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
import { BillingRules } from "../components/BillingRules";
// 管理端「用量 & 计费」= SaaS P2 计量的观测面:全平台 / 单租户的 token · 积分 · 成本口径
// 数据来自 /api/v1/admin/usage系统级跨租户)。金额/积分均为微单位(×10⁻⁶),展示时 ÷1e6
// 管理端「用量 & 计费」= 计费闭环一页:顶部配「规则」(单价/积分权重/汇率),下方看「结果」(用量观测)
// 规则→扣费→观测:改规则即对后续任务生效,用量观测(读 /admin/usage系统级跨租户)即其结果
// 金额/积分均为微单位(×10⁻⁶),展示时 ÷1e6。
const MICRO = 1_000_000;
const credits = (micro: number) => (micro / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
@@ -75,6 +77,15 @@ export function UsagePage() {
return (
<div className="space-y-6">
{/* 配置端:计费规则(改规则即对后续任务生效) */}
<BillingRules onSaved={() => void load()} />
{/* 观测端:用量结果 */}
<div className="flex items-center gap-2 pt-1">
<h3 className="text-sm font-semibold text-gray-700"></h3>
<span className="text-[11px] text-gray-400"></span>
</div>
{/* 顶栏:租户筛选 + 区间 + 刷新 */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">