feat(web): 更新菜单、角色和侧边栏组件
- MenuEditDialog 移除 component 和 permission 字段 - MenusTable 优化显示逻辑 - RoleEditDialog 增强角色编辑功能 - Sidebar 优化菜单渲染逻辑 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { MenuTreeNode } from '@seclusion/shared';
|
||||
import { ChevronLeft, ChevronDown } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronDown, ExternalLink } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useState, useMemo } from 'react';
|
||||
@@ -44,18 +44,42 @@ function MenuItem({
|
||||
return null;
|
||||
}
|
||||
|
||||
const linkClassName = cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground',
|
||||
collapsed && 'justify-center px-2'
|
||||
);
|
||||
|
||||
// 外部链接使用原生 a 标签,在新窗口打开
|
||||
if (item.isExternal && item.path) {
|
||||
return (
|
||||
<a
|
||||
href={item.path}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={onItemClick}
|
||||
className={linkClassName}
|
||||
title={collapsed ? item.name : undefined}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-1">{item.name}</span>
|
||||
<ExternalLink className="h-3 w-3 shrink-0 opacity-50" />
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={item.path || '#'}
|
||||
onClick={onItemClick}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground',
|
||||
collapsed && 'justify-center px-2'
|
||||
)}
|
||||
className={linkClassName}
|
||||
title={collapsed ? item.name : undefined}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import type { MenuResponse, MenuTreeNode } from '@seclusion/shared';
|
||||
import type { MenuMeta, MenuResponse, MenuTreeNode } from '@seclusion/shared';
|
||||
import { MenuType } from '@seclusion/shared';
|
||||
import React, { useEffect } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -51,10 +53,11 @@ interface MenuFormValues {
|
||||
type: MenuType;
|
||||
path: string;
|
||||
icon: string;
|
||||
permission: string;
|
||||
isExternal: boolean;
|
||||
isHidden: boolean;
|
||||
isEnabled: boolean;
|
||||
sort: number;
|
||||
keepAlive: boolean;
|
||||
}
|
||||
|
||||
// 递归渲染菜单树选项
|
||||
@@ -83,6 +86,7 @@ function renderMenuOptions(menus: MenuTreeNode[], level = 0): React.ReactNode[]
|
||||
|
||||
export function MenuEditDialog({ menu, open, onOpenChange }: MenuEditDialogProps) {
|
||||
const isEditing = !!menu;
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
// 获取菜单树(用于选择父级)
|
||||
const { data: menuTree = [], isLoading: isLoadingTree } = useMenuTree();
|
||||
@@ -99,10 +103,11 @@ export function MenuEditDialog({ menu, open, onOpenChange }: MenuEditDialogProps
|
||||
type: MenuType.MENU,
|
||||
path: '',
|
||||
icon: '',
|
||||
permission: '',
|
||||
isExternal: false,
|
||||
isHidden: false,
|
||||
isEnabled: true,
|
||||
sort: 0,
|
||||
keepAlive: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -117,10 +122,11 @@ export function MenuEditDialog({ menu, open, onOpenChange }: MenuEditDialogProps
|
||||
type: menu.type,
|
||||
path: menu.path || '',
|
||||
icon: menu.icon || '',
|
||||
permission: menu.permission || '',
|
||||
isExternal: menu.isExternal,
|
||||
isHidden: menu.isHidden,
|
||||
isEnabled: menu.isEnabled,
|
||||
sort: menu.sort,
|
||||
keepAlive: (menu.meta as MenuMeta | null)?.keepAlive ?? false,
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
@@ -130,10 +136,11 @@ export function MenuEditDialog({ menu, open, onOpenChange }: MenuEditDialogProps
|
||||
type: MenuType.MENU,
|
||||
path: '',
|
||||
icon: '',
|
||||
permission: '',
|
||||
isExternal: false,
|
||||
isHidden: false,
|
||||
isEnabled: true,
|
||||
sort: 0,
|
||||
keepAlive: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -141,12 +148,13 @@ export function MenuEditDialog({ menu, open, onOpenChange }: MenuEditDialogProps
|
||||
|
||||
const onSubmit = async (values: MenuFormValues) => {
|
||||
try {
|
||||
const { keepAlive, ...restValues } = values;
|
||||
const data = {
|
||||
...values,
|
||||
...restValues,
|
||||
parentId: values.parentId === NO_PARENT_VALUE ? undefined : values.parentId,
|
||||
path: values.path || undefined,
|
||||
icon: values.icon || undefined,
|
||||
permission: values.permission || undefined,
|
||||
meta: keepAlive ? { keepAlive: true } : undefined,
|
||||
};
|
||||
|
||||
if (isEditing && menu) {
|
||||
@@ -329,22 +337,7 @@ export function MenuEditDialog({ menu, open, onOpenChange }: MenuEditDialogProps
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="permission"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>权限编码</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="如:user:read(可选)" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>关联的权限编码,格式为 资源:操作</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isHidden"
|
||||
@@ -370,8 +363,53 @@ export function MenuEditDialog({ menu, open, onOpenChange }: MenuEditDialogProps
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{menuType !== MenuType.BUTTON && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isExternal"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-2">
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormLabel className="mt-0!">外部链接</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{menuType !== MenuType.BUTTON && (
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-between">
|
||||
<span>元数据</span>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${advancedOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keepAlive"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>缓存页面</FormLabel>
|
||||
<FormDescription>启用后页面组件将被缓存,切换时保持状态</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -40,8 +40,6 @@ import { useFullMenuTree, useDeleteMenu } from '@/hooks/useMenus';
|
||||
interface FlatMenuItem extends MenuTreeNode {
|
||||
level: number;
|
||||
createdAt?: string;
|
||||
isStatic?: boolean;
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
// 获取菜单类型图标
|
||||
@@ -192,6 +190,16 @@ export function MenusTable() {
|
||||
>
|
||||
{getMenuTypeIcon(row.original.type)}
|
||||
<span>{row.original.name}</span>
|
||||
{row.original.isStatic && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
静态
|
||||
</Badge>
|
||||
)}
|
||||
{!row.original.isEnabled && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
禁用
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -225,15 +233,6 @@ export function MenusTable() {
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'permission',
|
||||
header: '权限编码',
|
||||
cell: ({ row }) => (
|
||||
<code className="text-sm text-muted-foreground">
|
||||
{row.original.permission || '-'}
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'isHidden',
|
||||
header: '显示',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type { RoleResponse, PermissionResponse } from '@seclusion/shared';
|
||||
import type { RoleResponse, PermissionResponse, MenuTreeNode } from '@seclusion/shared';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
@@ -28,6 +28,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useFullMenuTree } from '@/hooks/useMenus';
|
||||
import { useAllPermissions, useCreateRole, useRole, useUpdateRole } from '@/hooks/useRoles';
|
||||
|
||||
interface RoleEditDialogProps {
|
||||
@@ -43,18 +44,22 @@ interface RoleFormValues {
|
||||
isEnabled: boolean;
|
||||
sort: number;
|
||||
permissionIds: string[];
|
||||
menuIds: string[];
|
||||
}
|
||||
|
||||
export function RoleEditDialog({ role, open, onOpenChange }: RoleEditDialogProps) {
|
||||
const isEditing = !!role;
|
||||
const [activeTab, setActiveTab] = useState('basic');
|
||||
|
||||
// 获取角色详情(包含权限)
|
||||
// 获取角色详情(包含权限和菜单)
|
||||
const { data: roleDetail, isLoading: isLoadingRole } = useRole(role?.id || '');
|
||||
|
||||
// 获取所有权限
|
||||
const { data: permissions = [], isLoading: isLoadingPermissions } = useAllPermissions();
|
||||
|
||||
// 获取完整菜单树(包含禁用的菜单)
|
||||
const { data: menuTree = [], isLoading: isLoadingMenus } = useFullMenuTree();
|
||||
|
||||
// 创建/更新
|
||||
const createRole = useCreateRole();
|
||||
const updateRole = useUpdateRole();
|
||||
@@ -67,6 +72,7 @@ export function RoleEditDialog({ role, open, onOpenChange }: RoleEditDialogProps
|
||||
isEnabled: true,
|
||||
sort: 0,
|
||||
permissionIds: [],
|
||||
menuIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -81,6 +87,7 @@ export function RoleEditDialog({ role, open, onOpenChange }: RoleEditDialogProps
|
||||
isEnabled: roleDetail.isEnabled,
|
||||
sort: roleDetail.sort,
|
||||
permissionIds: roleDetail.permissions?.map((p) => p.id) || [],
|
||||
menuIds: roleDetail.menus?.map((m) => m.id) || [],
|
||||
});
|
||||
} else if (!role) {
|
||||
form.reset({
|
||||
@@ -90,6 +97,7 @@ export function RoleEditDialog({ role, open, onOpenChange }: RoleEditDialogProps
|
||||
isEnabled: true,
|
||||
sort: 0,
|
||||
permissionIds: [],
|
||||
menuIds: [],
|
||||
});
|
||||
}
|
||||
setActiveTab('basic');
|
||||
@@ -107,6 +115,7 @@ export function RoleEditDialog({ role, open, onOpenChange }: RoleEditDialogProps
|
||||
isEnabled: values.isEnabled,
|
||||
sort: values.sort,
|
||||
permissionIds: values.permissionIds,
|
||||
menuIds: values.menuIds,
|
||||
},
|
||||
});
|
||||
toast.success('角色更新成功');
|
||||
@@ -118,6 +127,7 @@ export function RoleEditDialog({ role, open, onOpenChange }: RoleEditDialogProps
|
||||
isEnabled: values.isEnabled,
|
||||
sort: values.sort,
|
||||
permissionIds: values.permissionIds,
|
||||
menuIds: values.menuIds,
|
||||
});
|
||||
toast.success('角色创建成功');
|
||||
}
|
||||
@@ -140,7 +150,58 @@ export function RoleEditDialog({ role, open, onOpenChange }: RoleEditDialogProps
|
||||
{}
|
||||
);
|
||||
|
||||
const isLoading = isLoadingRole || isLoadingPermissions;
|
||||
// 递归收集所有菜单 ID(用于扁平化菜单树)
|
||||
const collectAllMenuIds = (menus: MenuTreeNode[]): string[] => {
|
||||
const ids: string[] = [];
|
||||
for (const menu of menus) {
|
||||
ids.push(menu.id);
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
ids.push(...collectAllMenuIds(menu.children));
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
|
||||
// 递归渲染菜单树
|
||||
const renderMenuTree = (
|
||||
menus: MenuTreeNode[],
|
||||
level = 0,
|
||||
selectedIds: string[],
|
||||
onChange: (id: string, checked: boolean) => void
|
||||
): React.ReactNode => {
|
||||
return menus.map((menu) => {
|
||||
const hasChildren = menu.children && menu.children.length > 0;
|
||||
const isChecked = selectedIds.includes(menu.id);
|
||||
|
||||
return (
|
||||
<div key={menu.id} className="space-y-2">
|
||||
<div
|
||||
className="flex items-center space-x-2"
|
||||
style={{ paddingLeft: `${level * 1.5}rem` }}
|
||||
>
|
||||
<Checkbox
|
||||
id={`menu-${menu.id}`}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) => onChange(menu.id, !!checked)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`menu-${menu.id}`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{menu.name}
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({menu.type}
|
||||
{menu.path ? ` - ${menu.path}` : ''})
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{hasChildren && renderMenuTree(menu.children!, level + 1, selectedIds, onChange)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const isLoading = isLoadingRole || isLoadingPermissions || isLoadingMenus;
|
||||
const isMutating = createRole.isPending || updateRole.isPending;
|
||||
|
||||
return (
|
||||
@@ -161,9 +222,10 @@ export function RoleEditDialog({ role, open, onOpenChange }: RoleEditDialogProps
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="basic">基本信息</TabsTrigger>
|
||||
<TabsTrigger value="permissions">权限配置</TabsTrigger>
|
||||
<TabsTrigger value="menus">菜单配置</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="basic" className="space-y-4 mt-4">
|
||||
@@ -323,6 +385,36 @@ export function RoleEditDialog({ role, open, onOpenChange }: RoleEditDialogProps
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="menus" className="space-y-4 mt-4">
|
||||
<div className="text-sm text-muted-foreground mb-4">
|
||||
选择该角色可访问的菜单:
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="menuIds"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="space-y-3 rounded-lg border p-4 max-h-96 overflow-y-auto">
|
||||
{menuTree.length > 0 ? (
|
||||
renderMenuTree(menuTree, 0, field.value, (id, checked) => {
|
||||
if (checked) {
|
||||
field.onChange([...field.value, id]);
|
||||
} else {
|
||||
field.onChange(field.value.filter((menuId) => menuId !== id));
|
||||
}
|
||||
})
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
暂无可分配的菜单
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
Reference in New Issue
Block a user