任何一个管理系统几乎都存在权限管理和动态路由,它一般和用户管理一同出现,可能有的人觉得这个东西每个管理系统都存在,觉得这个模块没有这么的重要;而在我的认知里,像权限控制和用户管理是管理系统中最重要的一部分,它是整个管理系统中最基础的部分,只有这部分完善了,其他模块才可以非常顺利的进行开发。
本篇文章通过文字+图片+动图演示的形式介绍了一个最基本的动态权限路由实现思路以及实现过程,阅读这篇文章你可能会收获:
用户管理部分的E-R图(简单版);
通过plop
脚手架快速创建同类型文件;
pinia
如何使用;
如何使用mock
;
如何编写动态权限路由;
PS:数据来源于mock、前端框架是Vue3、状态管理是Pinia、UI框架是ElementPlus。
为了方便理解,我这里画了一张图,如下:
现在我们来详细讲解一下思路:
用户登录获取用户id或者token;
根据token或者用户id去获取用户对应的权限;
获取到用户权限后进行缓存,并存储到pinia
中;
根据获取的用户权限生成对应的菜单;
根据编写好的route结合用户权限生成对应的router,并通过addRoute添加到路由实例中。
想要实现动态路由,最好的方式是通过后端配合实现,这里讲解一下数据库如何设计,先来看一下E-R图:
这里是最简单的一个版本,你可以根据这个E-R图进行扩展。
根据E-R图可以得知的数据库表如下:
用户表
角色表
权限表
角色权限表
其中,角色条件表中的地址,对应Route
中的path
选项(你也可以换一个字段,总之就是需要权限表中存在一个字段与****中相对应)。
我们还可以将路由的**配置在权限表中,比如****、******等信息。
用户和角色是一对多的关系,也就是说一个用户只有一个角色,也可以根据你的系统进行调整为多对多,无非就是增加一个中间表而已。
如果你有后端的话,这一步就可以直接跳过了,这里我为了演示模拟了一些数据。
首先安装依赖
npm i vite-plugin-mock
第二步在vite.config.ts
中只用这个插件,示例代码如下:
import { viteMockServe } from 'vite-plugin-mock'
export default defineConfig(({ mode }: ConfigEnv) => {
return {
plugins: [
// 配置mock
viteMockServe({
mockPath: '/mock',
localEnabled: true,
}),
],
}
})
然后自己造一些假数据,可以参考,如下;
export const userList = [
{
id: 0,
name: '短暂又灿烂的',
role: {
roleId: 0,
name: 'superAdmin',
},
createTime: '2022-05-05',
updateTime: '2022-05-05',
},
{
id: 1,
name: '臭小甜',
role: {
roleId: 1,
name: 'admin',
},
createTime: '2022-05-05',
updateTime: '2022-05-05',
},
{
id: 2,
name: '笨小贝',
role: {
roleId: 2,
name: 'user',
},
createTime: '2022-05-05',
updateTime: '2022-05-05',
},
]
export const roleList = [
{
id: 0,
name: 'superAdmin',
// 权限列表的id
permission: [0, 1, 2, 4, 5, 6, 7],
permissionNames: [],
createTime: '2022-05-05',
updateTime: '2022-05-05',
},
{
id: 1,
name: 'admin',
// 权限列表的id
permission: [0, 1, 2, 4, 5],
permissionNames: [],
createTime: '2022-05-05',
updateTime: '2022-05-05',
},
{
id: 2,
name: 'user',
// 权限列表的id
permission: [0, 1, 4],
permissionNames: [],
createTime: '2022-05-05',
updateTime: '2022-05-05',
},
]
export const permissionList = [
{
id: 0,
name: '工作台',
type: 0,
pid: null,
path: '/dashboard/workplace',
},
{
id: 1,
name: '数据可视化',
type: 0,
pid: null,
path: '/visualization',
},
{
id: 2,
name: '系统管理',
type: 0,
pid: null,
path: '/system',
},
{
id: 4,
name: 'ECharts图表',
type: 1,
pid: 1,
path: '/visualization/echarts',
},
{
id: 5,
name: '用户管理',
type: 1,
pid: 2,
path: '/system/user',
},
{
id: 6,
name: '角色管理',
type: 1,
pid: 2,
path: '/system/role',
},
{
id: 7,
name: '权限管理',
type: 1,
pid: 2,
path: '/system/permission',
},
]
然后编写一些假的接口,如下代码展示了如何编写一个接口:
const mockList: MockMethod[] = [
{
url: '/mock/login',
method: 'post', // 请求方式
statusCode: 200, // 返回的http状态码
response: opt => { // opt 对象中包含 url body query headers
return {
// 返回的结果集
statusCode: 200,
desc: '登录成功',
result: {
name: '短暂又灿烂的',
},
}
},
},
]
export default mockList
我伪造的接口可以参考;如下;
import { MockMethod } from 'vite-plugin-mock'
import { userList, permissionList, roleList } from './data'
export const listToTree = (array: any[]) => {
const arr = JSON.parse(JSON.stringify(array))
const result = []
const map = new Map()
arr.forEach(item => {
map.set(item.id, item)
})
for (const item of arr) {
// 判断pid是否在map中,如果在说明这是一个子节点
if (map.has(item.pid)) {
if (!map.get(item.pid).children) {
// 判断是否存在children属性,如果不存在则添加
map.get(item.pid).children = []
}
map.get(item.pid).children.push(item)
} else {
// 直接放到数组中,作为父节点
result.push(item)
}
}
return result
}
const mockList: MockMethod[] = [
{
url: '/mock/login',
method: 'post', // 请求方式
statusCode: 200, // 返回的http状态码
response: opt => {
console.log(opt)
return {
// 返回的结果集
statusCode: 200,
desc: '登录成功',
result: {
name: '短暂又灿烂的',
},
}
},
},
{
url: '/mock/getUserList',
method: 'get',
statusCode: 200,
response: () => {
return {
statusCode: 200,
desc: '获取成功',
result: userList,
}
},
},
{
url: '/mock/getRoleList',
method: 'get',
statusCode: 200,
response: () => {
roleList.forEach(role => {
role.permissionNames = []
for (const i in role.permission) {
role.permissionNames.push(
permissionList.find(power => power.id === role.permission[i]).name,
)
}
})
return {
statusCode: 200,
desc: '获取成功',
result: roleList,
}
},
},
{
url: '/mock/getPermissionList',
method: 'get',
statusCode: 200,
response: () => {
return {
statusCode: 200,
desc: '获取成功',
result: permissionList,
}
},
},
{
url: '/mock/getUserDetail',
method: 'get',
statusCode: 200,
response: ({ query }) => {
const id = query.id
if (id === undefined) {
return {
statusCode: 400,
desc: 'id必传',
// 返回最终数据
result: null,
}
}
const _userList = JSON.parse(JSON.stringify(userList))
// 获取用户
const user = _userList.find(u => u.id === parseInt(id))
// 获取用户权限
const permissionIdS = roleList.find(
r => r.id === user.role.roleId,
).permission
// 获取权限列表
const pList = permissionIdS.map(i => {
return permissionList.find(p => p.id === i)
})
return {
statusCode: 200,
desc: '获取成功',
// 返回最终数据
result: Object.assign(user, { permissionList: listToTree(pList) }),
}
},
},
]
export default mockList
请求数据我使用之前封装axios,示例代如下:
import request from '/@/service'
import type {
IPermissionList,
IRoleList,
IUserDetail,
IUserList,
} from './types/mock'
/* more request */
export const getUserDetail = (data: { id: any }) => {
return request({
url: '/mock/getUserDetail',
method: 'get',
data,
})
}
我在这里就不多说pinia是什么了,简而言之这个就是Vuex5,基本使用可以参考这里。
如下代码展示了如何在pinia
中获取数据:
import { defineStore } from 'pinia'
// 获取路由实例
import router from '/@/router'
import {
getPermissionList,
getRoleList,
getUserDetail,
getUserList,
} from '/@/api/mock'
import type { IUser } from './types'
export const useUserStore = defineStore({
id: 'user', // id必填,且需要唯一
// state
state: (): IUser => {
return {
permissionList: [],
roleList: [],
userList: [],
userDetail: undefined,
}
},
// getters
getters: {
menuList: state => {
return state.userDetail?.permissionList
},
},
// actions
actions: {
async getData() {
this.userList = (await getUserList()).result
this.permissionList = (await getPermissionList()).result
this.roleList = (await getRoleList()).result
this.userDetail = (await getUserDetail({ id: this.curId })).result
// TODO 动态添加路由
},
},
})
可以根据需要决定是否进行数据的缓存,如果缓存下次可以直接从缓存中获取,流程图如下:
我在getUserDetail
接口中返回了该用户的菜单,它的类型定义如下:
export interface IPermissionList {
id: number
// 菜单名称
name: string
// 菜单类型 1表示一级菜单、2表示二级菜单
type: number
// 父级
pid?: number
// 地址栏的路径,与route中的path对应,从而找到组件
path: string
}
interface PermissionList extends IPermissionList {
children: IPermissionList[]
}
export interface IUserDetail extends IUserList {
// 权限列表
permissionList: PermissionList[]
}
interface IUserRole {
roleId: number
name: string
}
export interface IUserList {
id: number
name: string
role: IUserRole
createTime: string
updateTime: string
}
我们在pinia
中编写了一个getters
获取了菜单的配置,直接使用就好,实现代码如下:
{{ menu.name }}
{{ menu.name }}
{{ _menu.name }}
最终如下图所示:
当我们创建一个路由时,需要编写很多重复易错的代码,且这些代码没有任何含量,除了path
和一些meat
属性都是一样的。
这个时候我们就可以使用plop
这款脚手架来创建同类型的文件,这个脚手架使用也比较简单,如下所示:
首先我们将Plop作为一个npm模块进行安装,命令如下:
npm i plop --dev
第二步,创建项目的根目录创建入口文件,文件名为plopfile.js
,然后写入如下代码:
// Plop工作的入口文件,需要导出一个函数
// 此函数接收一个 plop 对象,用于创建生成器任务
export default function (plop) {
// setGenerator方法接受两个参数,第一个参数作为生成器的名字,第二个参数是生成器的一些配置选项
plop.setGenerator('main', {
description: '创建新的路由以及组件',
// 在命令行看到交互信息
prompts: [
{
type: 'input',
name: 'pathName',
message: 'component path:',
},
{
type: 'input',
name: 'urlName',
message: 'url:',
},
{
type: 'input',
name: 'componentName',
message: 'component name:',
},
],
// 在命令行中执行的动作,数组中的每一个对象表示一个任务
actions: [
{
// type 为 add 表示添加文件
type: 'add',
path: 'src/views/main/{{pathName}}/{{componentName}}.vue',
templateFile: 'plop-templates/main/vue.hbs',
},
{
type: 'add',
path: 'src/router/main/{{urlName}}/index.ts',
// 指定模板文件
templateFile: 'plop-templates/main/router.hbs',
},
],
})
}
Plop中使用是Handlebars模板引擎,所以支持插槽的模式。pathName
表示我们上面输入的那个name
。
然后就是创建我们的模板文件,通常在根目录下创建plop-templates
文件夹,然后写入相应的模板文件,示例代码如下:
vue.hbs
{{ componentName }}
router.hbs
const router = { name: '{{componentName}}', path: '/main/{{pathName}}/{{urlName}}',
component: () => import('/@/views/main/{{pathName}}/{{componentName}}.vue'), }
export default router
最后在命令行中键入如下命令使用
npx plop main
我这里仅仅是做了最基础的配置,你也可以配置meta
属性,总之plop
的功能还是很强大的。
首先我们通过import.meta.glob()
函数获取指定目录下的模块,示例代码如下:
// 获取所有路由配置文件的函数
const getMainRouteFileList = async () => {
const allRoutes: RouteRecordRaw[] = []
// import.meta.glob 批量导入文件
const routeFileList = import.meta.glob('../router/main/**')
for (const path in routeFileList) {
const mod = await routeFileList[path]()
allRoutes.push(mod.default)
}
return allRoutes
}
然后我们根据menuList
动态生成路由配置文件,示例代码如下:
// src\utils\router.ts
// 处理动态路由
/**
* 1. 获取所有路由配置文件
* 2. 根据 menuList 动态生成 Route
*/
import type { RouteRecordRaw } from 'vue-router'
// 递归获取Route
const recurseGetRoute = (menus: any[], allRoutes: any[], route: any[]) => {
// 遍历传递的菜单
for (const menu of menus) {
// 如果没有children属性,则将该项直接push到route中
if (!menu.children) {
// 找到对应的路由配置文件
const r = allRoutes.find(
(route: any) => route.path === '/main' + menu.path,
)
// 如果找到匹配的则进行添加
r && route.push(r)
} else {
recurseGetRoute(menu.children, allRoutes, route)
}
}
}
// 根据菜单生成路由
export const menuToRoutes = (userMenu: any[]): Promise => {
return new Promise(resolve => {
const routes: RouteRecordRaw[] = []
getMainRouteFileList().then(res => {
// 1. 获取所有的routes
const allRoutes: RouteRecordRaw[] = res
// 2. 配置该权限的routes
recurseGetRoute(userMenu, allRoutes, routes)
resolve(routes)
})
})
}
现在我们封装的menuToRoutes
方法就可以获取全部的动态路由,在获取数据后进行动态的添加路由即可,示例代码如下:
actions: {
async getData() {
/* more request code */
// 动态添加路由
if (this.menuList) {
const routes = await menuToRoutes(this.menuList)
for (const route of routes) {
router.addRoute('main', route)
}
}
},
},
最后,也是最重要的一步,在main.ts
中调用函数,示例代码如下:
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { useUserStore } from './store'
import store from './store'
const app = createApp(App).use(store)
// 获取基础数据
await useUserStore().getData()
app.use(router).mount('#app')