feat: 添加管理员用户管理功能,包括创建、更新、删除用户的API和界面支持

This commit is contained in:
2026-05-13 11:35:39 +08:00
parent 96b966cf62
commit c4d566fc48
3 changed files with 394 additions and 13 deletions

View File

@@ -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>
);
}