CC 咖啡猫的工作空间 Coding Space

前端鉴权与路由守卫实践

前端鉴权解决的是"如何管理登录态"和"如何控制页面可访问性"的问题。但必须明确:前端鉴权是用户体验层面的防护,不是安全层面的兜底——真正的安全保障永远在后端。


一、前端鉴权概述

1.1 前端鉴权能做什么

能力 说明
管理登录态 Token 的存取、刷新、过期处理
控制页面访问 未登录跳转登录页、无权限显示 403
隐藏 UI 元素 根据权限显示/隐藏按钮、菜单项
无感刷新 Token 利用 Refresh Token 在后台续期
提升用户体验 减少因权限不足导致的报错弹窗

1.2 前端鉴权不能做什么

局限 原因
无法阻止恶意请求 前端代码完全暴露,Token 可被截获后直接调用 API
无法真正保护数据 所有接口数据最终依赖后端鉴权
无法防止重放攻击 前端无法验证 Token 是否被多次使用
无法保证 Token 安全 XSS 攻击可窃取 localStorage 中的 Token

核心认知:前端鉴权 = 防君子不防小人。前端做鉴权是为了给正常用户更好的体验(自动跳转登录、隐藏无权按钮),而不是为了拦住攻击者。攻击者可以通过浏览器 DevTools、curl、Postman 等工具绕过所有前端限制直接访问 API。

1.3 认证 vs 授权

概念 前端对应 说明
认证 是否有 Token / Token 是否过期 解决"你是谁"
授权 是否有角色 / 是否有权限码 解决"你能做什么"

二、常见前端鉴权方案对比

2.1 方案总览

方案 原理 复杂度 适用场景
Token (JWT) 登录获 Token,存客户端,请求时附带 前后端分离 SPA、移动端
Session-Cookie 登录后服务端存 Session,Cookie 自动携带 SessionID 传统 SSR 服务、同域部署
OAuth2.0 第三方授权码流程,获取 Access Token 第三方登录(微信、GitHub)
SSO 一个系统登录,多个系统共享登录态 企业级多应用套件

2.2 Token (JWT) 方案

流程

用户 → 登录页 -> 输入账号密码
    ↓
POST /api/auth/login
    ↓
后端验证成功 → 返回 { accessToken, refreshToken }
    ↓
前端存储 Token → 存入 localStorage / sessionStorage / Cookie
    ↓
后续请求 → Authorization: Bearer <accessToken>
    ↓
Token 过期 → 用 refreshToken 换取新的 accessToken → 无感续期
    ↓
Refresh Token 也过期 → 跳转登录页

流程

用户 → 登录页 → 输入账号密码
    ↓
POST /api/auth/login
    ↓
后端验证成功 → 创建 Session → Set-Cookie: sessionId=xxx
    ↓
后续请求 → 浏览器自动携带 Cookie
    ↓
服务端校验 Session → 通过则放行

前端工作量极小,但局限明显

维度 Session-Cookie Token (JWT)
跨域 困难,需配置 CORS + withCredentials 简单,Header 携带即可
移动端 不支持原生应用中自动携带 Cookie 支持,手动设置 Header
无状态 有状态(服务端存 Session) 无状态(JWT 自包含)
扩展性 多节点需 Redis Session 共享 天然支持水平扩展
CSRF 易受攻击(需额外防护) 不易受 CSRF 攻击

2.4 OAuth2.0 第三方登录

授权码流程(最常用)

前端 → 点击"微信登录"
    ↓
跳转微信授权页 → 用户确认
    ↓
微信回调 → 携带 authorization_code
    ↓
POST /api/auth/oauth/callback?code=xxx
    ↓
后端用 code 向微信换取 access_token → 获取用户信息
    ↓
后端生成自有的 Token 返回前端 → 后续和 JWT 流程一致

⚠️ 前端只需处理跳转和回调,核心 token 交换在后端完成,不暴露第三方 AppSecret。

2.5 SSO 单点登录

原理

用户访问 app1.com → 未登录 → 重定向到 sso.com/login
    ↓
用户在 sso.com 登录 → 生成全局 Token (TGT)
    ↓
携带 Ticket 跳回 app1.com → app1 用 Ticket 向 sso 验证
    ↓
app1 验证通过 → 创建局部 Token → 返回前端
    ↓
用户再访问 app2.com → 未登录 → 重定向到 sso.com
    ↓
sso.com 检查到已有 TGT → 直接签发 Ticket → 无需重新输入密码

前端关注点:处理跨域跳转时的 Token 传递,通常使用 URL 参数或 postMessage。


三、JWT 在前端的落地实践

3.1 Token 存储位置对比

存储位置 优点 缺点 安全性
localStorage 简单、持久化、跨标签页共享 易受 XSS 攻击(同源任何 JS 都可读取) ⭐⭐
sessionStorage 关闭标签页自动清除 不跨标签页(用户体验差) ⭐⭐⭐
Cookie(无标志) 请求自动携带 XSS 可读取(默认)、CSRF 风险、跨域配置复杂 ⭐⭐
HttpOnly Cookie JS 无法读取,防御 XSS 前端无法主动读取 Token(判断过期困难) ⭐⭐⭐⭐⭐
内存变量 最安全(XSS 无法获取) 刷新页面丢失 ⭐⭐⭐⭐⭐

推荐

高安全场景:Access Token → 内存变量  +  Refresh Token → HttpOnly Cookie
普通场景:  Access Token → localStorage  +  Refresh Token → localStorage
纯前端校验:Access Token → localStorage(配合 CSP 防 XSS)

3.2 Access Token + Refresh Token 双 Token 机制

为什么需要双 Token

单 Token 双 Token
Token 有效期短(15 分钟)→ 体验差,频繁登录 Access Token 短(15 分钟)+ Refresh Token 长(7 天)
Token 有效期长(7 天)→ 被盗风险大 被盗时只有短时效的 Access Token 生效
无法平滑续期 Refresh Token 可在后台无感续期

时序图

Access Token 有效期 15 分钟
Refresh Token 有效期 7 天

正常请求流程:
    ┌─────────────────────────────────────────┐
    │ 请求 API → 200 OK(Access Token 有效)   │
    │ 请求 API → 200 OK(Access Token 有效)   │
    └─────────────────────────────────────────┘

Token 过期流程:
    ┌─────────────────────────────────────────┐
    │ 请求 API → 401 Unauthorized             │
    │     ↓                                   │
    │ 拦截器捕获 401 → 调用 refreshToken API  │
    │     ↓                                   │
    │ 后端验证 Refresh Token → 返回新 Token   │
    │     ↓                                   │
    │ 更新存储中的 Access Token               │
    │     ↓                                   │
    │ 重放原请求 → 200 OK                     │
    └─────────────────────────────────────────┘

Refresh Token 也过期:
    ┌─────────────────────────────────────────┐
    │ Refresh Token 请求 → 401 Unauthorized   │
    │     ↓                                   │
    │ 清除所有 Token → 跳转登录页             │
    └─────────────────────────────────────────┘

3.3 axios 拦截器实现无感刷新

Vue 3 实现

// src/utils/http.ts
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { useUserStore } from '@/stores/user'
import router from '@/router'

const http = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000,
})

// 是否正在刷新 Token 的标记
let isRefreshing = false
// 等待刷新期间排队的请求
let pendingRequests: Array<{
  resolve: (value: unknown) => void
  reject: (reason: unknown) => void
}> = []

/** 请求拦截器:自动携带 Token */
http.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    const token = localStorage.getItem('accessToken')
    if (token && config.headers) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => Promise.reject(error),
)

/** 响应拦截器:无感刷新 Token */
http.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }

    // ❌ 不是 401 或已重放过,直接拒绝
    if (error.response?.status !== 401 || originalRequest._retry) {
      return Promise.reject(error)
    }

    // ✅ 防止并发刷新:正在刷新时,将后续请求排队
    if (isRefreshing) {
      return new Promise((resolve, reject) => {
        pendingRequests.push({ resolve, reject })
      }).then((token) => {
        if (originalRequest.headers) {
          originalRequest.headers.Authorization = `Bearer ${token}`
        }
        return http(originalRequest)
      })
    }

    originalRequest._retry = true
    isRefreshing = true

    try {
      const refreshToken = localStorage.getItem('refreshToken')
      if (!refreshToken) throw new Error('No refresh token')

      const res = await axios.post('/api/auth/refresh', {
        refreshToken,
      })

      const { accessToken: newAccessToken, refreshToken: newRefreshToken } = res.data
      localStorage.setItem('accessToken', newAccessToken)
      localStorage.setItem('refreshToken', newRefreshToken)

      // ✅ 重放排队的请求
      pendingRequests.forEach(({ resolve }) => resolve(newAccessToken))
      pendingRequests = []

      // ✅ 重放原请求
      if (originalRequest.headers) {
        originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
      }
      return http(originalRequest)
    } catch (refreshError) {
      // ❌ 刷新失败 → 清除状态 → 跳转登录
      pendingRequests.forEach(({ reject }) => reject(refreshError))
      pendingRequests = []

      const userStore = useUserStore()
      userStore.logout()
      router.push('/login')
      return Promise.reject(refreshError)
    } finally {
      isRefreshing = false
    }
  },
)

export default http

React 实现

// src/utils/http.ts
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { useUserStore } from '@/stores/userStore'
import { useNavigate } from 'react-router-dom'

const http = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000,
})

let isRefreshing = false
let pendingRequests: Array<{
  resolve: (value: unknown) => void
  reject: (reason: unknown) => void
}> = []

// 注意:React 中无法在模块顶层使用 hooks,
// 通过函数注入的方式获取 navigate
let _navigate: ReturnType<typeof useNavigate> | null = null
export const injectNavigate = (navigate: ReturnType<typeof useNavigate>) => {
  _navigate = navigate
}

http.interceptors.request.use((config: InternalAxiosRequestConfig) => {
  const token = localStorage.getItem('accessToken')
  if (token && config.headers) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

http.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }

    if (error.response?.status !== 401 || originalRequest._retry) {
      return Promise.reject(error)
    }

    if (isRefreshing) {
      return new Promise((resolve, reject) => {
        pendingRequests.push({ resolve, reject })
      }).then((token) => {
        if (originalRequest.headers) {
          originalRequest.headers.Authorization = `Bearer ${token}`
        }
        return http(originalRequest)
      })
    }

    originalRequest._retry = true
    isRefreshing = true

    try {
      const refreshToken = localStorage.getItem('refreshToken')
      if (!refreshToken) throw new Error('No refresh token')

      const res = await axios.post('/api/auth/refresh', { refreshToken })
      const { accessToken: newAccessToken, refreshToken: newRefreshToken } = res.data
      localStorage.setItem('accessToken', newAccessToken)
      localStorage.setItem('refreshToken', newRefreshToken)

      pendingRequests.forEach(({ resolve }) => resolve(newAccessToken))
      pendingRequests = []

      if (originalRequest.headers) {
        originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
      }
      return http(originalRequest)
    } catch (refreshError) {
      pendingRequests.forEach(({ reject }) => reject(refreshError))
      pendingRequests = []

      useUserStore.getState().logout()
      _navigate?.('/login')
      return Promise.reject(refreshError)
    } finally {
      isRefreshing = false
    }
  },
)

export default http

3.4 Token 过期判断

前端解码 JWT Payload 判断是否过期

// src/utils/jwt.ts

/**
 * 解析 JWT 的 Payload(不验证签名,仅供前端展示和判断过期时间)
 * ⚠️ 永远不要信任客户端解析出的数据,仅用于 UI 判断
 */
export function parseJwtPayload(token: string): Record<string, unknown> | null {
  try {
    const payloadBase64 = token.split('.')[1]
    const payload = JSON.parse(atob(payloadBase64))
    return payload
  } catch {
    return null
  }
}

/**
 * 判断 Token 是否即将过期(提前 5 分钟视为过期)
 */
export function isTokenExpiring(token: string, bufferSeconds = 300): boolean {
  const payload = parseJwtPayload(token)
  if (!payload?.exp) return true

  const exp = payload.exp as number
  const now = Math.floor(Date.now() / 1000)
  return now >= exp - bufferSeconds
}

/**
 * 获取 Token 剩余有效秒数
 */
export function getTokenRemainingSeconds(token: string): number {
  const payload = parseJwtPayload(token)
  if (!payload?.exp) return 0

  const exp = payload.exp as number
  const now = Math.floor(Date.now() / 1000)
  return Math.max(0, exp - now)
}

典型应用场景

// 在请求前主动判断,提前刷新
const token = localStorage.getItem('accessToken')
if (token && isTokenExpiring(token)) {
  // 主动刷新 Token,而不是等 401 再刷
  await refreshToken()
}

3.5 JWT 安全注意事项

注意事项 说明
前端不要存敏感数据 JWT payload 只 base64 编码,任何人可解码查看
不要信任前端解析的 payload 用户是否还有权限,必须请求后端接口确认
Token 刷新后旧 Token 仍可用 JWT 无状态,除非服务端维护黑名单
敏感操作不要只用 Token 修改密码、转账等操作应二次验证(验证码/生物识别)
HTTPS 是前提 非 HTTPS 下 Token 明文传输,中间人攻击可窃取

四、Vue 路由守卫

4.1 路由配置与 meta 信息

// src/router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'

// 声明路由 meta 类型
declare module 'vue-router' {
  interface RouteMeta {
    title?: string
    requiresAuth?: boolean       // 是否需要登录
    roles?: string[]             // 允许访问的角色
    permissions?: string[]       // 允许访问的权限码
    hidden?: boolean             // 是否在菜单中隐藏
    icon?: string                // 菜单图标
    keepAlive?: boolean          // 是否缓存
  }
}

const routes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/login/LoginView.vue'),
    meta: { title: '登录', requiresAuth: false },
  },
  {
    path: '/',
    component: () => import('@/layouts/AdminLayout.vue'),
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        name: 'Dashboard',
        component: () => import('@/views/dashboard/DashboardView.vue'),
        meta: { title: '仪表盘', requiresAuth: true, roles: ['admin', 'user'] },
      },
      {
        path: 'users',
        name: 'UserManagement',
        component: () => import('@/views/system/UserManagement.vue'),
        meta: {
          title: '用户管理',
          requiresAuth: true,
          roles: ['admin'],
          permissions: ['system:user:list'],
          icon: 'User',
        },
      },
      {
        path: 'settings',
        name: 'Settings',
        component: () => import('@/views/system/Settings.vue'),
        meta: { title: '系统设置', requiresAuth: true, roles: ['admin'] },
      },
    ],
  },
  {
    path: '/403',
    name: 'Forbidden',
    component: () => import('@/views/error/Forbidden.vue'),
    meta: { title: '无权限访问' },
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: () => import('@/views/error/NotFound.vue'),
    meta: { title: '页面不存在' },
  },
]

const router = createRouter({
  history: createWebHistory(),
  routes,
})

export default router

4.2 beforeEach:全局前置守卫

// src/router/guards.ts
import type { Router } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'

/**
 * 白名单:无需登录即可访问的路由
 */
const WHITE_LIST = ['/login', '/register', '/forgot-password', '/403', '/404']

export function setupRouterGuards(router: Router) {
  router.beforeEach(async (to, _from, next) => {
    // 设置页面标题
    document.title = `${to.meta.title ?? ''} - Admin`

    const userStore = useUserStore()
    const token = userStore.token

    // ===================== 未登录流程 =====================
    if (!token) {
      if (to.meta.requiresAuth !== false || !WHITE_LIST.includes(to.path)) {
        // ❌ 需要登录但无 Token → 跳转登录页,携带重定向地址
        next({ path: '/login', query: { redirect: to.fullPath } })
      } else {
        // ✅ 白名单路由 → 直接放行
        next()
      }
      return
    }

    // ===================== 已登录流程 =====================
    // 已登录时访问登录页 → 重定向到首页
    if (to.path === '/login') {
      next({ path: '/' })
      return
    }

    // ===================== 角色/权限校验 =====================
    const requiredRoles = to.meta.roles as string[] | undefined
    const requiredPermissions = to.meta.permissions as string[] | undefined

    // 无需角色校验 → 放行
    if (!requiredRoles && !requiredPermissions) {
      next()
      return
    }

    // 角色校验
    if (requiredRoles && requiredRoles.length > 0) {
      const hasRole = requiredRoles.some((role) => userStore.roles.includes(role))
      if (!hasRole) {
        ElMessage.warning('您没有访问该页面的权限')
        next({ path: '/403' })
        return
      }
    }

    // 权限码校验
    if (requiredPermissions && requiredPermissions.length > 0) {
      const hasPermission = requiredPermissions.every((perm) =>
        userStore.permissions.includes(perm),
      )
      if (!hasPermission) {
        ElMessage.warning('您没有访问该页面的权限')
        next({ path: '/403' })
        return
      }
    }

    next()
  })

  // 后置守卫:页面加载完成后操作
  router.afterEach((to) => {
    // 页面访问埋点
    console.log(`[Page View] ${to.path} - ${to.meta.title}`)
  })
}
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { setupRouterGuards } from './router/guards'

const app = createApp(App)
app.use(router)

setupRouterGuards(router)

app.mount('#app')

4.3 beforeEnter:路由独享守卫

适用于某个路由特殊的权限校验逻辑。

{
  path: 'articles/:id',
  name: 'ArticleDetail',
  component: () => import('@/views/content/ArticleDetail.vue'),
  meta: { title: '文章详情', requiresAuth: true },
  beforeEnter: (to, _from, next) => {
    const id = Number(to.params.id)

    // ❌ 防御式校验:参数不合法则重定向
    if (Number.isNaN(id) || id <= 0) {
      next({ path: '/404' })
      return
    }

    // 检查文章是否可访问(实际场景应异步请求后端)
    // ...

    next()
  },
}

4.4 动态路由:根据权限添加

// src/router/dynamic-routes.ts
import type { RouteRecordRaw } from 'vue-router'

/**
 * 根据后端返回的菜单/权限配置动态生成路由
 */
export function generateDynamicRoutes(menus: ApiMenu[]): RouteRecordRaw[] {
  return menus
    .filter((menu) => menu.visible !== false) // 过滤隐藏菜单
    .map((menu) => ({
      path: menu.path,
      name: menu.name,
      component: () => import(`@/views/${menu.component}.vue`), // 约定路径映射
      meta: {
        title: menu.title,
        icon: menu.icon,
        roles: menu.roles,
        permissions: menu.permissions,
        hidden: menu.hidden,
      },
      children: menu.children ? generateDynamicRoutes(menu.children) : undefined,
    }))
}
// 在 beforeEach 中使用
router.beforeEach(async (to, _from, next) => {
  const userStore = useUserStore()
  const token = userStore.token

  // 已登录但还没加载动态路由
  if (token && !userStore.dynamicRoutesLoaded) {
    try {
      // 1. 获取用户菜单/权限
      const menus = await userStore.fetchUserMenus()

      // 2. 生成动态路由并添加
      const dynamicRoutes = generateDynamicRoutes(menus)
      dynamicRoutes.forEach((route) => {
        router.addRoute(route) // 添加到根路由
      })

      // 3. 标记已加载
      userStore.dynamicRoutesLoaded = true

      // 4. 重定向到目标路由(addRoute 后需要重定向才能匹配新路由)
      next({ ...to, replace: true })
    } catch {
      // 获取菜单失败 → 退出登录
      userStore.logout()
      next({ path: '/login' })
    }
    return
  }

  next()
})

⚠️ 动态路由注意事项

注意事项 说明
addRoute 后需重定向 新添加的路由不会立即匹配当前导航,需 next({ ...to, replace: true })
路由名称冲突 添加同名路由会覆盖,建议使用唯一前缀
避免重复添加 router.hasRoute(name) 判断是否已注册
路由组件路径约定 约定优于配置,菜单的 component 字段对应 views 下的路径

4.5 路由守卫执行流程

导航被触发
    ↓
beforeRouteLeave(失活的组件)
    ↓
beforeEach(全局前置守卫)
    ↓
beforeEnter(路由独享守卫,只有当前路由有)
    ↓
beforeResolve(全局解析守卫)
    ↓
导航被确认
    ↓
afterEach(全局后置守卫)
    ↓
beforeRouteEnter(进入的组件)
    ↓
组件创建 → DOM 更新

五、React 路由守卫

5.1 ProtectedRoute 包装组件

// src/components/ProtectedRoute.tsx
import { Navigate, useLocation } from 'react-router-dom'
import { useUserStore } from '@/stores/userStore'
import { Result, Button } from 'antd'

interface ProtectedRouteProps {
  children: React.ReactNode
  /** 允许访问的角色 */
  roles?: string[]
  /** 需要的权限码 */
  permissions?: string[]
}

/**
 * 路由守卫包装组件
 *
 * 用法:
 * <Route path="/dashboard" element={<ProtectedRoute roles={['admin']}><Dashboard /></ProtectedRoute>} />
 */
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
  children,
  roles,
  permissions,
}) => {
  const { token, roles: userRoles, permissions: userPermissions } = useUserStore()
  const location = useLocation()

  // ===================== 1. 未登录 → 重定向到登录页 =====================
  if (!token) {
    return <Navigate to="/login" state={{ from: location }} replace />
  }

  // ===================== 2. 角色校验 =====================
  if (roles && roles.length > 0) {
    const hasRole = roles.some((role) => userRoles.includes(role))
    if (!hasRole) {
      return (
        <Result
          status="403"
          title="403"
          subTitle="抱歉,您没有访问此页面的权限。"
          extra={
            <Button type="primary" onClick={() => window.history.back()}>
              返回上一页
            </Button>
          }
        />
      )
    }
  }

  // ===================== 3. 权限码校验 =====================
  if (permissions && permissions.length > 0) {
    const hasPermission = permissions.every((perm) => userPermissions.includes(perm))
    if (!hasPermission) {
      return (
        <Result
          status="403"
          title="403"
          subTitle="抱歉,您没有访问此页面的权限。"
          extra={
            <Button type="primary" onClick={() => window.history.back()}>
              返回上一页
            </Button>
          }
        />
      )
    }
  }

  return <>{children}</>
}

export default ProtectedRoute

路由配置

// src/router/index.tsx
import { createBrowserRouter, Navigate } from 'react-router-dom'
import ProtectedRoute from '@/components/ProtectedRoute'
import AdminLayout from '@/layouts/AdminLayout'
import Login from '@/views/login/LoginView'
import Dashboard from '@/views/dashboard/DashboardView'
import UserManagement from '@/views/system/UserManagement'
import Settings from '@/views/system/Settings'

export const routerConfig = createBrowserRouter([
  {
    path: '/login',
    element: <Login />,
  },
  {
    path: '/',
    element: <AdminLayout />,
    children: [
      {
        index: true,
        element: <Navigate to="/dashboard" replace />,
      },
      {
        path: 'dashboard',
        element: (
          <ProtectedRoute>
            <Dashboard />
          </ProtectedRoute>
        ),
      },
      {
        path: 'users',
        element: (
          <ProtectedRoute roles={['admin']} permissions={['system:user:list']}>
            <UserManagement />
          </ProtectedRoute>
        ),
      },
      {
        path: 'settings',
        element: (
          <ProtectedRoute roles={['admin']}>
            <Settings />
          </ProtectedRoute>
        ),
      },
    ],
  },
  {
    path: '/403',
    element: (
      <Result status="403" title="403" subTitle="无权限访问此页面。" />
    ),
  },
  {
    path: '*',
    element: (
      <Result status="404" title="404" subTitle="页面不存在。" />
    ),
  },
])

5.2 全局路由守卫(类似 beforeEach)

React Router v6 没有 vue-router 那样的导航守卫,但可以通过路由拦截组件 + layout 组件内的监听实现:

// src/components/AppRouter.tsx
import { useEffect } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { useUserStore } from '@/stores/userStore'
import { message } from 'antd'

/**
 * 全局路由守卫组件(作为 Layout 的根节点)
 *
 * 处理:
 * - 页面标题
 * - 登录态校验(已在每个 ProtectedRoute 中处理,这里处理非 ProtectedRoute 的情况)
 * - 页面访问埋点
 */
const AppRouter: React.FC = () => {
  const location = useLocation()
  const navigate = useNavigate()

  useEffect(() => {
    const { token } = useUserStore.getState()

    // 设置页面标题
    document.title = `${location.pathname} - Admin`

    // 页面访问埋点
    console.log(`[Page View] ${location.pathname}`, {
      timestamp: Date.now(),
      token: !!token,
    })
  }, [location])

  return <Outlet />
}

export default AppRouter

5.3 动态路由实现

// src/router/dynamic-routes.tsx
import { lazy, Suspense } from 'react'
import type { RouteObject } from 'react-router-dom'
import { Spin } from 'antd'

// 组件路径映射表(约定优于配置)
const componentMap: Record<string, React.LazyExoticComponent<React.ComponentType<any>>> = {
  'system/user': lazy(() => import('@/views/system/UserManagement')),
  'system/role': lazy(() => import('@/views/system/RoleManagement')),
  'system/menu': lazy(() => import('@/views/system/MenuManagement')),
  'content/article': lazy(() => import('@/views/content/ArticleList')),
}

/**
 * 根据后端返回的菜单配置生成动态路由
 */
export function generateDynamicRoutes(menus: ApiMenu[]): RouteObject[] {
  return menus
    .filter((menu) => menu.visible !== false)
    .map((menu) => {
      const Component = menu.component ? componentMap[menu.component] : undefined

      const route: RouteObject = {
        path: menu.path,
        // lazy load component
        element: Component ? (
          <Suspense fallback={<Spin style={{ display: 'flex', justifyContent: 'center', marginTop: 200 }} />}>
            <Component />
          </Suspense>
        ) : undefined,
        children: menu.children ? generateDynamicRoutes(menu.children) : undefined,
      }

      return route
    })
}
// 在 App 或 Layout 初始化时调用
// src/App.tsx
import { useEffect } from 'react'
import { RouterProvider } from 'react-router-dom'
import { routerConfig } from '@/router'
import { useUserStore } from '@/stores/userStore'
import { generateDynamicRoutes } from '@/router/dynamic-routes'

function App() {
  const { token, dynamicRoutes, setDynamicRoutes } = useUserStore()

  useEffect(() => {
    if (token && dynamicRoutes.length === 0) {
      fetchMenus().then((menus) => {
        const routes = generateDynamicRoutes(menus)
        setDynamicRoutes(routes)
      })
    }
  }, [token])

  return <RouterProvider router={routerConfig} />
}

六、权限指令与权限组件

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

// src/directives/permission.ts
import type { App, DirectiveBinding } from 'vue'
import { useUserStore } from '@/stores/user'

/**
 * 权限指令
 *
 * 用法:
 * <el-button v-permission="'system:user:add'">新增用户</el-button>
 * <el-button v-permission="['system:user:add', 'system:user:edit']">操作</el-button>
 * <el-button v-permission:or="['system:user:add', 'system:user:edit']">操作</el-button>
 */
function checkPermission(el: HTMLElement, binding: DirectiveBinding) {
  const userStore = useUserStore()

  // 防御式编程:用户信息未加载完直接返回
  if (!userStore.permissions || userStore.permissions.length === 0) {
    el.parentNode?.removeChild(el)
    return
  }

  const value = binding.value
  const arg = binding.arg // 'or' 或 undefined

  // ❌ 未传入权限码 → 显示元素
  if (!value) return

  const requiredPermissions: string[] = Array.isArray(value) ? value : [value]

  // 默认 AND 逻辑:必须拥有所有权限码
  let hasPermission: boolean

  if (arg === 'or') {
    // OR 逻辑:拥有任一权限码即可
    hasPermission = requiredPermissions.some((perm) =>
      userStore.permissions.includes(perm),
    )
  } else {
    // AND 逻辑:必须拥有所有权限码(默认)
    hasPermission = requiredPermissions.every((perm) =>
      userStore.permissions.includes(perm),
    )
  }

  if (!hasPermission) {
    // ❌ 无权限 → 从 DOM 中移除元素
    el.parentNode?.removeChild(el)
  }
}

/**
 * v-permission 更新处理
 * ⚠️ 当权限列表动态变化时需要重新校验
 */
const updatedDirective = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    checkPermission(el, binding)
  },
  updated(el: HTMLElement, binding: DirectiveBinding) {
    // 权限发生变化时重新校验
    if (binding.value !== binding.oldValue) {
      checkPermission(el, binding)
    }
  },
}

export function setupPermissionDirective(app: App) {
  app.directive('permission', updatedDirective)
}
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { setupPermissionDirective } from '@/directives/permission'

const app = createApp(App)
setupPermissionDirective(app)
app.mount('#app')

使用示例

<template>
  <div class="user-management">
    <div class="toolbar">
      <!-- ❌ 无权限 → 按钮不渲染 -->
      <el-button v-permission="'system:user:add'" type="primary" @click="handleAdd">
        新增用户
      </el-button>

      <!-- 拥有任一权限即可显示 -->
      <el-button v-permission:or="['system:user:export', 'system:user:import']" @click="handleBatch">
        批量操作
      </el-button>

      <!-- 必须有所有权限才显示 -->
      <el-button v-permission="['system:user:edit', 'system:user:delete']" @click="handleManage">
        管理
      </el-button>
    </div>

    <el-table :data="users">
      <el-table-column prop="name" label="用户名" />
      <el-table-column prop="email" label="邮箱" />

      <!-- 按钮级权限:行内操作 -->
      <el-table-column label="操作">
        <template #default="{ row }">
          <el-button
            v-permission="'system:user:edit'"
            type="primary"
            link
            @click="handleEdit(row)"
          >
            编辑
          </el-button>
          <el-button
            v-permission="'system:user:delete'"
            type="danger"
            link
            @click="handleDelete(row)"
          >
            删除
          </el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

6.2 React 权限组件 <Permission>

// src/components/Permission.tsx
import { useUserStore } from '@/stores/userStore'

interface PermissionProps {
  children: React.ReactNode
  /** 所需的权限码(数组表示 AND 逻辑) */
  permissions: string[]
  /** 逻辑模式:and(默认,拥有所有权限)| or(拥有任一即可) */
  mode?: 'and' | 'or'
  /** 无权限时的 fallback 内容(默认不渲染) */
  fallback?: React.ReactNode
}

/**
 * 权限组件
 *
 * 用法:
 * <Permission permissions="system:user:add">
 *   <Button type="primary">新增用户</Button>
 * </Permission>
 *
 * <Permission permissions={['system:user:add', 'system:user:edit']} mode="or">
 *   <Button>操作</Button>
 * </Permission>
 */
const Permission: React.FC<PermissionProps> = ({
  children,
  permissions,
  mode = 'and',
  fallback = null,
}) => {
  const userPermissions = useUserStore((state) => state.permissions)

  // 防御式编程:权限数据未加载 → 不渲染
  if (!userPermissions || userPermissions.length === 0) {
    return <>{fallback}</>
  }

  const requiredPerms = Array.isArray(permissions) ? permissions : [permissions]
  if (requiredPerms.length === 0) return <>{children}</>

  let hasPermission: boolean

  if (mode === 'or') {
    hasPermission = requiredPerms.some((perm) => userPermissions.includes(perm))
  } else {
    hasPermission = requiredPerms.every((perm) => userPermissions.includes(perm))
  }

  return hasPermission ? <>{children}</> : <>{fallback}</>
}

export default Permission
// src/components/Can.tsx (另一种更语义化的命名)

/**
 * 简洁的权限守卫组件
 *
 * <Can perm="system:user:add">
 *   <Button>新增</Button>
 * </Can>
 */
export const Can: React.FC<{
  perm: string | string[]
  mode?: 'and' | 'or'
  children: React.ReactNode
}> = ({ perm, mode, children }) => (
  <Permission permissions={Array.isArray(perm) ? perm : [perm]} mode={mode}>
    {children}
  </Permission>
)

使用示例

import { Button, Table, Space, message } from 'antd'
import Permission from '@/components/Permission'

const UserManagement: React.FC = () => {
  const columns = [
    { title: '用户名', dataIndex: 'name', key: 'name' },
    { title: '邮箱', dataIndex: 'email', key: 'email' },
    {
      title: '操作',
      key: 'action',
      render: (_: unknown, record: User) => (
        <Space>
          {/* 权限不够,整个按钮不显示 */}
          <Permission permissions="system:user:edit">
            <Button type="link" onClick={() => handleEdit(record)}>
              编辑
            </Button>
          </Permission>

          {/* 显示禁用状态而不是直接隐藏 */}
          <Permission
            permissions="system:user:delete"
            fallback={
              <Button type="link" disabled title="没有删除权限">
                删除
              </Button>
            }
          >
            <Button type="link" danger onClick={() => handleDelete(record)}>
              删除
            </Button>
          </Permission>
        </Space>
      ),
    },
  ]

  return (
    <div>
      {/* ❌ 无权限 → Button 不渲染 */}
      <Permission permissions="system:user:add">
        <Button type="primary" onClick={handleAdd}>
          新增用户
        </Button>
      </Permission>

      {/* 拥有任一权限即可显示 */}
      <Permission permissions={['system:user:export', 'system:user:import']} mode="or">
        <Button onClick={handleBatch}>批量操作</Button>
      </Permission>

      <Table columns={columns} dataSource={users} />
    </div>
  )
}

七、RBAC 在前端的落地

7.1 RBAC 模型在前端的映射

后端 RBAC 模型:
    用户 (User)
       │ 多对多
    角色 (Role) ——— 多对多 ——— 权限 (Permission)
       │
    菜单 (Menu) / 按钮 (Button) ═══ 关联权限码

前端对应数据结构:
    {
      userInfo: { id, name, avatar },
      roles: ['admin', 'editor'],       // 角色列表
      permissions: ['system:user:list', 'system:user:add', ...], // 权限码列表
      menus: [                          // 菜单配置
        { path: '/dashboard', title: '仪表盘', icon: 'Dashboard' },
        { path: '/system', title: '系统管理', children: [
          { path: '/system/user', title: '用户管理', permission: 'system:user:list' },
          { path: '/system/role', title: '角色管理', permission: 'system:role:list' },
        ]},
      ],
    }

7.2 登录后获取角色和权限列表

Vue 3 + Pinia

// src/stores/user.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { loginApi, getUserInfoApi, getMenusApi } from '@/api/user'
import router from '@/router'
import { generateDynamicRoutes } from '@/router/dynamic-routes'
import type { ApiMenu } from '@/types/menu'

interface LoginParams {
  username: string
  password: string
}

interface UserInfo {
  id: number
  username: string
  avatar: string
  email: string
}

export const useUserStore = defineStore('user', () => {
  // ===================== State =====================
  const token = ref(localStorage.getItem('accessToken') ?? '')
  const refreshToken = ref(localStorage.getItem('refreshToken') ?? '')
  const userInfo = ref<UserInfo | null>(null)
  const roles = ref<string[]>([])
  const permissions = ref<string[]>([])
  const menus = ref<ApiMenu[]>([])
  const dynamicRoutesLoaded = ref(false)

  // ===================== Actions =====================

  /** 登录 */
  async function login(params: LoginParams) {
    const res = await loginApi(params)
    const { accessToken: at, refreshToken: rt } = res.data

    // ✅ 持久化存储
    token.value = at
    refreshToken.value = rt
    localStorage.setItem('accessToken', at)
    localStorage.setItem('refreshToken', rt)
  }

  /** 获取用户信息(登录后或刷新页面后调用) */
  async function fetchUserInfo() {
    const res = await getUserInfoApi()
    const data = res.data

    userInfo.value = {
      id: data.id,
      username: data.username,
      avatar: data.avatar,
      email: data.email,
    }
    roles.value = data.roles ?? []
    permissions.value = data.permissions ?? []
  }

  /** 获取用户菜单并注册动态路由 */
  async function fetchUserMenus() {
    const res = await getMenusApi()
    menus.value = res.data

    // 生成动态路由并注册
    const dynamicRoutes = generateDynamicRoutes(menus.value)
    dynamicRoutes.forEach((route) => {
      if (!router.hasRoute(route.name as string)) {
        router.addRoute(route)
      }
    })

    dynamicRoutesLoaded.value = true
  }

  /** 退出登录 */
  function logout() {
    token.value = ''
    refreshToken.value = ''
    userInfo.value = null
    roles.value = []
    permissions.value = []
    menus.value = []
    dynamicRoutesLoaded.value = false

    localStorage.removeItem('accessToken')
    localStorage.removeItem('refreshToken')

    router.push('/login')
  }

  return {
    token,
    refreshToken,
    userInfo,
    roles,
    permissions,
    menus,
    dynamicRoutesLoaded,
    login,
    fetchUserInfo,
    fetchUserMenus,
    logout,
  }
})

React + Zustand

// src/stores/userStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { loginApi, getUserInfoApi, getMenusApi } from '@/api/user'

interface UserInfo {
  id: number
  username: string
  avatar: string
  email: string
}

interface ApiMenu {
  path: string
  title: string
  icon?: string
  component?: string
  permission?: string
  visible?: boolean
  children?: ApiMenu[]
}

interface UserState {
  // State
  token: string
  refreshToken: string
  userInfo: UserInfo | null
  roles: string[]
  permissions: string[]
  menus: ApiMenu[]
  dynamicRoutesLoaded: boolean

  // Actions
  login: (params: { username: string; password: string }) => Promise<void>
  fetchUserInfo: () => Promise<void>
  fetchUserMenus: () => Promise<ApiMenu[]>
  setDynamicRoutes: (routes: any[]) => void
  logout: () => void
}

export const useUserStore = create<UserState>()(
  persist(
    (set, get) => ({
      // State
      token: '',
      refreshToken: '',
      userInfo: null,
      roles: [],
      permissions: [],
      menus: [],
      dynamicRoutesLoaded: false,

      // Actions
      login: async (params) => {
        const res = await loginApi(params)
        const { accessToken, refreshToken } = res.data
        set({
          token: accessToken,
          refreshToken,
        })
      },

      fetchUserInfo: async () => {
        const res = await getUserInfoApi()
        const data = res.data
        set({
          userInfo: {
            id: data.id,
            username: data.username,
            avatar: data.avatar,
            email: data.email,
          },
          roles: data.roles ?? [],
          permissions: data.permissions ?? [],
        })
      },

      fetchUserMenus: async () => {
        const res = await getMenusApi()
        const menus = res.data
        set({ menus, dynamicRoutesLoaded: true })
        return menus
      },

      setDynamicRoutes: (routes) => {
        // 动态路由存储在另一个 store 或全局变量中
      },

      logout: () => {
        set({
          token: '',
          refreshToken: '',
          userInfo: null,
          roles: [],
          permissions: [],
          menus: [],
          dynamicRoutesLoaded: false,
        })
        localStorage.removeItem('accessToken')
        localStorage.removeItem('refreshToken')
      },
    }),
    {
      name: 'user-storage',
      // 只持久化 token,用户信息每次刷新重新获取
      partialize: (state) => ({
        token: state.token,
        refreshToken: state.refreshToken,
      }),
    },
  ),
)

7.3 动态生成菜单(侧边栏)

Vue 3 + Element Plus

<!-- src/layouts/components/Sidebar.vue -->
<template>
  <el-menu
    :default-active="route.path"
    :collapse="isCollapse"
    router
  >
    <template v-for="menu in filteredMenus" :key="menu.path">
      <!-- 有子菜单 -->
      <el-sub-menu v-if="menu.children?.length" :index="menu.path">
        <template #title>
          <el-icon><component :is="menu.icon" /></el-icon>
          <span>{{ menu.title }}</span>
        </template>
        <el-menu-item
          v-for="child in menu.children"
          :key="child.path"
          :index="child.path"
        >
          <span>{{ child.title }}</span>
        </el-menu-item>
      </el-sub-menu>

      <!-- 没有子菜单 -->
      <el-menu-item v-else :index="menu.path">
        <el-icon><component :is="menu.icon" /></el-icon>
        <span>{{ menu.title }}</span>
      </el-menu-item>
    </template>
  </el-menu>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'

defineProps<{ isCollapse: boolean }>()

const route = useRoute()
const userStore = useUserStore()

/** 过滤无权限的菜单 */
const filteredMenus = computed(() => {
  return filterMenuByPermission(userStore.menus)
})

/** 递归过滤菜单:没有权限的子菜单整体移除 */
function filterMenuByPermission(menus: any[]): any[] {
  return menus
    .filter((menu) => {
      // 如果菜单需要权限码,检查是否拥有
      if (menu.permission) {
        return userStore.permissions.includes(menu.permission)
      }
      return true
    })
    .map((menu) => {
      if (menu.children) {
        return {
          ...menu,
          children: filterMenuByPermission(menu.children),
        }
      }
      return menu
    })
    .filter((menu) => {
      // 父菜单没有可显示的子孙 → 移除
      if (menu.children && menu.children.length === 0) return false
      return true
    })
}
</script>

React + Ant Design

// src/layouts/Sidebar.tsx
import { useNavigate, useLocation } from 'react-router-dom'
import { Menu } from 'antd'
import type { MenuProps } from 'antd'
import { useUserStore } from '@/stores/userStore'
import {
  DashboardOutlined,
  UserOutlined,
  SettingOutlined,
} from '@ant-design/icons'

/** 图标映射表 */
const iconMap: Record<string, React.ReactNode> = {
  Dashboard: <DashboardOutlined />,
  User: <UserOutlined />,
  Setting: <SettingOutlined />,
}

const Sidebar: React.FC = () => {
  const navigate = useNavigate()
  const location = useLocation()
  const { menus, permissions } = useUserStore()

  /** 递归过滤菜单 */
  const filterMenu = (items: ApiMenu[]): MenuProps['items'] => {
    return items
      .filter((item) => {
        if (item.permission) return permissions.includes(item.permission)
        return true
      })
      .map((item) => {
        const menuItem: any = {
          key: item.path,
          icon: item.icon ? iconMap[item.icon] : undefined,
          label: item.title,
        }

        if (item.children) {
          const filteredChildren = filterMenu(item.children)
          if (filteredChildren && filteredChildren.length > 0) {
            menuItem.children = filteredChildren
          }
        }

        return menuItem
      })
      .filter(Boolean)
  }

  const menuItems = filterMenu(menus)

  const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
    navigate(key)
  }

  return (
    <Menu
      mode="inline"
      selectedKeys={[location.pathname]}
      defaultOpenKeys={menus.map((m) => m.path)}
      items={menuItems}
      onClick={handleMenuClick}
    />
  )
}

export default Sidebar

7.4 权限数据流总览

登录流程:
    ┌──────────┐
    │  用户登录  │
    └────┬─────┘
         ↓
    ┌──────────────┐
    │ 获取 Token   │ ← 后端验证用户名密码
    └────┬─────────┘
         ↓
    ┌──────────────────┐
    │ 存储 Token        │ ← localStorage / sessionStorage
    │ 获取用户信息      │ ← /api/user/info(角色、权限码)
    │ 获取菜单配置      │ ← /api/user/menus
    └────┬─────────────┘
         ↓
    ┌────────────────────┐
    │ 动态注册路由       │ ← addRoute
    │ 生成侧边栏菜单     │ ← 权限过滤
    │ 初始化按钮级权限   │ ← v-permission / <Permission>
    └────┬───────────────┘
         ↓
    ┌──────────┐
    │  进入首页  │
    └──────────┘

页面刷新流程:
    ┌──────────────┐
    │ 页面刷新      │
    └────┬─────────┘
         ↓
    ┌──────────────────┐
    │ 从 storage 读取   │
    │ Token 是否存在?   │
    └────┬─────────────┘
         │
    ┌────┴────┐          ┌──────────────┐
    │  存在    │───有────→│ 获取用户信息  │
    └────┬────┘          │ 获取菜单配置  │
         │               └──────┬───────┘
         │ 不存在                ↓
         ↓               ┌──────────────┐
    ┌──────────┐         │ 动态注册路由  │
    │ 跳转登录  │         │ 渲染页面      │
    └──────────┘         └──────────────┘

八、退出登录处理

8.1 标准退出流程

用户点击"退出登录"
    ↓
1. 调用后端退出接口(让后端清除 Session/Token 缓存)
2. 清除本地 Token(localStorage / sessionStorage)
3. 清除用户状态(Pinia / Zustand store)
4. 清除动态路由(重置路由到初始状态)
5. 跳转登录页

8.2 Vue 退出登录实现

// src/stores/user.ts - logout action
async function logout() {
  try {
    // 1. 通知后端销毁 Token(可选,JWT 无状态时可以跳过)
    await logoutApi()
  } catch {
    // 即使后端接口失败,前端也要清理状态
    console.warn('退出登录接口调用失败,继续执行本地清理')
  } finally {
    // 2. 清除本地 Token
    token.value = ''
    refreshToken.value = ''
    localStorage.removeItem('accessToken')
    localStorage.removeItem('refreshToken')

    // 3. 清除用户状态
    userInfo.value = null
    roles.value = []
    permissions.value = []
    menus.value = []

    // 4. 清除动态路由(回到初始路由配置)
    dynamicRoutesLoaded.value = false
    const dynamicRouteNames = router.getRoutes()
      .filter((r) => r.meta?.dynamic)
      .map((r) => r.name as string)
    dynamicRouteNames.forEach((name) => {
      if (router.hasRoute(name)) {
        router.removeRoute(name)
      }
    })

    // 5. 跳转登录页
    router.push('/login')
  }
}

8.3 React 退出登录实现

// src/stores/userStore.ts
import { create } from 'zustand'
import { logoutApi } from '@/api/user'

// ...在 store 中
logout: async () => {
  try {
    await logoutApi()
  } catch {
    console.warn('退出登录接口调用失败,继续执行本地清理')
  } finally {
    set({
      token: '',
      refreshToken: '',
      userInfo: null,
      roles: [],
      permissions: [],
      menus: [],
      dynamicRoutesLoaded: false,
    })
    localStorage.removeItem('accessToken')
    localStorage.removeItem('refreshToken')

    // 跳转登录页
    window.location.href = '/login'
    // 或使用 navigate:注意不能在 store 中使用 hooks
  }
},

8.4 多标签页同步退出(BroadcastChannel)

当用户在一个标签页退出登录时,其他标签页也应该同步退出。

// src/utils/auth-sync.ts

/**
 * 多标签页登录状态同步
 *
 * 原理:利用 BroadcastChannel API 在同源的不同标签页间通信
 * 兼容性:Chrome 54+, Firefox 38+, Safari 15.4+
 */

const CHANNEL_NAME = 'auth-sync'
const LOGOUT_EVENT = 'logout'

let channel: BroadcastChannel | null = null

/**
 * 初始化广播频道监听
 * 在应用入口处调用一次
 */
export function initAuthSync(onLogout: () => void) {
  try {
    channel = new BroadcastChannel(CHANNEL_NAME)

    channel.onmessage = (event) => {
      if (event.data === LOGOUT_EVENT) {
        console.log('[AuthSync] 收到退出事件,同步退出')
        onLogout()
      }
    }

    console.log('[AuthSync] 多标签页同步已初始化')
  } catch (error) {
    // BroadcastChannel 不可用时静默降级
    console.warn('[AuthSync] BroadcastChannel 不可用,降级为单标签页模式', error)
  }
}

/**
 * 广播退出事件
 */
export function broadcastLogout() {
  if (channel) {
    channel.postMessage(LOGOUT_EVENT)
  }
}

/**
 * 清理广播频道
 */
export function destroyAuthSync() {
  if (channel) {
    channel.close()
    channel = null
  }
}

Vue 中使用

// src/App.vue
import { onMounted, onUnmounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { initAuthSync, destroyAuthSync } from '@/utils/auth-sync'

const userStore = useUserStore()

onMounted(() => {
  initAuthSync(() => {
    userStore.logout()
    // 提示用户
    ElMessage.warning('您的账号已在其他设备退出登录')
  })
})

onUnmounted(() => {
  destroyAuthSync()
})

React 中使用

// src/App.tsx
import { useEffect } from 'react'
import { useUserStore } from '@/stores/userStore'
import { initAuthSync, destroyAuthSync } from '@/utils/auth-sync'
import { message } from 'antd'

function App() {
  const logout = useUserStore((state) => state.logout)

  useEffect(() => {
    initAuthSync(() => {
      logout()
      message.warning('您的账号已在其他设备退出登录')
    })

    return () => {
      destroyAuthSync()
    }
  }, [logout])

  return (
    // ...
  )
}

多标签页同步流程

标签页 A                          标签页 B
    │                               │
    ├── 用户点击退出登录              │
    │                               │
    ├── 1. 调用后端退出接口           │
    ├── 2. 清理本地状态              │
    ├── 3. 广播退出事件 ────────────→│
    │    BroadcastChannel           │
    │                               ├── 收到退出事件
    │                               ├── 1. 清理本地状态
    │                               ├── 2. 跳转登录页
    │                               ├── 3. 显示提示 Toast
    │                               │
    ├── 4. 跳转登录页                │
    │                               │

8.5 退出登录常见问题

问题 处理方式
退出接口失败 前端依然执行本地清理,保证用户能正常登出
Token 已过期 退出时忽略后端接口调用,直接清理本地状态
多标签页不同步 使用 BroadcastChannel 或 storage 事件监听
不清除动态路由 退出后路由表中残留权限路由,下次登录可能出现 404
不清除权限数据 下次登录权限判断错误,页面显示异常

九、常见问题与踩坑点

9.1 刷新页面后路由守卫失效

现象:刷新页面后,有权限的用户被跳转到 403 或登录页。

原因:刷新时 Pinia/Zustand 状态丢失,用户信息和权限列表需要重新获取,但路由守卫在数据加载完成前已执行。

解决方案

// ✅ 正确做法:在路由守卫中使用 async/await 等待数据加载

router.beforeEach(async (to, _from, next) => {
  const userStore = useUserStore()

  // 有 Token 但用户信息未加载 → 先拉取
  if (userStore.token && !userStore.userInfo) {
    try {
      await userStore.fetchUserInfo()   // 等待用户信息加载
      await userStore.fetchUserMenus()  // 等待菜单加载
    } catch {
      userStore.logout()
      next({ path: '/login' })
      return
    }
  }

  next()
})
// ✅ React: 使用 loader 或外层组件
// src/layouts/AdminLayout.tsx
const AdminLayout: React.FC = () => {
  const { token, userInfo, fetchUserInfo, fetchUserMenus } = useUserStore()
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    if (token && !userInfo) {
      setLoading(true)
      Promise.all([fetchUserInfo(), fetchUserMenus()])
        .catch(() => {
          useUserStore.getState().logout()
        })
        .finally(() => setLoading(false))
    } else {
      setLoading(false)
    }
  }, [token, userInfo])

  if (loading) {
    return <Spin fullscreen />
  }

  return (
    <Layout>
      <Sidebar />
      <Content><Outlet /></Content>
    </Layout>
  )
}

9.2 Token 并发刷新问题

现象:多个请求同时返回 401,各自触发 refreshToken,导致多次刷新请求。

解决方案:使用"请求队列"机制(见第三章 axios 拦截器部分),同一时间只允许一个刷新请求,其他请求排队等待。

9.3 动态路由 404 问题

现象:动态路由添加后访问仍显示 404。

原因

  1. Vue Router 的 addRoute 不会影响当前导航,需要重定向
  2. 路由添加顺序问题:嵌套路由必须先添加父路由

排查步骤

// ✅ 调试:打印当前所有注册路由
console.log(
  router.getRoutes().map((r) => ({
    name: r.name,
    path: r.path,
    meta: r.meta,
  })),
)

// ✅ 添加路由后强制刷新匹配
router.replace(to.fullPath)

9.4 权限码命名规范

场景 命名格式 示例
模块 模块名:* system:*
列表/查询 模块名:list system:user:list
新增 模块名:add system:user:add
编辑 模块名:edit system:user:edit
删除 模块名:delete system:user:delete
导出 模块名:export system:user:export
导入 模块名:import system:user:import

最佳实践:前后端约定统一权限码命名规范,建议使用 资源:操作 格式。

9.5 前后端权限校验对比

维度 前端 后端
校验时机 路由跳转前、组件渲染时 每个 API 请求
校验对象 URL、组件、按钮 URL、Method、数据行
校验依据 localStorage 中的权限列表 数据库/缓存中的角色权限
可靠性 ⭐(可被绕过) ⭐⭐⭐⭐⭐
目的 提升用户体验 保障系统安全

9.6 安全建议总结

建议 说明
永远不要信任前端 所有权限校验在后端重复执行
Token 不要存敏感数据 JWT payload 可被任何人解码查看
使用 HTTPS 防止 Token 被中间人攻击截获
Access Token 短时效 建议 15-30 分钟,减少泄露风险
敏感操作二次验证 修改密码、转账等需额外确认
CSP 防 XSS Content-Security-Policy 减少脚本注入
退出清所有状态 清除 Token、用户信息、动态路由

十、要点速记

10.1 核心原则

前端鉴权 = 用户体验层防护
后端鉴权 = 系统安全层兜底
永远不要信任客户端数据
最小权限原则:只给当前页面需要的权限

10.2 关键流程口诀

登录拿到双 Token
请求拦截自动带
401 来了先刷新
刷新失败跳登录

路由守卫 beforeEach
无 Token 跳登录页
有 Token 查角色
角色不对走 403

退出登录三件事
清除 Token 清状态
跳转登录页收工
多标签页广播通

10.3 技术栈对应表

功能 Vue 技术选型 React 技术选型
状态管理 Pinia Zustand / Redux Toolkit
路由 Vue Router 4 React Router v6
UI 组件 Element Plus Ant Design
HTTP 请求 axios + 拦截器 axios + 拦截器
权限指令 v-permission 自定义指令 <Permission> 组件
登录态同步 BroadcastChannel BroadcastChannel
Token 存储 localStorage localStorage

10.4 文件目录建议

src/
├── api/
│   ├── user.ts              # 用户登录、信息、菜单 API
│   └── auth.ts              # 刷新 Token 等认证相关 API
├── stores/
│   ├── user.ts (Vue/Pinia)  # 用户状态管理
│   └── userStore.ts (React) # 用户状态管理
├── router/
│   ├── index.ts             # 路由配置
│   ├── guards.ts            # 路由守卫(Vue)
│   └── dynamic-routes.ts    # 动态路由生成
├── directives/
│   └── permission.ts        # v-permission 自定义指令(Vue)
├── components/
│   ├── ProtectedRoute.tsx   # 路由守卫组件(React)
│   └── Permission.tsx       # 权限组件(React)
├── layouts/
│   ├── Sidebar.vue          # 侧边栏菜单(Vue)
│   └── Sidebar.tsx          # 侧边栏菜单(React)
├── utils/
│   ├── http.ts              # axios 封装 + 拦截器
│   ├── jwt.ts               # JWT 解析工具
│   └── auth-sync.ts         # 多标签页同步
└── views/
    ├── login/
    └── error/
        ├── Forbidden.vue/tsx
        └── NotFound.vue/tsx