Files
yingqing/core/frontend/src/routes/account.tsx
T

626 lines
34 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 { 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<string, string> = { owner: "超管", admin: "团管", member: "成员", viewer: "访客" };
const STATUS_LABEL: Record<string, string> = { active: "活跃", invited: "待激活", disabled: "已停用" };
// 角色 pill 样式键(对齐 V1 .role-super / .role-admin / .role-member)
const ROLE_PILL: Record<string, string> = { owner: "role-super", admin: "role-admin", member: "role-member", viewer: "role-member" };
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";
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<string, number> = { 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<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;
// 积分制: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<unknown>;
}) {
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(
<div className={`modal-bg${show ? " show" : ""}`} onClick={close}>
<div className="modal topup-modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><CreditCard size={16} /></div>
<div className="ti">扫码支付<span>// {channelLabel}</span></div>
<button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button>
</div>
<div className="modal-b">
<div className="topup-info">支付金额</div>
<div className="topup-amt">{yuan(amount)}</div>
<div className="topup-note">{`// 到账 ${money(Math.round(amount * rate + bonus))}`}{bonus > 0 ? `(含 ${bonus} 积分赠送)` : ""}</div>
<div className="topup-qr" aria-label="二维码占位">
<div className="center">{scanLabel}<br /><span className="ref">{txRef}</span></div>
</div>
<div className="topup-valid">// 5 分钟内有效 · 到账后自动关闭</div>
<div className="topup-valid">// 积分不可提现、不可转让 · 长期有效 · 发票按实际支付金额开具</div>
</div>
<div className="modal-f">
<button className="btn" type="button" onClick={close}>取消</button>
<button className="btn btn-primary" type="button" onClick={() => void onDone()}>已完成支付</button>
</div>
</div>
</div>,
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<unknown>;
onNotify: (type: "success" | "error" | "info", text: string) => void;
}) {
const [tab, setTab] = useState<Tab>("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<TopupQuote | null>(null);
// 趋势(day,首屏)+ 团队成员(成员/限额 tab)本页懒加载
const [trend, setTrend] = useState<BillingTrend | null>(null);
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
// 团队级月限额本页自取最新值:team prop 来自登录时 /me/ 快照,团队页改完月限额后不刷新会陈旧,
// 造成本页「本月限额」与团队页「月限额」对不上(R72)
const [freshTeam, setFreshTeam] = useState<Team | null>(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<string>("all");
const [billMember, setBillMember] = 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,
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<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);
// ── 项目筛选(状态;真实数据无 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 (
<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}(到账 {Math.round(effectiveAmount * pointsRate + effectiveBonus)} 积分){effectiveBonus > 0 ? ` · 含 ${effectiveBonus} 积分赠送` : ""}</div>
</div>
<div className="recharge-row">
{RECHARGE.map((item) => (
<div
key={item.amt}
className={`recharge-card ${recharge === item.amt && !customAmt ? "selected" : ""}`}
role="button"
tabIndex={0}
aria-pressed={recharge === item.amt && !customAmt}
onClick={() => { setRecharge(item.amt); setCustomAmt(""); }}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setRecharge(item.amt); setCustomAmt(""); } }}
>
{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${!customAmountIsValid ? " is-invalid" : ""}`}
placeholder="最低 ¥50,可输入任意金额"
type="number"
min={MIN_RECHARGE_AMOUNT}
step="1"
inputMode="numeric"
value={customAmt}
aria-invalid={!customAmountIsValid}
aria-describedby="custom-recharge-error"
onChange={(event) => setCustomIntegerAmount(event.target.value)}
/>
{!customAmountIsValid && <div className="field-hint is-error" id="custom-recharge-error" role="alert">充值金额不能低于 {MIN_RECHARGE_AMOUNT} </div>}
<div className="pay-btn-row">
<button className="btn pay-method-btn pay-wechat" type="button" aria-label="微信支付" onClick={() => openTopup("wechat")}>
<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={() => openTopup("alipay")}>
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-alipay.png" alt="" /></span>
支付宝
</button>
</div>
</div>
<div className="tip" style={{ marginTop: 14 }}>
<strong>计费说明</strong>
1 = 10 积分。脚本/文案 10 积分/;图片生成 20 积分/;配音 10 积分/500 ;
视频按时长与分辨率计量、按实际用量结算( 15 720P 竖屏约 224 积分),多退少不补超;
拼接导出免费。生成失败、超时均不扣费,自动退回预留积分。
</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="filter-bar">
<select value={billType} onChange={(e) => { setBillType(e.target.value); setBillPage(1); }} aria-label="按类型筛选">
<option value="all">全部类型</option>
<option value="charge">扣费</option>
<option value="recharge">充值</option>
<option value="reserve">预扣</option>
<option value="release">释放</option>
<option value="adjustment">调整</option>
<option value="refund">退款</option>
</select>
<select value={billMember} onChange={(e) => { setBillMember(e.target.value); setBillPage(1); }} aria-label="按成员筛选">
<option value="all">全部成员</option>
{billMemberOptions.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
{billFiltered && (
<button className="filter-reset" type="button" onClick={() => { setBillType("all"); setBillMember("all"); setBillPage(1); }}>清除筛选</button>
)}
<span className="spacer"></span>
<span className="ct">本页 <b>{ledgerRows.length}</b> · {ledgerCount} </span>
</div>
<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" }}>{billFiltered ? "// 无匹配筛选条件的账单" : "// 暂无账单流水"}</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 bill-detail" title={ledgerReasonLabel(l.reason)}>{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 className="bill-status"><span className="status-tag ok">成功</span></td>
<td className="neg">{pts(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>
{/* row40:跳页输入 —— 输入页码回车或点「跳转」直达 */}
<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>
)}
</div>
<div className={`tab-panel ${tab === "by-project" ? "active" : ""}`}>
<div className="filter-bar">
<select value={projStatus} onChange={(e) => setProjStatus(e.target.value as typeof projStatus)} aria-label="按状态筛选">
<option value="all">全部状态</option>
<option value="wip">进行中</option>
<option value="ok">已完成</option>
<option value="fail">失败 · 待重跑</option>
</select>
{projFiltered && (
<button className="filter-reset" type="button" onClick={() => setProjStatus("all")}>清除筛选</button>
)}
<span className="spacer"></span>
<span className="ct"> <b>{filteredProjects.length}</b> 个项目 · 消耗 {money(projSpendSum)}</span>
</div>
<div className="billing-table-wrap">
<table className="billing-table">
<thead><tr><th>项目</th><th>商品</th><th>当前阶段</th><th>状态</th><th style={{ textAlign: "right" }}>消耗</th></tr></thead>
<tbody>
{filteredProjects.length === 0 ? (
<tr><td colSpan={5} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>// 当前筛选条件下没有项目</td></tr>
) : filteredProjects.map((p) => {
const spend = projectSpend(p.id);
const stg = projStage(p);
const bucket = projBucket(p);
return (
<tr key={p.id}>
<td><strong className="proj-name">{p.name}</strong></td>
<td className="muted">{p.product_title || "-"}</td>
<td><span className="muted">{stg.label}</span><span className="progress-mini"><span style={{ width: `${stg.pct}%` }} /></span></td>
<td><span className={`status-tag ${bucket}`}>{PROJ_STATUS_LABEL[bucket]}</span></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="filter-bar">
<select value={memRole} onChange={(e) => setMemRole(e.target.value as typeof memRole)} aria-label="按角色筛选">
<option value="all">全部角色</option>
<option value="owner">超管</option>
<option value="admin">团管</option>
<option value="member">成员</option>
</select>
{memFiltered && (
<button className="filter-reset" type="button" onClick={() => setMemRole("all")}>清除筛选</button>
)}
<span className="spacer"></span>
<span className="ct"> <b>{filteredMembers.length}</b> · 合计 {money(memUsedSum)}</span>
</div>
<div className="billing-table-wrap">
<table className="billing-table">
<thead><tr><th>成员</th><th>角色</th><th>已用 / 月度额度</th><th>状态</th></tr></thead>
<tbody>
{filteredMembers.length === 0 ? (
<tr><td colSpan={4} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>// 当前筛选条件下没有成员</td></tr>
) : 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 (
<tr key={m.id}>
<td><span className="who"><span className="av">{m.user.username.slice(0, 1).toUpperCase()}</span>{m.user.username}</span></td>
<td><span className={`role-pill ${rolePill}`}><span className="dot" />{ROLE_LABEL[m.role] || m.role}</span></td>
<td className="quota">
<span className="used">{money(monthUsed)}</span> <span className="lim">/ {monthly > 0 ? money(monthly) : "不限"}{monthly > 0 ? ` · ${usedPct.toFixed(1)}%` : ""}</span>
{monthly > 0 && (
<span className="progress-mini"><span style={{ width: `${usedPct}%`, background: usedPct >= 85 ? "var(--accent-honey)" : "var(--heat)" }} /></span>
)}
</td>
<td>{STATUS_LABEL[m.status] || m.status}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<TopupModal
open={topupChannel !== null}
channel={topupQuote?.channel ?? topupChannel ?? "wechat"}
amount={topupQuote?.amount ?? effectiveAmount}
bonus={topupQuote?.bonus ?? effectiveBonus} rate={pointsRate}
close={() => setTopupChannel(null)}
onDone={submitRecharge}
/>
</section>
);
}