From 65606339590cd4c8462509f6687a8198f7a1a5af Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 19 Jun 2026 21:39:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20Phase=203=20=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E5=9B=A2=E9=98=9F+=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86=20?= =?UTF-8?q?=E2=80=94=20=E5=88=97/=E5=90=AF=E5=81=9C/=E8=AF=A6=E6=83=85/?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E6=94=B9=E5=AF=86=20+=20=E5=9B=A2=E9=98=9F?= =?UTF-8?q?=E5=81=9C=E7=94=A8=E6=8B=A6=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: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 --- core/backend/apps/accounts/views.py | 5 +- core/backend/apps/adminpanel/serializers.py | 47 +++ core/backend/apps/adminpanel/tests.py | 86 +++++ core/backend/apps/adminpanel/urls.py | 17 +- core/backend/apps/adminpanel/views.py | 135 +++++++- core/frontend/src/admin-page.css | 15 + core/frontend/src/api.ts | 33 ++ core/frontend/src/routes/admin/admin-app.tsx | 7 + .../src/routes/admin/admin-teams-users.tsx | 315 ++++++++++++++++++ core/frontend/src/types.ts | 29 ++ core/qa/visual-parity/_admin-p3.mjs | 112 +++++++ docs/todo/admin-后台-进度.md | 21 ++ 12 files changed, 818 insertions(+), 4 deletions(-) create mode 100644 core/backend/apps/adminpanel/serializers.py create mode 100644 core/frontend/src/routes/admin/admin-teams-users.tsx create mode 100644 core/qa/visual-parity/_admin-p3.mjs diff --git a/core/backend/apps/accounts/views.py b/core/backend/apps/accounts/views.py index 57b899f..06145d3 100644 --- a/core/backend/apps/accounts/views.py +++ b/core/backend/apps/accounts/views.py @@ -12,7 +12,7 @@ from rest_framework.response import Response from apps.common.api import get_current_team -from .models import Invitation, LoginSession, TeamMember, User, UserPreference +from .models import Invitation, LoginSession, Team, TeamMember, User, UserPreference from .serializers import ( InvitationSerializer, LoginSerializer, @@ -124,6 +124,9 @@ def login(request): if user is None or user.is_disabled: return Response({"detail": "invalid credentials"}, status=status.HTTP_400_BAD_REQUEST) team = current_team_or_none(user) + # 团队被平台超管停用:其成员一律不准登录(平台超管无团队,不受影响) + if team is not None and team.status == Team.Status.DISABLED: + return Response({"detail": "团队已停用,请联系平台管理员"}, status=status.HTTP_400_BAD_REQUEST) token, _ = Token.objects.get_or_create(user=user) record_login_session(request, user) return Response(auth_payload(user, team, token)) diff --git a/core/backend/apps/adminpanel/serializers.py b/core/backend/apps/adminpanel/serializers.py new file mode 100644 index 0000000..cb2f9b3 --- /dev/null +++ b/core/backend/apps/adminpanel/serializers.py @@ -0,0 +1,47 @@ +from rest_framework import serializers + +from apps.accounts.models import Team, TeamMember, User + + +class AdminTeamSerializer(serializers.ModelSerializer): + owner_username = serializers.CharField(source="owner.username", read_only=True, default=None) + member_count = serializers.SerializerMethodField() + balance = serializers.SerializerMethodField() + + class Meta: + model = Team + fields = ["id", "name", "status", "owner", "owner_username", "member_count", "balance", "created_at"] + read_only_fields = fields + + def get_member_count(self, obj): + anno = getattr(obj, "member_count_anno", None) + return anno if anno is not None else obj.members.count() + + def get_balance(self, obj): + acct = getattr(obj, "credit_account", None) + return str(acct.balance) if acct is not None else "0" + + +class AdminTeamMemberSerializer(serializers.ModelSerializer): + username = serializers.CharField(source="user.username", read_only=True) + user_status = serializers.CharField(source="user.status", read_only=True) + + class Meta: + model = TeamMember + fields = ["id", "username", "role", "status", "user_status", "monthly_credit_limit"] + read_only_fields = fields + + +class AdminUserSerializer(serializers.ModelSerializer): + teams = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ["id", "username", "status", "is_platform_admin", "date_joined", "teams"] + read_only_fields = fields + + def get_teams(self, obj): + return [ + {"team_id": str(m.team_id), "team_name": m.team.name, "role": m.role} + for m in obj.team_memberships.select_related("team").all() + ] diff --git a/core/backend/apps/adminpanel/tests.py b/core/backend/apps/adminpanel/tests.py index 8d6244c..5e5ee83 100644 --- a/core/backend/apps/adminpanel/tests.py +++ b/core/backend/apps/adminpanel/tests.py @@ -85,3 +85,89 @@ class AdminInvitationApiTests(TestCase): format="json", ) self.assertEqual(reg.status_code, 400) + + +class AdminTeamUserApiTests(TestCase): + """Phase 3:平台团队 + 用户管理(列/详情/启停/强制改密)+ 权限 + 团队停用拦登录。""" + + def setUp(self): + from apps.billing.models import CreditAccount + + self.admin = User.objects.create_user(username="padmin3", password="x", is_platform_admin=True) + self.owner = User.objects.create_user(username="t-owner", password="ownerpass1") + self.member = User.objects.create_user(username="t-member", password="memberpass1") + self.team = Team.objects.create(name="Acme", owner=self.owner) + TeamMember.objects.create(team=self.team, user=self.owner, role=TeamMember.Role.OWNER) + TeamMember.objects.create(team=self.team, user=self.member, role=TeamMember.Role.MEMBER) + CreditAccount.objects.create(team=self.team, balance="123.4500") + self.ac = APIClient() + self.ac.force_authenticate(self.admin) + self.normal = APIClient() + self.normal.force_authenticate(self.owner) + + def _login(self, username, password): + return APIClient().post("/api/auth/login/", {"username": username, "password": password}, format="json") + + def test_teams_list_permission_and_fields(self): + self.assertEqual(self.normal.get("/api/admin/teams/").status_code, 403) + r = self.ac.get("/api/admin/teams/") + self.assertEqual(r.status_code, 200) + row = next(t for t in r.data["results"] if t["name"] == "Acme") + self.assertEqual(row["member_count"], 2) + self.assertEqual(row["owner_username"], "t-owner") + self.assertEqual(row["balance"], "123.4500") + + def test_team_detail_has_members(self): + r = self.ac.get(f"/api/admin/teams/{self.team.id}/") + self.assertEqual(r.status_code, 200) + self.assertEqual(len(r.data["members"]), 2) + + def test_team_toggle_blocks_then_restores_member_login(self): + self.assertEqual(self._login("t-member", "memberpass1").status_code, 200) + tr = self.ac.post(f"/api/admin/teams/{self.team.id}/toggle/") + self.assertEqual(tr.status_code, 200) + self.assertEqual(tr.data["status"], Team.Status.DISABLED) + self.assertEqual(self._login("t-member", "memberpass1").status_code, 400) # 停用后被拒 + self.ac.post(f"/api/admin/teams/{self.team.id}/toggle/") # 恢复 + self.assertEqual(self._login("t-member", "memberpass1").status_code, 200) + self.assertTrue(AdminAuditLog.objects.filter(action="team.toggle_status").exists()) + + def test_team_toggle_requires_admin(self): + self.assertEqual(self.normal.post(f"/api/admin/teams/{self.team.id}/toggle/").status_code, 403) + + def test_users_list_with_teams(self): + self.assertEqual(self.normal.get("/api/admin/users/").status_code, 403) + r = self.ac.get("/api/admin/users/") + self.assertEqual(r.status_code, 200) + member_row = next(u for u in r.data["results"] if u["username"] == "t-member") + self.assertEqual(member_row["teams"][0]["team_name"], "Acme") + + def test_user_toggle_blocks_login(self): + r = self.ac.post(f"/api/admin/users/{self.member.id}/toggle/") + self.assertEqual(r.status_code, 200) + self.assertEqual(r.data["status"], User.Status.DISABLED) + self.assertEqual(self._login("t-member", "memberpass1").status_code, 400) + self.assertTrue(AdminAuditLog.objects.filter(action="user.toggle_status").exists()) + + def test_cannot_disable_platform_admin(self): + self.assertEqual(self.ac.post(f"/api/admin/users/{self.admin.id}/toggle/").status_code, 400) + + def test_reset_password(self): + r = self.ac.post(f"/api/admin/users/{self.member.id}/reset-password/", {"password": "newpass789"}, format="json") + self.assertEqual(r.status_code, 204) + self.assertEqual(self._login("t-member", "newpass789").status_code, 200) + self.assertEqual(self._login("t-member", "memberpass1").status_code, 400) # 旧密码失效 + self.assertTrue(AdminAuditLog.objects.filter(action="user.reset_password").exists()) + + def test_reset_password_too_short_rejected(self): + self.assertEqual( + self.ac.post(f"/api/admin/users/{self.member.id}/reset-password/", {"password": "short"}, format="json").status_code, + 400, + ) + + def test_user_actions_require_admin(self): + self.assertEqual(self.normal.post(f"/api/admin/users/{self.member.id}/toggle/").status_code, 403) + self.assertEqual( + self.normal.post(f"/api/admin/users/{self.member.id}/reset-password/", {"password": "newpass789"}, format="json").status_code, + 403, + ) diff --git a/core/backend/apps/adminpanel/urls.py b/core/backend/apps/adminpanel/urls.py index 6e6f105..df00bbd 100644 --- a/core/backend/apps/adminpanel/urls.py +++ b/core/backend/apps/adminpanel/urls.py @@ -1,9 +1,24 @@ from django.urls import path -from .views import admin_invitations, admin_revoke_invitation +from .views import ( + admin_invitations, + admin_revoke_invitation, + admin_team_detail, + admin_team_toggle, + admin_teams, + admin_user_reset_password, + admin_user_toggle, + admin_users, +) urlpatterns = [ path("invitations/", admin_invitations, name="admin-invitations"), path("invitations//revoke/", admin_revoke_invitation, name="admin-invitation-revoke"), + path("teams/", admin_teams, name="admin-teams"), + path("teams//", admin_team_detail, name="admin-team-detail"), + path("teams//toggle/", admin_team_toggle, name="admin-team-toggle"), + path("users/", admin_users, name="admin-users"), + path("users//toggle/", admin_user_toggle, name="admin-user-toggle"), + path("users//reset-password/", admin_user_reset_password, name="admin-user-reset-password"), ] diff --git a/core/backend/apps/adminpanel/views.py b/core/backend/apps/adminpanel/views.py index 9a71f62..d17e45f 100644 --- a/core/backend/apps/adminpanel/views.py +++ b/core/backend/apps/adminpanel/views.py @@ -1,16 +1,26 @@ """平台超管后台 · 跨团队端点。所有视图统一挂 IsPlatformAdmin,非超管一律 403,写操作记审计。""" -from django.db.models import Q +from django.db.models import Count, Q from rest_framework import status +from rest_framework.authtoken.models import Token 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.models import Invitation, Team, User from apps.accounts.permissions import IsPlatformAdmin from apps.accounts.serializers import InvitationSerializer from apps.common.pagination import DefaultPagination +from .serializers import AdminTeamMemberSerializer, AdminTeamSerializer, AdminUserSerializer + + +def _team_qs(): + return ( + Team.objects.select_related("owner", "credit_account") + .annotate(member_count_anno=Count("members", distinct=True)) + ) + @api_view(["GET", "POST"]) @permission_classes([IsPlatformAdmin]) @@ -68,3 +78,124 @@ def admin_revoke_invitation(request, invite_id): target_name=invite.code, ) return Response(InvitationSerializer(invite).data) + + +# ─────────────────────────── 团队管理 ─────────────────────────── + + +@api_view(["GET"]) +@permission_classes([IsPlatformAdmin]) +def admin_teams(request): + """列所有团队(跨团队,可按 status/search 过滤,分页)。""" + qs = _team_qs().order_by("-created_at") + st = request.query_params.get("status") + if st in dict(Team.Status.choices): + qs = qs.filter(status=st) + search = (request.query_params.get("search") or "").strip() + if search: + qs = qs.filter(name__icontains=search) + paginator = DefaultPagination() + page = paginator.paginate_queryset(qs, request) + return paginator.get_paginated_response(AdminTeamSerializer(page, many=True).data) + + +@api_view(["GET"]) +@permission_classes([IsPlatformAdmin]) +def admin_team_detail(request, team_id): + team = _team_qs().filter(id=team_id).first() + if team is None: + return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND) + members = team.members.select_related("user").order_by("created_at") + data = AdminTeamSerializer(team).data + data["members"] = AdminTeamMemberSerializer(members, many=True).data + return Response(data) + + +@api_view(["POST"]) +@permission_classes([IsPlatformAdmin]) +def admin_team_toggle(request, team_id): + """启停团队。团队停用后其成员登录会被拒(login 校验团队状态)。""" + team = Team.objects.filter(id=team_id).first() + if team is None: + return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND) + before = team.status + team.status = Team.Status.ACTIVE if team.status == Team.Status.DISABLED else Team.Status.DISABLED + team.save(update_fields=["status", "updated_at"]) + log_admin_action( + request, + "team.toggle_status", + target_type="team", + target_id=team.id, + target_name=team.name, + before={"status": before}, + after={"status": team.status}, + ) + return Response(AdminTeamSerializer(_team_qs().get(id=team.id)).data) + + +# ─────────────────────────── 用户管理 ─────────────────────────── + + +@api_view(["GET"]) +@permission_classes([IsPlatformAdmin]) +def admin_users(request): + """列所有用户(跨团队,可按 status/search 过滤,分页)。""" + qs = User.objects.prefetch_related("team_memberships__team").order_by("-date_joined") + st = request.query_params.get("status") + if st in dict(User.Status.choices): + qs = qs.filter(status=st) + search = (request.query_params.get("search") or "").strip() + if search: + qs = qs.filter(username__icontains=search) + paginator = DefaultPagination() + page = paginator.paginate_queryset(qs, request) + return paginator.get_paginated_response(AdminUserSerializer(page, many=True).data) + + +@api_view(["POST"]) +@permission_classes([IsPlatformAdmin]) +def admin_user_toggle(request, user_id): + """启停用户。停用即清 token 强制下线;不允许停用平台超管(防自锁)。""" + user = User.objects.filter(id=user_id).first() + if user is None: + return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND) + if user.is_platform_admin: + return Response({"detail": "不能停用平台超管"}, status=status.HTTP_400_BAD_REQUEST) + before = user.status + user.status = User.Status.ACTIVE if user.status == User.Status.DISABLED else User.Status.DISABLED + user.save(update_fields=["status"]) + if user.status == User.Status.DISABLED: + Token.objects.filter(user=user).delete() + log_admin_action( + request, + "user.toggle_status", + target_type="user", + target_id=user.id, + target_name=user.username, + before={"status": before}, + after={"status": user.status}, + ) + return Response(AdminUserSerializer(user).data) + + +@api_view(["POST"]) +@permission_classes([IsPlatformAdmin]) +def admin_user_reset_password(request, user_id): + """平台超管强制改用户密码(改后清 token 强制重登)。""" + user = User.objects.filter(id=user_id).first() + if user is None: + return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND) + password = str(request.data.get("password") or "").strip() + if len(password) < 8: + return Response({"password": ["新密码至少 8 位"]}, status=status.HTTP_400_BAD_REQUEST) + user.set_password(password) + user.save(update_fields=["password"]) + Token.objects.filter(user=user).delete() + log_admin_action( + request, + "user.reset_password", + target_type="user", + target_id=user.id, + target_name=user.username, + ) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/core/frontend/src/admin-page.css b/core/frontend/src/admin-page.css index 8a947e9..4e6772c 100644 --- a/core/frontend/src/admin-page.css +++ b/core/frontend/src/admin-page.css @@ -129,3 +129,18 @@ line-height: 1.6; margin: 0 0 16px; } + +/* ── 团队 / 用户管理 ── */ +.admin-search { width: 220px; margin-left: auto; } +.modal.modal-wide { max-width: 640px; } +.admin-detail-meta { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px 24px; + margin-bottom: 20px; +} +.admin-detail-meta > div { display: flex; flex-direction: column; gap: 3px; } +.admin-detail-meta .k { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); } +.admin-detail-meta .v { font-size: 14px; color: var(--accent-black); } +.admin-detail-subhead { font-size: 13px; font-weight: 500; color: var(--accent-black); margin: 8px 0 10px; } +.admin-inline-pill { margin-left: 8px; } diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 4af88d2..03577df 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -1,4 +1,7 @@ import type { + AdminTeam, + AdminTeamDetail, + AdminUser, AITask, Asset, AuthPayload, @@ -531,5 +534,35 @@ export const adminApi = { }, revokeInvite(id: string) { return request(`/api/admin/invitations/${id}/revoke/`, { method: "POST" }); + }, + teams(params?: { status?: string; search?: string; page?: number; page_size?: number }) { + const qs = new URLSearchParams(); + 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/teams/${q ? `?${q}` : ""}`); + }, + teamDetail(id: string) { + return request(`/api/admin/teams/${id}/`); + }, + toggleTeam(id: string) { + return request(`/api/admin/teams/${id}/toggle/`, { method: "POST" }); + }, + users(params?: { status?: string; search?: string; page?: number; page_size?: number }) { + const qs = new URLSearchParams(); + 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/users/${q ? `?${q}` : ""}`); + }, + toggleUser(id: string) { + return request(`/api/admin/users/${id}/toggle/`, { method: "POST" }); + }, + resetUserPassword(id: string, password: string) { + return request(`/api/admin/users/${id}/reset-password/`, { method: "POST", body: JSON.stringify({ password }) }); } }; diff --git a/core/frontend/src/routes/admin/admin-app.tsx b/core/frontend/src/routes/admin/admin-app.tsx index 2177f4f..b248481 100644 --- a/core/frontend/src/routes/admin/admin-app.tsx +++ b/core/frontend/src/routes/admin/admin-app.tsx @@ -4,6 +4,7 @@ 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"; +import { AdminTeamsPage, AdminUsersPage } from "./admin-teams-users"; export type AdminNotify = (type: "success" | "error" | "info", text: string) => void; @@ -167,6 +168,12 @@ function AdminSectionView({ section, navigateAdmin, notify }: { section: AdminSe if (section.slug === "invites") { return ; } + if (section.slug === "teams") { + return ; + } + if (section.slug === "users") { + return ; + } // 其余模块在各自阶段替换此占位为真实页面 return ; } diff --git a/core/frontend/src/routes/admin/admin-teams-users.tsx b/core/frontend/src/routes/admin/admin-teams-users.tsx new file mode 100644 index 0000000..afb4195 --- /dev/null +++ b/core/frontend/src/routes/admin/admin-teams-users.tsx @@ -0,0 +1,315 @@ +import { useCallback, useEffect, useState } from "react"; +import { Building, KeyRound, X } from "lucide-react"; +import { adminApi } from "../../api"; +import { IconKitSvg } from "../../components/IconKitSvg"; +import type { AdminTeam, AdminTeamDetail, AdminUser } from "../../types"; + +type Notify = (type: "success" | "error" | "info", text: string) => void; + +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())}`; +} + +function statusPill(active: boolean) { + return active + ? 启用 + : 停用; +} + +const STATUS_TABS = [ + { key: "", label: "全部" }, + { key: "active", label: "启用" }, + { key: "disabled", label: "停用" } +]; + +// ─────────────────────────── 团队管理 ─────────────────────────── + +export function AdminTeamsPage({ notify }: { notify: Notify }) { + const [teams, setTeams] = useState([]); + const [count, setCount] = useState(0); + const [loading, setLoading] = useState(true); + const [statusFilter, setStatusFilter] = useState(""); + const [search, setSearch] = useState(""); + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await adminApi.teams({ status: statusFilter || undefined, search: search.trim() || undefined, page_size: 100 }); + setTeams(res.results); + setCount(res.count); + } catch { + notify("error", "加载团队失败"); + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [statusFilter, search]); + + useEffect(() => { void load(); }, [load]); + + async function openDetail(id: string) { + setDetailLoading(true); + setDetail(null); + try { + setDetail(await adminApi.teamDetail(id)); + } catch { + notify("error", "加载团队详情失败"); + } finally { + setDetailLoading(false); + } + } + + async function toggle(t: AdminTeam) { + try { + const next = await adminApi.toggleTeam(t.id); + notify("success", next.status === "disabled" ? `已停用「${t.name}」` : `已启用「${t.name}」`); + await load(); + } catch { + notify("error", "操作失败"); + } + } + + return ( + <> +
+
+

团队

+
// {count} 个团队 · 跨团队总览 · 启停 / 详情
+
+
+ +
+
+ {STATUS_TABS.map((t) => ( + + ))} +
+ setSearch(e.target.value)} /> +
+ + {loading ? ( +

加载中…

// fetching teams

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

暂无团队

// no teams

+ ) : ( +
+ + + + + + {teams.map((t) => ( + + + + + + + + + + ))} + +
团队名超管成员余额状态创建时间操作
{t.name}{t.owner_username || }{t.member_count}¥{t.balance}{statusPill(t.status === "active")}{fmtDate(t.created_at)} + + +
+
+ )} + + {(detail || detailLoading) && ( +
{ if (e.target === e.currentTarget) setDetail(null); }}> +
+ ++ +
+
+
{detail?.name || "团队详情"}// team detail
+ +
+
+ {detailLoading || !detail ? ( +

加载中…

+ ) : ( + <> +
+
超管{detail.owner_username || "—"}
+
成员数{detail.member_count}
+
余额¥{detail.balance}
+
状态{statusPill(detail.status === "active")}
+
+
成员 · {detail.members.length}
+ + + + {detail.members.map((m) => ( + + + + + + + ))} + +
用户名角色状态月度额度
{m.username}{m.role}{statusPill(m.user_status === "active")}¥{m.monthly_credit_limit}
+ + )} +
+
+ +
+
+
+ )} + + ); +} + +// ─────────────────────────── 用户管理 ─────────────────────────── + +export function AdminUsersPage({ notify }: { notify: Notify }) { + const [users, setUsers] = useState([]); + const [count, setCount] = useState(0); + const [loading, setLoading] = useState(true); + const [statusFilter, setStatusFilter] = useState(""); + const [search, setSearch] = useState(""); + const [pwdTarget, setPwdTarget] = useState(null); + const [pwd, setPwd] = useState(""); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await adminApi.users({ status: statusFilter || undefined, search: search.trim() || undefined, page_size: 100 }); + setUsers(res.results); + setCount(res.count); + } catch { + notify("error", "加载用户失败"); + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [statusFilter, search]); + + useEffect(() => { void load(); }, [load]); + + async function toggle(u: AdminUser) { + if (u.is_platform_admin) return; + try { + const next = await adminApi.toggleUser(u.id); + notify("success", next.status === "disabled" ? `已停用 ${u.username}` : `已启用 ${u.username}`); + await load(); + } catch { + notify("error", "操作失败"); + } + } + + async function doReset() { + if (!pwdTarget || saving) return; + if (pwd.trim().length < 8) { notify("error", "新密码至少 8 位"); return; } + setSaving(true); + try { + await adminApi.resetUserPassword(pwdTarget.id, pwd.trim()); + notify("success", `已重置 ${pwdTarget.username} 的密码`); + setPwdTarget(null); + setPwd(""); + } catch { + notify("error", "改密失败"); + } finally { + setSaving(false); + } + } + + return ( + <> +
+
+

用户

+
// {count} 个用户 · 跨团队 · 启停 / 强制改密
+
+
+ +
+
+ {STATUS_TABS.map((t) => ( + + ))} +
+ setSearch(e.target.value)} /> +
+ + {loading ? ( +

加载中…

// fetching users

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

暂无用户

// no users

+ ) : ( +
+ + + + + + {users.map((u) => ( + + + + + + + + ))} + +
用户名所属团队状态注册时间操作
+ {u.username} + {u.is_platform_admin && 超管} + + {u.teams.length === 0 + ? + : u.teams.map((t) => t.team_name).join("、")} + {statusPill(u.status === "active")}{fmtDate(u.date_joined)} + {u.is_platform_admin ? ( + + ) : ( + <> + + + + )} +
+
+ )} + + {pwdTarget && ( +
{ if (e.target === e.currentTarget) setPwdTarget(null); }}> +
+ ++ +
+
+
重置密码// {pwdTarget.username}
+ +
+
+

为「{pwdTarget.username}」设置新密码,保存后其当前登录会被强制下线。

+
+ + setPwd(e.target.value)} /> +
+
+
+ + +
+
+
+ )} + + ); +} diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index d26fccf..a63c787 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -45,6 +45,35 @@ export type AuthPayload = { team: Team; }; +// 平台后台 · 团队/用户管理视图模型 +export type AdminTeam = { + id: string; + name: string; + status: string; + owner: string; + owner_username: string | null; + member_count: number; + balance: string; + created_at: string; +}; +export type AdminTeamMember = { + id: string; + username: string; + role: string; + status: string; + user_status: string; + monthly_credit_limit: string; +}; +export type AdminTeamDetail = AdminTeam & { members: AdminTeamMember[] }; +export type AdminUser = { + id: string; + username: string; + status: string; + is_platform_admin: boolean; + date_joined: string; + teams: { team_id: string; team_name: string; role: string }[]; +}; + export type Paginated = { count: number; next: string | null; diff --git a/core/qa/visual-parity/_admin-p3.mjs b/core/qa/visual-parity/_admin-p3.mjs new file mode 100644 index 0000000..c0c4f1e --- /dev/null +++ b/core/qa/visual-parity/_admin-p3.mjs @@ -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); diff --git a/docs/todo/admin-后台-进度.md b/docs/todo/admin-后台-进度.md index 358f77e..1afc1de 100644 --- a/docs/todo/admin-后台-进度.md +++ b/docs/todo/admin-后台-进度.md @@ -83,3 +83,24 @@ - 类型/构建:`tsc --noEmit` + `npm run build` 全绿。 - 视觉:列表截图核对 —— 类型/状态橙/中性 pill、段控黑激活、成功 toast,符合 restraint。 - 回归:accounts 22 仍全绿;live 后端自动 reload 后 `/api/admin/invitations/` 200(admin)/401(匿名)。 + +--- + +## ✅ Phase 3 · 平台团队 + 用户管理(Admin)— 完成 2026-06-19 + +**后端(apps/adminpanel 扩展)** +- 团队:`GET teams/`(列跨团队 + status/search 过滤分页,带 owner/成员数/余额)、`GET teams//`(详情含成员)、`POST teams//toggle/`(启停 + 审计)。 +- 用户:`GET users/`(列跨团队 + 过滤分页,带所属团队)、`POST users//toggle/`(启停,停用清 token;**不允许停用平台超管**)、`POST users//reset-password/`(强制改密,清 token)。 +- **团队停用拦登录**:`login` 校验当前团队 `status`,停用团队的成员一律拒登(平台超管无团队不受影响)。 +- 新 `apps/adminpanel/serializers.py`(AdminTeam/AdminTeamMember/AdminUser);全程 IsPlatformAdmin + 写审计。 + +**前端** +- `adminApi`:teams/teamDetail/toggleTeam/users/toggleUser/resetUserPassword;类型 AdminTeam/AdminTeamDetail/AdminUser。 +- Admin「团队」页(状态筛选 + 搜索 + 表格 + 详情弹窗含成员 + 启停)、「用户」页(筛选 + 搜索 + 表格 + 启停 + 改密弹窗;超管行带「超管」标记且禁操作)。 +- admin-page.css 补 search/宽弹窗/详情元信息样式(仅 token)。 + +**测试底座(全过)** +- 后端单测:`apps.adminpanel` **19 项 + accounts 22 = 41 OK**(团队列字段/详情/启停拦登录再恢复/用户列含团队/启停拦登录/禁停超管/强制改密生效旧密失效/权限 403)。 +- 无头 e2e:`_admin-p3.mjs`(5188,用一次性 P3 团队不动 demo)—— **7 断言全过 + 0 console error**:团队页+搜索+详情弹窗;**UI 停用团队→该成员 API 登录 400→启用→200**;用户页超管标记;**UI 改密→新密码登录 200**。截图 `output/admin/p3-*.png`。 +- 类型/构建:`tsc --noEmit` + `npm run build` 全绿。 +- 视觉:团队/用户表格、绿/红状态 pill、详情弹窗,符合 restraint。