feat(web): 更新菜单、角色和侧边栏组件

- MenuEditDialog 移除 component 和 permission 字段
- MenusTable 优化显示逻辑
- RoleEditDialog 增强角色编辑功能
- Sidebar 优化菜单渲染逻辑

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
charilezhou
2026-01-17 21:42:18 +08:00
parent d7c4cbc98d
commit 6ade9b68c9
4 changed files with 201 additions and 48 deletions

View File

@@ -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" />

View File

@@ -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"

View File

@@ -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: '显示',

View File

@@ -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>