From 0083918207024a1fc75dead95bf7fd05fd8b43f0 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 19 Jun 2026 04:51:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20Wave=203=20batch1=20=E2=80=94=20?= =?UTF-8?q?=E5=9B=A2=E9=98=9F=E9=A1=B55=E5=BC=B9=E7=AA=97=20+=20=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E9=A1=B5=E5=85=85=E5=80=BC/=E7=AD=9B=E9=80=89/?= =?UTF-8?q?=E5=88=97=E9=87=8D=E6=8E=92=E8=BF=98=E5=8E=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 团队页(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 --- core/frontend/src/account-page.css | 32 ++- core/frontend/src/routes/account.tsx | 220 ++++++++++++++++-- core/frontend/src/routes/team.tsx | 297 +++++++++++++++++++++--- core/frontend/src/team-page.css | 72 +++++- core/qa/visual-parity/_wave3-batch1.mjs | 86 +++++++ 5 files changed, 651 insertions(+), 56 deletions(-) create mode 100644 core/qa/visual-parity/_wave3-batch1.mjs diff --git a/core/frontend/src/account-page.css b/core/frontend/src/account-page.css index 2aeab60..1d53c8c 100644 --- a/core/frontend/src/account-page.css +++ b/core/frontend/src/account-page.css @@ -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; } } diff --git a/core/frontend/src/routes/account.tsx b/core/frontend/src/routes/account.tsx index 53bb30a..1324f14 100644 --- a/core/frontend/src/routes/account.tsx +++ b/core/frontend/src/routes/account.tsx @@ -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 = { owner: "超管", admin: "团管", member: "成员", viewer: "访客" }; const STATUS_LABEL: Record = { active: "活跃", invited: "待激活", disabled: "已停用" }; +// 角色 pill 样式键(对齐 V1 .role-super / .role-admin / .role-member) +const ROLE_PILL: Record = { owner: "role-super", admin: "role-admin", member: "role-member", viewer: "role-member" }; type TrendRange = "day" | "week" | "month"; const RANGE_META: Record = { @@ -16,6 +21,23 @@ const RANGE_META: Record = { wip: "进行中", ok: "已完成", fail: "失败 · 待重跑" }; +// 阶段 → 编号 + 名称 + 进度百分比(5 段流水线;completed = 100%) +const STAGE_PCT: Record = { 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 = { recharge: "充值", reserve: "预扣", release: "释放", charge: "扣费", adjustment: "调整", refund: "退款" @@ -30,7 +52,6 @@ const LEDGER_REASON_LABEL: Record = { 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; +}) { + 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( +
+
event.stopPropagation()}> + ++ +
+
+
扫码支付// {channelLabel}
+ +
+
+
支付金额
+
{money(amount)}
+
{bonus > 0 ? `// 含 ¥${bonus} 赠送 · 实到账 ${money(amount + bonus)}` : "// 无赠送"}
+
+
{scanLabel}
{txRef}
+
+
// 5 分钟内有效 · 到账后自动关闭
+
+
+ + +
+
+
, + 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(null); const [teamMembers, setTeamMembers] = useState([]); @@ -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("all"); + const [billMember, setBillMember] = useState("all"); + const billMemberOptions = useMemo(() => { + const set = new Set(); + 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 (
@@ -174,11 +279,12 @@ export function AccountPage({ billing, projects, onRecharge }: { {RECHARGE.map((item) => (
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 && {item.ribbon}}
¥{item.amt}
@@ -190,11 +296,11 @@ export function AccountPage({ billing, projects, onRecharge }: {
自定义金额
setCustomAmt(event.target.value)} />
- - @@ -284,15 +390,35 @@ export function AccountPage({ billing, projects, onRecharge }: {
+
+ + + {billFiltered && ( + + )} + + 本页 {visibleLedgers.length} 条 · 共 {ledgerCount} 条 +
{ledgersLoading && ledgerRows.length === 0 ? ( - ) : ledgerRows.length === 0 ? ( - - ) : ledgerRows.map((l) => ( + ) : visibleLedgers.length === 0 ? ( + + ) : visibleLedgers.map((l) => ( @@ -324,14 +450,37 @@ export function AccountPage({ billing, projects, onRecharge }: {
+
+ + {projFiltered && ( + + )} + + {filteredProjects.length} 个项目 · 消耗 {money(projSpendSum)} +
时间项目 / 类型详情成员状态金额
// 加载中…
// 暂无账单流水
{billFiltered ? "// 本页无匹配筛选条件的账单" : "// 暂无账单流水"}
{new Date(l.created_at).toLocaleString("zh-CN")} {ledgerTypeLabel(l.ledger_type)}
- + - {projects.map((p) => { + {filteredProjects.length === 0 ? ( + + ) : filteredProjects.map((p) => { const spend = projectSpend(p.id); + const stg = projStage(p); + const bucket = projBucket(p); return ( - + + + + + + + ); })} @@ -340,20 +489,57 @@ export function AccountPage({ billing, projects, onRecharge }: {
+
+ + {memFiltered && ( + + )} + + {filteredMembers.length} 人 · 合计 {money(memUsedSum)} +
项目当前阶段状态消耗
项目商品当前阶段状态消耗
// 当前筛选条件下没有项目
{p.name}{p.current_stage}{p.status} 0 ? "neg" : "zero"}>{money(spend)}
{p.name}{p.product_title || "-"}{stg.label}{PROJ_STATUS_LABEL[bucket]} 0 ? "neg" : "zero"}>{money(spend)}
- {teamMembers.map((m) => { + {filteredMembers.length === 0 ? ( + + ) : 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 ( - + + + + + + ); })}
成员角色已用 / 月度额度状态
// 当前筛选条件下没有成员
{m.user.username.slice(0, 1).toUpperCase()}{m.user.username}{ROLE_LABEL[m.role] || m.role}{money(m.month_charged || 0)} / {monthly > 0 ? money(monthly) : "不限"}{STATUS_LABEL[m.status] || m.status}
{m.user.username.slice(0, 1).toUpperCase()}{m.user.username}{ROLE_LABEL[m.role] || m.role} + {money(monthUsed)} / {monthly > 0 ? money(monthly) : "不限"}{monthly > 0 ? ` · ${usedPct.toFixed(1)}%` : ""} + {monthly > 0 && ( + = 85 ? "var(--accent-honey)" : "var(--heat)" }} /> + )} + {STATUS_LABEL[m.status] || m.status}
+ + setTopupChannel(null)} + onDone={submitRecharge} + />
); } diff --git a/core/frontend/src/routes/team.tsx b/core/frontend/src/routes/team.tsx index b2d5a7f..3393d6d 100644 --- a/core/frontend/src/routes/team.tsx +++ b/core/frontend/src/routes/team.tsx @@ -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(null); const [edRole, setEdRole] = useState("member"); + const [edDaily, setEdDaily] = useState(""); const [edMonthly, setEdMonthly] = useState(""); + const [edTotal, setEdTotal] = useState("-1"); // 重置密码 const [resetTarget, setResetTarget] = useState(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
// 成员 · 角色 · 额度 · 共享资产库
- @@ -194,7 +282,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea 充值 - +
@@ -308,7 +396,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea ? 不可编辑 : <> - + } @@ -345,10 +433,38 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea - {/* 设置月限额(团队级)· 注:后端暂无团队级限额端点,真实限额请在「编辑成员」逐人设置 */} - } close={() => setModal("")}>
+ {/* 设置月限额(团队级)· 预设 pill + 实时剩余 */} + } + close={() => setModal("")} + footer={} + > +
+
+ + setLimitVal(e.target.value)} /> +
+
+ +
+ {LIMIT_PRESETS.map((v) => ( + + ))} +
+
+
+
当月已用{yuan(used, 2)}
+
本次调整后剩余{limitLeftPreview == null ? "不限" : yuan(limitLeftPreview, 2)}
+
+
+
- {/* 创建账户 */} + {/* 创建账户 · 用户名/密码 + 随机生成 · 角色双卡 · 三档额度 */} setModal("")} footer={} > -
setCuUser(e.target.value)} placeholder="zhang.yunying" />
-
setCuPass(e.target.value)} placeholder="至少 8 位" />
-
setCuName(e.target.value)} placeholder="张运营" />
-
- +
+
+ +
+ setCuUser(e.target.value)} /> + +
+
+
+ + setCuName(e.target.value)} /> +
+
+ +
+ setCuPass(e.target.value)} /> + +
+
+
+ +
+ {ROLE_CARDS.map((rc) => ( +
setCuRole(rc.value)}> +
{rc.title}
+
{rc.desc}
+
+ ))} +
+
+
+ +
+ + + +
+
+
+ + + {/* 分享凭据 · 创建成功 / 重置成功后弹绿勾 + cred-card + 单项/一键复制 */} + } + close={() => setShare(null)} + footer={} + > +
+
+
+ 登录地址 + {LOGIN_URL} + +
+
+ 用户名 + {share?.username} + +
+
+ {share?.mode === "reset" ? "新密码" : "初始密码"} + {share?.password} + +
+
+
+
建议成员首次登录后立即修改密码
+
凭据请通过加密渠道(企微 / 飞书私聊)分享,不要发到公共群
+
+
+ +
-
setCuMonthly(e.target.value)} placeholder="0" />
{/* 团队充值 */} @@ -379,10 +571,10 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea close={() => setModal("")} footer={} > -
setRechargeAmt(e.target.value)} placeholder="最低 ¥50" />
+
setRechargeAmt(e.target.value)} placeholder="最低 ¥50" />
- {/* 编辑成员 */} + {/* 编辑成员 · 角色双卡 + 三档额度 */} setEditTarget(null)} footer={} > -
- +
+
+ + +
+
+ +
+ {ROLE_CARDS.map((rc) => ( +
setEdRole(rc.value)}> +
{rc.title}
+
{rc.desc}
+
+ ))} +
+
+
+ + setEdDaily(e.target.value)} /> +
+
+ + setEdMonthly(e.target.value)} /> +
+
+ + setEdTotal(e.target.value)} /> +
-
setEdMonthly(e.target.value)} placeholder="0" />
- {/* 重置密码 */} + {/* 重置密码 · 警示框 + 密码输入 + 随机生成 */} } close={() => setResetTarget(null)} - footer={} + footer={} > -
setResetPwd(e.target.value)} placeholder="新密码" />
+
+
+ + 旧密码即刻失效,{resetTarget ? `「${resetTarget.user.username || resetTarget.user.email}」` : "该成员"}需用新密码重新登录 +
+
+ +
+ setResetPwd(e.target.value)} /> + +
+
+
{/* 移除成员确认 */} diff --git a/core/frontend/src/team-page.css b/core/frontend/src/team-page.css index 76c9644..c0abf41 100644 --- a/core/frontend/src/team-page.css +++ b/core/frontend/src/team-page.css @@ -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); } diff --git a/core/qa/visual-parity/_wave3-batch1.mjs b/core/qa/visual-parity/_wave3-batch1.mjs new file mode 100644 index 0000000..47f99bf --- /dev/null +++ b/core/qa/visual-parity/_wave3-batch1.mjs @@ -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));