最近做业务模板的时候,遇到了一个很坑的东西,就是要去做接口权限控制,leader说了,最好再把按钮权限控制也做了。
我了个去啊,要知道我们的目标页面都是跟antd-design-pro一样,都是玩的本地数据。我刚接手的时候,连mock都没接入呢。
坑归坑,老板要的还是要满足的。所以经过深入思考,基于现在项目已经有了mock接口,还差本地数据库,考虑菜单权限应该不是很大,于是就用浏览器的localStorage应该就足够用了。
业务思考
目前业务组那边做的权限控制,我看了有两个项目,总结就一个字:烂。。一个是很老的业务,代码一大堆,但是功能不是很全。另一个项目压根就没实现全部的权限控制。
权限控制目前的实现方式大概有两种
- 第一种是全局注册路由,所有的页面都卸载router配置表中,然后在路由守卫中去做权限控制。这里接口返回的路由权限是用作权限表来使用。
- 第二种是局部注册 + 接口返回后
addRoutes,局部注册的是一些固定页面,然后从接口拿到匹配的路由权限后,生成路由表再进行增量。 目前主流使用的都是第一种方案,具体为什么,好像是addRoutes有坑,有重复注册的问题,而且在Vue Router4.x版本好像也废弃了这个方法,而是使用了addRoute单此调用。
那么我把这么个情况报告给了老板,结果老板选择了第二套方案😢,说业务组的人好像更熟悉第二套方案。
行吧,第二套就第二套,下面我要思考的就是如何重构现有代码,现在模板库的菜单还是通过配置表的方式来获取,并没有走接口。那么,来吧,撸代码
实现过程
首先菜单配置需要想到菜单需要哪些配置,结合了多个项目,我整理如下
- 菜单名称
- 菜单类型:目录/菜单/按钮
- 前端路由:页面path
- 菜单图标
- 组件:需要展示的component组件名称 然后我们需要两套配置表,一个是所有组件的Map表,和一个模拟后端接口的路由配置表,大概如下
然后我们的接口应该是在初始化的时候,去查看localStorage,如果里面有就用现成的,不然就初始化,所有先封装一个storage操作对象
export const storage = {
set(key, value) {
localStorage.setItem(key, JSON.stringify(value));
},
get(key) {
return localStorage.getItem(key) ? JSON.parse(localStorage.getItem(key)) : undefined;
},
remove(key) {
localStorage.removeItem(key);
},
};
我们原始的路由表是没有菜单id的,所以在初始化数据的时候,还要加上菜单的id
export function initMenuList() {
const ids = [];
let list;
if (storage.get('menuList')) {
list = storage.get('menuList');
} else {
list = menuList;
list = cloneListBy(list, 'children', (item) => {
let id;
// 生成menu唯一键
while (ids.includes((id = (Math.random() * 999999).toFixed(0)))) {
ids.push(id);
}
return {
...item,
//是否隐藏菜单
hideMenu: item.hideMenu || false,
//菜单Id
id,
//菜单图标类型
iconType: item.iconType || '',
children: item.children || [],
};
});
storage.set('menuList', list);
}
return list;
}
cloneListBy是我自己封装的一个方法,因为没有后端,我自己在玩数组,所以我肯定经常需要对多维数组进行格式化。我干脆写了一个公共方法去做这件事。
/**
* @desc: 根据指定条件返回新数组
* @param {*} list 原始多维数组
* @param {*} key 子组key 如:children
* @param {*} fn 复制条件
* @returns [{},{},...]
*/
export function cloneListBy(list, key, fn) {
return list.map((item) => {
const o = fn(item);
if (item[key] && item[key].length) {
o[key] = cloneListBy(item[key], key, fn);
}
return o;
});
}
然后是写Mock接口,让项目初始化的时候去调取,拿到路由配置表
import Mock from 'mockjs';
import { initMenuList, filterListBy } from '@/utils';
export default Mock.mock(/\/api\/auth\/queryMenuList\/*/, ({ body }) => {
const { query } = JSON.parse(body);
let list = initMenuList();
if (query) {
list = filterListBy(list, 'children', (item) =>
item.menuText.indexOf(query) !== -1);
}
return Mock.mock({
code: 'success',
data: {
list,
},
});
});
filterListBy是过滤多维数组的一个方法
/**
* @desc: 根据指定条件过滤多维数组
* @param {*} list 多维数组
* @param {*} key 子组key 如:children
* @param {*} fn 过滤条件
* @returns [{},{},...]
*/
export function filterListBy(list, key, fn) {
return list.filter((item) => {
if (item[key] && item[key].length) {
item[key] = filterListBy(item[key], key, fn);
}
const hasItem = fn(item);
return hasItem || item[key].length;
});
}
在页面初始化App.vue中,定义以下方法
这里做了个偷懒的事情,没有去使用store维护菜单数据,因为我觉得菜单修改后,刷新变更也是可以接受的。
现在项目改造已经完成了,页面菜单导航/路由注册都是通过接口来实现的了。
下面就要去写菜单权限配置页面了
先看下成型的页面长什么样
由于代码比较多,就不写代码了,简单说下实现的逻辑
- 首先我之前封装过树形组件,这省了很多工作量,树形组件的数据就是接口返回的数据
- 然后右侧是一个多态(支持展示/编辑)的
el-form,也是我之前封装好的😍 - 这里的限制就是要
严格符合: 目录下面只能是菜单 菜单下面只能是按钮 不管是拖拽排序还是编辑上级目录,都要做好这个限制,不然会结构混乱 - 而且编辑已存在菜单的情况要,要禁止修改菜单的类型,防止出现子菜单类型混乱的情况
- 要根据父级菜单来判断当前菜单的类型,也是要符合之前的限制条件
以下是几个mock接口的封装
//新增菜单
import Mock from 'mockjs';
import {
storage, initMenuList, flattenArrayBy, findItemByList,
} from '@/utils';
export default Mock.mock(/\/api\/auth\/addMenu\/*/, ({ body }) => {
const { data } = JSON.parse(body).params;
const list = initMenuList();
const ids = flattenArrayBy(list, 'children', 'id');
let id;
// 添加子菜单
if (data.parentId) {
while (ids.includes((id = (Math.random() * 999999).toFixed(0)))) {}
const item = findItemByList(list, 'id', data.parentId);
if (!item.children) {
item.children = [];
}
item.children.push({
...data,
id,
children: [],
});
} else { // 添加顶级菜单
list.push({
...data,
children: [],
});
}
storage.set('menuList', list);
return Mock.mock({
code: 'success',
id,
});
});
//编辑菜单
import Mock from 'mockjs';
import {
storage, initMenuList, findItemByList, findItemParentByList,
} from '@/utils';
export default Mock.mock(/\/api\/auth\/editMenuById\/*/, ({ body }) => {
const list = initMenuList();
const { id, data } = JSON.parse(body).params;
// parentId如果是数组则取最后一位
const parentId = Array.isArray(data.parentId)
? data.parentId[data.parentId.length - 1]
: data.parentId;
const parent = findItemByList(list, 'id', parentId) || list;
const index = (parent.children || parent).findIndex((item) => item.id === id);
// 如果没有层级移动
if (index > -1) {
const item = (parent.children || parent)[index];
for (const key in data) {
if (key !== 'children') {
item[key] = data[key];
}
}
} else {
const lastParent = findItemParentByList(list, 'id', id);
const lastIndex = lastParent.children.findIndex((item) => item.id === id);
const item = lastParent.children.splice(lastIndex, 1)[0];
(parent.children || parent).push(item);
}
storage.set('menuList', list);
return Mock.mock({
code: 'success',
});
});
//删除菜单
import Mock from 'mockjs';
import { storage, initMenuList } from '@/utils';
let list;
function findItemParent(list, key, val) {
for (const o of list || []) {
if ((o.children || []).findIndex((e) => e[key] === val) !== -1) return o;
const o_ = findItemParent(o.children, key, val);
if (o_) return o_;
}
}
export default Mock.mock(/\/api\/auth\/removeMenu\/*/, ({ body }) => {
const list = initMenuList();
const { id } = JSON.parse(body).params;
const item = findItemParent(list, 'id', id);
// 如果是子级菜单
if (item) {
item.children = item.children.filter((item) => item.id !== id);
} else { // 如果是顶级菜单
const index = list.findIndex((item) => item.id === id);
list.splice(index, 1);
}
storage.set('menuList', list);
return Mock.mock({
code: 'success',
});
});
//拖拽操作
import Mock from 'mockjs';
import { storage, initMenuList, findItemParentByList } from '@/utils';
export default Mock.mock(/\/api\/auth\/dropMenu\/*/, ({ body }) => {
const { dragId, dropId, type } = JSON.parse(body).params;
const list = initMenuList();
// 被拖拽节点父级
let dragParent = findItemParentByList(list, 'id', dragId);
// 目标节点父级
let dropParent = findItemParentByList(list, 'id', dropId);
// 如果是插入
if (type === 'inner') {
// 被拖拽节点为顶级菜单兜底
dragParent = dragParent || list;
// 插入操作不会被插入到顶级菜单下
dropParent = dropParent || list.find((item) => item.id === dropId);
// 被拖拽节点下标
const dragIndex = (dragParent.children || dragParent).findIndex(
(item) => item.id === dragId,
);
// 被拖拽节点实例
const dragItem = (dragParent.children || dragParent).splice(dragIndex, 1)[0];
// 目标节点children下插入
dropParent.children.push(dragItem);
} else { // 排序
dragParent = dragParent || list;
dropParent = dropParent || list;
const dragIndex = (dragParent.children || dragParent).findIndex(
(item) => item.id === dragId,
);
const dragItem = (dragParent.children || dragParent).splice(dragIndex, 1)[0];
const dropIndex = (dropParent.children || dropParent).findIndex(
(item) => item.id === dropId,
);
const dropItem = (dropParent.children || dropParent)[dropIndex];
if (type === 'before') {
(dropParent.children || dropParent).splice(dropIndex, 1, ...[dragItem, dropItem]);
} else {
(dropParent.children || dropParent).splice(dropIndex, 1, ...[dropItem, dragItem]);
}
}
storage.set('menuList', list);
return Mock.mock({
code: 'success',
});
});