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:
co-authored by
Claude Opus 4.8
parent
8e6c6f0881
commit
0083918207
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user