在上一篇《Pinia 状态管理完全指南:从基础到模块化》中,我们已经掌握了 Pinia 的核心用法、模块化设计、高级特性及 TypeScript 集成,搭建了完整的状态管理体系。而在实际 Vue3 项目中,状态管理(Pinia)与路由控制(Vue Router)是密不可分的------用户的登录状态、权限信息(角色、权限码)需要通过 Pinia 存储,再结合 Vue Router 的导航守卫、动态路由等能力,实现从「路由拦截」到「页面按钮级控制」的全链路权限管理。
本文将完全衔接上一篇的 Pinia 基础,复用之前定义的 useUserStore,新增权限专属的 usePermissionStore,结合 Vue Router 4 实现企业级实战的权限控制方案,解决「未登录拦截、角色权限校验、动态路由生成、页面元素权限控制」四大核心问题,让状态管理与路由权限形成闭环,适配中大型项目的权限需求。
一、前置衔接:复用上一篇 Pinia 基础 Store
为了保证两篇文章的连贯性,我们直接复用上一篇中定义的 useUserStore(用户信息、登录/登出核心逻辑),无需重复编写,仅补充权限相关的扩展,确保代码复用性和一致性。
1.1 复用 useUserStore(回顾核心代码)
以下是上一篇中 stores/user.ts 的核心代码(保留关键逻辑,便于衔接):
typescript
// stores/user.ts(上一篇核心代码,复用无需修改)
import { defineStore } from 'pinia'
interface UserState {
userId: string
username: string
token: string
isLogin: boolean
lastLoginTime: number | null
}
export const useUserStore = defineStore<'user', UserState>('user', {
state: (): UserState => ({
userId: '',
username: '',
token: '',
isLogin: false,
lastLoginTime: null
}),
actions: {
// 登录操作(同步,上一篇已实现)
login(userData: { userId: string; username: string; token: string }): void {
this.userId = userData.userId
this.username = userData.username
this.token = userData.token
this.isLogin = true
this.lastLoginTime = Date.now()
},
// 登出操作(同步,上一篇已实现)
logout(): void {
this.$reset()
},
// 异步获取用户信息(上一篇已实现,可用于刷新页面恢复登录状态)
async fetchUserInfoByToken(): Promise<{ userId: string; username: string }> {
try {
// 模拟从后端通过 Token 获取用户信息
const res = await new Promise((resolve) => {
setTimeout(() => {
resolve({ userId: this.userId, username: this.username })
}, 500)
})
return res as { userId: string; username: string }
} catch (error) {
console.error('通过 Token 获取用户信息失败:', error)
throw error
}
}
}
})
1.2 环境补充(衔接上一篇,完善依赖)
上一篇已安装 Pinia,本文需补充 Vue Router 4 的安装(若未安装),确保环境完整:
bash
# 安装 Vue Router 4(适配 Vue3)
npm install vue-router@4
# 若需使用权限持久化,确保已安装 pinia-plugin-persistedstate(上一篇已提及)
npm install pinia-plugin-persistedstate
二、需求分析与整体方案设计(补充完整场景)
结合上一篇的 Pinia 基础,本文聚焦「权限控制全场景」,补充上一版缺失的细节,确保方案可直接落地,核心需求如下(覆盖企业级项目常见场景):
2.1 核心权限需求(完整覆盖)
- 登录拦截:未登录用户访问任何需要权限的路由,自动跳转至登录页,并记录跳转来源(登录后返回原页面)。
- 角色权限校验:不同角色(如 admin、user)可访问的路由不同(如 admin 可访问系统管理,user 不可)。
- 权限码校验:同一页面内,不同权限码的用户可操作的按钮不同(如 admin 有新增/删除权限,user 只有查看权限)。
- 动态路由生成:支持从后端获取当前用户可访问的路由表,动态注册到 Vue Router(适配 RBAC 权限模型)。
- 状态与路由同步:登录/登出/权限变更时,Pinia 状态与路由状态实时同步(如登出后清空路由、跳转登录页)。
- 刷新页面兼容:页面刷新后,通过 Pinia 持久化的 Token 恢复用户信息和权限,避免权限丢失。
- 异常处理:Token 过期、权限变更时,自动登出并跳转登录页,给出友好提示。
2.2 技术方案(衔接 Pinia 基础,不引入多余依赖)
- Pinia 核心 :复用
useUserStore存储 Token、登录状态;新增usePermissionStore存储角色、权限码、可访问路由表。 - Vue Router 核心:全局导航守卫(beforeEach)做登录/权限拦截;动态路由(addRoute)实现权限路由注册;路由元信息(meta)存储路由所需权限。
- 权限扩展 :自定义
v-permission指令(控制按钮级权限)、封装PermissionWrapper组件(控制区块级权限)。 - 持久化 :利用
pinia-plugin-persistedstate持久化useUserStore和usePermissionStore,解决刷新丢失问题。
三、基础配置:Vue Router 路由初始化(分常量/动态路由)
路由分为「常量路由」(无需权限,如登录页、404页)和「动态路由」(需要权限,如首页、系统管理),与 Pinia 权限 Store 联动,初始化如下:
3.1 路由配置(router/index.ts)
typescript
// router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw, Router } from 'vue-router'
// 1. 常量路由(无需权限,所有用户可访问)
export const constantRoutes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { title: '登录页', requiresAuth: false } // requiresAuth: 是否需要登录
},
{
path: '/403',
name: '403',
component: () => import('@/views/403.vue'),
meta: { title: '无权限', requiresAuth: false }
},
{
path: '/404',
name: '404',
component: () => import('@/views/404.vue'),
meta: { title: '页面不存在', requiresAuth: false }
},
{
path: '/',
redirect: '/dashboard', // 默认跳转首页(需要权限)
meta: { requiresAuth: true }
}
]
// 2. 动态路由(需要权限,登录后根据角色/权限动态注册)
export const asyncRoutes: RouteRecordRaw[] = [
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: {
title: '首页',
requiresAuth: true, // 需要登录
roles: ['admin', 'user'], // 可访问角色(admin 和 user 都能访问)
permissions: [] // 无需特殊权限码
}
},
{
path: '/system',
name: 'System',
component: () => import('@/views/System/Index.vue'),
meta: {
title: '系统管理',
requiresAuth: true,
roles: ['admin'], // 只有 admin 可访问
permissions: []
},
children: [
{
path: 'user',
name: 'SystemUser',
component: () => import('@/views/System/User.vue'),
meta: {
title: '用户管理',
requiresAuth: true,
roles: ['admin'],
permissions: ['system:user:list', 'system:user:add', 'system:user:edit', 'system:user:delete'] // 操作所需权限码
}
},
{
path: 'role',
name: 'SystemRole',
component: () => import('@/views/System/Role.vue'),
meta: {
title: '角色管理',
requiresAuth: true,
roles: ['admin'],
permissions: ['system:role:list', 'system:role:add']
}
}
]
},
{
path: '/profile',
name: 'Profile',
component: () => import('@/views/Profile.vue'),
meta: {
title: '个人中心',
requiresAuth: true,
roles: ['admin', 'user'],
permissions: []
}
}
]
// 3. 创建路由实例
const router: Router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), // 适配 Vite 环境变量
routes: constantRoutes // 初始只注册常量路由
})
export default router
3.2 路由与 Pinia 全局注册(main.ts,衔接上一篇)
在上一篇的基础上,补充 Vue Router 注册,同时注册 Pinia 持久化插件,确保权限状态刷新不丢失:
typescript
// main.ts(衔接上一篇,补充路由注册)
import { createApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
import persist from 'pinia-plugin-persistedstate' // 持久化插件(上一篇已提及)
import router from './router' // 新增路由注册
import { permission } from './directives/permission' // 后续自定义权限指令
// 1. 创建 Pinia 实例并注册持久化插件
const pinia = createPinia()
pinia.use(persist)
// 2. 创建 App 实例,注册 Pinia、Router、自定义指令
const app = createApp(App)
app.use(pinia)
app.use(router)
app.directive('permission', permission) // 注册权限指令
app.mount('#app')
四、核心实现:Pinia 权限 Store(usePermissionStore)
这是本文的核心,衔接上一篇的useUserStore,专门处理权限相关逻辑------存储角色、权限码、可访问路由,提供路由过滤、权限校验、动态路由注册等方法,与 useUserStore 联动,实现状态同步。
4.1 权限 Store 完整实现(stores/permission.ts)
typescript
// stores/permission.ts(新增,衔接 useUserStore)
import { defineStore } from 'pinia'
import router, { constantRoutes, asyncRoutes, RouteRecordRaw } from '@/router'
import { useUserStore } from './user' // 复用上一篇的用户 Store
// 类型定义(完善类型,衔接上一篇的 UserState)
interface PermissionState {
roles: string[] // 用户角色(如 ['admin'])
permissions: string[] // 用户权限码(如 ['system:user:add'])
accessibleRoutes: RouteRecordRaw[] // 用户可访问的完整路由表
dynamicRoutes: RouteRecordRaw[] // 动态注册的路由(从 asyncRoutes 过滤而来)
}
// 核心工具函数:根据用户角色、权限码,过滤可访问的动态路由
const filterAccessibleRoutes = (
routes: RouteRecordRaw[],
roles: string[],
permissions: string[]
): RouteRecordRaw[] => {
return routes.filter(route => {
// 1. 跳过不需要权限的路由(理论上 asyncRoutes 都需要权限,做双重保险)
if (!route.meta?.requiresAuth) return true
// 2. 角色校验:路由指定的 roles 与用户 roles 有交集即可访问
const hasRole = route.meta.roles
? roles.some(role => route.meta.roles?.includes(role))
: true
// 3. 权限码校验:路由指定的 permissions 与用户 permissions 有交集即可访问(无权限码则放行)
const hasPermission = route.meta.permissions
? permissions.some(perm => route.meta.permissions?.includes(perm))
: true
// 4. 递归过滤子路由(如系统管理下的用户管理、角色管理)
if (route.children && route.children.length > 0) {
route.children = filterAccessibleRoutes(route.children, roles, permissions)
// 若子路由全部被过滤,当前父路由也不显示
return hasRole && hasPermission && route.children.length > 0
}
return hasRole && hasPermission
})
}
// 定义权限 Store,与 useUserStore 联动
export const usePermissionStore = defineStore<'permission', PermissionState>('permission', {
state: (): PermissionState => ({
roles: [],
permissions: [],
accessibleRoutes: [],
dynamicRoutes: []
}),
// 持久化:与 useUserStore 同步,避免刷新丢失(关键衔接点)
persist: {
key: 'permission-store',
storage: localStorage,
paths: ['roles', 'permissions'] // 只持久化角色和权限码,路由无需持久化(登录后重新生成)
},
getters: {
// 衔接 useUserStore,判断是否已登录(复用登录状态,无需重复定义)
isLoggedIn: (state) => {
const userStore = useUserStore()
return userStore.isLogin
},
// 判断是否是管理员(常用快捷 getter)
isAdmin: (state) => state.roles.includes('admin')
},
actions: {
// 1. 初始化权限(页面刷新时调用,恢复权限状态)
async initPermission() {
const userStore = useUserStore()
// 若已登录,但权限未初始化(如刷新页面),从后端拉取权限
if (userStore.isLogin && this.roles.length === 0) {
await this.fetchUserPermission()
}
},
// 2. 从后端获取用户权限(登录后调用,核心方法)
async fetchUserPermission() {
const userStore = useUserStore()
try {
// 模拟后端接口:根据 Token 获取用户角色和权限码
// 实际项目中,可结合 userStore.token 发起请求
const permissionRes = await new Promise<{ roles: string[]; permissions: string[] }>((resolve) => {
setTimeout(() => {
// 模拟不同用户的权限(与上一篇 login 逻辑呼应)
if (userStore.username === 'admin') {
// admin 角色:拥有所有角色和大部分权限
resolve({
roles: ['admin'],
permissions: ['system:user:list', 'system:user:add', 'system:user:edit', 'system:user:delete', 'system:role:list', 'system:role:add']
})
} else {
// user 角色:只有 user 角色,无系统管理权限
resolve({
roles: ['user'],
permissions: []
})
}
}, 800)
})
// 更新权限状态
this.roles = permissionRes.roles
this.permissions = permissionRes.permissions
// 生成可访问路由并注册
await this.generateAccessibleRoutes()
return permissionRes
} catch (error) {
console.error('获取用户权限失败:', error)
// 权限获取失败,强制登出
await this.resetPermission()
throw error
}
},
// 3. 生成可访问路由并动态注册到 Router(核心联动方法)
async generateAccessibleRoutes() {
// 过滤动态路由:根据当前用户角色和权限码,筛选可访问的路由
this.dynamicRoutes = filterAccessibleRoutes(asyncRoutes, this.roles, this.permissions)
// 完整可访问路由 = 常量路由 + 过滤后的动态路由
this.accessibleRoutes = constantRoutes.concat(this.dynamicRoutes)
// 动态注册路由到 Vue Router(避免重复注册)
this.dynamicRoutes.forEach(route => {
// 判断路由是否已注册,避免刷新页面重复添加
const isRouteExists = router.hasRoute(route.name as string)
if (!isRouteExists) {
router.addRoute(route)
}
})
// 注册404路由(放在最后,避免拦截动态路由)
const is404Exists = router.hasRoute('404')
if (!is404Exists) {
router.addRoute({ path: '/:pathMatch(.*)*', redirect: '/404' })
}
return this.accessibleRoutes
},
// 4. 权限校验(通用方法:判断是否拥有某个权限码/角色)
hasPermission(permission: string | string[]): boolean {
if (Array.isArray(permission)) {
// 多个权限码,满足一个即可
return permission.some(perm => this.permissions.includes(perm))
}
// 单个权限码
return this.permissions.includes(permission)
},
hasRole(role: string | string[]): boolean {
if (Array.isArray(role)) {
return role.some(r => this.roles.includes(r))
}
return this.roles.includes(role)
},
// 5. 重置权限状态(登出时调用,与 useUserStore 同步)
async resetPermission() {
// 重置当前权限状态
this.$reset()
// 重置路由:移除所有动态注册的路由(保留常量路由)
this.dynamicRoutes.forEach(route => {
router.removeRoute(route.name as string)
})
// 联动 userStore 登出
const userStore = useUserStore()
await userStore.logout()
}
}
})
4.2 关键衔接点说明(与上一篇 Pinia 基础呼应)
- 复用
useUserStore:通过useUserStore()获取登录状态、Token、用户名,避免重复存储用户信息,保持状态统一。 - 持久化同步:
usePermissionStore持久化角色和权限码,与useUserStore的 Token 持久化同步,确保刷新页面后权限不丢失。 - 登出联动:
resetPermission方法中调用userStore.logout(),实现「权限重置 + 用户登出」一键同步,避免状态不一致。
五、路由守卫:实现登录与权限拦截(核心实战)
利用 Vue Router 的全局导航守卫(beforeEach),结合 Pinia 的 useUserStore 和 usePermissionStore,实现全路由的权限拦截,覆盖「未登录、无权限、刷新恢复」等场景,补充上一版缺失的异常处理和刷新兼容。
5.1 全局导航守卫实现(router/index.ts 补充)
typescript
// router/index.ts(补充全局导航守卫)
import { useUserStore } from '@/stores/user'
import { usePermissionStore } from '@/stores/permission'
// 白名单路由:无需登录即可访问(与常量路由对应)
const whiteList = ['/login', '/403', '/404']
// 全局前置守卫:路由跳转前校验登录和权限
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
const permissionStore = usePermissionStore()
// 1. 设置页面标题(优化用户体验)
document.title = (to.meta.title as string) || 'Vue3 + Pinia + Router 权限管理'
// 2. 检查是否已登录(复用 userStore 的 isLogin 状态)
const hasLogin = userStore.isLogin
if (hasLogin) {
// 已登录:禁止访问登录页,重定向到首页
if (to.path === '/login') {
next({ path: '/dashboard', replace: true })
} else {
// 检查权限是否已初始化(如刷新页面,权限未加载)
const hasPermissionInit = permissionStore.roles.length > 0
if (hasPermissionInit) {
// 权限已初始化,校验当前路由是否可访问
const isRouteAccessible = permissionStore.accessibleRoutes.some(
route => route.path === to.path || route.name === to.name
)
if (isRouteAccessible) {
// 有权限,放行
next()
} else {
// 无权限,跳转 403 页
next({ path: '/403', replace: true })
}
} else {
// 权限未初始化,先初始化权限(恢复刷新前的权限状态)
try {
await permissionStore.initPermission()
// 权限初始化完成后,重新跳转当前路由(确保路由已注册)
next({ ...to, replace: true })
} catch (error) {
// 初始化权限失败(如 Token 过期),强制登出,跳转登录页
await permissionStore.resetPermission()
next(`/login?redirect=${to.path}`)
}
}
}
} else {
// 未登录:检查是否在白名单内
if (whiteList.includes(to.path)) {
// 白名单路由,放行
next()
} else {
// 非白名单路由,重定向到登录页,并记录跳转来源(登录后返回)
next(`/login?redirect=${to.path}`)
}
}
})
// 全局后置守卫:处理路由跳转异常(可选,优化体验)
router.afterEach((to, from, failure) => {
if (failure) {
console.error('路由跳转失败:', failure)
// 跳转失败,默认跳转 404 页
router.push('/404')
}
})
5.2 核心拦截逻辑说明(覆盖完整场景)
- 已登录状态 :禁止访问登录页;若权限未初始化(刷新页面),先调用
initPermission恢复权限,再校验当前路由是否可访问。 - 未登录状态 :只允许访问白名单路由,其他路由跳转至登录页,并携带
redirect参数(登录后返回原页面)。 - 权限校验:已登录但无当前路由权限,跳转 403 页;路由跳转失败,跳转 404 页。
- 异常处理:权限初始化失败(如 Token 过期),强制登出并跳转登录页,避免出现权限错乱。
六、页面级权限控制:按钮/区块级权限
路由级拦截只能控制页面访问,页面内的按钮、区块需要更精细的权限控制。这里实现两种方式(指令 + 组件),适配不同场景,同时衔接 usePermissionStore,确保权限同步。
6.1 自定义 v-permission 指令(控制按钮级权限)
创建 directives/permission.ts,封装权限指令,直接调用 usePermissionStore 的 hasPermission 方法,实现按钮显示/隐藏:
typescript
// directives/permission.ts
import { usePermissionStore } from '@/stores/permission'
import type { Directive, DirectiveBinding } from 'vue'
// 自定义权限指令:v-permission="['权限码1', '权限码2']"
export const permission: Directive = {
// 组件挂载时执行
mounted(el: HTMLElement, binding: DirectiveBinding) {
const permissionStore = usePermissionStore()
const { value } = binding
// 校验参数:必须传入数组形式的权限码
if (!value || !Array.isArray(value) || value.length === 0) {
throw new Error('v-permission 指令必须传入非空权限码数组,如 v-permission="[\'system:user:add\']"')
}
// 校验权限:无权限则移除元素
const hasPermission = permissionStore.hasPermission(value)
if (!hasPermission) {
el.parentNode?.removeChild(el)
}
},
// 组件更新时执行(如权限动态变更,重新校验)
updated(el: HTMLElement, binding: DirectiveBinding) {
const permissionStore = usePermissionStore()
const { value } = binding
const hasPermission = permissionStore.hasPermission(value)
if (!hasPermission) {
el.parentNode?.removeChild(el)
} else {
// 若有权限,且元素已被移除,重新添加(适配权限动态变更场景)
if (!el.parentNode) {
const parent = document.querySelector('.permission-container') // 自定义父容器类名
parent?.appendChild(el)
}
}
}
}
6.2 封装 PermissionWrapper 组件(控制区块级权限)
对于区块级权限(如整个表格、表单),使用组件封装更灵活,支持插槽,适配复杂场景:
vue
// components/PermissionWrapper.vue
<template>
<!-- 有权限则显示插槽内容,无权限则显示默认提示(可自定义) -->
<div v-if="hasPermission" class="permission-wrapper">
<slot />
</div>
<div v-else class="permission-no-access" v-if="showTip">
{{ tipText }}
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { usePermissionStore } from '@/stores/permission'
// 组件 props:传入需要的权限码、是否显示提示、提示文本
interface Props {
permissions: string[] // 所需权限码数组
showTip?: boolean // 是否显示无权限提示
tipText?: string // 无权限提示文本
}
const props = defineProps<Props>({
permissions: {
type: Array,
required: true,
validator: (value: string[]) => value.length > 0 // 校验权限码数组非空
},
showTip: {
type: Boolean,
default: true
},
tipText: {
type: String,
default: '您暂无此操作权限,请联系管理员'
}
})
// 调用 permissionStore 的 hasPermission 方法,判断是否有权限
const permissionStore = usePermissionStore()
const hasPermission = computed(() => {
return permissionStore.hasPermission(props.permissions)
})
</script>
<style scoped>
.permission-no-access {
color: #999;
padding: 20px;
text-align: center;
background: #f5f5f5;
border-radius: 4px;
}
</style>
6.3 组件中实际使用
以「用户管理」页面为例,结合指令和组件,实现按钮和区块的权限控制,呼应上一篇的 Pinia 模块化思想:
vue
// views/System/User.vue
<template>
<div class="user-page">
<h2>用户管理(仅 admin 可访问)</h2>
<!-- 区块级权限:只有拥有 system:user:add 权限,才能看到新增用户区块 -->
<PermissionWrapper :permissions="['system:user:add']">
<div class="add-user-container">
<input v-model="username" placeholder="请输入用户名" />
<button @click="handleAddUser">新增用户</button>
</div>
</PermissionWrapper>
<!-- 按钮级权限:使用 v-permission 指令 -->
<div class="button-group">
<button v-permission="['system:user:list']" @click="handleQuery">查询用户</button>
<button v-permission="['system:user:edit']" @click="handleEdit">编辑用户</button>
<button v-permission="['system:user:delete']" @click="handleDelete">删除用户</button>
</div>
<!-- 表格:只有拥有 system:user:list 权限才能看到 -->
<PermissionWrapper :permissions="['system:user:list']">
<table class="user-table">
<thead>
<tr>
<th>用户ID</th>
<th>用户名</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="user in userList" :key="user.id">
<td>{{ user.id }}</td>
<td>{{ user.username }}</td>
<td>
<button v-permission="['system:user:edit']" @click="handleEdit(user.id)">编辑</button>
<button v-permission="['system:user:delete']" @click="handleDelete(user.id)">删除</button>
</td>
</tr>
</tbody>
</table>
</PermissionWrapper>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import PermissionWrapper from '@/components/PermissionWrapper.vue'
import { usePermissionStore } from '@/stores/permission'
const permissionStore = usePermissionStore()
const username = ref('')
const userList = ref([
{ id: '1', username: 'admin' },
{ id: '2', username: 'user' }
])
// 新增用户(仅拥有 system:user:add 权限可调用)
const handleAddUser = () => {
// 实际项目中,可结合 Pinia 的 actions 发起请求
userList.value.push({ id: Date.now().toString(), username: username.value })
username.value = ''
}
// 查询用户(仅拥有 system:user:list 权限可调用)
const handleQuery = () => {
console.log('查询用户列表')
}
// 编辑用户(仅拥有 system:user:edit 权限可调用)
const handleEdit = (userId: string) => {
console.log('编辑用户:', userId)
}
// 删除用户(仅拥有 system:user:delete 权限可调用)
const handleDelete = (userId: string) => {
userList.value = userList.value.filter(user => user.id !== userId)
}
</script>
七、登录与登出完整流程
结合上一篇的 useUserStore 和本文的 usePermissionStore,实现完整的登录/登出流程,确保状态与路由同步,补充上一版缺失的细节(如跳转来源、加载状态)。
7.1 登录页实现(views/Login.vue)
vue
// views/Login.vue
<template>
<div class="login-container">
<h2>Vue3 + Pinia + Router 权限管理登录</h2>
<div class="form-item">
<label>用户名:</label>
<input v-model="username" placeholder="请输入用户名(admin/user)" />
</div>
<div class="form-item">
<label>密码:</label>
<input v-model="password" type="password" placeholder="请输入密码(任意)" />
</div>
<button @click="handleLogin" :disabled="loading" class="login-btn">
{{ loading ? '登录中...' : '登录' }}
</button>
<p class="error-tip" v-if="errorTip">{{ errorTip }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { usePermissionStore } from '@/stores/permission'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const permissionStore = usePermissionStore()
// 表单数据
const username = ref('')
const password = ref('')
// 状态管理
const loading = ref(false)
const errorTip = ref('')
// 登录处理(衔接上一篇的 userStore.login,新增权限获取)
const handleLogin = async () => {
// 表单校验
if (!username.value || !password.value) {
errorTip.value = '用户名和密码不能为空'
return
}
loading.value = true
errorTip.value = ''
try {
// 1. 调用上一篇的 userStore.login,存储用户信息和 Token
userStore.login({
userId: username.value === 'admin' ? '1' : '2',
username: username.value,
token: `mock-token-${Date.now()}` // 模拟 Token
})
// 2. 调用 permissionStore,获取用户权限并生成动态路由
await permissionStore.fetchUserPermission()
// 3. 获取跳转来源(登录前访问的页面),无则跳转首页
const redirect = route.query.redirect as string || '/dashboard'
router.push({ path: redirect, replace: true })
} catch (error) {
errorTip.value = '登录失败,请重试'
console.error('登录异常:', error)
} finally {
loading.value = false
}
}
</script>
7.2 登出实现(组件中使用,如 Header 组件)
vue
// components/Header.vue
<template>
<div class="header">
<div class="user-info">
欢迎您,{{ username }}
</div>
<button @click="handleLogout" :disabled="loading">
{{ loading ? '登出中...' : '退出登录' }}
</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { usePermissionStore } from '@/stores/permission'
const router = useRouter()
const userStore = useUserStore()
const permissionStore = usePermissionStore()
const loading = ref(false)
// 从 userStore 获取用户名(衔接上一篇)
const username = computed(() => userStore.username)
// 登出处理(联动两个 Store,同步状态和路由)
const handleLogout = async () => {
loading.value = true
try {
// 调用 permissionStore 重置权限、路由
await permissionStore.resetPermission()
// 跳转登录页(无需携带 redirect,登出后默认重新登录)
router.push('/login')
} catch (error) {
console.error('登出失败:', error)
} finally {
loading.value = false
}
}
</script>
八、进阶优化与实战细节(补充完整,适配生产)
结合企业级项目需求,补充上一版缺失的优化点和实战细节,确保方案可直接落地,同时衔接上一篇的 Pinia 高级特性。
8.1 动态路由从后端获取(实战必备)
上一篇中我们实现了 Pinia 的模块化,本文中动态路由可从后端获取,适配 RBAC 模型,修改 fetchUserPermission 方法:
typescript
// stores/permission.ts(修改 fetchUserPermission 方法)
async fetchUserPermission() {
const userStore = useUserStore()
try {
// 实际项目中,携带 Token 从后端获取权限和路由表
const res = await api.get('/api/user/permission') // 模拟后端接口
const { roles, permissions, routes: backendRoutes } = res.data
// 更新权限状态
this.roles = roles
this.permissions = permissions
// 转换后端路由格式为 Vue Router 可识别的格式(关键:适配后端返回结构)
const transformRoutes = (routes: any[]): RouteRecordRaw[] => {
return routes.map(route => {
const routeObj: RouteRecordRaw = {
path: route.path,
name: route.name,
component: () => import(`@/views/${route.component}.vue`), // 动态导入组件
meta: {
title: route.title,
requiresAuth: route.requiresAuth,
roles: route.roles,
permissions: route.permissions
}
}
// 递归转换子路由
if (route.children && route.children.length > 0) {
routeObj.children = transformRoutes(route.children)
}
return routeObj
})
}
// 转换后端路由,替换本地 asyncRoutes
const transformedRoutes = transformRoutes(backendRoutes)
// 生成可访问路由并注册
this.dynamicRoutes = filterAccessibleRoutes(transformedRoutes, this.roles, this.permissions)
this.accessibleRoutes = constantRoutes.concat(this.dynamicRoutes)
this.dynamicRoutes.forEach(route => {
if (!router.hasRoute(route.name as string)) {
router.addRoute(route)
}
})
return { roles, permissions }
} catch (error) {
// 异常处理
await this.resetPermission()
throw error
}
}
8.2 权限持久化优化(避免刷新丢失)
确保 useUserStore 和 usePermissionStore 同步持久化,修改上一篇的 useUserStore,添加持久化配置:
typescript
// stores/user.ts(上一篇补充持久化)
export const useUserStore = defineStore<'user', UserState>('user', {
state: (): UserState => ({ /* 上一篇内容不变 */ }),
actions: { /* 上一篇内容不变 */ },
// 新增持久化,与 permissionStore 同步
persist: {
key: 'user-store',
storage: localStorage,
paths: ['userId', 'username', 'token', 'isLogin', 'lastLoginTime']
}
})
8.3 性能优化
- 路由懒加载 :所有页面组件均使用
() => import('@/views/xxx.vue'),实现代码分割,减少初始包体积(上一篇已提及,本文延续)。 - 权限缓存:权限只在登录、登出、权限变更时重新获取,避免每次路由跳转都重新计算。
- 路由重复注册拦截 :使用
router.hasRoute判断路由是否已注册,避免刷新页面重复添加。 - 指令优化 :
v-permission新增updated钩子,适配权限动态变更场景(如角色切换)。
8.4 常见问题排查(实战必备)
- 刷新页面后权限丢失 :检查 Pinia 持久化配置是否正确,确保
useUserStore和usePermissionStore的persist配置生效。 - 动态路由跳转 404 :确保动态路由注册在 404 路由之前,且路由
name唯一。 - 权限指令不生效 :检查权限码是否与后端返回一致,确保
usePermissionStore的permissions已正确更新。 - 登出后路由未重置 :检查
resetPermission方法是否调用router.removeRoute,移除所有动态路由。
九、总结
本文完全衔接上一篇《Pinia 状态管理完全指南:从基础到模块化》,复用 useUserStore,新增 usePermissionStore,结合 Vue Router 4 实现了「路由级 + 页面级」的完整权限控制方案,核心亮点如下:
- 衔接紧密:完全复用上一篇的 Pinia 基础 Store,不重复造轮子,形成「状态管理 → 权限控制」的闭环,符合专栏的进阶逻辑。
- 场景完整:覆盖登录拦截、角色校验、权限码校验、动态路由、页面级权限、刷新兼容、异常处理等企业级实战场景,补充上一版缺失的细节。
- 实战性强:所有代码可直接复制使用,包含完整的示例(登录页、用户管理页、权限指令、组件),适配中大型项目的权限需求。
- 类型安全:延续上一篇的 TypeScript 集成,完善所有类型定义,避免类型报错,提升代码可维护性。
通过本文的学习,你可以基于 Pinia 和 Vue Router,快速搭建企业级的权限管理体系,解决实际项目中的权限控制问题,同时巩固上一篇的 Pinia 基础知识点,让 Vue3 核心与进阶专栏的内容更具连贯性和实战价值。