本文采用的技术栈有:
前端:Vue3 + VueRouter@4 + VueX + Element-ui
后端:Express
在中后台管理系统中,我们知道可以有多种用户实体。以学生管理系统为例,老师
和教务主任
就是两个拥有不同职责的实体对象。
当不同权限的用户登录管理系统时,他们所需要的功能也就不同。比如老师
管理学生信息,而教务主任
不仅可以管理学生,也可以处理一些老师的信息。由于职责不同,(通常来说在左侧)的用户功能菜单也就不一样。
需求:不同的用户在登录后可以看到不同的菜单。
在实现功能需求前首先需要明确一点,在登录页面输入账号密码进行登录后,在没有direct
的情况下一般是跳转到首页。在这个过程中,仔细想想,常见的处理大致是:
需要注意的是,登录操作是在login页面完成的,而用户信息数据是在首页展现,这里采用路由守卫来实现这一功能。
import { ElLoading } from 'element-plus'
import router from '@/router'
import store from '@/store'
// 通过这个TOKEN_KEY拿到具体的token数据
import TOKEN_KEY from '@/store/modules/app'
import Test from './modules/test'
import User from './modules/user'
import Article from './modules/article'
// Test页面中存在:“由于用户权限不一致,最终渲染的菜单也许不一致”的需求
export const asyncRoutes = [...Test]
// 表示不管是什么用户,登陆后都能看到该菜单
export const fixedRoutes = [...User, ...Article]
const router = createRouter({
history: createWebHashHistory(),
routes: [{
path: '/',
redirect: '/home',
},
// 这里并未添加 asyncRoutes 数组中的路由配置
// 具体原因将在2.1中介绍
// 现在仅需要知道的是:页面中可以直接访问挂载在 fixedRoutes 中的路由对象,无法访问asyncRoutes中的路由
...fixedRoutes
],
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return {
left: 0,
top: 0
}
}
},
})
// vue-router4的路由守卫不再是通过next放行
// 而是通过return返回true或false或者一个路由地址
router.beforeEach(async to => {
// 如果路由配置中存在meta属性,则配置页面title
document.title = (!!to.meta && to.meta.title)
// 如果没有token信息
// 比如用户在未登录状态下通过URI链接访问固定页面
// 此时拦截跳转至登录页面,同时声明direct信息,让用户在登录后可以直接跳转到上次访问的页面
if(window.localStorage[TOKEN_KEY]){
return {
name: 'login',
query: {
direct: to.fullPath
}
}
}
else{
// 由于用户权限数据保存在用户信息中
let { userInfo } = store.state.account
// 第一次登录没有用户信息,需要发起相关请求
if(!userInfo){
const loadingInstance = ElLoading.service({
lock: true,
text: '加载数据中...\_(ツ)_/',
background: 'rgba(0, 0, 0, 0.7)',
})
// 异步获取用户信息
try{
// 在这里请求userInfo相关的信息
// 成功将用户数据保存在vuex中
userInfo = await store.dispatch('account/getUserInfo')
loadingInstance.close()
} catch (err) {
loadingInstance.close()
return false
}
}
}
})
export default router
在目标页面中,直接将userInfo数据读取出来即可使用:
const userinfo = computed(() => state.account.userinfo)
在路由守卫中我们已经成功地实现了:在页面跳转的过程中获取到用户信息并保存。
接下来思考如何获取菜单信息,由于不同用户拥有的菜单不同,我们可以将标识用户权限的字段添加在userInfo中,比如auth:'admin | visitor'
。由于菜单的动态性,我们需要在请求菜单数据的时候将用户信息一并传入接口中:
// 生成菜单
// 发起菜单的请求
if (store.state.menu.menus.length <= 0) {
const loadingInstance = ElLoading.service({
lock: true,
text: '正在加载数据,请稍候~',
background: 'rgba(0, 0, 0, 0.7)',
})
try {
// 关于用户信息的部分放在user模块中
// 菜单部分则放在menu模块中
await store.dispatch('menu/generateMenus', userinfo)
loadingInstance.close()
return to.fullPath // 添加动态路由后,必须加这一句触发重定向,否则会404
} catch (err) {
loadingInstance.close()
return false
}
}
根据前面的分析,我们需要在vuex中的menu模块下进行菜单内容的请求。
这样做的好处就在于,具体菜单信息的逻辑处理部分交由后端来实现,如果说某个用户权限需要更改,直接更改后台字段即可,前台只需要传入用户类型,就能收到该用户对应的权限菜单,进而渲染在页面中。
// store/modules/menu
export default {
namespaced: true,
state: {
menus: [],
},
mutations: {
SET_MENUS(state, data) {
state.menus = data
},
},
actions: {
async generateMenus({ commit }, userinfo) {
// 假设所有用户看到的都是同样的菜单,即:只有固定菜单
// const menus = getFilterMenus(fixedRoutes)
// commit('SET_MENUS', menus)
// 假设存在不同用户权限,需要设置动态菜单
// 从后台获取菜单
// 用户权限信息保存在userinfo中
const { code, data } = await axios.get('/api/menus', { role: userinfo.role })
// 获得返回数据
if (+code === 200) {
// 过滤出需要添加的动态路由
// ...
}
},
},
}
现在根据用户权限获取到了对应的菜单功能,假设后台返回的data数据形式如下:
[
name: 'test',
title: '测试页面',
children: [
{
name: 'stuList',
title: '学生列表'
},
{
name: 'stuAdd',
title: '添加'
},
{
name: 'stuEdit',
title: '修改'
}
]
]
接下来需要做的就是将与返回数据相对应的路由挂载在页面中。
现在有一个问题是,我们在配置路由文件的时候,代码的格式为:
[
{
path: '/test',
component: Layout,
name: 'test',
meta: {
title: '测试页面',
},
icon: 'el-icon-location',
children: [
{
path: '',
name: 'stuList',
component: List,
meta: {
title: '学生列表',
},
},
{
path: 'stuAdd',
name: 'stuAdd',
component: Add,
meta: {
title: '添加',
},
hidden: true, // 不在菜单中显示
}
],
},
]
在路由配置文件里面的对象数组记录的,是所有可能需要展示的菜单;而后台返回的数据则是用户需要使用到的菜单。
那么我们是不是可以根据后台返回的数据,和路由配置文件作比较,将符合条件的数据作为最终路由数据渲染到页面上呢?
回到store/modules/menu
模块中:
if (+code === 200) {
// 过滤出需要添加的动态路由
// 创建 getFilterRoutes 方法过滤路由数据
// 这里的 asuncRoutes 是从路由配置文件中导出的对象数组,记录着路由配置信息;
// data则是后台返回的权限数据
const filterRoutes = getFilterRoutes(asyncRoutes, data)
// 动态添加路由
// 回到1.1,这里解答当时留下的问题
// 动态路由需要通过和后台数据匹配后才能挂载到router对象身上
// 因此只能在后期通过router.addRoute方法添加
filterRoutes.forEach(route => router.addRoute(route))
// 生成菜单
const menus = getFilterMenus([...fixedRoutes, ...filterRoutes])
// 将数据保存至state中,方便菜单组件部分通过循环渲染,将数据渲染到页面上
commit('SET_MENUS', menus)
}
基本的思路已经介绍完毕,现在来编写一下getFilterRoutes
和getFilterMenus
方法,实现核心过滤操作。
回顾以下后台数据结构:
children
子路由进行判断const getFilterRoutes = (targetRoutes, ajaxRoutes) => {
const filterRoutes = []
ajaxRoutes.forEach(item => {
const target = targetRoutes.find(target => target.name === item.name)
if (target) {
// 将children子路由信息单独取出来
// 剩下的部分全部属于一级路由,通过...rest收集起来
const { children: targetChildren, ...rest } = target
// 将...rest中的数据直接添加到路由对象中
const route = {
...rest,
}
if (item.children) {
// 递归遍历
route.children = getFilterRoutes(targetChildren, item.children)
}
filterRoutes.push(route)
}
})
return filterRoutes
}
现在我们成功实现了路由的过滤,现在来过滤菜单。
具体的操作和过滤路由时差不多:
const getFilterMenus = (arr, parentPath = '') => {
const menus = []
arr.forEach(item => {
if (!item.hidden) {
// 某一级的菜单可能有多层子菜单数据
const menu = {
url: generateUrl(item.path, parentPath),
title: item.meta.title,
icon: item.icon,
}
if (item.children) {
if (item.children.filter(child => !child.hidden).length <= 1) {
menu.url = generateUrl(item.children[0].path, menu.url)
} else {
menu.children = getFilterMenus(item.children, menu.url)
}
}
menus.push(menu)
}
})
return menus
}
const generateUrl = (path, parentPath) => {
return path.startsWith('/')
? path
: path
? `${parentPath}/${path}`
: parentPath
}
至此已成功实现vue3权限菜单(动态路由)的设置。