权限控制:菜单级与按钮级
1. 三层权限模型
登录解决的是"你是谁",权限解决的是"你能做什么"。前端权限有三个层次:
┌─────────────────────────────────────┐
│ 1. 路由级:能不能进这个页面 │
│ └── 路由守卫拦截 │
├─────────────────────────────────────┤
│ 2. 菜单级:侧边栏看不看得到 │
│ └── 根据角色过滤菜单数据 │
├─────────────────────────────────────┤
│ 3. 按钮级:页面内某个操作能不能做 │
│ └── v-if / v-permission 指令 │
└─────────────────────────────────────┘
后端对应:
| 前端 | Spring Security |
|---|---|
| 路由守卫拦截 | HttpSecurity.authorizeRequests().antMatchers().hasRole() |
| 菜单根据角色过滤 | 查询当前用户有权限的菜单列表(菜单表 + RBAC) |
v-permission="'user:delete'" |
@PreAuthorize("hasAuthority('user:delete')") |
一个核心认知:前端权限只是"体验增强",不是"安全防线"。最终判断必须由后端做。前端能改本地 JS,把 v-if 去掉就能看到隐藏按钮——但点下去后端会 403 拦截。前端做权限是为了不让用户看到不能用的功能,而不是为了阻止攻击者。
2. 权限模型设计:角色 vs 权限码
角色(Role):admin、editor、viewer
权限码(Permission):note:create、note:delete、user:manage
两种方式对应不同粒度:
| 场景 | 推荐方案 |
|---|---|
| 角色少、权限边界清晰(管理员/普通用户) | 角色 |
| 角色多、功能细、要灵活配置 | 权限码 |
| 想两者结合 | 角色包含权限码,前端按权限码判断 |
in4vue 用**"角色包含权限码"**的方式:info.roles = ['editor'],info.permissions = ['note:create', 'note:edit']。
// src/stores/user.ts 补充
export interface UserInfo {
id: number
username: string
roles: string[]
permissions: string[]
}
// 判断权限的工具
const hasPermission = (code: string | string[]) => {
if (!info.value) return false
const codes = Array.isArray(code) ? code : [code]
return codes.some((c) => info.value!.permissions.includes(c))
}
const hasAnyRole = (role: string | string[]) => {
if (!info.value) return false
const roles = Array.isArray(role) ? role : [role]
return roles.some((r) => info.value!.roles.includes(r))
}
return { /* ... */ hasPermission, hasAnyRole }
3. 路由级权限:两种实现思路
3.1 静态路由 + 守卫过滤(简单)
所有路由在前端写死,守卫里判断当前用户是否有权限:
// src/router/index.ts
const routes = [
{
path: '/admin/users',
component: () => import('@/pages/admin/UserList.vue'),
meta: { requiresAuth: true, permissions: ['user:manage'] },
},
{
path: '/admin/logs',
component: () => import('@/pages/admin/Logs.vue'),
meta: { requiresAuth: true, roles: ['admin'] },
},
]
router.beforeEach((to) => {
const userStore = useUserStore()
if (!to.meta.requiresAuth) return true
if (!userStore.isLoggedIn) return { path: '/login', query: { redirect: to.fullPath } }
// 权限码检查
const perms = (to.matched.flatMap((r) => r.meta.permissions) as string[]).filter(Boolean)
if (perms.length && !perms.some((p) => userStore.hasPermission(p))) {
return { path: '/403' }
}
// 角色检查
const roles = (to.matched.flatMap((r) => r.meta.roles) as string[]).filter(Boolean)
if (roles.length && !userStore.hasAnyRole(roles)) {
return { path: '/403' }
}
return true
})
适合:权限相对固定、页面总数不多的项目。in4vue 用这种。
3.2 动态路由:登录后从后端拉路由表
适合"不同角色看到完全不同的功能模块"的复杂后台。登录后调接口:
// 登录成功后
const { menus } = await getUserMenusApi()
// menus 类似:[{ path: '/notes', component: 'NoteList', meta: {...} }]
// 动态添加路由
menus.forEach((m) => {
router.addRoute({
path: m.path,
component: loadComponent(m.component),
meta: m.meta,
})
})
坑点:
- 后端返的
component是字符串,前端要映射到() => import(...)。搞个组件字典:const components = { NoteList: () => import('@/pages/NoteList.vue'), UserList: () => import('@/pages/admin/UserList.vue'), } 404路由必须最后再加,否则会先匹配到它:menus.forEach((m) => router.addRoute(m)) router.addRoute({ path: '/:pathMatch(.*)*', component: NotFound })- 页面刷新时路由还没加载完,守卫可能放行到 404。守卫里判断"是否已拉过菜单",没拉就先拉再继续:
router.beforeEach(async (to) => { if (userStore.isLoggedIn && !userStore.menusLoaded) { await userStore.loadMenus() return { ...to, replace: true } // 重新进一次 } })
结论:动态路由强大但复杂。学习阶段先用静态路由,等产品真的需要"按角色配菜单"再升级。
4. 菜单级权限:渲染时过滤
不管是静态还是动态路由,菜单的显示都基于"当前用户能进哪些路由"。
// src/composables/useMenus.ts
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { computed } from 'vue'
export interface MenuItem {
path: string
title: string
icon?: any
children?: MenuItem[]
}
export function useVisibleMenus() {
const router = useRouter()
const userStore = useUserStore()
// 所有需要出现在菜单里的路由都加 meta.menu = true
const allMenus = router.getRoutes()
.filter((r) => r.meta.menu)
.map((r) => ({
path: r.path,
title: r.meta.title as string,
icon: r.meta.icon,
permissions: r.meta.permissions as string[] | undefined,
roles: r.meta.roles as string[] | undefined,
}))
const visibleMenus = computed<MenuItem[]>(() => {
return allMenus.filter((m) => {
if (m.roles?.length && !userStore.hasAnyRole(m.roles)) return false
if (m.permissions?.length && !m.permissions.some((p) => userStore.hasPermission(p))) {
return false
}
return true
})
})
return { visibleMenus }
}
侧边栏使用:
<script setup lang="ts">
import { useVisibleMenus } from '@/composables/useMenus'
const { visibleMenus } = useVisibleMenus()
</script>
<template>
<router-link v-for="m in visibleMenus" :key="m.path" :to="m.path">
<el-icon><component :is="m.icon" /></el-icon>
<span>{{ m.title }}</span>
</router-link>
</template>
Java 对照:这就是"后台菜单表 + 用户角色关联查询"的前端版,只不过数据源从 SQL 变成了 Vue Router 的路由表 + Pinia 的用户信息。
5. 按钮级权限:v-permission 自定义指令
最常见的需求:"管理员能看到删除按钮,普通用户看不到。" 用 v-if 能做,但满屏 v-if 读起来累:
<!-- 啰嗦版 -->
<el-button v-if="userStore.hasPermission('note:delete')">删除</el-button>
写一个自定义指令:
// src/directives/permission.ts
import type { Directive, DirectiveBinding } from 'vue'
import { useUserStore } from '@/stores/user'
/**
* v-permission 指令:根据权限码控制元素显示
*
* 用法:
* v-permission="'note:delete'"
* v-permission="['note:delete', 'note:archive']" // 任意一个满足
*/
export const vPermission: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding<string | string[]>) {
const userStore = useUserStore()
const required = binding.value
if (!userStore.hasPermission(required)) {
el.remove()
}
},
}
注册:
// src/main.ts
import { vPermission } from '@/directives/permission'
app.directive('permission', vPermission)
使用:
<el-button v-permission="'note:delete'" type="danger">删除</el-button>
<el-button v-permission="['note:delete', 'note:archive']">归档或删除</el-button>
为什么用 el.remove() 而不是 el.style.display = 'none':
display: none元素仍在 DOM 里,爱折腾的用户能用 DevTools 改回来el.remove()直接不渲染,干净
5.1 角色版指令
同理:
export const vRole: Directive = {
mounted(el, binding) {
const userStore = useUserStore()
if (!userStore.hasAnyRole(binding.value)) el.remove()
},
}
<el-button v-role="'admin'">管理员专属</el-button>
5.2 响应式版(权限会变时)
上面的指令只在 mounted 时判断一次。如果用户权限会在运行时变化(切换账号、角色变更),要升级到响应式:
import { watchEffect } from 'vue'
export const vPermission: Directive = {
mounted(el, binding) {
const userStore = useUserStore()
const parent = el.parentNode
const placeholder = document.createComment('v-permission')
const stop = watchEffect(() => {
const ok = userStore.hasPermission(binding.value)
if (ok && !el.isConnected) {
parent?.insertBefore(el, placeholder)
placeholder.parentNode?.removeChild(placeholder)
} else if (!ok && el.isConnected) {
parent?.insertBefore(placeholder, el)
parent?.removeChild(el)
}
})
;(el as any)._permissionStop = stop
},
unmounted(el) {
;(el as any)._permissionStop?.()
},
}
大多数项目登录后权限不变,第一版(mount 时一次判断)就够。
6. 组合式函数版:usePermission
除了指令,还可以用 composable,更灵活(能拿返回值做条件判断):
// src/composables/usePermission.ts
import { useUserStore } from '@/stores/user'
export function usePermission() {
const userStore = useUserStore()
const can = (code: string | string[]) => userStore.hasPermission(code)
const hasRole = (role: string | string[]) => userStore.hasAnyRole(role)
return { can, hasRole }
}
用在 script 里做逻辑判断:
<script setup lang="ts">
import { usePermission } from '@/composables/usePermission'
const { can } = usePermission()
const onSave = () => {
if (can('note:edit')) {
// 保存逻辑
}
}
</script>
模板里也能用:
<el-button v-if="can('note:publish')">发布</el-button>
指令 vs composable 怎么选:
- 纯展示控制 → 指令(语义清晰:
v-permission) - 需要配合逻辑判断 → composable(
if (can(...))) - 两种都可以在项目里共存
7. 表格行内操作:动态列
表格的"操作列"里的按钮要按权限显示。组合拳:
<script setup lang="ts">
import { usePermission } from '@/composables/usePermission'
const { can } = usePermission()
</script>
<template>
<el-table :data="list">
<el-table-column prop="title" label="标题" />
<el-table-column prop="author" label="作者" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button v-if="can('note:edit')" link type="primary" @click="edit(row)">
编辑
</el-button>
<el-button v-if="can('note:delete')" link type="danger" @click="remove(row)">
删除
</el-button>
<el-button v-if="can('note:publish') && row.status === 'draft'" link @click="publish(row)">
发布
</el-button>
</template>
</el-table-column>
</el-table>
</template>
细节:操作列宽度随权限显示的按钮数量变化时会有抖动。简单做法——给操作列一个固定宽度,即使某用户只看到 1 个按钮也预留位置。
8. 403 页面
访问无权限页面时跳到 /403,给出友好提示:
<!-- src/pages/Forbidden.vue -->
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
</script>
<template>
<div class="flex min-h-dvh flex-col items-center justify-center gap-4">
<h1 class="text-6xl font-bold text-gray-300">403</h1>
<p class="text-gray-600">抱歉,你没有访问这个页面的权限</p>
<el-button type="primary" @click="router.replace('/')">返回首页</el-button>
</div>
</template>
路由里加:
{ path: '/403', component: () => import('@/pages/Forbidden.vue') }
9. 数据级权限:只看自己的数据
除了界面能不能看、能不能点,还有一层:"即使能打开列表页,也只能看到自己相关的数据"。
这一层必须由后端做,前端不用特别处理——发请求时后端根据 token 识别用户,返回过滤后的数据即可。
前端要做的只是:当后端返回空列表时显示友好提示,不要让用户以为页面坏了。
<el-empty v-if="list.length === 0" description="暂无你参与的笔记" />
10. 测试权限的开发技巧
本地开发时经常要切换不同角色测效果。小技巧:
// src/stores/user.ts 加一个开发辅助
const mockRole = (role: 'admin' | 'editor' | 'viewer') => {
if (import.meta.env.DEV) {
info.value = {
id: 1,
username: `mock-${role}`,
roles: [role],
permissions: {
admin: ['note:create', 'note:edit', 'note:delete', 'user:manage'],
editor: ['note:create', 'note:edit'],
viewer: [],
}[role],
}
token.value = 'mock-token'
}
}
开发者控制台里:
// 浏览器控制台
$pinia.state.value.user.info = { roles: ['admin'], permissions: ['*'] }
或者直接做一个开发模式专用的"角色切换器"组件——仅在 import.meta.env.DEV 下挂载,生产构建时不打包。
11. 常见坑点
| 现象 | 原因 | 解法 |
|---|---|---|
| 指令不生效,按钮还在 | 忘了在 main.ts 注册 |
app.directive('permission', vPermission) |
| 菜单过滤了但路由还能直接访问 URL | 只做了菜单级没做路由级 | 路由级必须独立做一遍 |
| 动态路由刷新后 404 | addRoute 要在路由守卫里判断 |
守卫里判断"用户信息存在但路由未加载"时先加载 |
| 前端隐藏了但 API 还能调 | 前端权限不是安全防线 | 后端必须再判一次 |
指令 + v-if 混用导致不显示 |
指令在 v-if 为 false 时不执行 | 选一种,不要叠加 |
12. 心智模型:三层协作
回到开头那张图,每层权限都有具体对应物:
┌──────────────────────────────────────────┐
│ 1. 路由级 → router.beforeEach │
│ "不让你进来" │
├──────────────────────────────────────────┤
│ 2. 菜单级 → useVisibleMenus + v-for │
│ "不让你看到入口" │
├──────────────────────────────────────────┤
│ 3. 按钮级 → v-permission / usePermission│
│ "不让你点关键按钮" │
├──────────────────────────────────────────┤
│ 4. 数据级 → 后端过滤 │
│ "即使能看列表,只给你自己的数据" │
└──────────────────────────────────────────┘
完整的权限系统是前端 3 层 + 后端 1 层的组合。省略任何一层体验或安全都会出问题——前端少了"按钮级"用户会看到点下去 403 的按钮;后端少了"数据级"用户能看到别人的数据。
小练习
- 在
user.ts里加hasPermission和hasAnyRole方法 - 实现
v-permission指令并注册 - 在笔记列表的操作列里用
v-permission控制按钮 - 把侧边栏菜单改成响应用户权限的动态过滤版本
- 加一个
/403页面,在守卫里跳转测试
延伸阅读
- Vue 3 - 自定义指令
- Vue Router - 动态添加路由
- RBAC 模型介绍
- vue-element-admin 的权限实现(参考源码)
- CASL(更复杂权限表达式的库,如"用户能编辑自己创建的资源")