feat(core): Wave 3 batch1 — 团队页5弹窗 + 账户页充值/筛选/列重排还原

团队页(P0,sub-agent):创建账户(role-choice双卡+随机生成+每日/月/总额三档)、
  分享凭据(cred-card+单项/一键复制)、编辑成员/重置密码(警示框)/月限额(5预设pill+实时剩余),
  全用 TeamModal 外壳 + team-page.css restraint token 重写(不复用 styles.css 旧暖米类)
账户页(P0,sub-agent):充值扫码弹窗(QR占位+金额+赠送+双按钮)、三表 filter-bar、
  项目/成员表列重排(role-pill/progress-mini/status-tag),CSS 在 account-page.css

验证: tsc 0 · build 0 · 走查 团队2弹窗(role-choice双卡/三额度/预设)+账户充值弹窗 开关ESC 全过
  · 3 筛选条在 · 0 console error

⚠️ 真实数据缺(未造假,已降级):Project 无 owner 字段→账户项目表去「成员+角色」列;
  TeamMember 无完成数/最近活跃→去这两列。需后端补字段才能完整还原(移交监督)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 04:51:29 +08:00
co-authored by Claude Opus 4.8
parent 8e6c6f0881
commit 0083918207
5 changed files with 651 additions and 56 deletions
+203 -17
View File
@@ -1,11 +1,16 @@
import { useEffect, useState } from "react";
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, TeamMember } from "../types";
import { money } from "./stage-config";
import { money, stageMeta } 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 }> = {
@@ -16,6 +21,23 @@ const RANGE_META: Record<TrendRange, { chip: string; sub: string; totalLabel: st
type Tab = "overview" | "by-project" | "by-member" | "bills";
// 项目状态 → 三态(对齐 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: "退款"
@@ -30,7 +52,6 @@ const LEDGER_REASON_LABEL: Record<string, string> = {
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: "推荐" },
@@ -45,6 +66,50 @@ const STAGES: Array<{ k: string; color: string; bucket: keyof BillingTrend["by_s
{ k: "脚本 LLM", color: "var(--black-alpha-32)", bucket: "script" }
];
// 充值扫码弹窗 · 复用设计系统 .modal-bg/.modal 外壳(QR 占位 + 金额 + 赠送 + 5 分钟有效 + 双按钮)
// 关闭与进出场走 overlays 的 useOverlayTransition(ESC / 点遮罩关),完成支付回调交父级弹全局 toast。
function TopupModal({ open, channel, amount, bonus, close, onDone }: {
open: boolean;
channel: "wechat" | "alipay";
amount: number;
bonus: number;
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">{money(amount)}</div>
<div className="topup-note">{bonus > 0 ? `// 含 ¥${bonus} 赠送 · 实到账 ${money(amount + 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>
<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, onRecharge }: {
billing: BillingSummary | null;
projects: Project[];
@@ -56,6 +121,9 @@ export function AccountPage({ billing, projects, onRecharge }: {
// 账户页数据本页自取(不再走全局 bootstrap):充值后 bump 刷新流水/趋势
const [reloadFlag, setReloadFlag] = useState(0);
// 充值扫码弹窗:点支付方式先弹弹窗,弹窗内「已完成支付」才真正提交
const [topupChannel, setTopupChannel] = useState<"wechat" | "alipay" | null>(null);
// 趋势(day,首屏)+ 团队成员(成员/限额 tab)本页懒加载
const [trend, setTrend] = useState<BillingTrend | null>(null);
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
@@ -91,7 +159,8 @@ export function AccountPage({ billing, projects, onRecharge }: {
async function submitRecharge() {
if (effectiveAmount <= 0) return;
await onRecharge(effectiveAmount, effectiveBonus);
await onRecharge(effectiveAmount, effectiveBonus); // 父级 action 成功后弹全局「充值成功」toast
setTopupChannel(null);
setCustomAmt("");
setBillPage(1);
setReloadFlag((n) => n + 1); // 充值后刷新流水/趋势(余额由全局 action→loadData 刷新)
@@ -124,6 +193,42 @@ export function AccountPage({ billing, projects, onRecharge }: {
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";
// ── 账单流水筛选(类型 + 成员)── 后端为服务端分页且无过滤参数(api.ledgers 仅收 page/pageSize),
// 故只能对「当前页」已载入的 10 条做客户端过滤;计数标注为本页范围,避免误导成全量过滤。
const [billType, setBillType] = useState<string>("all");
const [billMember, setBillMember] = useState<string>("all");
const billMemberOptions = useMemo(() => {
const set = new Set<string>();
ledgerRows.forEach((l) => { if (l.user_label) set.add(l.user_label); });
return Array.from(set);
}, [ledgerRows]);
const visibleLedgers = useMemo(
() => ledgerRows.filter((l) =>
(billType === "all" || l.ledger_type === billType) &&
(billMember === "all" || (l.user_label || "系统") === billMember)
),
[ledgerRows, billType, billMember]
);
const billFiltered = billType !== "all" || billMember !== "all";
return (
<section className="account-page">
<div className="page-head">
@@ -174,11 +279,12 @@ export function AccountPage({ billing, projects, onRecharge }: {
{RECHARGE.map((item) => (
<div
key={item.amt}
className={`recharge-card ${recharge === item.amt ? "selected" : ""}`}
className={`recharge-card ${recharge === item.amt && !customAmt ? "selected" : ""}`}
role="button"
tabIndex={0}
aria-pressed={recharge === item.amt}
onClick={() => setRecharge(item.amt)}
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>
@@ -190,11 +296,11 @@ export function AccountPage({ billing, projects, onRecharge }: {
<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}>
<button className="btn pay-method-btn pay-wechat" type="button" aria-label="微信支付" onClick={() => setTopupChannel("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={submitRecharge}>
<button className="btn pay-method-btn pay-alipay" type="button" aria-label="支付宝" onClick={() => setTopupChannel("alipay")}>
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-alipay.png" alt="" /></span>
</button>
@@ -284,15 +390,35 @@ export function AccountPage({ billing, projects, onRecharge }: {
</div>
<div className={`tab-panel ${tab === "bills" ? "active" : ""}`}>
<div className="filter-bar">
<select value={billType} onChange={(e) => setBillType(e.target.value)} 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)} aria-label="按成员筛选">
<option value="all"></option>
{billMemberOptions.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
{billFiltered && (
<button className="filter-reset" type="button" onClick={() => { setBillType("all"); setBillMember("all"); }}></button>
)}
<span className="spacer"></span>
<span className="ct"> <b>{visibleLedgers.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" }}>// 暂无账单流水</td></tr>
) : ledgerRows.map((l) => (
) : visibleLedgers.length === 0 ? (
<tr><td colSpan={6} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>{billFiltered ? "// 本页无匹配筛选条件的账单" : "// 暂无账单流水"}</td></tr>
) : visibleLedgers.map((l) => (
<tr key={l.id}>
<td className="ts">{new Date(l.created_at).toLocaleString("zh-CN")}</td>
<td>{ledgerTypeLabel(l.ledger_type)}</td>
@@ -324,14 +450,37 @@ export function AccountPage({ billing, projects, onRecharge }: {
</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 style={{ textAlign: "right" }}></th></tr></thead>
<thead><tr><th></th><th></th><th></th><th></th><th style={{ textAlign: "right" }}></th></tr></thead>
<tbody>
{projects.map((p) => {
{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>{p.name}</td><td>{p.current_stage}</td><td>{p.status}</td><td className={spend > 0 ? "neg" : "zero"}>{money(spend)}</td></tr>
<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>
@@ -340,20 +489,57 @@ export function AccountPage({ billing, projects, onRecharge }: {
</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>
{teamMembers.map((m) => {
{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 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>
<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={topupChannel ?? "wechat"}
amount={effectiveAmount}
bonus={effectiveBonus}
close={() => setTopupChannel(null)}
onDone={submitRecharge}
/>
</section>
);
}