根据登陆用户动态展示Vue菜单

3,393 阅读8分钟

一、序言

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

项目前后端源码已上传至GitHub,传送门

结语:如果这篇博客有帮助到你,希望能给笔者点个赞和Star