import { useEffect, useMemo, useState } from "react"; 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, pts, stageMeta, yuan } from "./stage-config"; import { pageWindow } from "../components/pager"; import { useBodyScrollLock, useOverlayTransition } from "../components/overlays"; const ROLE_LABEL: Record = { owner: "超管", admin: "团管", member: "成员", viewer: "访客" }; const STATUS_LABEL: Record = { active: "活跃", invited: "待激活", disabled: "已停用" }; // 角色 pill 样式键(对齐 V1 .role-super / .role-admin / .role-member) const ROLE_PILL: Record = { owner: "role-super", admin: "role-admin", member: "role-member", viewer: "role-member" }; type TrendRange = "day" | "week" | "month"; const RANGE_META: Record = { 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"; type TopupQuote = { amount: number; bonus: number; channel: "wechat" | "alipay" }; // 项目状态 → 三态(对齐 V1 进行中 / 已完成 / 失败 · 待重跑) function projBucket(p: Project): "wip" | "ok" | "fail" { if (p.status === "completed") return "ok"; if (p.status === "failed") return "fail"; return "wip"; } const PROJ_STATUS_LABEL: Record<"wip" | "ok" | "fail", string> = { wip: "进行中", ok: "已完成", fail: "失败 · 待重跑" }; // 阶段 → 编号 + 名称 + 进度百分比(5 段流水线;completed = 100%) const STAGE_PCT: Record = { script: 20, base_assets: 40, storyboard: 60, video: 80, export: 100 }; function projStage(p: Project): { label: string; pct: number } { if (p.status === "completed") return { label: "Stage 5 拼接导出", pct: 100 }; const meta = stageMeta[p.current_stage]; const no = meta?.no ?? "1"; const label = meta ? `Stage ${no} ${meta.label}` : p.current_stage || "脚本"; return { label, pct: STAGE_PCT[p.current_stage] ?? 20 }; } // 账单类型 / 详情 中文化(后端历史英文流水也一并映射;未知值原样透出) const LEDGER_TYPE_LABEL: Record = { recharge: "充值", reserve: "预扣", release: "释放", charge: "扣费", adjustment: "调整", refund: "退款" }; const LEDGER_REASON_LABEL: Record = { "reserve ai task credit": "AI 任务预扣额度", "charge ai task credit": "AI 任务扣费", "release reserved credit": "释放预留额度", "release unused reserved credit": "释放未用预留额度", "release unused credit": "释放未用额度" }; 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: "+300 积分赠送", bonus: true, bonusAmt: 300, ribbon: "推荐" }, { amt: 1000, gift: "+800 积分赠送", bonus: true, bonusAmt: 800 }, { amt: 3000, gift: "+3000 积分赠送", bonus: true, bonusAmt: 3000 } ]; const MIN_RECHARGE_AMOUNT = 50; const STAGES: Array<{ k: string; color: string; bucket: keyof BillingTrend["by_stage"] }> = [ { k: "视频片段(Seedance)", color: "var(--heat)", bucket: "video" }, { k: "故事板(image-2)", color: "var(--accent-forest)", bucket: "storyboard" }, { k: "基础资产", color: "var(--black-alpha-56)", bucket: "base" }, { k: "脚本 LLM", color: "var(--black-alpha-32)", bucket: "script" } ]; // 充值扫码弹窗 · 复用设计系统 .modal-bg/.modal 外壳(QR 占位 + 金额 + 赠送 + 5 分钟有效 + 双按钮) // 关闭与进出场走 overlays 的 useOverlayTransition(ESC / 点遮罩关),完成支付回调交父级弹全局 toast。 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; }) { useBodyScrollLock(open); const { mounted, show } = useOverlayTransition(open, close); if (!mounted) return null; const channelLabel = channel === "alipay" ? "支付宝" : "微信支付"; const scanLabel = channel === "alipay" ? "支付宝扫码" : "微信扫码"; const txRef = `/topup/${channel === "alipay" ? "ali" : "wx"}/TX${Math.floor(Date.now() / 1000)}`; return createPortal(
event.stopPropagation()}> ++
扫码支付// {channelLabel}
支付金额
{yuan(amount)}
{`// 到账 ${money(Math.round(amount * rate + bonus))}`}{bonus > 0 ? `(含 ${bonus} 积分赠送)` : ""}
{scanLabel}
{txRef}
// 5 分钟内有效 · 到账后自动关闭
// 积分不可提现、不可转让 · 长期有效 · 发票按实际支付金额开具
, document.body, ); } export function AccountPage({ billing, projects, team, onRecharge, onNotify }: { billing: BillingSummary | null; projects: Project[]; // team prop 用于读取团队级月限额(与团队管理页保持同一来源 · #14) team?: Team | null; onRecharge: (amount: number, bonus: number) => void | Promise; onNotify: (type: "success" | "error" | "info", text: string) => void; }) { const [tab, setTab] = useState("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 刷新流水/趋势 const [reloadFlag, setReloadFlag] = useState(0); // 充值扫码弹窗:点支付方式先弹弹窗,弹窗内「已完成支付」才真正提交 const [topupChannel, setTopupChannel] = useState<"wechat" | "alipay" | null>(null); // 弹窗退场动画期间仍会保留在 DOM 中;打开时冻结本次支付快照,避免清空自定义金额后回显为预选套餐。 const [topupQuote, setTopupQuote] = useState(null); // 趋势(day,首屏)+ 团队成员(成员/限额 tab)本页懒加载 const [trend, setTrend] = useState(null); const [teamMembers, setTeamMembers] = useState([]); // 团队级月限额本页自取最新值:team prop 来自登录时 /me/ 快照,团队页改完月限额后不刷新会陈旧, // 造成本页「本月限额」与团队页「月限额」对不上(R72) const [freshTeam, setFreshTeam] = useState(null); useEffect(() => { let alive = true; api.billingTrend().then((d) => { if (alive) setTrend(d); }).catch(() => {}); api.teamMembers().then((m) => { if (alive) setTeamMembers(m); }).catch(() => {}); api.teamSettings().then((t) => { if (alive) setFreshTeam(t); }).catch(() => {}); return () => { alive = false; }; }, [reloadFlag]); // 账单流水分页:服务端分页(总数随流水增长,不再写死 100),每页 10 条 const BILLS_PER_PAGE = 10; const [billPage, setBillPage] = useState(1); // 账单流水筛选(类型 + 成员):传参给后端做全量过滤(R70)。 // 原实现只在前端过滤当前页 10 条,导致「扣费/充值」数据不全、总页数共用全部类型的 count。 const [billType, setBillType] = useState("all"); const [billMember, setBillMember] = useState("all"); const [ledgerRows, setLedgerRows] = useState([]); const [ledgerCount, setLedgerCount] = useState(0); const [ledgersLoading, setLedgersLoading] = useState(true); useEffect(() => { let alive = true; setLedgersLoading(true); api.ledgers(billPage, BILLS_PER_PAGE, { ledger_type: billType === "all" ? undefined : billType, user: billMember === "all" ? undefined : billMember }).then((data) => { if (!alive) return; setLedgerRows(data.results); setLedgerCount(data.count); }).catch(() => {}).finally(() => { if (alive) setLedgersLoading(false); }); return () => { alive = false; }; }, [billPage, billType, billMember, reloadFlag]); // row40:跳页输入框 —— 输入页码回车/点「跳转」即钳到 [1,总页数] 并翻页 const [billJump, setBillJump] = useState(""); const selectedCard = RECHARGE.find((item) => item.amt === recharge); const customAmount = Number(customAmt); const hasCustomAmount = customAmt.length > 0; const customAmountIsValid = !hasCustomAmount || (Number.isFinite(customAmount) && customAmount >= MIN_RECHARGE_AMOUNT); const effectiveAmount = hasCustomAmount ? customAmount : recharge; const effectiveBonus = hasCustomAmount ? 0 : selectedCard?.bonusAmt || 0; const setCustomIntegerAmount = (value: string) => setCustomAmt(value.replace(/\D/g, "").replace(/^0+/, "")); function validateCustomAmount() { if (customAmountIsValid) return true; onNotify("error", `最小充值金额为 ${MIN_RECHARGE_AMOUNT} 元`); return false; } function openTopup(channel: "wechat" | "alipay") { if (!validateCustomAmount()) return; setTopupQuote({ amount: effectiveAmount, bonus: effectiveBonus, channel }); setTopupChannel(channel); } async function submitRecharge() { if (!topupQuote || topupQuote.amount < MIN_RECHARGE_AMOUNT) { onNotify("error", `最小充值金额为 ${MIN_RECHARGE_AMOUNT} 元`); setTopupChannel(null); return; } await onRecharge(topupQuote.amount, topupQuote.bonus); // 父级 action 成功后弹全局「充值成功」toast setTopupChannel(null); setCustomAmt(""); setBillPage(1); setReloadFlag((n) => n + 1); // 充值后刷新流水/趋势(余额由全局 action→loadData 刷新) } const balance = Number(billing?.account.balance || 0); const used = Number(billing?.charged_total || 0); // 月限额:与团队管理页保持同一来源(team.monthly_credit_limit) · #14 // 优先用本页刚拉的最新团队设置,fallback 到 team prop(R72:prop 是 /me/ 快照,改完限额不刷新会陈旧) // -1 = 不限(显示余额);0/null = 未设置 → 用成员额度累加,再 fallback 余额 const teamData = freshTeam ?? team; const savedMonthlyLimit = teamData?.monthly_credit_limit == null ? 0 : Number(teamData.monthly_credit_limit); const memberLimitSum = teamMembers.reduce((sum, m) => sum + Math.max(0, Number(m.monthly_credit_limit || 0)), 0); const limit = savedMonthlyLimit === -1 ? balance : (savedMonthlyLimit > 0 ? savedMonthlyLimit : (memberLimitSum || balance)); const left = Math.max(0, limit - used); const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0; // 消费趋势(日/周/月可切)+ 按阶段 / 按项目分布 —— 全部来自真实 CHARGE 流水 // day 用首屏 prop;week/month 切换时按真接口拉对应区间(缺接口已补 ?range=) const [range, setRange] = useState("day"); const [rangeTrend, setRangeTrend] = useState(null); useEffect(() => { if (range === "day") { setRangeTrend(null); return; } let alive = true; api.billingTrend(range).then((data) => { if (alive) setRangeTrend(data); }).catch(() => {}); return () => { alive = false; }; }, [range]); const activeTrend = range === "day" ? trend : rangeTrend; const meta = RANGE_META[range]; const daily = activeTrend?.daily ?? []; const peak = Number(activeTrend?.peak || 0); const total14 = Number(activeTrend?.total_14d ?? used); const avgValue = daily.length ? total14 / daily.length : 0; const stageTotal = STAGES.reduce((sum, s) => sum + Number(trend?.by_stage[s.bucket] || 0), 0) || used; const projectSpend = (id: string) => Number(trend?.by_project[id] || 0); // ── 项目筛选(状态;真实数据无 owner/per-range,故只做状态过滤)── const [projStatus, setProjStatus] = useState<"all" | "wip" | "ok" | "fail">("all"); const filteredProjects = useMemo( () => projects.filter((p) => projStatus === "all" || projBucket(p) === projStatus), [projects, projStatus] ); const projSpendSum = filteredProjects.reduce((s, p) => s + projectSpend(p.id), 0); const projFiltered = projStatus !== "all"; // ── 成员筛选(角色)── const [memRole, setMemRole] = useState<"all" | "owner" | "admin" | "member">("all"); const filteredMembers = useMemo( () => teamMembers.filter((m) => memRole === "all" || m.role === memRole), [teamMembers, memRole] ); const memUsedSum = filteredMembers.reduce((s, m) => s + Number(m.month_charged || 0), 0); const memFiltered = memRole !== "all"; // ── 账单流水筛选辅助 ── 筛选本体在上方 useEffect 走后端全量过滤(R70)。 // 成员下拉选项取团队成员表(原先取自当前页流水 user_label,只能看到本页出现过的人);value 传 user id 给后端。 const billMemberOptions = useMemo( () => teamMembers.map((m) => ({ id: m.user.id, label: m.user.username || m.user.email || "成员" })), [teamMembers] ); const billFiltered = billType !== "all" || billMember !== "all"; // 服务端过滤后 count 即当前筛选自己的总数,总页数随筛选变化(R70) const billTotalPages = Math.max(1, Math.ceil(ledgerCount / BILLS_PER_PAGE)); const safeBillPage = Math.min(billPage, billTotalPages); function gotoBillPage() { const n = parseInt(billJump, 10); if (!Number.isNaN(n)) setBillPage(Math.min(billTotalPages, Math.max(1, n))); setBillJump(""); } return (

消费

// 余额 · 充值 · 4 维消费视图 + 账单流水
团队余额
{money(balance)}
// 充值累加 · 不重置
本月限额
{money(limit)}
// 按自然月重置
当月已用
{money(used)}
// 占比 {pct.toFixed(1)}% · {pct >= 80 ? "注意" : "健康"}
团队月剩余 {money(left)} 使用率 {pct.toFixed(1)}%

快速充值

// 充值后立刻到账,可开发票 · 仅超管可操作
已选 ¥{effectiveAmount}(到账 {Math.round(effectiveAmount * pointsRate + effectiveBonus)} 积分){effectiveBonus > 0 ? ` · 含 ${effectiveBonus} 积分赠送` : ""}
{RECHARGE.map((item) => (
{ setRecharge(item.amt); setCustomAmt(""); }} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setRecharge(item.amt); setCustomAmt(""); } }} > {item.ribbon && {item.ribbon}}
¥{item.amt}
{item.gift}
))}
自定义金额
setCustomIntegerAmount(event.target.value)} /> {!customAmountIsValid && }
计费说明 1 元 = 10 积分。脚本/文案 10 积分/次;图片生成 20 积分/张;配音 10 积分/500 字; 视频按时长与分辨率计量、按实际用量结算(如 15 秒 720P 竖屏约 224 积分),多退少不补超; 拼接导出免费。生成失败、超时均不扣费,自动退回预留积分。

消费趋势

{meta.sub} {(["day", "week", "month"] as TrendRange[]).map((r) => ( ))}
{daily.map((d) => { const amt = Number(d.amount); const h = peak > 0 ? Math.max(amt > 0 ? 4 : 0, (amt / peak) * 100) : 0; const isPeak = peak > 0 && amt === peak; return (
); })}
{daily.map((d, i) => ( {i % 2 === 0 ? d.label : ""} ))}
{meta.totalLabel}{money(total14)}
{meta.avgLabel}{money(avgValue)}
峰值{money(peak)}

本月按阶段分布

// PRD §5.3.5 扣费规则 · 仅确认后扣
{STAGES.map((s) => { const amt = Number(trend?.by_stage[s.bucket] || 0); const w = stageTotal > 0 ? Math.min(100, (amt / stageTotal) * 100) : 0; return (
{s.k}{money(amt)}
); })}
合计{money(used)}

扣费 + 四层额度预检规则

// PRD §5.3.5 + §10.3 · 对接团队请以此页为准
① 失败不扣:模型超时 / 内容审核拦截 / 生成异常一律不扣费。
② 用户重跑不扣首次:第一次重跑保留原扣费,第二次起按次结算。
③ 仅在你点击 [ 确认通过 ] 时入账。
④ 导出不再扣费,所有 token 已在过程中结算。
// 任务确认前 · 四层额度预检(任一不通过即拦截)
1个人日剩余 ≥ 任务预估 × 1.2
2个人月剩余 ≥ 同上
3团队月剩余 ≥ 同上
4团队总余额 ≥ 同上
{billFiltered && ( )} 本页 {ledgerRows.length} 条 · 共 {ledgerCount} 条
{ledgersLoading && ledgerRows.length === 0 ? ( ) : ledgerRows.length === 0 ? ( ) : ledgerRows.map((l) => ( ))}
时间项目 / 类型详情成员状态金额
// 加载中…
{billFiltered ? "// 无匹配筛选条件的账单" : "// 暂无账单流水"}
{new Date(l.created_at).toLocaleString("zh-CN")} {ledgerTypeLabel(l.ledger_type)} {ledgerReasonLabel(l.reason)} {l.user_label ? {l.user_label.slice(0, 1).toUpperCase()}{l.user_label} : 系统} 成功 {pts(l.amount)}
{ledgerCount > BILLS_PER_PAGE && (
// 共 {ledgerCount} 条 · 第 {safeBillPage} / {billTotalPages} 页
{pageWindow(safeBillPage, billTotalPages).map((p, i) => ( p === "ellipsis" ? … : ))}
{/* row40:跳页输入 —— 输入页码回车或点「跳转」直达 */} 跳至 setBillJump(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") gotoBillPage(); }} aria-label="跳至页码" /> 页
)}
{projFiltered && ( )} 共 {filteredProjects.length} 个项目 · 消耗 {money(projSpendSum)}
{filteredProjects.length === 0 ? ( ) : filteredProjects.map((p) => { const spend = projectSpend(p.id); const stg = projStage(p); const bucket = projBucket(p); return ( ); })}
项目商品当前阶段状态消耗
// 当前筛选条件下没有项目
{p.name} {p.product_title || "-"} {stg.label} {PROJ_STATUS_LABEL[bucket]} 0 ? "neg" : "zero"}>{money(spend)}
{memFiltered && ( )} 共 {filteredMembers.length} 人 · 合计 {money(memUsedSum)}
{filteredMembers.length === 0 ? ( ) : filteredMembers.map((m) => { const monthly = Number(m.monthly_credit_limit || 0); const monthUsed = Number(m.month_charged || 0); const usedPct = monthly > 0 ? Math.min(100, (monthUsed / monthly) * 100) : 0; const rolePill = ROLE_PILL[m.role] || "role-member"; return ( ); })}
成员角色已用 / 月度额度状态
// 当前筛选条件下没有成员
{m.user.username.slice(0, 1).toUpperCase()}{m.user.username} {ROLE_LABEL[m.role] || m.role} {money(monthUsed)} / {monthly > 0 ? money(monthly) : "不限"}{monthly > 0 ? ` · ${usedPct.toFixed(1)}%` : ""} {monthly > 0 && ( = 85 ? "var(--accent-honey)" : "var(--heat)" }} /> )} {STATUS_LABEL[m.status] || m.status}
setTopupChannel(null)} onDone={submitRecharge} />
); }