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
+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;