Vue从零开始(4):动态路由菜单+权限控制

8 minute read

前言

上一篇讲到了如何通过路由创建页面以及Mock的使用,然而在实际项目中,大部分中后台前端项目对于菜单都有一个权限控制,像某某角色只能看到这些菜单页面,某某角色只能看到另一些菜单页面,还有一些角色他们能看到同一个页面,但是其中一个只能查看看,而另一个就可以编辑。本篇文章通过一个实际的案例给大家介绍如何实现这些功能,主要涉及到Vue的以下三个知识点:

  • Vue Router
  • Vuex
  • 自定义指令

需求

这是A、B、C三个角色分别所看的菜单:

  1. A角色

    image-20200619145434391

  2. B角色

    image-20200619145910775

  3. C角色

    image-20200619145947133

思路

  1. 判断是否有 AccessToken 如果没有则跳转到登录页面
  2. 获取用户信息和拥有权限store.dispatch('GetInfo')
  3. 用户信息获取成功后, 调用 store.dispatch('GenerateRoutes', userInfo) 根据获取到的用户信息构建出一个已经过滤好权限的路由结构(src/store/modules/permission.js)
  4. 将构建的路由结构信息利用 Vue-Router 提供的动态增加路由方法 router.addRoutes 加入到路由表中
  5. 加入路由表后将页面跳转到用户原始要访问的页面,如果没有 redirect 则进入默认页面

代码实现

配置路由表

  1. 首先新建一个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 []
  2. 在初始化路由的时候,只添加router.config.js里面的constantRouterMap

    import 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项目的组件中使用。

  1. 新建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
       
    
  2. 根据配置好的权限,动态生成路由表,新建permission.js

    import { 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
       
    
  3. 在初始化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

写在最后

最后给大家整上一张偷来的图,便于理解

img