Vue 项目权限管理 路由 按钮权限

项目权限管理: 需要给用户分配角色,给角色分配路由、按钮权限。那么我们就需要3个管理页面:

  1. 用户管理 - 给用户添加不同的角色
  2. 角色管理 - 新建各种角色,对应不同的权限(控制路由、按钮)
  3. 菜单管理 - 配置菜单 菜单对应的路由,菜单类型(有可能是按钮、不需要在导航栏显示)

给前端一个接口,当用户登录的时候,返回用户信息,对应的权限路由。

js 复制代码
{
  data: {
    all_menu: [
      {
        id: 74,
        parent: null,
        name: "信息科技外包活动识别及处理",
        frontend_url: "/outsource/list",
        hidden: false,
        resource_type: "菜单",
        en_name: "",
        rank: 1,
        children: [
          {
            id: 120,
            parent: 74,
            name: "采购清单处理",
            frontend_url: "/outsource/process",
            hidden: true,
            resource_type: "按钮",
            en_name: "",
            rank: 1,
            children: []
          },
        ]
      },
      {
        id: 75,
        parent: null,
        name: "信息科技外包活动监控",
        frontend_url: "/monitor",
        hidden: false,
        resource_type: "菜单",
        en_name: "",
        rank: 2,
        children: [
          {
            id: 123,
            parent: 75,
            name: "采购清单监控",
            frontend_url: "/outsource/monitor",
            hidden: true,
            resource_type: "菜单",
            en_name: "",
            rank: 1,
            children: []
          }
        ]
      }
    ],
    user_menu: [74, 75, 123]
  }
}
  • all_menu 字段就是配置的所有菜单、按钮
  • user_menu 字段是用户有权限的菜单、按钮对应的ID

这个菜单数据导航栏需要 路由拦截权限控制也需要,所以我们需要把请求的数据放到Pinia/Vuex里

先写一下请求接口方法 api/menu.js

js 复制代码
import axios from 'axios'

export function fetchMenuApi() {
  // 获取菜单 权限校验
  return axios({
    method: 'get',
    url: '/api/manage/user_menu/',
    params: { app_name: '采购风险' }
  })
}

在store里去请求数据,store/modules/menu.js (Vuex写法)

js 复制代码
import { fetchMenuApi } from '@/api/menu'

// 获取菜单里的所有按钮
const getAllBtns = (list) => {
  const result = []
  function traverse(nodes) {
    for (const node of nodes) {
      if (node.resource_type === '按钮') {
        result.push(node)
      }
      if (node.children && node.children.length) {
        traverse(node.children)
      }
    }
  }
  traverse(list)
  return result
}

const state = {
  menuObj: {}
}

const mutations = {
  SET_MENU: (state, menu) => {
    state.menuObj = menu
  }
}

const actions = {
  async loadMenu({ commit }) {
    const res = await fetchMenuApi()
    // 获取所有按钮 就可以知道按钮对应的en_name 来判断对应的按钮是不是要显示
    const resData = res.data.data
    if (resData && Object.keys(resData)) {
      resData.all_btn = getAllBtns(resData.all_menu)
    }
    commit('SET_MENU', res.data.code === 0 ? resData : {})
    return resData
 }
}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}

Pinia写法

ts 复制代码
// stores/menu.ts
import { defineStore } from 'pinia'
import { fetchMenuApi } from '@/api/menu'

function getAllBtns(list: any[]) {
  const result: any[] = []
  function traverse(nodes: any[]) {
    for (const node of nodes) {
      if (node.resource_type === '按钮') {
        result.push(node)
      }
      if (node.children && node.children.length) {
        traverse(node.children)
      }
    }
  }
  traverse(list)
  return result
}

export const useMenuStore = defineStore('menu', {
  state: () => ({
    menuObj: {} as Record<string, any>
  }),

  actions: {
    async loadMenu() {
      const res = await fetchMenuApi()
      const resData = res.data.data
      if (resData && Object.keys(resData).length) {
        resData.all_btn = getAllBtns(resData.all_menu)
      } 
      // 在 Pinia 中直接改 state
      this.menuObj = res.data.code === 0 ? resData : {}
      return resData
    }
  }
})

对应使用方法

javascript 复制代码
import { useMenuStore } from '@/stores/menu'
const menuStore = useMenuStore()
// 加载菜单
await menuStore.loadMenu()
// 访问菜单对象
console.log(menuStore.menuObj)

我们要在路由拦截那里,增加路由权限方法

js 复制代码
import store from '@/store'
import { homePath, filterArray, getAllPaths, findFirstMatchingUrl } from '@/utils/route'

const router = new Router({
  routes: [
    {
      path: '/',
      name: 'Home',
      component: () => import('../views/layout'),
      redirect: '/outsource/list',
      children: [
        {
         path: '/outsource/list',
         name: '信息科技外包活动识别及处理',
         component: () => import('../views/outsource/index.vue')
        },
        ...
      ]
     },
     {
       path: '*',
       name: 'NotFound',
       component: () => import('../views/not_found/index.vue')
     } // 要放在外面 不然redirect 不会生效 /outsource/list
  ]
})


/**
 * 路由权限控制逻辑说明:
 *
 * 1. 无限循环问题:
 *    - 必须在 await 之前设置 isMenuLoaded = true
 *      否则如果 loadMenu 接口报错:
 *        a) isMenuLoaded 永远为 false
 *        b) axios 拦截器(在 main.js 里)会把 401 错误跳到 /unauthorized
 *        c) 路由会跳到 /error
 *        d) 再次触发 beforeEach,又会去调 loadMenu,导致无限循环
 *
 * 2. frontUrlList 和 authUrlList 的区别:
 *    - authMenuList: 返回一份新菜单 用全部菜单根据用户权限去过滤
 * 
 *    - frontUrlList:系统所有"菜单"路由
 *      * 如果按钮需要权限控制,且按钮对应一个路由,就必须放进去
 *      * 如果按钮没有对应路由,不放进去,因为按钮的显示是通过 en_name 控制
 *      * 这样能保证"菜单管理页面"里只包含真正需要控制权限的菜单和按钮
 *      * 如果不区分的话 把所有按钮对应的路由都放进去 但是按钮不需要做权限控制的话 按钮不会添加到菜单管理页面 导致没权限访问按钮对应的路由
 *
 *    - authUrlList:用户有权限访问的路由(在有权限的菜单里把frontend_url提取出来)
 *      * 不区分菜单还是按钮,只要用户有权限都放进来
 *      * 因为授权控制只看是否有权限,不关心是菜单还是按钮
 *
 * 3. 访问逻辑:
 *    - 如果路由在 frontUrlList 里,但不在 authUrlList 里:
 *        → 说明该路由需要权限控制,但用户没有权限
 *        → 跳转 /unauthorized
 *
 *    - 如果路由不在 frontUrlList 里:
 *        → 说明它不是菜单路由,不需要权限控制
 *        → 直接放行
 *
 * 4. 主页跳转逻辑:
 *    - 如果用户有主页权限:
 *        → 直接进入主页
 *    - 如果没有主页权限:
 *        → 自动跳转到用户第一个有权限的路由
 *        → 该路由的 children 必须为空,且类型为菜单(确保是最后一级路由)
 *        → 如果 children 不为空,children 中的每一个路由都不能是菜单类型
 *          否则表示用户没有主页权限,避免跳到未授权页面
 *
 * 5. 安全控制:
 *    - 如果frontUrlList不完整 要控制的路由不在frontUrlList中:
 *        → 用户无论是否授权,都能访问菜单、按钮对应的路由,存在安全漏洞
 *    - frontUrlList完整:
 *        → 只有授权用户才能访问对应路由,未授权用户会提示"未授权"
 *        → 默认进入页面的逻辑和未授权拦截逻辑必须配合使用
 *          才能既保证默认页体验,又保证权限安全控制
 *
 *。  - 权限清单里不会有/unauthorized 判断是否跳转/unauthorized 要判断的对象不会是/unauthorized
 */


let isMenuLoaded = false
router.beforeEach(async (to, from, next) => {
  if (to.path === '/error') {
    return next()
  }
  if (!isMenuLoaded) {
    try {
      isMenuLoaded = true
      await store.dispatch('menu/loadMenu')
    } catch (e) {
      return next('/error')
    }
  }
  
  let {all_menu, user_menu} = store.state.menu.menuObj
  const authMenuList = filterArray(all_menu, user_menu)
  const authUrlList = getAllPaths(authMenuList, 'auth')
  const frontUrlList = getAllPaths(all_menu, 'menu')
  if (from.path === '/' && to.path === homePath && authUrlList.length) {
    let childUrl = findFirstMatchingUrl(authUrlList, all_menu)
    if (authUrlList.includes(homePath) {
      return next()
    }
    // console.log('authUrlList', authUrlList, 'childUrl', childUrl)
    return next(childUrl)
  }
  
  if(frontUrlList.includes(to.path) && !authUrlList.includes(to.path)) {
    next('/unauthorized')
  } else {
    next()
  }
})

看下上面用到的方法

js 复制代码
import { homePath, filterArray, getAllPaths, findFirstMatchingUrl } from '@/utils/route'

路由拦截用到的方法

js 复制代码
export const homePath = '/outsource/list'
/**
 * 递归过滤菜单数组,只保留用户有权限的菜单项
 *
 * @param arr - 原始菜单数组,每个菜单项可能包含 children
 * @param ids - 用户有权限的菜单 ID 列表
 * @returns 返回一个新的菜单数组,只包含用户有权限的菜单项及其子菜单
 *
 * 用法示例:
 * const authMenuList = filterArray(all_menu, user_menu)
 */
export const filterArray = (arr, ids, isMenuSpecific = false) => {
  return arr
    .map(menuItem => {
      // 检查用户是否有权限访问当前菜单项
      if (ids.includes(menuItem.id) && (!isMenuSpecific || menuItem.resource_type === '菜单')) {
        // 克隆当前菜单项,避免修改原数组
        const newItem = { 
          ...menuItem, 
          ...(isMenuSpecific && { path: menuItem.frontend_url || menuItem.backend_url }) 
        };
        // 如果有子菜单,递归过滤子菜单
        if (menuItem.children && menuItem.children.length > 0) {
          newItem.children = filterArray(menuItem.children, ids, isMenuSpecific);
        }
        // 返回处理后的菜单项
        return newItem;
      }
      // 用户无权限访问,返回 null
      return null;
    })
    // 过滤掉 null 值,即没有权限的菜单项
    .filter(Boolean);
};


/**
* getAllPaths(routes, type)如果type为菜单 
* 那么就要包含需要权限控制的按钮跳转的url
* 只所以注释掉导出总览 导出明细 不加进去
* 因为这2个按钮跳转的不是链接
* 如果按钮跳转的是链接 那么就要在
* node.resource_type === '菜单' 加上对应按钮的URL
*/
export function getAllPaths(routes, type) {
  let paths = []
  function traverse(nodes, type) {
    for (const node of nodes) {
      if (type === 'menu') {
        if (node.frontend_url) {
          if (node.resource_type === '菜单') {
            paths.push(node.frontend_url)
          }
        }
      } else {
        if (node.frontend_url) {
         paths.push(node.frontend_url)
        }
      }
      
      if (node.children) {
        traverse(node.children, type)
      }
    }  
  }
  traverse(routes, type)
  return paths
}


/**
 * 递归检查单个权限项及其子项,判断是否存在匹配的URL且自身children为空
 * @param {object} authItem - 权限项
 * @param {string} targetUrl - 目标URL
 * @returns {boolean} 是否匹配
 */
function checkAuthItem(authItem, targetUrl) {
    // 检查当前项的URL是否匹配(注意字段名:front_url 或 frontend)
    const currentUrl = authItem.front_url || authItem.frontend;
    if (currentUrl === targetUrl) {
        // 若匹配,检查自身children是否为空
        return Array.isArray(authItem.children) && authItem.children.length === 0;
    }

    // 若当前项不匹配,递归检查其子项
    if (Array.isArray(authItem.children) && authItem.children.length > 0) {
        for (const child of authItem.children) {
            if (checkAuthItem(child, targetUrl)) {
                return true;
            }
        }
    }

    return false;
}

/**
 * 找出urlList中第一个满足条件的URL
 * @param {string[]} urlList - 待检查的URL数组
 * @param {object[]} authList - 权限列表(可能多级嵌套)
 * @returns {string|null} 第一个满足条件的URL
 */
function findFirstMatchingUrl(urlList, authList) {
    // 遍历urlList,按顺序检查每个URL
    for (const url of urlList) {
        // 遍历authList中的所有权限项
        for (const authItem of authList) {
            // 递归检查当前权限项及其子项
            if (checkAuthItem(authItem, url)) {
                return url; // 找到第一个匹配项,立即返回
            }
        }
    }
    return null; // 无匹配项
}
导航栏需要菜单数据

layout/index.vue

javascript 复制代码
<el-menu class="admin-menu" text-color="#ffffff" mode="horizontal" unique-opened :default-active="$route.path" router>
  <!-- 一级菜单 -->
  <template v-for="item in authList">
  
</el-menu>


computed: {
  menuObj() {
    return this.$store.state.menu.menuObj
  }
},
methods: {
  getMenu() {
    this.authList = []
    let {all_menu, user_menu} = this.menuObj
    // 返回有权限的菜单 就是说user_menu里有对应的ID且id对应的类型是菜单
    this.authList = filterArray(all_menu, user_menu, true)
  }
},
watch: {
  menuObj: {
    handler(val) {
      if(val?.all_menu?.length) {
        this.getMenu()
      }
    },
    immediate: true
  }
}
按钮权限校验 自定义指令

src/directives/index.js

js 复制代码
import permi from './permission'
export default {
 permi
}

在main.js里引入

vbnet 复制代码
import directives from '@/directives'

Object.keys(directives).forEach(key => {
  Vue.directive(key, directives[key])
})

在permission.js里

javascript 复制代码
import store from '@/store'
export default {
  bind(el, binding) {
    /*
     * 需要一个唯一标识 为什么不用id 因为id是自增的 删除了 那个id就不存在了 前端代码得改
     * 为什么不用路由 可能2个按钮跳转的链接是一样的 那就不可以区分是哪一个按钮 做校验 要显示/隐藏
     * 为什么不用中文名称 因为中文名称可能重复 可能都叫导出
     * 所以用唯一标识 en_name 这样配置的时候要确保en_name唯一 即使把配置的菜单删掉了 看下代码那个对应的en_name叫什么 重新配上 不用改前端代码
    */
    const menuObj = store.state.menu.menuObj
    if (menuObj && Object.keys(menuObj).length) {
      let {all_btn, user_menu} = menuObj
      let item = all_btn.find(item => item.en_name === binding.value)
      if (!item) el.style.display = 'none'
      if (item && !user_menu.includes(item.id)) el.style.display = 'none'
    } else {
      el.style.display = 'none'
    }
  }
}

在组件里应用

ini 复制代码
<el-button v-permi="清单及管理#导出总览">导出总览</el-button>
相关推荐
龙在天4 分钟前
npm run dev 做了什么❓小白也能看懂
前端
hellokai1 小时前
React Native新架构源码分析
android·前端·react native
li理1 小时前
鸿蒙应用开发完全指南:深度解析UIAbility、页面与导航的生命周期
前端·harmonyos
去伪存真1 小时前
因为rolldown-vite比vite打包速度快, 所以必须把rolldown-vite在项目中用起来🤺
前端
KubeSphere1 小时前
Kubernetes v1.34 重磅发布:调度更快,安全更强,AI 资源管理全面进化
前端
wifi歪f2 小时前
🎉 Stenciljs,一个Web Components框架新体验
前端·javascript
1024小神2 小时前
如何快速copy复制一个网站,或是将网站本地静态化访问
前端
掘金一周2 小时前
DeepSeek删豆包冲上热搜,大模型世子之争演都不演了 | 掘金一周 8.28
前端·人工智能·后端
moyu842 小时前
前端存储三剑客:Cookie、LocalStorage 与 SessionStorage 全方位解析
前端
不爱说话郭德纲2 小时前
👩‍💼产品姐一句小优化,让我给上百个列表加上一个动态实时计算高度的方法😿😿
前端·vue.js·性能优化