546 lines
23 KiB
TypeScript
546 lines
23 KiB
TypeScript
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>
|
||
);
|
||
}
|