Files
yingqing/core/frontend/src/routes/team.tsx
T
zycandClaude Opus 4.8 f1bc5933ef perf(core): 非资产全局态也改按页懒加载,bootstrap 从 ~10 接口瘦到 ~6
启动只保留「每页 shell 都要」的全局数据:商品/项目(侧栏+导航)、余额(顶栏)、
模型配置(多生成页+pipeline 共用)、未读数(侧栏徽标,page_size=1 轻量)。其余移到各页按需:
- 账户页:流水(本就服务端分页)+ 趋势 + 团队成员 本页自取,充值后刷新。
- 团队页:成员 + 团队动态 本页自取,增删成员/充值后刷新。
- 生图工厂:任务历史 + 任务缩略图本页自取。
- 通知:bootstrap 只取 page_size=1 拿未读数(notificationsBadge),列表由消息中心/团队页各自拉。
- 全局默认分页改 DefaultPagination(支持 ?page_size 覆盖,默认 20 上限 200):
  根治 projects/products 等列表端点忽略 page_size、数据过 20 即被迫翻页(同 assets 旧病)。

闸:9 页功能正常、无瀑布、API 检查全过;首屏接口数 5~8(原 8~13)。perf 预算下调到新基线。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 18:58:53 +08:00

428 lines
23 KiB
TypeScript

import { useEffect, useState } from "react";
import { CircleDollarSign, KeyRound, UserPlus } from "lucide-react";
import { api } from "../api";
import type { BillingSummary, 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;
// 角色 → 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 }
];
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; monthly_credit_limit?: number }) => void | Promise<unknown>;
onUpdateMember: (id: string, payload: { role?: string; monthly_credit_limit?: number }) => void | Promise<unknown>;
onRemoveMember: (id: string) => void | Promise<unknown>;
onResetPassword: (id: string, password: string) => void | Promise<unknown>;
onRecharge: (amount: number, bonus: number) => void | Promise<unknown>;
}) {
const [modal, setModal] = useState<"" | "invite" | "limit" | "recharge">("");
const [search, setSearch] = useState("");
// 团队成员 + 团队动态本页懒加载(不再走全局 bootstrap);成员/充值操作后 bump 刷新
const [reloadFlag, setReloadFlag] = useState(0);
const [members, setMembers] = useState<TeamMember[]>([]);
const [notifications, setNotifications] = useState<Notification[]>([]);
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<typeof onCreateMemberRaw>[0]) => { const r = await onCreateMemberRaw(p); reloadTeam(); return r; };
const onUpdateMember = async (id: string, p: Parameters<typeof onUpdateMemberRaw>[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 [cuMonthly, setCuMonthly] = useState("");
// 编辑成员
const [editTarget, setEditTarget] = useState<TeamMember | null>(null);
const [edRole, setEdRole] = useState("member");
const [edMonthly, setEdMonthly] = useState("");
// 重置密码
const [resetTarget, setResetTarget] = useState<TeamMember | null>(null);
const [resetPwd, setResetPwd] = useState("");
// 移除成员
const [removeTarget, setRemoveTarget] = useState<TeamMember | null>(null);
// 团队充值
const [rechargeAmt, setRechargeAmt] = useState("500");
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 limit = rows.reduce((sum, member) => sum + Math.max(0, Number(member.monthly_credit_limit || 0)), 0) || balance;
const left = Math.max(0, limit - used);
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0;
// 团队动态:取最近 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 openEdit(member: TeamMember) {
setEditTarget(member);
setEdRole(member.role === "owner" ? "admin" : member.role || "member");
setEdMonthly(Number(member.monthly_credit_limit || 0) > 0 ? String(Number(member.monthly_credit_limit)) : "");
}
async function submitCreate() {
if (!cuUser.trim() || cuPass.length < 8) return;
const created = await onCreateMember({
username: cuUser.trim(),
password: cuPass,
name: cuName.trim() || undefined,
role: cuRole,
monthly_credit_limit: Number(cuMonthly) || 0
});
// 创建失败(action 报错返回 null)时保持弹窗与已填内容,只成功才关闭并清空
if (!created) return;
setModal("");
setCuUser("");
setCuPass("");
setCuName("");
setCuRole("member");
setCuMonthly("");
}
async function submitEdit() {
if (!editTarget) return;
await onUpdateMember(editTarget.id, { role: edRole, monthly_credit_limit: Number(edMonthly) || 0 });
setEditTarget(null);
}
async function submitReset() {
if (!resetTarget || resetPwd.length < 8) return;
await onResetPassword(resetTarget.id, resetPwd);
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 (
<section className="team-page">
<div className="page-head">
<div>
<h1>团队管理</h1>
<div className="sub"><span className="mono">// 成员 · 角色 · 额度 · 共享资产库</span></div>
</div>
<div className="actions">
<button className="btn btn-primary" type="button" id="open-invite" onClick={() => setModal("invite")}>
<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>
</div>
</div>
{/* 顶部行:团队 banner(左)+ 团队动态(右) */}
<div className="team-top">
<div className="team-banner">
<span className="corner-tr" aria-hidden></span><span className="corner-bl" aria-hidden></span>
<div className="banner-head">
<div className="banner-id">
<div className="lbl">[ TEAM ]</div>
<div className="nm">{team.name} <span className="tag">企业</span></div>
<div className="meta">// 团队 ID: {team.id} · {rows.length} 名成员</div>
</div>
<div className="banner-actions">
<button className="btn btn-sm" type="button" onClick={() => setModal("recharge")}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 7v10M9 10h4a1.5 1.5 0 0 1 0 3h-3a1.5 1.5 0 0 0 0 3h4" /></svg>
充值
</button>
<button className="btn btn-ghost btn-sm" type="button" id="open-limit" onClick={() => setModal("limit")}>设置月限额</button>
</div>
</div>
<div className="banner-divider"></div>
<div className="banner-stats">
<div className="stat">
<div className="lbl">[ 充值余额 ]</div>
<div className="v">{money(balance)}</div>
<div className="sub">// 团队总池</div>
</div>
<div className="stat">
<div className="lbl">[ 月限额 ]</div>
<div className="v" id="stat-limit">{money(limit)}</div>
<div className="sub">// 自然月重置</div>
</div>
<div className="stat">
<div className="lbl">[ 当月已用 ]</div>
<div className="v" id="stat-used">{money(used)}</div>
<div className="sub" id="stat-used-sub">// 占月限 {pct.toFixed(1)}%</div>
</div>
<div className="stat">
<div className="lbl">[ 当月剩余 ]</div>
<div className="v warn" id="stat-left">{money(left)}</div>
<div className="sub" id="stat-left-sub">// 还可生成约 {Math.max(0, Math.round(left / 10))} 个项目</div>
</div>
</div>
</div>
{/* 团队动态(banner 右栏)· 接真实 ops/notifications 事件流 */}
<div className="team-feed">
<div className="h">
<h3>团队动态</h3>
<span className="ct">// 最近 {Math.min(feedItems.length, 6)} 条 · 共 {notificationTotal ?? notifications.length}</span>
<a className="more" id="open-feed-all" role="button" tabIndex={0} onClick={() => navigate("messages")}>全部 →</a>
</div>
<div className="feed-list">
{feedItems.length === 0 ? (
<div className="feed-item">
<div className="av">{(team.name || "T").slice(0, 1).toUpperCase()}</div>
<div>
<div className="txt"><span className="who">{team.name}</span><span className="act">已同步</span><span className="obj">{rows.length} 名成员</span></div>
<div className="ts">// 暂无团队动态</div>
</div>
</div>
) : (
feedItems.map((n) => (
<div className="feed-item" key={n.id}>
<div className="av">{(n.owner_label || n.source || team.name || "·").slice(0, 1).toUpperCase()}</div>
<div>
<div className="txt">
<span className="who">{n.owner_label || n.source || "系统"}</span>
<span className="act">{n.title}</span>
{n.project_name && <span className="obj">{n.project_name}</span>}
</div>
<div className="ts">{n.created_at ? new Date(n.created_at).toLocaleString("zh-CN") : n.brief}</div>
</div>
</div>
))
)}
</div>
</div>
</div>{/* /.team-top */}
<div className="team-grid">
{/* 左:成员表 */}
<div>
<div className="pane" style={{ padding: 0 }}>
<div style={{ display: "flex", alignItems: "center", padding: "16px 20px", borderBottom: "1px solid var(--border-faint)" }}>
<h3 style={{ margin: 0 }}>成员列表 <span className="ct">// {list.length} / {rows.length} 人 · 真实团队表</span></h3>
<span className="spacer"></span>
<input className="input" id="member-search" placeholder="搜索姓名 / 手机号" style={{ height: "32px", fontSize: "12px", width: "220px" }} value={search} onChange={(event) => setSearch(event.target.value)} />
</div>
<div className="members-table-wrap">
<table className="t members-table" style={{ border: 0, borderRadius: 0 }}>
<thead>
<tr>
<th>成员</th>
<th>角色</th>
<th>每日额度</th>
<th>月度额度</th>
<th style={{ width: "140px" }}>当月已用</th>
<th style={{ textAlign: "right", width: "88px" }}>操作</th>
</tr>
</thead>
<tbody id="members-tbody">
{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 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 (
<tr key={member.id} data-id={member.id}>
<td><span className="member-cell"><span className="av">{displayName.slice(0, 1).toUpperCase()}</span><span className="member-meta"><span className="nm">{displayName}</span>{showEmail && <span className="em">{email}</span>}</span></span></td>
<td><span className={`role-pill role-${role.key}`}><span className="dot"></span>{role.label}</span></td>
<td><span className="quota-cell"><span className="v">不限</span></span></td>
<td><span className="quota-cell"><span className="v">{monthly > 0 ? money(monthly) : "不限"}</span></span></td>
<td><div className="quota-cell"><span className="v">{money(memberUsed)}</span> <span className="lbl">/ {monthly > 0 ? `${memberPct.toFixed(0)}%` : "不限"}</span></div><div className="used-bar"><span className={barClass} style={{ width: `${barWidth}%` }}></span></div></td>
<td><div className="acts">{isOwner
? <span style={{ fontFamily: "var(--font-mono)", fontSize: "12px", color: "var(--black-alpha-32)", alignSelf: "center" }}>不可编辑</span>
: <>
<button className="icon-btn-sm" type="button" title="编辑" onClick={() => openEdit(member)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z" /></svg></button>
<button className="icon-btn-sm" type="button" title="重置密码" onClick={() => { setResetTarget(member); setResetPwd(""); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg></button>
<button className="icon-btn-sm danger" type="button" title="移出" onClick={() => setRemoveTarget(member)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg></button>
</>}</div></td>
</tr>
);
})}
</tbody>
</table>
</div>
<Pager page={memberCurPage} total={list.length} pageSize={MEMBERS_PER_PAGE} onChange={setMemberPage} />
</div>
</div>
{/* 右:权限矩阵 */}
<div>
<div className="pane">
<h3>角色权限</h3>
<div style={{ fontSize: "12px", color: "var(--black-alpha-48)", marginTop: "-10px", marginBottom: "12px", fontFamily: "var(--font-mono)", letterSpacing: ".02em" }}>// PRD §10.2 权限矩阵节选</div>
<table className="perm-table">
<thead>
<tr><th>能力</th><th>超管</th><th>团管</th><th>成员</th></tr>
</thead>
<tbody>
{PERM_ROWS.map((row) => (
<tr key={row.cap} style={row.last ? { borderBottom: 0 } : undefined}>
<td>{row.cap}</td>
{row.cells.map((cell, index) => (
<td key={index} className={cell === "✓" ? "yes" : "no"}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
{/* 设置月限额(团队级)· 注:后端暂无团队级限额端点,真实限额请在「编辑成员」逐人设置 */}
<TeamModal open={modal === "limit"} title="设置月限额" subtitle="// 团队级限额暂存本地 · 成员限额请在编辑成员中设置" icon={<CircleDollarSign size={16} />} close={() => setModal("")}><div className="field"><label className="field-label">月限额 ¥</label><input className="input" defaultValue="3000" /></div></TeamModal>
{/* 创建账户 */}
<TeamModal
open={modal === "invite"}
title="创建账户"
subtitle="// 直接生成账号 · 分享给成员登录"
icon={<UserPlus size={16} />}
close={() => setModal("")}
footer={<button className="btn btn-primary" type="button" onClick={submitCreate}>创建账户</button>}
>
<div className="field"><label className="field-label">用户名</label><input className="input" value={cuUser} onChange={(e) => setCuUser(e.target.value)} placeholder="zhang.yunying" /></div>
<div className="field"><label className="field-label">登录密码</label><input className="input mono" value={cuPass} onChange={(e) => setCuPass(e.target.value)} placeholder="至少 8 位" /></div>
<div className="field"><label className="field-label">姓名(可选)</label><input className="input" value={cuName} onChange={(e) => setCuName(e.target.value)} placeholder="张运营" /></div>
<div className="field"><label className="field-label">角色</label>
<select className="input" value={cuRole} onChange={(e) => setCuRole(e.target.value)}>
<option value="admin">团管</option>
<option value="member">成员</option>
<option value="viewer">访客</option>
</select>
</div>
<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
open={modal === "recharge"}
title="团队充值"
subtitle="// 充值后立即到账 · 仅超管可操作"
icon={<CircleDollarSign size={16} />}
close={() => setModal("")}
footer={<button className="btn btn-primary" type="button" onClick={submitRecharge}>确认充值</button>}
>
<div className="field"><label className="field-label">充值金额 ¥</label><input className="input" type="number" value={rechargeAmt} onChange={(e) => setRechargeAmt(e.target.value)} placeholder="最低 ¥50" /></div>
</TeamModal>
{/* 编辑成员 */}
<TeamModal
open={!!editTarget}
title="编辑成员"
subtitle={editTarget ? `// ${editTarget.user.username || editTarget.user.email}` : ""}
icon={<UserPlus size={16} />}
close={() => setEditTarget(null)}
footer={<button className="btn btn-primary" type="button" onClick={submitEdit}>保存</button>}
>
<div className="field"><label className="field-label">角色</label>
<select className="input" value={edRole} onChange={(e) => setEdRole(e.target.value)}>
<option value="admin">团管</option>
<option value="member">成员</option>
<option value="viewer">访客</option>
</select>
</div>
<div className="field"><label className="field-label">月度额度 ¥(0 = 不限)</label><input className="input" type="number" value={edMonthly} onChange={(e) => setEdMonthly(e.target.value)} placeholder="0" /></div>
</TeamModal>
{/* 重置密码 */}
<TeamModal
open={!!resetTarget}
title="重置密码"
subtitle={resetTarget ? `// ${resetTarget.user.username || resetTarget.user.email}` : ""}
icon={<KeyRound size={16} />}
close={() => setResetTarget(null)}
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>
</TeamModal>
{/* 移除成员确认 */}
<ConfirmModal
open={!!removeTarget}
title="移除成员"
detail={removeTarget ? `确认将「${removeTarget.user.username || removeTarget.user.email}」移出团队?移除后该成员将失去登录与访问权限。` : ""}
confirmText="移除"
onCancel={() => setRemoveTarget(null)}
onConfirm={submitRemove}
/>
</section>
);
}