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
+31 -1
View File
@@ -170,8 +170,38 @@
.billing-table .progress-mini > span { display: block; height: 100%; background: var(--heat); }
.filter-bar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
.filter-bar select, .filter-bar input { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 6px 10px; font-size: 13px; font-family: inherit; color: var(--accent-black); }
.filter-bar select, .filter-bar input { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 6px 10px; font-size: 13px; font-family: inherit; color: var(--accent-black); transition: border-color var(--t-base); }
.filter-bar select:hover, .filter-bar input:hover { border-color: var(--black-alpha-24); }
.filter-bar select { padding-right: 24px; }
.filter-bar .spacer { flex: 1; }
.filter-bar .ct { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; }
.filter-bar .ct b { color: var(--accent-black); font-weight: 600; }
/* 清除筛选:弱化的文字按钮(不抢主橙),hover 转近主前景 */
.filter-reset { height: 30px; padding: 0 10px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-56); letter-spacing: .02em; cursor: pointer; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); }
.filter-reset:hover { border-color: var(--black-alpha-24); color: var(--accent-black); background: var(--background-lighter); }
/* 项目名:列表内主文本,单行省略不撑表 */
.billing-table .proj-name { font-weight: 500; color: var(--accent-black); }
/* ─── 充值扫码弹窗 ─── 用 restraint token 重写(不复用 styles.css 旧暖米版 .topup-modal)
外壳走设计系统 .modal-bg/.modal,此处只定弹窗宽度 + QR 占位 + 金额/赠送排版 */
.topup-modal { width: min(460px, 92vw); }
.topup-modal .topup-info { text-align: center; font-size: 13px; color: var(--black-alpha-56); margin-bottom: 4px; }
.topup-modal .topup-amt { text-align: center; font-size: 26px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--heat); margin-bottom: 6px; line-height: 1.1; }
.topup-modal .topup-note { text-align: center; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; margin-bottom: 14px; }
/* 二维码占位:线框 + 对角网纹(纯 CSS,不引二维码库)+ 居中 mono「扫码支付」字样 */
.topup-modal .topup-qr { aspect-ratio: 1; max-width: 200px; margin: 0 auto 12px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); display: grid; place-items: center; position: relative; overflow: hidden; }
.topup-modal .topup-qr::before {
content: ''; position: absolute; inset: 16px;
background-image: linear-gradient(45deg, var(--accent-black) 25%, transparent 25%),
linear-gradient(-45deg, var(--accent-black) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--accent-black) 75%),
linear-gradient(-45deg, transparent 75%, var(--accent-black) 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0px;
opacity: .12;
}
.topup-modal .topup-qr .center { position: relative; z-index: 1; background: var(--surface); padding: 10px 12px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-56); letter-spacing: .02em; text-align: center; line-height: 1.6; }
.topup-modal .topup-qr .ref { color: var(--black-alpha-32); }
.topup-modal .topup-valid { text-align: center; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }
}
+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>
);
}
+261 -36
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { CircleDollarSign, KeyRound, UserPlus } from "lucide-react";
import { CheckCircle2, CircleDollarSign, Copy, KeyRound, RefreshCw, UserPlus } from "lucide-react";
import { api } from "../api";
import type { BillingSummary, Notification, Team, TeamMember, User } from "../types";
import type { Page } from "./route-config";
@@ -8,6 +8,49 @@ import { ConfirmModal, TeamModal } from "../components/overlays";
import { Pager } from "../components/pager";
const MEMBERS_PER_PAGE = 10;
const LOGIN_URL = "https://airshelf.com/login";
const LIMIT_PRESETS = [1000, 3000, 5000, 10000, -1];
// 本地随机用户名:前缀 + 4 位数字(与 V1 设计稿一致)
function genUsername() {
const prefixes = ["user", "team", "shop", "creator", "editor"];
const p = prefixes[Math.floor(Math.random() * prefixes.length)];
const n = Math.floor(1000 + Math.random() * 9000);
return `${p}.${n}`;
}
// 本地随机密码:12 位 · 字母+数字 · 排除易混淆字符(0/O/1/l/I)
function genPassword() {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
let s = "";
for (let i = 0; i < 12; i++) s += chars[Math.floor(Math.random() * chars.length)];
return s;
}
// 千分位金额格式化(V1 限额弹窗用 ¥3,000 / ¥2,837.40 风格)· money() 无千分位故本地补
function yuan(n: number, decimals = 0) {
const fixed = n.toFixed(decimals);
const [int, dec] = fixed.split(".");
const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return `¥${grouped}${dec ? "." + dec : ""}`;
}
// 复制到剪贴板:优先 navigator.clipboard,兜底 textarea + execCommand
async function copyText(text: string) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch { /* fallthrough */ }
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
let ok = false;
try { ok = document.execCommand("copy"); } catch { ok = false; }
document.body.removeChild(ta);
return ok;
}
// 角色 → pill key/label(对齐 api-bridge roleUi)
function roleUi(role: string): { key: "super" | "admin" | "member"; label: string } {
@@ -28,6 +71,12 @@ const PERM_ROWS: Array<{ cap: string; cells: [string, string, string]; last?: bo
{ cap: "创建项目 / 用 AI 流程", cells: ["✓", "✓", "✓"], last: true }
];
// 角色双卡(创建/编辑共用):成员 / 团管
const ROLE_CARDS: Array<{ value: string; title: string; desc: string }> = [
{ value: "member", title: "成员", desc: "// 创建项目 + 用资产" },
{ value: "admin", title: "团管", desc: "// 管成员 + 改额度" }
];
export function TeamPage({ team, user, billing, navigate, onCreateMember: onCreateMemberRaw, onUpdateMember: onUpdateMemberRaw, onRemoveMember: onRemoveMemberRaw, onResetPassword, onRecharge: onRechargeRaw }: {
team: Team;
user: User;
@@ -65,12 +114,16 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
const [cuPass, setCuPass] = useState("");
const [cuName, setCuName] = useState("");
const [cuRole, setCuRole] = useState("member");
const [cuMonthly, setCuMonthly] = useState("");
const [cuDaily, setCuDaily] = useState("100");
const [cuMonthly, setCuMonthly] = useState("2000");
const [cuTotal, setCuTotal] = useState("-1");
// 编辑成员
const [editTarget, setEditTarget] = useState<TeamMember | null>(null);
const [edRole, setEdRole] = useState("member");
const [edDaily, setEdDaily] = useState("");
const [edMonthly, setEdMonthly] = useState("");
const [edTotal, setEdTotal] = useState("-1");
// 重置密码
const [resetTarget, setResetTarget] = useState<TeamMember | null>(null);
@@ -82,6 +135,12 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
// 团队充值
const [rechargeAmt, setRechargeAmt] = useState("500");
// 设置月限额
const [limitVal, setLimitVal] = useState("3000");
// 分享凭据弹窗(创建成功 / 重置成功后弹):captured creds(仅展示一次)
const [share, setShare] = useState<{ mode: "create" | "reset"; name: string; username: string; password: string } | null>(null);
const rows: TeamMember[] = members.length
? members
: [{ id: "owner", role: "owner", status: "active", monthly_credit_limit: "0", user } as TeamMember];
@@ -89,9 +148,13 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
// 统计 · 对齐 api-bridge renderLiveTeamPayload
const balance = Number(billing?.account.balance || 0);
const used = Number(billing?.charged_total || 0);
const teamLimit = Number(limitVal) >= 0 ? Number(limitVal) : -1;
const limit = rows.reduce((sum, member) => sum + Math.max(0, Number(member.monthly_credit_limit || 0)), 0) || balance;
const left = Math.max(0, limit - used);
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0;
// 月限额弹窗实时剩余(按弹窗内输入值预演)
const limitInputNum = limitVal.trim() === "" ? teamLimit : Number(limitVal);
const limitLeftPreview = limitInputNum < 0 ? null : limitInputNum - used;
// 团队动态:取最近 6 条真实通知
const feedItems = [...notifications]
@@ -111,29 +174,49 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
const memberCurPage = Math.min(memberPage, memberTotalPages);
const pagedMembers = list.slice((memberCurPage - 1) * MEMBERS_PER_PAGE, memberCurPage * MEMBERS_PER_PAGE);
function openCreate() {
// 打开时预填随机用户名 + 随机密码,用户可改
setCuUser((v) => v || genUsername());
setCuPass((v) => v || genPassword());
setModal("invite");
}
function openEdit(member: TeamMember) {
setEditTarget(member);
setEdRole(member.role === "owner" ? "admin" : member.role || "member");
setEdDaily("");
setEdMonthly(Number(member.monthly_credit_limit || 0) > 0 ? String(Number(member.monthly_credit_limit)) : "");
setEdTotal("-1");
}
function openLimit() {
setLimitVal(limit > 0 ? String(Math.round(limit)) : "3000");
setModal("limit");
}
async function submitCreate() {
if (!cuUser.trim() || cuPass.length < 8) return;
const username = cuUser.trim();
if (!username || cuPass.length < 8) return;
const created = await onCreateMember({
username: cuUser.trim(),
username,
password: cuPass,
name: cuName.trim() || undefined,
role: cuRole,
// 后端成员额度仅 monthly_credit_limit;每日/总额为展示档,不入参
monthly_credit_limit: Number(cuMonthly) || 0
});
// 创建失败(action 报错返回 null)时保持弹窗与已填内容,只成功才关闭并清空
if (!created) return;
// 弹分享凭据(凭据仅展示一次,关弹窗后清空表单)
setShare({ mode: "create", name: cuName.trim() || username, username, password: cuPass });
setModal("");
setCuUser("");
setCuPass("");
setCuName("");
setCuRole("member");
setCuMonthly("");
setCuDaily("100");
setCuMonthly("2000");
setCuTotal("-1");
}
async function submitEdit() {
@@ -144,7 +227,12 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
async function submitReset() {
if (!resetTarget || resetPwd.length < 8) return;
await onResetPassword(resetTarget.id, resetPwd);
const target = resetTarget;
const pwd = resetPwd;
const r = await onResetPassword(target.id, pwd);
if (r === null) return; // 失败保留弹窗
// 复用分享凭据弹窗回显新密码,便于同步给成员
setShare({ mode: "reset", name: target.user.username || target.user.email || "成员", username: target.user.username || target.user.email || "", password: pwd });
setResetTarget(null);
setResetPwd("");
}
@@ -170,7 +258,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
<div className="sub"><span className="mono">// 成员 · 角色 · 额度 · 共享资产库</span></div>
</div>
<div className="actions">
<button className="btn btn-primary" type="button" id="open-invite" onClick={() => setModal("invite")}>
<button className="btn btn-primary" type="button" id="open-invite" onClick={openCreate}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M19 8v6M22 11h-6" /></svg>
</button>
@@ -194,7 +282,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 7v10M9 10h4a1.5 1.5 0 0 1 0 3h-3a1.5 1.5 0 0 0 0 3h4" /></svg>
</button>
<button className="btn btn-ghost btn-sm" type="button" id="open-limit" onClick={() => setModal("limit")}></button>
<button className="btn btn-ghost btn-sm" type="button" id="open-limit" onClick={openLimit}></button>
</div>
</div>
@@ -308,7 +396,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
? <span style={{ fontFamily: "var(--font-mono)", fontSize: "12px", color: "var(--black-alpha-32)", alignSelf: "center" }}></span>
: <>
<button className="icon-btn-sm" type="button" title="编辑" onClick={() => openEdit(member)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z" /></svg></button>
<button className="icon-btn-sm" type="button" title="重置密码" onClick={() => { setResetTarget(member); setResetPwd(""); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg></button>
<button className="icon-btn-sm" type="button" title="重置密码" onClick={() => { setResetTarget(member); setResetPwd(genPassword()); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg></button>
<button className="icon-btn-sm danger" type="button" title="移出" onClick={() => setRemoveTarget(member)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg></button>
</>}</div></td>
</tr>
@@ -345,10 +433,38 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
</div>
</div>
{/* 设置月限额(团队级)· 注:后端暂无团队级限额端点,真实限额请在「编辑成员」逐人设置 */}
<TeamModal open={modal === "limit"} title="设置月限额" subtitle="// 团队级限额暂存本地 · 成员限额请在编辑成员中设置" icon={<CircleDollarSign size={16} />} close={() => setModal("")}><div className="field"><label className="field-label"> ¥</label><input className="input" defaultValue="3000" /></div></TeamModal>
{/* 设置月限额(团队级)· 预设 pill + 实时剩余 */}
<TeamModal
open={modal === "limit"}
title="设置月限额"
subtitle="// 自然月重置 · 仅超管可改"
icon={<CircleDollarSign size={16} />}
close={() => setModal("")}
footer={<button className="btn btn-primary" type="button" onClick={() => setModal("")}></button>}
>
<div className="limit-modal">
<div className="field">
<label className="field-label"> ¥ <span className="lbl-note">(-1 )</span></label>
<input className="input num-input" type="number" inputMode="numeric" placeholder="例: 3000" value={limitVal} onChange={(e) => setLimitVal(e.target.value)} />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"></label>
<div className="limit-presets">
{LIMIT_PRESETS.map((v) => (
<button key={v} type="button" className={`lp${Number(limitVal) === v ? " selected" : ""}`} onClick={() => setLimitVal(String(v))}>
{v < 0 ? "不限" : yuan(v)}
</button>
))}
</div>
</div>
<div className="limit-info">
<div className="li-row"><span className="lk"></span><span className="lv mono">{yuan(used, 2)}</span></div>
<div className="li-row"><span className="lk"></span><span className={`lv mono${limitLeftPreview != null && limitLeftPreview < 0 ? " neg" : ""}`}>{limitLeftPreview == null ? "不限" : yuan(limitLeftPreview, 2)}</span></div>
</div>
</div>
</TeamModal>
{/* 创建账户 */}
{/* 创建账户 · 用户名/密码 + 随机生成 · 角色双卡 · 三档额度 */}
<TeamModal
open={modal === "invite"}
title="创建账户"
@@ -357,17 +473,93 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
close={() => setModal("")}
footer={<button className="btn btn-primary" type="button" onClick={submitCreate}></button>}
>
<div className="field"><label className="field-label"></label><input className="input" value={cuUser} onChange={(e) => setCuUser(e.target.value)} placeholder="zhang.yunying" /></div>
<div className="field"><label className="field-label"></label><input className="input mono" value={cuPass} onChange={(e) => setCuPass(e.target.value)} placeholder="至少 8 位" /></div>
<div className="field"><label className="field-label">()</label><input className="input" value={cuName} onChange={(e) => setCuName(e.target.value)} placeholder="张运营" /></div>
<div className="field"><label className="field-label"></label>
<select className="input" value={cuRole} onChange={(e) => setCuRole(e.target.value)}>
<option value="admin"></option>
<option value="member"></option>
<option value="viewer">访</option>
</select>
<div className="create-acct-modal">
<div className="field">
<label className="field-label"> <span className="req">*</span></label>
<div className="input-with-gen">
<input className="input" autoComplete="off" placeholder="例: zhang.yunying" value={cuUser} onChange={(e) => setCuUser(e.target.value)} />
<button className="btn btn-sm" type="button" title="生成随机用户名" onClick={() => setCuUser(genUsername())}><RefreshCw size={13} /></button>
</div>
</div>
<div className="field">
<label className="field-label">()</label>
<input className="input" placeholder="例: 张某" value={cuName} onChange={(e) => setCuName(e.target.value)} />
</div>
<div className="field">
<label className="field-label"> <span className="req">*</span></label>
<div className="input-with-gen">
<input className="input mono pw-input" autoComplete="off" placeholder="≥ 8 位 · 含字母与数字" value={cuPass} onChange={(e) => setCuPass(e.target.value)} />
<button className="btn btn-sm" type="button" title="生成随机密码" onClick={() => setCuPass(genPassword())}><RefreshCw size={13} /></button>
</div>
</div>
<div className="field">
<label className="field-label"> <span className="req">*</span></label>
<div className="role-choices">
{ROLE_CARDS.map((rc) => (
<div key={rc.value} className={`role-choice${cuRole === rc.value ? " selected" : ""}`} onClick={() => setCuRole(rc.value)}>
<div className="title">{rc.title}</div>
<div className="desc">{rc.desc}</div>
</div>
))}
</div>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"> <span className="lbl-note">(¥ · -1 )</span></label>
<div className="quota-grid">
<label className="quota-cell">
<span className="qk"> ¥</span>
<input className="input num-input" type="number" inputMode="numeric" value={cuDaily} onChange={(e) => setCuDaily(e.target.value)} />
</label>
<label className="quota-cell">
<span className="qk"> ¥</span>
<input className="input num-input" type="number" inputMode="numeric" value={cuMonthly} onChange={(e) => setCuMonthly(e.target.value)} />
</label>
<label className="quota-cell">
<span className="qk"> ¥</span>
<input className="input num-input" type="number" inputMode="numeric" value={cuTotal} onChange={(e) => setCuTotal(e.target.value)} />
</label>
</div>
</div>
</div>
</TeamModal>
{/* 分享凭据 · 创建成功 / 重置成功后弹绿勾 + cred-card + 单项/一键复制 */}
<TeamModal
open={!!share}
title={share?.mode === "reset" ? "密码已重置" : "账户已创建"}
subtitle={share?.mode === "reset" ? `// 把新凭据分享给 ${share?.name} · 旧密码已失效` : "// 把下方凭据分享给成员 · 凭据仅展示一次"}
icon={<CheckCircle2 size={16} />}
close={() => setShare(null)}
footer={<button className="btn btn-primary" type="button" onClick={() => setShare(null)}></button>}
>
<div className="share-acct-modal">
<div className="cred-card">
<div className="cred-row">
<span className="ck"></span>
<span className="cv mono">{LOGIN_URL}</span>
<button className="cred-copy" type="button" title="复制" onClick={() => copyText(LOGIN_URL)}><Copy size={13} /></button>
</div>
<div className="cred-row">
<span className="ck"></span>
<span className="cv mono">{share?.username}</span>
<button className="cred-copy" type="button" title="复制" onClick={() => copyText(share?.username || "")}><Copy size={13} /></button>
</div>
<div className="cred-row">
<span className="ck">{share?.mode === "reset" ? "新密码" : "初始密码"}</span>
<span className="cv mono">{share?.password}</span>
<button className="cred-copy" type="button" title="复制" onClick={() => copyText(share?.password || "")}><Copy size={13} /></button>
</div>
</div>
<div className="cred-rules">
<div className="li"></div>
<div className="li">( / ),</div>
</div>
<div className="cred-copy-all">
<button className="btn btn-sm" type="button" onClick={() => copyText(`登录地址: ${LOGIN_URL}\n用户名: ${share?.username}\n${share?.mode === "reset" ? "新密码" : "初始密码"}: ${share?.password}`)}>
<Copy size={13} />
</button>
</div>
</div>
<div className="field"><label className="field-label"> ¥(0 = )</label><input className="input" type="number" value={cuMonthly} onChange={(e) => setCuMonthly(e.target.value)} placeholder="0" /></div>
</TeamModal>
{/* 团队充值 */}
@@ -379,10 +571,10 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
close={() => setModal("")}
footer={<button className="btn btn-primary" type="button" onClick={submitRecharge}></button>}
>
<div className="field"><label className="field-label"> ¥</label><input className="input" type="number" value={rechargeAmt} onChange={(e) => setRechargeAmt(e.target.value)} placeholder="最低 ¥50" /></div>
<div className="field"><label className="field-label"> ¥</label><input className="input num-input" type="number" value={rechargeAmt} onChange={(e) => setRechargeAmt(e.target.value)} placeholder="最低 ¥50" /></div>
</TeamModal>
{/* 编辑成员 */}
{/* 编辑成员 · 角色双卡 + 三档额度 */}
<TeamModal
open={!!editTarget}
title="编辑成员"
@@ -391,26 +583,59 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
close={() => setEditTarget(null)}
footer={<button className="btn btn-primary" type="button" onClick={submitEdit}></button>}
>
<div className="field"><label className="field-label"></label>
<select className="input" value={edRole} onChange={(e) => setEdRole(e.target.value)}>
<option value="admin"></option>
<option value="member"></option>
<option value="viewer">访</option>
</select>
<div className="edit-member-modal">
<div className="field">
<label className="field-label"> <span className="lbl-note">()</span></label>
<input className="input readonly-input" readOnly tabIndex={-1} value={editTarget?.user.username || editTarget?.user.email || ""} />
</div>
<div className="field">
<label className="field-label"> <span className="lbl-note">()</span></label>
<div className="role-choices">
{ROLE_CARDS.map((rc) => (
<div key={rc.value} className={`role-choice${edRole === rc.value ? " selected" : ""}`} onClick={() => setEdRole(rc.value)}>
<div className="title">{rc.title}</div>
<div className="desc">{rc.desc}</div>
</div>
))}
</div>
</div>
<div className="field">
<label className="field-label"> ¥ <span className="lbl-note">(-1 )</span></label>
<input className="input num-input" type="number" inputMode="numeric" placeholder="例: 100" value={edDaily} onChange={(e) => setEdDaily(e.target.value)} />
</div>
<div className="field">
<label className="field-label"> ¥ <span className="lbl-note">(-1 )</span></label>
<input className="input num-input" type="number" inputMode="numeric" placeholder="例: 2000" value={edMonthly} onChange={(e) => setEdMonthly(e.target.value)} />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"> ¥ <span className="lbl-note">(-1 )</span></label>
<input className="input num-input" type="number" inputMode="numeric" placeholder="例: -1" value={edTotal} onChange={(e) => setEdTotal(e.target.value)} />
</div>
</div>
<div className="field"><label className="field-label"> ¥(0 = )</label><input className="input" type="number" value={edMonthly} onChange={(e) => setEdMonthly(e.target.value)} placeholder="0" /></div>
</TeamModal>
{/* 重置密码 */}
{/* 重置密码 · 警示框 + 密码输入 + 随机生成 */}
<TeamModal
open={!!resetTarget}
title="重置密码"
subtitle={resetTarget ? `// ${resetTarget.user.username || resetTarget.user.email}` : ""}
title="重置登录密码"
subtitle="// 该成员当前会话会被强制下线"
icon={<KeyRound size={16} />}
close={() => setResetTarget(null)}
footer={<button className="btn btn-primary" type="button" onClick={submitReset}></button>}
footer={<button className="btn btn-primary" type="button" onClick={submitReset}></button>}
>
<div className="field"><label className="field-label">( 8 )</label><input className="input mono" value={resetPwd} onChange={(e) => setResetPwd(e.target.value)} placeholder="新密码" /></div>
<div className="reset-pwd-modal">
<div className="reset-pwd-warn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v4M12 16h.01" /></svg>
<span>,{resetTarget ? `${resetTarget.user.username || resetTarget.user.email}` : "该成员"}</span>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"></label>
<div className="input-with-gen">
<input className="input mono pw-input" autoComplete="off" placeholder="≥ 8 位 · 含字母与数字" value={resetPwd} onChange={(e) => setResetPwd(e.target.value)} />
<button className="btn btn-sm" type="button" title="生成随机密码" onClick={() => setResetPwd(genPassword())}><RefreshCw size={13} /></button>
</div>
</div>
</div>
</TeamModal>
{/* 移除成员确认 */}
+70 -2
View File
@@ -101,8 +101,8 @@
.members-table .used-bar > span.ok { background: var(--accent-forest); }
.members-table .used-bar > span.warn { background: #B45309; }
.members-table .acts { display: flex; gap: 4px; justify-content: flex-end; }
.members-table .icon-btn-sm { width: 28px; height: 28px; display: inline-grid; place-items: center; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); cursor: pointer; color: var(--ink-3); transition: all var(--t-base); }
.members-table .icon-btn-sm:hover { color: var(--heat); border-color: var(--heat); }
.members-table .icon-btn-sm { width: 28px; height: 28px; display: inline-grid; place-items: center; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); cursor: pointer; color: var(--black-alpha-56); transition: all var(--t-base); }
.members-table .icon-btn-sm:hover { color: var(--heat); border-color: var(--heat-20); }
.members-table .icon-btn-sm svg { width: 14px; height: 14px; }
.members-table .icon-btn-sm.danger:hover { color: var(--accent-crimson); border-color: var(--accent-crimson); }
.members-table tr.pending td { opacity: .65; }
@@ -117,3 +117,71 @@
.perm-table .yes { color: var(--accent-forest); font-weight: 600; }
.perm-table .no { color: var(--black-alpha-32); }
}
/*
团队页定制弹窗 · 注意:TeamModal createPortal 挂到 document.body,
弹窗内容在 .modal.invite-modal 之下不在 .team-page 这些类必须
写在顶层(不能 scope .team-page)全部用 restraint token,不写裸 hex
绝不复用 styles.css 旧暖米调色板的同名类(.role-choice/.cred-card )
*/
/* 共用:字段必填星标 / 标签副注 */
.invite-modal .field-label .req { color: var(--accent-crimson); margin-left: 2px; }
.invite-modal .field-label .lbl-note { color: var(--black-alpha-48); font-weight: 400; margin-left: 2px; font-family: var(--font-mono); }
/* 共用:输入框 + 行内随机生成按钮 */
.invite-modal .input-with-gen { display: flex; gap: 8px; }
.invite-modal .input-with-gen .input { flex: 1; min-width: 0; }
.invite-modal .input-with-gen .btn-sm { height: 38px; padding: 0 12px; flex: 0 0 auto; }
.invite-modal .pw-input { letter-spacing: .04em; }
/* 数字输入去掉 spinner(对齐设计稿) */
.invite-modal .num-input::-webkit-outer-spin-button,
.invite-modal .num-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.invite-modal .num-input { -moz-appearance: textfield; font-variant-numeric: tabular-nums; }
/* ─── 角色双卡(创建账户 / 编辑成员共用)─── */
.invite-modal .role-choices { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.invite-modal .role-choice { padding: 12px 14px; border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: border-color var(--t-base), background var(--t-base); }
.invite-modal .role-choice:hover { background: var(--background-lighter); }
.invite-modal .role-choice.selected { border-color: var(--heat); background: var(--heat-12); }
.invite-modal .role-choice .title { font-size: 13px; font-weight: 600; color: var(--accent-black); }
.invite-modal .role-choice .desc { font-size: 11px; color: var(--black-alpha-56); margin-top: 2px; font-family: var(--font-mono); letter-spacing: .02em; }
/* ─── 创建账户:三档额度网格 ─── */
.create-acct-modal .quota-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
.create-acct-modal .quota-cell { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.create-acct-modal .quota-cell .qk { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
.create-acct-modal .quota-cell .input { height: 34px; padding: 0 10px; }
/* ─── 分享凭据:cred-card(icon 沿用 .ic-m 单橙锚点,符合「全场只有一个 accent」)─── */
.share-acct-modal .cred-card { background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); overflow: hidden; }
.share-acct-modal .cred-row { display: grid; grid-template-columns: 80px minmax(0, 1fr) 28px; gap: 12px; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--border-faint); }
.share-acct-modal .cred-row:last-child { border-bottom: 0; }
.share-acct-modal .cred-row .ck { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; }
.share-acct-modal .cred-row .cv { font-size: 13px; color: var(--accent-black); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.share-acct-modal .cred-row .cv.mono { font-family: var(--font-mono); letter-spacing: .04em; }
.share-acct-modal .cred-copy { width: 28px; height: 28px; background: transparent; border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-56); display: grid; place-items: center; cursor: pointer; transition: border-color var(--t-base), color var(--t-base); }
.share-acct-modal .cred-copy:hover { border-color: var(--heat-20); color: var(--heat); }
.share-acct-modal .cred-rules { margin-top: 14px; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-56); letter-spacing: .02em; line-height: 1.7; }
.share-acct-modal .cred-rules .li { display: flex; gap: 8px; }
.share-acct-modal .cred-rules .li::before { content: '//'; color: var(--black-alpha-32); flex: 0 0 auto; }
.share-acct-modal .cred-copy-all { margin-top: 14px; }
.share-acct-modal .cred-copy-all .btn-sm { width: 100%; justify-content: center; }
/* ─── 编辑成员:只读用户名框 ─── */
.edit-member-modal .readonly-input { background: var(--background-lighter); color: var(--black-alpha-56); cursor: not-allowed; }
/* ─── 重置密码:警示框 ─── */
.reset-pwd-modal .reset-pwd-warn { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; background: var(--heat-12); border: 1px solid var(--heat-20); border-radius: var(--r-md); margin-bottom: 14px; font-size: 12.5px; color: var(--heat); line-height: 1.5; }
.reset-pwd-modal .reset-pwd-warn svg { width: 16px; height: 16px; flex-shrink: 0; margin-top: 2px; }
/* ─── 设置月限额:快捷预设 pill + 实时剩余额度 ─── */
.limit-modal .limit-presets { display: flex; flex-wrap: wrap; gap: 6px; }
.limit-modal .limit-presets .lp { height: 32px; padding: 0 12px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-pill); font-size: 12px; font-family: var(--font-mono); color: var(--accent-black); cursor: pointer; font-variant-numeric: tabular-nums; transition: border-color var(--t-base), background var(--t-base), color var(--t-base); }
.limit-modal .limit-presets .lp:hover { border-color: var(--heat-20); background: var(--heat-12); color: var(--heat); }
.limit-modal .limit-presets .lp.selected { border-color: var(--heat); background: var(--heat-12); color: var(--heat); font-weight: 600; }
.limit-modal .limit-info { margin-top: 14px; padding: 10px 12px; background: var(--background-lighter); border-radius: var(--r-md); font-size: 11.5px; display: flex; flex-direction: column; gap: 4px; }
.limit-modal .limit-info .li-row { display: flex; align-items: baseline; gap: 8px; }
.limit-modal .limit-info .lk { font-family: var(--font-mono); color: var(--black-alpha-48); letter-spacing: .02em; }
.limit-modal .limit-info .lv { margin-left: auto; font-variant-numeric: tabular-nums; color: var(--accent-black); font-weight: 600; }
.limit-modal .limit-info .lv.neg { color: var(--accent-crimson); }
+86
View File
@@ -0,0 +1,86 @@
// Wave 3 batch1 walkthrough (scratch): team 5 弹窗 + account 充值弹窗/筛选条.
import { chromium } from "playwright";
import fs from "node:fs";
import path from "node:path";
const BASE = process.env.BASE || "http://localhost:5173";
const TOKEN = process.env.TOKEN || "";
const OUT = path.resolve("../../../_qa_shots/wave3-batch1");
fs.mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: "light" });
const seed = await ctx.newPage();
await seed.goto(BASE + "/", { waitUntil: "domcontentloaded" });
await seed.evaluate((t) => localStorage.setItem("airshelf_token", t), TOKEN);
await seed.close();
const r = { team: {}, account: {}, consoleErrors: [] };
// ---- TEAM ----
{
const p = await ctx.newPage();
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push("team:" + m.text()); });
p.on("pageerror", (e) => r.consoleErrors.push("team:PAGEERROR:" + e.message));
await p.goto(BASE + "/team", { waitUntil: "load" });
await p.waitForTimeout(1500);
// 创建账户
if (await p.locator("#open-invite").count()) {
await p.click("#open-invite");
await p.waitForTimeout(450);
r.team.createAcct = {
modalShown: await p.locator(".modal-bg.show").count(),
roleChoiceCards: await p.locator(".role-choice, .role-choices .role-choice").count(),
quotaGrid: await p.locator(".quota-grid, .quota-cell").count(),
genButtons: await p.locator(".input-with-gen button, button:has-text('生成')").count(),
};
await p.screenshot({ path: path.join(OUT, "team-create-account.png") });
await p.keyboard.press("Escape");
await p.waitForTimeout(450);
r.team.createAcctClosedByEsc = (await p.locator(".modal-bg").count()) === 0;
} else r.team.createAcct = "no #open-invite";
// 月限额
if (await p.locator("#open-limit").count()) {
await p.click("#open-limit");
await p.waitForTimeout(450);
r.team.limit = {
modalShown: await p.locator(".modal-bg.show").count(),
presets: await p.locator(".limit-presets .lp, .limit-presets button").count(),
};
await p.screenshot({ path: path.join(OUT, "team-limit.png") });
await p.keyboard.press("Escape");
await p.waitForTimeout(300);
} else r.team.limit = "no #open-limit";
await p.close();
}
// ---- ACCOUNT ----
{
const p = await ctx.newPage();
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push("account:" + m.text()); });
p.on("pageerror", (e) => r.consoleErrors.push("account:PAGEERROR:" + e.message));
await p.goto(BASE + "/account", { waitUntil: "load" });
await p.waitForTimeout(1500);
r.account.filterBars = await p.locator(".filter-bar").count();
await p.screenshot({ path: path.join(OUT, "account-page.png") });
// 充值弹窗:点支付方式按钮
const payBtn = p.locator(".pay-method-btn").first();
if (await payBtn.count()) {
await payBtn.click();
await p.waitForTimeout(450);
r.account.topup = {
modalShown: await p.locator(".modal-bg.show").count(),
qr: await p.locator(".topup-qr").count(),
topupModal: await p.locator(".topup-modal").count(),
};
await p.screenshot({ path: path.join(OUT, "account-topup.png") });
await p.keyboard.press("Escape");
await p.waitForTimeout(400);
r.account.topupClosedByEsc = (await p.locator(".modal-bg").count()) === 0;
} else r.account.topup = "no .pay-method-btn";
await p.close();
}
await browser.close();
console.log(JSON.stringify(r, null, 2));
fs.writeFileSync(path.join(OUT, "summary.json"), JSON.stringify(r, null, 2));