import { useEffect, useMemo, useState } from "react"; import { BellRing, Clapperboard, Download, Film, Image as ImageIcon, ReceiptText, Replace, ScanText, WandSparkles, X, } from "lucide-react"; import { createPortal } from "react-dom"; import { api } from "../api"; import type { BillingSummary, BillingTrend, Ledger, Project, Team, TeamMember } from "../types"; import type { NavigateFn } from "./route-config"; import { pts } from "./stage-config"; import { pageWindow } from "../components/pager"; import { useBodyScrollLock, useOverlayTransition } from "../components/overlays"; import { CustomSelect } from "../components/custom-select"; 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; const THRESHOLD_KEY = "airshelf:billing-balance-threshold"; // 与后台「任务监控」类型列同源(AITask.task_type);展示中文名,数值按类型聚合 const TASK_TYPE_LABEL: Record = { script_generation: "脚本生成", script_optimization: "脚本优化", entity_extraction: "实体提取", video_digest: "视频提炼", product_image: "商品图", person_image: "人物图", model_triview: "模特三视图", scene_image: "场景图", storyboard: "故事板", video_segment: "视频片段", voiceover: "配音", export: "导出", free_video: "自由视频", }; const taskTypeLabel = (t: string) => TASK_TYPE_LABEL[t] || t; function FeatureIcon({ name }: { name: string }) { const props = { size: 17, strokeWidth: 2 } as const; switch (name) { case "clapperboard": return ; case "wand": return ; case "replace": return ; case "film": return ; case "scan": return ; case "image": return ; default: return ; } } function featureIconForLedger(ledger: Ledger): string { const tt = (ledger.task_type || "").toLowerCase(); if (tt.includes("image") || tt.includes("triview") || tt.includes("product_image") || tt.includes("person_image") || tt.includes("scene_image")) return "image"; if (tt.includes("digest") || tt.includes("video_digest")) return "scan"; if (tt.includes("free_video") || tt.includes("film") || tt.includes("video_segment") || tt.includes("voiceover") || tt.includes("export")) return "film"; if (tt.includes("storyboard")) return "clapperboard"; const text = `${ledger.ledger_type} ${ledger.reason}`.toLowerCase(); if (text.includes("image") || text.includes("图片")) return "image"; if (text.includes("digest") || text.includes("提示词") || text.includes("提炼")) return "scan"; if (text.includes("replace") || text.includes("remix") || text.includes("复刻")) return "replace"; if (text.includes("free") || text.includes("自由") || text.includes("film")) return "film"; if (text.includes("quick") || text.includes("一键") || text.includes("wand")) return "wand"; return "clapperboard"; } function ledgerFeatureLabel(ledger: Ledger): string { if (ledger.task_type) return taskTypeLabel(ledger.task_type); return ledgerTypeLabel(ledger.ledger_type); } function ledgerLocationLabel(ledger: Ledger): string { if (ledger.project_title) return ledger.project_title; if (ledger.ledger_type === "recharge") return "账户充值"; return ledgerReasonLabel(ledger.reason); } function formatMonthLabel(ym: string) { const [y, m] = ym.split("-"); return `${y}年${Number(m)}月`; } function monthOptions(count = 4) { const now = new Date(); const out: Array<{ value: string; label: string }> = []; for (let i = 0; i < count; i++) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const value = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; out.push({ value, label: formatMonthLabel(value) }); } return out; } function formatLedgerTime(iso: string) { const d = new Date(iso); if (Number.isNaN(d.getTime())) return "—"; const mm = String(d.getMonth() + 1).padStart(2, "0"); const dd = String(d.getDate()).padStart(2, "0"); const hh = String(d.getHours()).padStart(2, "0"); const mi = String(d.getMinutes()).padStart(2, "0"); return `${mm}/${dd} ${hh}:${mi}`; } function formatDayLabel(dateStr: string, fallback: string) { // trend.daily.date 多为 YYYY-MM-DD;label 可能已是短标签 const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(dateStr); if (m) return `${m[2]}/${m[3]}`; return fallback || dateStr; } // 联系充值弹窗(对齐设计稿:对公转账联系财务) function ContactRechargeModal({ open, close }: { open: boolean; close: () => void }) { useBodyScrollLock(open); const { mounted, show } = useOverlayTransition(open, close); if (!mounted) return null; return createPortal(
e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="billingContactTitle" >

联系充值

平台当前通过对公转账充值,请联系财务确认金额与到账信息。

财务联系人专属客户经理
联系电话待平台配置
企业微信待平台配置
, document.body, ); } export function AccountPage({ billing, projects: _projects, team, onRecharge: _onRecharge, onNotify }: { billing: BillingSummary | null; projects: Project[]; team?: Team | null; navigate: NavigateFn; onRecharge: (amount: number, bonus: number) => void | Promise; onNotify: (type: "success" | "error" | "info", text: string) => void; }) { const months = useMemo(() => monthOptions(4), []); const [selectedMonth, setSelectedMonth] = useState(months[0]?.value ?? ""); const [ledgerMonth, setLedgerMonth] = useState(months[0]?.value ?? ""); const [featureFilter, setFeatureFilter] = useState("all"); const [threshold, setThreshold] = useState(() => { const saved = Number(localStorage.getItem(THRESHOLD_KEY)); return Number.isFinite(saved) && saved > 0 ? saved : 100000; }); const [rechargeOpen, setRechargeOpen] = useState(false); const [reloadFlag, setReloadFlag] = useState(0); const [trend, setTrend] = useState(null); const [teamMembers, setTeamMembers] = useState([]); const [freshTeam, setFreshTeam] = useState(null); useEffect(() => { let alive = true; api.billingTrend(undefined, selectedMonth || undefined).then((d) => { if (alive) setTrend(d); }).catch(() => {}); return () => { alive = false; }; }, [reloadFlag, selectedMonth]); useEffect(() => { let alive = true; api.teamMembers().then((m) => { if (alive) setTeamMembers(m); }).catch(() => {}); api.teamSettings().then((t) => { if (alive) setFreshTeam(t); }).catch(() => {}); return () => { alive = false; }; }, [reloadFlag]); const BILLS_PER_PAGE = 10; const [billPage, setBillPage] = useState(1); const [billType, setBillType] = 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, month: ledgerMonth || undefined, }).then((data) => { if (!alive) return; setLedgerRows(data.results); setLedgerCount(data.count); }).catch(() => {}).finally(() => { if (alive) setLedgersLoading(false); }); return () => { alive = false; }; }, [billPage, billType, ledgerMonth, reloadFlag]); const [billJump, setBillJump] = useState(""); const balance = Number(billing?.account.balance || 0); const used = Number(billing?.charged_total || 0); 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)); void limit; // 保留限额计算,设计稿本页不展示进度条 const balanceOk = balance >= threshold; const balanceState = balanceOk ? "当前余额充足" : "余额低于提醒阈值"; useEffect(() => { localStorage.setItem(THRESHOLD_KEY, String(threshold)); }, [threshold]); // 近七日消耗 const sevenDay = useMemo(() => { const daily = trend?.daily ?? []; const slice = daily.slice(-7); const peak = Math.max(0, ...slice.map((d) => Number(d.amount) || 0)); const total = slice.reduce((s, d) => s + (Number(d.amount) || 0), 0); // 较前 7 日:若有 14 天数据则对比前半段 let trendPct: number | null = null; if (daily.length >= 14) { const prev = daily.slice(-14, -7); const prevTotal = prev.reduce((s, d) => s + (Number(d.amount) || 0), 0); if (prevTotal > 0) trendPct = Math.round(((total - prevTotal) / prevTotal) * 100); } return { slice, peak, total, trendPct }; }, [trend]); // 积分消耗分布:查询月内有扣费的 task_type 全部展示(不截断),金额降序 const distribution = useMemo(() => { const by = trend?.by_task_type || {}; const rows = Object.entries(by) .map(([type, amount]) => ({ type, label: taskTypeLabel(type), amount: Number(amount) || 0 })) .filter((r) => r.amount > 0) .sort((a, b) => b.amount - a.amount); const total = rows.reduce((s, r) => s + r.amount, 0); const max = Math.max(0, ...rows.map((r) => r.amount), 0); return { rows, total, max }; }, [trend]); // 流水:类型筛映射 ledger_type;月份按 created_at 过滤当前页 const LEDGER_TYPE_FILTER: Record = { all: "all", charge: "charge", recharge: "recharge", reserve: "reserve", release: "release", adjustment: "adjustment", refund: "refund", }; useEffect(() => { const next = LEDGER_TYPE_FILTER[featureFilter] ?? "all"; setBillType(next); setBillPage(1); }, [featureFilter]); // 月份/类型已在服务端过滤,当前页结果直接展示 const visibleLedgers = ledgerRows; 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(""); } async function exportLedgers() { const pageSize = 100; const all: Ledger[] = []; let page = 1; let total = Infinity; try { while (all.length < total) { const data = await api.ledgers(page, pageSize, { ledger_type: billType === "all" ? undefined : billType, month: ledgerMonth || undefined, }); total = data.count; all.push(...data.results); if (data.results.length === 0) break; page += 1; if (page > 500) break; // 安全上限 } } catch { onNotify("error", "导出失败,请稍后重试"); return; } if (all.length === 0) { onNotify("info", "当前没有可导出的流水"); return; } const header = ["功能", "消耗位置", "成员", "时间", "消耗积分"]; const lines = all.map((l) => { const amt = Number(l.amount); const signed = (amt > 0 && l.ledger_type === "recharge") || l.ledger_type === "release" || l.ledger_type === "refund" ? `+${pts(Math.abs(amt))}` : `-${pts(Math.abs(amt))}`; return [ ledgerFeatureLabel(l), ledgerLocationLabel(l).replace(/,/g, ","), l.user_label || "系统", formatLedgerTime(l.created_at), signed, ].join(","); }); const blob = new Blob([[header.join(","), ...lines].join("\n")], { type: "text/csv;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `积分消耗流水-${ledgerMonth || "all"}.csv`; a.click(); URL.revokeObjectURL(url); onNotify("success", `已导出 ${all.length} 条积分消耗流水`); } const monthUsageDisplay = trend?.month_charged != null ? Number(trend.month_charged) : (selectedMonth === months[0]?.value ? used : null); return (

账户中心

查看积分余额、消耗趋势与使用明细

当前积分余额 积分账户
{pts(balance)}积分
{formatMonthLabel(selectedMonth)}已消耗 {monthUsageDisplay == null ? "—" : `${pts(monthUsageDisplay)} 积分`}
低于 { const n = Number(e.target.value); if (Number.isFinite(n) && n > 0) setThreshold(Math.floor(n)); }} /> 积分
{balanceState}

近七日消耗

每日更新,仅保留最近 7 天
{sevenDay.trendPct != null && ( = 0 ? "billing-trend-positive" : "billing-trend-negative"}> 较前7日 {sevenDay.trendPct >= 0 ? "+" : ""}{sevenDay.trendPct}% )} 共 {pts(sevenDay.total)} 积分
{sevenDay.slice.length === 0 ? (
暂无消耗记录
) : sevenDay.slice.map((d) => { const amt = Number(d.amount) || 0; const h = sevenDay.peak > 0 ? Math.max(amt > 0 ? 8 : 4, Math.round((amt / sevenDay.peak) * 100)) : 8; return (
{amt > 0 ? pts(Math.round(amt)) : "0"}
{formatDayLabel(d.date, d.label)}
); })}

积分消耗分布

按任务类型统计{formatMonthLabel(selectedMonth)}消耗
{pts(distribution.total)} 积分
{distribution.rows.length === 0 ? (
暂无任务类型消耗
) : distribution.rows.map((r) => { // 相对本月最大项 = 100%,不做 4% 保底(否则 120/100/10 会看起来一样长) const w = distribution.max > 0 ? Math.min(100, Math.round((r.amount / distribution.max) * 100)) : 0; return (
{r.label}
{Number(r.amount).toLocaleString("zh-CN")}
); })}

积分消耗流水

每次积分消耗均可追溯到具体任务
{ setLedgerMonth(v); setBillPage(1); }} options={months.map((m) => ({ value: m.value, label: m.label }))} />
功能 消耗位置 成员与时间 消耗积分
{ledgersLoading && visibleLedgers.length === 0 ? (
加载中… 正在拉取积分消耗流水
) : visibleLedgers.length === 0 ? (
暂无积分消耗 当前月份和功能下没有匹配的流水记录
) : visibleLedgers.map((l) => { const amt = Number(l.amount) || 0; const isCredit = l.ledger_type === "recharge" || l.ledger_type === "release" || l.ledger_type === "refund" || (amt > 0 && l.ledger_type !== "charge" && l.ledger_type !== "reserve"); const costText = `${isCredit ? "+" : "−"}${pts(Math.abs(amt))}`; return (
{ledgerFeatureLabel(l)}
{ledgerLocationLabel(l)} {ledgerTypeLabel(l.ledger_type)}{l.reason ? ` · ${ledgerReasonLabel(l.reason)}` : ""}
{l.user_label || "系统"} {formatLedgerTime(l.created_at)}
{costText}
); })}
{ledgerCount > BILLS_PER_PAGE && (
共 {ledgerCount} 条 · 第 {safeBillPage} / {billTotalPages} 页
{pageWindow(safeBillPage, billTotalPages).map((p, i) => ( p === "ellipsis" ? : ))}
跳至 setBillJump(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") gotoBillPage(); }} aria-label="跳至页码" /> 页
)}
setRechargeOpen(false)} />
); }