今天分享一个基于Vue3和Element Plus的动态菜单实现。这个方案很适用于需要权限管理的后台系统,能够根据用户角色权限显示不同的菜单项。
一、什么是动态菜单?为什么需要它?
在管理后台系统中,不同角色的用户通常需要不同的功能权限。比如:
- 管理员可以访问所有功能
- 编辑者只能管理内容
- 查看者只能浏览数据
如果为每个角色单独开发一套界面,显然效率低下。动态菜单就是解决这个问题的方案——一套代码,根据不同用户角色显示不同的菜单结构。
二、实现效果预览
我们先来看看最终实现的效果:
- 角色切换:右上角可以切换用户角色(管理员/编辑者/查看者)
- 菜单过滤:根据角色自动过滤无权限的菜单项
- 侧边栏折叠:支持展开/收起侧边栏
- 面包屑导航:显示当前页面位置
老样子,完整源码在文末获取哦~
三、核心实现原理
1. 菜单数据结构设计
合理的菜单数据结构是动态菜单的基础。我们的设计如下:
1const menuData = ref([ 2 { 3 id: 'dashboard', // 唯一标识 4 name: '仪表板', // 显示名称 5 icon: 'DataBoard', // 图标 6 route: '/dashboard', // 路由路径 7 roles: ['admin', 'editor', 'viewer'] // 可访问的角色 8 }, 9 { 10 id: 'content', 11 name: '内容管理', 12 icon: 'Document', 13 roles: ['admin', 'editor'], 14 children: [ // 子菜单 15 { 16 id: 'articles', 17 name: '文章管理', 18 route: '/articles', 19 roles: ['admin', 'editor'] 20 } 21 // ... 更多子菜单 22 ] 23 } 24 // ... 更多菜单项 25]); 26
这种结构的特点:
- 支持多级嵌套菜单
- 每个菜单项明确指定可访问的角色
- 图标使用 Element Plus 的图标组件
2. 菜单过滤逻辑
核心功能是根据当前用户角色过滤菜单:
1const filteredMenu = computed(() => { 2 return menuData.value 3 .map(item => { 4 // 1. 检查主菜单权限 5 if (!item.roles.includes(currentUser.value.role)) { 6 return null; // 无权限,过滤掉 7 } 8 9 // 2. 深拷贝菜单项(避免修改原始数据) 10 const menuItem = { ...item }; 11 12 // 3. 如果有子菜单,过滤子菜单 13 if (menuItem.children) { 14 menuItem.children = menuItem.children.filter( 15 child => child.roles.includes(currentUser.value.role) 16 ); 17 18 // 如果子菜单全被过滤掉,主菜单也不显示 19 if (menuItem.children.length === 0) { 20 return null; 21 } 22 } 23 24 return menuItem; 25 }) 26 .filter(Boolean); // 过滤掉null值 27}); 28
过滤过程详解:
- 映射(map):遍历每个菜单项,返回处理后的菜单项或null
- 权限检查:检查当前用户角色是否在菜单项的角色列表中
- 子菜单过滤:对有子菜单的项,递归过滤无权限的子项
- 空子菜单处理:如果所有子项都被过滤,父项也不显示
- 最终过滤:用filter(Boolean)移除所有null值
计算属性(computed)的优势:
- 自动响应依赖变化(当用户角色变化时自动重新计算)
- 缓存结果,避免重复计算
3. 用户角色管理
用户信息和角色切换的实现:
1// 当前用户信息 2const currentUser = ref({ 3 name: '管理员', 4 role: 'admin', 5 avatar: 'https://example.com/avatar.png' 6}); 7 8// 处理角色切换 9const handleRoleChange = (role) => { 10 currentUser.value.role = role; 11 12 // 角色切换后更新当前激活的菜单 13 if (role === 'viewer') { 14 // 查看者只能访问仪表板 15 activeMenu.value = '/dashboard'; 16 currentPageTitle.value = '仪表板'; 17 } else { 18 // 其他角色显示第一个可访问的菜单 19 const firstMenu = findFirstAccessibleMenu(); 20 if (firstMenu) { 21 activeMenu.value = firstMenu.route; 22 currentPageTitle.value = firstMenu.name; 23 } 24 } 25}; 26
四、界面布局与组件使用
1. 整体布局结构
1<div class="app-container"> 2 <!-- 侧边栏 --> 3 <div class="sidebar" :class="{ collapsed: isCollapse }"> 4 <!-- Logo区域 --> 5 <div class="logo-area">...</div> 6 <!-- 菜单区域 --> 7 <el-menu>...</el-menu> 8 </div> 9 10 <!-- 主内容区 --> 11 <div class="main-content"> 12 <!-- 顶部导航 --> 13 <div class="header">...</div> 14 <!-- 页面内容 --> 15 <div class="content">...</div> 16 <!-- 页脚 --> 17 <div class="footer">...</div> 18 </div> 19</div> 20
这种布局是管理后台的经典设计,具有清晰的视觉层次。
2. Element Plus 菜单组件使用
1<el-menu 2 :default-active="activeMenu" <!-- 当前激活的菜单 --> 3 class="el-menu-vertical" 4 background-color="#001529" <!-- 背景色 --> 5 text-color="#bfcbd9" <!-- 文字颜色 --> 6 active-text-color="#409EFF" <!-- 激活项文字颜色 --> 7 :collapse="isCollapse" <!-- 是否折叠 --> 8 :collapse-transition="false" <!-- 关闭折叠动画 --> 9 :unique-opened="true" <!-- 只保持一个子菜单展开 --> 10> 11 <!-- 菜单项渲染 --> 12 <template v-for="item in filteredMenu" :key="item.id"> 13 <!-- 有子菜单的情况 --> 14 <el-sub-menu v-if="item.children" :index="item.id"> 15 <!-- 标题区域 --> 16 <template #title> 17 <el-icon><component :is="item.icon" /></el-icon> 18 <span>{{ item.name }}</span> 19 </template> 20 21 <!-- 子菜单项 --> 22 <el-menu-item v-for="child in item.children" 23 :key="child.id" 24 :index="child.route" 25 @click="selectMenu(child)"> 26 {{ child.name }} 27 </el-menu-item> 28 </el-sub-menu> 29 30 <!-- 没有子菜单的情况 --> 31 <el-menu-item v-else :index="item.route" @click="selectMenu(item)"> 32 ... 33 </el-menu-item> 34 </template> 35</el-menu> 36
关键点说明:
- 动态组件:
<component :is="item.icon">实现动态图标渲染 - 条件渲染:使用
v-if和v-else区分子菜单和单菜单项 - 循环渲染:
v-for遍历过滤后的菜单数据 - 唯一key:为每个菜单项设置唯一的
:key="item.id"提高性能
五、样式设计技巧
1. 侧边栏折叠动画
1.sidebar { 2 width: 240px; 3 background-color: #001529; 4 transition: width 0.3s; /* 宽度变化动画 */ 5 overflow: hidden; 6} 7 8.sidebar.collapsed { 9 width: 64px; 10} 11 12.logo-area .logo-text { 13 margin-left: 10px; 14 transition: opacity 0.3s; /* 文字淡入淡出 */ 15} 16 17.sidebar.collapsed .logo-text { 18 opacity: 0; /* 折叠时隐藏文字 */ 19} 20
2. 布局技巧
1.app-container { 2 display: flex; 3 min-height: 100vh; /* 全屏高度 */ 4} 5 6.main-content { 7 flex: 1; /* 占据剩余空间 */ 8 display: flex; 9 flex-direction: column; 10 overflow: hidden; /* 防止内容溢出 */ 11} 12 13.content { 14 flex: 1; /* 内容区占据主要空间 */ 15 padding: 20px; 16 overflow-y: auto; /* 内容过多时滚动 */ 17} 18
使用 Flex 布局可以轻松实现经典的侧边栏+主内容区布局。
六、实际应用扩展建议
在实际项目中,你还可以进一步扩展这个基础实现:
1. 与路由集成
1import { useRouter, useRoute } from 'vue-router'; 2 3const router = useRouter(); 4const route = useRoute(); 5 6// 菜单点击处理 7const selectMenu = (item) => { 8 // 路由跳转 9 router.push(item.route); 10}; 11 12// 根据当前路由设置激活菜单 13watch(route, (newRoute) => { 14 activeMenu.value = newRoute.path; 15 // 根据路由查找对应的页面标题 16 currentPageTitle.value = findTitleByRoute(newRoute.path); 17}); 18
2. 后端动态菜单
在实际项目中,菜单数据通常来自后端:
1// 从API获取菜单数据 2const fetchMenuData = async () => { 3 try { 4 const response = await axios.get('/api/menus', { 5 params: { role: currentUser.value.role } 6 }); 7 menuData.value = response.data; 8 } catch (error) { 9 console.error('获取菜单数据失败:', error); 10 } 11}; 12
3. 权限控制增强
除了菜单过滤,还可以添加更细粒度的权限控制:
1// 权限指令 2app.directive('permission', { 3 mounted(el, binding) { 4 const { value: requiredRoles } = binding; 5 const userRole = currentUser.value.role; 6 7 if (!requiredRoles.includes(userRole)) { 8 el.parentNode && el.parentNode.removeChild(el); 9 } 10 } 11}); 12 13// 在模板中使用 14<button v-permission="['admin', 'editor']">编辑内容</button> 15
总结
通过这个 Vue 3 + Element Plus 的动态菜单实现,我们学到了:
- 设计合理的菜单数据结构是动态菜单的基础
- 使用计算属性实现菜单过滤,自动响应角色变化
- 利用 Element Plus 组件快速构建美观的界面
- Flex 布局技巧实现响应式侧边栏
- 扩展思路,如路由集成、后端动态菜单等
这个实现方案具有很好的可扩展性,你可以根据实际需求进行调整和增强。
完整源码GitHub地址:github.com/1344160559-…
你可以直接复制到HTML文件中运行体验。尝试切换不同的用户角色,观察菜单的变化,加深对动态菜单工作原理的理解。
本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!
📌往期精彩
《SpringBoot+MySQL+Vue实现文件共享系统》
《SpringBoot 动态菜单权限系统设计的企业级解决方案》
《Vue3和Vue2的核心区别?很多开发者都没完全搞懂的10个细节》
