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
+4 -1
View File
@@ -12,7 +12,7 @@ from rest_framework.response import Response
from apps.common.api import get_current_team 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 ( from .serializers import (
InvitationSerializer, InvitationSerializer,
LoginSerializer, LoginSerializer,
@@ -124,6 +124,9 @@ def login(request):
if user is None or user.is_disabled: if user is None or user.is_disabled:
return Response({"detail": "invalid credentials"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "invalid credentials"}, status=status.HTTP_400_BAD_REQUEST)
team = current_team_or_none(user) 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) token, _ = Token.objects.get_or_create(user=user)
record_login_session(request, user) record_login_session(request, user)
return Response(auth_payload(user, team, token)) return Response(auth_payload(user, team, token))
@@ -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()
]
+86
View File
@@ -85,3 +85,89 @@ class AdminInvitationApiTests(TestCase):
format="json", format="json",
) )
self.assertEqual(reg.status_code, 400) 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,
)
+16 -1
View File
@@ -1,9 +1,24 @@
from django.urls import path 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 = [ urlpatterns = [
path("invitations/", admin_invitations, name="admin-invitations"), path("invitations/", admin_invitations, name="admin-invitations"),
path("invitations/<uuid:invite_id>/revoke/", admin_revoke_invitation, name="admin-invitation-revoke"), path("invitations/<uuid:invite_id>/revoke/", admin_revoke_invitation, name="admin-invitation-revoke"),
path("teams/", admin_teams, name="admin-teams"),
path("teams/<uuid:team_id>/", admin_team_detail, name="admin-team-detail"),
path("teams/<uuid:team_id>/toggle/", admin_team_toggle, name="admin-team-toggle"),
path("users/", admin_users, name="admin-users"),
path("users/<uuid:user_id>/toggle/", admin_user_toggle, name="admin-user-toggle"),
path("users/<uuid:user_id>/reset-password/", admin_user_reset_password, name="admin-user-reset-password"),
] ]
+133 -2
View File
@@ -1,16 +1,26 @@
"""平台超管后台 · 跨团队端点。所有视图统一挂 IsPlatformAdmin,非超管一律 403,写操作记审计。""" """平台超管后台 · 跨团队端点。所有视图统一挂 IsPlatformAdmin,非超管一律 403,写操作记审计。"""
from django.db.models import Q from django.db.models import Count, Q
from rest_framework import status from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response from rest_framework.response import Response
from apps.accounts.audit import log_admin_action 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.permissions import IsPlatformAdmin
from apps.accounts.serializers import InvitationSerializer from apps.accounts.serializers import InvitationSerializer
from apps.common.pagination import DefaultPagination 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"]) @api_view(["GET", "POST"])
@permission_classes([IsPlatformAdmin]) @permission_classes([IsPlatformAdmin])
@@ -68,3 +78,124 @@ def admin_revoke_invitation(request, invite_id):
target_name=invite.code, target_name=invite.code,
) )
return Response(InvitationSerializer(invite).data) 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)
+15
View File
@@ -129,3 +129,18 @@
line-height: 1.6; line-height: 1.6;
margin: 0 0 16px; 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; }
+33
View File
@@ -1,4 +1,7 @@
import type { import type {
AdminTeam,
AdminTeamDetail,
AdminUser,
AITask, AITask,
Asset, Asset,
AuthPayload, AuthPayload,
@@ -531,5 +534,35 @@ export const adminApi = {
}, },
revokeInvite(id: string) { revokeInvite(id: string) {
return request<Invitation>(`/api/admin/invitations/${id}/revoke/`, { method: "POST" }); return request<Invitation>(`/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<Paginated<AdminTeam>>(`/api/admin/teams/${q ? `?${q}` : ""}`);
},
teamDetail(id: string) {
return request<AdminTeamDetail>(`/api/admin/teams/${id}/`);
},
toggleTeam(id: string) {
return request<AdminTeam>(`/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<Paginated<AdminUser>>(`/api/admin/users/${q ? `?${q}` : ""}`);
},
toggleUser(id: string) {
return request<AdminUser>(`/api/admin/users/${id}/toggle/`, { method: "POST" });
},
resetUserPassword(id: string, password: string) {
return request<void>(`/api/admin/users/${id}/reset-password/`, { method: "POST", body: JSON.stringify({ password }) });
} }
}; };
@@ -4,6 +4,7 @@ import { CornerMarks, Decorations, ToastLike } from "../../components/app-shell"
import type { Team, User } from "../../types"; import type { Team, User } from "../../types";
import type { NavigateFn } from "../route-config"; import type { NavigateFn } from "../route-config";
import { AdminInvitesPage } from "./admin-invites"; import { AdminInvitesPage } from "./admin-invites";
import { AdminTeamsPage, AdminUsersPage } from "./admin-teams-users";
export type AdminNotify = (type: "success" | "error" | "info", text: string) => void; 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") { if (section.slug === "invites") {
return <AdminInvitesPage notify={notify} />; return <AdminInvitesPage notify={notify} />;
} }
if (section.slug === "teams") {
return <AdminTeamsPage notify={notify} />;
}
if (section.slug === "users") {
return <AdminUsersPage notify={notify} />;
}
// 其余模块在各自阶段替换此占位为真实页面 // 其余模块在各自阶段替换此占位为真实页面
return <AdminPlaceholder section={section} />; return <AdminPlaceholder section={section} />;
} }
@@ -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
? <span className="pill ok"><span className="dot" /></span>
: <span className="pill err"><span className="dot" /></span>;
}
const STATUS_TABS = [
{ key: "", label: "全部" },
{ key: "active", label: "启用" },
{ key: "disabled", label: "停用" }
];
// ─────────────────────────── 团队管理 ───────────────────────────
export function AdminTeamsPage({ notify }: { notify: Notify }) {
const [teams, setTeams] = useState<AdminTeam[]>([]);
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState("");
const [search, setSearch] = useState("");
const [detail, setDetail] = useState<AdminTeamDetail | null>(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 (
<>
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// {count} 个团队</span> · 跨团队总览 · 启停 / 详情</div>
</div>
</div>
<div className="admin-toolbar">
<div className="tabs-sub">
{STATUS_TABS.map((t) => (
<button key={t.key || "all"} type="button" className={`tab-sub${statusFilter === t.key ? " active" : ""}`} onClick={() => setStatusFilter(t.key)}>{t.label}</button>
))}
</div>
<input className="input admin-search" type="text" placeholder="搜索团队名…" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
{loading ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="building" size={24} /></div><h3></h3><p>// fetching teams</p></div>
) : teams.length === 0 ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="building" size={24} /></div><h3></h3><p>// no teams</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>
{teams.map((t) => (
<tr key={t.id}>
<td>{t.name}</td>
<td>{t.owner_username || <span className="muted"></span>}</td>
<td className="num">{t.member_count}</td>
<td className="num mono">¥{t.balance}</td>
<td>{statusPill(t.status === "active")}</td>
<td className="mono col-time">{fmtDate(t.created_at)}</td>
<td className="col-actions">
<button className="btn btn-sm btn-ghost" type="button" onClick={() => openDetail(t.id)}></button>
<button className={`btn btn-sm btn-ghost${t.status === "active" ? " danger" : ""}`} type="button" onClick={() => toggle(t)}>
{t.status === "active" ? "停用" : "启用"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{(detail || detailLoading) && (
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) setDetail(null); }}>
<div className="modal modal-wide" 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"><Building size={16} /></div>
<div className="ti">{detail?.name || "团队详情"}<span>// team detail</span></div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setDetail(null)}><X size={14} /></button>
</div>
<div className="modal-b">
{detailLoading || !detail ? (
<p className="admin-modal-desc"></p>
) : (
<>
<div className="admin-detail-meta">
<div><span className="k"></span><span className="v">{detail.owner_username || "—"}</span></div>
<div><span className="k"></span><span className="v">{detail.member_count}</span></div>
<div><span className="k"></span><span className="v mono">¥{detail.balance}</span></div>
<div><span className="k"></span><span className="v">{statusPill(detail.status === "active")}</span></div>
</div>
<div className="admin-detail-subhead"> · {detail.members.length}</div>
<table className="t admin-table">
<thead><tr><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{detail.members.map((m) => (
<tr key={m.id}>
<td>{m.username}</td>
<td>{m.role}</td>
<td>{statusPill(m.user_status === "active")}</td>
<td className="num mono">¥{m.monthly_credit_limit}</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
<div className="modal-f">
<button className="btn" type="button" onClick={() => setDetail(null)}></button>
</div>
</div>
</div>
)}
</>
);
}
// ─────────────────────────── 用户管理 ───────────────────────────
export function AdminUsersPage({ notify }: { notify: Notify }) {
const [users, setUsers] = useState<AdminUser[]>([]);
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState("");
const [search, setSearch] = useState("");
const [pwdTarget, setPwdTarget] = useState<AdminUser | null>(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 (
<>
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// {count} 个用户</span> · 跨团队 · 启停 / 强制改密</div>
</div>
</div>
<div className="admin-toolbar">
<div className="tabs-sub">
{STATUS_TABS.map((t) => (
<button key={t.key || "all"} type="button" className={`tab-sub${statusFilter === t.key ? " active" : ""}`} onClick={() => setStatusFilter(t.key)}>{t.label}</button>
))}
</div>
<input className="input admin-search" type="text" placeholder="搜索用户名…" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
{loading ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="users" size={24} /></div><h3></h3><p>// fetching users</p></div>
) : users.length === 0 ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="users" size={24} /></div><h3></h3><p>// no users</p></div>
) : (
<div className="admin-table-wrap">
<table className="t admin-table">
<thead>
<tr><th></th><th></th><th></th><th></th><th className="col-actions"></th></tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>
{u.username}
{u.is_platform_admin && <span className="pill info admin-inline-pill"><span className="dot" /></span>}
</td>
<td>
{u.teams.length === 0
? <span className="muted"></span>
: u.teams.map((t) => t.team_name).join("、")}
</td>
<td>{statusPill(u.status === "active")}</td>
<td className="mono col-time">{fmtDate(u.date_joined)}</td>
<td className="col-actions">
{u.is_platform_admin ? (
<span className="muted"></span>
) : (
<>
<button className="btn btn-sm btn-ghost" type="button" onClick={() => setPwdTarget(u)}></button>
<button className={`btn btn-sm btn-ghost${u.status === "active" ? " danger" : ""}`} type="button" onClick={() => toggle(u)}>
{u.status === "active" ? "停用" : "启用"}
</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{pwdTarget && (
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) setPwdTarget(null); }}>
<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"><KeyRound size={16} /></div>
<div className="ti"><span>// {pwdTarget.username}</span></div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setPwdTarget(null)}><X size={14} /></button>
</div>
<div className="modal-b">
<p className="admin-modal-desc">{pwdTarget.username},线</p>
<div className="field">
<label className="field-label" htmlFor="reset-pwd"> <span className="field-hint"> 8 </span></label>
<input id="reset-pwd" className="input" type="text" placeholder="输入新密码" value={pwd} onChange={(e) => setPwd(e.target.value)} />
</div>
</div>
<div className="modal-f">
<button className="btn" type="button" onClick={() => setPwdTarget(null)}></button>
<button className="btn btn-primary" type="button" disabled={saving} onClick={() => void doReset()}>{saving ? "保存中…" : "重置密码"}</button>
</div>
</div>
</div>
)}
</>
);
}
+29
View File
@@ -45,6 +45,35 @@ export type AuthPayload = {
team: Team; 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<T> = { export type Paginated<T> = {
count: number; count: number;
next: string | null; next: string | null;
+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);
+21
View File
@@ -83,3 +83,24 @@
- 类型/构建:`tsc --noEmit` + `npm run build` 全绿。 - 类型/构建:`tsc --noEmit` + `npm run build` 全绿。
- 视觉:列表截图核对 —— 类型/状态橙/中性 pill、段控黑激活、成功 toast,符合 restraint。 - 视觉:列表截图核对 —— 类型/状态橙/中性 pill、段控黑激活、成功 toast,符合 restraint。
- 回归:accounts 22 仍全绿;live 后端自动 reload 后 `/api/admin/invitations/` 200(admin)/401(匿名)。 - 回归: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/<id>/`(详情含成员)、`POST teams/<id>/toggle/`(启停 + 审计)。
- 用户:`GET users/`(列跨团队 + 过滤分页,带所属团队)、`POST users/<id>/toggle/`(启停,停用清 token;**不允许停用平台超管**)、`POST users/<id>/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。