feat(admin): Phase 3 平台团队+用户管理 — 列/启停/详情/强制改密 + 团队停用拦登录

后端:adminpanel 增 teams(列/详情/启停)+ users(列/启停/强制改密),AdminTeam/User 序列化器;
login 校验团队 status,停用团队成员拒登;停用用户清 token、禁停平台超管;全程 IsPlatformAdmin + 审计。
前端:adminApi teams/users 系列;Admin 团队页(筛选+搜索+详情弹窗+启停)、用户页(启停+改密弹窗,超管行禁操作)。
测试:adminpanel 19 + accounts 22 = 41 单测过;无头 e2e _admin-p3.mjs 7 断言过 + 0 console error
(UI 停用团队→成员登录 400→启用→200;UI 改密→新密码登录 200);tsc+build 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 21:39:15 +08:00
co-authored by Claude Opus 4.8
parent 45a4ceae51
commit 6560633959
12 changed files with 818 additions and 4 deletions
+112
View File
@@ -0,0 +1,112 @@
// Phase 3 e2e:平台团队 + 用户管理 —— 列表/详情/启停(团队停用→成员登录被拒)/强制改密。
// 用一次性 P3 团队做启停与改密,绝不动 demo 团队。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();
const OWNER = `p3o-${stamp}`;
const TEAM = `P3Team${stamp}`;
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 }) });
return { status: res.status, body: res.ok ? await res.json() : null };
}
const admin = (await apiLogin("admin", "admin123")).body;
// 一次性团队:超管发开团队码 → 注册 P3 owner 开团队
const inviteRes = await fetch(`${API}/api/admin/invitations/`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Token ${admin.token}` }, body: JSON.stringify({}) });
const code = (await inviteRes.json()).code;
await fetch(`${API}/api/auth/register/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: OWNER, password: "ownerpass1", team_name: TEAM, invite_code: code }) });
const r = { teams: {}, toggle: {}, users: {}, reset: {}, 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 } });
await ctx.addInitScript((tk) => localStorage.setItem("airshelf_token", tk), admin.token);
// ── 团队页 ──
{
const p = await ctx.newPage(); hook(p, "teams");
await p.goto(BASE + "/admin/teams", { waitUntil: "load" });
await p.waitForSelector(".admin-app", { timeout: 12000 });
await p.waitForSelector(".admin-table, .empty-state.show", { timeout: 8000 }).catch(() => {});
r.teams.title = (await p.locator(".content h1").first().innerText().catch(() => "")).trim();
await p.screenshot({ path: path.join(OUT, "p3-teams-list.png"), fullPage: true });
// 搜索我们的一次性团队
await p.fill(".admin-search", TEAM);
await p.waitForTimeout(800);
r.teams.foundOurTeam = (await p.locator(".admin-table tbody tr").count()) === 1;
// 详情弹窗
await p.locator(".admin-table tbody tr:first-child .btn-ghost").first().click();
await p.waitForSelector(".modal", { timeout: 6000 });
await p.waitForTimeout(400);
r.teams.detailMembers = await p.locator(".modal .admin-table tbody tr").count();
await p.screenshot({ path: path.join(OUT, "p3-team-detail.png") });
await p.locator(".modal-f .btn").click();
await p.waitForTimeout(300);
// 停用 → 成员登录应被拒
await p.locator(".admin-table tbody tr:first-child .btn-ghost.danger").click();
await p.waitForTimeout(900);
r.toggle.afterDisableLogin = (await apiLogin(OWNER, "ownerpass1")).status; // 期望 400
// 启用 → 恢复可登
await p.locator(".admin-table tbody tr:first-child .btn-ghost").last().click();
await p.waitForTimeout(900);
r.toggle.afterEnableLogin = (await apiLogin(OWNER, "ownerpass1")).status; // 期望 200
}
// ── 用户页 ──
{
const p = await ctx.newPage(); hook(p, "users");
await p.goto(BASE + "/admin/users", { waitUntil: "load" });
await p.waitForSelector(".admin-app", { timeout: 12000 });
await p.waitForSelector(".admin-table, .empty-state.show", { timeout: 8000 }).catch(() => {});
r.users.title = (await p.locator(".content h1").first().innerText().catch(() => "")).trim();
await p.screenshot({ path: path.join(OUT, "p3-users-list.png"), fullPage: true });
// admin 行应有「超管」标记且不可启停(搜 admin)
await p.fill(".admin-search", "admin");
await p.waitForTimeout(700);
r.users.adminHasBadge = (await p.locator(".admin-table tbody tr:first-child .admin-inline-pill").count()) >= 1;
// 改密一次性 owner
await p.fill(".admin-search", OWNER);
await p.waitForTimeout(700);
r.users.foundOwner = (await p.locator(".admin-table tbody tr").count()) === 1;
await p.locator(".admin-table tbody tr:first-child .btn-ghost").first().click(); // 改密
await p.waitForSelector(".modal", { timeout: 6000 });
await p.fill("#reset-pwd", "p3newpass1");
await p.locator(".modal-f .btn-primary").click();
await p.waitForTimeout(900);
r.reset.newPwdLogin = (await apiLogin(OWNER, "p3newpass1")).status; // 期望 200
await p.screenshot({ path: path.join(OUT, "p3-after-reset.png") });
}
await ctx.close();
await browser.close();
const checks = {
teamsPage: r.teams.title === "团队" && r.teams.foundOurTeam === true,
teamDetail: r.teams.detailMembers >= 1,
disableBlocksLogin: r.toggle.afterDisableLogin === 400,
enableRestoresLogin: r.toggle.afterEnableLogin === 200,
usersPage: r.users.title === "用户" && r.users.adminHasBadge === true && r.users.foundOwner === true,
resetPasswordWorks: r.reset.newPwdLogin === 200,
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, "p3-summary.json"), JSON.stringify(r, null, 2));
process.exit(r.pass ? 0 : 1);