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
@@ -170,8 +170,38 @@
|
|||||||
.billing-table .progress-mini > span { display: block; height: 100%; background: var(--heat); }
|
.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 { 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 select { padding-right: 24px; }
|
||||||
.filter-bar .spacer { flex: 1; }
|
.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 { 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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { api } from "../api";
|
||||||
import type { BillingSummary, BillingTrend, Ledger, Project, TeamMember } from "../types";
|
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 { pageWindow } from "../components/pager";
|
||||||
|
import { useBodyScrollLock, useOverlayTransition } from "../components/overlays";
|
||||||
|
|
||||||
const ROLE_LABEL: Record<string, string> = { owner: "超管", admin: "团管", member: "成员", viewer: "访客" };
|
const ROLE_LABEL: Record<string, string> = { owner: "超管", admin: "团管", member: "成员", viewer: "访客" };
|
||||||
const STATUS_LABEL: Record<string, string> = { active: "活跃", invited: "待激活", disabled: "已停用" };
|
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";
|
type TrendRange = "day" | "week" | "month";
|
||||||
const RANGE_META: Record<TrendRange, { chip: string; sub: string; totalLabel: string; avgLabel: string }> = {
|
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";
|
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> = {
|
const LEDGER_TYPE_LABEL: Record<string, string> = {
|
||||||
recharge: "充值", reserve: "预扣", release: "释放", charge: "扣费", adjustment: "调整", refund: "退款"
|
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 ledgerTypeLabel = (t: string) => LEDGER_TYPE_LABEL[t] ?? t;
|
||||||
const ledgerReasonLabel = (r: string) => LEDGER_REASON_LABEL[r] ?? r;
|
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 }> = [
|
const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: number; ribbon?: string }> = [
|
||||||
{ amt: 100, gift: "无赠送", bonus: false, bonusAmt: 0 },
|
{ amt: 100, gift: "无赠送", bonus: false, bonusAmt: 0 },
|
||||||
{ amt: 500, gift: "+ ¥30 赠送", bonus: true, bonusAmt: 30, ribbon: "推荐" },
|
{ 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" }
|
{ 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 }: {
|
export function AccountPage({ billing, projects, onRecharge }: {
|
||||||
billing: BillingSummary | null;
|
billing: BillingSummary | null;
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
@@ -56,6 +121,9 @@ export function AccountPage({ billing, projects, onRecharge }: {
|
|||||||
// 账户页数据本页自取(不再走全局 bootstrap):充值后 bump 刷新流水/趋势
|
// 账户页数据本页自取(不再走全局 bootstrap):充值后 bump 刷新流水/趋势
|
||||||
const [reloadFlag, setReloadFlag] = useState(0);
|
const [reloadFlag, setReloadFlag] = useState(0);
|
||||||
|
|
||||||
|
// 充值扫码弹窗:点支付方式先弹弹窗,弹窗内「已完成支付」才真正提交
|
||||||
|
const [topupChannel, setTopupChannel] = useState<"wechat" | "alipay" | null>(null);
|
||||||
|
|
||||||
// 趋势(day,首屏)+ 团队成员(成员/限额 tab)本页懒加载
|
// 趋势(day,首屏)+ 团队成员(成员/限额 tab)本页懒加载
|
||||||
const [trend, setTrend] = useState<BillingTrend | null>(null);
|
const [trend, setTrend] = useState<BillingTrend | null>(null);
|
||||||
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
|
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
|
||||||
@@ -91,7 +159,8 @@ export function AccountPage({ billing, projects, onRecharge }: {
|
|||||||
|
|
||||||
async function submitRecharge() {
|
async function submitRecharge() {
|
||||||
if (effectiveAmount <= 0) return;
|
if (effectiveAmount <= 0) return;
|
||||||
await onRecharge(effectiveAmount, effectiveBonus);
|
await onRecharge(effectiveAmount, effectiveBonus); // 父级 action 成功后弹全局「充值成功」toast
|
||||||
|
setTopupChannel(null);
|
||||||
setCustomAmt("");
|
setCustomAmt("");
|
||||||
setBillPage(1);
|
setBillPage(1);
|
||||||
setReloadFlag((n) => n + 1); // 充值后刷新流水/趋势(余额由全局 action→loadData 刷新)
|
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 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);
|
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 (
|
return (
|
||||||
<section className="account-page">
|
<section className="account-page">
|
||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
@@ -174,11 +279,12 @@ export function AccountPage({ billing, projects, onRecharge }: {
|
|||||||
{RECHARGE.map((item) => (
|
{RECHARGE.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.amt}
|
key={item.amt}
|
||||||
className={`recharge-card ${recharge === item.amt ? "selected" : ""}`}
|
className={`recharge-card ${recharge === item.amt && !customAmt ? "selected" : ""}`}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-pressed={recharge === item.amt}
|
aria-pressed={recharge === item.amt && !customAmt}
|
||||||
onClick={() => setRecharge(item.amt)}
|
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>}
|
{item.ribbon && <span className="ribbon">{item.ribbon}</span>}
|
||||||
<div className="amt">¥{item.amt}</div>
|
<div className="amt">¥{item.amt}</div>
|
||||||
@@ -190,11 +296,11 @@ export function AccountPage({ billing, projects, onRecharge }: {
|
|||||||
<div className="pay-title">自定义金额</div>
|
<div className="pay-title">自定义金额</div>
|
||||||
<input className="input" placeholder="最低 ¥50,可输入任意金额" type="number" value={customAmt} onChange={(event) => setCustomAmt(event.target.value)} />
|
<input className="input" placeholder="最低 ¥50,可输入任意金额" type="number" value={customAmt} onChange={(event) => setCustomAmt(event.target.value)} />
|
||||||
<div className="pay-btn-row">
|
<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>
|
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-wechat.png" alt="" /></span>
|
||||||
微信支付
|
微信支付
|
||||||
</button>
|
</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>
|
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-alipay.png" alt="" /></span>
|
||||||
支付宝
|
支付宝
|
||||||
</button>
|
</button>
|
||||||
@@ -284,15 +390,35 @@ export function AccountPage({ billing, projects, onRecharge }: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`tab-panel ${tab === "bills" ? "active" : ""}`}>
|
<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">
|
<div className="billing-table-wrap">
|
||||||
<table className="billing-table">
|
<table className="billing-table">
|
||||||
<thead><tr><th>时间</th><th>项目 / 类型</th><th>详情</th><th>成员</th><th>状态</th><th style={{ textAlign: "right" }}>金额</th></tr></thead>
|
<thead><tr><th>时间</th><th>项目 / 类型</th><th>详情</th><th>成员</th><th>状态</th><th style={{ textAlign: "right" }}>金额</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{ledgersLoading && ledgerRows.length === 0 ? (
|
{ledgersLoading && ledgerRows.length === 0 ? (
|
||||||
<tr><td colSpan={6} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>// 加载中…</td></tr>
|
<tr><td colSpan={6} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>// 加载中…</td></tr>
|
||||||
) : ledgerRows.length === 0 ? (
|
) : visibleLedgers.length === 0 ? (
|
||||||
<tr><td colSpan={6} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>// 暂无账单流水</td></tr>
|
<tr><td colSpan={6} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>{billFiltered ? "// 本页无匹配筛选条件的账单" : "// 暂无账单流水"}</td></tr>
|
||||||
) : ledgerRows.map((l) => (
|
) : visibleLedgers.map((l) => (
|
||||||
<tr key={l.id}>
|
<tr key={l.id}>
|
||||||
<td className="ts">{new Date(l.created_at).toLocaleString("zh-CN")}</td>
|
<td className="ts">{new Date(l.created_at).toLocaleString("zh-CN")}</td>
|
||||||
<td>{ledgerTypeLabel(l.ledger_type)}</td>
|
<td>{ledgerTypeLabel(l.ledger_type)}</td>
|
||||||
@@ -324,14 +450,37 @@ export function AccountPage({ billing, projects, onRecharge }: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`tab-panel ${tab === "by-project" ? "active" : ""}`}>
|
<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">
|
<div className="billing-table-wrap">
|
||||||
<table className="billing-table">
|
<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>
|
<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 spend = projectSpend(p.id);
|
||||||
|
const stg = projStage(p);
|
||||||
|
const bucket = projBucket(p);
|
||||||
return (
|
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>
|
</tbody>
|
||||||
@@ -340,20 +489,57 @@ export function AccountPage({ billing, projects, onRecharge }: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`tab-panel ${tab === "by-member" ? "active" : ""}`}>
|
<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">
|
<div className="billing-table-wrap">
|
||||||
<table className="billing-table">
|
<table className="billing-table">
|
||||||
<thead><tr><th>成员</th><th>角色</th><th>已用 / 月度额度</th><th>状态</th></tr></thead>
|
<thead><tr><th>成员</th><th>角色</th><th>已用 / 月度额度</th><th>状态</th></tr></thead>
|
||||||
<tbody>
|
<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 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 (
|
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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TopupModal
|
||||||
|
open={topupChannel !== null}
|
||||||
|
channel={topupChannel ?? "wechat"}
|
||||||
|
amount={effectiveAmount}
|
||||||
|
bonus={effectiveBonus}
|
||||||
|
close={() => setTopupChannel(null)}
|
||||||
|
onDone={submitRecharge}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 { api } from "../api";
|
||||||
import type { BillingSummary, Notification, Team, TeamMember, User } from "../types";
|
import type { BillingSummary, Notification, Team, TeamMember, User } from "../types";
|
||||||
import type { Page } from "./route-config";
|
import type { Page } from "./route-config";
|
||||||
@@ -8,6 +8,49 @@ import { ConfirmModal, TeamModal } from "../components/overlays";
|
|||||||
import { Pager } from "../components/pager";
|
import { Pager } from "../components/pager";
|
||||||
|
|
||||||
const MEMBERS_PER_PAGE = 10;
|
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)
|
// 角色 → pill key/label(对齐 api-bridge roleUi)
|
||||||
function roleUi(role: string): { key: "super" | "admin" | "member"; label: string } {
|
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 }
|
{ 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 }: {
|
export function TeamPage({ team, user, billing, navigate, onCreateMember: onCreateMemberRaw, onUpdateMember: onUpdateMemberRaw, onRemoveMember: onRemoveMemberRaw, onResetPassword, onRecharge: onRechargeRaw }: {
|
||||||
team: Team;
|
team: Team;
|
||||||
user: User;
|
user: User;
|
||||||
@@ -65,12 +114,16 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
|||||||
const [cuPass, setCuPass] = useState("");
|
const [cuPass, setCuPass] = useState("");
|
||||||
const [cuName, setCuName] = useState("");
|
const [cuName, setCuName] = useState("");
|
||||||
const [cuRole, setCuRole] = useState("member");
|
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 [editTarget, setEditTarget] = useState<TeamMember | null>(null);
|
||||||
const [edRole, setEdRole] = useState("member");
|
const [edRole, setEdRole] = useState("member");
|
||||||
|
const [edDaily, setEdDaily] = useState("");
|
||||||
const [edMonthly, setEdMonthly] = useState("");
|
const [edMonthly, setEdMonthly] = useState("");
|
||||||
|
const [edTotal, setEdTotal] = useState("-1");
|
||||||
|
|
||||||
// 重置密码
|
// 重置密码
|
||||||
const [resetTarget, setResetTarget] = useState<TeamMember | null>(null);
|
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 [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
|
const rows: TeamMember[] = members.length
|
||||||
? members
|
? members
|
||||||
: [{ id: "owner", role: "owner", status: "active", monthly_credit_limit: "0", user } as TeamMember];
|
: [{ 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
|
// 统计 · 对齐 api-bridge renderLiveTeamPayload
|
||||||
const balance = Number(billing?.account.balance || 0);
|
const balance = Number(billing?.account.balance || 0);
|
||||||
const used = Number(billing?.charged_total || 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 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 left = Math.max(0, limit - used);
|
||||||
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0;
|
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 条真实通知
|
// 团队动态:取最近 6 条真实通知
|
||||||
const feedItems = [...notifications]
|
const feedItems = [...notifications]
|
||||||
@@ -111,29 +174,49 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
|||||||
const memberCurPage = Math.min(memberPage, memberTotalPages);
|
const memberCurPage = Math.min(memberPage, memberTotalPages);
|
||||||
const pagedMembers = list.slice((memberCurPage - 1) * MEMBERS_PER_PAGE, memberCurPage * MEMBERS_PER_PAGE);
|
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) {
|
function openEdit(member: TeamMember) {
|
||||||
setEditTarget(member);
|
setEditTarget(member);
|
||||||
setEdRole(member.role === "owner" ? "admin" : member.role || "member");
|
setEdRole(member.role === "owner" ? "admin" : member.role || "member");
|
||||||
|
setEdDaily("");
|
||||||
setEdMonthly(Number(member.monthly_credit_limit || 0) > 0 ? String(Number(member.monthly_credit_limit)) : "");
|
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() {
|
async function submitCreate() {
|
||||||
if (!cuUser.trim() || cuPass.length < 8) return;
|
const username = cuUser.trim();
|
||||||
|
if (!username || cuPass.length < 8) return;
|
||||||
const created = await onCreateMember({
|
const created = await onCreateMember({
|
||||||
username: cuUser.trim(),
|
username,
|
||||||
password: cuPass,
|
password: cuPass,
|
||||||
name: cuName.trim() || undefined,
|
name: cuName.trim() || undefined,
|
||||||
role: cuRole,
|
role: cuRole,
|
||||||
|
// 后端成员额度仅 monthly_credit_limit;每日/总额为展示档,不入参
|
||||||
monthly_credit_limit: Number(cuMonthly) || 0
|
monthly_credit_limit: Number(cuMonthly) || 0
|
||||||
});
|
});
|
||||||
// 创建失败(action 报错返回 null)时保持弹窗与已填内容,只成功才关闭并清空
|
// 创建失败(action 报错返回 null)时保持弹窗与已填内容,只成功才关闭并清空
|
||||||
if (!created) return;
|
if (!created) return;
|
||||||
|
// 弹分享凭据(凭据仅展示一次,关弹窗后清空表单)
|
||||||
|
setShare({ mode: "create", name: cuName.trim() || username, username, password: cuPass });
|
||||||
setModal("");
|
setModal("");
|
||||||
setCuUser("");
|
setCuUser("");
|
||||||
setCuPass("");
|
setCuPass("");
|
||||||
setCuName("");
|
setCuName("");
|
||||||
setCuRole("member");
|
setCuRole("member");
|
||||||
setCuMonthly("");
|
setCuDaily("100");
|
||||||
|
setCuMonthly("2000");
|
||||||
|
setCuTotal("-1");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitEdit() {
|
async function submitEdit() {
|
||||||
@@ -144,7 +227,12 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
|||||||
|
|
||||||
async function submitReset() {
|
async function submitReset() {
|
||||||
if (!resetTarget || resetPwd.length < 8) return;
|
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);
|
setResetTarget(null);
|
||||||
setResetPwd("");
|
setResetPwd("");
|
||||||
}
|
}
|
||||||
@@ -170,7 +258,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
|||||||
<div className="sub"><span className="mono">// 成员 · 角色 · 额度 · 共享资产库</span></div>
|
<div className="sub"><span className="mono">// 成员 · 角色 · 额度 · 共享资产库</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<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>
|
<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>
|
</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>
|
<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>
|
||||||
<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>
|
||||||
</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>
|
? <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={() => 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>
|
<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>
|
</>}</div></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -345,10 +433,38 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 设置月限额(团队级)· 注:后端暂无团队级限额端点,真实限额请在「编辑成员」逐人设置 */}
|
{/* 设置月限额(团队级)· 预设 pill + 实时剩余 */}
|
||||||
<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>
|
<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
|
<TeamModal
|
||||||
open={modal === "invite"}
|
open={modal === "invite"}
|
||||||
title="创建账户"
|
title="创建账户"
|
||||||
@@ -357,17 +473,93 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
|||||||
close={() => setModal("")}
|
close={() => setModal("")}
|
||||||
footer={<button className="btn btn-primary" type="button" onClick={submitCreate}>创建账户</button>}
|
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="create-acct-modal">
|
||||||
<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">
|
||||||
<div className="field"><label className="field-label">姓名(可选)</label><input className="input" value={cuName} onChange={(e) => setCuName(e.target.value)} placeholder="张运营" /></div>
|
<label className="field-label">用户名 <span className="req">*</span></label>
|
||||||
<div className="field"><label className="field-label">角色</label>
|
<div className="input-with-gen">
|
||||||
<select className="input" value={cuRole} onChange={(e) => setCuRole(e.target.value)}>
|
<input className="input" autoComplete="off" placeholder="例: zhang.yunying" value={cuUser} onChange={(e) => setCuUser(e.target.value)} />
|
||||||
<option value="admin">团管</option>
|
<button className="btn btn-sm" type="button" title="生成随机用户名" onClick={() => setCuUser(genUsername())}><RefreshCw size={13} /></button>
|
||||||
<option value="member">成员</option>
|
</div>
|
||||||
<option value="viewer">访客</option>
|
</div>
|
||||||
</select>
|
<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>
|
||||||
<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>
|
</TeamModal>
|
||||||
|
|
||||||
{/* 团队充值 */}
|
{/* 团队充值 */}
|
||||||
@@ -379,10 +571,10 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
|||||||
close={() => setModal("")}
|
close={() => setModal("")}
|
||||||
footer={<button className="btn btn-primary" type="button" onClick={submitRecharge}>确认充值</button>}
|
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>
|
||||||
|
|
||||||
{/* 编辑成员 */}
|
{/* 编辑成员 · 角色双卡 + 三档额度 */}
|
||||||
<TeamModal
|
<TeamModal
|
||||||
open={!!editTarget}
|
open={!!editTarget}
|
||||||
title="编辑成员"
|
title="编辑成员"
|
||||||
@@ -391,26 +583,59 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
|||||||
close={() => setEditTarget(null)}
|
close={() => setEditTarget(null)}
|
||||||
footer={<button className="btn btn-primary" type="button" onClick={submitEdit}>保存</button>}
|
footer={<button className="btn btn-primary" type="button" onClick={submitEdit}>保存</button>}
|
||||||
>
|
>
|
||||||
<div className="field"><label className="field-label">角色</label>
|
<div className="edit-member-modal">
|
||||||
<select className="input" value={edRole} onChange={(e) => setEdRole(e.target.value)}>
|
<div className="field">
|
||||||
<option value="admin">团管</option>
|
<label className="field-label">用户名 <span className="lbl-note">(无权修改)</span></label>
|
||||||
<option value="member">成员</option>
|
<input className="input readonly-input" readOnly tabIndex={-1} value={editTarget?.user.username || editTarget?.user.email || ""} />
|
||||||
<option value="viewer">访客</option>
|
</div>
|
||||||
</select>
|
<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>
|
||||||
<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>
|
||||||
|
|
||||||
{/* 重置密码 */}
|
{/* 重置密码 · 警示框 + 密码输入 + 随机生成 */}
|
||||||
<TeamModal
|
<TeamModal
|
||||||
open={!!resetTarget}
|
open={!!resetTarget}
|
||||||
title="重置密码"
|
title="重置登录密码"
|
||||||
subtitle={resetTarget ? `// ${resetTarget.user.username || resetTarget.user.email}` : ""}
|
subtitle="// 该成员当前会话会被强制下线"
|
||||||
icon={<KeyRound size={16} />}
|
icon={<KeyRound size={16} />}
|
||||||
close={() => setResetTarget(null)}
|
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>
|
</TeamModal>
|
||||||
|
|
||||||
{/* 移除成员确认 */}
|
{/* 移除成员确认 */}
|
||||||
|
|||||||
@@ -101,8 +101,8 @@
|
|||||||
.members-table .used-bar > span.ok { background: var(--accent-forest); }
|
.members-table .used-bar > span.ok { background: var(--accent-forest); }
|
||||||
.members-table .used-bar > span.warn { background: #B45309; }
|
.members-table .used-bar > span.warn { background: #B45309; }
|
||||||
.members-table .acts { display: flex; gap: 4px; justify-content: flex-end; }
|
.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 { 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); }
|
.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 svg { width: 14px; height: 14px; }
|
||||||
.members-table .icon-btn-sm.danger:hover { color: var(--accent-crimson); border-color: var(--accent-crimson); }
|
.members-table .icon-btn-sm.danger:hover { color: var(--accent-crimson); border-color: var(--accent-crimson); }
|
||||||
.members-table tr.pending td { opacity: .65; }
|
.members-table tr.pending td { opacity: .65; }
|
||||||
@@ -117,3 +117,71 @@
|
|||||||
.perm-table .yes { color: var(--accent-forest); font-weight: 600; }
|
.perm-table .yes { color: var(--accent-forest); font-weight: 600; }
|
||||||
.perm-table .no { color: var(--black-alpha-32); }
|
.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); }
|
||||||
|
|||||||
@@ -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));
|
||||||
Reference in New Issue
Block a user