项目权限管理: 需要给用户分配角色,给角色分配路由、按钮权限。那么我们就需要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>