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:
co-authored by
Claude Opus 4.8
parent
3f8547564c
commit
45a4ceae51
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user