渐进渲染后,列表在接口返回前会短暂显示「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>
360 lines
19 KiB
TypeScript
360 lines
19 KiB
TypeScript
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>
|
||
);
|
||
}
|