feat(admin): 计费页加「余额硬拦截」开关 + 各租户「充值」按钮
闭合积分环的配置/操作端,接到 P4 后端: - 计费规则区:加「余额硬拦截」开关(credit_enforce),与汇率合成一个「保存规则」。 - 用量观测·租户排行:每行加「充值」按钮(正=充值、负=校正),调 /admin/credits/grant, 充值后即时刷新余额。 - api:getBillingConfig/saveBillingConfig 带 credit_enforce;新增 grantCredits。 live 验证(preview):开关渲染+可存;点公司A「充值 10」→余额 5.92→15.92、账本落对租户; tsc + 41 vitest 全过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -18,9 +18,10 @@ interface Row {
|
||||
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 [enforce, setEnforce] = useState(false); // 硬拦截开关
|
||||
const [cfgDirty, setCfgDirty] = useState(false);
|
||||
const [cfgSaving, setCfgSaving] = useState(false);
|
||||
const [cfgMsg, setCfgMsg] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
@@ -45,7 +46,8 @@ export function BillingRules({ onSaved }: { onSaved?: () => void }) {
|
||||
};
|
||||
setRows([...chat.map(mk), ...emb.map(mk)]);
|
||||
setRate(String(cfg.tokens_per_credit));
|
||||
setRateDirty(false);
|
||||
setEnforce(cfg.credit_enforce);
|
||||
setCfgDirty(false);
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
@@ -75,23 +77,23 @@ export function BillingRules({ onSaved }: { onSaved?: () => void }) {
|
||||
}
|
||||
};
|
||||
|
||||
const saveRate = async () => {
|
||||
const saveConfig = async () => {
|
||||
const n = Number(rate);
|
||||
if (!(n > 0)) {
|
||||
setRateMsg("汇率必须 > 0");
|
||||
setCfgMsg("汇率必须 > 0");
|
||||
return;
|
||||
}
|
||||
setRateSaving(true);
|
||||
setRateMsg("");
|
||||
setCfgSaving(true);
|
||||
setCfgMsg("");
|
||||
try {
|
||||
await saveBillingConfig(n);
|
||||
setRateDirty(false);
|
||||
setRateMsg("✓ 已保存");
|
||||
await saveBillingConfig(n, enforce);
|
||||
setCfgDirty(false);
|
||||
setCfgMsg("✓ 已保存");
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
setRateMsg((e as Error).message);
|
||||
setCfgMsg((e as Error).message);
|
||||
} finally {
|
||||
setRateSaving(false);
|
||||
setCfgSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,8 +110,8 @@ export function BillingRules({ onSaved }: { onSaved?: () => void }) {
|
||||
<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">
|
||||
{/* 全局:token→积分 汇率 + 硬拦截开关 */}
|
||||
<div className="mb-5 flex flex-wrap items-end gap-5 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">
|
||||
@@ -120,22 +122,38 @@ export function BillingRules({ onSaved }: { onSaved?: () => void }) {
|
||||
value={rate}
|
||||
onChange={(e) => {
|
||||
setRate(e.target.value);
|
||||
setRateDirty(true);
|
||||
setRateMsg("");
|
||||
setCfgDirty(true);
|
||||
setCfgMsg("");
|
||||
}}
|
||||
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>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500">余额硬拦截</label>
|
||||
<label className="mt-1 flex cursor-pointer items-center gap-2 text-sm text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enforce}
|
||||
onChange={(e) => {
|
||||
setEnforce(e.target.checked);
|
||||
setCfgDirty(true);
|
||||
setCfgMsg("");
|
||||
}}
|
||||
className="h-4 w-4 accent-violet-600"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">开启后:租户积分余额 ≤0 拒绝新任务(默认关=软扣)</span>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void saveRate()}
|
||||
disabled={!rateDirty || rateSaving}
|
||||
onClick={() => void saveConfig()}
|
||||
disabled={!cfgDirty || cfgSaving}
|
||||
className="rounded bg-violet-600 px-3 py-1 text-xs text-white hover:bg-violet-700 disabled:opacity-40"
|
||||
>
|
||||
{rateSaving ? "保存中…" : "保存汇率"}
|
||||
{cfgSaving ? "保存中…" : "保存规则"}
|
||||
</button>
|
||||
{rateMsg && <span className={`text-[11px] ${rateMsg.startsWith("✓") ? "text-emerald-600" : "text-rose-600"}`}>{rateMsg}</span>}
|
||||
{cfgMsg && <span className={`text-[11px] ${cfgMsg.startsWith("✓") ? "text-emerald-600" : "text-rose-600"}`}>{cfgMsg}</span>}
|
||||
</div>
|
||||
|
||||
{/* 每模型单价 + 积分权重 */}
|
||||
|
||||
Reference in New Issue
Block a user