前言

最近做一个中大型的后台菜单权限控制,之前交给一位新同事来做,效果不太理想,存在一些bug,于是重构了一遍。

先看需求,录入菜单的页面大概长这样:
在这里插入图片描述
给角色分配菜单权限时大概长这样:
在这里插入图片描述
最终呈现的页面效果大概长这样:
在这里插入图片描述

先分析一下需求:

  1. 只显示有权限的菜单,没有权限的菜单不显示
  2. 如果输入一个路由,是没权限的,则跳转到notfound页面
  3. 当前页刷新后,需要高亮刷新前的顶部菜单和左侧菜单
  4. 当输入一个可访问的路由时,需要高亮对应的顶部菜单和左侧菜单
  5. 当去到一个按钮级别的路由时,需要高亮对应的顶部菜单和左侧菜单
  6. 没有权限的按钮也不显示

难点在于:高亮当前路由对应菜单,第一次访问的路由需要计算,禁止通过地址栏访问没有权限的路由。

旧方案存在问题

之前的同事采用了缓存的方案,把选中的顶部菜单和左侧菜单id缓存起来,刷新后再重新读取缓存里的id,去做左侧菜单和顶部菜单的高亮,并且通过缓存下来的id找到刷新前的路由,动态跳转到这个路由。这里存在几个问题:1是如果直接通过地址栏输入某个可访问路由,菜单还是显示跳转前的菜单,因为高亮控制全在菜单点击里了,2是没有做路由守卫,有些写在本地的路由可以通过菜单栏输入访问,3是加入了计算登录进来后访问第一个路由的逻辑,然后跟刷新冲突了,导致偶尔会跳转到计算出的路由。整体做的比较乱。

为了解决这些问题,经过分析后发现旧方案存在逻辑上的漏洞,于是决定推翻重来,分步去实现整个权限控制方案。

  1. 用真实的菜单数据,提供一个简单的数据请求服务
  2. 搭建相似的目录结构
  3. 实现路由动态加载
  4. 找到当前路由的在整个菜单树里的链路
  5. 根据路由链路计算顶部菜单和左侧菜单的高亮
  6. 对比可访问菜单的数组,通过自定义指令删除不可访问的按钮
  7. 通过登录页进来后,跳转到第一个可访问路由

整理好思路后,撸起袖子一步步实现起来。

我们先用脚手架快速创建一个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

附上完整demo: https://github.com/Nigular/vue-dynamic-router

Logo

前往低代码交流专区

更多推荐