Files
yingqing/core/frontend/src/routes/account.tsx
T
2026-09-07 10:46:00 +08:00

546 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
recharge: "充值", reserve: "预扣", release: "释放", charge: "扣费", adjustment: "调整", refund: "退款"
};
const LEDGER_REASON_LABEL: Record<string, string> = {
"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<string, string> = {
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 <Clapperboard {...props} />;
case "wand": return <WandSparkles {...props} />;
case "replace": return <Replace {...props} />;
case "film": return <Film {...props} />;
case "scan": return <ScanText {...props} />;
case "image": return <ImageIcon {...props} />;
default: return <ReceiptText {...props} />;
}
}
function featureIconForLedger(ledger: Ledger): string {
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 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(
<div className={`billing-contact-backdrop${show ? " active" : ""}`} onClick={close}>
<div
className="billing-contact-dialog"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="billingContactTitle"
>
<div className="billing-contact-head">
<div>
<h2 id="billingContactTitle">联系充值</h2>
<p>平台当前通过对公转账充值,请联系财务确认金额与到账信息。</p>
</div>
<button type="button" className="billing-contact-close" aria-label="关闭充值联系方式" onClick={close}>
<X size={16} />
</button>
</div>
<div className="billing-contact-card">
<div className="billing-contact-line"><span>财务联系人</span><strong>专属客户经理</strong></div>
<div className="billing-contact-line"><span>联系电话</span><strong>待平台配置</strong></div>
<div className="billing-contact-line"><span>企业微信</span><strong>待平台配置</strong></div>
</div>
</div>
</div>,
document.body,
);
}
export function AccountPage({ billing, projects: _projects, team, onRecharge: _onRecharge, onNotify: _onNotify }: {
billing: BillingSummary | null;
projects: Project[];
team?: Team | null;
navigate: NavigateFn;
onRecharge: (amount: number, bonus: number) => void | Promise<unknown>;
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<BillingTrend | null>(null);
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
const [freshTeam, setFreshTeam] = useState<Team | null>(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<string>("all");
const [ledgerRows, setLedgerRows] = useState<Ledger[]>([]);
const [ledgerCount, setLedgerCount] = useState<number>(0);
const [ledgersLoading, setLedgersLoading] = useState(true);
useEffect(() => {
let alive = true;
setLedgersLoading(true);
api.ledgers(billPage, BILLS_PER_PAGE, {
ledger_type: billType === "all" ? undefined : billType,
}).then((data) => {
if (!alive) return;
setLedgerRows(data.results);
setLedgerCount(data.count);
}).catch(() => {}).finally(() => { if (alive) setLedgersLoading(false); });
return () => { alive = false; };
}, [billPage, billType, 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<string, string> = {
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 = useMemo(() => {
if (!ledgerMonth) return ledgerRows;
return ledgerRows.filter((l) => (l.created_at || "").startsWith(ledgerMonth));
}, [ledgerRows, ledgerMonth]);
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("");
}
function exportLedgers() {
if (visibleLedgers.length === 0) {
onNotify("info", "当前没有可导出的流水");
return;
}
const header = ["功能", "消耗位置", "成员", "时间", "消耗积分"];
const lines = visibleLedgers.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 [
ledgerTypeLabel(l.ledger_type),
ledgerReasonLabel(l.reason).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", "已导出积分消耗流水");
}
const monthUsageDisplay = trend?.month_charged != null ? Number(trend.month_charged) : (selectedMonth === months[0]?.value ? used : null);
return (
<section className="account-page billing-page">
<div className="ac-inner">
<header className="page-header">
<div className="page-heading">
<h1>账户中心</h1>
<p>查看积分余额、消耗趋势与使用明细</p>
</div>
</header>
<div className="billing-overview">
<section className="billing-account-card" aria-label="积分账户概览">
<div className="billing-card-kicker">
<span>当前积分余额</span>
<span>积分账户</span>
</div>
<strong className="billing-balance-number">
{pts(balance)}<small>积分</small>
</strong>
<div className="billing-month-control">
<div className="billing-month-copy">
<span>{formatMonthLabel(selectedMonth)}已消耗</span>
<strong>
{monthUsageDisplay == null ? "—" : `${pts(monthUsageDisplay)} 积分`}
</strong>
</div>
<label className="billing-month-picker">
<CustomSelect
size="sm"
className="billing-month-select"
aria-label="选择查看月份"
value={selectedMonth}
onChange={setSelectedMonth}
options={months.map((m) => ({ value: m.value, label: m.label }))}
/>
</label>
</div>
<div className="billing-threshold-row">
<label htmlFor="billingThreshold">
<BellRing size={15} strokeWidth={2} />
<span>余额提醒</span>
</label>
<div className="billing-threshold-input">
<span>低于</span>
<input
type="number"
id="billingThreshold"
min={1}
step={1000}
value={threshold}
aria-label="余额提醒积分额度"
onChange={(e) => {
const n = Number(e.target.value);
if (Number.isFinite(n) && n > 0) setThreshold(Math.floor(n));
}}
/>
<span>积分</span>
</div>
</div>
<div className="billing-card-actions">
<span className={`billing-balance-state${balanceOk ? "" : " is-low"}`}>{balanceState}</span>
<button type="button" className="billing-contact-trigger" onClick={() => setRechargeOpen(true)}>充值</button>
</div>
</section>
<section className="data-panel billing-chart-panel">
<div className="panel-head">
<div>
<h2>近七日消耗</h2>
<span className="billing-panel-meta">每日更新,仅保留最近 7 </span>
</div>
<div className="billing-chart-summary">
{sevenDay.trendPct != null && (
<span className={sevenDay.trendPct >= 0 ? "billing-trend-positive" : "billing-trend-negative"}>
较前7 {sevenDay.trendPct >= 0 ? "+" : ""}{sevenDay.trendPct}%
</span>
)}
<span className="tag"> {pts(sevenDay.total)} 积分</span>
</div>
</div>
<div className="billing-seven-day-chart" aria-label="近七日每日积分消耗量">
{sevenDay.slice.length === 0 ? (
<div className="billing-chart-empty">暂无消耗记录</div>
) : 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 (
<div className="billing-day-bar" key={d.date} title={`${d.label} · ${pts(amt)}`}>
<span className="billing-day-value">{amt > 0 ? pts(Math.round(amt)) : "0"}</span>
<div className="billing-day-track"><span style={{ height: `${h}%` }} /></div>
<span className="billing-day-label">{formatDayLabel(d.date, d.label)}</span>
</div>
);
})}
</div>
</section>
<section className="data-panel billing-distribution">
<div className="panel-head">
<div>
<h2>积分消耗分布</h2>
<span className="billing-panel-meta">按任务类型统计{formatMonthLabel(selectedMonth)}消耗</span>
</div>
<span className="tag">{pts(distribution.total)} 积分</span>
</div>
<div className="stage-list">
{distribution.rows.length === 0 ? (
<div className="billing-dist-empty">暂无任务类型消耗</div>
) : 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 (
<div className="stage-usage" key={r.type} data-billing-feature={r.type} title={r.type}>
<span>{r.label}</span>
<div className="billing-usage-line">
<span className="usage-fill" style={{ width: `${w}%` }} />
</div>
<strong>{Number(r.amount).toLocaleString("zh-CN")}</strong>
</div>
);
})}
</div>
</section>
</div>
<section className="data-panel billing-ledger">
<div className="panel-head">
<div>
<h2>积分消耗流水</h2>
<span className="billing-panel-meta">每次积分消耗均可追溯到具体任务</span>
</div>
<div className="billing-ledger-toolbar">
<CustomSelect
size="sm"
className="billing-ledger-filter"
aria-label="按功能筛选积分消耗流水"
value={featureFilter}
onChange={setFeatureFilter}
options={[
{ value: "all", label: "类型:全部" },
{ value: "charge", label: "扣费" },
{ value: "recharge", label: "充值" },
{ value: "reserve", label: "预扣" },
{ value: "release", label: "释放" },
{ value: "adjustment", label: "调整" },
{ value: "refund", label: "退款" },
]}
/>
<CustomSelect
size="sm"
className="billing-ledger-filter"
aria-label="按月份筛选积分消耗流水"
value={ledgerMonth}
onChange={(v) => { setLedgerMonth(v); setBillPage(1); }}
options={months.map((m) => ({ value: m.value, label: m.label }))}
/>
<button className="compact-action billing-ledger-export" type="button" onClick={exportLedgers}>
<Download size={16} strokeWidth={2} />
导出流水
</button>
</div>
</div>
<div className="billing-ledger-list">
<div className="billing-ledger-row header">
<span>功能</span>
<span>消耗位置</span>
<span>成员与时间</span>
<span style={{ textAlign: "right" }}>消耗积分</span>
</div>
{ledgersLoading && visibleLedgers.length === 0 ? (
<div className="billing-ledger-empty">
<ReceiptText size={28} />
<strong>加载中…</strong>
<span>正在拉取积分消耗流水</span>
</div>
) : visibleLedgers.length === 0 ? (
<div className="billing-ledger-empty">
<ReceiptText size={28} />
<strong>暂无积分消耗</strong>
<span>当前月份和功能下没有匹配的流水记录</span>
</div>
) : 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 (
<div className="billing-ledger-row" key={l.id} data-ledger-month={(l.created_at || "").slice(0, 7)} data-ledger-feature={l.ledger_type}>
<div className="billing-ledger-feature">
<span className="billing-ledger-icon"><FeatureIcon name={featureIconForLedger(l)} /></span>
<strong>{ledgerTypeLabel(l.ledger_type)}</strong>
</div>
<div className="billing-ledger-copy">
<strong title={ledgerReasonLabel(l.reason)}>{ledgerReasonLabel(l.reason)}</strong>
<span>{l.ledger_type === "recharge" ? "账户充值" : "积分变动 · 可追溯流水"}</span>
</div>
<div className="billing-ledger-meta">
<strong>{l.user_label || "系统"}</strong>
<span>{formatLedgerTime(l.created_at)}</span>
</div>
<strong className={`billing-ledger-cost${isCredit ? " is-credit" : ""}`}>{costText}</strong>
</div>
);
})}
</div>
{ledgerCount > BILLS_PER_PAGE && (
<div className="bill-pager">
<span className="total"> {ledgerCount} · {safeBillPage} / {billTotalPages} </span>
<div className="pages">
<button type="button" disabled={safeBillPage <= 1} onClick={() => setBillPage(safeBillPage - 1)}>上一页</button>
{pageWindow(safeBillPage, billTotalPages).map((p, i) => (
p === "ellipsis"
? <span key={`e${i}`} className="ellipsis"></span>
: <button key={p} className={p === safeBillPage ? "active" : ""} type="button" onClick={() => setBillPage(p)}>{p}</button>
))}
<button type="button" disabled={safeBillPage >= billTotalPages} onClick={() => setBillPage(safeBillPage + 1)}>下一页</button>
</div>
<span className="bill-jump">
跳至
<input
type="number" min={1} max={billTotalPages} inputMode="numeric"
value={billJump} placeholder={String(safeBillPage)}
onChange={(e) => setBillJump(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") gotoBillPage(); }}
aria-label="跳至页码"
/>
<button type="button" disabled={!billJump.trim()} onClick={gotoBillPage}>跳转</button>
</span>
</div>
)}
</section>
</div>
<ContactRechargeModal
open={rechargeOpen}
close={() => setRechargeOpen(false)}
/>
</section>
);
}