626 lines
34 KiB
TypeScript
626 lines
34 KiB
TypeScript
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>
|
||
);
|
||
}
|