feat: 更新管理员导航,重定向菜单权限至根路径,添加角色同步API,移除菜单权限模块

This commit is contained in:
2026-05-13 10:40:12 +08:00
parent 188c6a04cf
commit 96b966cf62
15 changed files with 640 additions and 243 deletions

View File

@@ -1,20 +1,28 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronDown } from "lucide-react";
import { toast } from "sonner";
import {
getAdminUserPermissionCatalog,
getAdminUsers,
putAdminUserPermissions,
putAdminUserRoles,
} from "@/api/admin-users";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
@@ -24,6 +32,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { LotteryApiBizError } from "@/types/api/errors";
import { cn } from "@/lib/utils";
import type { AdminPermissionCatalogData, AdminUserPermissionRow } from "@/types/api/index";
export function AdminUsersConsole(): React.ReactElement {
@@ -40,14 +49,57 @@ export function AdminUsersConsole(): React.ReactElement {
const [err, setErr] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [draftRoles, setDraftRoles] = useState<string[]>([]);
const [draftPermissions, setDraftPermissions] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [savingRoles, setSavingRoles] = useState(false);
const [permissionOpen, setPermissionOpen] = useState(false);
/** `false` = 折叠;缺省为展开 */
const [directMenuExpanded, setDirectMenuExpanded] = useState<Record<string, boolean>>({});
const selectedUser = useMemo(
() => items.find((u) => u.id === selectedId) ?? null,
[items, selectedId],
);
function openPermissionEditor(row: AdminUserPermissionRow): void {
setSelectedId(row.id);
setDraftRoles([...row.roles].sort());
setDraftPermissions([...row.direct_permissions].sort());
setDirectMenuExpanded({});
setPermissionOpen(true);
}
function handlePermissionDialogOpenChange(open: boolean): void {
setPermissionOpen(open);
if (!open) {
setSelectedId(null);
}
}
const directPermissionGroups = useMemo(() => {
const g = catalog?.permission_menu_groups;
if (g && g.length > 0) {
return g;
}
const flat = catalog?.permissions ?? [];
if (flat.length > 0) {
return [{ key: "all", label: "全部权限", permissions: flat }];
}
return [];
}, [catalog]);
function isDirectGroupOpen(key: string): boolean {
return directMenuExpanded[key] !== false;
}
function toggleDirectGroup(key: string): void {
setDirectMenuExpanded((prev) => {
const wasOpen = prev[key] !== false;
return { ...prev, [key]: wasOpen ? false : true };
});
}
const load = useCallback(async () => {
setLoading(true);
setErr(null);
@@ -90,6 +142,43 @@ export function AdminUsersConsole(): React.ReactElement {
});
}
function toggleRole(slug: string, checked: boolean): void {
setDraftRoles((prev) => {
if (checked) {
return Array.from(new Set([...prev, slug])).sort();
}
return prev.filter((s) => s !== slug);
});
}
async function saveRoles(): Promise<void> {
if (!selectedUser) {
return;
}
setSavingRoles(true);
try {
const result = await putAdminUserRoles(selectedUser.id, draftRoles);
setDraftRoles([...result.roles].sort());
setItems((prev) =>
prev.map((row) =>
row.id === result.id
? {
...row,
roles: result.roles,
effective_permissions: result.effective_permissions,
}
: row,
),
);
toast.success(`已更新 ${result.username} 的角色`);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "保存角色失败";
toast.error(msg);
} finally {
setSavingRoles(false);
}
}
async function savePermissions(): Promise<void> {
if (!selectedUser) {
return;
@@ -98,6 +187,7 @@ export function AdminUsersConsole(): React.ReactElement {
try {
const result = await putAdminUserPermissions(selectedUser.id, draftPermissions);
setDraftPermissions([...result.direct_permissions].sort());
setDraftRoles([...result.roles].sort());
setItems((prev) =>
prev.map((row) =>
row.id === result.id
@@ -105,6 +195,7 @@ export function AdminUsersConsole(): React.ReactElement {
...row,
direct_permissions: result.direct_permissions,
effective_permissions: result.effective_permissions,
roles: result.roles,
}
: row,
),
@@ -206,10 +297,9 @@ export function AdminUsersConsole(): React.ReactElement {
<Button
type="button"
size="sm"
variant={selectedId === row.id ? "default" : "outline"}
variant={permissionOpen && selectedId === row.id ? "default" : "outline"}
onClick={() => {
setSelectedId(row.id);
setDraftPermissions([...row.direct_permissions].sort());
openPermissionEditor(row);
}}
>
@@ -237,65 +327,163 @@ export function AdminUsersConsole(): React.ReactElement {
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
<div>
<CardTitle></CardTitle>
</div>
<Button
type="button"
onClick={() => void savePermissions()}
disabled={!selectedUser || saving}
>
{saving ? "保存中…" : "保存权限"}
</Button>
</CardHeader>
<CardContent className="space-y-4">
{!selectedUser ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<>
<div className="rounded-md border bg-muted/20 p-3 text-sm">
<span className="ml-1 font-medium">{selectedUser.username}</span>
<span className="ml-2 text-muted-foreground">({selectedUser.nickname})</span>
</div>
<Dialog open={permissionOpen} onOpenChange={handlePermissionDialogOpenChange}>
<DialogContent
showCloseButton
className="flex h-[min(88vh,800px)] max-h-[90vh] w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:h-[min(85vh,780px)] sm:max-w-3xl"
>
<DialogHeader className="shrink-0 space-y-1 border-b px-4 py-3 pr-12">
<DialogTitle></DialogTitle>
<DialogDescription>
{selectedUser ? (
<>
<span className="font-medium text-foreground">{selectedUser.username}</span>
<span className="text-muted-foreground"> · {selectedUser.nickname}</span>
</>
) : null}
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 rounded-md border p-4 sm:grid-cols-2 lg:grid-cols-3">
{(catalog?.permissions ?? []).map((p) => {
const checked = draftPermissions.includes(p.slug);
return (
<label key={p.slug} className="flex items-start gap-2 text-sm">
<Checkbox
checked={checked}
onCheckedChange={(v) => togglePermission(p.slug, v === true)}
/>
<span className="space-y-0.5">
<span className="block leading-none">{p.name}</span>
<span className="text-xs text-muted-foreground">{p.slug}</span>
</span>
</label>
);
})}
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
{selectedUser.roles.length === 0 ? (
<span className="text-sm text-muted-foreground"></span>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3">
<div className="space-y-6 pb-1">
<section className="space-y-3">
<div>
<h3 className="text-sm font-medium leading-none"></h3>
<p className="mt-1.5 text-xs text-muted-foreground">
</p>
</div>
<div className="grid gap-3 rounded-md border p-3 sm:grid-cols-2">
{(catalog?.roles ?? []).map((r) => {
const checked = draftRoles.includes(r.slug);
return (
<label key={r.slug} className="flex items-start gap-2 text-sm">
<Checkbox
checked={checked}
onCheckedChange={(v) => toggleRole(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 className="block text-xs text-muted-foreground/90">
{r.permission_slugs.length}
</span>
</span>
</label>
);
})}
</div>
</section>
<section className="space-y-3">
<div>
<h3 className="text-sm font-medium leading-none"></h3>
<p className="mt-1.5 text-xs text-muted-foreground">
/ prd.*
</p>
</div>
<div className="rounded-md border bg-muted/20 p-2.5 text-xs text-muted-foreground">
{draftRoles.length === 0 ? (
<span className="ml-1 text-foreground/80"></span>
) : (
selectedUser.roles.map((slug) => (
<Badge key={slug} variant="outline">
{slug}
</Badge>
))
<span className="ml-1 inline-flex flex-wrap gap-1 align-middle">
{draftRoles.map((slug) => (
<Badge key={slug} variant="secondary" className="text-xs font-normal">
{slug}
</Badge>
))}
</span>
)}
</div>
</div>
</>
)}
</CardContent>
</Card>
<div className="overflow-hidden rounded-md border">
{directPermissionGroups.map((group) => {
const isOpen = isDirectGroupOpen(group.key);
const nSelected = group.permissions.filter((p) =>
draftPermissions.includes(p.slug),
).length;
return (
<div key={group.key} className="border-b last:border-b-0">
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm hover:bg-muted/50"
onClick={() => toggleDirectGroup(group.key)}
>
<ChevronDown
aria-hidden
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
isOpen && "rotate-180",
)}
/>
<span className="min-w-0 flex-1 font-medium">{group.label}</span>
<span className="shrink-0 tabular-nums text-xs text-muted-foreground">
{nSelected}/{group.permissions.length}
</span>
</button>
{isOpen ? (
<div className="space-y-2 border-t bg-muted/20 px-3 py-3 sm:grid sm:grid-cols-2 sm:gap-2 sm:gap-y-3">
{group.permissions.map((p) => {
const checked = draftPermissions.includes(p.slug);
return (
<label key={p.slug} className="flex items-start gap-2 text-sm">
<Checkbox
checked={checked}
onCheckedChange={(v) => togglePermission(p.slug, v === true)}
/>
<span className="space-y-0.5">
<span className="block leading-none">{p.name}</span>
<span className="text-xs text-muted-foreground">{p.slug}</span>
</span>
</label>
);
})}
</div>
) : null}
</div>
);
})}
</div>
</section>
</div>
</div>
<div
className="flex shrink-0 flex-col gap-3 border-t bg-muted/40 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
data-slot="dialog-footer-actions"
>
<Button
type="button"
variant="outline"
className="w-full shrink-0 sm:w-auto"
onClick={() => {
handlePermissionDialogOpenChange(false);
}}
>
</Button>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-nowrap sm:justify-end">
<Button
type="button"
variant="secondary"
className="w-full shrink-0 sm:w-auto"
disabled={!selectedUser || savingRoles}
onClick={() => void saveRoles()}
>
{savingRoles ? "保存中…" : "保存角色"}
</Button>
<Button
type="button"
className="w-full shrink-0 sm:w-auto"
disabled={!selectedUser || saving}
onClick={() => void savePermissions()}
>
{saving ? "保存中…" : "保存直接权限"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}