feat(admin): Phase 2 邀请码发放管理 — apps/adminpanel 跨团队端点 + Admin 邀请码页

后端:新建 apps/adminpanel(跨团队后台容器,/api/admin/);GET/POST invitations(列跨团队码+筛选分页 /
平台发 create_team 开团队码,写审计)+ revoke(写审计),全程 IsPlatformAdmin。
前端:adminApi 分层;Admin 邀请码页(类型筛选段控 + 表格 + 发码弹窗 + 复制邀请链接 + 撤销)接入 AdminApp toast;
Invitation 类型补 kind/team_name;admin-page.css 补段控/表格/弹窗(仅 token)。
测试:adminpanel 9 + accounts 22 = 31 单测过;无头 e2e _admin-p2.mjs 6 断言过 + 0 console error
+ 端到端凭 UI 发的码注册开团队 201;tsc+build 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 21:29:55 +08:00
co-authored by Claude Opus 4.8
parent 3f8547564c
commit 45a4ceae51
14 changed files with 589 additions and 6 deletions
+45
View File
@@ -84,3 +84,48 @@
.admin-app .topbar .crumbs a {
cursor: pointer;
}
/* ── 邀请码页:筛选条 + 表格 + 弹窗 ── */
.admin-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin: 4px 0 16px;
}
/* 类型筛选 = 模式切换段控(模式切换用黑激活,不抢主橙) */
.tabs-sub {
display: inline-flex;
gap: 2px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 3px;
}
.tab-sub {
height: 28px;
padding: 0 14px;
border: 0;
background: transparent;
border-radius: 6px;
font-size: 12.5px;
color: var(--black-alpha-56);
cursor: pointer;
transition: background var(--t-base), color var(--t-base);
}
.tab-sub:hover { background: var(--black-alpha-4); color: var(--accent-black); }
.tab-sub.active { background: var(--accent-black); color: var(--accent-white); }
.admin-table-wrap { overflow-x: auto; }
.admin-table { width: 100%; }
.admin-table .admin-code { font-size: 12.5px; color: var(--accent-black); letter-spacing: 0.02em; }
.admin-table .col-time { color: var(--black-alpha-56); font-size: 12px; }
.admin-table .col-actions { text-align: right; white-space: nowrap; }
.admin-table td .muted { color: var(--black-alpha-32); }
.admin-table .btn-ghost.danger:hover { color: var(--accent-crimson); }
.admin-modal-desc {
font-size: 13px;
color: var(--black-alpha-64);
line-height: 1.6;
margin: 0 0 16px;
}
+20
View File
@@ -513,3 +513,23 @@ export const api = {
return request<void>(`/api/ops/notifications/${id}/archive/`, { method: "POST" });
}
};
// 平台超管后台 API(/api/admin/*,全程 IsPlatformAdmin)。与团队级 api 分层,便于权限收敛。
export const adminApi = {
invitations(params?: { kind?: string; status?: string; search?: string; page?: number; page_size?: number }) {
const qs = new URLSearchParams();
if (params?.kind) qs.set("kind", params.kind);
if (params?.status) qs.set("status", params.status);
if (params?.search) qs.set("search", params.search);
if (params?.page) qs.set("page", String(params.page));
if (params?.page_size) qs.set("page_size", String(params.page_size));
const q = qs.toString();
return request<Paginated<Invitation>>(`/api/admin/invitations/${q ? `?${q}` : ""}`);
},
createInvite(payload?: { email?: string }) {
return request<Invitation>("/api/admin/invitations/", { method: "POST", body: JSON.stringify(payload || {}) });
},
revokeInvite(id: string) {
return request<Invitation>(`/api/admin/invitations/${id}/revoke/`, { method: "POST" });
}
};
+22 -6
View File
@@ -1,8 +1,11 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { IconKitSvg } from "../../components/IconKitSvg";
import { CornerMarks, Decorations } from "../../components/app-shell";
import { CornerMarks, Decorations, ToastLike } from "../../components/app-shell";
import type { Team, User } from "../../types";
import type { NavigateFn } from "../route-config";
import { AdminInvitesPage } from "./admin-invites";
export type AdminNotify = (type: "success" | "error" | "info", text: string) => void;
// 平台超管后台 · 分阶段上线。section slug 与 URL /admin/<slug> 对应("" = 概览)。
// 视觉一律复用 restraint 外壳类(.app/.sidebar/.topbar/.content/.nav-section),只加极少 admin 专属样式。
@@ -42,12 +45,21 @@ type AdminAppProps = {
export function AdminApp({ section, user, team, navigateAdmin, navigate, logout }: AdminAppProps) {
const active = ADMIN_SECTIONS.find((s) => s.slug === section) || ADMIN_SECTIONS[0];
const [toast, setToast] = useState<{ type: "success" | "error" | "info"; text: string } | null>(null);
const notify: AdminNotify = (type, text) => setToast({ type, text });
// 进后台任一页都滚到顶,行为与主壳 navigate 一致
useEffect(() => {
window.scrollTo({ top: 0, behavior: "auto" });
}, [section]);
// toast 自动消失:错误 5s / 其余 3s(与主壳一致)
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(null), toast.type === "error" ? 5000 : 3000);
return () => clearTimeout(t);
}, [toast]);
return (
<div className="app admin-app">
<aside className="sidebar admin-sidebar">
@@ -135,23 +147,27 @@ export function AdminApp({ section, user, team, navigateAdmin, navigate, logout
)}
</div>
<div className="right">
<span className="pill pill-l2 pill-info"><span className="dot" /></span>
<span className="pill info"><span className="dot" /></span>
</div>
</header>
<div className="content" id="page-content">
<CornerMarks />
<AdminSectionView section={active} navigateAdmin={navigateAdmin} />
{toast && <ToastLike notice={toast} />}
<AdminSectionView section={active} navigateAdmin={navigateAdmin} notify={notify} />
</div>
</main>
</div>
);
}
function AdminSectionView({ section, navigateAdmin }: { section: AdminSection; navigateAdmin: (s: string) => void }) {
function AdminSectionView({ section, navigateAdmin, notify }: { section: AdminSection; navigateAdmin: (s: string) => void; notify: AdminNotify }) {
if (section.slug === "") {
return <AdminOverview navigateAdmin={navigateAdmin} />;
}
// Phase 1+ 的模块在各自阶段替换此占位为真实页面
if (section.slug === "invites") {
return <AdminInvitesPage notify={notify} />;
}
// 其余模块在各自阶段替换此占位为真实页面
return <AdminPlaceholder section={section} />;
}
@@ -0,0 +1,215 @@
import { useCallback, useEffect, useState } from "react";
import { Ticket, X } from "lucide-react";
import { adminApi } from "../../api";
import { IconKitSvg } from "../../components/IconKitSvg";
import type { Invitation } from "../../types";
type Notify = (type: "success" | "error" | "info", text: string) => void;
type KindFilter = "" | "create_team" | "join_team";
// 色调用 restraint pill 颜色类(.pill.info / .ok / .err / .neutral),默认尺寸即 L2。
const STATUS_PILL: Record<Invitation["status"], { cls: string; label: string }> = {
pending: { cls: "info", label: "待用" },
used: { cls: "neutral", label: "已用" },
revoked: { cls: "err", label: "已撤销" },
expired: { cls: "neutral", label: "已过期" }
};
function fmtDate(iso: string) {
if (!iso) return "—";
const d = new Date(iso);
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}/${p(d.getMonth() + 1)}/${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
export function AdminInvitesPage({ notify }: { notify: Notify }) {
const [invites, setInvites] = useState<Invitation[]>([]);
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(true);
const [kind, setKind] = useState<KindFilter>("");
const [modalOpen, setModalOpen] = useState(false);
const [modalEmail, setModalEmail] = useState("");
const [creating, setCreating] = useState(false);
const [copiedId, setCopiedId] = useState("");
const load = useCallback(async () => {
setLoading(true);
try {
const res = await adminApi.invitations({ kind: kind || undefined, page_size: 100 });
setInvites(res.results);
setCount(res.count);
} catch {
notify("error", "加载邀请码失败");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [kind]);
useEffect(() => { void load(); }, [load]);
async function doCreate() {
if (creating) return;
setCreating(true);
try {
const inv = await adminApi.createInvite(modalEmail.trim() ? { email: modalEmail.trim() } : {});
setModalOpen(false);
setModalEmail("");
notify("success", `已发放开团队码 ${inv.code}`);
await load();
} catch {
notify("error", "发放失败,请重试");
} finally {
setCreating(false);
}
}
async function copyLink(inv: Invitation) {
const url = `${window.location.origin}${inv.register_url}`;
try {
await navigator.clipboard.writeText(url);
setCopiedId(inv.id);
setTimeout(() => setCopiedId(""), 1500);
notify("success", "邀请链接已复制");
} catch {
notify("error", "复制失败,请手动复制");
}
}
async function revoke(inv: Invitation) {
try {
await adminApi.revokeInvite(inv.id);
notify("success", "邀请码已撤销");
await load();
} catch {
notify("error", "撤销失败");
}
}
const TABS: { key: KindFilter; label: string }[] = [
{ key: "", label: "全部" },
{ key: "create_team", label: "开团队码" },
{ key: "join_team", label: "加入码" }
];
return (
<>
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span className="mono">// {count} 个</span> · 平台发「开团队码」开新团队;团管发「加入码」拉成员
</div>
</div>
<div className="actions">
<button className="btn btn-primary" type="button" onClick={() => setModalOpen(true)}>
+
</button>
</div>
</div>
<div className="admin-toolbar">
<div className="tabs-sub">
{TABS.map((t) => (
<button
key={t.key || "all"}
type="button"
className={`tab-sub${kind === t.key ? " active" : ""}`}
onClick={() => setKind(t.key)}
>
{t.label}
</button>
))}
</div>
</div>
{loading ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="ticket" size={24} /></div><h3></h3><p>// fetching invitations</p></div>
) : invites.length === 0 ? (
<div className="empty-state show">
<div className="ic-empty"><IconKitSvg name="ticket" size={24} /></div>
<h3></h3>
<p>// 点右上「发开团队码」生成第一个</p>
</div>
) : (
<div className="admin-table-wrap">
<table className="t admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>使</th>
<th className="col-actions"></th>
</tr>
</thead>
<tbody>
{invites.map((inv) => {
const pill = STATUS_PILL[inv.status];
return (
<tr key={inv.id}>
<td><span className="mono admin-code">{inv.code}</span></td>
<td>
<span className={`pill ${inv.kind === "create_team" ? "info" : "neutral"}`}>
<span className="dot" />{inv.kind === "create_team" ? "开团队" : "加入"}
</span>
</td>
<td>{inv.team_name || <span className="muted"></span>}</td>
<td><span className={`pill ${pill.cls}`}><span className="dot" />{pill.label}</span></td>
<td className="mono col-time">{fmtDate(inv.created_at)}</td>
<td>{inv.used_by_username || <span className="muted"></span>}</td>
<td className="col-actions">
<button className="btn btn-sm btn-ghost" type="button" onClick={() => copyLink(inv)}>
{copiedId === inv.id ? "已复制" : "复制链接"}
</button>
{inv.status === "pending" && (
<button className="btn btn-sm btn-ghost danger" type="button" onClick={() => revoke(inv)}>
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{modalOpen && (
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) setModalOpen(false); }}>
<div className="modal" role="dialog" aria-modal="true" aria-label="发放开团队码">
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><Ticket size={16} /></div>
<div className="ti"><span>// issue create_team</span></div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setModalOpen(false)}><X size={14} /></button>
</div>
<div className="modal-b">
<p className="admin-modal-desc">,</p>
<div className="field">
<label className="field-label" htmlFor="inv-email"> <span className="field-hint"> · </span></label>
<input
id="inv-email"
className="input"
type="text"
placeholder="发给谁(可留空)"
value={modalEmail}
onChange={(e) => setModalEmail(e.target.value)}
/>
</div>
</div>
<div className="modal-f">
<button className="btn" type="button" onClick={() => setModalOpen(false)}></button>
<button className="btn btn-primary" type="button" disabled={creating} onClick={() => void doCreate()}>
{creating ? "生成中…" : "生成开团队码"}
</button>
</div>
</div>
</div>
)}
</>
);
}
+3
View File
@@ -24,10 +24,13 @@ export type TeamMember = {
export type Invitation = {
id: string;
code: string;
kind: "join_team" | "create_team";
role: string;
email: string;
monthly_credit_limit: string;
status: "pending" | "used" | "revoked" | "expired";
team: string | null;
team_name: string | null;
expires_at: string;
used_by: string | null;
used_by_username: string | null;