前言
在前端开发中,自定义菜单是一个常见的需求,特别是在构建复杂的Web应用或用户界面时。自定义菜单不仅允许开发者根据业务需求灵活调整菜单项和布局,还能提升用户体验和应用的可用性。以下将详细介绍前端如何处理自定义菜单的几个关键步骤。
技术领域
本发明涉及前端交互技术领域,具体涉及一种支持多层级拖拽、动态权限适配和实时状态同步的菜单管理系统及实现方法。
背景技术
现有菜单管理系统存在以下技术瓶颈:
- 菜单层级关系维护困难,容易产生非法嵌套
- 权限控制与菜单呈现强耦合,动态更新效率低
- 批量操作缺乏状态一致性保证机制
- 可视化配置与数据持久化存在断层
- 跨中心菜单配置缺乏统一管理界面
实现思路
注:本功能后端只接受最后保存好的菜单数据,所以所有交互行为、数据存储都需要前端来实现
- 切换中心可以选择不同中心的菜单
- 拖拽区域分为两个区域,左侧和右侧,并且一级菜单区域和二级菜单区域分别写draggle,这样拖拽时存放区域就不会错把一级和二级放在一块,引起数据紊乱。
- 拖拽结束需要更新右侧菜单的状态,被拖拽后需要置灰(通过keyword对比)
- 左侧菜单需要给每一项生成checkbox,用于批量删除,且做到全选、半选逻辑
- 自定义菜单属于一个盒子,所以前端需要初始化模板菜单数据结构
发明内容
系统架构
本系统采用双视图交互架构,包含以下核心组件:
+----------------------+
| 可视化配置界面 |
| (双视图拖拽+状态反馈) |
+-----------+----------+
|
+----------------+ +----------v----------+ +-----------------+
| 中心切换模块 |<-->| 菜单结构引擎 |<-->| 权限适配器 |
| (多中心隔离) | | (层级校验+状态同步) | | (动态权限过滤) |
+----------------+ +----------+----------+ +-----------------+
|
+-----------v-----------+
| 持久化服务模块 |
| (操作日志+版本管理) |
+-----------------------+
技术方案
- 多层级拖拽约束方法
// 实现层级关系校验的拖拽约束
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
})
})
}
创新点
- 可视化层级约束技术
- 发明双视图拖拽区域分离技术,左侧为菜单源仓库,右侧为配置工作区
- 提出基于CSS选择器的层级校验方法,通过
data-level属性实现拖拽实时验证 - 开发菜单项DNA标识技术,采用复合型UUID保证跨中心唯一性
- 动态状态同步机制
- 创建操作历史堆栈,支持最多50步操作回滚
- 采用差异比对算法,实现菜单树复杂度更新
- 开发状态映射表技术,通过哈希索引快速定位菜单项
- 混合式权限适配方案
- 提出权限元数据注入技术,实现权限规则与菜单呈现解耦
- 开发运行时权限解析器,支持动态角色切换时的菜单热更新
- 发明菜单项权限继承机制,子菜单自动继承父级权限设置
具体实施方式
跨中心菜单配置
- 用户在中心选择面板点击"门户中心"
- 系统加载默认菜单结构和已配置项
- 从系统菜单拖拽"安全审计"至工作区
- 从子菜单拖拽"操作日志"至"安全审计"下方
- 实时生成符合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 的 navMenu(
sysPermission.js),结构为树形。
3.2 面包屑生成逻辑(BreadCrumb.vue)
-
当前通过
$route.matched获取路由层级。 -
在门户模式(centerMoudel === 'P')下,尝试用magicMap和magicSecondMap修正标题:
if (sessionStorage.getItem('centerMoudel') === 'P') { // 根据当前路由的 name,从 magicMap 查找应显示的父级名称 }
3.3 映射表构建时机
❗ 核心缺陷:当前
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. 注意事项
- 事件总线替代方案:若项目已使用 Vuex,建议将
customMenu存入 store,面包屑通过mapState监听。 - 关键词唯一性:确保
node.keyWord全局唯一,避免映射冲突。 - 缓存清理:用户登出时清除
sessionStorage.customMenu。
通过以上改造,即可实现 “菜单自由定制,面包屑智能跟随” 的产品体验。