基于可视化拖拽的层级菜单管理系统及实现方法

162 阅读7分钟

customMenu.jpg

前言

在前端开发中,自定义菜单是一个常见的需求,特别是在构建复杂的Web应用或用户界面时。自定义菜单不仅允许开发者根据业务需求灵活调整菜单项和布局,还能提升用户体验和应用的可用性。以下将详细介绍前端如何处理自定义菜单的几个关键步骤。

技术领域

本发明涉及前端交互技术领域,具体涉及一种支持多层级拖拽、动态权限适配和实时状态同步的菜单管理系统及实现方法。

背景技术

现有菜单管理系统存在以下技术瓶颈:

  1. 菜单层级关系维护困难,容易产生非法嵌套
  2. 权限控制与菜单呈现强耦合,动态更新效率低
  3. 批量操作缺乏状态一致性保证机制
  4. 可视化配置与数据持久化存在断层
  5. 跨中心菜单配置缺乏统一管理界面

实现思路

注:本功能后端只接受最后保存好的菜单数据,所以所有交互行为、数据存储都需要前端来实现

  1. 切换中心可以选择不同中心的菜单
  1. 拖拽区域分为两个区域,左侧和右侧,并且一级菜单区域和二级菜单区域分别写draggle,这样拖拽时存放区域就不会错把一级和二级放在一块,引起数据紊乱。
  1. 拖拽结束需要更新右侧菜单的状态,被拖拽后需要置灰(通过keyword对比)
  1. 左侧菜单需要给每一项生成checkbox,用于批量删除,且做到全选、半选逻辑
  1. 自定义菜单属于一个盒子,所以前端需要初始化模板菜单数据结构

发明内容

系统架构

本系统采用双视图交互架构,包含以下核心组件:

                     +----------------------+
                     | 可视化配置界面        |
                     | (双视图拖拽+状态反馈) |
                     +-----------+----------+
                                 |
+----------------+    +----------v----------+    +-----------------+
| 中心切换模块    |<-->| 菜单结构引擎         |<-->| 权限适配器       |
| (多中心隔离)    |    | (层级校验+状态同步)  |    | (动态权限过滤)   |
+----------------+    +----------+----------+    +-----------------+
                                 |
                     +-----------v-----------+
                     | 持久化服务模块         |
                     | (操作日志+版本管理)    |
                     +-----------------------+

技术方案

  1. 多层级拖拽约束方法
// 实现层级关系校验的拖拽约束
const validateDrag = (source, target) => {
  // 一级菜单不能作为子项
  if(source.node.menuLevel === 1 && target.parent) return false
  // 二级菜单必须具有父级
  if(source.node.menuLevel === 2 && !target.parent) return false
  // 禁止跨中心拖拽
  if(source.node.center !== target.node.center) return false
  return true
}

2.动态权限适配算法

// 基于角色权限的实时过滤
function filterMenuTree(menuItems, permissions) {
  return menuItems.reduce((acc, item) => {
    if(item.requireAuth && !permissions.includes(item.key)) return acc
    
    const newItem = {...item}
    if(newItem.childrenList) {
      newItem.childrenList = filterMenuTree(newItem.childrenList, permissions)
      if(newItem.childrenList.length === 0) delete newItem.childrenList
    }
    acc.push(newItem)
    return acc
  }, [])
}

3. 状态同步引擎

// 拖拽事件处理
onDrop(event) {
  const transferredData = JSON.parse(event.dataTransfer.getData('menuItem'))
  this.commit('UPDATE_MENU_STATE', {
    type: 'ADD_ITEM',
    data: transferredData
  })
  this.dispatch('SYNC_SOURCE_STATUS') // 更新源区域置灰状态
}

4.跨中心菜单管理方法

// 实现中心切换时的菜单加载
async function loadCenterMenu(centerCode) {
  const [baseMenu, customMenu] = await Promise.all([
    fetchSystemMenu(centerCode),
    fetchCustomMenu(centerCode)
  ])
  
  return mergeMenus(baseMenu, customMenu, {
    conflictResolver: (sysItem, customItem) => ({
      ...customItem,
      permissions: sysItem.permissions
    })
  })
}

创新点

  1. 可视化层级约束技术
  • 发明双视图拖拽区域分离技术,左侧为菜单源仓库,右侧为配置工作区
  • 提出基于CSS选择器的层级校验方法,通过data-level属性实现拖拽实时验证
  • 开发菜单项DNA标识技术,采用复合型UUID保证跨中心唯一性
  1. 动态状态同步机制
  • 创建操作历史堆栈,支持最多50步操作回滚
  • 采用差异比对算法,实现菜单树复杂度更新
  • 开发状态映射表技术,通过哈希索引快速定位菜单项
  1. 混合式权限适配方案
  • 提出权限元数据注入技术,实现权限规则与菜单呈现解耦
  • 开发运行时权限解析器,支持动态角色切换时的菜单热更新
  • 发明菜单项权限继承机制,子菜单自动继承父级权限设置

具体实施方式

跨中心菜单配置

  1. 用户在中心选择面板点击"门户中心"
  2. 系统加载默认菜单结构和已配置项
  3. 从系统菜单拖拽"安全审计"至工作区
  4. 从子菜单拖拽"操作日志"至"安全审计"下方
  5. 实时生成符合REST规范的菜单配置数据

门户自定义菜单与面包屑动态适配方案

1. 产品需求

在门户(Portal)模式下,用户可通过 拖拽方式 自由组合来自不同业务模块的菜单项,构建个性化的一级/二级菜单结构。具体要求如下:

  • ✅ 支持跨模块拖拽:将任意子模块的二级菜单拖入其他模块作为一级菜单。
  • ✅ 支持一、二级菜单内部排序。
  • ✅ 路由跳转仍使用 Vue Router 的 name 方式(保持原有路由逻辑不变)。
  • ✅ 面包屑(Breadcrumb)需根据用户自定义后的菜单结构动态显示正确的层级名称,而非原始路由配置中的静态名称。

💡 核心目标:菜单可定制,路由不变,面包屑智能适配

2. 问题难点

2.1 路由与菜单解耦

  • Vue Router 的 meta.name 是静态配置,无法反映用户自定义后的菜单归属关系。

  • 用户将资产列表「资产中心 > 资产列表」拖到运维中心下,访问/assetsManage时:

    • 路由匹配仍是 assetsManage
    • 但面包屑应显示为 运维中心 / 资产列表,而非原资产中心 / 资产列表`

2.2 多层嵌套映射复杂

  • 自定义菜单可能形成:

    • 一级菜单 → 原二级菜单
    • 一级菜单 → 原三级菜单(需向上追溯两层)
  • 需建立 keyword → 父级菜单名称 的动态映射关系。

2.3 性能与实时性

  • 拖拽操作频繁,需高效更新映射表。
  • 面包屑需在路由切换时立即生效,不能有延迟。

3. 问题分析

结合提供的代码文件,关键点如下:

3.1 数据来源

  • 自定义菜单数据:来自CustomMenuSet.vue中保存的customMenu结构,包含:

    js{
      name: "自定义名称",
      node: { keyWord: "assetsManage", sysMenuUuid: "xxx" },
      childrenList: [ ... ]
    }
    
  • 原始菜单数据:存储在 Vuex 的 navMenusysPermission.js),结构为树形。

3.2 面包屑生成逻辑(BreadCrumb.vue

  • 当前通过 $route.matched 获取路由层级。

  • 在门户模式(centerMoudel === 'P')下,尝试用magicMap和magicSecondMap修正标题:

    if (sessionStorage.getItem('centerMoudel') === 'P') {
      // 根据当前路由的 name,从 magicMap 查找应显示的父级名称
    }
    

3.3 映射表构建时机

  • getMagicMap() 方法在 created 和事件监听中调用。
  • 它遍历 navMenu(原始菜单),而非用户自定义后的 customMenu

核心缺陷:当前 magicMap 基于系统原始菜单构建,无法反映用户拖拽后的结构。


4. 解决方案

4.1 重构映射表构建逻辑

✅ 修改 getMagicMap 方法(BreadCrumb.vue

不再依赖 this.$store.getters.navMenu,而是监听自定义菜单变更事件,从最新 customMenu 构建映射:

js// BreadCrumb.vue
methods: {
  getMagicMap() {
    const centerMoudel = sessionStorage.getItem('centerMoudel');
    if (centerMoudel === 'P') {
      this.magicMap = {};
      this.magicSecondMap = {};
​
      // 监听自定义菜单更新事件(由 CustomMenuSet.vue 触发)
      this.$eventBus.$on('updateCustomMenu', (customMenu) => {
        this.buildMagicMapFromCustom(customMenu);
      });
​
      // 初始加载
      const savedCustomMenu = JSON.parse(sessionStorage.getItem('customMenu') || '[]');
      if (savedCustomMenu.length > 0) {
        this.buildMagicMapFromCustom(savedCustomMenu);
      }
    }
  },
​
  buildMagicMapFromCustom(customMenu) {
    customMenu.forEach(firstLevel => {
      // 一级菜单下的每个二级项,其父级应显示为 firstLevel.name
      if (firstLevel.childrenList) {
        firstLevel.childrenList.forEach(secondLevel => {
          // secondLevel.node.keyWord → firstLevel.name
          this.magicMap[secondLevel.node.keyWord] = firstLevel.name;
          this.magicSecondMap[secondLevel.node.keyWord] = secondLevel.name;
​
          // 如果二级菜单还有子项(三级),也映射到 firstLevel.name
          if (secondLevel.childrenList) {
            secondLevel.childrenList.forEach(thirdLevel => {
              this.magicMap[thirdLevel.node.keyWord] = secondLevel.name;
            });
          }
        });
      }
​
      // 若一级菜单本身是叶子节点(无 childrenList),也记录自身
      if (!firstLevel.childrenList || firstLevel.childrenList.length === 0) {
        this.magicMap[firstLevel.node.keyWord] = firstLevel.name;
      }
    });
  }
}

4.2 在保存菜单时触发更新(CustomMenuSet.vue

当用户点击「保存并应用」时,通知面包屑组件刷新映射:

js// CustomMenuSet.vue
methods: {
  async saveCustomMenu() {
    // ... 保存逻辑
    await this.saveAxiosMenu(paramArr);
​
    // 👇 关键:保存后广播事件
    sessionStorage.setItem('customMenu', JSON.stringify(this.customMenu));
    this.$eventBus.$emit('updateCustomMenu', this.customMenu);
  }
}

4.3 面包屑名称修正逻辑优化

保留现有逻辑,但确保 magicMap 来源正确:

js// BreadCrumb.vue - getBreadCrumb()
if (sessionStorage.getItem('centerMoudel') === 'P') {
  if (reusltAry.length === 1 && this.magicMap[reusltAry[0].name]) {
    reusltAry[0].title = this.magicMap[reusltAry[0].name];
  }
  if (reusltAry.length > 1 && this.magicMap[reusltAry[1].name]) {
    reusltAry[0].title = this.magicMap[reusltAry[1].name]; // 第一级显示第二级的父名
    if (reusltAry.length === 2 && this.magicSecondMap[reusltAry[1].name]) {
      reusltAry[1].title = this.magicSecondMap[reusltAry[1].name];
    }
    if (reusltAry.length >= 3 && this.magicMap[reusltAry[2].name]) {
      reusltAry[1].title = this.magicMap[reusltAry[2].name]; // 第二级显示第三级的父名
    }
  }
}

4.4 初始化兼容处理

  • 首次进入门户时,若无自定义菜单,则回退到原始 navMenu 构建 magicMap
  • 可通过判断 sessionStorage.getItem('customMenu') 是否存在实现。

5. 方案优势

特性实现效果
动态适配面包屑始终与用户自定义结构一致
低侵入不修改路由配置,仅增强面包屑逻辑
高性能映射表一次性构建,O(1) 查询
实时同步保存菜单后立即生效,无需刷新

6. 注意事项

  1. 事件总线替代方案:若项目已使用 Vuex,建议将 customMenu 存入 store,面包屑通过 mapState 监听。
  2. 关键词唯一性:确保 node.keyWord 全局唯一,避免映射冲突。
  3. 缓存清理:用户登出时清除 sessionStorage.customMenu

通过以上改造,即可实现 “菜单自由定制,面包屑智能跟随” 的产品体验。