diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index 6ebb1eb..688fe96 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -125,6 +125,7 @@ export interface Pricing { model_id: string; input_per_1k: number; output_per_1k: number; + credit_weight: number; // 每模型积分权重(0/缺省=1.0,不加权) currency: string; } @@ -142,6 +143,24 @@ export async function savePricing(p: Pricing): Promise { } } +// —— 全局计费规则(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 { + 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 探活(不受鉴权影响)。 export async function gatewayOnline(): Promise { try { diff --git a/sundynix-admin/src/components/BillingRules.tsx b/sundynix-admin/src/components/BillingRules.tsx new file mode 100644 index 0000000..106272e --- /dev/null +++ b/sundynix-admin/src/components/BillingRules.tsx @@ -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([]); + 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(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) => 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
加载计费规则中…
; + if (err) return
计费规则加载失败:{err}
; + + return ( +
+
+
+

计费规则

+

扣费按此规则实时折算 · 改完对后续任务立即生效

+
+ 配置端 +
+ + {/* 全局 token→积分 汇率 */} +
+
+ +
+ { + 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" + /> + token = 1 积分(设 1 即按 token 直计) +
+
+ + {rateMsg && {rateMsg}} +
+ + {/* 每模型单价 + 积分权重 */} + {rows.length === 0 ? ( +
还没有登记模型,先到「模型」页添加。
+ ) : ( +
+ + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + + ))} + +
模型类型输入 / 1K输出 / 1K币种积分权重
+
{r.model.model}
+
{r.model.provider}
+
{r.model.kind} + patch(r.model.id, { inPer1k: e.target.value })} placeholder="0" /> + + patch(r.model.id, { outPer1k: e.target.value })} placeholder="0" /> + + + + patch(r.model.id, { weight: e.target.value })} placeholder="1.0" title="每 token 烧积分的倍率(空/0=1.0)" /> + + + {r.msg && {r.msg}} +
+
+ )} +
+ ); +} diff --git a/sundynix-admin/src/pages/PricingPage.tsx b/sundynix-admin/src/pages/PricingPage.tsx deleted file mode 100644 index dcf835c..0000000 --- a/sundynix-admin/src/pages/PricingPage.tsx +++ /dev/null @@ -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([]); - 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(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) => 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
加载中…
; - if (err) return
{err}
; - - // Top 消费者条形图数据计算 - const barChartWidth = 280; - const maxUsed = Math.max(...budgets.map((b) => b.currentUsed)); - - return ( -
- {/* 1. 计价配置 */} -
-

模型单价设置 (Token ↔ 计费)

-

为每个已登记模型设置输入/输出的每 1K token 计费单价,供费用折算统计使用。

- - {rows.length === 0 ? ( -
还没有登记模型,先到「模型」页添加。
- ) : ( - - - - - - - - - - - - - {rows.map((r) => ( - - - - - - - - - ))} - -
模型类型输入 / 1K输出 / 1K币种
-
{r.model.model}
-
{r.model.provider}
-
{r.model.kind} - patch(r.model.id, { inPer1k: e.target.value })} - placeholder="0" - /> - - patch(r.model.id, { outPer1k: e.target.value })} - placeholder="0" - /> - - - - - {r.msg && {r.msg}} -
- )} -
- - {/* 2. 成本护栏与 Token 预算 */} -
- {/* 左侧:预算分配表单 */} -
-
-
-

Token 预算与额度限制

-

配置用户每日 Token 消费总额度(Harness 成本护栏最后一环)

-
- - {/* 全局任务上限配置 */} -
- - setGlobalTaskLimit(Number(e.target.value))} - /> -
-
- - - - - - - - - - - - - {budgets.map((b) => ( - - - - - - - - ))} - -
成员租户今日消耗比例每日配额上限 (Daily Limit)超限处置
{b.name}{b.code} -
-
-
= 0.9 ? "bg-rose-500" : "bg-violet-600"}`} - style={{ width: `${Math.min(100, (b.currentUsed / b.dailyLimit) * 100)}%` }} - /> -
- {(b.currentUsed / b.dailyLimit * 100).toFixed(0)}% -
-
- handleBudgetLimitChange(b.id, Number(e.target.value))} - /> - - -
-
- - {/* 右侧:Top 消费大户排行 */} -
-
-

今日消耗大户排行

-

截止目前今日消耗 Token 额度最高的前几名成员

-
- -
- {budgets.map((b) => { - const pct = maxUsed > 0 ? (b.currentUsed / maxUsed) * 100 : 0; - return ( -
-
- {b.name} - {b.currentUsed.toLocaleString()} tokens -
- {/* SVG 进度柱 */} -
-
- - {pct.toFixed(0)}% 的最高峰值 - -
-
- ); - })} -
-
-
-
- ); -} diff --git a/sundynix-admin/src/pages/UsagePage.tsx b/sundynix-admin/src/pages/UsagePage.tsx index 1cc588a..09f5d4f 100644 --- a/sundynix-admin/src/pages/UsagePage.tsx +++ b/sundynix-admin/src/pages/UsagePage.tsx @@ -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 (
+ {/* 配置端:计费规则(改规则即对后续任务生效) */} + void load()} /> + + {/* 观测端:用量结果 */} +
+

用量观测

+ 按规则折算后的实际消耗 +
+ {/* 顶栏:租户筛选 + 区间 + 刷新 */}
diff --git a/sundynix-admin/src/routes.tsx b/sundynix-admin/src/routes.tsx index d26b71b..66a151c 100644 --- a/sundynix-admin/src/routes.tsx +++ b/sundynix-admin/src/routes.tsx @@ -9,7 +9,6 @@ const DashboardPage = lazy(() => import("./pages/DashboardPage").then((m) => ({ const UsagePage = lazy(() => import("./pages/UsagePage").then((m) => ({ default: m.UsagePage }))); const ModelsPage = lazy(() => import("./pages/ModelsPage").then((m) => ({ default: m.ModelsPage }))); 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 EvalsPage = lazy(() => import("./pages/EvalsPage").then((m) => ({ default: m.EvalsPage }))); const TenantsPage = lazy(() => import("./pages/TenantsPage").then((m) => ({ default: m.TenantsPage }))); @@ -35,7 +34,7 @@ export const routes: RouteDef[] = [ }, { path: "/usage", - label: "用量 & 计费", + label: "计费 & 用量", group: "分析", ready: true, element: , @@ -54,13 +53,6 @@ export const routes: RouteDef[] = [ ready: true, element: , }, - { - path: "/pricing", - label: "计价 & 预算", - group: "配置", - ready: true, - element: , - }, { path: "/prompts", label: "提示词",