普通用户改一下地址栏就能进管理后台的模型价目管理页——页面框架和菜单就此全暴露,权限不能再是”登录了就能进”的粗放模式。这件事之后,我决定把权限从”后端的事”变成”前后端一起的事”。评测平台有”游客/普通用户/管理员”三类身份,前端要做路由级、组件级、按钮级三层权限控制。下面是平台对 Vue Router 守卫 + Pinia 状态 + 组件指令的全套实践。
一、权限模型
权限设计上,平台用 RBAC(Role-Based Access Control)——用户挂角色,角色挂权限,前端只判断角色。这个模型比”给每个用户单独配权限”好维护得多,加一个管理员不用逐个改用户。
| 角色 | 权限 |
|---|---|
| 游客(未登录) | 仅可看登录页 |
| user(普通用户) | 评测、Lab、查看自己的数据 |
| admin(管理员) | 全部用户数据、模型价目管理 |
| superAdmin(超管) | 用户管理、系统配置 |
这里有个容易忽略的点:角色表是后端下发的,前端不要写死。我们早期把角色写死在 store 里,后来加 superAdmin 角色时改了一堆判断。现在角色从登录接口的返回里取,前端只管按角色名做判断逻辑。
二、路由元信息定义权限
// router/routes.ts
import type { RouteRecordRaw } from 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
roles?: Array<'user' | 'admin' | 'superAdmin'>
title?: string
icon?: string
}
}
const routes: RouteRecordRaw[] = [
{
path: '/login',
component: () => import('@/views/Login.vue'),
meta: { title: '登录' }
},
{
path: '/',
component: () => import('@/views/Layout.vue'),
meta: { requiresAuth: true, roles: ['user', 'admin', 'superAdmin'] },
children: [
{
path: 'eval',
component: () => import('@/views/EvalLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
component: () => import('@/modules/eval/EvalHome.vue'),
meta: { title: '评测' }
},
{
path: 'sxs',
component: () => import('@/modules/eval/SxSPage.vue'),
meta: { title: 'Side-by-Side' }
}
]
},
{
path: 'admin',
component: () => import('@/views/AdminLayout.vue'),
meta: { requiresAuth: true, roles: ['admin', 'superAdmin'] },
children: [
{
path: 'user',
component: () => import('@/modules/admin/UserList.vue'),
meta: { roles: ['admin', 'superAdmin'] }
},
{
path: 'system',
component: () => import('@/modules/admin/System.vue'),
meta: { roles: ['superAdmin'] }
}
]
}
]
}
]
权限信息写在路由 meta 里,守卫直接读。这个方案对比过”权限码写进接口返回再逐路由比对”的做法,前者声明式更强——一眼看到某个路由谁能进,不需要翻代码逻辑。注意 RouteMeta 的扩展声明全项目只放一个文件,多个文件重复 declare 会编译报错。
三、路由守卫实现
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach(async (to, from) => {
const userStore = useUserStore()
// 1) 不需要登录的页面直接放行
if (!to.meta.requiresAuth) {
return true
}
// 2) 未登录跳登录
if (!userStore.isLogin) {
return { path: '/login', query: { redirect: to.fullPath } }
}
// 3) 拉用户信息(首次进需要)
if (!userStore.profile) {
try {
await userStore.fetchProfile()
} catch (e) {
userStore.logout()
return { path: '/login' }
}
}
// 4) 角色检查
const requiredRoles = to.meta.roles
if (requiredRoles && requiredRoles.length > 0) {
if (!requiredRoles.includes(userStore.profile!.role)) {
return { path: '/403' }
}
}
return true
})
守卫的执行顺序很有讲究:先放行公开页、再拦未登录、然后拉用户信息、最后做角色校验。我们把”拉用户信息”放在守卫里而不是登录时一次性拉,是为了处理”token 还有效但 profile 丢了”的场景——比如刷新页面之后。第 3 步如果拉取失败要 logout 并回登录页,否则会卡在无限重定向里。
四、组件级权限指令 v-permission
// directives/permission.ts
import type { Directive, App } from 'vue'
import { useUserStore } from '@/stores/user'
const permission: Directive = {
mounted(el, binding) {
const { value } = binding
const userStore = useUserStore()
if (!value) return
const required = Array.isArray(value) ? value : [value]
if (!required.includes(userStore.profile?.role)) {
el.parentNode?.removeChild(el)
}
}
}
export function setupPermission(app: App) {
app.directive('permission', permission)
}
<template>
<!-- 普通用户看不到"删除"按钮 -->
<el-button v-permission="'admin'" @click="onDelete">删除</el-button>
<!-- 多角色任一即可 -->
<el-button v-permission="['admin', 'superAdmin']" @click="onExport">导出</el-button>
</template>
组件级权限解决的是”按钮该不该显示”的问题。指令在 mounted 时判断一次,角色不符直接移除 DOM 节点。这里我们对比过 v-if 和指令两种方案,结论是:散落的单个判断用 v-if 更直观,成体系的按钮权限用指令更省代码。指令默认是删 DOM,想保留占位可以改成 display:none,看产品要不要留空位。
五、Pinia store 权限状态
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export type Role = 'user' | 'admin' | 'superAdmin'
export const useUserStore = defineStore('user', () => {
const profile = ref<UserProfile | null>(null)
const role = computed<Role | null>(() => profile.value?.role ?? null)
const isAdmin = computed(() =>
role.value === 'admin' || role.value === 'superAdmin')
const isSuperAdmin = computed(() => role.value === 'superAdmin')
function hasRole(required: Role | Role[]): boolean {
if (!role.value) return false
const r = Array.isArray(required) ? required : [required]
return r.includes(role.value)
}
return { profile, role, isAdmin, isSuperAdmin, hasRole }
})
把权限状态收敛到 store 里,所有判断逻辑只有一个来源。isAdmin、isSuperAdmin 这些 computed 是高频用到的语义化判断,hasRole 方法接收单个角色或数组。平台约定:所有权限判断必须走 store 的方法或 computed,不允许在组件里直接写 profile.value?.role === 'admin' 这种裸判断——否则角色逻辑变更时要改几百处。
六、按钮级权限判断
<template>
<div class="action-bar">
<!-- 用 v-if 判断(更明显) -->
<el-button v-if="userStore.isAdmin" type="danger"
@click="onDelete">删除</el-button>
<!-- 用 v-permission 指令(更简洁) -->
<el-button v-permission="'admin'" @click="onEdit">编辑</el-button>
</div>
</template>
平台两种都支持,团队约定:
- 简单条件用
v-if="store.isXxx"; - 角色判断统一用
v-permission。
七、API 层的权限校验
// api/client.ts
http.interceptors.response.use(
res => res,
err => {
if (err.response?.status === 401) {
useUserStore().logout()
router.push('/login')
}
if (err.response?.status === 403) {
ElMessage.error('无权限访问')
}
return Promise.reject(err)
}
)
前端拦截器也要兜底(防止路由绕过)。路由守卫只挡页面跳转,直接调接口绕开路由是拦不住的,所以响应拦截器必须独立处理 401/403。401 统一登出回登录页,403 给用户提示。注意前端权限只是用户体验的一部分,真正的安全校验在后端,这里只负责让用户体验不出错。
八、动态路由(高级)
// router/index.ts
const staticRoutes: RouteRecordRaw[] = [
{ path: '/login', component: () => import('@/views/Login.vue') },
{ path: '/', component: () => import('@/views/Layout.vue'),
children: [...] }
]
const adminRoutes: RouteRecordRaw[] = [
{
path: '/admin',
component: () => import('@/views/AdminLayout.vue'),
meta: { roles: ['admin', 'superAdmin'] },
children: [
{ path: 'user', component: () => import('@/modules/admin/UserList.vue') }
]
}
]
router.beforeEach(async (to) => {
const userStore = useUserStore()
if (userStore.isAdmin) {
// 动态添加 admin 路由
if (!router.hasRoute('admin')) {
adminRoutes.forEach(r => router.addRoute(r))
return { ...to, replace: true } // 重新匹配
}
}
})
动态路由的核心收益是”按需加载”:普通用户根本不会下载 admin 的 chunk,管理模块的代码对非管理员完全不可见。这个方案对比过”全量注册路由”的做法——全量注册实现简单,但所有权限相关代码都会打进主包,而且地址栏直接输入 admin 路径时守卫逻辑更绕。动态路由也有代价:刷新页面后动态加的路由会丢,要在应用初始化时重新判定并注册,这是必踩的坑。
九、403 / 404 页面
{ path: '/403', component: () => import('@/views/Forbidden.vue') }
{ path: '/:pathMatch(.*)*', component: () => import('@/views/NotFound.vue') }
<!-- views/Forbidden.vue -->
<template>
<el-result icon="warning" title="403" sub-title="无权限访问此页面">
<template #extra>
<el-button @click="$router.push('/')">返回首页</el-button>
</template>
</el-result>
</template>
403 页面有两个细节:一是提示文案不要带具体原因,避免泄漏内部信息;二是要提供返回首页的出口,不然用户就卡死了。404 的通配路由必须放最后,否则会拦截所有未匹配路径。
十、登录后回跳
// 登录页
async function login() {
await userStore.login(form)
const redirect = route.query.redirect as string || '/'
router.push(redirect)
}
守卫跳登录时带 redirect 参数,登录成功后回原页面。这个细节很影响体验:没有回跳的话,用户在评测页被踢到登录,登录完又回到首页,还要再点一遍导航。
十一、退出登录清状态
async function logout() {
await api.post('/api/auth/logout')
userStore.logout()
pinia.state.value = {} // 清空所有 store
router.push('/login')
}
登出时清空所有 store 是容易漏的一步。我们踩过这样的坑:用户 A 登出后,用户 B 登录,结果界面上还残留 A 的数据——因为 store 里的旧状态没清。pinia.state.value = {} 一行搞定,比逐个 store 手动 reset 可靠。
十二、踩过的坑
- 动态路由刷新丢失:F5 后动态加的路由没了。要在
app.mount之前判断并 add。 - beforeEach 死循环:守卫里再次跳转触发无限循环。返回
false或检查from !== to。 - 403 信息泄漏:后端 403 时返回详细原因可能泄漏系统信息。前端只显示”无权限”。
- 角色变更未生效:用户被提权/降权后要等 token 过期才生效。提供”刷新 token 时重新拉 profile”。
- 前端权限 ≠ 后端权限:前端权限只是 UX,后端必须独立校验。前端绕过是常态,安全靠后端。
- 菜单/路由不一致:路由守卫挡住但菜单仍显示。菜单从同一份
meta生成。
这几个坑里,”前端权限不等于后端权限”排在团队共识前面。前端做的所有事情都可以被绕过,地址栏、控制台、抓包改请求都行,所以后端接口必须独立校验。前端权限的作用是体验,不是安全。
十三、最佳实践清单
- 路由
meta.requiresAuth必填; - 角色判断用
userStore.hasRole()不用散落; - 按钮级用
v-permission指令; - API 拦截器兜底 401/403;
- 后端独立校验权限(前端不可信);
- 登出清空所有 store;
- 403 页面友好提示。
这套清单是平台权限模块的验收标准,每条都对应过一次线上问题。新加功能时照着清单过一遍,权限相关的坑基本都能避开。
常见问题(FAQ)
Q1:动态路由什么时候用?
超管才能进的模块用动态路由,避免普通用户下载 admin chunk。
Q2:v-permission 删 DOM 还是 v-show?
删 DOM(默认)。如果想保留占位改 display: none。
Q3:怎么调试权限问题?
在 Pinia store 加 $patch({ profile: { role: 'admin' } }) 临时改角色。