博客 / 2025

菜单(路由)权限、按钮权限与路由进度条

返回博客 <

前言

在写后台管理或者类似的项目中,开发者希望用户只能访问部分路由或者路由中的部分按钮 那么又该如何实现呢 本篇是基于对硅谷甄选项目(可搜到)的代码进行一个总结

菜单权限

方案一:后端动态返回的路由配置

路由的拆分

  • 静态路由(常量路由):大家都可以拥有的路由
  • 异步路由 :不同身份有的有这个路由 有的没有
  • 任意路由 : 比如说用户输入其他路由都跳到404

后端返回路由配置:后端根据用户的不同角色返回不同的路由表(这个在一开始的时候前后端就应该商量好路由的命名,后端的命名要和前端路由的name保持一致) 前端接收路由配置 :通过API调用后端的接口,获取路由配置 前段生成路由 :前段通过vue router 动态添加路由,将后端返回的路由表注入到vue-router中 渲染页面 :前端根据动态添加的路由,展示用户可以访问的页面

后端返回的数据: 在这里插入图片描述

下面对路由进行拆分 分为常量 异步 任意

//对外暴露配置路由(常量路由)
export const constantRoute = [
  {
    //登录路由
    path: "/login",
    component: () => import("@/views/login/index.vue"),
    name: "login", //命名路由
    meta: {
      title: "登录", //菜单标题
      hidden: true, //路由的标题在菜单中是否隐藏
    },
  },
  {
    //登录成功以后展示数据的路由
    path: "/",
    component: () => import("@/layout/index.vue"),
    name: "layout",
    meta: {
      hidden: false,
    },
    redirect: "/home",
    children: [
      {
        path: "/home",
        component: () => import("@/views/home/index.vue"),
        meta: {
          title: "首页",
          hidden: false,
          icon: "HomeFilled",
        },
      },
    ],
  },
  {
    path: "/404",
    component: () => import("@/views/404/index.vue"),
    name: "404",
    meta: {
      title: "404",
      hidden: true,
    },
  },
  {
    path: "/screen",
    component: () => import("@/views/screen/index.vue"),
    name: "Screen",
    meta: {
      hidden: false,
      title: "数据大屏",
      icon: "Platform",
    },
  },
];

//异步路由
export const asnycRoute = [
  {
    path: "/acl",
    component: () => import("@/layout/index.vue"),
    name: "Acl",
    meta: {
      hidden: false,
      title: "权限管理",
      icon: "Lock",
    },
    redirect: "/acl/user",
    children: [
      {
        path: "/acl/user",
        component: () => import("@/views/acl/user/index.vue"),
        name: "User",
        meta: {
          hidden: false,
          title: "用户管理",
          icon: "User",
        },
      },
      {
        path: "/acl/role",
        component: () => import("@/views/acl/role/index.vue"),
        name: "Role",
        meta: {
          hidden: false,
          title: "角色管理",
          icon: "UserFilled",
        },
      },
      {
        path: "/acl/permission",
        component: () => import("@/views/acl/permission/index.vue"),
        name: "Permission",
        meta: {
          hidden: false,
          title: "菜单管理",
          icon: "Monitor",
        },
      },
    ],
  },
  {
    path: "/product",
    component: () => import("@/layout/index.vue"),
    name: "Product",
    meta: {
      hidden: false,
      title: "商品管理",
      icon: "Goods",
    },
    redirect: "/product/trademark",
    children: [
      {
        path: "/product/trademark",
        component: () => import("@/views/product/trademark/index.vue"),
        name: "Trademark",
        meta: {
          hidden: false,
          title: "品牌管理",
          icon: "ShoppingCartFull",
        },
      },
      {
        path: "/product/attr",
        component: () => import("@/views/product/attr/index.vue"),
        name: "Attr",
        meta: {
          hidden: false,
          title: "属性管理",
          icon: "CollectionTag",
        },
      },
      {
        path: "/product/spu",
        component: () => import("@/views/product/spu/index.vue"),
        name: "Spu",
        meta: {
          hidden: false,
          title: "SPU管理",
          icon: "Calendar",
        },
      },
      {
        path: "/product/sku",
        component: () => import("@/views/product/sku/index.vue"),
        name: "Sku",
        meta: {
          hidden: false,
          title: "SKU管理",
          icon: "Orange",
        },
      },
    ],
  },
];

//任意路由
//任意路由
export const anyRoute = {
  //任意路由
  path: "/:pathMatch(.*)*",
  redirect: "/404",
  name: "Any",
  meta: {
    title: "任意路由",
    hidden: true,
    icon: "DataLine",
  },
};

获取路由的方法

注意:拼接数组时,任意路由要放在最后

//硅谷333: routes['Product','Trademark','Sku']
let guigu333 = ["Product", "Trademark", "Sku"];
function filterAsyncRoute(asnycRoute, routes) {
  return asnycRoute.filter((item) => {
    if (routes.includes(item.name)) {
      if (item.children && item.children.length > 0) {
        item.children = filterAsyncRoute(item.children, routes);
      }
      return true;
    }
  });
}
//硅谷333需要展示的异步路由
let guigu333Result = filterAsyncRoute(asnycRoute, guigu333);
//将常量  异步  任意合并
console.log([...constRoute, ...guigu333Result, anyRoute], "硅谷333");

Product为一级 TradeMark Sku Spu为Product的二级

如果不使用递归 只能筛选出一级 当筛选出Product时,由于三者均为二级路由 并不会再进行深层过滤,最后渲染到页面上三者都在 如果要只展示某几个 要用到递归进行深层筛选并重新赋值给item.children

获取路由完整方法

。。。。。。

import router from '@/router'
//引入路由(常量路由)
import { constantRoute, asnycRoute, anyRoute } from '@/router/routes'
//用于过滤当前用户需要展示的异步路由
function filterAsyncRoute(asnycRoute: any, routes: any) {
  return asnycRoute.filter((item: any) => {
    if (routes.includes(item.name)) {
      if (item.children && item.children.length > 0) {
        //硅谷333账号:product\trademark\attr\sku
        item.children = filterAsyncRoute(item.children, routes)
      }
      return true
    }
  })
}
//创建用户小仓库
const useUserStore = defineStore('User', {
  //小仓库存储数据地方
  state: (): UserState => {
    return {
      。。。。。。。
      menuRoutes: constantRoute, //仓库存储生成菜单需要数组(路由)
      us。。。。。。
    }
  },
  //处理异步|逻辑地方
  actions: {
    。。。。。。。
    //获取用户信息方法
    async userInfo() {
      //获取用户信息进行存储
      const result: userInfoResponseData = await reqUserInfo()
      if (result.code == 200) {
        this.username = result.data.name
        this.avatar = result.data.avatar
        //计算当前用户需要展示的异步路由
        const userAsyncRoute = filterAsyncRoute(asnycRoute, result.data.routes)
        //菜单需要的数据整理完毕
        this.menuRoutes = [...constantRoute, ...userAsyncRoute, anyRoute];
        //目前路由器管理的只有常量路由:用户计算完毕异步路由、任意路由动态追加
        [...userAsyncRoute, anyRoute].forEach((route: any) => {
          router.addRoute(route)
        })
        return 'ok'
      } else {
        return Promise.reject(new Error(result.message))
      }
    },
    。。。。。。
})
//对外暴露小仓库
export default useUserStore

过滤出来每个用户所需要的异步路由(userAsnycRoute) 然后赋值给仓库的menuRoutes 由于这只是将menuRoutes更新 也就是说侧边栏的数据有了 但实际上项目的路由还是只有常量路由,需要动态添加

残留问题

1.深拷贝

之前获取需要的路由方法是浅拷贝,会改变原有的路由 比如说:我现在登录了一个超级权限管理员的账号拥有所有路由 然后又登录了一个只有部分权限(guigu333)的账号 此时经过过滤,menuRoutes只有部分路由展示 然后再次登录超级权限管理员的,就会发现超级权限管理员展示的路由不是全部的路由,而是上次guigu33筛选出的路由 这是因为 : 1.两次登录没有刷新页面 数据也不会进行重新刷新 2.数组是进行的浅拷贝,会改变原有的路由,也就是说asnycRoute被修改了

这里引用深拷贝的方法lodash 需要npm install 进行下载

//引入深拷贝方法
//@ts-expect-error
import cloneDeep from 'lodash/cloneDeep'
。。。。。。
 //获取用户信息方法
    async userInfo() {
      //获取用户信息进行存储
      const result: userInfoResponseData = await reqUserInfo()
      if (result.code == 200) {
        this.username = result.data.name
        this.avatar = result.data.avatar
        //计算当前用户需要展示的异步路由
        const userAsyncRoute = filterAsyncRoute(
          cloneDeep(asnycRoute),
          result.data.routes,
        )
        //菜单需要的数据整理完毕
        this.menuRoutes = [...constantRoute, ...userAsyncRoute, anyRoute]
        //目前路由器管理的只有常量路由:用户计算完毕异步路由、任意路由动态追加
        ;[...userAsyncRoute, anyRoute].forEach((route: any) => {
          router.addRoute(route)
        })
        return 'ok'
      } else {
        return Promise.reject(new Error(result.message))
      }
    },

2.路由加载问题

这样配置路由后,如果你访问的是异步路由,会在刷新的时候出现空白页面。原因是异步路由是异步获取的,加载的时候还没有。因此我们可以在路由守卫文件中改写。这个的意思就是一直加载。

在这里插入图片描述 在这里也分享一下路由守卫的代码(其中有进度条)

//  路由鉴权 项目当中路由能不能被访问的权限校验 (某一个路由什么条件下可以访问)
import router from "@/router";
import setting from "./setting";
import nprogress from "nprogress";

// 引入进度条样式
import "nprogress/nprogress.css";
nprogress.configure({ showSpinner: false });

// 获取用户相关的小仓库内部token数据 去判断用户是否登录
import useUserStore from "@/store/modules/user";
import pinia from "./store";
let userStore = useUserStore(pinia);

// 全局守卫:项目当中任意路由切换都会触发的钩子
// 全局前置守卫

router.beforeEach(async (to: any, from: any, next: any) => {
  document.title = `${setting.title}-${to.meta.title as string}`;
  // to:将要访问那个路由
  // from:从哪个路由来
  // next  路由的放行函数
  //   访问某一个路由之前的守卫
  nprogress.start();
  let token = userStore.token;
  //   获取用户名字
  let username = userStore.username;

  //   用户登录判断
  if (token) {
    //  登录成功 访问login  不能访问 指向首页
    if (to.path == "/login") {
      next({ path: "/" });
    } else {
      // 登录成功访问其余6个

      //有用户信息
      if (username) {
        next();
      } else {
        // 没有用户信息 就在守卫这里发请求获取到了用户信息再放行

        try {
          // 获取用户信息以后再进行放行
          await userStore.userInfo();
          next({ ...to });
        } catch (error) {
          // token过期
          // 用户手动修改了本地token
          // 那么采用退出登录->清空数据

          // 万一:刷新的时候是异步路由,有可能获取到用户信息,异步路由还没有加载完毕,出现空白的效果
          await userStore.userLogout();
          next({ path: "./login", query: { redirect: to.path } });
        }
      }
    }
  } else {
    // 用户未登录判断
    if (to.path == "/login") {
      next();
    } else {
      next({ path: "./login", query: { redirect: to.path } });
    }
  }

  next();
});

// 全局后置守卫
router.afterEach((to: any, from: any) => {
  nprogress.done();
});

// 问题一:任意路由切换实现进度条业务  ---nprogress
// 问题二: 路由鉴权(路由组件访问权限的设置)
// 全部的路由组件 登录 404 任意 首页 数据大屏 权限管理(三个子路由) 商品管理 (4个子路由)

// 用户未登录  可以访问login 其余6个路由不可以访问
// 用户登录 不可以访问login(指向首页) 其余的路由可以访问

方案二:前端基于角色的路由表

前端配置好完整的路由表,并通过meta对象中设置的roles属性来指定那些角色可以访问某些路由 通过vue-router的导航守卫beforeEach()来权限校验,拦截不符合访问权限的访问请求

jsx;
复制代码;
const routerMap = [
  {
    path: "/permission",
    component: Layout,
    meta: {
      title: "permission",
      roles: ["admin", "editor"], // 允许的角色
    },
    children: [
      {
        path: "page",
        component: () => import("@/views/permission/page"),
        meta: {
          title: "pagePermission",
          roles: ["admin"], // 只有管理员可访问
        },
      },
    ],
  },
];

router.beforeEach((to, from, next) => {
  const userRole = localStorage.getItem("userRole");
  if (to.meta.roles && !to.meta.roles.includes(userRole)) {
    // 如果用户角色不在路由的 roles 中,跳转到无权限页面
    next("/unauthorized");
  } else {
    // 否则正常跳转
    next();
  }
});

按钮权限

在某些场景下,除了页面级别的权限控制,按钮级别的控制也是常见的需求,通过自定义指令,可以根据用户的权限动态控制按钮的显示与隐藏

步骤: 1.创建一个src/directive/has.ts文件

import pinia from "@/store";

import useUserStore from "@/store/modules/user";
let userStore = useUserStore(pinia);
export const isHasButton = (app: any) => {
  //    获取对应的用户仓库

  // 全局自定义指令
  app.directive("has", {
    // 代表使用这个全局自定义指令的DOM|组件挂载完毕的时候会执行一次
    // 指令的定义
    mounted(el: any, options: any) {
      // 自定义指令右侧的数值,如果在用户信息buttons数组当中没有
      //从DOM树上干掉

      if (!userStore.buttons.includes(options.value)) {
        el.parentNode.removeChild(el);
      }
    },
  });
};

2.在main.ts文件中注入该文件

// 引入仓库
import pinia from "@/store";

const app = createApp(App);
// 安装element-plus插件
app.use(ElementPlus, {
  locale: zhCn, //国际化配置
});
app.use(pinia);
// 安装自定义插件
app.use(globalComponent);

// 注册模版路由
app.use(router);

// 引入路由鉴权文件
import "./permission";
// 引入自定义指令文件
import { isHasButton } from "./directive/has";
isHasButton(app);
// 将应用挂载到挂载点上
app.mount("#app");

总结

前端权限控制不仅仅是页面和路由的访问权限,还包括按钮、操作权限的细粒度控制。它与后端的权限体系相辅相成,前端负责用户体验的优化与增强,而后端则负责核心的权限认证与校验。在实际项目中,合理设计权限系统可以提高系统的安全性、灵活性和可维护性。通过接口、路由和按钮的权限控制机制,前端开发者可以构建一个更加安全、用户友好的应用系统。


查看 CSDN 原文