Pinia 与 Vue Router 权限控制实战(衔接Pinia基础篇)

在上一篇《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 持久化 useUserStoreusePermissionStore,解决刷新丢失问题。

三、基础配置: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 的 useUserStoreusePermissionStore,实现全路由的权限拦截,覆盖「未登录、无权限、刷新恢复」等场景,补充上一版缺失的异常处理和刷新兼容。

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 核心拦截逻辑说明(覆盖完整场景)

  1. 已登录状态 :禁止访问登录页;若权限未初始化(刷新页面),先调用 initPermission 恢复权限,再校验当前路由是否可访问。
  2. 未登录状态 :只允许访问白名单路由,其他路由跳转至登录页,并携带 redirect 参数(登录后返回原页面)。
  3. 权限校验:已登录但无当前路由权限,跳转 403 页;路由跳转失败,跳转 404 页。
  4. 异常处理:权限初始化失败(如 Token 过期),强制登出并跳转登录页,避免出现权限错乱。

六、页面级权限控制:按钮/区块级权限

路由级拦截只能控制页面访问,页面内的按钮、区块需要更精细的权限控制。这里实现两种方式(指令 + 组件),适配不同场景,同时衔接 usePermissionStore,确保权限同步。

6.1 自定义 v-permission 指令(控制按钮级权限)

创建 directives/permission.ts,封装权限指令,直接调用 usePermissionStorehasPermission 方法,实现按钮显示/隐藏:

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 权限持久化优化(避免刷新丢失)

确保 useUserStoreusePermissionStore 同步持久化,修改上一篇的 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 持久化配置是否正确,确保 useUserStoreusePermissionStorepersist 配置生效。
  • 动态路由跳转 404 :确保动态路由注册在 404 路由之前,且路由 name 唯一。
  • 权限指令不生效 :检查权限码是否与后端返回一致,确保 usePermissionStorepermissions 已正确更新。
  • 登出后路由未重置 :检查 resetPermission 方法是否调用 router.removeRoute,移除所有动态路由。

九、总结

本文完全衔接上一篇《Pinia 状态管理完全指南:从基础到模块化》,复用 useUserStore,新增 usePermissionStore,结合 Vue Router 4 实现了「路由级 + 页面级」的完整权限控制方案,核心亮点如下:

  1. 衔接紧密:完全复用上一篇的 Pinia 基础 Store,不重复造轮子,形成「状态管理 → 权限控制」的闭环,符合专栏的进阶逻辑。
  2. 场景完整:覆盖登录拦截、角色校验、权限码校验、动态路由、页面级权限、刷新兼容、异常处理等企业级实战场景,补充上一版缺失的细节。
  3. 实战性强:所有代码可直接复制使用,包含完整的示例(登录页、用户管理页、权限指令、组件),适配中大型项目的权限需求。
  4. 类型安全:延续上一篇的 TypeScript 集成,完善所有类型定义,避免类型报错,提升代码可维护性。

通过本文的学习,你可以基于 Pinia 和 Vue Router,快速搭建企业级的权限管理体系,解决实际项目中的权限控制问题,同时巩固上一篇的 Pinia 基础知识点,让 Vue3 核心与进阶专栏的内容更具连贯性和实战价值。

相关推荐
啥咕啦呛2 小时前
3个月前端转全栈计划
前端
BradyC2 小时前
laya编译内存溢出问题
前端
木斯佳2 小时前
前端八股文面经大全:阿里云AI应用开发一面(2026-03-20)·面经深度解析
前端·人工智能·阿里云·ai·智能体·流式打印
绝世唐门三哥2 小时前
React--- 状态更新:何时需要拷贝,何时不需要?
javascript·react.js·ecmascript
我叫黑大帅2 小时前
JS中的两大定时器
前端·javascript·面试
掘金安东尼2 小时前
⏰前端周刊第 458 期v2026.3.24
前端·javascript·面试
前端付豪2 小时前
实现必要的流式输出(Streaming)
前端·后端·agent
张元清2 小时前
useMediaQuery:React 响应式设计完全指南
前端·javascript·面试
小金鱼Y2 小时前
一文吃透 JavaScript 防抖:从原理到实战,让你的页面不再 “手抖”
前端·javascript·面试