vue后台多级菜单权限控制方案(动态路由)附demo
待写
前言
最近做一个中大型的后台菜单权限控制,之前交给一位新同事来做,效果不太理想,存在一些bug,于是重构了一遍。
先看需求,录入菜单的页面大概长这样:
给角色分配菜单权限时大概长这样:
最终呈现的页面效果大概长这样:
先分析一下需求:
- 只显示有权限的菜单,没有权限的菜单不显示
- 如果输入一个路由,是没权限的,则跳转到notfound页面
- 当前页刷新后,需要高亮刷新前的顶部菜单和左侧菜单
- 当输入一个可访问的路由时,需要高亮对应的顶部菜单和左侧菜单
- 当去到一个按钮级别的路由时,需要高亮对应的顶部菜单和左侧菜单
- 没有权限的按钮也不显示
难点在于:高亮当前路由对应菜单,第一次访问的路由需要计算,禁止通过地址栏访问没有权限的路由。
旧方案存在问题
之前的同事采用了缓存的方案,把选中的顶部菜单和左侧菜单id缓存起来,刷新后再重新读取缓存里的id,去做左侧菜单和顶部菜单的高亮,并且通过缓存下来的id找到刷新前的路由,动态跳转到这个路由。这里存在几个问题:1是如果直接通过地址栏输入某个可访问路由,菜单还是显示跳转前的菜单,因为高亮控制全在菜单点击里了,2是没有做路由守卫,有些写在本地的路由可以通过菜单栏输入访问,3是加入了计算登录进来后访问第一个路由的逻辑,然后跟刷新冲突了,导致偶尔会跳转到计算出的路由。整体做的比较乱。
为了解决这些问题,经过分析后发现旧方案存在逻辑上的漏洞,于是决定推翻重来,分步去实现整个权限控制方案。
- 用真实的菜单数据,提供一个简单的数据请求服务
- 搭建相似的目录结构
- 实现路由动态加载
- 找到当前路由的在整个菜单树里的链路
- 根据路由链路计算顶部菜单和左侧菜单的高亮
- 对比可访问菜单的数组,通过自定义指令删除不可访问的按钮
- 通过登录页进来后,跳转到第一个可访问路由
整理好思路后,撸起袖子一步步实现起来。
我们先用脚手架快速创建一个vue项目,创建时选择vue2.0+vue-router+vuex+eslint/prettier。一分钟就搞好。
第一步,创建一个简单服务。
在项目根目录下创建一个server文件夹,一个写服务,另一个是菜单树数据。服务简单点用koa搞一个好了。直接上代码:
const Koa = require("koa");
const app = new Koa();
//导入菜单树json
let { menus } = require("./menu-tree");
app.use(async (ctx, next) => {
await next();
if (ctx.request.path == "/api/menulist") {
ctx.response.type = "json";
ctx.response.body = menus;
} else {
ctx.response.body = "404 not fount";
}
});
app.listen(3333, () => {
console.log("This server is running at http://localhost:" + 3333);
});
然后跑一下命令
node server/main.js
就可以暴露一个接口来模拟真实数据的请求过程了。可以看出,本地服务接口使用了3333的端口,而vue项目接口我会用8081端口,直接请求会有跨域问题,需要写个代理配置。在vue脚手架里这些都变得简单,去根目录新建个文件vue.config.js,贴代码:
module.exports = {
devServer: {
disableHostCheck: false,
port: 8081,
//把端口号3333里的/api接口,代理到8081端口
proxy: {
"/api": {
target: "http://localhost:3333",
},
},
},
};
这样就解决了接口跨域请求的问题。
第二步,模拟真实项目结构
就把项目里的路由文件和views页面贴进来,把多余代码干掉。【这步省略】
第三步,实现动态路由。
动态路由,也就是说一开始把不需要权限验证的路由加进去,其他路由都需要经过跟权限树比对过滤,再使用方法 router.addRoute() 去循环添加。【addRoutes方法官方已经废弃,所以用这个】
核心代码:【src/router/index.js】
import { getAsyncRoutes } from "./authorise";
//默认能访问的路由
const routes = [...notFoundRoute];
//动态加载的路由
const otherRouhes = [
...Indexs,
...service,
...scenic,
...product,
...order,
...setting,
];
const router = new VueRouter({
routes,
});
//异步获计算能访问的动态路由后,再添加动态路由
getAsyncRoutes(otherRouhes).then((asyncRoutes) => {
asyncRoutes.forEach((item) => {
router.addRoute(item);
});
//所有页面找不到时
router.addRoute({
path: "*",
redirect: "/404",
});
});
这里最关键的是把接口返回的菜单权限跟本地的路由比对的逻辑,也就是getAsyncRoutes() 这个方法的实现,我把逻辑都抽离到同级的authorise.js里。处理树结构主要是用到递归会耗点脑子,还有就是要处理好同步异步的问题,es6的promise,还有async,await提供了很大的便利,善用的话同步异步也很容易解决,只要保持清晰的思路就OK。
核心代码:【src/router/authorise.js】
//获取到接口的菜单权限,跟本地的路由匹对,返回可访问的路由
export const getAsyncRoutes = (otherRouhes) => {
return new Promise(function (resolve) {
axios.get("/api/menulist").then((res) => {
//把菜单按sort字段排序一下
let currentTopMenuList = multilevelMenuSort(res.data, "sort");
//筛选可访问路由
let routes = filterRoutes(otherRouhes, currentTopMenuList);
//同步到vuex
store.commit("common/setCurrentTopMenuList", currentTopMenuList);
//筛选所有可访问的按钮权限标记
let allowMenuKeys = filterMenukey(res.data);
store.commit("common/setCurrentBtnPermissionList", allowMenuKeys);
resolve(routes);
});
});
};
/**
* @description 递归比对菜单树,返回可访问的路由
*/
function filterRoutes(routes, menuTree) {
let allowRoutes = [];
function tree(newData) {
for (const data of newData) {
if (data.url) allowRoutes.push(data.url);
if (data.children && data.children.length) {
//如果有子菜单,继续递归
tree(data.children);
}
}
}
tree(menuTree);
//把拿到的数组去重
let arr = [...new Set(allowRoutes)];
//把路由跟权限菜单比对,过滤出拥有权限的路由
let output = routes.filter((item) => {
return arr.includes(item.path);
});
//返回可访问的路由
return output;
}
第四步,找到当前路由的在整个菜单树里的链路
为什么要找链路呢,我认为让菜单高亮应该完全由当前路由决定,而不是通过点击把点到的按钮id存到缓存,这样就会跟用户直接输入或者页面里跳转到别的地方脱钩。
如果拿到了当前路由在整个菜单树里的精确链路,譬如我们项目是4级菜单树结构,我计算得出:一级菜单id==>二级菜单id==>三级菜单id==>四级菜单id,然后存到状态管理里,我们就在任何地方很轻易的知道当前路由需要高亮的顶部菜单,由一级菜单id决定,左侧菜单,由三级菜单id决定。
思路有了,然后需要确定在哪里获取这个链路,什么时候更新这个链路。经过对比后我决定在顶部菜单组件里做这个事情,由于跟路由变化有关,于是我去监听路由变化事件作为计算链路的时机。
监听代码:【src/components/top-nav.vue】
watch: {
"$route.path": {
handler(value) {
//匹配高亮菜单
this.findActiveMenu(value);
},
immediate: true,
},
},
加上immediate: true,是因为组件第一次加载就要执行,不能等到路由改变了才执行。
核心代码:
async findActiveMenu(path) {
//由于第一次访问时,顶部菜单列表是异步的,需要等待结果才能进行计算
let topMenuList = await this.checkCurrentTopMenuList();
//记录当前路由在整个树节点里的链路
let treeLink = [];
/**
* @param tree 树结构菜单
* @param func 判断找到节点的条件
* @description 在树结构里找节点
*/
function treeFind(tree, func) {
for (const data of tree) {
if (func(data)) {
treeLink.push(data); //把最终节点加入链路
return data;
}
if (data.children && data.children.length) {
const res = treeFind(data.children, func);
if (res) {
treeLink.push(data); //把每个结果的父节点加入链路
return res;
}
}
}
return null;
}
for (let k = 0; k < topMenuList.length; k++) {
let output = treeFind(topMenuList[k].children, function (data) {
return data.url === path;
});
if (output) {
//如果找到了这个树下拥有这个节点,则拿到顶级节点的路由id
this.$store.commit("common/setCurrentTopMenuId", topMenuList[k].id);
//把顶级也加到链路
treeLink.push(topMenuList[k]);
treeLink = treeLink.map(({ id, name, level, url }) => {
return { id, name, level, url };
});
//console.log(treeLink);
this.$store.commit("common/setCurrentRouterTreeLink", treeLink);
break;
}
}
},
这里的 treeLink 变量就把路由链路计算出来了,并同步到状态管理去。同时这里的顶部菜单id也计算出来了,直接更新到状态管理就好,无需二次计算。
第五步,根据路由链路计算顶部菜单和左侧菜单的高亮
通过上面几步,其实在vuex里已经存好了几个重要变量:1.顶部的菜单树,2.顶部的当前路由对应的菜单id,3.当前路由在菜单树里的完整链路。这时候我们在vuex的getters里就可以把顶部菜单,顶部选中菜单id,左侧菜单,左侧选中菜单id获取过来了。上代码:【src/store/common/getters.js】
export default {
currentTopMenuList(state) {
return state.currentTopMenuList;
},
currentTopMenuId(state) {
return state.currentTopMenuId;
},
curretnLeftMenuId(state) {
let target = state.currentRouterTreeLink.filter((item) => {
return item.level === 3;
});
if (target && target.length) {
return target[0].id;
} else {
return null;
}
},
currentLeftMenuList(state) {
let target = state.currentTopMenuList.filter((item) => {
return item.id === state.currentTopMenuId;
});
if (target.length) {
return target[0];
} else {
return null;
}
},
};
已经高亮的菜单id,做高亮渲染就分分钟的事情。
第六步,对比可访问菜单的数组,通过自定义指令删除不可访问的按钮
在src下新建一个自定义指令的文件夹directive,新建文件permission.js,里面写自定义权限的代码。
由于我们在第三步里,已经把可访问的按钮级别的所以menuKey收集起来,存到了vuex里的currentBtnPermissionList,于是我们在自定义指令里用这个对比就可以了。当前的menuKey不存在于currentBtnPermissionList,则移除元素。
import store from "../store";
/**
* @description 自定义权限指令,通过收集到的可访问标识比对
*/
function hasPermission(currentPermission) {
const currentBtnPermissionList = store.state.common.currentBtnPermissionList;
if (!currentBtnPermissionList || !currentBtnPermissionList.length) {
return false;
}
return currentBtnPermissionList.some((item) => item === currentPermission);
}
export default {
inserted(el, binding) {
if (!hasPermission(binding.value)) {
el.parentNode.removeChild(el);
}
},
};
然后在main.js入口文件里,去注册这个指令
//自定义按钮权限指令
import permissionDirective from "@/directive/permission";
Vue.directive("permission", permissionDirective);
最后在需要判断的按钮上,调用这个指令就可以实现,没有权限的按钮自动移除的效果。
<button v-permission="'product:eidt'">编辑产品</button>
第七步,通过登录页进来后,跳转到第一个可访问路由
我们拿到整个菜单树,其实算出第一个可访问路由也不难,关键在于如何判断当前进入是刷新,还是通过登录页进来,这时候可以在登录页加一个特殊参数,当捕获到这个query时做特殊处理就好。
这个逻辑写在top-nav组件里的findActiveMenu函数里,在拿到topMenuList后。
关键代码:
//处理从登录页面进来的首次跳转
if (this.$route.query.from === "selectPage") {
let firstGoPage = "/";
let level2 = this.currentTopMenuList[0]?.children;
if (level2 && level2.length) {
let level3 = level2[0]?.children;
//console.log(level3);
if (level3 && level3.length) {
//找到第一个3级菜单的第一个rul
if (level3[0]?.url) firstGoPage = level3[0].url;
}
}
this.$router.push(firstGoPage);
}
完整demo
更多推荐
所有评论(0)