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:
co-authored by
Claude Opus 4.8
parent
45a4ceae51
commit
6560633959
@@ -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()
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user