feat(billing): 计费商业化重构(积分制)+ 团队差异化调价

统一计价引擎 apps/billing/pricing.py:1积分=¥0.1,平台成本(¥)与用户价(积分)双记账,
视频按火山真实 usage.total_tokens 结算(true-up,终结¥1/段倒贴)+ 首发×1.5毛利系数。
Team.price_multiplier 差异化调价(jimeng同款):挂牌价×系数两步HALF_UP取整,视频类
按下单时的价格/汇率快照结算(中途改配不影响在途任务)。

BillingConfig 单例(汇率/毛利系数/预留buffer,admin可调即刻生效)。存量数据 ×10 rescale
迁移(RunPython+atomic,MySQL 迁移不可中断重跑)。开户赠送归零(DEFAULT_TRIAL_CREDITS=0,
商业决策)。audit_billing 加 I9(卖亏审计)。271 条测试 + tsc/build 全绿。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-07-07 10:04:00 +08:00
co-authored by Claude Sonnet 5
parent c1420316c2
commit bf20de956c
46 changed files with 1490 additions and 150 deletions
+2 -2
View File
@@ -850,7 +850,7 @@ export function App() {
billing={billing}
projects={projects}
team={currentTeam}
onRecharge={(amount, bonus) => action(() => api.recharge({ amount, bonus }), "充值成功")}
onRecharge={(amount, bonusPoints) => action(() => api.recharge({ amount, bonus_points: bonusPoints }), "充值成功")}
/>
);
case "team":
@@ -864,7 +864,7 @@ export function App() {
onUpdateMember={(id, payload) => action(() => api.updateTeamMember(id, payload), "成员已更新")}
onRemoveMember={(id) => action(() => api.removeTeamMember(id), "成员已移除")}
onResetPassword={(id, password) => action(() => api.resetMemberPassword(id, password), "密码已重置")}
onRecharge={(amount, bonus) => action(() => api.recharge({ amount, bonus }), "充值成功")}
onRecharge={(amount, bonusPoints) => action(() => api.recharge({ amount, bonus_points: bonusPoints }), "充值成功")}
/>
);
case "messages":
+17 -2
View File
@@ -15,6 +15,7 @@ import type {
AITask,
Asset,
AuthPayload,
BillingConfigInfo,
BillingSummary,
BillingTrend,
FreeAssetGroup,
@@ -726,9 +727,14 @@ export const api = {
pollFreeAsset(id: string) {
return request<{ asset: FreeAssetItem }>(`/api/assets/free-assets/${id}/poll/`, { method: "POST" });
},
recharge(payload: { amount: number | string; bonus?: number | string; channel?: string }) {
recharge(payload: { amount: number | string; bonus_points?: number | string; channel?: string }) {
// amount 是真实支付 ¥,到账积分 = ¥ × points_per_yuan + bonus_points(赠送积分)
return request<RechargeResult>("/api/billing/recharge/", { method: "POST", body: JSON.stringify(payload) });
},
// 计费公共配置(积分汇率/视频毛利/预留 buffer):所有前端预估统一读这里,与后端计价引擎同源
billingConfig() {
return request<BillingConfigInfo>("/api/billing/config/");
},
// 收件箱按页拉取 —— 滚动加载逐页向后端要(tab/搜索 走服务端,计数随响应回来)
listNotifications(params?: { type?: string; unread?: boolean; search?: string; page?: number; pageSize?: number }) {
const query = new URLSearchParams();
@@ -794,6 +800,9 @@ export const adminApi = {
teamDetail(id: string) {
return request<AdminTeamDetail>(`/api/admin/teams/${id}/`);
},
setTeamPricing(id: string, priceMultiplier: string) {
return request<AdminTeam>(`/api/admin/teams/${id}/pricing/`, { method: "POST", body: JSON.stringify({ price_multiplier: priceMultiplier }) });
},
toggleTeam(id: string) {
return request<AdminTeam>(`/api/admin/teams/${id}/toggle/`, { method: "POST" });
},
@@ -878,10 +887,16 @@ export const adminApi = {
const q = qs.toString();
return request<Paginated<AdminLedger>>(`/api/admin/ledgers/${q ? `?${q}` : ""}`);
},
billingConfig() {
return request<BillingConfigInfo & { updated_at: string }>("/api/admin/billing-config/");
},
updateBillingConfig(payload: { points_per_yuan?: string; video_margin_multiplier?: string; video_reserve_buffer?: string }) {
return request<BillingConfigInfo & { updated_at: string }>("/api/admin/billing-config/", { method: "PATCH", body: JSON.stringify(payload) });
},
adjustCredit(payload: { team: string; amount: string; reason: string }) {
return request<AdminLedger>("/api/admin/ledgers/adjust/", { method: "POST", body: JSON.stringify(payload) });
},
quotaPolicies(params?: { team?: string; page_size?: number }) {
quotaPolicies(params?: { team?: string; page?: number; page_size?: number }) {
const qs = new URLSearchParams();
if (params?.team) qs.set("team", params.team);
if (params?.page_size) qs.set("page_size", String(params.page_size));
@@ -83,17 +83,34 @@ export function tokenPrice(config: ModelConfig | undefined, resolution: string,
return (hasVideoRef ? tier.with_ref_video : tier.no_ref_video) || 0;
}
// 积分制:与后端 apps/billing/pricing.py 取整规则逐字一致 ——
// ¥成本先 round 到 0.01 → ×毛利系数 ×积分汇率 → HALF_UP 取整积分,最低 1。
// billing(毛利/汇率)来自 GET /api/billing/config/,与后端计价引擎同源;未加载时回退首发默认值。
export type BillingRates = { margin: number; rate: number; multiplier: number };
export const DEFAULT_BILLING_RATES: BillingRates = { margin: 1.5, rate: 10, multiplier: 1 };
export function estimateCost(
config: ModelConfig | undefined,
params: { ratio: string; resolution: string; duration: number; refs: { type: string; duration?: number }[] }
): { tokens: number; cost: number } {
params: { ratio: string; resolution: string; duration: number; refs: { type: string; duration?: number }[] },
billing: BillingRates = DEFAULT_BILLING_RATES
): { tokens: number; points: number } {
const inputVideoSeconds = params.refs
.filter((r) => r.type === "video")
.reduce((sum, r) => sum + (r.duration || 0), 0);
const tokens = estimateTokens(params.ratio, params.resolution, params.duration, inputVideoSeconds);
const hasVideoRef = params.refs.some((r) => r.type === "video");
const price = tokenPrice(config, params.resolution, hasVideoRef);
return { tokens, cost: Math.round(((tokens * price) / 1e6) * 100) / 100 };
const costYuan = Math.round(((tokens * price) / 1e6) * 100) / 100;
if (costYuan <= 0) return { tokens, points: 0 };
// 浮点乘积在 .5 边界可能落成 27.499999…,先吸附 6 位小数再 round,
// 与后端 Decimal ROUND_HALF_UP 逐字对齐。只吸浮点噪声(1e-9 级),
// 不能用 toFixed(2):非默认 margin 下 x.495 会被先四舍五入成 x.50 多显 1 积分(review 确认)
const raw = Number((costYuan * billing.margin * billing.rate).toFixed(6));
const listPoints = Math.max(1, Math.round(raw));
// 团队价格系数(差异化调价):两步取整与后端 apply_team_price 逐字对齐(先挂牌取整,再乘系数取整)
const multiplier = billing.multiplier || 1;
const points = multiplier === 1 ? listPoints : Math.max(1, Math.round(Number((listPoints * multiplier).toFixed(6))));
return { tokens, points };
}
export function modelLabel(name: string): string {
@@ -90,7 +90,7 @@ export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavori
<div className="fc-card-prompt" title={task.prompt}>{task.prompt}</div>
<div className="fc-card-sub mono">
// {MODE_LABELS[task.mode] || task.mode} · {modelLabel(task.model)} · {task.resolution.toUpperCase()} · {task.duration}s
{task.status === "succeeded" && ` · ¥${Number(task.actual_cost || 0).toFixed(2)}`}
{task.status === "succeeded" && ` · ${Number(task.actual_cost || 0)} 积分`}
</div>
</div>
</div>
@@ -3,7 +3,7 @@
import { useRef, useState, type RefObject } from "react";
import { IconKitSvg } from "../IconKitSvg";
import type { ModelConfig } from "../../types";
import { MODE_LABELS, type FreeMode, type LocalRef } from "./constants";
import { MODE_LABELS, type BillingRates, type FreeMode, type LocalRef } from "./constants";
import { PromptInput, type PromptInputHandle } from "./prompt-input";
import { FreeToolbar } from "./toolbar";
@@ -53,7 +53,7 @@ function KeyframeSlot({ role, item, onPick, onRemove }: {
);
}
export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onClear, onSend }: {
export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onClear, onSend }: {
mode: FreeMode;
model: string;
ratio: string;
@@ -62,6 +62,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
seed: number;
refs: LocalRef[];
videoConfigs: ModelConfig[];
billingRates?: BillingRates;
submitting: boolean;
promptRef: RefObject<PromptInputHandle | null>;
/** 用户选择/拖入文件;keyframe 模式下带目标 role */
@@ -156,6 +157,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
seed={seed}
refs={refs}
videoConfigs={videoConfigs}
billingRates={billingRates}
hasPrompt={hasPrompt}
submitting={submitting}
onModeChange={onModeChange}
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react";
import type { ModelConfig } from "../../types";
import {
DEFAULT_BILLING_RATES,
FC_DURATIONS,
FC_MODELS,
FC_RATIOS,
@@ -10,6 +11,7 @@ import {
FC_STANDARD_MODEL,
MODE_LABELS,
estimateCost,
type BillingRates,
type FreeMode,
type LocalRef
} from "./constants";
@@ -63,7 +65,7 @@ function FcDropdown({ label, display, items, onSelect, disabled }: {
);
}
export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, hasPrompt, submitting, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onClear, onSend }: {
export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, hasPrompt, submitting, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onClear, onSend }: {
mode: FreeMode;
model: string;
ratio: string;
@@ -72,6 +74,7 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
seed: number;
refs: LocalRef[];
videoConfigs: ModelConfig[];
billingRates?: BillingRates;
hasPrompt: boolean;
submitting: boolean;
onModeChange: (mode: FreeMode) => void;
@@ -85,7 +88,7 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
}) {
const isStandard = model === FC_STANDARD_MODEL;
const config = videoConfigs.find((c) => c.name === model);
const { tokens, cost } = estimateCost(config, { ratio, resolution, duration, refs });
const { tokens, points } = estimateCost(config, { ratio, resolution, duration, refs }, billingRates || DEFAULT_BILLING_RATES);
const [seedOpen, setSeedOpen] = useState(false);
const seedRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -164,8 +167,8 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
</div>
</div>
<div className="fc-toolbar-r">
<span className="fc-estimate mono" title="预估消耗(实际按火山返回用量结算)">
{tokens.toLocaleString()} tokens · ¥{cost.toFixed(2)}
<span className="fc-estimate mono" title="预估消耗(实际按真实用量结算,多退少不补超)">
{tokens.toLocaleString()} tokens · {points.toLocaleString()}
</span>
<button type="button" className="btn btn-sm btn-ghost" onClick={onClear}></button>
<button type="button" className="btn btn-sm btn-primary" disabled={!canSend} onClick={onSend} title="Ctrl/Cmd + Enter">
@@ -142,7 +142,7 @@ export function VideoDetailModal({ task, hasPrev, hasNext, onPrev, onNext, onClo
<div className="fc-player-sub mono">
// {MODE_LABELS[task.mode] || task.mode} · {modelLabel(task.model)} · {task.aspect_ratio} · {task.resolution.toUpperCase()} · {task.duration}s
{task.seed_used != null && ` · seed ${task.seed_used}`}
{` · ¥${Number(task.actual_cost || 0).toFixed(2)}`}
{` · ${Number(task.actual_cost || 0)} 积分`}
</div>
{task.fallback_note && <div className="fc-player-warn mono">// {task.fallback_note}</div>}
<div className="fc-player-actions">
+6 -4
View File
@@ -4,7 +4,8 @@
// onPageSizeChange 传则「每页 N 条」变成可点击循环按钮(默认循环 12/24/48/96)
// pageSizeOptions 自定义每页条数候选
// sticky 传则分页器吸底浮动
// total <= pageSize 且不可改每页条数时不渲染(单页无需分页器)。
// alwaysShow 单页也渲染(admin 列表用:让「共 N 条 · 每页 10 条」可见,否则数据少时像没接分页)
// 默认 total <= pageSize 且不可改每页条数时不渲染(单页无需分页器)。
import { useState } from "react";
@@ -21,7 +22,7 @@ export function pageWindow(current: number, total: number): Array<number | "elli
return items;
}
export function Pager({ page, total, pageSize, onChange, onPageSizeChange, pageSizeOptions = [12, 24, 48, 96], sticky = false }: {
export function Pager({ page, total, pageSize, onChange, onPageSizeChange, pageSizeOptions = [12, 24, 48, 96], sticky = false, alwaysShow = false }: {
page: number;
total: number;
pageSize: number;
@@ -29,12 +30,13 @@ export function Pager({ page, total, pageSize, onChange, onPageSizeChange, pageS
onPageSizeChange?: (size: number) => void;
pageSizeOptions?: number[];
sticky?: boolean;
alwaysShow?: boolean;
}) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const cur = Math.min(Math.max(1, page), totalPages);
const [jump, setJump] = useState("");
// 单页且不可改每页条数 → 不渲染;可改条数时仍渲染(让用户能切回更小分页)
if (total <= pageSize && !onPageSizeChange) return null;
// 单页且不可改每页条数 → 不渲染;可改条数或 alwaysShow 时仍渲染
if (total <= pageSize && !onPageSizeChange && !alwaysShow) return null;
function cycleSize() {
if (!onPageSizeChange) return;
+27 -13
View File
@@ -3,7 +3,7 @@ import { CreditCard, X } from "lucide-react";
import { createPortal } from "react-dom";
import { api } from "../api";
import type { BillingSummary, BillingTrend, Ledger, Project, Team, TeamMember } from "../types";
import { money, stageMeta } from "./stage-config";
import { money, pts, stageMeta, yuan } from "./stage-config";
import { pageWindow } from "../components/pager";
import { useBodyScrollLock, useOverlayTransition } from "../components/overlays";
@@ -14,9 +14,9 @@ const ROLE_PILL: Record<string, string> = { owner: "role-super", admin: "role-ad
type TrendRange = "day" | "week" | "month";
const RANGE_META: Record<TrendRange, { chip: string; sub: string; totalLabel: string; avgLabel: string }> = {
day: { chip: "日", sub: "// 近 14 天 · 单位 ¥", totalLabel: "14 天合计", avgLabel: "日均" },
week: { chip: "周", sub: "// 近 8 周 · 单位 ¥", totalLabel: "8 周合计", avgLabel: "周均" },
month: { chip: "月", sub: "// 近 6 个月 · 单位 ¥", totalLabel: "6 月合计", avgLabel: "月均" }
day: { chip: "日", sub: "// 近 14 天 · 单位 积分", totalLabel: "14 天合计", avgLabel: "日均" },
week: { chip: "周", sub: "// 近 8 周 · 单位 积分", totalLabel: "8 周合计", avgLabel: "周均" },
month: { chip: "月", sub: "// 近 6 个月 · 单位 积分", totalLabel: "6 月合计", avgLabel: "月均" }
};
type Tab = "overview" | "by-project" | "by-member" | "bills";
@@ -52,11 +52,12 @@ const LEDGER_REASON_LABEL: Record<string, string> = {
const ledgerTypeLabel = (t: string) => LEDGER_TYPE_LABEL[t] ?? t;
const ledgerReasonLabel = (r: string) => LEDGER_REASON_LABEL[r] ?? r;
// 积分制:amt 是真实支付 ¥,到账积分 = ¥×10;bonusAmt 是**赠送积分**(等值维持原 ¥ 赠送 ×10)
const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: number; ribbon?: string }> = [
{ amt: 100, gift: "无赠送", bonus: false, bonusAmt: 0 },
{ amt: 500, gift: "+ ¥30 赠送", bonus: true, bonusAmt: 30, ribbon: "推荐" },
{ amt: 1000, gift: "+ ¥80 赠送", bonus: true, bonusAmt: 80 },
{ amt: 3000, gift: "+ ¥300 赠送", bonus: true, bonusAmt: 300 }
{ amt: 500, gift: "+300 积分赠送", bonus: true, bonusAmt: 300, ribbon: "推荐" },
{ amt: 1000, gift: "+800 积分赠送", bonus: true, bonusAmt: 800 },
{ amt: 3000, gift: "+3000 积分赠送", bonus: true, bonusAmt: 3000 }
];
const STAGES: Array<{ k: string; color: string; bucket: keyof BillingTrend["by_stage"] }> = [
@@ -68,11 +69,12 @@ const STAGES: Array<{ k: string; color: string; bucket: keyof BillingTrend["by_s
// 充值扫码弹窗 · 复用设计系统 .modal-bg/.modal 外壳(QR 占位 + 金额 + 赠送 + 5 分钟有效 + 双按钮)
// 关闭与进出场走 overlays 的 useOverlayTransition(ESC / 点遮罩关),完成支付回调交父级弹全局 toast。
function TopupModal({ open, channel, amount, bonus, close, onDone }: {
function TopupModal({ open, channel, amount, bonus, rate, close, onDone }: {
open: boolean;
channel: "wechat" | "alipay";
amount: number;
bonus: number;
rate: number; // 积分汇率(积分/¥),来自 /api/billing/config/,与后端到账口径同源
close: () => void;
onDone: () => void | Promise<unknown>;
}) {
@@ -93,12 +95,13 @@ function TopupModal({ open, channel, amount, bonus, close, onDone }: {
</div>
<div className="modal-b">
<div className="topup-info"></div>
<div className="topup-amt">{money(amount)}</div>
<div className="topup-note">{bonus > 0 ? `// 含 ¥${bonus} 赠送 · 实到账 ${money(amount + bonus)}` : "// 无赠送"}</div>
<div className="topup-amt">{yuan(amount)}</div>
<div className="topup-note">{`// 到账 ${money(Math.round(amount * rate + bonus))}`}{bonus > 0 ? `(含 ${bonus} 积分赠送)` : ""}</div>
<div className="topup-qr" aria-label="二维码占位">
<div className="center">{scanLabel}<br /><span className="ref">{txRef}</span></div>
</div>
<div className="topup-valid">// 5 分钟内有效 · 到账后自动关闭</div>
<div className="topup-valid">// 积分不可提现、不可转让 · 长期有效 · 发票按实际支付金额开具</div>
</div>
<div className="modal-f">
<button className="btn" type="button" onClick={close}></button>
@@ -118,6 +121,11 @@ export function AccountPage({ billing, projects, team, onRecharge }: {
onRecharge: (amount: number, bonus: number) => void | Promise<unknown>;
}) {
const [tab, setTab] = useState<Tab>("overview");
// 积分汇率(积分/¥):预览换算与后端 recharge 同源,汇率改配后不至于「预览 ×10 实到 ×N」
const [pointsRate, setPointsRate] = useState(10);
useEffect(() => {
void api.billingConfig().then((cfg) => setPointsRate(Number(cfg.points_per_yuan) || 10)).catch(() => undefined);
}, []);
const [recharge, setRecharge] = useState(500);
const [customAmt, setCustomAmt] = useState("");
// 账户页数据本页自取(不再走全局 bootstrap):充值后 bump 刷新流水/趋势
@@ -289,7 +297,7 @@ export function AccountPage({ billing, projects, team, onRecharge }: {
<h3></h3>
<div className="desc">// 充值后立刻到账,可开发票 · 仅超管可操作</div>
</div>
<div className="topup-selected"> ¥{effectiveAmount}{effectiveBonus > 0 ? ` + ¥${effectiveBonus} 赠送` : ""}</div>
<div className="topup-selected"> ¥{effectiveAmount}( {Math.round(effectiveAmount * pointsRate + effectiveBonus)} ){effectiveBonus > 0 ? ` · 含 ${effectiveBonus} 积分赠送` : ""}</div>
</div>
<div className="recharge-row">
{RECHARGE.map((item) => (
@@ -322,6 +330,12 @@ export function AccountPage({ billing, projects, team, onRecharge }: {
</button>
</div>
</div>
<div className="tip" style={{ marginTop: 14 }}>
<strong></strong>
1 = 10 / 10 /; 20 /; 10 /500 ;
( 15 720P 224 ),退;
,退
</div>
</div>
</div>
@@ -443,7 +457,7 @@ export function AccountPage({ billing, projects, team, onRecharge }: {
? <span className="who"><span className="av">{l.user_label.slice(0, 1).toUpperCase()}</span>{l.user_label}</span>
: <span className="sys"></span>}</td>
<td className="bill-status"><span className="status-tag ok"></span></td>
<td className="neg">{l.amount}</td>
<td className="neg">{pts(l.amount)}</td>
</tr>
))}
</tbody>
@@ -565,7 +579,7 @@ export function AccountPage({ billing, projects, team, onRecharge }: {
open={topupChannel !== null}
channel={topupChannel ?? "wechat"}
amount={effectiveAmount}
bonus={effectiveBonus}
bonus={effectiveBonus} rate={pointsRate}
close={() => setTopupChannel(null)}
onDone={submitRecharge}
/>
@@ -1,11 +1,15 @@
import { useCallback, useEffect, useState } from "react";
import { Gauge, Wallet, X } from "lucide-react";
import { adminApi } from "../../api";
import { Pager } from "../../components/pager";
import { IconKitSvg } from "../../components/IconKitSvg";
import type { AdminLedger, AdminQuotaPolicy, AdminTeam } from "../../types";
import { pts } from "../stage-config";
type Notify = (type: "success" | "error" | "info", text: string) => void;
const PAGE_SIZE = 10;
function fmtDate(iso: string) {
if (!iso) return "—";
const d = new Date(iso);
@@ -37,6 +41,70 @@ function useTeams() {
return teams;
}
// ─────────────────────────── 平台计费配置 ───────────────────────────
// 积分汇率 / 视频毛利系数 / 预留 buffer(与后端计价引擎同源,改动即刻生效于下一次估价/结算)
function BillingConfigCard({ notify }: { notify: Notify }) {
const [form, setForm] = useState({ points_per_yuan: "", video_margin_multiplier: "", video_reserve_buffer: "" });
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
const cfg = await adminApi.billingConfig();
setForm({
points_per_yuan: cfg.points_per_yuan,
video_margin_multiplier: cfg.video_margin_multiplier,
video_reserve_buffer: cfg.video_reserve_buffer
});
setLoaded(true);
} catch {
notify("error", "计费配置加载失败");
}
}, [notify]);
useEffect(() => { void load(); }, [load]);
const save = async () => {
setSaving(true);
try {
await adminApi.updateBillingConfig(form);
notify("success", "计费配置已更新,即刻生效");
} catch (e) {
notify("error", e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
if (!loaded) return null;
return (
<div className="card-hard admin-billing-config" style={{ padding: "18px 20px", marginBottom: 20 }}>
<div className="section-h" style={{ marginBottom: 12 }}>
<h2 style={{ fontSize: 16 }}></h2>
<span className="more">[ /billing-config ]</span>
</div>
<div style={{ display: "flex", gap: 16, alignItems: "flex-end", flexWrap: "wrap" }}>
<div className="field" style={{ width: 160 }}>
<label className="field-label">(/¥)</label>
<input className="input" type="number" step="0.01" value={form.points_per_yuan} onChange={(e) => setForm((f) => ({ ...f, points_per_yuan: e.target.value }))} />
<div className="field-hint">1 </div>
</div>
<div className="field" style={{ width: 160 }}>
<label className="field-label"></label>
<input className="input" type="number" step="0.01" value={form.video_margin_multiplier} onChange={(e) => setForm((f) => ({ ...f, video_margin_multiplier: e.target.value }))} />
<div className="field-hint"> = × </div>
</div>
<div className="field" style={{ width: 160 }}>
<label className="field-label"> buffer</label>
<input className="input" type="number" step="0.01" value={form.video_reserve_buffer} onChange={(e) => setForm((f) => ({ ...f, video_reserve_buffer: e.target.value }))} />
<div className="field-hint"> = × buffer</div>
</div>
<button className="btn btn-primary" type="button" disabled={saving} onClick={() => void save()}>{saving ? "保存中…" : "保存配置"}</button>
</div>
</div>
);
}
// ─────────────────────────── 计费审计 ───────────────────────────
export function AdminLedgersPage({ notify }: { notify: Notify }) {
@@ -44,6 +112,7 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState("");
const [page, setPage] = useState(1);
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState({ team: "", amount: "", reason: "" });
const [saving, setSaving] = useState(false);
@@ -52,7 +121,8 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
const load = useCallback(async () => {
setLoading(true);
try {
const res = await adminApi.ledgers({ ledger_type: tab || undefined, page_size: 80 });
const res = await adminApi.ledgers({ ledger_type: tab || undefined, page, page_size: PAGE_SIZE });
if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; }
setLedgers(res.results);
setCount(res.count);
} catch {
@@ -61,7 +131,7 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab]);
}, [tab, page]);
useEffect(() => { void load(); }, [load]);
@@ -94,11 +164,12 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
<button className="btn btn-primary" type="button" onClick={() => setModalOpen(true)}>+ </button>
</div>
</div>
<BillingConfigCard notify={notify} />
<div className="admin-toolbar">
<div className="tabs-sub">
{LEDGER_TABS.map((t) => (
<button key={t.key || "all"} type="button" className={`tab-sub${tab === t.key ? " active" : ""}`} onClick={() => setTab(t.key)}>{t.label}</button>
<button key={t.key || "all"} type="button" className={`tab-sub${tab === t.key ? " active" : ""}`} onClick={() => { setTab(t.key); setPage(1); }}>{t.label}</button>
))}
</div>
</div>
@@ -117,14 +188,15 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
<td>{l.team_name || <span className="muted"></span>}</td>
<td>{l.username || <span className="muted"></span>}</td>
<td>{ledgerPill(l.ledger_type)}</td>
<td className="num mono">¥{l.amount}</td>
<td className="num mono">¥{l.balance_after}</td>
<td className="num mono">{pts(l.amount)} </td>
<td className="num mono">{pts(l.balance_after)} </td>
<td>{l.reason || <span className="muted"></span>}</td>
<td className="mono col-time">{fmtDate(l.created_at)}</td>
</tr>
))}
</tbody>
</table>
<Pager page={page} total={count} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
</div>
)}
@@ -179,19 +251,22 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
const [saving, setSaving] = useState(false);
const teams = useTeams();
const [page, setPage] = useState(1);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await adminApi.quotaPolicies({ page_size: 100 });
const res = await adminApi.quotaPolicies({ page, page_size: PAGE_SIZE });
if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; }
setPolicies(res.results);
setCount(res.count);
} catch {
if (page > 1) { setPage(1); return; }
notify("error", "加载额度策略失败");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [page]);
useEffect(() => { void load(); }, [load]);
@@ -241,7 +316,7 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
}
}
const lim = (v: string | null) => (v == null ? <span className="muted"></span> : `¥${v}`);
const lim = (v: string | null) => (v == null ? <span className="muted"></span> : `${pts(v)} 积分`);
return (
<>
@@ -279,6 +354,7 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
))}
</tbody>
</table>
<Pager page={page} total={count} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
</div>
)}
@@ -1,8 +1,10 @@
import { useCallback, useEffect, useState } from "react";
import { Building, KeyRound, X } from "lucide-react";
import { Building, KeyRound, Percent, X } from "lucide-react";
import { adminApi } from "../../api";
import { Pager } from "../../components/pager";
import { IconKitSvg } from "../../components/IconKitSvg";
import type { AdminTeam, AdminTeamDetail, AdminUser } from "../../types";
import { pts } from "../stage-config";
type Notify = (type: "success" | "error" | "info", text: string) => void;
@@ -19,6 +21,8 @@ function statusPill(active: boolean) {
: <span className="pill err"><span className="dot" /></span>;
}
const PAGE_SIZE = 10;
const STATUS_TABS = [
{ key: "", label: "全部" },
{ key: "active", label: "启用" },
@@ -33,22 +37,36 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState("");
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [detail, setDetail] = useState<AdminTeamDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
// 差异化调价:行级「定价」弹窗(系数 0.10~10.00,<1 折扣 / >1 加价,静默生效)
const [pricingTarget, setPricingTarget] = useState<AdminTeam | null>(null);
const [pricingValue, setPricingValue] = useState("1.00");
const [pricingSaving, setPricingSaving] = useState(false);
// 卖亏告警阈值 = 1/毛利系数(系数×毛利<1 即视频卖低于成本):跟随计费配置,不硬编码 0.67(review 确认)
const [videoMargin, setVideoMargin] = useState(1.5);
useEffect(() => {
void adminApi.billingConfig().then((cfg) => setVideoMargin(Number(cfg.video_margin_multiplier) || 1.5)).catch(() => undefined);
}, []);
const lossThreshold = 1 / videoMargin;
const load = useCallback(async () => {
setLoading(true);
try {
const res = await adminApi.teams({ status: statusFilter || undefined, search: search.trim() || undefined, page_size: 100 });
const res = await adminApi.teams({ status: statusFilter || undefined, search: search.trim() || undefined, page, page_size: PAGE_SIZE });
// 删完当前页最后一条(或筛选变化)导致本页悬空 → 回退一页
if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; }
setTeams(res.results);
setCount(res.count);
} catch {
if (page > 1) { setPage(1); return; } // 页码越界(后端 404)→ 回第一页
notify("error", "加载团队失败");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusFilter, search]);
}, [statusFilter, search, page]);
useEffect(() => { void load(); }, [load]);
@@ -74,6 +92,23 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
}
}
async function savePricing() {
if (!pricingTarget || pricingSaving) return;
const v = Number(pricingValue);
if (!Number.isFinite(v) || v < 0.1 || v > 10) { notify("error", "系数需在 0.10 ~ 10.00 之间"); return; }
setPricingSaving(true);
try {
await adminApi.setTeamPricing(pricingTarget.id, pricingValue);
notify("success", `${pricingTarget.name}」价格系数已设为 ×${Number(pricingValue).toFixed(2)}`);
setPricingTarget(null);
await load();
} catch (e) {
notify("error", e instanceof Error ? e.message : "保存失败");
} finally {
setPricingSaving(false);
}
}
return (
<>
<div className="page-head">
@@ -86,10 +121,10 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
<div className="admin-toolbar">
<div className="tabs-sub">
{STATUS_TABS.map((t) => (
<button key={t.key || "all"} type="button" className={`tab-sub${statusFilter === t.key ? " active" : ""}`} onClick={() => setStatusFilter(t.key)}>{t.label}</button>
<button key={t.key || "all"} type="button" className={`tab-sub${statusFilter === t.key ? " active" : ""}`} onClick={() => { setStatusFilter(t.key); setPage(1); }}>{t.label}</button>
))}
</div>
<input className="input admin-search" type="text" placeholder="搜索团队名…" value={search} onChange={(e) => setSearch(e.target.value)} />
<input className="input admin-search" type="text" placeholder="搜索团队名…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(1); }} />
</div>
{loading ? (
@@ -100,7 +135,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
<div className="admin-table-wrap">
<table className="t admin-table">
<thead>
<tr><th></th><th></th><th></th><th></th><th></th><th></th><th className="col-actions"></th></tr>
<tr><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th className="col-actions"></th></tr>
</thead>
<tbody>
{teams.map((t) => (
@@ -108,10 +143,12 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
<td>{t.name}</td>
<td>{t.owner_username || <span className="muted"></span>}</td>
<td className="num">{t.member_count}</td>
<td className="num mono">¥{t.balance}</td>
<td className="num mono">{pts(t.balance)} </td>
<td className="num mono">{Number(t.price_multiplier) === 1 ? <span className="muted"></span> : `×${Number(t.price_multiplier).toFixed(2)}`}</td>
<td>{statusPill(t.status === "active")}</td>
<td className="mono col-time">{fmtDate(t.created_at)}</td>
<td className="col-actions">
<button className="btn btn-sm btn-ghost" type="button" onClick={() => { setPricingTarget(t); setPricingValue(Number(t.price_multiplier).toFixed(2)); }}></button>
<button className="btn btn-sm btn-ghost" type="button" onClick={() => openDetail(t.id)}></button>
<button className={`btn btn-sm btn-ghost${t.status === "active" ? " danger" : ""}`} type="button" onClick={() => toggle(t)}>
{t.status === "active" ? "停用" : "启用"}
@@ -121,6 +158,32 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
))}
</tbody>
</table>
<Pager page={page} total={count} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
</div>
)}
{pricingTarget && (
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) setPricingTarget(null); }}>
<div className="modal" role="dialog" aria-modal="true" aria-label="团队定价">
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><Percent size={16} /></div>
<div className="ti"><span>// {pricingTarget.name}</span></div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setPricingTarget(null)}><X size={14} /></button>
</div>
<div className="modal-b">
<p className="admin-modal-desc"> = × (,)&lt;1 ,&gt;1 ;</p>
<div className="field">
<label className="field-label"> <span className="lbl-note">(0.10 ~ 10.00,1.00 = )</span></label>
<input className="input num-input" type="number" step="0.05" min="0.1" max="10" value={pricingValue} onChange={(e) => setPricingValue(e.target.value)} />
{Number(pricingValue) < lossThreshold && <div className="field-hint">// ⚠ 低于 ×{lossThreshold.toFixed(2)} 时视频将卖低于平台成本(毛利 ×{videoMargin} 被抵消),I9 审计会告警</div>}
</div>
</div>
<div className="modal-f">
<button className="btn" type="button" onClick={() => setPricingTarget(null)}></button>
<button className="btn btn-primary" type="button" disabled={pricingSaving} onClick={() => void savePricing()}>{pricingSaving ? "保存中…" : "保存"}</button>
</div>
</div>
</div>
)}
@@ -141,7 +204,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
<div className="admin-detail-meta">
<div><span className="k"></span><span className="v">{detail.owner_username || "—"}</span></div>
<div><span className="k"></span><span className="v">{detail.member_count}</span></div>
<div><span className="k"></span><span className="v mono">¥{detail.balance}</span></div>
<div><span className="k"></span><span className="v mono">{pts(detail.balance)} </span></div>
<div><span className="k"></span><span className="v">{statusPill(detail.status === "active")}</span></div>
</div>
<div className="admin-detail-subhead"> · {detail.members.length}</div>
@@ -153,7 +216,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
<td>{m.username}</td>
<td>{m.role}</td>
<td>{statusPill(m.user_status === "active")}</td>
<td className="num mono">¥{m.monthly_credit_limit}</td>
<td className="num mono">{pts(m.monthly_credit_limit)} </td>
</tr>
))}
</tbody>
@@ -179,6 +242,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState("");
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [pwdTarget, setPwdTarget] = useState<AdminUser | null>(null);
const [pwd, setPwd] = useState("");
const [saving, setSaving] = useState(false);
@@ -186,16 +250,18 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
const load = useCallback(async () => {
setLoading(true);
try {
const res = await adminApi.users({ status: statusFilter || undefined, search: search.trim() || undefined, page_size: 100 });
const res = await adminApi.users({ status: statusFilter || undefined, search: search.trim() || undefined, page, page_size: PAGE_SIZE });
if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; }
setUsers(res.results);
setCount(res.count);
} catch {
if (page > 1) { setPage(1); return; }
notify("error", "加载用户失败");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusFilter, search]);
}, [statusFilter, search, page]);
useEffect(() => { void load(); }, [load]);
@@ -238,10 +304,10 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
<div className="admin-toolbar">
<div className="tabs-sub">
{STATUS_TABS.map((t) => (
<button key={t.key || "all"} type="button" className={`tab-sub${statusFilter === t.key ? " active" : ""}`} onClick={() => setStatusFilter(t.key)}>{t.label}</button>
<button key={t.key || "all"} type="button" className={`tab-sub${statusFilter === t.key ? " active" : ""}`} onClick={() => { setStatusFilter(t.key); setPage(1); }}>{t.label}</button>
))}
</div>
<input className="input admin-search" type="text" placeholder="搜索用户名…" value={search} onChange={(e) => setSearch(e.target.value)} />
<input className="input admin-search" type="text" placeholder="搜索用户名…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(1); }} />
</div>
{loading ? (
@@ -284,6 +350,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
))}
</tbody>
</table>
<Pager page={page} total={count} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
</div>
)}
+23 -15
View File
@@ -121,21 +121,21 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
tag: "[ MODEL · TRY-ON ]",
title: "模特上身图",
desc: "选择模特,AI 生成商品模特上身效果图",
cost: "≈ ¥0.30 / 张"
cost: "≈ 20 积分 / 张"
},
{
page: "platformCover" as Page,
tag: "[ PLATFORM · KIT ]",
title: "平台套图",
desc: "选择平台模板,AI 生成电商平台套图",
cost: "≈ ¥0.50 / 张"
cost: "≈ 20 积分 / 张"
},
{
page: "imageOptimize" as Page,
tag: "[ IMAGE · STUDIO ]",
title: "图片创作",
desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写",
cost: "≈ ¥0.40 / "
cost: "≈ 20 积分 / "
}
];
@@ -729,15 +729,23 @@ export function ImageWorkbenchPage({
}
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
// 团队价格系数(差异化调价):预估所见即所扣;拉不到按标准价 1
const [priceMultiplier, setPriceMultiplier] = useState(1);
useEffect(() => {
void api.billingConfig().then((cfg) => setPriceMultiplier(Number(cfg.team_price_multiplier) || 1)).catch(() => undefined);
}, []);
// 每张图实扣单价:取「当前选的生图模型」的 unit_price(火山/gpt-image),没匹配上用第一个图像模型,
// 再兜底 ¥2(后端 estimate_cost=unit_price,默认模型 gpt-image-2=¥2)。前端预估据此算,和后端实扣一致(PMC#20)。
// 再兜底 20 积分(后端 quote_flat=unit_price 积分/张,默认模型 gpt-image-2=20 积分)。前端预估据此算,和后端实扣一致(PMC#20)。
const perImagePrice = (() => {
const want = genModel === "gpt-image" ? "gpt-image" : genModel === "volcano" ? "seedream" : genModel;
const m = imageModels.find((x) => x.name.toLowerCase().includes(want)) || imageModels[0];
const p = Number(m?.unit_price);
return Number.isFinite(p) && p > 0 ? p : 2;
return Number.isFinite(p) && p > 0 ? p : 20;
})();
const estimateFee = (n: number) => `¥${(n * perImagePrice).toFixed(2)}`;
// 与后端逐张对齐:每张 = 挂牌单价取整 × 系数 → HALF_UP 最低 1,总价 = 张数 × 单张
// (不能先乘张数再取整:0.85 系数 × 3 张会比后端逐张各取整少 1-2 积分,review 确认)
const perImageFinal = Math.max(1, Math.round(Number((Math.round(perImagePrice) * priceMultiplier).toFixed(6))));
const estimateFee = (n: number) => `${n * perImageFinal} 积分`;
/* :2 (,)
ModelEntityAsset :id= id( model_id ),metadata model_entity_id
@@ -2477,8 +2485,8 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
<div className="dm-form-cta">
<div className="dm-cost">
<span> <span className="v"> ¥1.20</span></span>
<span> ¥327.40</span>
<span> <span className="v"> 20 /</span></span>
<span></span>
</div>
<button className="dm-gen" type="button" onClick={toRealTool}>
<Sparkles size={15} />
@@ -2502,7 +2510,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
<div className="nm">Ava × 4 </div>
<div className="info">
{active.title} <span className="sep">·</span> 3:4 <span className="sep">·</span> 3 {" "}
<span className="sep">·</span> ¥1.20
<span className="sep">·</span> 20 /
</div>
</div>
<div className="ops">
@@ -2527,7 +2535,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
<div className="nm">Zoe × 4 </div>
<div className="info">
{active.title} <span className="sep">·</span> 3:4 <span className="sep">·</span> 12 {" "}
<span className="sep">·</span> ¥1.20
<span className="sep">·</span> 20 /
</div>
</div>
<div className="ops">
@@ -2624,7 +2632,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
<div className="info">
<span>3:4</span><span className="sep">·</span>
<span>3 </span><span className="sep">·</span>
<span>¥1.20</span>
<span>20 </span>
</div>
</div>
<div className="ops">
@@ -2647,7 +2655,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
<div className="info">
<span>3:4</span><span className="sep">·</span>
<span>12 </span><span className="sep">·</span>
<span>¥1.20</span>
<span>20 </span>
</div>
</div>
<div className="ops">
@@ -2670,7 +2678,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
<div className="info">
<span>3:4</span><span className="sep">·</span>
<span></span><span className="sep">·</span>
<span>¥0.60</span>
<span>20 </span>
</div>
</div>
<div className="ops">
@@ -2697,7 +2705,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
<div className="info">
<span>3:4</span><span className="sep">·</span>
<span> 18:24</span><span className="sep">·</span>
<span>¥1.20</span>
<span>20 </span>
</div>
</div>
<div className="ops">
@@ -2762,7 +2770,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
<span className="muted">+ </span>
</button>
<span className="spacer" />
<span className="meta-right"> <span className="v">¥1.20</span> · <span className="v">¥327.40</span></span>
<span className="meta-right"> <span className="v">20 /</span></span>
<button className="gen-btn" type="button" onClick={toRealTool}>
<Sparkles size={14} />
· {active.title} × Ava
+1 -1
View File
@@ -72,7 +72,7 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill
const allProjectsLoaded = projects.length >= projCount;
const totalDelta = allProjectsLoaded && newThisMonth > 0 ? `↑ 本月 +${newThisMonth}` : undefined;
// 冻结信息:真实「冻结 ¥X / 共 ¥Y」,无 budget/已用字段,故不复刻 V1「已用 ¥X/¥Y」。
// 冻结信息:真实「冻结 X 积分 / 共 Y 积分」,无 budget/已用字段,故不复刻 V1「已用/共」。
// R41:余额 stat 块已从 KPI 行移除,冻结信息改放 page-head 小标题行右侧(见下方 .sub 内)。
const balanceNum = Number(billing?.account.balance ?? 0);
const reservedNum = Number(billing?.account.reserved_balance ?? 0);
+11
View File
@@ -6,8 +6,10 @@ import { Users } from "lucide-react";
import { api, ApiError } from "../api";
import type { FreeVideoRef, FreeVideoTask, ModelConfig } from "../types";
import {
DEFAULT_BILLING_RATES,
FC_MODELS,
FC_STANDARD_MODEL,
type BillingRates,
MAX_AUDIOS,
MAX_IMAGES,
MAX_VIDEOS,
@@ -59,6 +61,14 @@ export function FreeCreatePage({ modelConfigs, onNotify }: {
[modelConfigs]
);
// 计费配置(积分汇率/视频毛利):预估口径与后端计价引擎同源;拉不到用首发默认值兜底
const [billingRates, setBillingRates] = useState<BillingRates>(DEFAULT_BILLING_RATES);
useEffect(() => {
void api.billingConfig()
.then((cfg) => setBillingRates({ margin: Number(cfg.video_margin_multiplier) || DEFAULT_BILLING_RATES.margin, rate: Number(cfg.points_per_yuan) || DEFAULT_BILLING_RATES.rate, multiplier: Number(cfg.team_price_multiplier) || 1 }))
.catch(() => undefined);
}, []);
// —— 任务流 ——
const [tasks, setTasks] = useState<FreeVideoTask[]>([]);
const [total, setTotal] = useState(0);
@@ -504,6 +514,7 @@ export function FreeCreatePage({ modelConfigs, onNotify }: {
seed={seed}
refs={refs}
videoConfigs={videoConfigs}
billingRates={billingRates}
submitting={submitting}
promptRef={promptRef}
onFiles={(files, role) => void addFiles(files, role)}
+15 -7
View File
@@ -507,6 +507,14 @@ export function PipelinePage(props: {
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
} = props;
// ── 团队价格系数(差异化调价):页面各处「N 积分/次」文案按团队系数动态显示,拉不到按标准价 1 ──
const [priceMultiplier, setPriceMultiplier] = useState(1);
useEffect(() => {
void api.billingConfig().then((cfg) => setPriceMultiplier(Number(cfg.team_price_multiplier) || 1)).catch(() => undefined);
}, []);
// 与后端 apply_team_price 逐字对齐:挂牌整数积分 × 系数 → HALF_UP 最低 1(toFixed(6) 只吸浮点噪声)
const pts = (base: number) => (priceMultiplier === 1 ? base : Math.max(1, Math.round(Number((base * priceMultiplier).toFixed(6)))));
// ── 资产解析:把各阶段引用的 asset id → 真实缩略图 preview_url(主图优先,其次首张)──
const byId = new Map(assets.map((a) => [a.id, a] as const));
const assetUrl = (id: string | null | undefined): string => {
@@ -2821,7 +2829,7 @@ export function PipelinePage(props: {
</div>
<div className="stage-foot">
<div className="info"><span className="mono">[ LLM ~2.4k tokens · ¥0.04 · · ]</span></div>
<div className="info"><span className="mono">[ {pts(10)} / · ]</span></div>
<div className="hstack">
<button className="btn" type="button" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑", "重新生成全部", undefined, "auto")}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg> </button>
<button className="btn btn-primary btn-lg" type="button" disabled={loading || !currentScript} onClick={confirmScript}>{scriptAdopted ? "进入下一步" : "确认脚本,进入下一步"} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
@@ -2897,8 +2905,8 @@ export function PipelinePage(props: {
<div className="info">
,
<br /><br />
<strong className="mono">// 人物 +¥0.20/张</strong>
<strong className="mono">// 场景 +¥0.15/张</strong>
<strong className="mono">// 人物 +{pts(20)} 积分/张</strong>
<strong className="mono">// 场景 +{pts(20)} 积分/张</strong>
<span style={{ color: "var(--black-alpha-48)" }}>()</span>
{(entitiesExtracted || hasAnyAsset) && (
<button type="button" className="btn btn-sm eg-reextract" disabled={extractState === "running"} data-stop onClick={() => void runExtract("only")}>
@@ -2977,7 +2985,7 @@ export function PipelinePage(props: {
{previewTriAsset === adoptedTriAsset ? "已采用" : "采用此版本"}
</button>
<span className="spacer"></span>
<span className="muted-2 mono" style={{ fontSize: 12 }}>~¥0.30 / </span>
<span className="muted-2 mono" style={{ fontSize: 12 }}>{pts(20)} / </span>
</div>
)}
<div className={`prod-preview-history${productVersions.length ? " show" : ""}`} id="prod-preview-history">
@@ -3290,7 +3298,7 @@ export function PipelinePage(props: {
</button>
)}
<span className="spacer"></span>
<span className="muted-2 mono" style={{ fontSize: "12px", alignSelf: "center" }}>~¥0.45/</span>
<span className="muted-2 mono" style={{ fontSize: "12px", alignSelf: "center" }}>{pts(20)} /</span>
</div>
<div className="sb-history">
<div className="sb-history-h">// 本场历史版本(<span id="sb-history-ct">{activeVers.length}</span>)· 点击预览</div>
@@ -3405,7 +3413,7 @@ export function PipelinePage(props: {
</strong>
<span className={`pill ${tone}`}><span className="dot"></span>{statusLabel(seg.status)}</span>
</div>
<div className="video-meta">{seg.target_duration_seconds}s · {timeline?.resolution || "1080×1920"} · ~¥0.45{seg.error_message ? ` · ${seg.error_message}` : ""}</div>
<div className="video-meta">{seg.target_duration_seconds}s · {timeline?.resolution || "1080×1920"} · {seg.error_message ? ` · ${seg.error_message}` : ""}</div>
<div className="video-actions">
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading || showBusy} onClick={() => guardVideoGen(shots.filter((s) => s.sort_order === seg.sort_order), seg.id, () => submitVideoOptimistic(seg.id, `${videoPrompt}${seg.sort_order + 1} 段,时长 ${seg.target_duration_seconds}`))}>{showBusy ? <><span className="spinner btn-spin" aria-hidden="true" />{busy ? "生成中…" : "提交中…"}</> : "重跑"}</button>
{/* 多版本入口:N>1 才显示,点开详情弹窗看历史/切换/采用(数据全留着,不吞历史) */}
@@ -3633,7 +3641,7 @@ export function PipelinePage(props: {
</select>
</div>
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-sm" type="button" disabled={loading || !edState.clips.some((c) => c.subtitle.trim())} onClick={() => onGenerateVoiceover({ items: edState.clips.map((c, idx) => ({ index: idx, text: c.subtitle.trim() })).filter((item) => item.text), voice_type: voVoicePick || voInfo?.voice_type || VO_VOICES[0].key })}>{voInfo ? "重新生成配音" : "生成配音 · ~¥1"}</button>
<button className="btn btn-sm" type="button" disabled={loading || !edState.clips.some((c) => c.subtitle.trim())} onClick={() => onGenerateVoiceover({ items: edState.clips.map((c, idx) => ({ index: idx, text: c.subtitle.trim() })).filter((item) => item.text), voice_type: voVoicePick || voInfo?.voice_type || VO_VOICES[0].key })}>{voInfo ? "重新生成配音" : `生成配音 · ${pts(10)} 积分/500字`}</button>
{voInfo && <button className="btn btn-sm btn-ghost" type="button" disabled={loading} onClick={() => onSaveTimeline({ voiceover: { clear: true } })}></button>}
</div>
</>
+1 -1
View File
@@ -922,7 +922,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
</button>
)}
<span style={{ flex: 1 }}></span>
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-56)" }}>~¥0.30 / </span>
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-56)" }}>20 / </span>
</div>
{/* 版本历史:点缩略图切换查看;已采用版本主橙描边 + 徽标 */}
<div className={`prod-preview-history${triVersions.length ? " show" : ""}`} id="ov-tri-history">
+18 -1
View File
@@ -8,8 +8,25 @@ export const stageMeta: Record<string, { no: string; label: string }> = {
export: { no: "5", label: "拼接导出" }
};
// 积分裸数字格式化:DecimalField 序列化的 "75.0000" → "75"、"23.1000" → "23.1"(历史 ¥×10 迁移数据留有效小数)。
// admin 列表 / 账户流水等自带「积分」后缀的位置用它;带后缀的整串用 money()。
export function pts(value: string | number | undefined | null) {
if (value === undefined || value === "" || value === null) return "0";
const n = Number(value);
if (!Number.isFinite(n)) return String(value);
return Number.isInteger(n)
? n.toLocaleString("en-US")
: n.toLocaleString("en-US", { maximumFractionDigits: 2 });
}
// 积分制重构(2026-07-03):余额/流水/限额/任务计价全站换积分,money() 语义随之重定义为积分格式化
// (调用点的值在 rescale 迁移后本来就是积分,函数名保留避免全站改名)。真实人民币(充值支付/成本/毛利)用 yuan()。
export function money(value: string | number | undefined) {
if (value === undefined || value === "") return "¥0.00";
return `${pts(value)} 积分`;
}
export function yuan(value: string | number | undefined) {
if (value === undefined || value === "" || value === null) return "¥0.00";
const n = Number(value);
return Number.isFinite(n) ? `¥${n.toFixed(2)}` : `¥${value}`;
}
+17 -12
View File
@@ -26,12 +26,12 @@ function genPassword() {
for (let i = 0; i < 12; i++) s += chars[Math.floor(Math.random() * chars.length)];
return s;
}
// 千分位金额格式化(V1 限额弹窗用 ¥3,000 / ¥2,837.40 风格)· money() 无千分位故本地补
// 千分位积分格式化(积分制:限额/已用全是积分)
function yuan(n: number, decimals = 0) {
const fixed = n.toFixed(decimals);
const [int, dec] = fixed.split(".");
const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return `¥${grouped}${dec ? "." + dec : ""}`;
return `${grouped}${dec ? "." + dec : ""} 积分`;
}
// 复制到剪贴板:优先 navigator.clipboard,兜底 textarea + execCommand
async function copyText(text: string) {
@@ -113,6 +113,11 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
const onUpdateMember = async (id: string, p: Parameters<typeof onUpdateMemberRaw>[1]) => { const r = await onUpdateMemberRaw(id, p); reloadTeam(); return r; };
const onRemoveMember = async (id: string) => { const r = await onRemoveMemberRaw(id); reloadTeam(); return r; };
const onRecharge = async (amount: number, bonus: number) => { const r = await onRechargeRaw(amount, bonus); reloadTeam(); return r; };
// 积分汇率(积分/¥):充值 hint 与后端到账口径同源,汇率改配后不写死 ×10
const [pointsRate, setPointsRate] = useState(10);
useEffect(() => {
void api.billingConfig().then((cfg) => setPointsRate(Number(cfg.points_per_yuan) || 10)).catch(() => undefined);
}, []);
// 创建账户表单
const [cuUser, setCuUser] = useState("");
@@ -526,7 +531,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
</select>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"> ¥ <span className="lbl-note">(-1 )</span></label>
<label className="field-label">()<span className="lbl-note">(-1 )</span></label>
<input className="input num-input" type="number" inputMode="numeric" placeholder="例: 2000" value={invMonthly} onChange={(e) => setInvMonthly(e.target.value)} />
</div>
{inviteErr && <div className="form-error" role="alert" style={{ marginTop: 12 }}>{inviteErr}</div>}
@@ -558,7 +563,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
>
<div className="limit-modal">
<div className="field">
<label className="field-label"> ¥ <span className="lbl-note">(-1 )</span></label>
<label className="field-label">()<span className="lbl-note">(-1 )</span></label>
<input className="input num-input" type="number" inputMode="numeric" placeholder="例: 3000" value={limitVal} onChange={(e) => setLimitVal(e.target.value)} />
</div>
<div className="field" style={{ marginBottom: 0 }}>
@@ -619,18 +624,18 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
</div>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"> <span className="lbl-note">(¥ · -1 )</span></label>
<label className="field-label"> <span className="lbl-note">( · -1 )</span></label>
<div className="quota-grid">
<label className="quota-cell">
<span className="qk"> ¥</span>
<span className="qk">()</span>
<input className="input num-input" type="number" inputMode="numeric" value={cuDaily} onChange={(e) => setCuDaily(e.target.value)} />
</label>
<label className="quota-cell">
<span className="qk"> ¥</span>
<span className="qk">()</span>
<input className="input num-input" type="number" inputMode="numeric" value={cuMonthly} onChange={(e) => setCuMonthly(e.target.value)} />
</label>
<label className="quota-cell">
<span className="qk"> ¥</span>
<span className="qk">()</span>
<input className="input num-input" type="number" inputMode="numeric" value={cuTotal} onChange={(e) => setCuTotal(e.target.value)} />
</label>
</div>
@@ -687,7 +692,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
dismissable={false}
footer={<button className="btn btn-primary" type="button" onClick={submitRecharge}></button>}
>
<div className="field"><label className="field-label"> ¥</label><input className="input num-input" type="number" value={rechargeAmt} onChange={(e) => setRechargeAmt(e.target.value)} placeholder="最低 ¥50" /></div>
<div className="field"><label className="field-label"> ¥</label><input className="input num-input" type="number" value={rechargeAmt} onChange={(e) => setRechargeAmt(e.target.value)} placeholder="最低 ¥50" /><div className="field-hint">// 到账积分 = 支付金额 × {pointsRate}(当前汇率)</div></div>
</TeamModal>
{/* 编辑成员 · 角色双卡 + 三档额度 */}
@@ -717,15 +722,15 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
</div>
</div>
<div className="field">
<label className="field-label"> ¥ <span className="lbl-note">(-1 )</span></label>
<label className="field-label">()<span className="lbl-note">(-1 )</span></label>
<input className="input num-input" type="number" inputMode="numeric" placeholder="例: 100" value={edDaily} onChange={(e) => setEdDaily(e.target.value)} />
</div>
<div className="field">
<label className="field-label"> ¥ <span className="lbl-note">(-1 )</span></label>
<label className="field-label">()<span className="lbl-note">(-1 )</span></label>
<input className="input num-input" type="number" inputMode="numeric" placeholder="例: 2000" value={edMonthly} onChange={(e) => setEdMonthly(e.target.value)} />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"> ¥ <span className="lbl-note">(-1 )</span></label>
<label className="field-label">()<span className="lbl-note">(-1 )</span></label>
<input className="input num-input" type="number" inputMode="numeric" placeholder="例: -1" value={edTotal} onChange={(e) => setEdTotal(e.target.value)} />
</div>
</div>
+18
View File
@@ -63,6 +63,8 @@ export type AdminTeam = {
owner_username: string | null;
member_count: number;
balance: string;
// 团队价格系数(差异化调价):最终积分价 = 挂牌价 × 系数
price_multiplier: string;
created_at: string;
};
export type AdminTeamMember = {
@@ -122,6 +124,9 @@ export type AdminTask = {
model_name: string | null;
estimated_cost: string;
actual_cost: string;
// 平台成本(¥)与单任务毛利(¥,成本未知时 null)—— 积分制重构后毛利首次可审
base_cost?: string;
margin_yuan?: string | null;
cost_anomaly: boolean;
error_code: string;
created_at: string;
@@ -713,4 +718,17 @@ export type NotificationList = Paginated<Notification> & {
export type RechargeResult = {
account: BillingSummary["account"];
ledger: Ledger;
// 积分制:到账积分与当时汇率快照(支付金额是 ¥,到账是积分)
credited_points?: string;
points_per_yuan?: string;
};
// 平台计费配置(公共 GET /api/billing/config/):前端预估与后端计价引擎同源
export type BillingConfigInfo = {
currency_unit: string;
points_per_yuan: string;
video_margin_multiplier: string;
video_reserve_buffer: string;
// 当前请求者团队的价格系数(差异化调价,默认 "1"):预估所见即所扣
team_price_multiplier?: string;
};