feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen #1
@@ -125,6 +125,7 @@ export interface Pricing {
|
|||||||
model_id: string;
|
model_id: string;
|
||||||
input_per_1k: number;
|
input_per_1k: number;
|
||||||
output_per_1k: number;
|
output_per_1k: number;
|
||||||
|
credit_weight: number; // 每模型积分权重(0/缺省=1.0,不加权)
|
||||||
currency: string;
|
currency: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +143,24 @@ export async function savePricing(p: Pricing): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// —— 全局计费规则(token→积分汇率)——
|
||||||
|
export async function getBillingConfig(): Promise<{ tokens_per_credit: number }> {
|
||||||
|
const res = guard(await fetch(`${ADMIN}/billing-config`, { headers: authHeaders() }));
|
||||||
|
if (!res.ok) throw new Error(`billing config failed: ${res.status}`);
|
||||||
|
const d = (await res.json()) as { tokens_per_credit?: string };
|
||||||
|
return { tokens_per_credit: Number(d.tokens_per_credit) || 1000 }; // 空/未设 → 回退默认 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBillingConfig(tokensPerCredit: number): Promise<void> {
|
||||||
|
const res = guard(
|
||||||
|
await fetch(`${ADMIN}/billing-config`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify({ tokens_per_credit: tokensPerCredit }) }),
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
throw new Error(d.error ?? `save billing config failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。
|
// gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。
|
||||||
export async function gatewayOnline(): Promise<boolean> {
|
export async function gatewayOnline(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { adminUsage, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
|
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 MICRO = 1_000_000;
|
||||||
const credits = (micro: number) => (micro / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
const credits = (micro: number) => (micro / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||||
@@ -75,6 +77,15 @@ export function UsagePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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 flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ const DashboardPage = lazy(() => import("./pages/DashboardPage").then((m) => ({
|
|||||||
const UsagePage = lazy(() => import("./pages/UsagePage").then((m) => ({ default: m.UsagePage })));
|
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 StatusPage = lazy(() => import("./pages/StatusPage").then((m) => ({ default: m.StatusPage })));
|
const StatusPage = lazy(() => import("./pages/StatusPage").then((m) => ({ default: m.StatusPage })));
|
||||||
const EvalsPage = lazy(() => import("./pages/EvalsPage").then((m) => ({ default: m.EvalsPage })));
|
const EvalsPage = lazy(() => import("./pages/EvalsPage").then((m) => ({ default: m.EvalsPage })));
|
||||||
const TenantsPage = lazy(() => import("./pages/TenantsPage").then((m) => ({ default: m.TenantsPage })));
|
const TenantsPage = lazy(() => import("./pages/TenantsPage").then((m) => ({ default: m.TenantsPage })));
|
||||||
@@ -35,7 +34,7 @@ export const routes: RouteDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/usage",
|
path: "/usage",
|
||||||
label: "用量 & 计费",
|
label: "计费 & 用量",
|
||||||
group: "分析",
|
group: "分析",
|
||||||
ready: true,
|
ready: true,
|
||||||
element: <UsagePage />,
|
element: <UsagePage />,
|
||||||
@@ -54,13 +53,6 @@ export const routes: RouteDef[] = [
|
|||||||
ready: true,
|
ready: true,
|
||||||
element: <DatasourcesPage />,
|
element: <DatasourcesPage />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/pricing",
|
|
||||||
label: "计价 & 预算",
|
|
||||||
group: "配置",
|
|
||||||
ready: true,
|
|
||||||
element: <PricingPage />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/prompts",
|
path: "/prompts",
|
||||||
label: "提示词",
|
label: "提示词",
|
||||||
|
|||||||
Reference in New Issue
Block a user