Vue 3 动态路由 + v-permission 指令:菜单权限 + 按钮权限完整方案
前端动态路由与权限按钮实现方案
引言
在企业级后台管理系统中,权限控制是核心需求之一。传统的静态路由方案将所有路由写死在前端代码中,通过 v-if 控制页面显示,这种方式存在两个问题:一是路由信息暴露在前端代码中,存在安全隐患;二是每次新增菜单都需要前端发版。
动态路由方案则由后端返回菜单数据,前端根据菜单数据动态生成路由,实现了菜单与权限的完全后端管控。本文将分享 Vue 3 + Vue Router 中动态路由的实现方案,以及按钮级权限控制 v-permission 指令的实现。
一、整体架构
flowchart TD
A[用户登录] --> B[获取Token]
B --> C[请求菜单接口]
C --> D[后端返回菜单树]
D --> E[前端解析菜单数据]
E --> F[动态注册路由]
F --> G[渲染侧边栏菜单]
G --> H[用户访问页面]
H --> I{路由守卫校验}
I -->|有权限| J[正常访问]
I -->|无权限| K[跳转403页面]
style F fill:#e1f5fe,stroke:#0288d1
style I fill:#fff9c4,stroke:#f9a825
二、后端菜单数据结构
2.1 菜单实体设计
/**
* 菜单实体
*/
public class SysMenu {
private Long id;
private String menuName; // 菜单名称
private String path; // 路由路径
private String component; // 组件路径
private String icon; // 图标
private Integer menuType; // 类型:1-目录 2-菜单 3-按钮
private String permission; // 权限标识
private Long parentId; // 父菜单ID
private Integer sortOrder; // 排序
private Integer visible; // 是否可见
}
2.2 菜单接口返回格式
后端接口返回树形结构的菜单数据:
{
"code": 200,
"data": [
{
"id": 1,
"menuName": "系统管理",
"path": "/system",
"component": "LAYOUT",
"icon": "setting",
"menuType": 1,
"children": [
{
"id": 2,
"menuName": "用户管理",
"path": "/system/user",
"component": "system/user/index",
"icon": "user",
"menuType": 2,
"children": [
{
"id": 10,
"menuName": "新增用户",
"menuType": 3,
"permission": "system:user:add"
},
{
"id": 11,
"menuName": "删除用户",
"menuType": 3,
"permission": "system:user:delete"
}
]
}
]
}
]
}
2.3 权限标识格式设计
权限标识采用 模块:资源:操作 三段式格式:
格式:module:resource:action
示例:
system:user:add - 系统模块-用户-新增
system:user:edit - 系统模块-用户-编辑
system:user:delete - 系统模块-用户-删除
system:user:query - 系统模块-用户-查询
system:role:add - 系统模块-角色-新增
ai:chat:send - AI模块-对话-发送
ai:chat:export - AI模块-对话-导出
设计原则:
- 模块级:按业务域划分,如 system、ai、lowcode
- 资源级:对应具体的功能资源,如 user、role、chat
- 操作级:CRUD + 业务操作,如 add、edit、delete、query、export、import
三、前端动态路由实现
3.1 路由生成工具函数
/**
* 路由工具 - 将后端菜单数据转换为Vue Router路由配置
*/
import type { RouteRecordRaw } from 'vue-router'
import Layout from '@/layout/index.vue'
/** 后端菜单数据类型 */
interface MenuData {
id: number
menuName: string
path: string
component: string
icon: string
menuType: number // 1-目录 2-菜单 3-按钮
permission: string
children?: MenuData[]
}
/**
* 将后端菜单数据转换为路由配置
* @param menus 后端菜单数据
* @returns Vue Router路由配置数组
*/
export function generateRoutes(menus: MenuData[]): RouteRecordRaw[] {
const routes: RouteRecordRaw[] = []
for (const menu of menus) {
// 按钮类型不生成路由
if (menu.menuType === 3) continue
const route: RouteRecordRaw = {
path: menu.path,
name: menu.path,
meta: {
title: menu.menuName,
icon: menu.icon,
permission: menu.permission,
},
}
// 处理组件
if (menu.component === 'LAYOUT') {
// 目录类型,使用Layout组件
route.component = Layout
} else {
// 菜单类型,动态导入组件
route.component = loadView(menu.component)
}
// 递归处理子菜单
if (menu.children && menu.children.length > 0) {
route.children = generateRoutes(menu.children)
}
routes.push(route)
}
return routes
}
/**
* 动态导入视图组件
* 使用Vite的import.meta.glob实现
*/
const views = import.meta.glob('@/views/**/*.vue')
function loadView(component: string) {
// component格式: system/user/index
const path = `/src/views/${component}.vue`
return views[path] || (() => import('@/views/error/404.vue'))
}
3.2 路由守卫中动态注册
/**
* 路由守卫 - 登录后动态注册路由
*/
import { createRouter, createWebHistory } from 'vue-router'
import { generateRoutes } from './routeUtils'
// 静态路由(不需要权限的页面)
const constantRoutes: RouteRecordRaw[] = [
{ path: '/login', component: () => import('@/views/login/index.vue') },
{ path: '/403', component: () => import('@/views/error/403.vue') },
{ path: '/404', component: () => import('@/views/error/404.vue') },
]
const router = createRouter({
history: createWebHistory(),
routes: constantRoutes,
})
// 标记是否已加载动态路由
let isRoutesLoaded = false
router.beforeEach(async (to, from, next) => {
const token = localStorage.getItem('token')
// 未登录,跳转登录页
if (!token) {
if (to.path === '/login') return next()
return next('/login')
}
// 已登录但访问登录页,跳转首页
if (to.path === '/login') return next('/')
// 动态路由已加载,直接放行
if (isRoutesLoaded) return next()
try {
// 1. 获取后端菜单数据
const menus = await fetchMenuData()
// 2. 生成路由配置
const dynamicRoutes = generateRoutes(menus)
// 3. 动态注册路由
dynamicRoutes.forEach(route => {
router.addRoute(route)
})
// 4. 添加兜底路由
router.addRoute({
path: '/:pathMatch(.*)*',
redirect: '/404',
})
// 5. 标记已加载
isRoutesLoaded = true
// 6. 重新导航到目标路由(确保动态路由生效)
next({ ...to, replace: true })
} catch (error) {
// 获取菜单失败,清除Token跳转登录
localStorage.removeItem('token')
next('/login')
}
})
export default router
3.3 动态路由注册流程
sequenceDiagram
participant U as 用户
participant G as 路由守卫
participant API as 后端接口
participant R as Vue Router
U->>G: 访问 /system/user
G->>G: 检查Token ✓
G->>G: 检查路由是否已加载 ✗
G->>API: GET /api/menus
API-->>G: 返回菜单树数据
G->>G: generateRoutes() 转换路由
loop 每条路由
G->>R: router.addRoute(route)
end
G->>R: router.addRoute(404兜底)
G->>U: next({...to, replace: true})
U->>R: 重新导航 /system/user
R-->>U: 渲染用户管理页面
四、按钮级权限控制
4.1 权限状态管理
/**
* 权限Store - 管理用户权限标识列表
*/
import { defineStore } from 'pinia'
export const usePermissionStore = defineStore('permission', {
state: () => ({
/** 用户拥有的权限标识集合 */
permissions: new Set<string>(),
}),
actions: {
/**
* 设置权限列表
* 从菜单数据中提取所有按钮权限标识
*/
setPermissions(menus: MenuData[]) {
const perms = new Set<string>()
this.extractPermissions(menus, perms)
this.permissions = perms
},
/**
* 递归提取权限标识
*/
extractPermissions(menus: MenuData[], perms: Set<string>) {
menus.forEach(menu => {
if (menu.menuType === 3 && menu.permission) {
perms.add(menu.permission)
}
if (menu.children) {
this.extractPermissions(menu.children, perms)
}
})
},
/**
* 判断是否拥有指定权限
*/
hasPermission(permission: string): boolean {
return this.permissions.has(permission)
},
},
})
4.2 v-permission 指令实现
/**
* v-permission 自定义指令
* 用于按钮级别的权限控制
*
* 使用方式:
* <el-button v-permission="'system:user:add'">新增</el-button>
* <el-button v-permission="['system:user:edit', 'system:user:delete']">操作</el-button>
*/
import type { Directive, DirectiveBinding } from 'vue'
import { usePermissionStore } from '@/stores/permission'
export const vPermission: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding<string | string[]>) {
const permissionStore = usePermissionStore()
const { value } = binding
if (!value) return
const permissions = Array.isArray(value) ? value : [value]
// 检查是否拥有任一权限
const hasPermission = permissions.some(
perm => permissionStore.hasPermission(perm)
)
// 无权限则移除元素
if (!hasPermission) {
el.parentNode?.removeChild(el)
}
},
}
4.3 全局注册指令
// main.ts
import { createApp } from 'vue'
import { vPermission } from './directives/permission'
import App from './App.vue'
const app = createApp(App)
// 注册全局自定义指令
app.directive('permission', vPermission)
app.mount('#app')
4.4 使用示例
<template>
<div class="user-management">
<!-- 工具栏 -->
<div class="toolbar">
<!-- 只有拥有新增权限的用户才能看到此按钮 -->
<el-button v-permission="'system:user:add'" type="primary" @click="handleAdd">
新增用户
</el-button>
<!-- 只有拥有导出权限的用户才能看到此按钮 -->
<el-button v-permission="'system:user:export'" @click="handleExport">
导出
</el-button>
</div>
<!-- 表格操作列 -->
<el-table :data="tableData">
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button v-permission="'system:user:edit'" link @click="handleEdit(row)">
编辑
</el-button>
<el-button v-permission="'system:user:delete'" link type="danger" @click="handleDelete(row)">
删除
</el-button>
<!-- 多权限:拥有重置密码或分配角色任一权限即可看到 -->
<el-button v-permission="['system:user:resetPwd', 'system:user:assignRole']"
link @click="handleMore(row)">
更多
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
五、权限判断函数(编程式使用)
除了指令方式,有时需要在逻辑代码中判断权限:
/**
* 权限判断组合式函数
*/
import { usePermissionStore } from '@/stores/permission'
export function usePermission() {
const permissionStore = usePermissionStore()
/**
* 判断是否拥有指定权限
*/
function hasPermission(permission: string | string[]): boolean {
const perms = Array.isArray(permission) ? permission : [permission]
return perms.some(p => permissionStore.hasPermission(p))
}
return { hasPermission }
}
在逻辑代码中使用:
// 在组合式函数中使用
const { hasPermission } = usePermission()
// 控制导出逻辑
function handleExport() {
if (!hasPermission('system:user:export')) {
ElMessage.warning('您没有导出权限')
return
}
// 执行导出逻辑
doExport()
}
六、权限控制流程总览
flowchart TD
A[用户登录成功] --> B[请求菜单接口]
B --> C[后端根据角色返回菜单]
C --> D[前端解析菜单数据]
D --> E[提取路由信息 → 动态注册路由]
D --> F[提取权限标识 → 存入PermissionStore]
E --> G[侧边栏渲染菜单]
F --> H[v-permission指令控制按钮]
F --> I[hasPermission函数控制逻辑]
style E fill:#e1f5fe,stroke:#0288d1
style F fill:#f3e5f5,stroke:#7b1fa2
style H fill:#fff9c4,stroke:#f9a825
style I fill:#fff9c4,stroke:#f9a825
结论与建议
核心要点
- 动态路由:后端返回菜单数据,前端动态注册路由,实现菜单的完全后端管控
- 权限标识三段式:
模块:资源:操作格式清晰、可扩展、便于管理 - v-permission 指令:声明式权限控制,使用简洁,与模板代码无缝集成
- 路由守卫:在
beforeEach中统一处理动态路由加载,避免白屏问题
注意事项
- 路由刷新问题:动态路由在页面刷新后会丢失,需要在路由守卫中重新加载
- 404 路由兜底:动态路由注册完成后添加
/:pathMatch(.*)*兜底路由 - 权限缓存:权限数据可缓存到 sessionStorage,避免每次刷新都请求接口
- 前后端权限标识一致:权限标识必须前后端统一,建议通过接口获取而非前端硬编码