视频/工作台/商品库(ZWQ): - row19 视频阶段移除已取消的【上传视频】按钮 (pipeline.tsx) - row20 工作台统计栏(总项目/进行中/成片)点击跳转视频项目对应 tab (dashboard/projects/route-config/App) - row21 最新项目跳转改用真实 projectId, 不再跳到最近打开项目 (dashboard.tsx) - row22+PMC28 网格/列表视图模式持久化(localStorage), 跨菜单记住用户选择 (projects/ai-tools + 新 hook use-view-mode.ts) - row24 已有三视图的演员/模特直接复用, 不再强制点AI生成三视图 (pipeline.tsx) 图片生成/设置(PMC): - row27 任务中心: 6个进行中状态补全归类, 不再把生成中误判为失败栏 (stage-config.ts) - row29 头像上传500: avatar_url 存公读直链替代超长预签名URL(URLField max_length=200溢出) (accounts/views.py, 需部署) - row30 显示名保存后回显: 改读 first_name 回退 username, 修刷新仍旧名 (settings.tsx) - row31/32 隐藏【创作默认】【显示】两个设置模块 (settings.tsx) 平台套图(YYX): - row3 图片预览铺满占位 (ai-tools-page.css) 团队: - PMC25 充值/邀请成员/设置月限额三弹窗禁止点遮罩误关 (team.tsx) 含平台套图线上提示词优化(services.py/views.py/api.ts, 前序会话遗留一并提交) 构建: tsc --noEmit=0, npm run build=0 (1755模块); 后端 py_compile OK 注: row29 头像/后端项需部署生效 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
733 lines
40 KiB
TypeScript
733 lines
40 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { CheckCircle2, CircleDollarSign, Copy, KeyRound, RefreshCw, UserPlus } from "lucide-react";
|
|
import { api } from "../api";
|
|
import type { BillingSummary, Invitation, Notification, Team, TeamMember, User } from "../types";
|
|
import type { Page } from "./route-config";
|
|
import { money } from "./stage-config";
|
|
import { ConfirmModal, TeamModal } from "../components/overlays";
|
|
import { Pager } from "../components/pager";
|
|
|
|
const MEMBERS_PER_PAGE = 10;
|
|
// 登录地址必须是用户当前真实访问的域名(与邀请链接同源),写死的 airshelf.com 不存在 → PMC#16
|
|
const LOGIN_URL = `${location.origin}/login`;
|
|
const LIMIT_PRESETS = [1000, 3000, 5000, 10000, -1];
|
|
|
|
// 本地随机用户名:前缀 + 4 位数字(与 V1 设计稿一致)
|
|
function genUsername() {
|
|
const prefixes = ["user", "team", "shop", "creator", "editor"];
|
|
const p = prefixes[Math.floor(Math.random() * prefixes.length)];
|
|
const n = Math.floor(1000 + Math.random() * 9000);
|
|
return `${p}.${n}`;
|
|
}
|
|
// 本地随机密码:12 位 · 字母+数字 · 排除易混淆字符(0/O/1/l/I)
|
|
function genPassword() {
|
|
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
|
|
let s = "";
|
|
for (let i = 0; i < 12; i++) s += chars[Math.floor(Math.random() * chars.length)];
|
|
return s;
|
|
}
|
|
// 千分位金额格式化(V1 限额弹窗用 ¥3,000 / ¥2,837.40 风格)· money() 无千分位故本地补
|
|
function yuan(n: number, decimals = 0) {
|
|
const fixed = n.toFixed(decimals);
|
|
const [int, dec] = fixed.split(".");
|
|
const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
return `¥${grouped}${dec ? "." + dec : ""}`;
|
|
}
|
|
// 复制到剪贴板:优先 navigator.clipboard,兜底 textarea + execCommand
|
|
async function copyText(text: string) {
|
|
try {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
await navigator.clipboard.writeText(text);
|
|
return true;
|
|
}
|
|
} catch { /* fallthrough */ }
|
|
const ta = document.createElement("textarea");
|
|
ta.value = text;
|
|
ta.style.position = "fixed";
|
|
ta.style.opacity = "0";
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
let ok = false;
|
|
try { ok = document.execCommand("copy"); } catch { ok = false; }
|
|
document.body.removeChild(ta);
|
|
return ok;
|
|
}
|
|
|
|
// 角色 → pill key/label(对齐 api-bridge roleUi)
|
|
function roleUi(role: string): { key: "super" | "admin" | "member"; label: string } {
|
|
if (role === "owner" || role === "super") return { key: "super", label: "超管" };
|
|
if (role === "admin") return { key: "admin", label: "团管" };
|
|
if (role === "viewer") return { key: "member", label: "访客" };
|
|
return { key: "member", label: "成员" };
|
|
}
|
|
|
|
const PERM_ROWS: Array<{ cap: string; cells: [string, string, string]; last?: boolean }> = [
|
|
{ cap: "邀请 / 移除成员", cells: ["✓", "✓", "—"] },
|
|
{ cap: "设置成员额度", cells: ["✓", "✓", "—"] },
|
|
{ cap: "团队充值", cells: ["✓", "—", "—"] },
|
|
{ cap: "设置月限额", cells: ["✓", "—", "—"] },
|
|
{ cap: "编辑别人项目", cells: ["✓", "✓", "—"] },
|
|
{ cap: "团队共享资产库管理", cells: ["✓", "✓", "仅自传"] },
|
|
{ cap: "查看团队消费明细", cells: ["✓", "✓", "仅自己"] },
|
|
{ cap: "创建项目 / 用 AI 流程", cells: ["✓", "✓", "✓"], last: true }
|
|
];
|
|
|
|
// 角色双卡(创建/编辑共用):成员 / 团管
|
|
const ROLE_CARDS: Array<{ value: string; title: string; desc: string }> = [
|
|
{ value: "member", title: "成员", desc: "// 创建项目 + 用资产" },
|
|
{ value: "admin", title: "团管", desc: "// 管成员 + 改额度" }
|
|
];
|
|
|
|
export function TeamPage({ team, user, billing, navigate, onCreateMember: onCreateMemberRaw, onUpdateMember: onUpdateMemberRaw, onRemoveMember: onRemoveMemberRaw, onResetPassword, onRecharge: onRechargeRaw }: {
|
|
team: Team;
|
|
user: User;
|
|
billing: BillingSummary | null;
|
|
navigate: (page: Page) => void;
|
|
onCreateMember: (payload: { username: string; password: string; name?: string; role?: string; daily_credit_limit?: number; monthly_credit_limit?: number; total_credit_limit?: number }) => void | Promise<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(() => {});
|
|
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 [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");
|
|
|
|
// 设置月限额
|
|
const [limitVal, setLimitVal] = useState("3000");
|
|
|
|
// 分享凭据弹窗(创建成功 / 重置成功后弹):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;
|
|
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;
|
|
// 月限额弹窗实时剩余(按弹窗内输入值预演)
|
|
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() {
|
|
setLimitVal(limit > 0 ? String(Math.round(limit)) : "3000");
|
|
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" onClick={() => setModal("")}>保存</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>
|
|
</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="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>}
|
|
>
|
|
<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}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|