import { useEffect, useState } from "react"; import { CheckCircle2, CircleDollarSign, Copy, KeyRound, RefreshCw, UserPlus } from "lucide-react"; import { api } from "../api"; import type { BillingSummary, Invitation, Notification, Team, TeamMember, User } from "../types"; import type { Page } from "./route-config"; import { money } from "./stage-config"; import { ConfirmModal, TeamModal } from "../components/overlays"; import { Pager } from "../components/pager"; const MEMBERS_PER_PAGE = 10; // 登录地址必须是用户当前真实访问的域名(与邀请链接同源),写死的 airshelf.com 不存在 → PMC#16 const LOGIN_URL = `${location.origin}/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 } { if (role === "owner" || role === "super") return { key: "super", label: "超管" }; if (role === "admin") return { key: "admin", label: "团管" }; if (role === "viewer") return { key: "member", label: "访客" }; return { key: "member", label: "成员" }; } const PERM_ROWS: Array<{ cap: string; cells: [string, string, string]; last?: boolean }> = [ { cap: "邀请 / 移除成员", cells: ["✓", "✓", "—"] }, { cap: "设置成员额度", cells: ["✓", "✓", "—"] }, { cap: "团队充值", cells: ["✓", "—", "—"] }, { cap: "设置月限额", cells: ["✓", "—", "—"] }, { cap: "编辑别人项目", cells: ["✓", "✓", "—"] }, { cap: "团队共享资产库管理", cells: ["✓", "✓", "仅自传"] }, { cap: "查看团队消费明细", cells: ["✓", "✓", "仅自己"] }, { 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; billing: BillingSummary | null; navigate: (page: Page) => void; onCreateMember: (payload: { username: string; password: string; name?: string; role?: string; daily_credit_limit?: number; monthly_credit_limit?: number; total_credit_limit?: number }) => void | Promise; onUpdateMember: (id: string, payload: { role?: string; daily_credit_limit?: number; monthly_credit_limit?: number; total_credit_limit?: number }) => void | Promise; onRemoveMember: (id: string) => void | Promise; onResetPassword: (id: string, password: string) => void | Promise; onRecharge: (amount: number, bonus: number) => void | Promise; }) { const [modal, setModal] = useState<"" | "invite" | "limit" | "recharge" | "gen-invite">(""); const [search, setSearch] = useState(""); // 团队成员 + 团队动态本页懒加载(不再走全局 bootstrap);成员/充值操作后 bump 刷新 const [reloadFlag, setReloadFlag] = useState(0); const [members, setMembers] = useState([]); const [notifications, setNotifications] = useState([]); const [notificationTotal, setNotificationTotal] = useState(0); useEffect(() => { let alive = true; api.teamMembers().then((m) => { if (alive) setMembers(m); }).catch(() => {}); api.allNotifications().then((n) => { if (alive) { setNotifications(n.results); setNotificationTotal(n.count); } }).catch(() => {}); return () => { alive = false; }; }, [reloadFlag]); const reloadTeam = () => setReloadFlag((n) => n + 1); // 包一层:操作完成后刷新本页成员列表(余额由全局 action→loadData 刷新);透传返回值(调用处据此判成功) const onCreateMember = async (p: Parameters[0]) => { const r = await onCreateMemberRaw(p); reloadTeam(); return r; }; const onUpdateMember = async (id: string, p: Parameters[1]) => { const r = await onUpdateMemberRaw(id, p); reloadTeam(); return r; }; const onRemoveMember = async (id: string) => { const r = await onRemoveMemberRaw(id); reloadTeam(); return r; }; const onRecharge = async (amount: number, bonus: number) => { const r = await onRechargeRaw(amount, bonus); reloadTeam(); return r; }; // 创建账户表单 const [cuUser, setCuUser] = useState(""); const [cuPass, setCuPass] = useState(""); const [cuName, setCuName] = useState(""); const [cuRole, setCuRole] = useState("member"); const [cuDaily, setCuDaily] = useState("100"); const [cuMonthly, setCuMonthly] = useState("100"); // 新成员默认月度限额 100(PMC#3) 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); const [resetPwd, setResetPwd] = useState(""); // 移除成员 const [removeTarget, setRemoveTarget] = useState(null); // 团队充值 const [rechargeAmt, setRechargeAmt] = useState("500"); // 设置月限额:savedMonthlyLimit 持久化已保存值(-1=不限,0=未设置/跟成员累加),limitVal 为弹窗编辑中间态 // 初值取后端团队设置(team.monthly_credit_limit:null=未设置→0),刷新后不再丢(PMC#12) const [savedMonthlyLimit, setSavedMonthlyLimit] = useState( team.monthly_credit_limit == null ? 0 : Number(team.monthly_credit_limit), ); const [limitVal, setLimitVal] = useState("3000"); const [limitBusy, setLimitBusy] = useState(false); // team prop 异步刷新(/me/ 回来)后同步已保存月限额 useEffect(() => { setSavedMonthlyLimit(team.monthly_credit_limit == null ? 0 : Number(team.monthly_credit_limit)); }, [team.monthly_credit_limit]); // 保存团队月限额:乐观更新 + 落库;失败回滚到原值(PMC#12) async function saveLimit() { const v = limitVal.trim() === "" ? -1 : Number(limitVal); if (Number.isNaN(v)) return; const prev = savedMonthlyLimit; setSavedMonthlyLimit(v); setLimitBusy(true); try { await api.updateTeamSettings({ monthly_credit_limit: v }); setModal(""); } catch { setSavedMonthlyLimit(prev); // 落库失败回滚,避免显示假成功 } finally { setLimitBusy(false); } } // 分享凭据弹窗(创建成功 / 重置成功后弹):captured creds(仅展示一次) const [share, setShare] = useState<{ mode: "create" | "reset"; name: string; username: string; password: string } | null>(null); // 邀请码生成(凭码注册即加入本团队,不重复发额度) const [invRole, setInvRole] = useState("member"); const [invMonthly, setInvMonthly] = useState("2000"); const [inviteResult, setInviteResult] = useState(null); const [inviteBusy, setInviteBusy] = useState(false); const [inviteErr, setInviteErr] = useState(""); const rows: TeamMember[] = members.length ? members : [{ id: "owner", role: "owner", status: "active", monthly_credit_limit: "0", user } as TeamMember]; // 统计 · 对齐 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; // limit:优先用已保存的团队月限额(savedMonthlyLimit !== 0);-1 表示不限(显示余额);未设置则用成员额度累加 const memberLimitSum = rows.reduce((sum, member) => sum + Math.max(0, Number(member.monthly_credit_limit || 0)), 0); const limit = savedMonthlyLimit === -1 ? balance : (savedMonthlyLimit > 0 ? savedMonthlyLimit : (memberLimitSum || 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] .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) .slice(0, 6); const needle = search.trim().toLowerCase(); const list = rows.filter((member) => { const name = member.user.username || ""; const email = member.user.email || ""; return !needle || `${name} ${email}`.toLowerCase().includes(needle); }); // 成员分页:每页 10 人,搜索变化回第 1 页 const [memberPage, setMemberPage] = useState(1); useEffect(() => { setMemberPage(1); }, [search]); const memberTotalPages = Math.max(1, Math.ceil(list.length / MEMBERS_PER_PAGE)); 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"); // 回填该成员当前三档真实额度(0=不限 → 输入框留空) setEdDaily(Number(member.daily_credit_limit || 0) > 0 ? String(Number(member.daily_credit_limit)) : ""); setEdMonthly(Number(member.monthly_credit_limit || 0) > 0 ? String(Number(member.monthly_credit_limit)) : ""); setEdTotal(Number(member.total_credit_limit || 0) > 0 ? String(Number(member.total_credit_limit)) : ""); } function openLimit() { // 打开时回填已保存的团队月限额;若未设置则 fallback 成员累加值或默认 3000 const current = savedMonthlyLimit !== 0 ? savedMonthlyLimit : (limit > 0 ? Math.round(limit) : 3000); setLimitVal(String(current)); setModal("limit"); } async function submitCreate() { const username = cuUser.trim(); if (!username || cuPass.length < 8) return; const created = await onCreateMember({ username, password: cuPass, name: cuName.trim() || undefined, role: cuRole, // 三档额度均真实入库:-1/空 = 不限(后端归一成 0)。PMC#17/#18 daily_credit_limit: cuDaily.trim() === "" ? -1 : Number(cuDaily), monthly_credit_limit: cuMonthly.trim() === "" ? -1 : Number(cuMonthly), total_credit_limit: cuTotal.trim() === "" ? -1 : Number(cuTotal) }); // 创建失败(action 报错返回 null)时保持弹窗与已填内容,只成功才关闭并清空 if (!created) return; // 弹分享凭据(凭据仅展示一次,关弹窗后清空表单) setShare({ mode: "create", name: cuName.trim() || username, username, password: cuPass }); setModal(""); setCuUser(""); setCuPass(""); setCuName(""); setCuRole("member"); setCuDaily("100"); setCuMonthly("2000"); setCuTotal("-1"); } async function submitEdit() { if (!editTarget) return; // 三档额度均下发:留空 = 不限(-1,后端归一成 0)。PMC#17/#18 await onUpdateMember(editTarget.id, { role: edRole, daily_credit_limit: edDaily.trim() === "" ? -1 : Number(edDaily), monthly_credit_limit: edMonthly.trim() === "" ? -1 : Number(edMonthly), total_credit_limit: edTotal.trim() === "" ? -1 : Number(edTotal) }); setEditTarget(null); } async function submitReset() { if (!resetTarget || resetPwd.length < 8) return; 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(""); } async function submitRemove() { if (!removeTarget) return; await onRemoveMember(removeTarget.id); setRemoveTarget(null); } async function submitRecharge() { const amt = Number(rechargeAmt) || 0; if (amt <= 0) return; await onRecharge(amt, 0); setModal(""); } return (

团队管理

// 成员 · 角色 · 额度 · 共享资产库
{/* 顶部行:团队 banner(左)+ 团队动态(右) */}
[ TEAM ]
{team.name} 企业
// 团队 ID: {team.id} · {rows.length} 名成员
[ 充值余额 ]
{money(balance)}
// 团队总池
[ 月限额 ]
{money(limit)}
// 自然月重置
[ 当月已用 ]
{money(used)}
// 占月限 {pct.toFixed(1)}%
[ 当月剩余 ]
{money(left)}
// 还可生成约 {Math.max(0, Math.round(left / 10))} 个项目
{/* 团队动态(banner 右栏)· 接真实 ops/notifications 事件流 */}

团队动态

// 最近 {Math.min(feedItems.length, 6)} 条 · 共 {notificationTotal ?? notifications.length} navigate("messages")}>全部 →
{feedItems.length === 0 ? (
{(team.name || "T").slice(0, 1).toUpperCase()}
{team.name}已同步{rows.length} 名成员
// 暂无团队动态
) : ( feedItems.map((n) => (
{(n.owner_label || n.source || team.name || "·").slice(0, 1).toUpperCase()}
{n.owner_label || n.source || "系统"} {n.title} {n.project_name && {n.project_name}}
{n.created_at ? new Date(n.created_at).toLocaleString("zh-CN") : n.brief}
)) )}
{/* /.team-top */}
{/* 左:成员表 */}

成员列表 // {list.length} / {rows.length} 人 · 真实团队表

setSearch(event.target.value)} />
{pagedMembers.map((member) => { const rawName = (member.user.username || "").trim(); const email = (member.user.email || "").trim(); // 用户名是邮箱时取 @ 前作为显示名,完整邮箱作副行,避免名字与邮箱重复显示 const displayName = rawName && !rawName.includes("@") ? rawName : (email ? email.split("@")[0] : (rawName || "成员")); const showEmail = !!email && email.toLowerCase() !== displayName.toLowerCase(); const role = roleUi(member.role); const daily = Number(member.daily_credit_limit || 0); const totalLimit = Number(member.total_credit_limit || 0); const dayUsed = Number(member.day_charged || 0); const totalUsed = Number(member.total_charged || 0); const monthly = Number(member.monthly_credit_limit || 0); const memberUsed = Number(member.month_charged || 0); // 月度不限时按团队月限额/余额作分母给参考进度;有消费即显可见细条,避免「用了钱进度条却空白」 const quotaDenom = monthly > 0 ? monthly : limit; const memberPct = quotaDenom > 0 ? Math.min(100, (memberUsed / quotaDenom) * 100) : 0; const barWidth = memberUsed > 0 ? Math.max(memberPct, 3) : 0; const barClass = memberPct >= 80 ? "warn" : "ok"; const isOwner = member.role === "owner"; return ( ); })}
成员 角色 每日额度 月度额度 总额度 当月已用 操作
{displayName.slice(0, 1).toUpperCase()}{displayName}{showEmail && {email}} {role.label} {daily > 0 ? money(daily) : "不限"}{daily > 0 && 今日用 {money(dayUsed)}} {monthly > 0 ? money(monthly) : "不限"} {totalLimit > 0 ? money(totalLimit) : "不限"}{totalLimit > 0 && 累计用 {money(totalUsed)}}
{money(memberUsed)} / {monthly > 0 ? `${memberPct.toFixed(0)}%` : "不限"}
{isOwner ? 不可编辑 : <> }
{/* 右:权限矩阵 */}

角色权限

// PRD §10.2 权限矩阵节选
{PERM_ROWS.map((row) => ( {row.cells.map((cell, index) => ( ))} ))}
能力超管团管成员
{row.cap}{cell}
{/* 设置月限额(团队级)· 预设 pill + 实时剩余 */} } close={() => setModal("")} dismissable={false} footer={inviteResult ? : } >
{!inviteResult ? ( <>
setInvMonthly(e.target.value)} />
{inviteErr &&
{inviteErr}
} ) : (
邀请码
{inviteResult.code}
邀请链接
{`${location.origin}${inviteResult.register_url}`}
// 角色 {invRole === "admin" ? "团队管理员" : "成员"} · 7 天有效 · 凭码注册即加入,不重复发额度
)}
} close={() => setModal("")} dismissable={false} footer={} >
setLimitVal(e.target.value)} />
{LIMIT_PRESETS.map((v) => ( ))}
当月已用{yuan(used, 2)}
本次调整后剩余{limitLeftPreview == null ? "不限" : yuan(limitLeftPreview, 2)}
{/* 创建账户 · 用户名/密码 + 随机生成 · 角色双卡 · 三档额度 */} } close={() => setModal("")} footer={} dismissable={false} >
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}
建议成员首次登录后立即修改密码
凭据请通过加密渠道(企微 / 飞书私聊)分享,不要发到公共群
{/* 团队充值 */} } close={() => setModal("")} dismissable={false} footer={} >
setRechargeAmt(e.target.value)} placeholder="最低 ¥50" />
{/* 编辑成员 · 角色双卡 + 三档额度 */} } close={() => setEditTarget(null)} footer={} dismissable={false} >
{ROLE_CARDS.map((rc) => (
setEdRole(rc.value)}>
{rc.title}
{rc.desc}
))}
setEdDaily(e.target.value)} />
setEdMonthly(e.target.value)} />
setEdTotal(e.target.value)} />
{/* 重置密码 · 警示框 + 密码输入 + 随机生成 */} } close={() => setResetTarget(null)} footer={} dismissable={false} >
旧密码即刻失效,{resetTarget ? `「${resetTarget.user.username || resetTarget.user.email}」` : "该成员"}需用新密码重新登录
setResetPwd(e.target.value)} />
{/* 移除成员确认 */} setRemoveTarget(null)} onConfirm={submitRemove} dismissable={false} />
); }