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

发布于:2025-03-24 ⋅ 阅读:(29) ⋅ 点赞:(0)

前言

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

菜单权限

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

路由的拆分

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

总结

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