feat: 更新管理员导航,重定向菜单权限至根路径,添加角色同步API,移除菜单权限模块
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user