import { useEffect, useState } from "react"; import { CheckCircle2, CircleDollarSign, Copy, Gauge, KeyRound, RefreshCw, Search, UserPlus } from "lucide-react"; import { api } from "../api"; import type { BillingSummary, BillingTrend, 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 { CustomSelect } from "../components/custom-select"; import { Pager } from "../components/pager"; const MEMBERS_PER_PAGE = 10; type TrendRange = "day" | "week" | "month"; const TREND_CHIPS: Array<{ key: TrendRange; label: string }> = [ { key: "day", label: "日" }, { key: "week", label: "周" }, { key: "month", label: "月" }, ]; const STAGE_SPEND: Array<{ key: keyof BillingTrend["by_stage"]; label: string }> = [ { key: "video", label: "视频生成" }, { key: "storyboard", label: "故事板" }, { key: "base", label: "基础资产" }, { key: "script", label: "脚本生成" }, ]; // 登录地址必须是用户当前真实访问的域名(与邀请链接同源),写死的 airshelf.com 不存在 → PMC#16 function shortTs(iso?: string) { if (!iso) return ""; const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; const pad = (n: number) => String(n).padStart(2, "0"); return `${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } 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; } // 千分位积分格式化(积分制:限额/已用全是积分) 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(() => {}); // 月限额自取最新值:team prop 来自登录时 /me/ 快照,改完限额换页再回来会显示旧值(R72) api.teamSettings().then((t) => { if (alive) setSavedMonthlyLimit(t.monthly_credit_limit == null ? 0 : Number(t.monthly_credit_limit)); }).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; }; // 积分汇率(积分/¥):充值 hint 与后端到账口径同源,汇率改配后不写死 ×10 const [pointsRate, setPointsRate] = useState(10); useEffect(() => { void api.billingConfig().then((cfg) => setPointsRate(Number(cfg.points_per_yuan) || 10)).catch(() => undefined); }, []); const [trendRange, setTrendRange] = useState("day"); const [trend, setTrend] = useState(null); useEffect(() => { let alive = true; api.billingTrend(trendRange).then((d) => { if (alive) setTrend(d); }).catch(() => {}); return () => { alive = false; }; }, [trendRange]); // 创建账户表单 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 { const updated = await api.updateTeamSettings({ monthly_credit_limit: v }); // 以后端回传的落库值为准(消费页同源读 teamSettings,保证两页立即一致 · R72) setSavedMonthlyLimit(updated.monthly_credit_limit == null ? 0 : Number(updated.monthly_credit_limit)); 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); const daily = trend?.daily ?? []; const peak = Number(trend?.peak || 0); const stageSpend = STAGE_SPEND.map((s) => ({ ...s, amount: Number(trend?.by_stage[s.key] || 0) })); const stageSum = stageSpend.reduce((s, x) => s + x.amount, 0); const topStage = stageSpend.reduce((a, b) => (b.amount > a.amount ? b : a), stageSpend[0]); const topPct = stageSum > 0 ? Math.round((topStage.amount / stageSum) * 100) : 0; 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 (

团队中心

在一个页面管理成员协作与额度消费
TEAM

{team.name}

企业团队 · {rows.length} 名成员

团队 ID:{team.id}

团队余额 {money(balance)} 团队总池
月限额 {money(limit)} 自然月重置
当月已用 {money(used)} 使用率 {pct.toFixed(1)}%
当月剩余 {money(left)} 还可生成约 {Math.max(0, Math.round(left / 10))} 个项目

成员与团队动态

成员权限和最近协作记录

成员列表

成员 角色 每日额度 月额度 总额度 当月已用 操作
{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 dailyLimit = 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 : team.name}
{role.label} {dailyLimit > 0 ? money(dailyLimit) : "不限"}{dailyLimit > 0 ? ` · 今日 ${money(dayUsed)}` : ""} {monthly > 0 ? money(monthly) : "不限"} {totalLimit > 0 ? money(totalLimit) : "不限"}{totalLimit > 0 ? ` · 累计 ${money(totalUsed)}` : ""}
{money(memberUsed)}
{isOwner ? 不可编辑 : <> }
); })}

最近动态

{notificationTotal || feedItems.length} 条
{feedItems.length === 0 ? (

{team.name} 暂无团队动态

已同步 {rows.length} 名成员
) : ( feedItems.map((n) => (

{n.owner_label || n.source || "系统"} {n.title}

{shortTs(n.created_at)}{n.cost_label ? ` · ${n.cost_label}` : ""}{n.project_name ? ` · ${n.project_name}` : ""}
)) )}

消费概览

{trendRange === "week" ? "近 8 周" : trendRange === "month" ? "近 6 个月" : "近 14 天"} · 单位:积分

消费趋势

{TREND_CHIPS.map((chip) => ( ))}
本月已用 {money(used)}
本月剩余 {money(left)}
主要消耗 {stageSum > 0 ? `${topStage.label} ${topPct}%` : "暂无消耗"}
{daily.length === 0 ? (
暂无消费记录
) : daily.map((d) => { const amt = Number(d.amount); const h = peak > 0 ? Math.max(amt > 0 ? 8 : 4, (amt / peak) * 100) : 8; return (
{amt > 0 ? Math.round(amt) : ""}
{d.label}
); })}

角色权限

超管 / 团管 / 成员能力对照

{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" />
到账积分 = 支付金额 × {pointsRate}(当前汇率)
{/* 编辑成员 · 角色双卡 + 三档额度 */} } 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} />
); }