Vue3动态路由与菜单权限结合实现方案
需求概述
在Vue3项目中实现动态菜单功能,需要结合本地预定义的路由配置与后端返回的权限数据。主要场景包括:
- 根据用户登录后获取的菜单数据进行动态渲染
- 公共路由(如登录页、404页面)无需权限控制
- 业务路由需要根据后端返回的菜单标识进行动态匹配和组装
实现思路
业界常见的动态菜单实现方案主要有以下几种:
方案一:全动态加载
后端返回完整菜单数据,前端直接添加到路由对象中。这种方式后端耦合度高,前端逻辑相对简单。
方案二:本地预定义 + 标识匹配
在router.js中预先定义所有路由,通过唯一标识(如menuCode)与后端返回的菜单数据进行匹配处理。
方案三:混合模式
部分公共路由预先定义,业务相关的父级目录固定,子级菜单根据接口数据动态生成。
方案四:基于角色的权限过滤
在路由的meta属性中定义roles权限数组,登录时获取用户权限信息,通过比对筛选出可访问的路由。
方案五:自定义指令控制
完整的路由信息预先配置,登录时获取权限信息,渲染时通过自定义指令控制页面元素显示。
数据模型设计
后端返回的菜单数据结构中,父级菜单固定展示,子级菜单通过接口动态获取:
{
name: "ModelCenter",
path: "/model",
hidden: false,
component: Layout,
alwaysShow: true,
permissionCode: "biz_1000",
meta: {
title: "模型中心",
icon: "fa fa-folder",
noCache: false,
link: "model"
},
children: [
{
name: "ModelList",
path: ":category",
component: () => import('@/views/models/list/index'),
meta: {
title: "模型列表",
modelType: 'own',
importComponent: true,
},
}
]
}
路由配置文件
import {
createWebHistory,
createRouter,
createWebHashHistory
} from 'vue-router'
import Layout from '@/layout'
// 静态公共路由
export const constantRoutes = [{
path: '/redirect',
component: Layout,
hidden: true,
children: [{
path: '/redirect/:path(.*)',
component: () => import('@/views/redirect/index.vue')
}]
},
{
path: '/modelindex',
component: () => import('@/views/models/index'),
hidden: false
},
{
path: '/resource',
component: () => import('@/views/resource/index'),
hidden: true,
meta: {
title: '资源中心',
}
},
{
path: '/login',
component: () => import('@/views/login'),
hidden: true
},
{
path: "/:pathMatch(.*)*",
component: () => import('@/views/error/404'),
hidden: true
},
{
path: '/401',
component: () => import('@/views/error/401'),
hidden: true
},
{
path: '',
redirect: '/home/index',
},
{
path: '/application',
component: () => import('@/views/application/index'),
hidden: true,
meta: {
title: '模型申请',
}
},
{
path: '/analysis',
hidden: true,
component: () => import('@/views/analysis/index'),
meta: {
title: '模型分析',
}
},
];
/**
* 本地路由映射表(获取接口路由后补充component信息)
*/
export const localRouteMap = [
{
name: "Dashboard",
path: "/home",
hidden: true,
redirect: "index",
component: Layout,
children: [{
name: "DashboardIndex",
path: "index",
hidden: true,
component: () => import('@/views/home/index'),
meta: {
title: "控制台",
importComponent: true,
noCache: false,
link: "model"
},
}]
},
{
name: "ModelCenter",
path: "/model",
hidden: false,
component: Layout,
alwaysShow: true,
permissionCode: "biz_1000",
meta: {
title: "模型中心",
icon: "fa fa-folder",
noCache: false,
link: "model"
},
children: [
{
name: "ModelList",
path: ":category",
component: () => import('@/views/models/list/index'),
meta: {
title: "模型列表",
modelType: 'own',
importComponent: true,
},
}
]
}, {
name: "ApplyCenter",
path: "/apply",
hidden: false,
component: Layout,
alwaysShow: true,
permissionCode: "biz_2000",
meta: {
title: "申请记录",
icon: "fa fa-file-text",
noCache: false,
link: "apply"
},
children: [
{
name: "ApplyList",
path: ":category",
component: () => import('@/views/models/list/index'),
meta: {
title: "申请列表",
modelType: 'applied',
importComponent: true,
},
},
]
},
{
permissionCode: "biz_1001",
component: () => import('@/views/models/create/index'),
title: "创建模型",
},
{
component: Layout,
permissionCode: "biz_3000",
title: "审批管理",
},
{
permissionCode: "biz_3002",
component: () => import('@/views/audit/task/index'),
title: "任务审批",
},
{
permissionCode: "biz_3001",
component: () => import('@/views/audit/comments/index'),
title: "评论审核",
},
{
component: Layout,
permissionCode: "biz_4000",
title: "系统设置",
},
{
permissionCode: "biz_4001",
component: () => import('@/views/system/category/index'),
title: "分类管理",
},
{
permissionCode: "biz_4002",
component: () => import('@/views/system/user/index'),
title: "用户管理",
},
{
permissionCode: "biz_4003",
component: () => import('@/views/system/role/index'),
title: "角色管理",
},
{
permissionCode: "biz_4004",
component: () => import('@/views/system/message/index'),
title: "消息管理",
},
{
permissionCode: "biz_4005",
component: () => import('@/views/system/operlog/index'),
title: "日志记录",
},
]
const router = createRouter({
history: createWebHashHistory(),
routes: constantRoutes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return {
top: 0
}
}
},
});
export default router;
Vuex中的路由生成逻辑
import {getPermissionCodes,mergeRouteConfig,filterAccessibleRoutes } from '@/utils/routeHelper'
//生成动态路由
GenerateRoutes({
commit
}) {
return new Promise((resolve) => {
// 从后端获取菜单权限数据
fetchServerRoutes().then(response => {
let serverMenuData = response.data
let permissionCodes = getPermissionCodes(serverMenuData, [])
commit("SET_MENU_PERMISSIONS", serverMenuData);
let serverCodeList = permissionCodes;
let finalRoutes = [];
let serverMenuArray = serverMenuData;
// 合并后端菜单与本地路由配置
finalRoutes = mergeRouteConfig(serverMenuArray, localRouteMap)
// 根据权限码过滤可用路由
finalRoutes = filterAccessibleRoutes(finalRoutes, serverCodeList)
// 合并公共静态路由
finalRoutes = finalRoutes.concat(constantRoutes)
commit('SET_ROUTES', finalRoutes)
// 初始化侧边栏,处理动态子菜单
initMenuRoutes(finalRoutes, commit)
resolve(finalRoutes);
})
})
}
/**
* 初始化侧边栏菜单
*/
const initMenuRoutes = async (routes, commit) => {
let routeChain = _lodash.cloneDeep(routes);
let {
applyCategoryList,
applyTotal,
modelCategoryList,
modelTotal
} = await fetchModelCategories();
// 将分类数据转换为路由格式
let myModelRoute = (modelCategoryList || []).map(item => ({
path: `${item.categoryId}`,
meta: {
title: item.categoryName
},
}))
let applyRoute = (applyCategoryList || []).map(item => ({
path: `${item.categoryId}`,
meta: {
title: item.categoryName
},
}))
// 查找对应菜单的索引位置
let applyIndex = _lodash.findIndex(routeChain, {
name: 'ApplyCenter'
})
let modelIndex = _lodash.findIndex(routeChain, {
name: 'ModelCenter'
})
// 根据接口数据动态更新菜单
if (applyIndex > -1) {
routeChain[applyIndex].alwaysShow = true
routeChain[applyIndex].meta.title = `${routeChain[applyIndex].meta.title}(${applyTotal})`
routeChain[applyIndex].children = applyRoute
routeChain[applyIndex].hidden = !routeChain[applyIndex].children.length
}
if (modelIndex > -1) {
routeChain[modelIndex].meta.title = `${routeChain[modelIndex].meta.title}(${modelTotal})`
routeChain[modelIndex].children = [routeChain[modelIndex].children[0],...myModelRoute]
}
// 保存分类数据到vuex
commit('SET_MY_MODEL_CATEGORY', {
applyCategoryList,
applyTotal,
modelCategoryList,
modelTotal
})
// 更新侧边栏路由
commit('SET_SIDEBAR_ROUTERS', routeChain)
}
路由守卫配置
store.dispatch('GetUserInfo').then(() => {
store.dispatch('GenerateRoutes').then(accessibleRoutes => {
// 动态添加有权限访问的路由
accessibleRoutes.forEach(route => {
router.addRoute(route)
})
next({
...to,
replace: true
})
})
})
工具函数封装
/**
* 提取后端菜单权限码列表
* @param {Array} menuList 菜单数据列表
* @param {Array} result 结果数组
* @return {Array} 权限码数组
*/
export function getPermissionCodes(menuList, result = []) {
menuList.forEach(item => {
const code = item.permissionCode;
if (item.subMenuList) {
getPermissionCodes(item.subMenuList, result)
}
result.push(code)
})
return result;
}
/**
* 权限校验函数
* @param {Array} codeList 权限码列表
* @param {Object} route 路由对象
*/
function validatePermission(codeList, route) {
if (route.meta && route.meta.permissionCode) {
return codeList.includes(route.meta.permissionCode)
} else {
return true
}
}
/**
* 根据权限过滤路由
* @param {Array} routes 路由数组
* @param {Array} codeList 权限码列表
*/
export function filterAccessibleRoutes(routes, codeList) {
const result = []
routes.forEach(route => {
const temp = {
...route
}
if (validatePermission(codeList, temp)) {
if (temp.children) {
temp.children = filterAccessibleRoutes(temp.children, codeList)
}
result.push(temp)
}
})
return result;
}
/**
* 合并后端菜单与本地路由配置
* @param {Array} remoteList 后端返回的菜单结构
* @param {Array} localMap 本地路由映射数据
*/
export function mergeRouteConfig(remoteList, localMap) {
let result = []
remoteList.forEach(item => {
let localMatch = localMap.find(z => z.permissionCode === item.permissionCode)
let temp = {
children: item.subMenuList,
...item
}
if (temp.children && temp.children.length) {
temp.children = mergeRouteConfig(temp.children, localMap)
}
result.push({
name: localMatch ? localMatch.name : "",
path: item.menuPath,
component: localMatch ? localMatch.component : null,
hidden: item.hidden || false,
meta: {
title: item.menuName,
permissionCode: item.permissionCode,
},
children: (temp.children || []).concat(localMatch && localMatch.children ? localMatch.children : [])
})
})
return result
}
常见问题与解决方案
问题一:方法名称变化
Vue3移除了addRoutes方法,仅保留addRoute进行单条路由添加。
问题二:子路由路径格式
后端返回的路径中,父级path格式为/xxx,子级为yyy(无前导斜杠)。需注意后端返回的子菜单字段名是否为children。
问题三:父级菜单显示问题
当父级菜单下只有一个子菜单时,需要设置alwaysShow: true确保父级菜单正常显示。
问题四:路由name冲突
如果需要自定义部分父级和子级路由,需确保name属性唯一,否则会导致路由匹配错误。