万字长文~vue+express+mysql带你彻底搞懂项目中的权限控制(附所有源码)
2024-01-24 15:52 古兆洋 阅读(258) 评论(0) 编辑 收藏 举报转自:https://zhuanlan.zhihu.com/p/599240205
本文略长,建议收藏。
线上网站演示效果地址:http://ashuai.work:8891/home
GitHub仓库地址:https://github.com/shuirongshuifu/authority
所谓的权限,其实指的就是:用户是否能看到,以及是否允许其对数据进行增删改查的操作
,因为现在开发项目的主流方式是前后端分离,所以整个项目的权限是后端权限控制
搭配前端权限控制
共同实现的
后端权限
1. 知道是哪个用户(角色)发的请求
- token判断(前端请求头中传过去,通过请求拦截器)
- userid判断(前端请求头中传过去,通过请求拦截器)
- cookie判断(用的少)
- session判断(用的很少)
2. 权限设计模式
RBAC模式~基于角色的权限访问控制(Role-Based Access Control)
是商业系统中最常见的权限管理技术之一。
关于什么是RBAC模式
的概念本文这里不做赘述,大家可以这样理解:
2.1 RBAC模式
- 某个系统有一些人在使用,但是使用这个系统的这些人分为两个派系。有老板派(老板、老板小姨子、老板小舅子等),也有打工仔派(张三、李四、王二、麻子)。
- 系统有一些页面用于展示数据,作为老板派的相关工作人员,肯定是哪个页面都能够看到,毕竟公司都是老板的,必须什么都能看到
- 而作为打工仔派的工作人员,肯定是权限比较低了,只能看到一个业绩页面(假设业绩页面记录了自己每个月为公司创造的业绩)
- 这个案例中的老板派和打工仔派就是两个角色,而老板、老板小姨子、老板小舅子和张三、李四、王二、麻子这些用户就是隶属于角色的用户
- 用户是角色的具象化体现
- 用户的数量一般都大于角色的数量的(想一下...)
- 所以我们在做权限控制的时候,只需要控制角色能看到那个页面即可,至于用户就让他隶属于这个角色便是
- 通过用户和角色进行关联,也做到了复用和解耦
- 角色能看到哪个页面,取决于角色和菜单页面进行的关联(通过勾选菜单树关联)
步骤如下:
- 给对应角色赋予(分配)相应菜单权限
- 让新建的用户隶属于某个角色,就是给新建的用户分配一下角色
2.2 后端建表
正常情况下需要五张表:
- 菜单表(用于存储系统的菜单信息,如菜单的名字、点击这个菜单要去跳转的路由url、以及这个菜单的icon图标名、菜单id等...)
- 角色表(用于存储系统的角色信息,如角色名、角色id、角色备注等...)
- 角色菜单表(用于存储某个角色能看到菜单id有哪些,一个角色id对应多个菜单id,一对多关系)
- 用户表(用于存储用户隶属于哪个角色,比如老板小舅子就是隶属于老板角色,以及用户名、用户id、用户备注啥的...)
- 组织表(用于记录角色属于哪个组织,再大一些的系统项目会建此表,小项目没有也行)
三张表也能用
我们将角色菜单表,糅在角色表中,这样的话,只要新建菜单表、角色表(包含角色菜单信息)、用户表,根据用户的用户名和密码进行登录(后端会根据用户名和密码查询到这个用户隶属于那个角色下,从而返回此用户对应角色的菜单信息数据)
本文演示建两张表
为了更好的便于大家理解,本文只新建两张表,一张是菜单表,另一张是角色表(角色表中存此角色能看到的单id),而登录的时候,大家选择角色登录,其实选择用户登录从某种意义上来说,也是相当于角色登录。
第一步:
角色登录发请求,后端返回此角色对应的菜单树数据(需要前端进一步加工一下),前端获取菜单树数据以后,将其存到vuex
中比如是menuTree
数组字段,el-menu
组件再取到vuex
中的menuTree
数组数据使用,根据这个菜单树数据进行自动递归渲染
关于el-menu组件的递归自调用,可以参见笔者之前的这篇文章:
若对于组件递归知识忘了,可以参见笔者的这篇文章:
第二步:
第一步中,完成了el-menu
组件的渲染展示,但是还少了路由表树结构数组数据(因为点击菜单需要进行路由跳转),所以我们还需要再搞一份数据routerTree
,这个routerTree
数据是根据后端返回的菜单树数据,加工成符合路由表树结构的数组,这个数组是给路由表使用的
routerTree
的值是动态,因为不同角色的routerTree
不一样- 对应的路由表还有静态的,不变的,比如
404
路由、login
路由、首页home
路由 - 静态路由前端直接写死即可,动态路由使用
router.addRoutes(routerTree)
将其添加到路由表中即可
至于刷新页面vuex
中数据丢失,就重发一次请求,或者本地存一份都行的
实际上,刷新页面,并不是vuex
中的数据丢失,而是,而是vuex
中的数据初始化了(回到最初的样子,最初时,vuex中的数据本来就是空的)
通过上述两步骤,一个权限系统的基本样子结构就出来了,只不过还有一些细节需要处理,这个请继续往下阅读
前端权限细化控制
前端权限细化分类大致可以分为四种:
- 菜单的权限控制
- 页面的权限控制
- 按钮的权限控制
- 字段的权限控制
1.菜单的权限控制(以左侧导航菜单为例)
不同角色的用户登录以后,看到的是不同的菜单内容。比如:
- 普通角色(的用户),只能看到某一个菜单
- 管理员角色能看到所有的菜单
- 以
el-menu
菜单组件进行举例说明
2.页面的权限控制
- 角色没登录时,手动在地址栏输入
url
地址跳转,就强制用户跳转到登录页 - 角色登录后,手动在地址栏输入不存在的
url
(或者自己不能看的url
)地址跳转,让其跳转404页面
- 某些特殊的页面,还可以使用
vue-router
中的beforeRouteEnter
再进行细化控制
假设打工仔张三,想要去看老板的页面,因为自己登录时,后端返回的菜单树中没有老板页面的数据,所以路由表中也没有老板页面的路由,所以地址栏url
直接跳转时,就会跳转到一个不存在的路由页面,就不会显示出东西了。
关于路由,大家也可以这样理解:就是当地址栏中输入对应的path
即url
时,路由表大佬
去做对应匹配,匹配到了以后,再去渲染对应的.vue组件
从而呈现对应的数据内容(匹配不到那就404
呗)
3.按钮的权限控制
按钮的控制,可以细分为两块:一是 ~~~ 是否能看到这个按钮、另外是 ~~~ 能看到不过是否能点击这个按钮(按钮是否禁用)
- 按钮是否展示,取决于是否给角色分配了按钮权限。
- 如有的只能看到
查看按钮
,有的增删改查按钮
都可以看到 - 再一个,我们可以把按钮当做是一个特殊的菜单页面
- 最后,要有一个规则限制,新增节点时,可以新增菜单节点,也可以新增按钮节点,只不过按钮节点永远是最底层的位置,不能在按钮节点下再去新增页面(无意义操作)
- 不理解上面那句话,请继续往下阅读
注意,在项目中最好不要使用禁用按钮去控制权限,如果角色用户没有某个按钮的权限,直接删除这个节点即可,比如v-if
,或el.parentNode.removeChild(el)
,因为使用禁用按钮去控制权限存在一定风险,如笔者的这篇文章:
特殊情况下,使用禁用按钮也可以去分配控制权限,具体情况具体分析
4. 字段的权限控制
如下的表格:
姓名 | 年龄 | 家乡 | 爱好 |
孙悟空 | 500 | 花果山 | 大闹天宫 |
比如年龄字段是隐私,只有老板能看到员工的年龄,老板的小姨子和小舅子都不能看到,这个需求的实现可以前端根据角色id进行过滤;或者后端再建表做映射关系,返回给前端。
篇幅原因,这里不赘述了。后续空闲了,笔者再写一篇介绍
后端建菜单表和角色表
我们先从后端开始写,关于每个字段的意思,笔者在代码中提到了
1. 菜单表
两张表都在代码中,大家阅读完文章以后,可以在GitHub
仓库中自行获取
1.1菜单表数据库截图
这里有几个字段需要着重介绍一下:
1.2pid
字段
pid
字段,即为:parentId
是父级节点字段,就是当前节点的父节点,后端在数据库中存储前端菜单树数据时,是不会存成树结构的数据的(JSON形式除外,但这种方式很少用)后端存储的数据是:把树结构的数据铺平(拍平)
以后的数据。
比如我们有这样一个树结构数据
let treeData = [ { id: 1, name: '中国', children: [ // 有的后端喜欢使用child字段,一个意思 { id: 3, name: '北京', }, { id: 4, name: '上海', children: [ { id: 6, name: '浦东新区' } ] }, ] }, { id: 2, name: '美国', children: [ // 有的后端喜欢使用child字段,一个意思 { id: 5, name: '纽约', }, ] }, ]
数据库中可不会直接存一个树结构,数据库会把树结构拍平存起来,即这样存储:
pid | id | name |
---|---|---|
0 | 1 | 中国 |
1 | 3 | 北京 |
1 | 4 | 上海 |
4 | 6 | 浦东新区 |
0 | 2 | 美国 |
2 | 5 | 纽约 |
注意!数据库中不需要存储children
字段,children
字段是一个虚拟字段,是当后端同事查询菜单表数据结构时,将扁平化的数据转成树结构时,递归代码创建的,并返回给前端。
实际上,后端同事可以直接将sql
语句查询到的扁平化数据库数据数组直接丢给前端,至于加工成树结构,也可以由前端同事去加工,不过正常情况下,扁平化数据转成树结构数据都是后端做的,不过前端也要会写相应递归函数
也就是相当于这样的JSON
[ { "pid": 0, "id": 1, "name": "中国", }, { "pid": 1, "id": 3, "name": "北京", }, { "pid": 1, "id": 4, "name": "上海", }, { "pid": 4, "id": 6, "name": "浦东新区", }, { "pid": 0, "id": 2, "name": "美国", }, { "pid": 2, "id": 5, "name": "纽约", } ]
至于树结构如何拍平的,可以笔者写了两个递归函数大家可以看看,函数写法二的可读性更好一些哦
函数写法一:
// 拍平加pid字段 function pidFn(data, sqlArr = [], pid = 0) { // 假设顶级的pid为0 data.forEach((item) => { // 遍历得到每一项 let obj = JSON.parse(JSON.stringify(item)) // 深拷贝一份 obj.pid = pid // 给每一项都赋值pid delete obj.children // 拍平不需要children sqlArr.push(obj) // 丢到sqlArr数组中 if (item.children) { // 有子节点就递归操作 pidFn(item.children, sqlArr, item.id) // 当前项的id就是子项的pid } }) return sqlArr // 一波操作,最后再丢出来即可 } let res = pidFn(treeData) console.log('拍平加父id字段', res);
函数写法二:
function pidFn(data) { let sqlArr = [] // 定义一个数组用于存储拍平后的数据 function digui(data, pid) { // 专门定义个递归函数用户清晰的存储数据 data.forEach((item) => { // 遍历树结构数据 let obj = JSON.parse(JSON.stringify(item)) // 深拷贝一份 obj.pid = pid // 给每一项都赋值pid delete obj.children // 拍平了,就不需要children了 sqlArr.push(obj) // 丢到sqlArr数组中 if (item.children) { // 如果树结构有子节点就继续递归操作 // 当前节点的id就是子节点的pid digui(item.children, item.id) // 递归函数接收树结构数据,以后pid参数 } }) } digui(data, 0) // 递归函数初次执行,假设顶级pid是0 return sqlArr // 将递归的结果丢出去 } let res = pidFn(treeData) console.log('拍平加父id字段', res);
1.3pids
字段
pids
字段是所有的父级节点的组合的数组,比如一个三级节点,它的pid
是二级节点的id
,而它的pids
是所有的父级节点,包括二级节点id
和一级节点id
。
当然数据库存储,不能直接存一个数组进去,所以toString()
转成字符串存储即可
1.4cUrl
字段
cUrl
即componentUrl
组件的地址的意思。就是路由表中用于读取组件的component
函数。比如有以下一个路由表:
{ path: "/welcome", name: "welcome", component: resolve => require(["@/views/pages/welcome.vue"], resolve), },
在数据库中存储为:
url | cUrl | ... |
---|---|---|
/welcome | /pages/welcome.vue | ... |
表示:当地址栏的url
值为/welcome
时,去读取并渲染@/views/pages/welcome.vue
文件,即做到了url
和页面
的对应关系
其他的字段含义,看上述图片的注释即可理解
1.5菜单表sql代码
DROP TABLE IF EXISTS `menus`; CREATE TABLE `menus` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '唯一id', `pid` int(11) NOT NULL COMMENT '上级父节点的id,即为parentId(注意,children字段是不用存储,children字段是递归时,添加进去的)', `pids` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '上级节点的id数组转的字符串', `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '树节点的名字', `url` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '即为菜单的path', `cUrl` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '当访问url时,前端路由需要读取并渲染的.vue文件的路径,一般是相对于views里的', `type` int(255) NULL DEFAULT NULL COMMENT 'type为1是菜单,为2是按钮', `icon` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '菜单的图标名', `sort` int(255) NULL DEFAULT NULL COMMENT '菜单的上下排序', `status` int(255) NULL DEFAULT NULL COMMENT '是否开启字段,1是开启,2是关闭', `isHidden` int(255) NULL DEFAULT NULL COMMENT '是否隐藏菜单,1是显示,2是隐藏', `isCache` int(255) NULL DEFAULT NULL COMMENT '是否缓存,1是缓存,2是不缓存', `remark` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '备注', `isDel` int(255) NULL DEFAULT 1 COMMENT '删除标识,1代表未删除可用,2代表已删除不可用', PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 105 CHARACTER SET = utf8 COLLATE = utf8_unicode_ci ROW_FORMAT = Compact;
2. 角色表
2.1角色表数据库截图
2.2menuIds字段
实际项目中,会有一个角色菜单表
用于做角色和菜单的映射关系,这里笔者直接糅在一块了,便于大家理解。
2.3角色表sql代码
DROP TABLE IF EXISTS `roles`; CREATE TABLE `roles` ( `roleId` int(255) NOT NULL AUTO_INCREMENT COMMENT '每一个角色的id', `roleName` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '每一个角色的name名字', `roleRemark` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '角色的备注', `menuIds` text CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL COMMENT '当前的这个角色能看到(勾选)的菜单的id(给角色赋予菜单)', PRIMARY KEY (`roleId`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 32 CHARACTER SET = utf8 COLLATE = utf8_unicode_ci ROW_FORMAT = Compact;
前端使用角色去登录控制页面的权限
1. 登录页(选择角色发请求获取对应菜单树数据)
1.1效果图
1.2代码思路
el-select
点击下拉框
<el-select v-model="value" placeholder="请选择角色登录" @visible-change=" (flag) => { if (flag) { listRoles(); } } " > <el-option v-for="item in roleList" :key="item.value" :label="item.roleName + ' (' + item.roleRemark + ')'" :value="item.menuIds" > </el-option> </el-select>
发请求获取角色列表有哪些
async listRoles() { const res = await this.$auth.listRoles(); if (res.code == 0) { this.roleList = res.data; } }
接口返回角色列表有以下数据
[ { "roleId": 29, "roleName": "超级管理员", "roleRemark": "能看到所有", "menuIds": "1,19,21,42,30,31,41,22,32,33,27,34,39,40,35,36,37,38,100,101,99,25,26" }, { "roleId": 30, "roleName": "前端", "roleRemark": "只能看前端相关", "menuIds": "1,19,21,42,30,31,41,22,32,33,27,34,39,40,35" }, { "roleId": 31, "roleName": "后端", "roleRemark": "只能看后端相关", "menuIds": "1,36,37,38,100,101" } ]
当我们选择以超级管理员登录时,就根据超级管理员roleId为29
对应的能看到的menuIds
有哪些的菜单,去查询对应的菜单数据。
/auth/roleMenuByMenuId?menuIds=1,19,21,42,30,31,41,22,32,33,27,34,39,40,35,36,37,38,100,101,99,25,26
后端接口如下:
// 根据角色id查询能看到的菜单有哪些(前提是这些菜单是启用状态的) route.get('/roleMenuByMenuId', (req, res) => { // 1. 接收前端传的参数 let menuIds = req.query.menuIds // 2. 拼接sql语句准备去数据库查询 let sql = `SELECT * FROM menus WHERE id IN (${menuIds}) AND isDel = 1 AND status = 1` // 数据库连接池建立连接去数据库中捞数据 pool.getConnection(function (err, connection) { if (err) { throw err } connection.query(sql, function (error, results, fields) { connection.release() let apiRes = { code: 0, msg: "成功", // 注意!注意!注意! // 这时候捞到的数据还是扁平化的数组 // 需要将扁平化的数组转成树结构 // 所以这里定义了一个changeTree函数用于加工数据 data: changeTree(results, 0, 0) } res.send(apiRes) }) }) })
如果大家对于express写接口,做数据库查询忘了,可以看下笔者之前写的一篇全栈文章复习一下遗忘的知识:
打印查到的results
结果:扁平化数组数据截图:
所以需要有个函数方法(工具类),能够将扁平化结构转成树结构,函数如下:
/** * 想要将扁平化数组转成树结构,首先必须知道顶级的pid是啥(0) * 第一步,假设我们只需要找顶级的这一项, * 只需要对比一下那一项的pid是这个pid即可 * 而后递归即可 * */ // 通过pid将扁平化的数组转成树结构。给树结构添加level字段(数据库中没存,当然存也可以) module.exports = function changeTree(arr, pid, level) { let treeArr = [] level = level + 1 // 添加层级字段 arr.forEach((item, index) => { if (item.pid == pid) { // 把不是这一项的剩余几项,都丢到这个children数组里面,再进行递归(这一项已经确定了,是父级,没必要再递归了) let restArr = arr.filter((item, index) => { return item.pid != pid }) item['children'] = changeTree(restArr, item.id, level) // 这里需要进行传id当做pid(因为自己的id就是子节点的pid) if (item.children.length == 0) { delete item.children } // 加一个判断,若是children: [] 即没有内容就删除,不返回给前端children字段 item['level'] = level // 添加层级字段 // 操作完以后,把一整个的都追加到数组中去 treeArr.push(item) } }); return treeArr }
调用:changeTree(results, 0, 0)
这里要告知顶级的pid
,这里我假设为0
,同时level
层级从第0层
开始
经过这样一加工再返回给前端,就是一个树结构的数据了,如下图:
这样的话,前端就可以使用了
因为业务简单,所以这里笔者的后端express没有分层处理
关于后端的分层,前端同事可以这样简单理解:
控制层
接收参数
校验参数
处理参数
.....
业务层
数据处理查、改什么的...
持久层
生成sql语句
数据库操作
数据返回
2.加工后端返回的菜单树数据存在vuex中以供使用
2.1登录成功跳转
async loginIn() { // vuex中发相应的请求 const res = await this.$store.dispatch("menu/tree_menu", this.value); if (res.code == 0) { // 存一份登录的角色名 let i = this.roleList.findIndex((item) => { return item.menuIds == this.value; }); sessionStorage.setItem("username", this.roleList[i].roleName); // 然后登录成功去跳转 this.$message({ type: "success", message: "登录成功", }); this.$router.push({ path: "/" }); } },
2.2vuex中的操作~el-menu需要的菜单树
- 注意,上述图中,后端是返回了菜单树,但是!但是!依旧是不能直接使用!
- 原因是前端还需要再进行一次加工,将菜单树加工成两个树结构的数据
- 一个是
menuTree: [], // el-menu需要的菜单树
- 另一个是
routerTree: [] // vue-router需要的路由树
el-menu
菜单树需要的menuTree
的值是不包含按钮节点的,而因为按钮被当做了特殊的页面节点,所以这里需要过滤一下vue-router
需要的路由树routerTree
也要根据后端返回的菜单树进行加工,加工成具有component
函数属性值的路由树数据
我们先看el-menu
菜单树需要的menuTree
数据的加工函数
// 加工后端返的树结构数据 是给菜单递归组件el-menu使用的 export function setElMenuTreeWithoutBtn(oldTree, newTree) { oldTree.forEach((item) => { let newTreeObj = { ...item, children: null } if (item.children) { // 有子集内容,且子集内容为菜单type=1才去递归(按钮type=2就不要了,这样就过滤了...) if (item.children[0].type == 1) { setElMenuTreeWithoutBtn(item.children, newTreeObj.children = []) } } if (newTreeObj.children == null) { // 为null说明没有子集,或者子集都是按钮被忽略了,删除之 delete newTreeObj.children } newTree.push(newTreeObj) }) return newTree }
调用此方法是在vuex
中的登录逻辑中
const state = { isCollapse: false, menuTree: [], // el-menu需要的菜单树 routerTree: [] // vue-router需要的路由树 }; const mutations = { ... } const actions = { // 相当于login登录接口 tree_menu({ commit, dispatch }, menuIds) { return new Promise((resolve, reject) => { roleMenuByMenuId(menuIds) .then( (res) => { let menuTree = res.data[0].children // 顶级节点PC不要 commit('TREE_MENU', setElMenuTreeWithoutBtn(menuTree, [])) // 加工菜单树给到el-menu组件使用 commit('ROUTE_TREE', setRouterTree(menuTree, [])) // 加工菜单树给到路由表使用 sessionStorage.setItem("token", "token"); // 模拟token存储 sessionStorage.setItem("menuIds", menuIds); // 存一份当前角色的menuIds setBtnAuth(menuTree) // 设置按钮 resolve(res) // 抛出去告知结果 } ).catch(...) }) } }
最终加工好的menuTree
树结构数据,会被el-menu
组件使用:
注意,el-menu
组件放在视图层组件中:
让我们来看一下layout
文件夹中的index.vue
组件是如何使用menuTree
数据的吧
layout-->index.vue
<el-menu ref="elMenu" :collapse="isCollapse" :default-active="activeIndex" class="elMenu" background-color="#333" text-color="#B0B0B2" active-text-color="#fff" :unique-opened="false" router @select="menuSelect" > <!-- 普通菜单(前端写死固定,如首页、欢迎页等) --> <el-menu-item index="/"> <i class="el-icon-s-home"></i> <span slot="title">首页</span> </el-menu-item> <!-- 递归动态菜单(后端返回,不同角色菜单不一致) --> <myitem :data="menuArr"></myitem> </el-menu> import myitem from "./components/myitem.vue"; components: { myitem } data(){ return { menuArr: this.$store.state.menu.menuTree } }
再复习一下递归组件
layout-->components-->myitem.vue
<template> <div> <template v-for="(item, index) in data"> <!-- isHidden值等于1才去显示,等于2隐藏 --> <template v-if="item.isHidden == 1"> <!-- 因为有子集和无子集渲染html标签不一样,所以要分为两种情况:情况一:有子集的情况:--> <template v-if="item.children"> <!-- 有子集去递归显示 --> <el-submenu :key="index" :index="item.url"> <template slot="title"> <i class="el-icon-platform-eleme"></i> <span>{{ item.name }}</span> </template> <myitem :data="item.children"></myitem> </el-submenu> </template> <!-- 情况二:没子集的情况 --> <template v-else> <!-- 没子集直接显示内容即可 --> <el-menu-item :key="index" :index="item.url"> <i class="el-icon-eleme"></i> <span slot="title">{{ item.name }}</span> </el-menu-item> </template> </template> </template> </div> </template> <script> export default { name: "myitem", props: { data: { type: Array, default: [], }, }, // 注意: 在template标签上使用v-for,:key="index"不能写在template标签上,因为其标签不会被渲染,会引起循环错误 }; </script>
关于组件的name属性
,其实不写也行,不过当需要使用到组件的递归自调用或使用keep-alive缓存组件
时,就得加上了。当然也有别的方案...
到目前为止,菜单能够显示了,但是点击菜单却是空白页,因为少了路由表树结构数据了
2.3vuex中的操作~vue-router需要的路由树
路由表中的路由分为静态路由(固定路由)和动态路由(后端根据不同的用户/角色返回的路由),比如可以有以下的静态固定路由:
// 固定的静态路由,比如登录页、首页、404页等...
const staticRoutes = [
{
path: '/',
// component: resolve => require(["@/layout/index.vue"], resolve),
component: Layout, // 二者一个意思
redirect: '/home',
children: [
{
path: "/home",
name: "home",
component: resolve => require(["@/views/home.vue"], resolve),
},
]
},
{
path: '/login',
component: resolve => require(["@/views/login.vue"], resolve),
},
{
path: '/404',
component: resolve => require(["@/views/404.vue"], resolve),
},
// { path: '*', redirect: '/404' }
]
这里有一个坑,404页面的匹配兜底路由
在最后,但是不能直接写在静态路由中的最后一个,会刷新自动到404页面了。因为是动态路由的做法,所以404页面的匹配兜底路由
拼接在vue-router
的路由树数组数据中即可。所以上述的{ path: '*', redirect: '/404' }
笔者注释掉了。
登录获取到后端返回的树结构数据后,加工,给路由表使用
// vuex中
roleMenuByMenuId(menuIds).then(
(res) => {
let menuTree = res.data[0].children // 顶级节点PC不要
commit('ROUTE_TREE', setRouterTree(menuTree, []))
}
)
setRouterTree
加工函数
// 加工后端返的树结构数据 是给vue-router路由表使用
import Layout from '@/layout/index.vue'
let page404 = { path: '*', redirect: '/404' }
export function setRouterTree(oldTree, newTree) {
oldTree.forEach((item) => {
let newTreeObj = {
path: item.level == 2 ? `/${item.url}` : `${item.url}`,
name: item.name,
component(resolve) { require([`@/views${item.cUrl}`], resolve) },
meta: {
title: item.name
}
}
if (item.level == 2) { // 如果是二级,就统一使用layout组件视图层
// newTreeObj['component'] = Layout // 这两个一个意思
newTreeObj['component'] = (resolve) => {
require(["@/layout/index.vue"], resolve)
}
}
if (item.children) {
if (item.children[0].type == 1) { // 路由表树结构也不需要按钮哦,只要type等于1的菜单
setRouterTree(item.children, newTreeObj.children = [])
}
}
newTree.push(newTreeObj)
});
return newTree.concat(page404) // 将404页面拼接到最后面,做通配路由使用
}
打印加工好的路由树结果:
注意箭头指向的地方,component
函数要去引用指向解析组件哦,这个一定要有
所以commit('ROUTE_TREE', setRouterTree(menuTree, []))
的值给到路由表去使用
那么?路由表如何使用加工好的这个路由表树数据呢?使用vue
的addRoutes
方法添加即可,在登录成功以后。加工好以后,直接添加即可
import router from "@/router"; import store from "@/store"; async loginIn() { const res = await this.$store.dispatch("menu/tree_menu", this.value); if (res.code == 0) { /** * 登录成功以后也要动态添加一下路由tip1,或者异步重加载一下tip2 * 否则会出现首次登录router.beforeEach中动态路由方法不触发 * 即首次登录点击动态菜单部分出现空白页(刷新后正常) * */ router.addRoutes(store.state.menu.routerTree); // tip1: 注掉效果明显 let i = this.roleList.findIndex((item) => { return item.menuIds == this.value; }); sessionStorage.setItem("username", this.roleList[i].roleName); this.$message({ type: "success", message: "登录成功", }); this.$router.push({ path: "/" }); // setTimeout(() => { // tip2:注掉效果明显 // location.reload(); // }, 10); } },
最后一步,刷新页面时,vuex
初始化,所以在beforeEach
钩子函数中重新发请求,获取菜单树数据即可
// 路由全局拦截 router.beforeEach((to, from, next) => { // 去登录页面肯定放行的,管他有没有token if (to.path === '/login') { next() } // 去的不是登录页面,再看看有没有token认证 else { const token = sessionStorage.getItem('token') if (!token) { // 没token,就让其回到登录页登录 next({ path: "/login" }) } else { // 有token,再看看有没有菜单路由信息 if (store.state.menu.routerTree.length > 0) { // 有菜单信息,就放行 next() } else { // 没有菜单信息,就再发一次请求获取菜单信息 let menuIds = sessionStorage.getItem('menuIds') store.dispatch('menu/tree_menu', menuIds).then((res) => { if (res.code == 0) { router.addRoutes(store.state.menu.routerTree) next({ ...to, replace: true }) // 确保动态路由已被完全添加进去了 } }) } } } })
前端使用角色去登录控制按钮的权限
按钮的权限思路,在登录时,根据菜单树转成一个按钮树,有按钮的权限,就为true,没有就直接没有。
然后再定义函数去获取某某页面下的某某按钮的值是否为true,为true就说明有权限,为false就说明没有权限,可以使用函数引入方式,或者自定义指令方式。
将菜单树的按钮转成key/value布尔值按钮树形式
目标按钮树结构
{ "前端框架": { "vue页面": { "新增vue": true, "编辑vue": true, "删除vue": true, "占位按钮": true }, "react页面": { "新增react": true, "编辑react": true }, "angular": { "agl1": { "agl1新增/编辑": true, "agl1删除": true }, "agl2": {} } }, "后端框架": { "springBoot": {}, "myBatis": {}, "特工001": {}, "特工002": {} }, "系统设置": { "角色管理": {}, "菜单管理": {} } }
定义函数去设置
依旧是在登录的时候,去根据菜单树去设置按钮树
roleMenuByMenuId(menuIds).then( (res) => { let menuTree = res.data[0].children // 顶级节点PC不要 //...... setBtnAuth(menuTree) // 设置按钮树 resolve(res) // 抛出去告知结果 } )
设置按钮树递归函数
// 按钮权限设置 export function setBtnAuth(tree, btnAuthObj = {}) { // 循环加工 tree.forEach((item) => { if (item.type == 1) { // 类型为1说明是菜单 btnAuthObj[item.name] = {} // 菜单就加上一个对象属性 if (item.children) { // 若有子集,就递归加对象属性 // 因为对象是引用类型,所以直接赋值整个按钮权限对象就都有了 setBtnAuth(item.children, btnAuthObj[item.name]) } } if (item.type == 2) { // 类型为2说明是按钮 btnAuthObj[item.name] = true // 按钮的赋值true表示有按钮权限 } }) // 加工完毕以后,丢出去以供使用 sessionStorage.setItem('btnAuthObj', JSON.stringify(btnAuthObj)) return btnAuthObj }
获取按钮树递归函数
// 按钮权限获取 export function getBtnAuth(whichPage, btnName) { // 查找:那个页面下的什么按钮名字是否有权限 let flag // 找到了,才说明有权限 function getBtn(whichPage, btnName, btnAuthObj = JSON.parse(sessionStorage.getItem('btnAuthObj'))) { for (const key in btnAuthObj) { if (key == whichPage) { flag = btnAuthObj[key][btnName] } else { getBtn(whichPage, btnName, btnAuthObj[key]) } } } getBtn(whichPage, btnName) // 递归查找标识赋值 return flag ? true : false // 找到了为true,没找到undefined,这里再判断一下,返回布尔值 }
函数使用方式
<el-button type="danger" v-if="isShowDeleteBtn">删除vue</el-button> computed: { isShowDeleteBtn() { return getBtnAuth("vue页面", "删除vue"); }, },
自定义指令方式
<el-button type="primary" v-btn="{ vue页面: '新增vue' }">新增vue</el-button> <el-button type="primary" v-btn="{ vue页面: '编辑vue' }">编辑vue</el-button>
自定义指令v-btn
import { getBtnAuth } from "@/utils"; export default { inserted(el, binding, vnode) { const whichPage = Object.keys(binding.value)[0] const btnName = Object.values(binding.value)[0] let flag = getBtnAuth(whichPage, btnName) if (!flag) { el.parentNode.removeChild(el) } }, unbind(el, binding, vnode) { } }
相当于去查询,查 某个页面
中有没有 某个按钮
共用的动态路由组件
比如好几个页面,都是同一个结构内容,只是id不同,这个时候就需要使用动态路由组件了传参
/dynaOne/:001
<template> <div> <span>共用的动态组件:</span> <h2> 根据 <span class="val">{{ val }}</span> 的不同发相应请求 </h2> </div> </template> <script> export default { data() { return { val: Object.values(this.$route.params)[0].slice(1), /* 注意url的配置: 思路是多个路由path使用同一个组件,需要加上监听,解决路由变了,组件不刷新的问题 身份标识id直接url中拼接即可使用 url:'xxx/:001' */ }; }, watch: { $route: { handler: function () { this.val = Object.values(this.$route.params)[0].slice(1); }, }, }, }; </script> <style> .val { color: brown; font-size: 36px; margin: 0 8px; } </style>
总结
- 通过这种方式,就可以做到动态菜单得了。
- 但是纸上得来终觉浅,还是得实操
- 又因为篇幅原因,笔者关于一些细节文章中没有提到
- 所以大家可以去笔者发布出的网站上看效果
- 以及star一下笔者的仓库
- 将代码拉下来跑起来,一点点捋清代码思路
- 这样印象才会深刻
比如后端接口如何写,前端菜单树el-tree
勾选传参细节问题,详情,请看代码
另外,后台管理系统,都有tab
标签页,动态路由的tab标签页
在三层菜单中会出现缓存问题
,后续笔者不忙了,会继续更新这个仓库的哦。
等等...
大家可以随意新增角色,并赋予菜单权限,不过不能编辑菜单数据哦。笔者为了降低自己的数据不被打乱了
如果帮到了您,还请赏赐一个star
,也是咱更文的动力。(PS:写文章真的要花费不少时间哦 )
DROP TABLE IF EXISTS `menus`;
CREATE TABLE `menus` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '唯一id',
`pid` int(11) NOT NULL COMMENT '上级父节点的id,即为parentId(注意,children字段是不用存储,children字段是递归时,添加进去的)',
`pids` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '上级节点的id数组转的字符串',
`name` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '树节点的名字',
`url` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '即为菜单的path',
`cUrl` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '当访问url时,前端路由需要读取并渲染的.vue文件的路径,一般是相对于views里的',
`type` int(255) NULL DEFAULT NULL COMMENT 'type为1是菜单,为2是按钮',
`icon` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '菜单的图标名',
`sort` int(255) NULL DEFAULT NULL COMMENT '菜单的上下排序',
`status` int(255) NULL DEFAULT NULL COMMENT '是否开启字段,1是开启,2是关闭',
`isHidden` int(255) NULL DEFAULT NULL COMMENT '是否隐藏菜单,1是显示,2是隐藏',
`isCache` int(255) NULL DEFAULT NULL COMMENT '是否缓存,1是缓存,2是不缓存',
`remark` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '备注',
`isDel` int(255) NULL DEFAULT 1 COMMENT '删除标识,1代表未删除可用,2代表已删除不可用',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 105 CHARACTER SET = utf8 COLLATE = utf8_unicode_ci ROW_FORMAT = Compact;
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· DeepSeek 开源周回顾「GitHub 热点速览」
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了