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:
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user