pm 5 місяців тому
батько
коміт
ea1c385ff4

+ 829 - 0
src/components/MenuForm.tsx

@@ -0,0 +1,829 @@
+import React, { useState, useEffect, useImperativeHandle, forwardRef } from 'react';
+import { menuApi, MenuVO, MenuType, MenuStatus } from '../api/Menu';
+import { handleTree, TreeNode } from '../utils/tree';
+import { toast } from 'sonner';
+import { 
+  Maximize2, HelpCircle, ChevronDown, Check, 
+  FileText, Folder, Menu, Settings, User, Users, 
+  Lock, Unlock, Search, Edit, Trash2, Plus, Minus, 
+  CheckCircle, X, AlertTriangle, Info, Bell, MessageSquare, 
+  Phone, MapPin, Calendar, Clock, Star, Share2, 
+  Download, Upload, RefreshCw, Eye, EyeOff, Home, 
+  ShoppingBag, Package, Database, PieChart, BarChart3, 
+  Grid3x3, List, Link2, Key, Wrench, Monitor, Server
+} from 'lucide-react';
+import { Input } from './ui/input';
+import { Label } from './ui/label';
+import { Switch } from './ui/switch';
+import { Popover, PopoverTrigger, PopoverContent } from './ui/popover';
+import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
+
+interface MenuNode extends Omit<MenuVO, 'id'>, Omit<TreeNode, 'id'> {
+  id: number;
+  children?: MenuNode[];
+}
+
+interface MenuFormProps {
+  onSuccess?: () => void;
+}
+
+export interface MenuFormRef {
+  open: (type: string, id?: number, parentId?: number) => void;
+}
+
+const MenuForm = forwardRef<MenuFormRef, MenuFormProps>(({ onSuccess }, ref) => {
+  const [dialogVisible, setDialogVisible] = useState(false);
+  const [dialogTitle, setDialogTitle] = useState('');
+  const [formType, setFormType] = useState<'create' | 'update'>('create');
+  const [formLoading, setFormLoading] = useState(false);
+  const [menuTree, setMenuTree] = useState<MenuNode[]>([]);
+  const [popoverOpen, setPopoverOpen] = useState(false);
+  const [iconPickerOpen, setIconPickerOpen] = useState(false);
+  const [formData, setFormData] = useState<MenuVO>({
+    name: '',
+    permission: '',
+    type: MenuType.DIR,
+    sort: 0,
+    parentId: 0,
+    path: '',
+    icon: '',
+    component: '',
+    componentName: '',
+    status: 0, // 0 是开启,1 是关闭
+    visible: true,
+    keepAlive: true,
+    alwaysShow: true,
+  });
+
+  // 暴露方法给父组件
+  useImperativeHandle(ref, () => ({
+    open: (type: string, id?: number, parentId?: number) => {
+      setDialogTitle(type === 'create' ? '新增菜单' : '修改菜单');
+      setFormType(type as 'create' | 'update');
+      resetForm();
+
+      if (parentId) {
+        setFormData(prev => ({ ...prev, parentId }));
+      }
+
+      if (id) {
+        loadMenuData(id);
+      } else {
+        setFormLoading(false);
+      }
+
+      // 获取菜单树
+      getTree();
+      
+      // 设置 dialogVisible 为 true
+      setDialogVisible(true);
+    },
+  }));
+
+  // 组件挂载时获取菜单树
+  useEffect(() => {
+    getTree();
+  }, []);
+
+  // 获取菜单树
+  const getTree = async () => {
+    try {
+      const res = await menuApi.getSimpleMenusList();
+      const data = (res as any)?.data || res || [];
+      const treeData = handleTree<MenuNode>(data);
+      const rootMenu: MenuNode = { 
+        id: 0, 
+        name: '主类目', 
+        children: treeData,
+        type: MenuType.DIR,
+        sort: 0,
+        parentId: 0,
+        path: '',
+        icon: '',
+        permission: '',
+        status: 0, // 0 是开启,1 是关闭
+        visible: true,
+        keepAlive: false,
+      };
+      setMenuTree([rootMenu]);
+    } catch (error: any) {
+      toast.error(error.message || '获取菜单树失败');
+    }
+  };
+
+  // 加载菜单数据
+  const loadMenuData = async (id: number) => {
+    setFormLoading(true);
+    try {
+      const res = await menuApi.getMenu(id);
+      const data = (res as any)?.data || res;
+      // 确保 status 是数字类型,用于正确回显
+      setFormData({
+        ...data,
+        status: typeof data.status === 'string' ? Number(data.status) : (data.status ?? 0) // 0 是开启,1 是关闭
+      } as MenuVO);
+    } catch (error: any) {
+      toast.error(error.message || '获取菜单详情失败');
+    } finally {
+      setFormLoading(false);
+    }
+  };
+
+  // 重置表单
+  const resetForm = () => {
+    setFormData({
+      name: '',
+      permission: '',
+      type: MenuType.DIR,
+      sort: 0,
+      parentId: 0,
+      path: '',
+      icon: '',
+      component: '',
+      componentName: '',
+      status: 0, // 0 是开启,1 是关闭
+      visible: true,
+      keepAlive: true,
+      alwaysShow: true,
+    });
+  };
+
+  // 判断是否是外部链接
+  const isExternal = (path: string) => {
+    return /^(https?:|mailto:|tel:)/.test(path);
+  };
+
+  // 提交表单
+  const submitForm = async () => {
+    // 验证必填字段
+    if (!formData.name) {
+      toast.error('菜单名称不能为空');
+      return;
+    }
+    if (!formData.type) {
+      toast.error('菜单类型不能为空');
+      return;
+    }
+    if (formData.sort === undefined || formData.sort === null) {
+      toast.error('菜单顺序不能为空');
+      return;
+    }
+    if (formData.type !== MenuType.BUTTON && !formData.path) {
+      toast.error('路由地址不能为空');
+      return;
+    }
+    if (formData.status === undefined || formData.status === null) {
+      toast.error('菜单状态不能为空');
+      return;
+    }
+
+    // 验证路径格式
+    if (formData.type === MenuType.DIR || formData.type === MenuType.MENU) {
+      if (!isExternal(formData.path)) {
+        if (formData.parentId === 0 && formData.path.charAt(0) !== '/') {
+          toast.error('路径必须以 / 开头');
+          return;
+        } else if (formData.parentId !== 0 && formData.path.charAt(0) === '/') {
+          toast.error('路径不能以 / 开头');
+          return;
+        }
+      }
+    }
+
+    setFormLoading(true);
+    try {
+      if (formType === 'create') {
+        await menuApi.createMenu(formData);
+        toast.success('创建成功');
+      } else {
+        await menuApi.updateMenu(formData);
+        toast.success('更新成功');
+      }
+      setDialogVisible(false);
+      onSuccess?.();
+    } catch (error: any) {
+      toast.error(error.message || '操作失败');
+    } finally {
+      setFormLoading(false);
+    }
+  };
+
+  // 获取当前选中菜单的名称
+  const getSelectedMenuName = (): string => {
+    if (!formData.parentId || formData.parentId === 0) return '主类目';
+    
+    const findMenuName = (nodes: MenuNode[], targetId: number): string | null => {
+      for (const node of nodes) {
+        if (node.id === targetId) return node.name;
+        if (node.children) {
+          const found = findMenuName(node.children, targetId);
+          if (found) return found;
+        }
+      }
+      return null;
+    };
+    
+    return findMenuName(menuTree, formData.parentId) || '主类目';
+  };
+
+  // ElementPlus 图标列表(显示 React 图标,保存 ElementPlus 格式)
+  const elementPlusIcons = [
+    { name: 'ep:document', label: '文档', icon: FileText },
+    { name: 'ep:folder', label: '文件夹', icon: Folder },
+    { name: 'ep:menu', label: '菜单', icon: Menu },
+    { name: 'ep:setting', label: '设置', icon: Settings },
+    { name: 'ep:user', label: '用户', icon: User },
+    { name: 'ep:users', label: '用户组', icon: Users },
+    { name: 'ep:lock', label: '锁定', icon: Lock },
+    { name: 'ep:unlock', label: '解锁', icon: Unlock },
+    { name: 'ep:search', label: '搜索', icon: Search },
+    { name: 'ep:edit', label: '编辑', icon: Edit },
+    { name: 'ep:delete', label: '删除', icon: Trash2 },
+    { name: 'ep:plus', label: '添加', icon: Plus },
+    { name: 'ep:minus', label: '减少', icon: Minus },
+    { name: 'ep:check', label: '确认', icon: CheckCircle },
+    { name: 'ep:close', label: '关闭', icon: X },
+    { name: 'ep:warning', label: '警告', icon: AlertTriangle },
+    { name: 'ep:info', label: '信息', icon: Info },
+    { name: 'ep:success', label: '成功', icon: CheckCircle },
+    { name: 'ep:error', label: '错误', icon: X },
+    { name: 'ep:bell', label: '通知', icon: Bell },
+    { name: 'ep:message', label: '消息', icon: MessageSquare },
+    { name: 'ep:phone', label: '电话', icon: Phone },
+    { name: 'ep:location', label: '位置', icon: MapPin },
+    { name: 'ep:calendar', label: '日历', icon: Calendar },
+    { name: 'ep:clock', label: '时钟', icon: Clock },
+    { name: 'ep:star', label: '星标', icon: Star },
+    { name: 'ep:share', label: '分享', icon: Share2 },
+    { name: 'ep:download', label: '下载', icon: Download },
+    { name: 'ep:upload', label: '上传', icon: Upload },
+    { name: 'ep:refresh', label: '刷新', icon: RefreshCw },
+    { name: 'ep:view', label: '查看', icon: Eye },
+    { name: 'ep:hide', label: '隐藏', icon: EyeOff },
+    { name: 'ep:home', label: '首页', icon: Home },
+    { name: 'ep:shop', label: '商店', icon: ShoppingBag },
+    { name: 'ep:goods', label: '商品', icon: Package },
+    { name: 'ep:data', label: '数据', icon: Database },
+    { name: 'ep:pie-chart', label: '饼图', icon: PieChart },
+    { name: 'ep:bar-chart', label: '柱状图', icon: BarChart3 },
+    { name: 'ep:grid', label: '网格', icon: Grid3x3 },
+    { name: 'ep:list', label: '列表', icon: List },
+    { name: 'ep:connection', label: '连接', icon: Link2 },
+    { name: 'ep:link', label: '链接', icon: Link2 },
+    { name: 'ep:key', label: '钥匙', icon: Key },
+    { name: 'ep:tools', label: '工具', icon: Wrench },
+    { name: 'ep:monitor', label: '监控', icon: Monitor },
+    { name: 'ep:server', label: '服务器', icon: Server },
+    { name: 'ep:database', label: '数据库', icon: Database },
+  ];
+
+  // 递归渲染菜单树选项(自定义组件)
+  const renderMenuTreeOptions = (nodes: MenuNode[], level: number = 0, onSelect: (id: number) => void): React.ReactNode => {
+    return nodes.map(node => {
+      if (node.id === undefined || node.id === null) return null;
+      const idValue = node.id;
+      if (idValue === undefined) return null;
+      
+      const indentWidth = level * 20; 
+      const hasChildren = node.children && node.children.length > 0;
+      const isSelected = formData.parentId === idValue;
+      const isDisabled = node.id === formData.id;
+  
+      return (
+        <React.Fragment key={node.id}>
+          <div
+            onClick={() => {
+              if (!isDisabled) {
+                onSelect(idValue);
+              }
+            }}
+            className={`
+              flex items-center justify-between px-3 py-2 text-sm cursor-pointer
+              transition-colors select-none
+              ${isDisabled 
+                ? 'opacity-50 cursor-not-allowed' 
+                : 'hover:bg-blue-50 active:bg-blue-100'
+              }
+              ${isSelected 
+                ? 'bg-blue-50 text-blue-600 font-semibold' 
+                : 'text-gray-700'
+              }
+            `}
+            style={{ 
+              paddingLeft: `${12 + indentWidth}px`,
+            }}
+          >
+            {/* 文本区域:左侧层级线 + 菜单名 */}
+            <div className="flex items-center gap-1.5 flex-1 min-w-0">
+              {level > 0 && (
+                <span className="flex-shrink-0 text-gray-400 text-xs font-mono">│</span>
+              )}
+              <span className="flex-1 min-w-0 truncate">{node.name}</span>
+            </div>
+            {/* 自定义选中标记 */}
+            {isSelected && !isDisabled && (
+              <Check className="w-4 h-4 text-blue-600 ml-2 flex-shrink-0" />
+            )}
+          </div>
+          
+          {hasChildren && node.children && renderMenuTreeOptions(node.children, level + 1, onSelect)}
+        </React.Fragment>
+      );
+    }).filter(Boolean);
+  };
+  if (!dialogVisible) return null;
+
+  return (
+    <div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[9999] animate-in fade-in duration-200 p-4">
+      <div className="bg-white rounded-xl shadow-2xl w-full max-w-4xl animate-in zoom-in duration-200 relative z-[10000] max-h-[85vh] flex flex-col overflow-hidden">
+        {/* 弹窗标题 */}
+        <div className="px-5 py-3 border-b border-gray-200 flex items-center justify-between shrink-0">
+          <h3 className="text-base text-gray-900">{dialogTitle}</h3>
+          <div className="flex items-center gap-2">
+            <button className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
+              <Maximize2 className="w-4 h-4 text-gray-600" />
+            </button>
+            <button
+              onClick={() => setDialogVisible(false)}
+              className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
+            >
+              <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+              </svg>
+            </button>
+          </div>
+        </div>
+
+        {/* 弹窗内容 */}
+        <div className="px-5 py-3 overflow-y-auto overflow-x-hidden flex-1 min-h-0">
+          {formLoading ? (
+            <div className="py-6 text-center text-gray-500">加载中...</div>
+          ) : (
+            <div className="grid grid-cols-2 gap-x-32 gap-y-2.5">
+           {/* 上级菜单 - 占满整行(优化版) */}
+<div className="col-span-2 grid grid-cols-[100px_1fr] items-center gap-3 min-h-[40px]">
+  <label className="text-sm text-gray-700 whitespace-nowrap">上级菜单</label>
+  {/* 外层包裹确保下拉容器定位正确 */}
+  <div className="relative w-full">
+    <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
+      <PopoverTrigger asChild>
+        <button
+          type="button"
+          disabled={!menuTree || menuTree.length === 0}
+          className="w-full h-[36px] bg-white border border-gray-200 focus:border-blue-400 focus:ring-2 focus:ring-blue-100 
+                    rounded-md transition-all outline-none px-3 text-left flex items-center justify-between
+                    disabled:opacity-50 disabled:cursor-not-allowed hover:border-gray-300"
+        >
+          <span className={formData.parentId === 0 ? 'text-gray-500' : 'text-gray-900'}>
+            {getSelectedMenuName()}
+          </span>
+          <ChevronDown className={`w-4 h-4 text-gray-400 transition-transform ${popoverOpen ? 'rotate-180' : ''}`} />
+        </button>
+      </PopoverTrigger>
+      <PopoverContent 
+        className="p-0 border-gray-200 shadow-md bg-white rounded-md"
+        align="start"
+        side="bottom"
+        sideOffset={4}
+        style={{ width: 'var(--radix-popover-trigger-width)' }}
+      >
+        <div 
+          className="max-h-[200px] overflow-y-auto"
+          style={{ maxHeight: '200px' }}
+        >
+          {/* 空数据兜底 */}
+          {!menuTree || menuTree.length === 0 ? (
+            <div className="px-3 py-2 text-sm text-gray-400">暂无可选菜单</div>
+          ) : (
+            renderMenuTreeOptions(menuTree, 0, (id: number) => {
+              setFormData(prev => ({ ...prev, parentId: id }));
+              setPopoverOpen(false);
+            })
+          )}
+        </div>
+      </PopoverContent>
+    </Popover>
+  </div>
+</div>
+
+              {/* 菜单名称 */}
+              <div className="grid grid-cols-[100px_1fr] items-center gap-3" style={{ marginRight: '30px' }}>
+                <label className="text-sm text-gray-700">
+                  <span className="text-red-500 mr-1">*</span>
+                  菜单名称
+                </label>
+                <Input
+                  value={formData.name}
+                  onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
+                  placeholder="请输入菜单名称"
+                  className="bg-white border-gray-200 focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
+                />
+              </div>
+
+              {/* 菜单类型 */}
+              <div className="grid grid-cols-[100px_1fr] items-center gap-3">
+                <label className="text-sm text-gray-700">
+                  <span className="text-red-500 mr-1">*</span>
+                  菜单类型
+                </label>
+                <div className="flex gap-0">
+                  <button
+                    onClick={() => setFormData(prev => ({ ...prev, type: MenuType.DIR }))}
+                    className={`flex-1 px-3 py-1.5 text-xs border border-r-0 rounded-l-lg transition-all ${
+                      formData.type === MenuType.DIR
+                        ? 'bg-blue-500 text-white border-blue-500 z-10'
+                        : 'bg-white text-gray-700 border-gray-200 hover:bg-gray-50'
+                    }`}
+                  >
+                    目录
+                  </button>
+                  <button
+                    onClick={() => setFormData(prev => ({ ...prev, type: MenuType.MENU }))}
+                    className={`flex-1 px-3 py-1.5 text-xs border transition-all ${
+                      formData.type === MenuType.MENU
+                        ? 'bg-blue-500 text-white border-blue-500 z-10'
+                        : 'bg-white text-gray-700 border-gray-200 hover:bg-gray-50'
+                    }`}
+                  >
+                    菜单
+                  </button>
+                  <button
+                    onClick={() => setFormData(prev => ({ ...prev, type: MenuType.BUTTON }))}
+                    className={`flex-1 px-3 py-1.5 text-xs border border-l-0 rounded-r-lg transition-all ${
+                      formData.type === MenuType.BUTTON
+                        ? 'bg-blue-500 text-white border-blue-500 z-10'
+                        : 'bg-white text-gray-700 border-gray-200 hover:bg-gray-50'
+                    }`}
+                  >
+                    按钮
+                  </button>
+                </div>
+              </div>
+
+              {/* 菜单图标 */}
+              {formData.type !== MenuType.BUTTON && (
+                <div className="grid grid-cols-[100px_1fr] items-center gap-3" style={{ marginRight: '30px' }}>
+                  <label className="text-sm text-gray-700">菜单图标</label>
+                  <div className="flex items-center gap-2">
+                    <Input
+                      value={formData.icon}
+                      onChange={(e) => setFormData(prev => ({ ...prev, icon: e.target.value }))}
+                      placeholder="请选择或输入图标名称(如:ep:document)"
+                      className="bg-white border-gray-200 focus:border-blue-400 focus:ring-2 focus:ring-blue-100 flex-1"
+                    />
+                    <Popover open={iconPickerOpen} onOpenChange={setIconPickerOpen}>
+                      <PopoverTrigger asChild>
+                        <button
+                          type="button"
+                          className="px-3 h-[36px] bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors text-sm whitespace-nowrap"
+                        >
+                          选择图标
+                        </button>
+                      </PopoverTrigger>
+                      <PopoverContent 
+                        className="w-[400px] p-3 border-gray-200 shadow-md bg-white rounded-md max-h-[85vh]"
+                        align="end"
+                        side="bottom"
+                        sideOffset={4}
+                      >
+                        <div className="flex flex-col">
+                          <h4 className="text-sm font-semibold text-gray-700 mb-2">选择图标</h4>
+                          <div 
+                            className="grid gap-1.5 overflow-y-auto"
+                            style={{ 
+                              gridTemplateColumns: 'repeat(8, 1fr)',
+                              maxHeight: '300px',
+                              height: '300px'
+                            }}
+                          >
+                            {elementPlusIcons.map((iconItem) => {
+                              const IconComponent = iconItem.icon;
+                              const isSelected = formData.icon === iconItem.name;
+                              return (
+                                <div
+                                  key={iconItem.name}
+                                  onClick={() => {
+                                    setFormData(prev => ({ ...prev, icon: iconItem.name }));
+                                    setIconPickerOpen(false);
+                                  }}
+                                  className={`
+                                    flex items-center justify-center p-1.5 rounded cursor-pointer
+                                    transition-all border
+                                    ${isSelected
+                                      ? 'bg-blue-50 border-blue-500 shadow-sm'
+                                      : 'bg-white border-gray-200 hover:bg-gray-50 hover:border-blue-300'
+                                    }
+                                  `}
+                                  style={{ aspectRatio: '1' }}
+                                  title={`${iconItem.name} - ${iconItem.label}`}
+                                >
+                                  <IconComponent 
+                                    className={`
+                                      w-3.5 h-3.5
+                                      ${isSelected ? 'text-blue-600' : 'text-gray-600'}
+                                    `}
+                                  />
+                                </div>
+                              );
+                            })}
+                          </div>
+                        </div>
+                      </PopoverContent>
+                    </Popover>
+                  </div>
+                </div>
+              )}
+
+              {/* 路由地址 */}
+              {formData.type !== MenuType.BUTTON && (
+                <div className="grid grid-cols-[100px_1fr] items-center gap-3">
+                  <label className="text-sm text-gray-700 flex items-center gap-1">
+                    <span className="text-red-500">*</span>
+                    路由地址
+                    <HelpCircle className="w-3.5 h-3.5 text-gray-400" />
+                  </label>
+                  <Input
+                    value={formData.path}
+                    onChange={(e) => setFormData(prev => ({ ...prev, path: e.target.value }))}
+                    placeholder="请输入路由地址"
+                    className="bg-white border-gray-200 focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
+                  />
+                </div>
+              )}
+
+              {/* 组件地址 */}
+              {formData.type === MenuType.MENU && (
+                <div className="grid grid-cols-[100px_1fr] items-center gap-3" style={{ marginRight: '30px' }}>
+                  <label className="text-sm text-gray-700">组件地址</label>
+                  <Input
+                    value={formData.component}
+                    onChange={(e) => setFormData(prev => ({ ...prev, component: e.target.value }))}
+                    placeholder="例如:system/user/index"
+                    className="bg-white border-gray-200 focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
+                  />
+                </div>
+              )}
+
+              {/* 组件名字 */}
+              {formData.type === MenuType.MENU && (
+                <div className="grid grid-cols-[100px_1fr] items-center gap-3">
+                  <label className="text-sm text-gray-700">组件名字</label>
+                  <Input
+                    value={formData.componentName || ''}
+                    onChange={(e) => setFormData(prev => ({ ...prev, componentName: e.target.value }))}
+                    placeholder="例如:SystemUser"
+                    className="bg-white border-gray-200 focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
+                  />
+                </div>
+              )}
+
+              {/* 权限标识 */}
+              {formData.type !== MenuType.DIR && (
+                <div className="grid grid-cols-[100px_1fr] items-center gap-3" style={{ marginRight: '30px' }}>
+                  <label className="text-sm text-gray-700 flex items-center gap-1">
+                    权限标识
+                    <HelpCircle className="w-3.5 h-3.5 text-gray-400" />
+                  </label>
+                  <Input
+                    value={formData.permission}
+                    onChange={(e) => setFormData(prev => ({ ...prev, permission: e.target.value }))}
+                    placeholder="请输入权限标识"
+                    className="bg-white border-gray-200 focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
+                  />
+                </div>
+              )}
+
+              {/* 显示排序 */}
+              <div className="grid grid-cols-[100px_1fr] items-center gap-3" style={{ marginRight: '30px' }}>
+                <label className="text-sm text-gray-700">
+                  <span className="text-red-500 mr-1">*</span>
+                  显示排序
+                </label>
+                <Input
+                  type="number"
+                  value={formData.sort}
+                  onChange={(e) => setFormData(prev => ({ ...prev, sort: Number(e.target.value) || 0 }))}
+                  placeholder="请输入排序号"
+                  min={0}
+                  className="bg-white border-gray-200 focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
+                />
+              </div>
+
+              {/* 菜单状态 */}
+              <div className="col-span-4 grid grid-cols-[100px_1fr] items-center gap-3" style={{ marginRight: '30px' }}>
+                <label className="text-sm text-gray-700">
+                  <span className="text-red-500 mr-1">*</span>
+                  菜单状态
+                </label>
+                <div className="flex gap-0">
+                  <button
+                    type="button"
+                    onClick={() => setFormData(prev => ({ ...prev, status: 0 }))}
+                    className={`flex-1 px-4 py-1.5 text-xs border rounded-l-lg transition-all flex items-center justify-center gap-1.5 ${
+                      Number(formData.status) === 0
+                        ? 'bg-blue-50 text-blue-700 border-blue-500'
+                        : 'bg-white text-gray-700 border-gray-200 hover:bg-gray-50'
+                    }`}
+                  >
+                    <div className={`w-3 h-3 rounded-full border-2 flex items-center justify-center ${
+                      Number(formData.status) === 0 ? 'border-blue-500 bg-blue-500' : 'border-gray-300 bg-white'
+                    }`}>
+                      {Number(formData.status) === 0 && (
+                        <div className="w-1.5 h-1.5 rounded-full bg-white"></div>
+                      )}
+                    </div>
+                    开启
+                  </button>
+                  <button
+                    type="button"
+                    onClick={() => setFormData(prev => ({ ...prev, status: 1 }))}
+                    className={`flex-1 px-4 py-1.5 text-xs border border-l-0 rounded-r-lg transition-all flex items-center justify-center gap-1.5 ${
+                      Number(formData.status) === 1
+                        ? 'bg-blue-50 text-blue-700 border-blue-500'
+                        : 'bg-white text-gray-700 border-gray-200 hover:bg-gray-50'
+                    }`}
+                  >
+                    <div className={`w-3 h-3 rounded-full border-2 flex items-center justify-center ${
+                      Number(formData.status) === 1 ? 'border-blue-500 bg-blue-500' : 'border-gray-300 bg-white'
+                    }`}>
+                      {Number(formData.status) === 1 && (
+                        <div className="w-1.5 h-1.5 rounded-full bg-white"></div>
+                      )}
+                    </div>
+                    关闭
+                  </button>
+                </div>
+              </div>
+
+              {/* 显示状态 */}
+              {formData.type !== MenuType.BUTTON && (
+                <div className="grid grid-cols-[100px_1fr] items-center gap-3" style={{ marginRight: '30px' }}>
+                  <label className="text-sm text-gray-700 flex items-center gap-1">
+                    显示状态
+                    <HelpCircle className="w-3.5 h-3.5 text-gray-400" />
+                  </label>
+                  <div className="flex gap-0">
+                    <button
+                      type="button"
+                      onClick={() => setFormData(prev => ({ ...prev, visible: true }))}
+                      className={`flex-1 px-4 py-1.5 text-xs border rounded-l-lg transition-all flex items-center justify-center gap-1.5 ${
+                        formData.visible === true
+                          ? 'bg-blue-50 text-blue-700 border-blue-500'
+                          : 'bg-white text-gray-700 border-gray-200 hover:bg-gray-50'
+                      }`}
+                    >
+                      <div className={`w-3 h-3 rounded-full border-2 flex items-center justify-center ${
+                        formData.visible === true ? 'border-blue-500 bg-blue-500' : 'border-gray-300 bg-white'
+                      }`}>
+                        {formData.visible === true && (
+                          <div className="w-1.5 h-1.5 rounded-full bg-white"></div>
+                        )}
+                      </div>
+                      显示
+                    </button>
+                    <button
+                      type="button"
+                      onClick={() => setFormData(prev => ({ ...prev, visible: false }))}
+                      className={`flex-1 px-4 py-1.5 text-xs border border-l-0 rounded-r-lg transition-all flex items-center justify-center gap-1.5 ${
+                        formData.visible === false
+                          ? 'bg-blue-50 text-blue-700 border-blue-500'
+                          : 'bg-white text-gray-700 border-gray-200 hover:bg-gray-50'
+                      }`}
+                    >
+                      <div className={`w-3 h-3 rounded-full border-2 flex items-center justify-center ${
+                        formData.visible === false ? 'border-blue-500 bg-blue-500' : 'border-gray-300 bg-white'
+                      }`}>
+                        {formData.visible === false && (
+                          <div className="w-1.5 h-1.5 rounded-full bg-white"></div>
+                        )}
+                      </div>
+                      隐藏
+                    </button>
+                  </div>
+                </div>
+              )}
+
+              {/* 总是显示 */}
+              {formData.type !== MenuType.BUTTON && (
+                <div className="grid grid-cols-[100px_1fr] items-center gap-3" style={{ marginRight: '30px' }}>
+                  <label className="text-sm text-gray-700 flex items-center gap-1">
+                    总是显示
+                    <HelpCircle className="w-3.5 h-3.5 text-gray-400" />
+                  </label>
+                  <div className="flex gap-0">
+                    <button
+                      type="button"
+                      onClick={() => setFormData(prev => ({ ...prev, alwaysShow: true }))}
+                      className={`flex-1 px-4 py-1.5 text-xs border rounded-l-lg transition-all flex items-center justify-center gap-1.5 ${
+                        (formData.alwaysShow ?? true) === true
+                          ? 'bg-blue-50 text-blue-700 border-blue-500'
+                          : 'bg-white text-gray-700 border-gray-200 hover:bg-gray-50'
+                      }`}
+                    >
+                      <div className={`w-3 h-3 rounded-full border-2 flex items-center justify-center ${
+                        (formData.alwaysShow ?? true) === true ? 'border-blue-500 bg-blue-500' : 'border-gray-300 bg-white'
+                      }`}>
+                        {(formData.alwaysShow ?? true) === true && (
+                          <div className="w-1.5 h-1.5 rounded-full bg-white"></div>
+                        )}
+                      </div>
+                      总是
+                    </button>
+                    <button
+                      type="button"
+                      onClick={() => setFormData(prev => ({ ...prev, alwaysShow: false }))}
+                      className={`flex-1 px-4 py-1.5 text-xs border border-l-0 rounded-r-lg transition-all flex items-center justify-center gap-1.5 ${
+                        (formData.alwaysShow ?? true) === false
+                          ? 'bg-blue-50 text-blue-700 border-blue-500'
+                          : 'bg-white text-gray-700 border-gray-200 hover:bg-gray-50'
+                      }`}
+                    >
+                      <div className={`w-3 h-3 rounded-full border-2 flex items-center justify-center ${
+                        (formData.alwaysShow ?? true) === false ? 'border-blue-500 bg-blue-500' : 'border-gray-300 bg-white'
+                      }`}>
+                        {(formData.alwaysShow ?? true) === false && (
+                          <div className="w-1.5 h-1.5 rounded-full bg-white"></div>
+                        )}
+                      </div>
+                      不是
+                    </button>
+                  </div>
+                </div>
+              )}
+
+              {/* 缓存状态 */}
+              {formData.type === MenuType.MENU && (
+                <div className="col-span-4 grid grid-cols-[100px_1fr] items-center gap-3" style={{ marginRight: '30px' }}>
+                  <label className="text-sm text-gray-700 flex items-center gap-1">
+                    缓存状态
+                    <HelpCircle className="w-3.5 h-3.5 text-gray-400" />
+                  </label>
+                  <div className="flex gap-0">
+                    <button
+                      type="button"
+                      onClick={() => setFormData(prev => ({ ...prev, keepAlive: true }))}
+                      className={`flex-1 px-4 py-1.5 text-xs border rounded-l-lg transition-all flex items-center justify-center gap-1.5 ${
+                        (formData.keepAlive ?? true) === true
+                          ? 'bg-blue-50 text-blue-700 border-blue-500'
+                          : 'bg-white text-gray-700 border-gray-200 hover:bg-gray-50'
+                      }`}
+                    >
+                      <div className={`w-3 h-3 rounded-full border-2 flex items-center justify-center ${
+                        (formData.keepAlive ?? true) === true ? 'border-blue-500 bg-blue-500' : 'border-gray-300 bg-white'
+                      }`}>
+                        {(formData.keepAlive ?? true) === true && (
+                          <div className="w-1.5 h-1.5 rounded-full bg-white"></div>
+                        )}
+                      </div>
+                      缓存
+                    </button>
+                    <button
+                      type="button"
+                      onClick={() => setFormData(prev => ({ ...prev, keepAlive: false }))}
+                      className={`flex-1 px-4 py-1.5 text-xs border border-l-0 rounded-r-lg transition-all flex items-center justify-center gap-1.5 ${
+                        (formData.keepAlive ?? true) === false
+                          ? 'bg-blue-50 text-blue-700 border-blue-500'
+                          : 'bg-white text-gray-700 border-gray-200 hover:bg-gray-50'
+                      }`}
+                    >
+                      <div className={`w-3 h-3 rounded-full border-2 flex items-center justify-center ${
+                        (formData.keepAlive ?? true) === false ? 'border-blue-500 bg-blue-500' : 'border-gray-300 bg-white'
+                      }`}>
+                        {(formData.keepAlive ?? true) === false && (
+                          <div className="w-1.5 h-1.5 rounded-full bg-white"></div>
+                        )}
+                      </div>
+                      不缓存
+                    </button>
+                  </div>
+                </div>
+              )}
+            </div>
+          )}
+        </div>
+
+        {/* 弹窗底部 */}
+        <div className="px-5 py-3 bg-gray-50/50 border-t border-gray-200 flex justify-end gap-2 rounded-b-xl shrink-0">
+          <button
+            onClick={() => setDialogVisible(false)}
+            className="px-4 py-2 text-sm text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
+          >
+            取消
+          </button>
+          <button
+            onClick={submitForm}
+            disabled={formLoading}
+            className="px-4 py-2 text-sm text-white bg-gradient-to-r from-blue-500 to-blue-600 rounded-lg hover:shadow-lg hover:shadow-blue-400/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
+          >
+            {formLoading ? '提交中...' : '确定'}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+});
+
+MenuForm.displayName = 'MenuForm';
+
+export default MenuForm;
+

+ 513 - 0
src/components/MenuManagement.tsx

@@ -0,0 +1,513 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { 
+  Search, Plus, RefreshCw, ChevronRight, ChevronDown, Edit2, Trash2,
+  FileText, Folder, Menu, Menu as MenuIcon, Settings, User, Users, 
+  Lock, Unlock, Edit, Minus, 
+  CheckCircle, X, AlertTriangle, Info, Bell, MessageSquare, 
+  Phone, MapPin, Calendar, Clock, Star, Share2, 
+  Download, Upload, Eye, EyeOff, Home, 
+  ShoppingBag, Package, Database, PieChart, BarChart3, 
+  Grid3x3, List, Link2, Key, Wrench, Monitor, Server
+} from 'lucide-react';
+import { menuApi, MenuVO, MenuQueryParams, MenuType, MenuStatus } from '../api/Menu';
+import { handleTree, TreeNode } from '../utils/tree';
+import { toast } from 'sonner';
+import { Button } from './ui/button';
+import { Input } from './ui/input';
+import { Label } from './ui/label';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
+import { Switch } from './ui/switch';
+import MenuForm, { MenuFormRef } from './MenuForm';
+
+interface MenuNode extends MenuVO, TreeNode {
+  children?: MenuNode[];
+}
+
+export default function MenuManagement() {
+  const [loading, setLoading] = useState(true);
+  const [list, setList] = useState<MenuVO[]>([]);
+  const [treeList, setTreeList] = useState<MenuNode[]>([]);
+  const [queryParams, setQueryParams] = useState<MenuQueryParams>({
+    name: undefined,
+    status: undefined,
+  });
+  const [expandedRowKeys, setExpandedRowKeys] = useState<number[]>([]);
+  const [isExpandAll, setIsExpandAll] = useState(false);
+  const [menuStatusUpdating, setMenuStatusUpdating] = useState<Record<number, boolean>>({});
+  const formRef = useRef<MenuFormRef>(null);
+
+  // 获取菜单列表
+  const getList = async () => {
+    setLoading(true);
+    try {
+      const data = await menuApi.getMenuList(queryParams);
+      setList(data || []);
+      const treeData = handleTree<MenuNode>(data || []);
+      setTreeList(treeData);
+      
+      // 首次加载时默认展开所有
+      if (expandedRowKeys.length === 0 && treeData.length > 0) {
+        const allIds = getAllMenuIds(treeData);
+        setExpandedRowKeys(allIds);
+        setIsExpandAll(true);
+      }
+    } catch (error: any) {
+      toast.error(error.message || '获取菜单列表失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // 获取所有菜单ID(递归)
+  const getAllMenuIds = (nodes: MenuNode[]): number[] => {
+    const ids: number[] = [];
+    nodes.forEach(node => {
+      if (node.id) {
+        ids.push(node.id);
+        if (node.children && node.children.length > 0) {
+          ids.push(...getAllMenuIds(node.children));
+        }
+      }
+    });
+    return ids;
+  };
+
+  useEffect(() => {
+    getList();
+  }, []);
+
+  // 搜索
+  const handleQuery = () => {
+    getList();
+  };
+
+  // 重置搜索
+  const resetQuery = () => {
+    setQueryParams({
+      name: undefined,
+    });
+    setTimeout(() => {
+      getList();
+    }, 0);
+  };
+
+  // 展开/折叠所有
+  const toggleExpandAll = () => {
+    if (isExpandAll) {
+      setExpandedRowKeys([]);
+      setIsExpandAll(false);
+    } else {
+      const allIds = getAllMenuIds(treeList);
+      setExpandedRowKeys(allIds);
+      setIsExpandAll(true);
+    }
+  };
+
+  // 切换单个节点展开/折叠
+  const toggleExpand = (id: number) => {
+    setExpandedRowKeys(prev =>
+      prev.includes(id) ? prev.filter(key => key !== id) : [...prev, id]
+    );
+  };
+
+  // 打开表单
+  const openForm = (type: string, id?: number, parentId?: number) => {
+    if (formRef.current) {
+      formRef.current.open(type, id, parentId);
+    }
+  };
+
+  // 删除菜单
+  const handleDelete = async (id: number) => {
+    if (!confirm('确定要删除这个菜单吗?删除后无法恢复!')) {
+      return;
+    }
+
+    try {
+      await menuApi.deleteMenu(id);
+      toast.success('删除成功');
+      await getList();
+    } catch (error: any) {
+      toast.error(error.message || '删除失败');
+    }
+  };
+
+  // 状态切换
+  const handleStatusChanged = async (menu: MenuVO, val: number) => {
+    if (!menu.id) {
+      toast.error('菜单ID不存在');
+      return;
+    }
+
+    setMenuStatusUpdating(prev => ({ ...prev, [menu.id!]: true }));
+    try {
+      // 确保传递完整的菜单数据,包括所有必需字段
+      const updatedMenu: MenuVO = {
+        id: menu.id,
+        name: menu.name,
+        permission: menu.permission ?? '',
+        type: menu.type,
+        sort: menu.sort ?? 0,
+        parentId: menu.parentId ?? 0,
+        path: menu.path ?? '',
+        icon: menu.icon ?? '',
+        component: menu.component ?? null,
+        componentName: menu.componentName ?? null,
+        status: val,
+        visible: menu.visible ?? true,
+        keepAlive: menu.keepAlive ?? true,
+        alwaysShow: menu.alwaysShow ?? true,
+        createTime: menu.createTime,
+      };
+      await menuApi.updateMenu(updatedMenu);
+      toast.success('状态更新成功');
+      await getList();
+    } catch (error: any) {
+      toast.error(error.message || '状态更新失败');
+      // 恢复原状态 - 重新获取列表以恢复正确的状态
+      await getList();
+    } finally {
+      setMenuStatusUpdating(prev => ({ ...prev, [menu.id!]: false }));
+    }
+  };
+
+  // 刷新菜单缓存
+  const refreshMenu = async () => {
+    if (!confirm('即将更新缓存并刷新浏览器!')) {
+      return;
+    }
+
+    try {
+      // 清空缓存
+      localStorage.removeItem('token');
+      localStorage.removeItem('permissionInfo');
+      localStorage.removeItem('roleRouters');
+      
+      // 刷新浏览器
+      window.location.reload();
+    } catch (error) {
+      console.error('刷新失败:', error);
+    }
+  };
+
+  // ElementPlus 图标名称到 React 图标的映射
+  const iconMap: Record<string, React.ComponentType<any>> = {
+    'ep:document': FileText,
+    'ep:folder': Folder,
+    'ep:menu': Menu,
+    'ep:setting': Settings,
+    'ep:user': User,
+    'ep:users': Users,
+    'ep:lock': Lock,
+    'ep:unlock': Unlock,
+    'ep:search': Search,
+    'ep:edit': Edit,
+    'ep:delete': Trash2,
+    'ep:plus': Plus,
+    'ep:minus': Minus,
+    'ep:check': CheckCircle,
+    'ep:close': X,
+    'ep:warning': AlertTriangle,
+    'ep:info': Info,
+    'ep:success': CheckCircle,
+    'ep:error': X,
+    'ep:bell': Bell,
+    'ep:message': MessageSquare,
+    'ep:phone': Phone,
+    'ep:location': MapPin,
+    'ep:calendar': Calendar,
+    'ep:clock': Clock,
+    'ep:star': Star,
+    'ep:share': Share2,
+    'ep:download': Download,
+    'ep:upload': Upload,
+    'ep:refresh': RefreshCw,
+    'ep:view': Eye,
+    'ep:hide': EyeOff,
+    'ep:home': Home,
+    'ep:shop': ShoppingBag,
+    'ep:goods': Package,
+    'ep:data': Database,
+    'ep:pie-chart': PieChart,
+    'ep:bar-chart': BarChart3,
+    'ep:grid': Grid3x3,
+    'ep:list': List,
+    'ep:connection': Link2,
+    'ep:link': Link2,
+    'ep:key': Key,
+    'ep:tools': Wrench,
+    'ep:monitor': Monitor,
+    'ep:server': Server,
+    'ep:database': Database,
+  };
+
+  // 可用图标池(用于随机分配)
+  const iconPool = [
+    FileText, Folder, Menu, Settings, User, Users,
+    Lock, Unlock, Search, Edit, Trash2, Plus, Minus,
+    CheckCircle, X, AlertTriangle, Info, Bell, MessageSquare,
+    Phone, MapPin, Calendar, Clock, Star, Share2,
+    Download, Upload, RefreshCw, Eye, EyeOff, Home,
+    ShoppingBag, Package, Database, PieChart, BarChart3,
+    Grid3x3, List, Link2, Key, Wrench, Monitor, Server
+  ];
+
+  // 根据字符串生成哈希值(用于稳定随机分配)
+  const hashString = (str: string): number => {
+    let hash = 0;
+    for (let i = 0; i < str.length; i++) {
+      const char = str.charCodeAt(i);
+      hash = ((hash << 5) - hash) + char;
+      hash = hash & hash; // Convert to 32bit integer
+    }
+    return Math.abs(hash);
+  };
+
+  // 获取图标组件
+  const getIconComponent = (iconName?: string, menuName?: string, menuId?: number) => {
+    // 首先尝试从 ElementPlus 映射中获取
+    if (iconName && iconMap[iconName]) {
+      return iconMap[iconName];
+    }
+    
+    // 如果没有映射,根据菜单名称或ID随机分配一个图标
+    const seed = menuName || menuId?.toString() || 'default';
+    const hash = hashString(seed);
+    const randomIndex = hash % iconPool.length;
+    return iconPool[randomIndex];
+  };
+
+  // 获取菜单类型标签
+  const getMenuTypeLabel = (type: number) => {
+    switch (type) {
+      case MenuType.DIR:
+        return '目录';
+      case MenuType.MENU:
+        return '菜单';
+      case MenuType.BUTTON:
+        return '按钮';
+      default:
+        return '未知';
+    }
+  };
+
+  // 递归渲染树形表格行
+  const renderTreeRow = (node: MenuNode, level: number = 0): React.ReactNode => {
+    const isExpanded = expandedRowKeys.includes(node.id!);
+    const hasChildren = node.children && node.children.length > 0;
+    const IconComponent = getIconComponent(node.icon, node.name, node.id);
+
+    return (
+      <React.Fragment key={node.id}>
+        <TableRow className="hover:bg-gray-50">
+          <TableCell style={{ paddingLeft: `${level * 24 + 12}px` }} className="font-medium">
+            <div className="flex items-center gap-2">
+              {hasChildren ? (
+                <button
+                  onClick={() => toggleExpand(node.id!)}
+                  className="p-0.5 hover:bg-gray-200 rounded transition-colors"
+                >
+                  {isExpanded ? (
+                    <ChevronDown className="w-4 h-4" />
+                  ) : (
+                    <ChevronRight className="w-4 h-4" />
+                  )}
+                </button>
+              ) : (
+                <span className="w-5"></span>
+              )}
+              <IconComponent className="w-4 h-4 text-gray-500" />
+              <span>{node.name}</span>
+            </div>
+          </TableCell>
+          <TableCell>
+            <div className="flex items-center justify-center">
+              <IconComponent className="w-4 h-4" />
+            </div>
+          </TableCell>
+          <TableCell className="text-center">{node.sort}</TableCell>
+          <TableCell className="text-sm text-gray-600">{node.permission || '-'}</TableCell>
+          <TableCell className="text-sm text-gray-600">{node.component || '-'}</TableCell>
+          <TableCell className="text-sm text-gray-600">{node.componentName || '-'}</TableCell>
+          <TableCell className="text-center">
+            <button
+              type="button"
+              onClick={() => {
+                if (!menuStatusUpdating[node.id!]) {
+                  // 0 是开启,1 是关闭
+                  const newStatus = Number(node.status) === 0 ? 1 : 0;
+                  handleStatusChanged(node, newStatus);
+                }
+              }}
+              disabled={menuStatusUpdating[node.id!]}
+              className={`
+                relative inline-flex h-6 w-11 items-center rounded-full transition-colors
+                focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
+                disabled:opacity-50 disabled:cursor-not-allowed
+                ${Number(node.status) === 0 
+                  ? 'bg-blue-500' 
+                  : 'bg-gray-300'
+                }
+              `}
+            >
+              <span
+                className={`
+                  inline-block h-4 w-4 transform rounded-full bg-white transition-transform
+                  ${Number(node.status) === 0 ? 'translate-x-6' : 'translate-x-1'}
+                `}
+              />
+            </button>
+          </TableCell>
+          <TableCell>
+            <div className="flex items-center gap-2 justify-center">
+              <Button
+                variant="ghost"
+                size="sm"
+                onClick={() => openForm('update', node.id)}
+                className="h-8 px-2"
+              >
+                <Edit2 className="w-4 h-4" />
+              </Button>
+              <Button
+                variant="ghost"
+                size="sm"
+                onClick={() => openForm('create', undefined, node.id)}
+                className="h-8 px-2"
+              >
+                <Plus className="w-4 h-4" />
+              </Button>
+              <Button
+                variant="ghost"
+                size="sm"
+                onClick={() => handleDelete(node.id!)}
+                className="h-8 px-2 text-red-600 hover:text-red-700"
+              >
+                <Trash2 className="w-4 h-4" />
+              </Button>
+            </div>
+          </TableCell>
+        </TableRow>
+        {isExpanded && hasChildren && (
+          <>
+            {node.children!.map(child => renderTreeRow(child, level + 1))}
+          </>
+        )}
+      </React.Fragment>
+    );
+  };
+
+  return (
+    <div className="space-y-4">
+      {/* 搜索栏 */}
+      <div className="bg-white rounded-xl border border-gray-200/50 shadow-sm p-5">
+        <div className="flex items-center justify-between gap-4">
+          {/* 搜索输入框 */}
+          <div className="flex items-center gap-3 flex-1 max-w-md">
+            <Label htmlFor="menu-name-search" className="text-sm font-medium text-gray-700 whitespace-nowrap">
+              菜单名称
+            </Label>
+            <Input
+              id="menu-name-search"
+              placeholder="请输入菜单名称"
+              value={queryParams.name || ''}
+              onChange={(e) => setQueryParams(prev => ({ ...prev, name: e.target.value }))}
+              onKeyDown={(e) => e.key === 'Enter' && handleQuery()}
+              className="h-10 bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition-all"
+            />
+          </div>
+
+          {/* 操作按钮组 */}
+          <div className="flex items-center gap-3">
+            <button
+              onClick={handleQuery}
+              className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl hover:shadow-lg hover:shadow-blue-400/40 transition-all duration-300"
+            >
+              <Search className="w-4 h-4" strokeWidth={2.5} />
+              <span className="text-sm">搜索</span>
+            </button>
+            
+            <button
+              onClick={resetQuery}
+              className="flex items-center gap-2 px-4 py-2.5 bg-white border border-gray-300 text-gray-700 rounded-xl hover:bg-gray-50 hover:border-gray-400 transition-all duration-300"
+            >
+              <RefreshCw className="w-4 h-4" strokeWidth={2.5} />
+              <span className="text-sm">重置</span>
+            </button>
+            
+            <button
+              onClick={() => openForm('create')}
+              className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl hover:shadow-lg hover:shadow-blue-400/40 transition-all duration-300"
+            >
+              <Plus className="w-4 h-4" strokeWidth={2.5} />
+              <span className="text-sm">新增</span>
+            </button>
+            
+            <button
+              onClick={toggleExpandAll}
+              className="flex items-center gap-2 px-4 py-2.5 bg-white border border-gray-300 text-gray-700 rounded-xl hover:bg-gray-50 hover:border-gray-400 transition-all duration-300"
+            >
+              {isExpandAll ? (
+                <>
+                  <ChevronDown className="w-4 h-4" strokeWidth={2.5} />
+                  <span className="text-sm">折叠</span>
+                </>
+              ) : (
+                <>
+                  <ChevronRight className="w-4 h-4" strokeWidth={2.5} />
+                  <span className="text-sm">展开</span>
+                </>
+              )}
+            </button>
+            
+            <button
+              onClick={refreshMenu}
+              className="flex items-center gap-2 px-4 py-2.5 bg-white border border-gray-300 text-gray-700 rounded-xl hover:bg-gray-50 hover:border-gray-400 transition-all duration-300"
+            >
+              <RefreshCw className="w-4 h-4" strokeWidth={2.5} />
+              <span className="text-sm">刷新缓存</span>
+            </button>
+          </div>
+        </div>
+      </div>
+
+      {/* 表格 */}
+      <div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
+        <Table>
+          <TableHeader>
+            <TableRow>
+              <TableHead className="w-[250px]">菜单名称</TableHead>
+              <TableHead className="w-[100px] text-center">图标</TableHead>
+              <TableHead className="w-[60px] text-center">排序</TableHead>
+              <TableHead className="w-[300px]">权限标识</TableHead>
+              <TableHead className="w-[500px]">组件路径</TableHead>
+              <TableHead className="w-[200px]">组件名称</TableHead>
+              <TableHead className="w-[60px] text-center">状态</TableHead>
+              <TableHead className="w-[160px] text-center">操作</TableHead>
+            </TableRow>
+          </TableHeader>
+          <TableBody>
+            {loading ? (
+              <TableRow>
+                <TableCell colSpan={8} className="text-center py-8 text-gray-500">
+                  加载中...
+                </TableCell>
+              </TableRow>
+            ) : treeList.length === 0 ? (
+              <TableRow>
+                <TableCell colSpan={8} className="text-center py-8 text-gray-500">
+                  暂无数据
+                </TableCell>
+              </TableRow>
+            ) : (
+              treeList.map(node => renderTreeRow(node))
+            )}
+          </TableBody>
+        </Table>
+      </div>
+
+      {/* 表单弹窗 */}
+      <MenuForm ref={formRef} onSuccess={getList} />
+    </div>
+  );
+}
+

+ 6 - 0
src/components/SystemConfig.tsx

@@ -1,6 +1,7 @@
 import React, { useState } from 'react';
 import { Plus, Search, Edit2, Trash2, MoreVertical, ChevronRight, ChevronDown } from 'lucide-react';
 import DepartmentManagement from './DepartmentManagement';
+import MenuManagement from './MenuManagement';
 
 interface TableRow {
   id: number;
@@ -400,6 +401,11 @@ export default function SystemConfig({ subMenu }: SystemConfigProps) {
     setShowAddModal(true);
   };
 
+  // 菜单管理使用专门的组件
+  if (subMenu === '菜单管理' || subMenu === 'menuManagement') {
+    return <MenuManagement />;
+  }
+
   // 字典管理使用卡片展示
   if (subMenu === '字典管理') {
     return (

+ 16 - 13
src/components/ui/dialog.tsx

@@ -30,34 +30,36 @@ function DialogClose({
   return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
 }
 
-function DialogOverlay({
-  className,
-  ...props
-}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
+const DialogOverlay = React.forwardRef<
+  React.ElementRef<typeof DialogPrimitive.Overlay>,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
+>(({ className, ...props }, ref) => {
   return (
     <DialogPrimitive.Overlay
+      ref={ref}
       data-slot="dialog-overlay"
       className={cn(
-        "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
+        "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[100] bg-black/50",
         className,
       )}
       {...props}
     />
   );
-}
+});
+DialogOverlay.displayName = "DialogOverlay";
 
-function DialogContent({
-  className,
-  children,
-  ...props
-}: React.ComponentProps<typeof DialogPrimitive.Content>) {
+const DialogContent = React.forwardRef<
+  React.ElementRef<typeof DialogPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
+>(({ className, children, ...props }, ref) => {
   return (
     <DialogPortal data-slot="dialog-portal">
       <DialogOverlay />
       <DialogPrimitive.Content
+        ref={ref}
         data-slot="dialog-content"
         className={cn(
-          "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
+          "bg-white data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[101] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
           className,
         )}
         {...props}
@@ -70,7 +72,8 @@ function DialogContent({
       </DialogPrimitive.Content>
     </DialogPortal>
   );
-}
+});
+DialogContent.displayName = "DialogContent";
 
 function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
   return (