一、序言
声明:本博客涉及到的前台Vue项目是基于GitHub花裤衩大神的开源项目vue-admin-template进行拓展开发的
在 使用Gateway网关实现用户认证与鉴权这一篇博客中,我介绍了基于Gateway实现的基本用户认证与鉴权,可以将对用户权限的控制精细到API级别,但在前台页面的展示中,我们也需要根据用户的角色权限决定为用户展示哪部分特定内容,例如侧边栏菜单项。
在非前后台分离项目中,使用模板引擎的强化标签即可实现该功能,例如Themeleaf中使用sec:authorize=”hasAnyAuthority(‘admin’)”,搭配后台security提供的UserDeatil对象,即可实现将被标记的代码块呈现给具有admin角色的用户
在前台使用Html呈现页面的前后台分离的系统中,采用”后台查询可访问菜单集合,传到前台使用js构建DOM节点”的方式也可以实现动态菜单,但在Vue中,侧边栏往往是通过路由表来构建的,那么应该如何通过配置路由表的方式实现该功能呢?
二、需求
在Vue中通过对路由表的配置实现动态菜单,用户登陆进入主界面后只能看到已分配给该”用户具备的角色”的菜单项
三、实现思路
流程图如下:
1.建立”菜单表”和”角色-菜单中间表”
2.用户登录后获取可访问的菜单集合 MenuList
3.单独维护一份静态路由表 RouterMap(Key:菜单名,Value:路由信息)
注:实际上一二三级菜单是嵌套关系,不过在静态路由表(Map)中并不呈现父子关系,该Map的做用只是”狸猫换太子”中的”太子储备”,真正的父子关系体现在”狸猫”群中
// 静态路由表 export const asyncRoutes = { /* ==================================资产管理=================================== */ // 一级菜单 资产管理 'assets': { path: '/assets', component: Layout, name: 'assets', meta: { title: '资产管理', icon: 'nested' } }, // 二级菜单 资产类型 'assetsType': { path: '/type', component: () => import('@/views/assets/type/index'), name: 'assetsType', meta: { title: '资产类型' } }, // 三级菜单 新增资产类型 'assetsTypeEdit': { path: '/type-edit', component: () => import('@/views/assets/type/edit'), name: 'assetsTypeEdit', meta: { title: '新增资产类型' } } }
4.对菜单集合进行处理,根据元素间的父子关系重构为树形结构 MenuTree
// 先把菜单列表转为树形结构 menus.forEach(menu => { const menuPid = menu.menuPid if (menuPid !== 0) { menus.forEach(Menu => { if (Menu.menuId === menuPid) { if (!Menu.children) { Menu.children = [] } Menu.children.push(menu) } }) } }) // 只保留一级菜单 menus = menus.filter(menu => menu.menuSort === 1)
*5.最关键的一步,根据特定字段将MenuTree与RouterMap中元素进行匹配调换,我称其为”狸猫换太子”
menusToRoutes({ commit }, menus) { const result = [] let children = [] // 解析menu树,构造动态菜单 menus.forEach(menu => { children = generateRoutes(children, menu) }) children.forEach(menu => { result.push(menu) }) // 最后添加404页面 否则会在登陆成功后跳到404页面 result.push( { path: '*', redirect: '/404', hidden: true } ) } // 向菜单树中添加节点 function generateRoutes(children, item) { if (item.children) { // 先把该节点放入children const parentMenu = asyncRoutes[item.menuCodeName] children.push(parentMenu) // 如果当前父节点没有children的话则创建一个 if (!parentMenu.childrens) { parentMenu.children = [] } // 既然进了下一层循环,要操作的数组自然是下一层children item.children.forEach(e => { generateRoutes(parentMenu.children, e) }) // 为叶子节点时才去静态路由表里找 } else if (item.menuCodeName) { children.push(asyncRoutes[item.menuCodeName]) } return children }
6.最后让Vue侧边栏组件执行渲染,完成侧边栏的初始化
四、完整代码
1.目录结构
2.前台请求拦截(src/permission.js)
import router from './router' import store from './store' import { Message } from 'element-ui' import NProgress from 'nprogress' // progress bar import 'nprogress/nprogress.css' // progress bar style import { getToken } from '@/utils/auth' // get token from cookie import getPageTitle from '@/utils/get-page-title' NProgress.configure({ showSpinner: false }) // NProgress Configuration const whiteList = ['/login'] // no redirect whitelist // 前端拦截器,用于执行登陆校验 router.beforeEach(async (to, from, next) => { // start progress bar NProgress.start() // 先把要跳转的页面的title加载出来 document.title = getPageTitle(to.meta.title) // 查看是否存在token const hasToken = getToken() if (hasToken) { console.log('hasToken === true!') if (to.path === '/login') { // 如果token存在且要跳转的路径为login,则直接送进主页面dashboard next({ path: '/' }) NProgress.done() } else { // 当存在token但访问的不是login页面,则查看用户名 const userName = store.getters.name if (userName) { // 用户名存在则直接进入 next() } else { try { // 没有用户名则尝试通过token获取用户信息 const { menus } = await store.dispatch('user/getInfo') const accessRoutes = await store.dispatch('permission/menusToRoutes', menus) router.addRoutes(accessRoutes) next({ ...to, replace: true }) } catch (error) { // 如果没有获取到用户信息,则提示重新登陆 await store.dispatch('user/resetToken') Message.error(error || '未能获取到用户信息,请重新登陆!') next(`/login?redirect=${to.path}`) NProgress.done() } } } } else { console.log('hasToken === false!') // 但如果直接没能获取到token,则无限返回上一层,即无限停留在login页面 if (whiteList.indexOf(to.path) !== -1) { next() } else { // other pages that do not have permission to access are redirected to the login page. next(`/login?redirect=${to.path}`) NProgress.done() } } }) router.afterEach(() => { // finish progress bar NProgress.done() })
核心代码在这里,当重新获取用户信息时,执行store下的permission/menuToRoutes方法,将查询得到的MenuList转为动态路由树
3.处理菜单列表(src/store/modules/permission.js)
import { asyncRoutes, constantRoutes } from '@/router' const state = { routes: [], addRoutes: [] } const mutations = { SET_ROUTES: (state, routes) => { state.addRoutes = routes state.routes = constantRoutes.concat(routes) } } // 将菜单信息转成对应的路由信息 动态添加 const actions = { menusToRoutes({ commit }, menus) { return new Promise(resolve => { const result = [] let children = [] /** * 方案一: * 1.先把列表转为树形结构 * 2.遍历该树形结构,根据menuCodeName映射生成另一棵由静态路由表中元素构成的树 */ // 先把菜单列表转为树形结构 menus.forEach(menu => { const menuPid = menu.menuPid if (menuPid !== 0) { menus.forEach(Menu => { if (Menu.menuId === menuPid) { if (!Menu.children) { Menu.children = [] } Menu.children.push(menu) } }) } }) // 只保留一级菜单 menus = menus.filter(menu => menu.menuSort === 1) // 解析menu树,构造动态菜单 menus.forEach(menu => { children = generateRoutes(children, menu) }) children.forEach(menu => { result.push(menu) }) commit('SET_ROUTES', result) resolve(result) }) } } // 向菜单树中添加节点 function generateRoutes(children, item) { if (item.children) { // 先把该节点放入children const parentMenu = asyncRoutes[item.menuCodeName] children.push(parentMenu) // 如果当前父节点没有children的话则创建一个 if (!parentMenu.childrens) { parentMenu.children = [] } // 既然进了下一层循环,要操作的数组自然是下一层children item.children.forEach(e => { generateRoutes(parentMenu.children, e) }) // 为叶子节点时才去静态路由表里找 } else if (item.menuCodeName) { children.push(asyncRoutes[item.menuCodeName]) } return children } export default { namespaced: true, state, mutations, actions }
4.路由表
笔者的路由表分为了两部分,一部分为基本路由表(不存入数据库),一部分为静态路由表(存入数据库并接受分配),基本路由表的存在一是便于开发测试,二是有一些特定的路由本身就没有执行分配的必要,例如404,500自定义异常页面
import Vue from 'vue' import Router from 'vue-router' Vue.use(Router) /* Layout */ import Layout from '@/layout' /** * Note: sub-menu only appear when route children.length >= 1 * Detail see: https://panjiachen.github.io/vue-element-admin-site/guide/essentials/router-and-nav.html * * hidden: true if set true, item will not show in the sidebar(default is false) * alwaysShow: true if set true, will always show the root menu * if not set alwaysShow, when item has more than one children route, * it will becomes nested mode, otherwise not show the root menu * redirect: noRedirect if set noRedirect will no redirect in the breadcrumb * name:'router-name' the name is used by <keep-alive> (must set!!!) * meta : { roles: ['admin','editor'] control the page roles (you can set multiple roles) title: 'title' the name show in sidebar and breadcrumb (recommend set) icon: 'svg-name'/'el-icon-x' the icon show in the sidebar breadcrumb: false if set false, the item will hidden in breadcrumb(default is true) activeMenu: '/example/list' if set path, the sidebar will highlight the path you set } */ /** * constantRoutes * a base page that does not have permission requirements * all roles can be accessed */ // 基本路由表 export const constantRoutes = [ { path: '/login', component: () => import('@/views/login/index'), hidden: true }, /* { path: '/404', component: () => import('@/views/404'), hidden: true }, */ { path: '/', component: Layout, redirect: '/dashboard', children: [{ path: 'dashboard', name: 'Dashboard', component: () => import('@/views/dashboard/index'), meta: { title: '仪表盘', icon: 'dashboard' } }] }, /* 展示页面开始 */ { hidden: true, path: '/essential', component: Layout, redirect: '/essential', children: [{ path: 'essential', name: 'essential', component: () => import('@/views/essential/index'), meta: { title: '展示', icon: 'dashboard' } }] }, /* 展示页面关闭 */ /* 工单管理 开始 */ { path: '/workorder', component: Layout, redirect: '/nested/menu1', meta: { title: '工单管理', icon: 'clipboard' }, children: [{ path: 'workorder-edit', name: 'workorder-edit', component: () => import('@/views/workorder/edit/index'), meta: { title: '编辑工单' } }, { path: 'workorder-list', name: 'workorder-list', component: () => import('@/views/workorder/list/index'), meta: { title: '工单列表' } }, { path: 'workorder-type', name: 'workorder-type', component: () => import('@/views/workorder/type'), meta: { title: '工单类型管理' }, children: [{ path: 'table', name: 'workorder-type-edit', component: () => import('@/views/workorder/type/edit'), meta: { title: '编辑工单类型' } }, { path: 'tree', name: 'workorder-type-list', component: () => import('@/views/workorder/type/list'), meta: { title: '工单类型列表' } } ] } ] }, /* 工单管理 结束 */ // 404 page must be placed at the end !!! /* { path: '*', redirect: '/404', hidden: true } */ ] // 静态路由表 export const asyncRoutes = { /* ==================================资产管理=================================== */ // 一级菜单 资产管理 'assets': { path: '/assets', component: Layout, name: 'assets', meta: { title: '资产管理', icon: 'nested' } }, // 二级菜单 资产类型 'assetsType': { path: '/type', component: () => import('@/views/assets/type/index'), name: 'assetsType', meta: { title: '资产类型' } }, // 三级菜单 新增资产类型 'assetsTypeEdit': { path: '/type-edit', component: () => import('@/views/assets/type/edit'), name: 'assetsTypeEdit', meta: { title: '新增资产类型' } }, // 三级菜单 资产类型列表 'assetsTypeList': { path: '/type-list', component: () => import('@/views/assets/type/list'), name: 'assetsTypeList', meta: { title: '资产类型列表' } }, // 二级菜单 硬件资产 'hardware': { path: '/hardware', component: () => import('@/views/assets/hardware/index'), name: 'hardware', meta: { title: '硬件资产' } }, // 三级菜单 新增硬件资产 'hardwareEdit': { path: '/hardware-edit', component: () => import('@/views/assets/hardware/edit'), name: 'hardwareEdit', meta: { title: '新增硬件资产' } }, // 三级菜单 硬件资产列表 'hardwareList': { path: '/hardware-list', component: () => import('@/views/assets/hardware/list'), name: 'hardwareList', meta: { title: '全部硬件资产' } }, // 三级菜单 IPMI硬件设备(服务器)列表 'ipmiHardwareList': { path: '/ipmi-hardware-list', component: () => import('@/views/assets/hardware/ipmilist'), name: 'ipmiHardwareList', meta: { title: 'IPMI设备列表' } }, // 二级菜单 软件资产 'software': { path: '/software', component: () => import('@/views/assets/software/index'), name: 'software', meta: { title: '软件资产' } }, // 三级菜单 新增软件资产 'softwareEdit': { path: '/software-edit', component: () => import('@/views/assets/software/edit'), name: 'softwareEdit', meta: { title: '新增软件资产' } }, // 三级菜单 软件资产列表 'softwareList': { path: '/software-list', component: () => import('@/views/assets/software/list'), name: 'softwareList', meta: { title: '软件资产列表' } }, // 二级菜单 资产变更记录 'changeRecord': { path: '/changeRecord-list', component: () => import('@/views/assets/changerecord/index'), name: 'changeRecord', meta: { title: '资产变更记录' } } } const createRouter = () => new Router({ // mode: 'history', // require service support scrollBehavior: () => ({ y: 0 }), routes: constantRoutes }) const router = createRouter() // Detail see: https://github.com/vuejs/vue-router/issues/1234#issuecomment-357941465 // 重新设置路由 export function resetRouter() { const newRouter = createRouter() router.matcher = newRouter.matcher // reset router } export default router