当前位置:首页 > 技术 > 正文内容

Vue3动态路由与菜单权限结合实现方案

访客 技术 2026年6月18日 1

需求概述

在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属性唯一,否则会导致路由匹配错误。

返回列表

上一篇:ROS2服务通信机制详解与实践

没有最新的文章了...

相关文章

Linux crontab 详解

1) crontab 是什么cron 是 Linux 的定时任务守护进程;crontab 是用来编辑/查看“按时间周期执行命令”的表(cron table)。常见两类:用户 crontab:每个用户一份(crontab -e 编辑)系统级 crontab / cron.d:可指定执行用户(/etc/crontab、/etc/cron.d/*)2) crontab 时间...

富文本里可以允许的 HTML 属性

一、所有标签默认允许的安全属性(极少)class        (可选)id           (通常建议禁用)title️ 注意:id 容易被滥用做锚点注入,很多系统直接禁用class 允许的话最好只允许固定前缀(如 editor-*)二、a 标签允许属性<a href="" t...

Mac 安装 Node.js 指南

方法一:通过官网安装包(最简单,适合初学者)如果你只是想快速安装并开始使用,这是最直接的方法。访问 Node.js 官网。页面会显示两个版本:LTS (Recommended For Most Users):长期支持版,最稳定。建议选这个。Current:最新特性版,包含最新功能但可能不够稳定。下载 .pkg 安装包并运行。按照安装向导点击“下一步”即可完成。方法二:使用 Homebrew 安装(...

Dom\HTML_NO_DEFAULT_NS 的副作用:自动加闭合标签

在使用Dom\HTMLDocument时,Dom\HTML_NO_DEFAULT_NS 将禁止在解析过程中设置元素的命名空间, 此设置是为了与DOMDocument向后兼容而存在的。当使用它时,已知的一个副作用就是:自动加闭合标签例如 </img> 为什么会这样?当你使用:Dom\HTML_NO_DEFAULT_NS文档会变成 无命名空间模式,此时内部更接近 XML...

Laravel 事件和监听器创建

在 Laravel 中,使用 Artisan 命令创建 Events(事件) 和 Listeners(监听器) 是非常高效的。你可以通过以下几种方式来实现:1. 手动创建单个 Event如果你只想创建一个事件类,可以使用 make:event 命令:Bashphp artisan make:event UserRegistered执行后,文件将生成在 app/Even...

自定义域名解析神器 dnsmasq

什么是 dnsmasq?dnsmasq 是一个轻量级、功能强大的网络服务工具,专为小型和中等规模网络设计。它是一个综合的网络基础设施解决方案[1]。dnsmasq 能做什么?功能说明应用场景DNS 转发与缓存将 DNS 查询转发到上游服务器(ISP、Google DNS 等),并在本地缓存结果加快 DNS 查询速度,减少外部 DNS 流量本地 DNS解析本地网络设备的主机名,无需编辑&n...

发表评论

访客

◎欢迎参与讨论,请在这里发表您的看法和观点。