feat: 添加管理员用户管理功能,包括创建、更新、删除用户的API和界面支持
This commit is contained in:
@@ -4,9 +4,13 @@ import { API_V1_PREFIX } from "./paths";
|
||||
|
||||
import type {
|
||||
AdminPermissionCatalogData,
|
||||
AdminUserCreatePayload,
|
||||
AdminUserDeleteResult,
|
||||
AdminUserPermissionListData,
|
||||
AdminUserPermissionRow,
|
||||
AdminUserPermissionSyncData,
|
||||
AdminUserRoleSyncData,
|
||||
AdminUserUpdatePayload,
|
||||
} from "@/types/api/admin-user";
|
||||
|
||||
const A = `${API_V1_PREFIX}/admin`;
|
||||
@@ -25,6 +29,25 @@ export async function getAdminUserPermissionCatalog(): Promise<AdminPermissionCa
|
||||
return adminRequest.get<AdminPermissionCatalogData>(`${A}/admin-user-permission-catalog`);
|
||||
}
|
||||
|
||||
export async function getAdminUser(adminUserId: number): Promise<AdminUserPermissionRow> {
|
||||
return adminRequest.get<AdminUserPermissionRow>(`${A}/admin-users/${adminUserId}`);
|
||||
}
|
||||
|
||||
export async function postAdminUser(body: AdminUserCreatePayload): Promise<AdminUserPermissionRow> {
|
||||
return adminRequest.post<AdminUserPermissionRow>(`${A}/admin-users`, body);
|
||||
}
|
||||
|
||||
export async function putAdminUser(
|
||||
adminUserId: number,
|
||||
body: AdminUserUpdatePayload,
|
||||
): Promise<AdminUserPermissionRow> {
|
||||
return adminRequest.put<AdminUserPermissionRow>(`${A}/admin-users/${adminUserId}`, body);
|
||||
}
|
||||
|
||||
export async function deleteAdminUser(adminUserId: number): Promise<AdminUserDeleteResult> {
|
||||
return adminRequest.delete<AdminUserDeleteResult>(`${A}/admin-users/${adminUserId}`);
|
||||
}
|
||||
|
||||
export async function putAdminUserPermissions(
|
||||
adminUserId: number,
|
||||
permissionSlugs: string[],
|
||||
|
||||
@@ -5,8 +5,11 @@ import { ChevronDown } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
deleteAdminUser,
|
||||
getAdminUserPermissionCatalog,
|
||||
getAdminUsers,
|
||||
postAdminUser,
|
||||
putAdminUser,
|
||||
putAdminUserPermissions,
|
||||
putAdminUserRoles,
|
||||
} from "@/api/admin-users";
|
||||
@@ -33,9 +36,11 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import type { AdminPermissionCatalogData, AdminUserPermissionRow } from "@/types/api/index";
|
||||
|
||||
export function AdminUsersConsole(): React.ReactElement {
|
||||
const profile = useAdminProfile();
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(25);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
@@ -57,6 +62,20 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
/** `false` = 折叠;缺省为展开 */
|
||||
const [directMenuExpanded, setDirectMenuExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
const [accountOpen, setAccountOpen] = useState(false);
|
||||
const [accountMode, setAccountMode] = useState<"create" | "edit">("create");
|
||||
const [accountSaving, setAccountSaving] = useState(false);
|
||||
const [editingAccountId, setEditingAccountId] = useState<number | null>(null);
|
||||
const [formUsername, setFormUsername] = useState("");
|
||||
const [formNickname, setFormNickname] = useState("");
|
||||
const [formEmail, setFormEmail] = useState("");
|
||||
const [formPassword, setFormPassword] = useState("");
|
||||
const [formStatus, setFormStatus] = useState(0);
|
||||
const [formCreateRoles, setFormCreateRoles] = useState<string[]>([]);
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<AdminUserPermissionRow | null>(null);
|
||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||
|
||||
const selectedUser = useMemo(
|
||||
() => items.find((u) => u.id === selectedId) ?? null,
|
||||
[items, selectedId],
|
||||
@@ -77,6 +96,132 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateAccount(): void {
|
||||
setAccountMode("create");
|
||||
setEditingAccountId(null);
|
||||
setFormUsername("");
|
||||
setFormNickname("");
|
||||
setFormEmail("");
|
||||
setFormPassword("");
|
||||
setFormStatus(0);
|
||||
setFormCreateRoles([]);
|
||||
setAccountOpen(true);
|
||||
}
|
||||
|
||||
function openEditAccount(row: AdminUserPermissionRow): void {
|
||||
setAccountMode("edit");
|
||||
setEditingAccountId(row.id);
|
||||
setFormUsername(row.username);
|
||||
setFormNickname(row.nickname);
|
||||
setFormEmail(row.email ?? "");
|
||||
setFormPassword("");
|
||||
setFormStatus(row.status);
|
||||
setAccountOpen(true);
|
||||
}
|
||||
|
||||
function handleAccountDialogOpenChange(open: boolean): void {
|
||||
setAccountOpen(open);
|
||||
if (!open) {
|
||||
setEditingAccountId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAccount(): Promise<void> {
|
||||
const nick = formNickname.trim();
|
||||
if (nick === "") {
|
||||
toast.error("请填写昵称");
|
||||
return;
|
||||
}
|
||||
if (accountMode === "edit" && formPassword !== "" && formPassword.length < 8) {
|
||||
toast.error("新密码至少 8 位");
|
||||
return;
|
||||
}
|
||||
|
||||
if (accountMode === "create" && formCreateRoles.length === 0) {
|
||||
toast.error("请至少选择一个角色");
|
||||
return;
|
||||
}
|
||||
|
||||
setAccountSaving(true);
|
||||
try {
|
||||
if (accountMode === "create") {
|
||||
const u = formUsername.trim();
|
||||
if (u === "") {
|
||||
toast.error("请填写登录账号");
|
||||
return;
|
||||
}
|
||||
if (formPassword.length < 8) {
|
||||
toast.error("密码至少 8 位");
|
||||
return;
|
||||
}
|
||||
const created = await postAdminUser({
|
||||
username: u.toLowerCase(),
|
||||
nickname: nick,
|
||||
email: formEmail.trim() === "" ? null : formEmail.trim(),
|
||||
password: formPassword,
|
||||
status: formStatus,
|
||||
role_slugs: formCreateRoles,
|
||||
});
|
||||
setItems((prev) => [created, ...prev]);
|
||||
setTotal((t) => t + 1);
|
||||
toast.success(`已创建管理员 ${created.username}`);
|
||||
handleAccountDialogOpenChange(false);
|
||||
} else {
|
||||
const id = editingAccountId;
|
||||
if (id === null) {
|
||||
return;
|
||||
}
|
||||
const body: {
|
||||
nickname: string;
|
||||
email: string | null;
|
||||
status: number;
|
||||
password?: string;
|
||||
} = {
|
||||
nickname: nick,
|
||||
email: formEmail.trim() === "" ? null : formEmail.trim(),
|
||||
status: formStatus,
|
||||
};
|
||||
if (formPassword.trim() !== "") {
|
||||
body.password = formPassword.trim();
|
||||
}
|
||||
const updated = await putAdminUser(id, body);
|
||||
setItems((prev) => prev.map((row) => (row.id === updated.id ? updated : row)));
|
||||
toast.success(`已更新 ${updated.username}`);
|
||||
handleAccountDialogOpenChange(false);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : "保存账号失败";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setAccountSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete(): Promise<void> {
|
||||
if (!deleteTarget) {
|
||||
return;
|
||||
}
|
||||
setDeleteBusy(true);
|
||||
try {
|
||||
await deleteAdminUser(deleteTarget.id);
|
||||
setItems((prev) => prev.filter((r) => r.id !== deleteTarget.id));
|
||||
setTotal((t) => Math.max(0, t - 1));
|
||||
toast.success(`已删除 ${deleteTarget.username}`);
|
||||
setDeleteTarget(null);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : "删除失败";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setDeleteBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const selectClassName = cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base outline-none transition-colors",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm",
|
||||
"dark:bg-input/30 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
);
|
||||
|
||||
const directPermissionGroups = useMemo(() => {
|
||||
const g = catalog?.permission_menu_groups;
|
||||
if (g && g.length > 0) {
|
||||
@@ -100,6 +245,15 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
});
|
||||
}
|
||||
|
||||
function toggleFormCreateRole(slug: string, checked: boolean): void {
|
||||
setFormCreateRoles((prev) => {
|
||||
if (checked) {
|
||||
return Array.from(new Set([...prev, slug])).sort();
|
||||
}
|
||||
return prev.filter((s) => s !== slug);
|
||||
});
|
||||
}
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
@@ -213,8 +367,11 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
<div className="flex w-full max-w-none flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<CardTitle>管理员用户列表</CardTitle>
|
||||
<Button type="button" size="sm" onClick={() => openCreateAccount()}>
|
||||
新建管理员
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex w-full max-w-lg gap-2">
|
||||
<Input
|
||||
@@ -254,16 +411,17 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
<TableHead className="w-16">ID</TableHead>
|
||||
<TableHead>账号</TableHead>
|
||||
<TableHead>昵称</TableHead>
|
||||
<TableHead className="w-20 whitespace-nowrap">状态</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>直接权限</TableHead>
|
||||
<TableHead>有效权限</TableHead>
|
||||
<TableHead className="w-24">操作</TableHead>
|
||||
<TableHead className="min-w-[11rem]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-muted-foreground">
|
||||
<TableCell colSpan={8} className="text-muted-foreground">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -278,6 +436,17 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{row.nickname}</TableCell>
|
||||
<TableCell>
|
||||
{row.status === 0 ? (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
启用
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-amber-600/50 text-amber-800 dark:text-amber-400">
|
||||
禁用
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.roles.length === 0 ? (
|
||||
@@ -294,16 +463,40 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
<TableCell className="tabular-nums">{row.direct_permissions.length}</TableCell>
|
||||
<TableCell className="tabular-nums">{row.effective_permissions.length}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={permissionOpen && selectedId === row.id ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
openPermissionEditor(row);
|
||||
}}
|
||||
>
|
||||
权限
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={permissionOpen && selectedId === row.id ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
openPermissionEditor(row);
|
||||
}}
|
||||
>
|
||||
权限
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={
|
||||
accountOpen && editingAccountId === row.id ? "secondary" : "outline"
|
||||
}
|
||||
onClick={() => openEditAccount(row)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={profile?.id === row.id}
|
||||
title={
|
||||
profile?.id === row.id ? "不能删除当前登录账号" : "删除该管理员"
|
||||
}
|
||||
onClick={() => setDeleteTarget(row)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
@@ -484,6 +677,149 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={accountOpen} onOpenChange={handleAccountDialogOpenChange}>
|
||||
<DialogContent showCloseButton className="max-h-[90vh] max-w-lg gap-4 overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{accountMode === "create" ? "新建管理员" : "编辑账号"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{accountMode === "create"
|
||||
? "须为账号指定至少一个默认站点角色。登录账号仅可使用字母、数字、点、下划线与连字符,保存后为小写。"
|
||||
: "登录账号不可修改。留空密码表示不修改。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium leading-none">登录账号</div>
|
||||
<Input
|
||||
value={formUsername}
|
||||
disabled={accountMode === "edit"}
|
||||
placeholder="例如 ops_admin"
|
||||
autoComplete="off"
|
||||
onChange={(e) => setFormUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium leading-none">昵称</div>
|
||||
<Input
|
||||
value={formNickname}
|
||||
placeholder="显示名称"
|
||||
onChange={(e) => setFormNickname(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium leading-none">邮箱(可选)</div>
|
||||
<Input
|
||||
type="email"
|
||||
value={formEmail}
|
||||
placeholder="留空则不填"
|
||||
onChange={(e) => setFormEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium leading-none">
|
||||
密码{accountMode === "edit" ? "(可选)" : ""}
|
||||
</div>
|
||||
<Input
|
||||
type="password"
|
||||
value={formPassword}
|
||||
placeholder={accountMode === "create" ? "至少 8 位" : "不修改请留空"}
|
||||
autoComplete="new-password"
|
||||
onChange={(e) => setFormPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{accountMode === "create" ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium leading-none">角色(默认站点,至少一项)</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
创建后即可在「权限」中继续调整角色或直接授权。
|
||||
</p>
|
||||
<div className="max-h-52 space-y-2 overflow-y-auto rounded-md border p-2.5 sm:grid sm:max-h-56 sm:grid-cols-2 sm:gap-2 sm:space-y-0">
|
||||
{(catalog?.roles ?? []).length === 0 ? (
|
||||
<p className="col-span-full text-xs text-muted-foreground">
|
||||
暂无角色数据,请等待列表加载完成后重试。
|
||||
</p>
|
||||
) : (
|
||||
(catalog?.roles ?? []).map((r) => {
|
||||
const checked = formCreateRoles.includes(r.slug);
|
||||
return (
|
||||
<label key={r.slug} className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => toggleFormCreateRole(r.slug, v === true)}
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="block leading-none font-medium">{r.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{r.slug}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium leading-none">状态</div>
|
||||
<select
|
||||
className={selectClassName}
|
||||
value={formStatus}
|
||||
onChange={(e) => setFormStatus(Number(e.target.value))}
|
||||
>
|
||||
<option value={0}>启用</option>
|
||||
<option value={1}>禁用</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleAccountDialogOpenChange(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" disabled={accountSaving} onClick={() => void submitAccount()}>
|
||||
{accountSaving ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<DialogContent showCloseButton className="max-w-md gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
{deleteTarget ? (
|
||||
<>
|
||||
确定删除管理员{" "}
|
||||
<span className="font-medium text-foreground">{deleteTarget.username}</span>
|
||||
?此操作不可撤销。
|
||||
</>
|
||||
) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={deleteBusy}
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={deleteBusy}
|
||||
onClick={() => void confirmDelete()}
|
||||
>
|
||||
{deleteBusy ? "删除中…" : "删除"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,3 +47,25 @@ export type AdminUserPermissionSyncData = {
|
||||
|
||||
/** 与 {@link AdminUserPermissionSyncData} 相同(角色同步返回体一致) */
|
||||
export type AdminUserRoleSyncData = AdminUserPermissionSyncData;
|
||||
|
||||
export type AdminUserCreatePayload = {
|
||||
username: string;
|
||||
nickname: string;
|
||||
email?: string | null;
|
||||
password: string;
|
||||
status?: number;
|
||||
/** 默认站点角色,至少一项 */
|
||||
role_slugs: string[];
|
||||
};
|
||||
|
||||
export type AdminUserUpdatePayload = {
|
||||
nickname?: string;
|
||||
email?: string | null;
|
||||
password?: string;
|
||||
status?: number;
|
||||
};
|
||||
|
||||
export type AdminUserDeleteResult = {
|
||||
deleted: boolean;
|
||||
id: number;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user