一、序言
声明:本博客涉及到的前台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