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
@@ -47,6 +47,7 @@ INSTALLED_APPS = [
|
||||
"apps.ai",
|
||||
"apps.billing",
|
||||
"apps.ops",
|
||||
"apps.adminpanel",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
@@ -14,4 +14,5 @@ urlpatterns = [
|
||||
path("api/billing/", include("apps.billing.urls")),
|
||||
path("api/ai/", include("apps.ai.urls")),
|
||||
path("api/ops/", include("apps.ops.urls")),
|
||||
path("api/admin/", include("apps.adminpanel.urls")),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AdminpanelConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.adminpanel"
|
||||
verbose_name = "平台后台"
|
||||
@@ -0,0 +1,87 @@
|
||||
from rest_framework.test import APIClient
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.accounts.models import AdminAuditLog, Invitation, Team, TeamMember, User
|
||||
|
||||
|
||||
class AdminInvitationApiTests(TestCase):
|
||||
"""Phase 2:平台超管邀请码发放管理(发开团队码 / 列跨团队码 / 撤销)+ 权限 + 审计。"""
|
||||
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_user(username="padmin", password="x", is_platform_admin=True)
|
||||
self.normal = User.objects.create_user(username="normal", password="x")
|
||||
self.team = Team.objects.create(name="Normal Team", owner=self.normal)
|
||||
TeamMember.objects.create(team=self.team, user=self.normal, role=TeamMember.Role.OWNER)
|
||||
self.admin_client = APIClient()
|
||||
self.admin_client.force_authenticate(self.admin)
|
||||
self.normal_client = APIClient()
|
||||
self.normal_client.force_authenticate(self.normal)
|
||||
|
||||
def test_list_requires_platform_admin(self):
|
||||
self.assertEqual(self.normal_client.get("/api/admin/invitations/").status_code, 403)
|
||||
self.assertEqual(self.admin_client.get("/api/admin/invitations/").status_code, 200)
|
||||
|
||||
def test_create_create_team_code_and_audit(self):
|
||||
r = self.admin_client.post("/api/admin/invitations/", {"email": "x@y.z"}, format="json")
|
||||
self.assertEqual(r.status_code, 201)
|
||||
self.assertEqual(r.data["kind"], Invitation.Kind.CREATE_TEAM)
|
||||
self.assertTrue(r.data["code"].startswith("TEAM-"))
|
||||
self.assertIsNone(r.data["team"])
|
||||
self.assertEqual(r.data["register_url"], f"/register?invite={r.data['code']}")
|
||||
self.assertTrue(AdminAuditLog.objects.filter(action="invite.issue_create_team").exists())
|
||||
|
||||
def test_create_requires_platform_admin(self):
|
||||
self.assertEqual(self.normal_client.post("/api/admin/invitations/", {}, format="json").status_code, 403)
|
||||
|
||||
def test_list_is_cross_team(self):
|
||||
self.admin_client.post("/api/admin/invitations/", {}, format="json")
|
||||
Invitation.objects.create(kind=Invitation.Kind.JOIN_TEAM, team=self.team, role=TeamMember.Role.MEMBER)
|
||||
r = self.admin_client.get("/api/admin/invitations/")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertGreaterEqual(r.data["count"], 2)
|
||||
kinds = {i["kind"] for i in r.data["results"]}
|
||||
self.assertIn(Invitation.Kind.CREATE_TEAM, kinds)
|
||||
self.assertIn(Invitation.Kind.JOIN_TEAM, kinds)
|
||||
|
||||
def test_filter_by_kind(self):
|
||||
self.admin_client.post("/api/admin/invitations/", {}, format="json")
|
||||
Invitation.objects.create(kind=Invitation.Kind.JOIN_TEAM, team=self.team, role=TeamMember.Role.MEMBER)
|
||||
r = self.admin_client.get("/api/admin/invitations/?kind=create_team")
|
||||
self.assertTrue(all(i["kind"] == Invitation.Kind.CREATE_TEAM for i in r.data["results"]))
|
||||
|
||||
def test_revoke_and_audit(self):
|
||||
created = self.admin_client.post("/api/admin/invitations/", {}, format="json")
|
||||
inv_id = created.data["id"]
|
||||
r = self.admin_client.post(f"/api/admin/invitations/{inv_id}/revoke/")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.data["status"], Invitation.Status.REVOKED)
|
||||
self.assertTrue(AdminAuditLog.objects.filter(action="invite.revoke").exists())
|
||||
|
||||
def test_revoke_requires_platform_admin(self):
|
||||
created = self.admin_client.post("/api/admin/invitations/", {}, format="json")
|
||||
inv_id = created.data["id"]
|
||||
self.assertEqual(self.normal_client.post(f"/api/admin/invitations/{inv_id}/revoke/").status_code, 403)
|
||||
|
||||
def test_issued_create_team_code_registers_new_team(self):
|
||||
created = self.admin_client.post("/api/admin/invitations/", {}, format="json")
|
||||
code = created.data["code"]
|
||||
reg = APIClient().post(
|
||||
"/api/auth/register/",
|
||||
{"username": "new-via-admin-code", "password": "strong-pass-1", "team_name": "Via Admin", "invite_code": code},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(reg.status_code, 201)
|
||||
self.assertTrue(Team.objects.filter(name="Via Admin").exists())
|
||||
# 码标 used
|
||||
self.assertEqual(Invitation.objects.get(code=code).status, Invitation.Status.USED)
|
||||
|
||||
def test_revoked_code_cannot_register(self):
|
||||
created = self.admin_client.post("/api/admin/invitations/", {}, format="json")
|
||||
code = created.data["code"]
|
||||
self.admin_client.post(f"/api/admin/invitations/{created.data['id']}/revoke/")
|
||||
reg = APIClient().post(
|
||||
"/api/auth/register/",
|
||||
{"username": "blocked-user", "password": "strong-pass-1", "team_name": "Blocked", "invite_code": code},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(reg.status_code, 400)
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import admin_invitations, admin_revoke_invitation
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("invitations/", admin_invitations, name="admin-invitations"),
|
||||
path("invitations/<uuid:invite_id>/revoke/", admin_revoke_invitation, name="admin-invitation-revoke"),
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""平台超管后台 · 跨团队端点。所有视图统一挂 IsPlatformAdmin,非超管一律 403,写操作记审计。"""
|
||||
|
||||
from django.db.models import Q
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.accounts.audit import log_admin_action
|
||||
from apps.accounts.models import Invitation
|
||||
from apps.accounts.permissions import IsPlatformAdmin
|
||||
from apps.accounts.serializers import InvitationSerializer
|
||||
from apps.common.pagination import DefaultPagination
|
||||
|
||||
|
||||
@api_view(["GET", "POST"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_invitations(request):
|
||||
"""GET 列所有邀请码(跨团队,可按 kind/status/search 过滤,分页);
|
||||
POST 平台超管发「开团队码」(create_team,不绑团队,新用户凭码开新团队当 owner)。"""
|
||||
if request.method == "GET":
|
||||
qs = Invitation.objects.select_related("team", "used_by").order_by("-created_at")
|
||||
kind = request.query_params.get("kind")
|
||||
if kind in {Invitation.Kind.JOIN_TEAM, Invitation.Kind.CREATE_TEAM}:
|
||||
qs = qs.filter(kind=kind)
|
||||
st = request.query_params.get("status")
|
||||
if st in dict(Invitation.Status.choices):
|
||||
qs = qs.filter(status=st)
|
||||
search = (request.query_params.get("search") or "").strip()
|
||||
if search:
|
||||
qs = qs.filter(Q(code__icontains=search) | Q(team__name__icontains=search))
|
||||
paginator = DefaultPagination()
|
||||
page = paginator.paginate_queryset(qs, request)
|
||||
return paginator.get_paginated_response(InvitationSerializer(page, many=True).data)
|
||||
|
||||
email = str(request.data.get("email") or "").strip()
|
||||
invite = Invitation.objects.create(
|
||||
kind=Invitation.Kind.CREATE_TEAM,
|
||||
team=None,
|
||||
email=email,
|
||||
created_by=request.user,
|
||||
)
|
||||
log_admin_action(
|
||||
request,
|
||||
"invite.issue_create_team",
|
||||
target_type="invitation",
|
||||
target_id=invite.id,
|
||||
target_name=invite.code,
|
||||
after={"kind": invite.kind, "email": email},
|
||||
)
|
||||
return Response(InvitationSerializer(invite).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_revoke_invitation(request, invite_id):
|
||||
"""撤销一个待用邀请码(任意团队)。已用/已撤销/已过期则原样返回(幂等)。"""
|
||||
invite = Invitation.objects.select_related("team").filter(id=invite_id).first()
|
||||
if invite is None:
|
||||
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if invite.status == Invitation.Status.PENDING:
|
||||
invite.status = Invitation.Status.REVOKED
|
||||
invite.save(update_fields=["status", "updated_at"])
|
||||
log_admin_action(
|
||||
request,
|
||||
"invite.revoke",
|
||||
target_type="invitation",
|
||||
target_id=invite.id,
|
||||
target_name=invite.code,
|
||||
)
|
||||
return Response(InvitationSerializer(invite).data)
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Phase 2 e2e:平台超管邀请码发放管理页 —— 列表加载、发开团队码弹窗、复制链接反馈、端到端凭码注册开团队。
|
||||
// 沿用 _wave35-auth.mjs / _admin-p0.mjs 模式:headless + token 注入 + 真点击 + 断言 + 截图 + 0 console error。
|
||||
import { chromium } from "playwright";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const BASE = process.env.BASE || "http://127.0.0.1:5188";
|
||||
const API = process.env.API || "http://127.0.0.1:8010";
|
||||
const OUT = path.resolve("output/admin");
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
const stamp = Date.now();
|
||||
|
||||
async function apiLogin(u, p) {
|
||||
const res = await fetch(`${API}/api/auth/login/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: u, password: p }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`login ${u} -> ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
const admin = await apiLogin("admin", "admin123");
|
||||
const r = { page: {}, create: {}, copy: {}, endToEnd: {}, consoleErrors: [], pass: false };
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const hook = (p, tag) => {
|
||||
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push(`${tag}:${m.text()}`); });
|
||||
p.on("pageerror", (e) => r.consoleErrors.push(`${tag}:PAGEERR:${e.message}`));
|
||||
};
|
||||
|
||||
{
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, permissions: ["clipboard-read", "clipboard-write"] });
|
||||
await ctx.addInitScript((tk) => localStorage.setItem("airshelf_token", tk), admin.token);
|
||||
const p = await ctx.newPage(); hook(p, "invites");
|
||||
await p.goto(BASE + "/admin/invites", { waitUntil: "load" });
|
||||
await p.waitForSelector(".admin-app", { timeout: 12000 });
|
||||
await p.waitForSelector(".admin-table, .empty-state.show", { timeout: 8000 }).catch(() => {});
|
||||
await p.waitForTimeout(400);
|
||||
r.page.title = (await p.locator(".content h1").first().innerText().catch(() => "")).trim();
|
||||
r.page.hasCreateBtn = (await p.locator(".page-head .actions .btn-primary").count()) >= 1;
|
||||
await p.screenshot({ path: path.join(OUT, "p2-invites-list.png"), fullPage: true });
|
||||
|
||||
// 发开团队码 → 弹窗 → 确认
|
||||
await p.locator(".page-head .actions .btn-primary").click();
|
||||
await p.waitForSelector(".modal", { timeout: 6000 });
|
||||
r.create.modalShown = (await p.locator(".modal").count()) === 1;
|
||||
await p.screenshot({ path: path.join(OUT, "p2-create-modal.png") });
|
||||
await p.locator(".modal-f .btn-primary").click();
|
||||
await p.waitForTimeout(1300);
|
||||
r.create.modalClosed = (await p.locator(".modal").count()) === 0;
|
||||
r.create.rowCount = await p.locator(".admin-table tbody tr").count();
|
||||
r.create.newCode = (await p.locator(".admin-table tbody tr:first-child .admin-code").innerText().catch(() => "")).trim();
|
||||
await p.screenshot({ path: path.join(OUT, "p2-after-create.png"), fullPage: true });
|
||||
|
||||
// 复制首行链接 → 按钮反馈「已复制」
|
||||
await p.locator(".admin-table tbody tr:first-child .btn-ghost").first().click();
|
||||
await p.waitForTimeout(400);
|
||||
r.copy.btnText = (await p.locator(".admin-table tbody tr:first-child .btn-ghost").first().innerText().catch(() => "")).trim();
|
||||
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// 端到端:用刚发的开团队码注册 → 应开出新团队(201)
|
||||
if (r.create.newCode) {
|
||||
const reg = await fetch(`${API}/api/auth/register/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: `p2-ct-${stamp}`, password: "strong-pass-1", team_name: `P2团队${stamp}`, invite_code: r.create.newCode }),
|
||||
});
|
||||
r.endToEnd.registerStatus = reg.status;
|
||||
r.endToEnd.ok = reg.status === 201;
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
const checks = {
|
||||
pageLoads: r.page.title === "邀请码" && r.page.hasCreateBtn === true,
|
||||
createModal: r.create.modalShown === true,
|
||||
created: !!r.create.newCode && r.create.newCode.startsWith("TEAM-") && r.create.modalClosed === true,
|
||||
copyFeedback: r.copy.btnText === "已复制",
|
||||
endToEndRegister: r.endToEnd.ok === true,
|
||||
zeroConsoleErrors: r.consoleErrors.length === 0,
|
||||
};
|
||||
r.checks = checks;
|
||||
r.pass = Object.values(checks).every(Boolean);
|
||||
console.log(JSON.stringify(r, null, 2));
|
||||
fs.writeFileSync(path.join(OUT, "p2-summary.json"), JSON.stringify(r, null, 2));
|
||||
process.exit(r.pass ? 0 : 1);
|
||||
@@ -61,3 +61,25 @@
|
||||
- 无头 e2e:`_admin-p1.mjs`(端口 5188)—— **全断言过 + 0 console error**:create_team 验码→展开团队名→注册落工作台;join_team 验码→只读团队名「演示团队」→入伙;错码不展开+报错;URL 自动验码。截图 `output/admin/p1-*.png`。
|
||||
- 类型/构建:`tsc --noEmit` + `npm run build` 全绿。
|
||||
- 视觉:两态注册截图核对 —— 黑块等高、验证按钮对齐、绿色 ok 提示、只读团队名灰底,符合 restraint。
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 2 · 邀请码发放管理(Admin)— 完成 2026-06-19
|
||||
|
||||
**后端(新建 `apps/adminpanel/`)**
|
||||
- 新 Django app `apps.adminpanel`(跨团队后台端点容器),注册进 INSTALLED_APPS + 主 urls `path("api/admin/", ...)`。
|
||||
- `GET/POST /api/admin/invitations/`:列所有邀请码(跨团队,按 kind/status/search 过滤 + 分页);POST 平台超管发 create_team「开团队码」(写审计 `invite.issue_create_team`)。
|
||||
- `POST /api/admin/invitations/<id>/revoke/`:撤销待用码(写审计 `invite.revoke`)。
|
||||
- 全程 `IsPlatformAdmin`,非超管 403。
|
||||
|
||||
**前端**
|
||||
- `adminApi`(api.ts 分层):invitations / createInvite / revokeInvite。
|
||||
- Admin「邀请码」页(`routes/admin/admin-invites.tsx`):类型筛选段控(全部/开团队码/加入码)+ 表格(码/类型/团队/状态徽章/创建时间/使用者)+「发开团队码」弹窗 + 复制邀请链接(剪贴板 + 反馈)+ 撤销;接入 AdminApp(本地 toast notify)。
|
||||
- `Invitation` 类型补 kind/team/team_name;admin-page.css 补段控/表格/弹窗样式(仅 token)。
|
||||
|
||||
**测试底座(全过)**
|
||||
- 后端单测:`apps.adminpanel` **9 项 + apps.accounts 22 = 31 OK**(列权限 403/发码+审计/跨团队列表/kind 过滤/撤销+审计/端到端凭发出的码注册开团队/撤销后码失效)。
|
||||
- 无头 e2e:`_admin-p2.mjs`(5188)—— **6 断言全过 + 0 console error**:页面加载、发码弹窗、新码入列、复制链接反馈「已复制」、**端到端用 UI 发的码 API 注册开团队 201**。截图 `output/admin/p2-*.png`。
|
||||
- 类型/构建:`tsc --noEmit` + `npm run build` 全绿。
|
||||
- 视觉:列表截图核对 —— 类型/状态橙/中性 pill、段控黑激活、成功 toast,符合 restraint。
|
||||
- 回归:accounts 22 仍全绿;live 后端自动 reload 后 `/api/admin/invitations/` 200(admin)/401(匿名)。
|
||||
|
||||
Reference in New Issue
Block a user