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:
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { listModels, listPricing, savePricing, getBillingConfig, saveBillingConfig, type Model, type Pricing } from "../api";
|
||||
|
||||
// 计费规则(配置端):全局 token→积分汇率 + 每模型单价/积分权重。
|
||||
// 这是「扣费按规则扣」的规则源——改完立即对后续任务生效;下方用量观测即其结果。
|
||||
|
||||
interface Row {
|
||||
model: Model;
|
||||
inPer1k: string;
|
||||
outPer1k: string;
|
||||
weight: string; // 积分权重(空/0 → 后端按 1.0)
|
||||
currency: string;
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
msg: string;
|
||||
}
|
||||
|
||||
export function BillingRules({ onSaved }: { onSaved?: () => void }) {
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [rate, setRate] = useState(""); // tokens_per_credit
|
||||
const [rateDirty, setRateDirty] = useState(false);
|
||||
const [rateSaving, setRateSaving] = useState(false);
|
||||
const [rateMsg, setRateMsg] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setErr("");
|
||||
try {
|
||||
const [chat, emb, pricing, cfg] = await Promise.all([listModels("chat"), listModels("embedding"), listPricing(), getBillingConfig()]);
|
||||
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) : "",
|
||||
weight: p && p.credit_weight ? String(p.credit_weight) : "",
|
||||
currency: p?.currency || "CNY",
|
||||
dirty: false,
|
||||
saving: false,
|
||||
msg: "",
|
||||
};
|
||||
};
|
||||
setRows([...chat.map(mk), ...emb.map(mk)]);
|
||||
setRate(String(cfg.tokens_per_credit));
|
||||
setRateDirty(false);
|
||||
} 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 saveRow = 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,
|
||||
credit_weight: Number(r.weight) || 0,
|
||||
currency: r.currency || "CNY",
|
||||
});
|
||||
setRows((rs) => rs.map((x) => (x.model.id === r.model.id ? { ...x, saving: false, dirty: false, msg: "✓ 已保存" } : x)));
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
setRows((rs) => rs.map((x) => (x.model.id === r.model.id ? { ...x, saving: false, msg: (e as Error).message } : x)));
|
||||
}
|
||||
};
|
||||
|
||||
const saveRate = async () => {
|
||||
const n = Number(rate);
|
||||
if (!(n > 0)) {
|
||||
setRateMsg("汇率必须 > 0");
|
||||
return;
|
||||
}
|
||||
setRateSaving(true);
|
||||
setRateMsg("");
|
||||
try {
|
||||
await saveBillingConfig(n);
|
||||
setRateDirty(false);
|
||||
setRateMsg("✓ 已保存");
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
setRateMsg((e as Error).message);
|
||||
} finally {
|
||||
setRateSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-sm text-gray-400">加载计费规则中…</div>;
|
||||
if (err) return <div className="text-sm text-rose-600">计费规则加载失败:{err}</div>;
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700">计费规则</h3>
|
||||
<p className="text-[11px] text-gray-400">扣费按此规则实时折算 · 改完对后续任务立即生效</p>
|
||||
</div>
|
||||
<span className="rounded bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700">配置端</span>
|
||||
</div>
|
||||
|
||||
{/* 全局 token→积分 汇率 */}
|
||||
<div className="mb-5 flex flex-wrap items-end gap-3 rounded-lg border border-violet-100 bg-violet-50/40 p-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500">token → 积分 汇率</label>
|
||||
<div className="mt-1 flex items-center gap-2 text-sm text-gray-600">
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
min="1"
|
||||
value={rate}
|
||||
onChange={(e) => {
|
||||
setRate(e.target.value);
|
||||
setRateDirty(true);
|
||||
setRateMsg("");
|
||||
}}
|
||||
className="w-28 rounded border px-2 py-1 text-right font-mono text-xs focus:border-violet-500 focus:outline-none"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">token = 1 积分(设 1 即按 token 直计)</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void saveRate()}
|
||||
disabled={!rateDirty || rateSaving}
|
||||
className="rounded bg-violet-600 px-3 py-1 text-xs text-white hover:bg-violet-700 disabled:opacity-40"
|
||||
>
|
||||
{rateSaving ? "保存中…" : "保存汇率"}
|
||||
</button>
|
||||
{rateMsg && <span className={`text-[11px] ${rateMsg.startsWith("✓") ? "text-emerald-600" : "text-rose-600"}`}>{rateMsg}</span>}
|
||||
</div>
|
||||
|
||||
{/* 每模型单价 + 积分权重 */}
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-sm text-gray-400">还没有登记模型,先到「模型」页添加。</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||||
<th className="py-2 pr-3 font-medium">模型</th>
|
||||
<th className="pr-3 font-medium">类型</th>
|
||||
<th className="pr-3 font-medium">输入 / 1K</th>
|
||||
<th className="pr-3 font-medium">输出 / 1K</th>
|
||||
<th className="pr-3 font-medium">币种</th>
|
||||
<th className="pr-3 font-medium">积分权重</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.model.id} className="border-t">
|
||||
<td className="py-2 pr-3">
|
||||
<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="pr-3 text-xs text-gray-500">{r.model.kind}</td>
|
||||
<td className="pr-3">
|
||||
<input className="w-20 rounded border px-2 py-1 text-xs focus:border-violet-500 focus:outline-none" type="number" step="0.0001" min="0" value={r.inPer1k} onChange={(e) => patch(r.model.id, { inPer1k: e.target.value })} placeholder="0" />
|
||||
</td>
|
||||
<td className="pr-3">
|
||||
<input className="w-20 rounded border px-2 py-1 text-xs focus:border-violet-500 focus:outline-none" type="number" step="0.0001" min="0" value={r.outPer1k} onChange={(e) => patch(r.model.id, { outPer1k: e.target.value })} placeholder="0" />
|
||||
</td>
|
||||
<td className="pr-3">
|
||||
<select className="rounded border bg-white px-2 py-1 text-xs text-gray-700 focus:border-violet-500 focus:outline-none" 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="pr-3">
|
||||
<input className="w-16 rounded border px-2 py-1 text-xs focus:border-violet-500 focus:outline-none" type="number" step="0.1" min="0" value={r.weight} onChange={(e) => patch(r.model.id, { weight: e.target.value })} placeholder="1.0" title="每 token 烧积分的倍率(空/0=1.0)" />
|
||||
</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={() => void saveRow(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>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user