Vue从零开始(4):动态路由菜单+权限控制
前言
上一篇讲到了如何通过路由创建页面以及Mock的使用,然而在实际项目中,大部分中后台前端项目对于菜单都有一个权限控制,像某某角色只能看到这些菜单页面,某某角色只能看到另一些菜单页面,还有一些角色他们能看到同一个页面,但是其中一个只能查看看,而另一个就可以编辑。本篇文章通过一个实际的案例给大家介绍如何实现这些功能,主要涉及到Vue的以下三个知识点:
- Vue Router
- Vuex
- 自定义指令
需求
这是A、B、C三个角色分别所看的菜单:
- 
    A角色  
- 
    B角色  
- 
    C角色  
思路
- 判断是否有 AccessToken如果没有则跳转到登录页面
- 获取用户信息和拥有权限store.dispatch('GetInfo')
- 用户信息获取成功后, 调用 store.dispatch('GenerateRoutes', userInfo)根据获取到的用户信息构建出一个已经过滤好权限的路由结构(src/store/modules/permission.js)
- 将构建的路由结构信息利用 Vue-Router提供的动态增加路由方法router.addRoutes加入到路由表中
- 加入路由表后将页面跳转到用户原始要访问的页面,如果没有 redirect则进入默认页面
代码实现
配置路由表
- 
    首先新建一个 router.config.js文件,asyncRouterMap用于存放要根据角色权限动态添加的路由,constantRouterMap用于存放基础路由,constantRouterMap这个里面的路由页面所有角色都可看见,例如登录页、注册页之类的页面可以放在这个里面// eslint-disable-next-line import { UserLayout, BasicLayout, RouteView, BlankLayout, PageView } from '@/layouts' import { dashboard, file, tableIcon } from '@/core/icons' /** * 动态路由表 */ export const asyncRouterMap = [ { path: '/', name: 'index', component: BasicLayout, meta: { title: '首页' }, redirect: '', children: [ { path: 'work-space', name: 'workSpace', component: () => import('@/views/dashboard/WorkSpace'), meta: { title: '工作台', icon: dashboard, permission: ['workSpace'] }, }, { path: 'receipt-issued', name: 'receiptIssued', component: () => import('@/views/receipt/ReceiptIssued'), meta: { title: '收据开具', icon: file, permission: ['receiptIssued'] }, }, { path: 'receipt-collection', name: 'receiptCollection', component: () => import('@/views/receipt/ReceiptPayment'), meta: { title: '收据收款', icon: tableIcon, permission: ['receiptCollection'] }, }, { path: 'receipt-audit', name: 'receiptAudit', component: () => import('@/views/receipt/ReceiptAudit'), meta: { title: '收据审核', icon: tableIcon, permission: ['receiptAudit'] }, }, { path: 'receipt-query', name: 'receiptQuery', component: () => import('@/views/receipt/ReceiptTheQuery'), meta: { title: '收据查询', icon: tableIcon, permission: ['receiptQuery'] }, }, { path: 'receipt-print-list', name: 'receiptPrintList', component: () => import('@/views/receipt/ReceiptPrintList'), meta: { title: '收据打印', icon: tableIcon, permission: ['receiptPrintList'] }, }, { path: 'receipt-management', name: 'receiptManagement', component: () => import('@/views/receipt/ReceiptManagement'), meta: { title: '收据管理', icon: tableIcon, permission: ['receiptManagement'] }, }, { path: 'receipt-print', name: 'receiptPrint', component: () => import('@/views/receipt/ReceiptPrint'), meta: { title: '收据打印详情', permission: ['receiptPrintList'] }, hidden: true, }, { path: 'receipt-detail', name: 'receiptDetail', component: () => import('@/views/receipt/ReceiptDetail'), meta: { title: '收据详情', permission: ['receiptQuery', 'receiptAudit'] }, hidden: true, }, ], }, { path: '/pay', name: 'pay', component: () => import('@/views/receipt/Pay'), hidden: true, meta: { title: '收据收款二维码', permission: ['receiptCollection'] }, }, { path: '/print', name: 'print', component: () => import('@/views/receipt/Print'), hidden: true, meta: { title: '收据打印', permission: ['receiptPrintList'] }, }, { path: '*', redirect: '/404', hidden: true, }, ] /** * 基础路由 * @type { *[] } */ export const constantRouterMap = []{ Route }对象参数 说明 类型 默认值 hidden 控制路由和子路由是否显示在 菜单 boolean false redirect 重定向地址, 访问这个路由时,自定进行重定向 string - name 路由名称, 必须设置,且不能重名 string - meta 路由元信息(路由附带扩展信息) object {} hideChildrenInMenu 强制菜单显示为Item而不是SubItem(配合 meta.hidden) boolean - { Meta }路由元信息对象参数 说明 类型 默认值 title 路由标题, 用于显示面包屑, 页面标题 string - icon 路由在 菜单 上显示的图标 [string,svg] - keepAlive 缓存该路由 (开启 multi-tab 是默认值为 true) boolean false hiddenHeaderContent *特殊 隐藏 PageHeader 组件中的页面带的 面包屑和页面标题栏 boolean false permission 与项目提供的权限拦截匹配的权限,如果不匹配,则会被禁止访问该路由页面 array [] 
- 
    在初始化路由的时候,只添加 router.config.js里面的constantRouterMapimport Vue from 'vue' import Router from 'vue-router' import { constantRouterMap } from '@/config/router.config' // hack router push callback const originalPush = Router.prototype.push Router.prototype.push = function push(location, onResolve, onReject) { if (onResolve || onReject) return originalPush.call(this, location, onResolve, onReject) return originalPush.call(this, location).catch((err) => err) } Vue.use(Router) export default new Router({ mode: 'history', base: process.env.BASE_URL, scrollBehavior: () => ({ y: 0 }), routes: constantRouterMap, // 只添加router.config.js里面的constantRouterMap })
配置角色权限
这里登录过程就不讲了~主要讲用户登录过后,通过根据抓取到的用户信息里面的角色来进行权限分配,这里用到了Vuex(全局状态管理),它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。我们只需要把一些频繁使用的值(例如:用户信息、角色权限等)定义在VueX中,即可在整个Vue项目的组件中使用。
- 
    新建 user.js,通过角色信息动态配置权限,并写入到用户信息里,存入到Vuex中,以供全局调用import Vue from 'vue' import { ACCESS_TOKEN, USER_NAME } from '@/store/mutation-types' import { getUserAuth } from '@/api/api' import authentication from '@/config/authentication.config' import { isEmpty } from '@/utils/util' import lodash from 'lodash' const user = { state: { token: '', name: '', avatar: '', roles: [], info: {}, }, mutations: { SET_TOKEN: (state, token) => { state.token = token }, SET_NAME: (state, { name }) => { state.name = name }, SET_AVATAR: (state, avatar) => { state.avatar = avatar }, SET_ROLES: (state, roles) => { state.roles = roles }, SET_INFO: (state, info) => { state.info = info }, }, actions: { // 登录 Login({ commit }) { return new Promise((resolve) => { window.Vue = Vue const cachedUser = authentication.getCachedUser() if (!isEmpty(cachedUser)) { const profile = cachedUser.profile const employeeNo = lodash(profile.upn).split('@', 1) Vue.ls.set('upn', employeeNo) // return this Vue.ls.set(USER_NAME, employeeNo) authentication.acquireToken().then((token) => { Vue.ls.set(ACCESS_TOKEN, token) resolve() }) } }) }, // 获取用户信息,根据角色动态生成路由权限 GetInfo({ commit }) { return new Promise((resolve, reject) => { getUserAuth() // 获取用户角色接口 .then((res) => { const userInfo = { name: res[0].memberName, username: res[0].memberCode, roleId: '', role: {}, } res.forEach((e) => { // 根据拿到的用户角色,为用户配置权限表 switch (e.roleCode) { case 'A': // 角色A userInfo.roleId = e.roleCode userInfo.role = { id: 'A', // 角色ID name: '角色A', // 角色名 homePath: '/receipt-issued', // 角色首页 permissions: [ // 角色权限表 { roleId: 'A', // 角色ID permissionId: 'receiptIssued', // 角色权限ID permissionName: '收据开具', // 角色权限名 actionEntitySet: [], // 该权限下的行为权限表 actionList: null, dataAccess: null, }, { roleId: 'A', permissionId: 'receiptManagement', permissionName: '收据管理', actionEntitySet: [], actionList: null, dataAccess: null, }, ], } break case 'B': // 角色B userInfo.roleId = e.roleCode userInfo.role = { id: 'B', name: '角色B', homePath: '/work-space', permissions: [ { roleId: 'B', permissionId: 'workSpace', permissionName: '工作台', actionEntitySet: [ // 该权限下的行为权限表 { action: 'receiptIssued', // 行为权限ID describe: '收据开具', // 行为权限描述 defaultCheck: false, }, { action: 'receiptAudit', describe: '收据审核', defaultCheck: false, }, { action: 'receiptQuery', describe: '收据查询', defaultCheck: false, }, { action: 'receiptPrintList', describe: '收据打印', defaultCheck: false, }, ], actionList: null, dataAccess: null, }, { roleId: 'B', permissionId: 'receiptIssued', permissionName: '收据开具', actionEntitySet: [], actionList: null, dataAccess: null, }, { roleId: 'B', permissionId: 'receiptAudit', permissionName: '收据审核', actionEntitySet: [], actionList: null, dataAccess: null, }, { roleId: 'B', permissionId: 'receiptPrintList', permissionName: '收据打印', actionEntitySet: [ { action: 'print', describe: '打印', defaultCheck: false, }, ], actionList: null, dataAccess: null, }, { roleId: 'B', permissionId: 'receiptQuery', permissionName: '收据查询', actionEntitySet: [ { action: 'tally', describe: '记账', defaultCheck: false, }, ], actionList: null, dataAccess: null, }, ], } break case 'C': // 角色C userInfo.roleId = e.roleCode userInfo.role = { id: 'C', name: '角色C', homePath: '/work-space', permissions: [ { roleId: 'C', permissionId: 'workSpace', permissionName: '工作台', actionEntitySet: [ { action: 'receiptCollection', describe: '收据收款', defaultCheck: false, }, { action: 'receiptQuery', describe: '收据查询', defaultCheck: false, }, ], actionList: null, dataAccess: null, }, { roleId: 'C', permissionId: 'receiptCollection', permissionName: '收据收款', actionEntitySet: [], actionList: null, dataAccess: null, }, { roleId: 'C', permissionId: 'receiptQuery', permissionName: '收据查询', actionEntitySet: [ { action: 'no', describe: 'no', defaultCheck: false, }, ], actionList: null, dataAccess: null, }, ], } break default: break } }) if (userInfo.role && userInfo.role.permissions.length > 0) { const role = userInfo.role role.permissions = userInfo.role.permissions role.permissions.map((per) => { if (per.actionEntitySet != null && per.actionEntitySet.length > 0) { const action = per.actionEntitySet.map((action) => { return action.action }) per.actionList = action } }) role.permissionList = role.permissions.map((permission) => { return permission.permissionId }) commit('SET_ROLES', userInfo.role) commit('SET_INFO', userInfo) } else { reject(new Error('getInfo: roles must be a non-null array !')) } commit('SET_NAME', { name: userInfo.name }) resolve(userInfo) }) .catch((error) => { reject(error) }) }) }, // 登出 Logout({ commit }) { return new Promise((resolve) => { commit('SET_TOKEN', '') commit('SET_ROLES', []) Vue.ls.remove(ACCESS_TOKEN) Vue.ls.remove(USER_NAME) authentication.signOut() resolve() }) }, }, } export default user
- 
    根据配置好的权限,动态生成路由表,新建 permission.jsimport { asyncRouterMap, constantRouterMap } from '@/config/router.config' /** * 过滤账户是否拥有某一个权限,并将菜单从加载列表移除 * * @param permission * @param route * @returns {boolean} */ function hasPermission(permission, route) { if (route.meta && route.meta.permission) { let flag = false for (let i = 0, len = permission.length; i < len; i++) { flag = route.meta.permission.includes(permission[i]) if (flag) { return true } } return false } return true } function filterAsyncRouter(routerMap, roles) { const accessedRouters = routerMap.filter((route) => { if (hasPermission(roles.permissionList, route)) { if (route.children && route.children.length) { route.children = filterAsyncRouter(route.children, roles) } return true } return false }) return accessedRouters } const permission = { state: { routers: constantRouterMap, addRouters: [], }, mutations: { SET_ROUTERS: (state, routers) => { state.addRouters = routers state.routers = constantRouterMap.concat(routers) }, }, actions: { GenerateRoutes({ commit }, data) { return new Promise((resolve) => { const { roles } = data if (roles.homePath) { asyncRouterMap[0].redirect = roles.homePath } const accessedRouters = filterAsyncRouter(asyncRouterMap, roles) commit('SET_ROUTERS', accessedRouters) resolve() }) }, }, } export default permission
- 
    在初始化Vuex的时候,引入这两个文件 import Vue from 'vue' import Vuex from 'vuex' import app from './modules/app' import user from './modules/user' // default router permission control import permission from './modules/permission' import receipt from '@/store/modules/receipt' import getters from './getters' Vue.use(Vuex) export default new Vuex.Store({ modules: { app, user, // 引入user permission, // 引入permission receipt, }, state: {}, mutations: {}, actions: {}, getters, })
路由守卫
正如其名,vue-router 提供的守卫主要用来通过跳转或取消的方式守卫路由。在路由守卫里,可以判断当前要进入的路由页面是否在当前角色的路由表里,以此来判断是否放行。
main.js同级目录下新建permission.js
import Vue from 'vue'
import router from './router'
import store from './store'
import NProgress from 'nprogress' // progress bar
import '@/components/NProgress/nprogress.less' // progress bar custom style
import { setDocumentTitle, domTitle } from '@/utils/domUtil'
import { ACCESS_TOKEN } from '@/store/mutation-types'
import { Modal } from 'ant-design-vue'
import authentication from '@/config/authentication.config'
NProgress.configure({ showSpinner: false }) // NProgress Configuration
// 全局前置守卫
router.beforeEach((to, from, next) => {
  NProgress.start() // start progress bar
  to.meta && typeof to.meta.title !== 'undefined' && setDocumentTitle(`${to.meta.title} - ${domTitle}`)
  // 首先判断token是否存在,也就是用户是否登录
  if (Vue.ls.get(ACCESS_TOKEN)) {
    /* has token */
    if (store.getters.roles.length === 0) {
      // 获取存入Vuex里的用户信息
      store
        .dispatch('GetInfo')
        .then((res) => {
          const roles = res && res.role
          store.dispatch('GenerateRoutes', { roles }).then(() => {
            // 根据roles权限生成可访问的路由表
            // 动态添加可访问路由表
            router.addRoutes(store.getters.addRouters)
            // 请求带有 redirect 重定向时,登录自动重定向到该地址
            const redirect = decodeURIComponent(from.query.redirect || to.path)
            if (to.path === redirect) {
              // hack方法 确保addRoutes已完成 ,set the replace: true so the navigation will not leave a history record
              next({ ...to, replace: true })
            } else {
              // 跳转到目的路由
              next({ path: redirect })
            }
          })
        })
        .catch(() => {
          if (!authentication.isAuthenticated()) {
            Modal.error({
              title: 'Token失效,请重新登录!',
              content: '很抱歉,Token失效,请重新登录!',
              okText: '重新登录',
              mask: false,
              onOk: () => {
                store.dispatch('Logout').then()
              },
            })
          } else {
            Modal.error({
              title: '获取用户信息失败',
              content: '很抱歉,获取用户信息失败,请重新登录',
              okText: '重新登录',
              mask: false,
              onOk: () => {
                store.dispatch('Logout').then()
              },
            })
          }
        })
    } else {
      next()
    }
  }
})
// 全局后置钩子
router.afterEach((to) => {
  NProgress.done() // finish progress bar
})
到这里路由权限的配置就完成了,菜单页面我就不写了,大家自己可以选择自己喜欢的UI组件去实现
指令权限
在实际应用中,不止菜单有权限控制,页面上的组件、按钮之类的也会有权限控制,就像我在前言里说的一些角色他们能看到同一个页面,但是其中一个只能查看看,而另一个就可以编辑。这里我们可以通过自定义指令来实现,在配置角色权限里有一个actionEntitySet数组,这里存放的就是这类权限。
新建一个自定义指令action.js,然后在main.js里引入即可
import Vue from 'vue'
import store from '@/store'
/**
 * Action 权限指令
 * 指令用法:
 *  - 在需要控制 action 级别权限的组件上使用 v-action:[method] , 如下:
 *    <i-button v-action:add >添加用户</a-button>
 *    <a-button v-action:delete>删除用户</a-button>
 *    <a v-action:edit @click="edit(record)">修改</a>
 *
 *  - 当前用户没有权限时,组件上使用了该指令则会被隐藏
 *  - 当后台权限跟 pro 提供的模式不同时,只需要针对这里的权限过滤进行修改即可
 *
 */
const action = Vue.directive('action', {
  inserted: function (el, binding, vnode) {
    const actionName = binding.arg
    const roles = store.getters.roles
    const elVal = vnode.context.$route.meta.permission
    const permissionId = (elVal instanceof String && [elVal]) || elVal
    roles.permissions.forEach((p) => {
      if (!permissionId.includes(p.permissionId)) {
        return
      }
      if (p.actionList && !p.actionList.includes(actionName)) {
        ;(el.parentNode && el.parentNode.removeChild(el)) || (el.style.display = 'none')
      }
    })
  },
})
export default action
写在最后
最后给大家整上一张偷来的图,便于理解

