大量修改UI
This commit is contained in:
+270
-189
@@ -1,14 +1,34 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { CheckCircle2, CircleDollarSign, Copy, KeyRound, RefreshCw, UserPlus } from "lucide-react";
|
||||
import { CheckCircle2, CircleDollarSign, Copy, Gauge, KeyRound, RefreshCw, Search, UserPlus } from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import type { BillingSummary, Invitation, Notification, Team, TeamMember, User } from "../types";
|
||||
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 { 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];
|
||||
|
||||
@@ -67,15 +87,15 @@ const PERM_ROWS: Array<{ cap: string; cells: [string, string, string]; last?: bo
|
||||
{ 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: "// 管成员 + 改额度" }
|
||||
{ value: "member", title: "成员", desc: "创建项目 + 用资产" },
|
||||
{ value: "admin", title: "团管", desc: "管成员 + 改额度" }
|
||||
];
|
||||
|
||||
export function TeamPage({ team, user, billing, navigate, onCreateMember: onCreateMemberRaw, onUpdateMember: onUpdateMemberRaw, onRemoveMember: onRemoveMemberRaw, onResetPassword, onRecharge: onRechargeRaw }: {
|
||||
@@ -119,6 +139,14 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
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("");
|
||||
@@ -221,6 +249,13 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
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());
|
||||
@@ -312,199 +347,245 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
<section className="team-page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>团队管理</h1>
|
||||
<div className="sub"><span className="mono">// 成员 · 角色 · 额度 · 共享资产库</span></div>
|
||||
<h1>团队中心</h1>
|
||||
<div className="sub">在一个页面管理成员协作与额度消费</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 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>
|
||||
|
||||
{/* 顶部行:团队 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 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>
|
||||
|
||||
{/* 团队动态(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 className="tc-metric">
|
||||
<span>团队余额</span>
|
||||
<strong>{money(balance)}</strong>
|
||||
<small>团队总池</small>
|
||||
</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 className="tc-metric">
|
||||
<span>月限额</span>
|
||||
<strong id="stat-limit">{money(limit)}</strong>
|
||||
<small>自然月重置</small>
|
||||
</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 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>
|
||||
|
||||
{/* 设置月限额(团队级)· 预设 pill + 实时剩余 */}
|
||||
<TeamModal
|
||||
open={modal === "gen-invite"}
|
||||
title="邀请成员"
|
||||
subtitle="// 生成邀请码 · 新成员凭码注册即加入本团队"
|
||||
subtitle="生成邀请码 · 新成员凭码注册即加入本团队"
|
||||
icon={<UserPlus size={16} />}
|
||||
close={() => setModal("")}
|
||||
dismissable={false}
|
||||
@@ -546,7 +627,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
<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 className="cred-rules">角色 {invRole === "admin" ? "团队管理员" : "成员"} · 7 天有效 · 凭码注册即加入,不重复发额度</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -555,7 +636,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
<TeamModal
|
||||
open={modal === "limit"}
|
||||
title="设置月限额"
|
||||
subtitle="// 自然月重置 · 仅超管可改"
|
||||
subtitle="自然月重置 · 仅超管可改"
|
||||
icon={<CircleDollarSign size={16} />}
|
||||
close={() => setModal("")}
|
||||
dismissable={false}
|
||||
@@ -587,7 +668,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
<TeamModal
|
||||
open={modal === "invite"}
|
||||
title="创建账户"
|
||||
subtitle="// 直接生成账号 · 分享给成员登录"
|
||||
subtitle="直接生成账号 · 分享给成员登录"
|
||||
icon={<UserPlus size={16} />}
|
||||
close={() => setModal("")}
|
||||
footer={<button className="btn btn-primary" type="button" onClick={submitCreate}>创建账户</button>}
|
||||
@@ -647,7 +728,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
<TeamModal
|
||||
open={!!share}
|
||||
title={share?.mode === "reset" ? "密码已重置" : "账户已创建"}
|
||||
subtitle={share?.mode === "reset" ? `// 把新凭据分享给 ${share?.name} · 旧密码已失效` : "// 把下方凭据分享给成员 · 凭据仅展示一次"}
|
||||
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>}
|
||||
@@ -686,20 +767,20 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
<TeamModal
|
||||
open={modal === "recharge"}
|
||||
title="团队充值"
|
||||
subtitle="// 充值后立即到账 · 仅超管可操作"
|
||||
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>
|
||||
<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}` : ""}
|
||||
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>}
|
||||
@@ -740,7 +821,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
<TeamModal
|
||||
open={!!resetTarget}
|
||||
title="重置登录密码"
|
||||
subtitle="// 该成员当前会话会被强制下线"
|
||||
subtitle="该成员当前会话会被强制下线"
|
||||
icon={<KeyRound size={16} />}
|
||||
close={() => setResetTarget(null)}
|
||||
footer={<button className="btn btn-primary" type="button" onClick={submitReset}>确认重置</button>}
|
||||
|
||||
Reference in New Issue
Block a user