From 45a4ceae5102c59680ff2d6ea01702a041871235 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 19 Jun 2026 21:29:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20Phase=202=20=E9=82=80=E8=AF=B7?= =?UTF-8?q?=E7=A0=81=E5=8F=91=E6=94=BE=E7=AE=A1=E7=90=86=20=E2=80=94=20app?= =?UTF-8?q?s/adminpanel=20=E8=B7=A8=E5=9B=A2=E9=98=9F=E7=AB=AF=E7=82=B9=20?= =?UTF-8?q?+=20Admin=20=E9=82=80=E8=AF=B7=E7=A0=81=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端:新建 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 --- core/backend/airshelf/settings/base.py | 1 + core/backend/airshelf/urls.py | 1 + core/backend/apps/adminpanel/__init__.py | 0 core/backend/apps/adminpanel/apps.py | 7 + core/backend/apps/adminpanel/tests.py | 87 +++++++ core/backend/apps/adminpanel/urls.py | 9 + core/backend/apps/adminpanel/views.py | 70 ++++++ core/frontend/src/admin-page.css | 45 ++++ core/frontend/src/api.ts | 20 ++ core/frontend/src/routes/admin/admin-app.tsx | 28 ++- .../src/routes/admin/admin-invites.tsx | 215 ++++++++++++++++++ core/frontend/src/types.ts | 3 + core/qa/visual-parity/_admin-p2.mjs | 87 +++++++ docs/todo/admin-后台-进度.md | 22 ++ 14 files changed, 589 insertions(+), 6 deletions(-) create mode 100644 core/backend/apps/adminpanel/__init__.py create mode 100644 core/backend/apps/adminpanel/apps.py create mode 100644 core/backend/apps/adminpanel/tests.py create mode 100644 core/backend/apps/adminpanel/urls.py create mode 100644 core/backend/apps/adminpanel/views.py create mode 100644 core/frontend/src/routes/admin/admin-invites.tsx create mode 100644 core/qa/visual-parity/_admin-p2.mjs diff --git a/core/backend/airshelf/settings/base.py b/core/backend/airshelf/settings/base.py index 51bec7c..67e0f47 100644 --- a/core/backend/airshelf/settings/base.py +++ b/core/backend/airshelf/settings/base.py @@ -47,6 +47,7 @@ INSTALLED_APPS = [ "apps.ai", "apps.billing", "apps.ops", + "apps.adminpanel", ] MIDDLEWARE = [ diff --git a/core/backend/airshelf/urls.py b/core/backend/airshelf/urls.py index d06a4a3..ebffa16 100644 --- a/core/backend/airshelf/urls.py +++ b/core/backend/airshelf/urls.py @@ -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")), ] diff --git a/core/backend/apps/adminpanel/__init__.py b/core/backend/apps/adminpanel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/backend/apps/adminpanel/apps.py b/core/backend/apps/adminpanel/apps.py new file mode 100644 index 0000000..bc61489 --- /dev/null +++ b/core/backend/apps/adminpanel/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AdminpanelConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.adminpanel" + verbose_name = "平台后台" diff --git a/core/backend/apps/adminpanel/tests.py b/core/backend/apps/adminpanel/tests.py new file mode 100644 index 0000000..8d6244c --- /dev/null +++ b/core/backend/apps/adminpanel/tests.py @@ -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) diff --git a/core/backend/apps/adminpanel/urls.py b/core/backend/apps/adminpanel/urls.py new file mode 100644 index 0000000..6e6f105 --- /dev/null +++ b/core/backend/apps/adminpanel/urls.py @@ -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//revoke/", admin_revoke_invitation, name="admin-invitation-revoke"), +] diff --git a/core/backend/apps/adminpanel/views.py b/core/backend/apps/adminpanel/views.py new file mode 100644 index 0000000..9a71f62 --- /dev/null +++ b/core/backend/apps/adminpanel/views.py @@ -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) diff --git a/core/frontend/src/admin-page.css b/core/frontend/src/admin-page.css index 0a20a86..8a947e9 100644 --- a/core/frontend/src/admin-page.css +++ b/core/frontend/src/admin-page.css @@ -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; +} diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index e647749..4af88d2 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -513,3 +513,23 @@ export const api = { return request(`/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>(`/api/admin/invitations/${q ? `?${q}` : ""}`); + }, + createInvite(payload?: { email?: string }) { + return request("/api/admin/invitations/", { method: "POST", body: JSON.stringify(payload || {}) }); + }, + revokeInvite(id: string) { + return request(`/api/admin/invitations/${id}/revoke/`, { method: "POST" }); + } +}; diff --git a/core/frontend/src/routes/admin/admin-app.tsx b/core/frontend/src/routes/admin/admin-app.tsx index ece6b6b..2177f4f 100644 --- a/core/frontend/src/routes/admin/admin-app.tsx +++ b/core/frontend/src/routes/admin/admin-app.tsx @@ -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/ 对应("" = 概览)。 // 视觉一律复用 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 (
- 超管模式 + 超管模式
- + {toast && } +
); } -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 ; } - // Phase 1+ 的模块在各自阶段替换此占位为真实页面 + if (section.slug === "invites") { + return ; + } + // 其余模块在各自阶段替换此占位为真实页面 return ; } diff --git a/core/frontend/src/routes/admin/admin-invites.tsx b/core/frontend/src/routes/admin/admin-invites.tsx new file mode 100644 index 0000000..37cec86 --- /dev/null +++ b/core/frontend/src/routes/admin/admin-invites.tsx @@ -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 = { + 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([]); + const [count, setCount] = useState(0); + const [loading, setLoading] = useState(true); + const [kind, setKind] = useState(""); + 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 ( + <> +
+
+

邀请码

+
+ // {count} 个 · 平台发「开团队码」开新团队;团管发「加入码」拉成员 +
+
+
+ +
+
+ +
+
+ {TABS.map((t) => ( + + ))} +
+
+ + {loading ? ( +

加载中…

// fetching invitations

+ ) : invites.length === 0 ? ( +
+
+

暂无邀请码

+

// 点右上「发开团队码」生成第一个

+
+ ) : ( +
+ + + + + + + + + + + + + + {invites.map((inv) => { + const pill = STATUS_PILL[inv.status]; + return ( + + + + + + + + + + ); + })} + +
邀请码类型团队状态创建时间使用者操作
{inv.code} + + {inv.kind === "create_team" ? "开团队" : "加入"} + + {inv.team_name || }{pill.label}{fmtDate(inv.created_at)}{inv.used_by_username || } + + {inv.status === "pending" && ( + + )} +
+
+ )} + + {modalOpen && ( +
{ if (e.target === e.currentTarget) setModalOpen(false); }}> +
+ ++ +
+
+
发放开团队码// issue create_team
+ +
+
+

生成一个「开团队码」,凭此码注册的人将开一个新团队并成为团队超管。

+
+ + setModalEmail(e.target.value)} + /> +
+
+
+ + +
+
+
+ )} + + ); +} diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index 9ee343f..d26fccf 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -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; diff --git a/core/qa/visual-parity/_admin-p2.mjs b/core/qa/visual-parity/_admin-p2.mjs new file mode 100644 index 0000000..05911a9 --- /dev/null +++ b/core/qa/visual-parity/_admin-p2.mjs @@ -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); diff --git a/docs/todo/admin-后台-进度.md b/docs/todo/admin-后台-进度.md index f651e6f..358f77e 100644 --- a/docs/todo/admin-后台-进度.md +++ b/docs/todo/admin-后台-进度.md @@ -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//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(匿名)。