路由守卫与权限控制实现方法详解(AI 大模型评测平台的前端鉴权)

普通用户改一下地址栏就能进管理后台的模型价目管理页——页面框架和菜单就此全暴露,权限不能再是”登录了就能进”的粗放模式。这件事之后,我决定把权限从”后端的事”变成”前后端一起的事”。评测平台有”游客/普通用户/管理员”三类身份,前端要做路由级、组件级、按钮级三层权限控制。下面是平台对 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 生成。

这几个坑里,”前端权限不等于后端权限”排在团队共识前面。前端做的所有事情都可以被绕过,地址栏、控制台、抓包改请求都行,所以后端接口必须独立校验。前端权限的作用是体验,不是安全。

十三、最佳实践清单

  1. 路由 meta.requiresAuth 必填;
  2. 角色判断用 userStore.hasRole() 不用散落;
  3. 按钮级用 v-permission 指令;
  4. API 拦截器兜底 401/403;
  5. 后端独立校验权限(前端不可信);
  6. 登出清空所有 store;
  7. 403 页面友好提示。

这套清单是平台权限模块的验收标准,每条都对应过一次线上问题。新加功能时照着清单过一遍,权限相关的坑基本都能避开。

常见问题(FAQ)

Q1:动态路由什么时候用?

超管才能进的模块用动态路由,避免普通用户下载 admin chunk。

Q2:v-permission 删 DOM 还是 v-show?

删 DOM(默认)。如果想保留占位改 display: none。

Q3:怎么调试权限问题?

在 Pinia store 加 $patch({ profile: { role: 'admin' } }) 临时改角色。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
其AI的头像其AI普通用户

相关推荐

返回顶部