Files
yingqing/core/frontend/src/routes/team.tsx
T
zycandClaude Sonnet 5 bf20de956c feat(billing): 计费商业化重构(积分制)+ 团队差异化调价
统一计价引擎 apps/billing/pricing.py:1积分=¥0.1,平台成本(¥)与用户价(积分)双记账,
视频按火山真实 usage.total_tokens 结算(true-up,终结¥1/段倒贴)+ 首发×1.5毛利系数。
Team.price_multiplier 差异化调价(jimeng同款):挂牌价×系数两步HALF_UP取整,视频类
按下单时的价格/汇率快照结算(中途改配不影响在途任务)。

BillingConfig 单例(汇率/毛利系数/预留buffer,admin可调即刻生效)。存量数据 ×10 rescale
迁移(RunPython+atomic,MySQL 迁移不可中断重跑)。开户赠送归零(DEFAULT_TRIAL_CREDITS=0,
商业决策)。audit_billing 加 I9(卖亏审计)。271 条测试 + tsc/build 全绿。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 10:04:00 +08:00

777 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
// 千分位积分格式化(积分制:限额/已用全是积分)
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<unknown>;
onUpdateMember: (id: string, payload: { role?: string; daily_credit_limit?: number; monthly_credit_limit?: number; total_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" | "gen-invite">("");
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(() => {});
// 月限额自取最新值: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<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; };
// 积分汇率(积分/¥):充值 hint 与后端到账口径同源,汇率改配后不写死 ×10
const [pointsRate, setPointsRate] = useState(10);
useEffect(() => {
void api.billingConfig().then((cfg) => setPointsRate(Number(cfg.points_per_yuan) || 10)).catch(() => undefined);
}, []);
// 创建账户表单
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<TeamMember | null>(null);
const [edRole, setEdRole] = useState("member");
const [edDaily, setEdDaily] = useState("");
const [edMonthly, setEdMonthly] = useState("");
const [edTotal, setEdTotal] = useState("-1");
// 重置密码
const [resetTarget, setResetTarget] = useState<TeamMember | null>(null);
const [resetPwd, setResetPwd] = useState("");
// 移除成员
const [removeTarget, setRemoveTarget] = useState<TeamMember | null>(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<Invitation | null>(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 (
<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={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>
创建账户
</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-gen-invite" onClick={() => { setInviteResult(null); setInviteErr(""); setModal("gen-invite"); }}>
<UserPlus size={13} /> 邀请成员
</button>
<button className="btn btn-ghost btn-sm" type="button" id="open-limit" onClick={openLimit}>设置月限额</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>总额度</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 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 (
<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">{daily > 0 ? money(daily) : "不限"}</span>{daily > 0 && <span className="lbl">今日用 {money(dayUsed)}</span>}</span></td>
<td><span className="quota-cell"><span className="v">{monthly > 0 ? money(monthly) : "不限"}</span></span></td>
<td><span className="quota-cell"><span className="v">{totalLimit > 0 ? money(totalLimit) : "不限"}</span>{totalLimit > 0 && <span className="lbl">累计用 {money(totalUsed)}</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(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>
</>}</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>
{/* 设置月限额(团队级)· 预设 pill + 实时剩余 */}
<TeamModal
open={modal === "gen-invite"}
title="邀请成员"
subtitle="// 生成邀请码 · 新成员凭码注册即加入本团队"
icon={<UserPlus size={16} />}
close={() => setModal("")}
dismissable={false}
footer={inviteResult
? <button className="btn btn-primary" type="button" onClick={() => setModal("")}>完成</button>
: <button className="btn btn-primary" type="button" disabled={inviteBusy} onClick={() => {
if (inviteBusy) return;
setInviteBusy(true);
setInviteErr("");
api.createInvitation({ role: invRole, monthly_credit_limit: invMonthly || 0 })
.then((inv) => setInviteResult(inv))
.catch((err) => setInviteErr(err instanceof Error ? err.message : "生成失败,请稍后重试"))
.finally(() => setInviteBusy(false));
}}>{inviteBusy ? "生成中…" : "生成邀请码"}</button>}
>
<div className="invite-gen-modal">
{!inviteResult ? (
<>
<div className="field">
<label className="field-label">成员角色</label>
<select className="input" value={invRole} onChange={(e) => setInvRole(e.target.value)}>
<option value="member">成员 · 可用生成,不可管理团队</option>
<option value="admin">团队管理员 · 可管成员/额度/邀请</option>
</select>
</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="例: 2000" value={invMonthly} onChange={(e) => setInvMonthly(e.target.value)} />
</div>
{inviteErr && <div className="form-error" role="alert" style={{ marginTop: 12 }}>{inviteErr}</div>}
</>
) : (
<div className="cred-card">
<div className="cred-row">
<div><div className="ck">邀请码</div><div className="cv">{inviteResult.code}</div></div>
<button className="cred-copy" type="button" onClick={() => navigator.clipboard?.writeText(inviteResult.code)}><Copy size={13} /> 复制</button>
</div>
<div className="cred-row">
<div><div className="ck">邀请链接</div><div className="cv">{`${location.origin}${inviteResult.register_url}`}</div></div>
<button className="cred-copy" type="button" onClick={() => navigator.clipboard?.writeText(`${location.origin}${inviteResult.register_url}`)}><Copy size={13} /> 复制</button>
</div>
<div className="cred-rules">// 角色 {invRole === "admin" ? "团队管理员" : "成员"} · 7 天有效 · 凭码注册即加入,不重复发额度</div>
</div>
)}
</div>
</TeamModal>
<TeamModal
open={modal === "limit"}
title="设置月限额"
subtitle="// 自然月重置 · 仅超管可改"
icon={<CircleDollarSign size={16} />}
close={() => setModal("")}
dismissable={false}
footer={<button className="btn btn-primary" type="button" disabled={limitBusy} onClick={saveLimit}>{limitBusy ? "保存中…" : "保存"}</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
open={modal === "invite"}
title="创建账户"
subtitle="// 直接生成账号 · 分享给成员登录"
icon={<UserPlus size={16} />}
close={() => setModal("")}
footer={<button className="btn btn-primary" type="button" onClick={submitCreate}>创建账户</button>}
dismissable={false}
>
<div className="create-acct-modal">
<div className="field">
<label className="field-label">用户名 <span className="req">*</span></label>
<div className="input-with-gen">
<input className="input" autoComplete="off" placeholder="例: zhang.yunying" value={cuUser} onChange={(e) => setCuUser(e.target.value)} />
<button className="btn btn-sm" type="button" title="生成随机用户名" onClick={() => setCuUser(genUsername())}><RefreshCw size={13} /></button>
</div>
</div>
<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>
</TeamModal>
{/* 团队充值 */}
<TeamModal
open={modal === "recharge"}
title="团队充值"
subtitle="// 充值后立即到账 · 仅超管可操作"
icon={<CircleDollarSign size={16} />}
close={() => setModal("")}
dismissable={false}
footer={<button className="btn btn-primary" type="button" onClick={submitRecharge}>确认充值</button>}
>
<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 className="field-hint">// 到账积分 = 支付金额 × {pointsRate}(当前汇率)</div></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>}
dismissable={false}
>
<div className="edit-member-modal">
<div className="field">
<label className="field-label">用户名 <span className="lbl-note">(无权修改)</span></label>
<input className="input readonly-input" readOnly tabIndex={-1} value={editTarget?.user.username || editTarget?.user.email || ""} />
</div>
<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>
</TeamModal>
{/* 重置密码 · 警示框 + 密码输入 + 随机生成 */}
<TeamModal
open={!!resetTarget}
title="重置登录密码"
subtitle="// 该成员当前会话会被强制下线"
icon={<KeyRound size={16} />}
close={() => setResetTarget(null)}
footer={<button className="btn btn-primary" type="button" onClick={submitReset}>确认重置</button>}
dismissable={false}
>
<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>
{/* 移除成员确认 */}
<ConfirmModal
open={!!removeTarget}
title="移除成员"
detail={removeTarget ? `确认将「${removeTarget.user.username || removeTarget.user.email}」移出团队?移除后该成员将失去登录与访问权限。` : ""}
confirmText="移除"
onCancel={() => setRemoveTarget(null)}
onConfirm={submitRemove}
dismissable={false}
/>
</section>
);
}