Files
yingqing/core/frontend/src/routes/team.tsx
T

866 lines
43 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, 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<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 [trendRange, setTrendRange] = useState<TrendRange>("day");
const [trend, setTrend] = useState<BillingTrend | null>(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<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);
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 (
<section className="team-page">
<div className="team-inner">
<div className="page-head">
<div>
<h1>团队中心</h1>
<div className="sub">在一个页面管理成员协作与额度消费</div>
</div>
<div className="actions">
<button className="tc-ghost" type="button" id="open-gen-invite" onClick={() => { setInviteResult(null); setInviteErr(""); setModal("gen-invite"); }}>
<UserPlus size={18} />邀请成员
</button>
<button className="tc-ghost" type="button" id="open-invite" onClick={openCreate}>
<UserPlus size={18} />创建账户
</button>
<button className="tc-primary" type="button" onClick={() => setModal("recharge")}>
<CircleDollarSign size={18} />充值
</button>
</div>
</div>
<div className="tc-hero">
<div className="tc-identity">
<div>
<span className="tc-team-tag">TEAM</span>
<h2>{team.name}</h2>
<p>企业团队 · {rows.length} 名成员</p>
</div>
<p>团队 ID:{team.id}</p>
</div>
<div className="tc-metric">
<span>团队余额</span>
<strong>{money(balance)}</strong>
<small>团队总池</small>
</div>
<div className="tc-metric">
<span>月限额</span>
<strong id="stat-limit">{money(limit)}</strong>
<small>自然月重置</small>
</div>
<div className="tc-metric">
<span>当月已用</span>
<strong id="stat-used">{money(used)}</strong>
<small>使用率 {pct.toFixed(1)}%</small>
</div>
<div className="tc-metric">
<span>当月剩余</span>
<strong id="stat-left">{money(left)}</strong>
<small>还可生成约 {Math.max(0, Math.round(left / 10))} 个项目</small>
</div>
</div>
<section className="tc-section" aria-labelledby="teamCollaborationTitle">
<div className="tc-section-title">
<div>
<h2 id="teamCollaborationTitle">成员与团队动态</h2>
<p>成员权限和最近协作记录</p>
</div>
<button className="tc-compact tc-compact-klein" type="button" id="viewAllMessages" onClick={() => navigate("messages")}>查看全部消息</button>
</div>
<div className="tc-grid">
<div className="tc-panel">
<div className="tc-panel-head">
<h2>成员列表</h2>
<label className="tc-search">
<Search size={16} />
<input id="member-search" placeholder="搜索姓名 / 手机号" value={search} onChange={(event) => setSearch(event.target.value)} />
</label>
</div>
<div className="tc-row tc-row-head">
<span>成员</span>
<span>角色</span>
<span>每日额度</span>
<span>月额度</span>
<span>总额度</span>
<span>当月已用</span>
<span>操作</span>
</div>
{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 (
<div className="tc-row" key={member.id} data-id={member.id}>
<div className="tc-person">
<div className="tc-avatar">{displayName.slice(0, 1).toUpperCase()}</div>
<div>
<strong>{displayName}</strong>
<span>{showEmail ? email : team.name}</span>
</div>
</div>
<span className={`tc-tag tc-tag-${role.key}`}>{role.label}</span>
<span className="tc-quota">{dailyLimit > 0 ? money(dailyLimit) : "不限"}{dailyLimit > 0 ? ` · 今日 ${money(dayUsed)}` : ""}</span>
<span>{monthly > 0 ? money(monthly) : "不限"}</span>
<span className="tc-quota">{totalLimit > 0 ? money(totalLimit) : "不限"}{totalLimit > 0 ? ` · 累计 ${money(totalUsed)}` : ""}</span>
<div className="tc-used">
<strong>{money(memberUsed)}</strong>
<div className="used-bar"><span className={barClass} style={{ width: `${barWidth}%` }} /></div>
</div>
<div className="tc-acts">{isOwner
? <span className="tc-muted">不可编辑</span>
: <>
<button className="tc-icon" 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="tc-icon" 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="tc-icon 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>
</div>
);
})}
<Pager page={memberCurPage} total={list.length} pageSize={MEMBERS_PER_PAGE} onChange={setMemberPage} />
</div>
<div className="tc-panel">
<div className="tc-panel-head">
<h2>最近动态</h2>
<span className="tc-tag">{notificationTotal || feedItems.length} 条</span>
</div>
<div className="tc-activity">
{feedItems.length === 0 ? (
<div className="tc-activity-item">
<span className="tc-dot" />
<div>
<p><strong>{team.name}</strong> 暂无团队动态</p>
<span>已同步 {rows.length} 名成员</span>
</div>
</div>
) : (
feedItems.map((n) => (
<div className="tc-activity-item" key={n.id}>
<span className="tc-dot" />
<div>
<p><strong>{n.owner_label || n.source || "系统"}</strong> {n.title}</p>
<span>{shortTs(n.created_at)}{n.cost_label ? ` · ${n.cost_label}` : ""}{n.project_name ? ` · ${n.project_name}` : ""}</span>
</div>
</div>
))
)}
</div>
</div>
</div>
</section>
<section className="tc-section" aria-labelledby="teamConsumptionTitle">
<div className="tc-section-title">
<div>
<h2 id="teamConsumptionTitle">消费概览</h2>
<p>{trendRange === "week" ? "近 8 周" : trendRange === "month" ? "近 6 个月" : "近 14 天"} · 单位:积分</p>
</div>
<button className="tc-compact" type="button" id="open-limit" onClick={openLimit}>
<Gauge size={16} />设置月限额
</button>
</div>
<div className="tc-panel">
<div className="tc-panel-head">
<h2>消费趋势</h2>
<div className="tc-segmented">
{TREND_CHIPS.map((chip) => (
<button
key={chip.key}
type="button"
className={`tc-seg${trendRange === chip.key ? " active" : ""}`}
onClick={() => setTrendRange(chip.key)}
>
{chip.label}
</button>
))}
</div>
</div>
<div className="tc-summary">
<div>
<span>本月已用</span>
<strong id="stat-used-sub">{money(used)}</strong>
</div>
<div>
<span>本月剩余</span>
<strong id="stat-left-sub">{money(left)}</strong>
</div>
<div>
<span>主要消耗</span>
<strong>{stageSum > 0 ? `${topStage.label} ${topPct}%` : "暂无消耗"}</strong>
</div>
</div>
<div className="tc-chart">
{daily.length === 0 ? (
<div className="tc-chart-empty">暂无消费记录</div>
) : daily.map((d) => {
const amt = Number(d.amount);
const h = peak > 0 ? Math.max(amt > 0 ? 8 : 4, (amt / peak) * 100) : 8;
return (
<div className="tc-bar-group" key={d.date} title={`${d.label} · ${money(amt)}`}>
<strong className="tc-bar-value">{amt > 0 ? Math.round(amt) : ""}</strong>
<div className="tc-bar" style={{ height: `${h}%` }} />
<span>{d.label}</span>
</div>
);
})}
</div>
</div>
</section>
<section className="tc-section" aria-labelledby="teamPermTitle">
<div className="tc-section-title">
<div>
<h2 id="teamPermTitle">角色权限</h2>
<p>超管 / 团管 / 成员能力对照</p>
</div>
</div>
<div className="tc-panel tc-perm-panel">
<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}>
<td>{row.cap}</td>
{row.cells.map((cell, index) => (
<td key={index} className={cell === "✓" ? "yes" : "no"}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</section>
</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>
<CustomSelect
fill
value={invRole}
onChange={setInvRole}
options={[
{ value: "member", label: "成员 · 可用生成,不可管理团队" },
{ value: "admin", label: "团队管理员 · 可管成员/额度/邀请" },
]}
/>
</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>
);
}