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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user