Files
yingqing/core/frontend/src/routes/account.tsx
T
zycandClaude Opus 4.8 7c6d4477da feat(core): 列表数据在途显示骨架屏(加载态),不再先显示空状态
渐进渲染后,列表在接口返回前会短暂显示「0 SKU / 显示 0」的空状态,易被误读为「没数据」。
新增 SkeletonGrid/SkeletonRows(components/loading),区分「加载中(请求在途且无数据)」与
「加载完确实为空」:

- 全局态页(商品库/视频项目/工作台最近项目):App 传 loading={!dataLoaded},未加载完显示骨架。
- 自取页(资产库/商品详情AI素材/图片生成任务/账单流水):各自 loading 标志,首次拉取时显示骨架/加载行。
- 团队成员有 owner 兜底行(非空)不改。

闸:9 页全绿、功能正常;实测 /products 加载期显示骨架→数据到后渲染真实卡片。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:37:07 +08:00

360 lines
19 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, useState } from "react";
import { api } from "../api";
import type { BillingSummary, BillingTrend, Ledger, Project, TeamMember } from "../types";
import { money } from "./stage-config";
import { pageWindow } from "../components/pager";
const ROLE_LABEL: Record<string, string> = { owner: "超管", admin: "团管", member: "成员", viewer: "访客" };
const STATUS_LABEL: Record<string, string> = { active: "活跃", invited: "待激活", disabled: "已停用" };
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: "月均" }
};
type Tab = "overview" | "by-project" | "by-member" | "bills";
// 账单类型 / 详情 中文化(后端历史英文流水也一并映射;未知值原样透出)
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;
// 分页页码窗口:页数多时折叠成 1 … 当前±1 … 末页(≤7 页则全展开)
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 }
];
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" }
];
export function AccountPage({ billing, projects, onRecharge }: {
billing: BillingSummary | null;
projects: Project[];
onRecharge: (amount: number, bonus: number) => void | Promise<unknown>;
}) {
const [tab, setTab] = useState<Tab>("overview");
const [recharge, setRecharge] = useState(500);
const [customAmt, setCustomAmt] = useState("");
// 账户页数据本页自取(不再走全局 bootstrap):充值后 bump 刷新流水/趋势
const [reloadFlag, setReloadFlag] = useState(0);
// 趋势(day,首屏)+ 团队成员(成员/限额 tab)本页懒加载
const [trend, setTrend] = useState<BillingTrend | null>(null);
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
useEffect(() => {
let alive = true;
api.billingTrend().then((d) => { if (alive) setTrend(d); }).catch(() => {});
api.teamMembers().then((m) => { if (alive) setTeamMembers(m); }).catch(() => {});
return () => { alive = false; };
}, [reloadFlag]);
// 账单流水分页:服务端分页(总数随流水增长,不再写死 100),每页 10 条
const BILLS_PER_PAGE = 10;
const [billPage, setBillPage] = useState(1);
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).then((data) => {
if (!alive) return;
setLedgerRows(data.results);
setLedgerCount(data.count);
}).catch(() => {}).finally(() => { if (alive) setLedgersLoading(false); });
return () => { alive = false; };
}, [billPage, reloadFlag]);
const billTotalPages = Math.max(1, Math.ceil(ledgerCount / BILLS_PER_PAGE));
const safeBillPage = Math.min(billPage, billTotalPages);
const selectedCard = RECHARGE.find((item) => item.amt === recharge);
const effectiveAmount = Number(customAmt) > 0 ? Number(customAmt) : recharge;
const effectiveBonus = Number(customAmt) > 0 ? 0 : selectedCard?.bonusAmt || 0;
async function submitRecharge() {
if (effectiveAmount <= 0) return;
await onRecharge(effectiveAmount, effectiveBonus);
setCustomAmt("");
setBillPage(1);
setReloadFlag((n) => n + 1); // 充值后刷新流水/趋势(余额由全局 action→loadData 刷新)
}
const balance = Number(billing?.account.balance || 0);
const used = Number(billing?.charged_total || 0);
const memberLimit = teamMembers.reduce((sum, m) => sum + Math.max(0, Number(m.monthly_credit_limit || 0)), 0);
const limit = memberLimit || 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<TrendRange>("day");
const [rangeTrend, setRangeTrend] = useState<BillingTrend | null>(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);
return (
<section className="account-page">
<div className="page-head">
<div>
<h1>消费</h1>
<div className="sub"><span className="mono">// 余额 · 充值 · 4 维消费视图 + 账单流水</span></div>
</div>
</div>
<div className="top-grid">
<div className="balance-banner">
<span className="corner-tr" aria-hidden="true"></span><span className="corner-bl" aria-hidden="true"></span>
<div className="balance-hero">
<div className="lbl">团队余额</div>
<div className="v">{money(balance)}</div>
<div className="meta">// 充值累加 · 不重置</div>
</div>
<div className="balance-sub">
<div className="col">
<div className="lbl">本月限额</div>
<div className="v">{money(limit)}</div>
<div className="meta">// 按自然月重置</div>
</div>
<div className="col">
<div className="lbl">当月已用</div>
<div className="v">{money(used)}</div>
<div className="meta">// 占比 {pct.toFixed(1)}% · {pct >= 80 ? "注意" : "健康"}</div>
</div>
</div>
<div className="balance-foot">
<div className="balance-meter" aria-label={`本月额度使用率 ${pct.toFixed(1)}%`}><span style={{ width: `${pct}%` }} /></div>
<div className="balance-foot-meta">
<span>团队月剩余 {money(left)}</span>
<span>使用率 {pct.toFixed(1)}%</span>
</div>
</div>
</div>
<div className="pane topup-pane">
<div className="topup-head">
<div>
<h3>快速充值</h3>
<div className="desc">// 充值后立刻到账,可开发票 · 仅超管可操作</div>
</div>
<div className="topup-selected">已选 ¥{effectiveAmount}{effectiveBonus > 0 ? ` + ¥${effectiveBonus} 赠送` : ""}</div>
</div>
<div className="recharge-row">
{RECHARGE.map((item) => (
<div
key={item.amt}
className={`recharge-card ${recharge === item.amt ? "selected" : ""}`}
role="button"
tabIndex={0}
aria-pressed={recharge === item.amt}
onClick={() => setRecharge(item.amt)}
>
{item.ribbon && <span className="ribbon">{item.ribbon}</span>}
<div className="amt">¥{item.amt}</div>
<div className={`gift ${item.bonus ? "bonus" : ""}`}>{item.gift}</div>
</div>
))}
</div>
<div className="pay-row">
<div className="pay-title">自定义金额</div>
<input className="input" placeholder="最低 ¥50,可输入任意金额" type="number" value={customAmt} onChange={(event) => setCustomAmt(event.target.value)} />
<div className="pay-btn-row">
<button className="btn pay-method-btn pay-wechat" type="button" aria-label="微信支付" onClick={submitRecharge}>
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-wechat.png" alt="" /></span>
微信支付
</button>
<button className="btn pay-method-btn pay-alipay" type="button" aria-label="支付宝" onClick={submitRecharge}>
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-alipay.png" alt="" /></span>
支付宝
</button>
</div>
</div>
</div>
</div>
<div className="billing-tabs" role="tablist">
<button className={`tab ${tab === "overview" ? "active" : ""}`} type="button" onClick={() => setTab("overview")}>总览</button>
<button className={`tab ${tab === "by-project" ? "active" : ""}`} type="button" onClick={() => setTab("by-project")}>项目 <span className="count">{projects.length}</span></button>
<button className={`tab ${tab === "by-member" ? "active" : ""}`} type="button" onClick={() => setTab("by-member")}>成员 <span className="count">{teamMembers.length}</span></button>
<button className={`tab ${tab === "bills" ? "active" : ""}`} type="button" onClick={() => setTab("bills")}>账单流水 <span className="count">{ledgerCount}</span></button>
</div>
<div className={`tab-panel ${tab === "overview" ? "active" : ""}`}>
<div className="overview-grid">
<div className="pane trend-pane">
<div className="trend-head">
<h3>消费趋势</h3>
<span className="sub">{meta.sub}</span>
<span className="spacer"></span>
{(["day", "week", "month"] as TrendRange[]).map((r) => (
<button key={r} className={`chip${range === r ? " active" : ""}`} type="button" onClick={() => setRange(r)}>{RANGE_META[r].chip}</button>
))}
</div>
<div className="trend-chart">
<div className="bars">
{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 (
<div className={`bar${isPeak ? " peak" : ""}`} key={d.date} title={`${d.label} · ${money(amt)}`}>
<span style={{ height: `${h}%` }} />
</div>
);
})}
</div>
<div className="x-axis">
{daily.map((d, i) => (
<span key={d.date}>{i % 2 === 0 ? d.label : ""}</span>
))}
</div>
</div>
<div className="trend-foot">
<div className="item"><span className="k">{meta.totalLabel}</span><span className="v">{money(total14)}</span></div>
<div className="item"><span className="k">{meta.avgLabel}</span><span className="v">{money(avgValue)}</span></div>
<div className="item"><span className="k">峰值</span><span className="v warn">{money(peak)}</span></div>
</div>
</div>
<div className="pane stage-pane">
<h3>本月按阶段分布</h3>
<div className="desc">// PRD §5.3.5 扣费规则 · 仅确认后扣</div>
{STAGES.map((s) => {
const amt = Number(trend?.by_stage[s.bucket] || 0);
const w = stageTotal > 0 ? Math.min(100, (amt / stageTotal) * 100) : 0;
return (
<div key={s.k}>
<div className="usage-line"><span className="k">{s.k}</span><span className="v">{money(amt)}</span></div>
<div className="usage-bar"><span style={{ width: `${w}%`, background: s.color }} /></div>
</div>
);
})}
<div className="total"><span>合计</span><span className="v">{money(used)}</span></div>
</div>
</div>
<div className="pane rule-pane" style={{ marginTop: 16 }}>
<h3>扣费 + 四层额度预检规则</h3>
<div className="desc">// PRD §5.3.5 + §10.3 · 对接团队请以此页为准</div>
<div className="rule-list">
<strong>① 失败不扣</strong>:模型超时 / 内容审核拦截 / 生成异常一律不扣费。<br />
<strong>② 用户重跑不扣首次</strong>:第一次重跑保留原扣费,第二次起按次结算。<br />
<strong>③ 仅在你点击 <span className="mono-acc">[ 确认通过 ]</span> 时入账</strong>。<br />
<strong>④ 导出不再扣费</strong>,所有 token 已在过程中结算。
</div>
<div className="quota-rules">
<div className="qr-head">// 任务确认前 · 四层额度预检(任一不通过即拦截)</div>
<div className="step"><span className="num">1</span><span><strong>个人日剩余</strong> ≥ 任务预估 × <span className="formula">1.2</span></span></div>
<div className="step"><span className="num">2</span><span><strong>个人月剩余</strong> ≥ 同上</span></div>
<div className="step"><span className="num">3</span><span><strong>团队月剩余</strong> ≥ 同上</span></div>
<div className="step"><span className="num">4</span><span><strong>团队总余额</strong> ≥ 同上</span></div>
</div>
</div>
</div>
<div className={`tab-panel ${tab === "bills" ? "active" : ""}`}>
<div className="billing-table-wrap">
<table className="billing-table">
<thead><tr><th>时间</th><th>项目 / 类型</th><th>详情</th><th>成员</th><th>状态</th><th style={{ textAlign: "right" }}>金额</th></tr></thead>
<tbody>
{ledgersLoading && ledgerRows.length === 0 ? (
<tr><td colSpan={6} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>// 加载中…</td></tr>
) : ledgerRows.length === 0 ? (
<tr><td colSpan={6} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>// 暂无账单流水</td></tr>
) : ledgerRows.map((l) => (
<tr key={l.id}>
<td className="ts">{new Date(l.created_at).toLocaleString("zh-CN")}</td>
<td>{ledgerTypeLabel(l.ledger_type)}</td>
<td className="muted">{ledgerReasonLabel(l.reason)}</td>
<td>{l.user_label
? <span className="who"><span className="av">{l.user_label.slice(0, 1).toUpperCase()}</span>{l.user_label}</span>
: <span className="sys">系统</span>}</td>
<td><span className="status-tag ok">成功</span></td>
<td className="neg">{l.amount}</td>
</tr>
))}
</tbody>
</table>
</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>
</div>
)}
</div>
<div className={`tab-panel ${tab === "by-project" ? "active" : ""}`}>
<div className="billing-table-wrap">
<table className="billing-table">
<thead><tr><th>项目</th><th>当前阶段</th><th>状态</th><th style={{ textAlign: "right" }}>消耗</th></tr></thead>
<tbody>
{projects.map((p) => {
const spend = projectSpend(p.id);
return (
<tr key={p.id}><td>{p.name}</td><td>{p.current_stage}</td><td>{p.status}</td><td className={spend > 0 ? "neg" : "zero"}>{money(spend)}</td></tr>
);
})}
</tbody>
</table>
</div>
</div>
<div className={`tab-panel ${tab === "by-member" ? "active" : ""}`}>
<div className="billing-table-wrap">
<table className="billing-table">
<thead><tr><th>成员</th><th>角色</th><th>已用 / 月度额度</th><th>状态</th></tr></thead>
<tbody>
{teamMembers.map((m) => {
const monthly = Number(m.monthly_credit_limit || 0);
return (
<tr key={m.id}><td className="who"><span className="av">{m.user.username.slice(0, 1).toUpperCase()}</span>{m.user.username}</td><td>{ROLE_LABEL[m.role] || m.role}</td><td className="quota"><span className="used">{money(m.month_charged || 0)}</span> <span className="lim">/ {monthly > 0 ? money(monthly) : "不限"}</span></td><td>{STATUS_LABEL[m.status] || m.status}</td></tr>
);
})}
</tbody>
</table>
</div>
</div>
</section>
);
}