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}
|
||||
/>
|
||||
|
||||
@@ -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">最终积分价 = 标准挂牌价 × 系数(全部计费类型统一生效,对团队静默)。<1 为折扣,>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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 改为「模特库」(顶级实体,引用其形象图当上身图参考)。
|
||||
映射 ModelEntity→Asset 形: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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user