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 .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))
@@ -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",
)
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 .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/<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,写操作记审计。"""
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)
+15
View File
@@ -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; }
+33
View File
@@ -1,4 +1,7 @@
import type {
AdminTeam,
AdminTeamDetail,
AdminUser,
AITask,
Asset,
AuthPayload,
@@ -531,5 +534,35 @@ export const adminApi = {
},
revokeInvite(id: string) {
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 { 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 <AdminInvitesPage notify={notify} />;
}
if (section.slug === "teams") {
return <AdminTeamsPage notify={notify} />;
}
if (section.slug === "users") {
return <AdminUsersPage notify={notify} />;
}
// 其余模块在各自阶段替换此占位为真实页面
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;
};
// 平台后台 · 团队/用户管理视图模型
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> = {
count: number;
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` 全绿。
- 视觉:列表截图核对 —— 类型/状态橙/中性 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/<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。