diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index 688fe96..7a046d7 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -143,17 +143,21 @@ export async function savePricing(p: Pricing): Promise { } } -// —— 全局计费规则(token→积分汇率)—— -export async function getBillingConfig(): Promise<{ tokens_per_credit: number }> { +// —— 全局计费规则(token→积分汇率 + 硬拦截开关)—— +export async function getBillingConfig(): Promise<{ tokens_per_credit: number; credit_enforce: boolean }> { 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 + const d = (await res.json()) as { tokens_per_credit?: string; credit_enforce?: boolean }; + return { tokens_per_credit: Number(d.tokens_per_credit) || 1000, credit_enforce: !!d.credit_enforce }; // 空/未设 → 回退默认 } -export async function saveBillingConfig(tokensPerCredit: number): Promise { +export async function saveBillingConfig(tokensPerCredit: number, creditEnforce: boolean): Promise { const res = guard( - await fetch(`${ADMIN}/billing-config`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify({ tokens_per_credit: tokensPerCredit }) }), + await fetch(`${ADMIN}/billing-config`, { + method: "PUT", + headers: authHeaders(true), + body: JSON.stringify({ tokens_per_credit: tokensPerCredit, credit_enforce: creditEnforce }), + }), ); if (!res.ok) { const d = (await res.json().catch(() => ({}))) as { error?: string }; @@ -161,6 +165,16 @@ export async function saveBillingConfig(tokensPerCredit: number): Promise } } +// —— 充值/发放积分(正=充值,负=校正)—— +export async function grantCredits(tenantId: string, credits: number, memo: string): Promise { + const res = guard( + await fetch(`${ADMIN}/credits/grant`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ tenant_id: tenantId, credits, memo }) }), + ); + const d = (await res.json().catch(() => ({}))) as { balance_micro?: number; error?: string }; + if (!res.ok) throw new Error(d.error ?? `grant failed: ${res.status}`); + return d.balance_micro ?? 0; +} + // gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。 export async function gatewayOnline(): Promise { try { diff --git a/sundynix-admin/src/components/BillingRules.tsx b/sundynix-admin/src/components/BillingRules.tsx index 106272e..6729d17 100644 --- a/sundynix-admin/src/components/BillingRules.tsx +++ b/sundynix-admin/src/components/BillingRules.tsx @@ -18,9 +18,10 @@ interface Row { export function BillingRules({ onSaved }: { onSaved?: () => void }) { const [rows, setRows] = useState([]); 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 }) { 配置端 - {/* 全局 token→积分 汇率 */} -
+ {/* 全局:token→积分 汇率 + 硬拦截开关 */} +
@@ -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" /> token = 1 积分(设 1 即按 token 直计)
+
+ + +
- {rateMsg && {rateMsg}} + {cfgMsg && {cfgMsg}}
{/* 每模型单价 + 积分权重 */} diff --git a/sundynix-admin/src/pages/UsagePage.tsx b/sundynix-admin/src/pages/UsagePage.tsx index 09f5d4f..9ddac07 100644 --- a/sundynix-admin/src/pages/UsagePage.tsx +++ b/sundynix-admin/src/pages/UsagePage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; -import { adminUsage, type UsageReport, type UsageTenantSum, type UsageDay } from "../api"; +import { adminUsage, grantCredits, type UsageReport, type UsageTenantSum, type UsageDay } from "../api"; import { BillingRules } from "../components/BillingRules"; // 管理端「用量 & 计费」= 计费闭环一页:顶部配「规则」(单价/积分权重/汇率),下方看「结果」(用量观测)。 @@ -68,6 +68,23 @@ export function UsagePage() { const series = useMemo(() => (report ? fillDays(report.trend, days) : []), [report, days]); + const [granting, setGranting] = useState(""); + const doGrant = async (tenantId: string, name: string) => { + const s = window.prompt(`给「${name}」充值积分(正=充值,负=扣减/校正)`, "100"); + if (s === null) return; + const n = Number(s); + if (!n) return; + setGranting(tenantId); + try { + await grantCredits(tenantId, n, "admin 手动充值"); + await load(); + } catch (e) { + window.alert((e as Error).message); + } finally { + setGranting(""); + } + }; + if (loading) return
加载用量数据中…
; if (err) return
用量加载失败:{err}
; if (!report) return null; @@ -170,7 +187,8 @@ export function UsagePage() { Token 成本 运行数 - 当前余额 + 当前余额 + 操作 @@ -185,7 +203,16 @@ export function UsagePage() { {int(r.total_tok)} {money(r.cost_micros)} {int(r.task_count)} - = 0 ? "text-gray-800" : "text-rose-500"}`}>{credits(r.balance_micro)} + = 0 ? "text-gray-800" : "text-rose-500"}`}>{credits(r.balance_micro)} + + + ))}