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
里面的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项目的组件中使用。
-
新建
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.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
-
在初始化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
写在最后
最后给大家整上一张偷来的图,便于理解