阅读视图

发现新文章,点击刷新页面。

Vue3 路由实战 | Vue Router 从 0 到 1 搭建权限管理系统

Vue3 路由实战 | Vue Router 从 0 到 1 搭建权限管理系统

零、为什么路由权限是企业级项目的“灵魂”?

你有没有遇到过这样的场景:

// 用户A登录后,看到了“用户管理”菜单
// 用户B登录后,菜单栏里没有“用户管理”

// 更离谱的是:用户B虽然看不到菜单,但直接输入URL:
// /user/manage
// 页面居然能打开!——这是巨大的安全漏洞!

企业级项目的核心诉求:用户能看到什么,取决于他有什么权限。这不只是UI层面的隐藏,更是路由层面的拦截。

今天,我们就来搭建一个完整的权限路由系统,包含:

  • 登录拦截
  • 动态路由生成
  • 菜单权限控制
  • 按钮级权限

一、路由基础:从0到1的快速回顾

1.1 安装与基础配置

npm install vue-router@4
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

// 静态路由(任何人都能访问)
export const constantRoutes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue'),
    meta: { title: '登录', requiresAuth: false }
  },
  {
    path: '/404',
    name: 'NotFound',
    component: () => import('@/views/error/404.vue'),
    meta: { title: '404', requiresAuth: false }
  },
  {
    path: '/',
    redirect: '/dashboard',
    meta: { requiresAuth: true }
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: () => import('@/views/Dashboard.vue'),
    meta: { 
      title: '仪表盘', 
      icon: 'dashboard',
      requiresAuth: true,
      permissions: ['dashboard:view']  // 需要的权限
    }
  }
]

// 动态路由(根据权限动态添加)
export const asyncRoutes: RouteRecordRaw[] = [
  {
    path: '/user',
    name: 'User',
    component: () => import('@/layout/index.vue'),
    meta: { title: '用户管理', icon: 'user', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'UserList',
        component: () => import('@/views/user/List.vue'),
        meta: { 
          title: '用户列表', 
          permissions: ['user:list'],
          requiresAuth: true 
        }
      },
      {
        path: 'role',
        name: 'RoleList',
        component: () => import('@/views/user/Role.vue'),
        meta: { 
          title: '角色管理', 
          permissions: ['role:list'],
          requiresAuth: true 
        }
      }
    ]
  },
  {
    path: '/product',
    name: 'Product',
    component: () => import('@/layout/index.vue'),
    meta: { title: '商品管理', icon: 'product', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'ProductList',
        component: () => import('@/views/product/List.vue'),
        meta: { 
          title: '商品列表', 
          permissions: ['product:list'],
          requiresAuth: true 
        }
      },
      {
        path: 'category',
        name: 'CategoryList',
        component: () => import('@/views/product/Category.vue'),
        meta: { 
          title: '分类管理', 
          permissions: ['category:list'],
          requiresAuth: true 
        }
      }
    ]
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes: constantRoutes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { top: 0 }
    }
  }
})

export default router

1.2 路由元信息(meta)的妙用

// 定义路由元信息类型
declare module 'vue-router' {
  interface RouteMeta {
    title?: string          // 页面标题
    icon?: string           // 菜单图标
    requiresAuth?: boolean  // 是否需要登录
    permissions?: string[]  // 需要的权限列表
    hidden?: boolean        // 是否在菜单中隐藏
    keepAlive?: boolean     // 是否缓存
    breadcrumb?: boolean    // 是否显示面包屑
    activeMenu?: string     // 高亮的菜单(用于详情页)
  }
}

二、路由守卫:权限控制的守门员

2.1 全局前置守卫

// src/router/index.ts
import { useUserStore } from '@/stores/modules/user'
import { ElMessage } from 'element-plus'

// 白名单:不需要登录就能访问的页面
const whiteList = ['/login', '/404', '/register', '/forget-password']

router.beforeEach(async (to, from, next) => {
  // 设置页面标题
  document.title = to.meta.title ? `${to.meta.title} - 后台管理系统` : '后台管理系统'
  
  const userStore = useUserStore()
  const hasToken = userStore.token
  
  // 1. 如果有 token
  if (hasToken) {
    if (to.path === '/login') {
      // 已登录,访问登录页 → 重定向到首页
      next({ path: '/' })
    } else {
      // 检查是否已经获取过用户信息
      if (userStore.userInfo === null) {
        try {
          // 获取用户信息
          await userStore.fetchUserInfo()
          
          // 根据权限生成动态路由
          const accessRoutes = await generateRoutes(userStore.permissions)
          accessRoutes.forEach(route => {
            router.addRoute(route)
          })
          
          // 解决动态路由刷新后404问题
          next({ ...to, replace: true })
        } catch (error) {
          // token 无效,清除并跳转登录
          await userStore.logout()
          ElMessage.error('登录已过期,请重新登录')
          next(`/login?redirect=${to.path}`)
        }
      } else {
        // 已有用户信息,直接放行
        next()
      }
    }
  } 
  // 2. 没有 token
  else {
    if (whiteList.includes(to.path)) {
      // 在白名单中,直接放行
      next()
    } else {
      // 不在白名单,跳转登录页
      next(`/login?redirect=${to.path}`)
    }
  }
})

2.2 全局后置守卫

// 路由跳转完成后
router.afterEach((to, from) => {
  // 关闭页面加载动画
  // 上报页面访问数据
  // 等等...
  
  // 滚动到顶部(除了需要保持滚动位置的情况)
  if (to.hash) {
    const element = document.querySelector(to.hash)
    if (element) element.scrollIntoView()
  } else {
    window.scrollTo(0, 0)
  }
})

2.3 路由独享守卫

// 在路由配置中单独配置
{
  path: '/settings',
  component: () => import('@/views/Settings.vue'),
  beforeEnter: (to, from, next) => {
    // 检查用户是否有权限访问设置页面
    const userStore = useUserStore()
    if (userStore.userRole === 'admin') {
      next()
    } else {
      next('/403')
    }
  }
}

三、动态路由:根据权限生成菜单

3.1 生成动态路由的核心逻辑

// src/router/utils/dynamicRoutes.ts
import type { RouteRecordRaw } from 'vue-router'
import { asyncRoutes } from '@/router'

/**
 * 根据权限过滤路由
 * @param routes 路由列表
 * @param permissions 用户权限列表
 */
export function filterRoutesByPermissions(
  routes: RouteRecordRaw[],
  permissions: string[]
): RouteRecordRaw[] {
  return routes.filter(route => {
    // 检查当前路由是否需要权限
    if (route.meta?.permissions) {
      // 判断用户是否有任一所需权限
      const hasPermission = route.meta.permissions.some(perm => 
        permissions.includes(perm)
      )
      if (!hasPermission) return false
    }
    
    // 递归过滤子路由
    if (route.children) {
      route.children = filterRoutesByPermissions(route.children, permissions)
      // 如果子路由全部被过滤掉,则当前路由也不显示
      if (route.children.length === 0 && route.meta?.permissions) {
        return false
      }
    }
    
    return true
  })
}

/**
 * 将后端返回的权限树转换为路由
 * @param menus 后端返回的菜单树
 */
export function convertMenusToRoutes(menus: any[]): RouteRecordRaw[] {
  return menus.map(menu => {
    const route: RouteRecordRaw = {
      path: menu.path,
      name: menu.name,
      component: loadComponent(menu.component),
      meta: {
        title: menu.title,
        icon: menu.icon,
        permissions: menu.permissions,
        hidden: menu.hidden
      }
    }
    
    if (menu.children && menu.children.length > 0) {
      route.children = convertMenusToRoutes(menu.children)
    }
    
    return route
  })
}

/**
 * 懒加载组件
 */
function loadComponent(componentPath: string) {
  // 返回一个函数,Vue Router 会异步加载
  return () => import(`@/views/${componentPath}.vue`)
}

3.2 在路由守卫中生成动态路由

// src/router/index.ts
let hasAddedDynamicRoutes = false

router.beforeEach(async (to, from, next) => {
  const userStore = useUserStore()
  const hasToken = userStore.token
  
  if (hasToken) {
    if (to.path === '/login') {
      next({ path: '/' })
    } else {
      if (!hasAddedDynamicRoutes && userStore.userInfo) {
        try {
          // 方式一:前端定义路由,根据权限过滤
          const accessRoutes = filterRoutesByPermissions(
            asyncRoutes, 
            userStore.permissions
          )
          
          // 方式二:后端返回路由,动态添加
          // const accessRoutes = convertMenusToRoutes(userStore.menus)
          
          // 添加动态路由
          accessRoutes.forEach(route => {
            router.addRoute(route)
          })
          
          // 添加404路由(必须放在最后)
          router.addRoute({
            path: '/:pathMatch(.*)*',
            name: 'NotFound',
            component: () => import('@/views/error/404.vue')
          })
          
          hasAddedDynamicRoutes = true
          
          // 重新跳转,确保路由已添加
          next({ ...to, replace: true })
        } catch (error) {
          console.error('生成动态路由失败:', error)
          await userStore.logout()
          next(`/login?redirect=${to.path}`)
        }
      } else {
        next()
      }
    }
  } else {
    // 没有 token 的处理...
    if (whiteList.includes(to.path)) {
      next()
    } else {
      next(`/login?redirect=${to.path}`)
    }
  }
})

3.3 根据路由生成菜单

<!-- components/SidebarMenu.vue -->
<template>
  <el-menu
    :default-active="activeMenu"
    :collapse="isCollapse"
    :unique-opened="true"
    background-color="#304156"
    text-color="#bfcbd9"
    active-text-color="#409eff"
    router
  >
    <template v-for="route in menuRoutes" :key="route.path">
      <!-- 单级菜单 -->
      <el-menu-item 
        v-if="!route.children || route.children.length === 0"
        :index="route.path"
      >
        <el-icon><component :is="route.meta?.icon" /></el-icon>
        <template #title>
          <span>{{ route.meta?.title }}</span>
        </template>
      </el-menu-item>
      
      <!-- 多级菜单(递归) -->
      <el-sub-menu 
        v-else
        :index="route.path"
      >
        <template #title>
          <el-icon><component :is="route.meta?.icon" /></el-icon>
          <span>{{ route.meta?.title }}</span>
        </template>
        <sidebar-menu-item 
          v-for="child in route.children"
          :key="child.path"
          :route="child"
        />
      </el-sub-menu>
    </template>
  </el-menu>
</template>

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

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

const isCollapse = computed(() => appStore.sidebarCollapsed)
const activeMenu = computed(() => {
  const { path, meta } = route
  // 如果路由有 activeMenu 配置,则高亮指定菜单
  if (meta.activeMenu) {
    return meta.activeMenu
  }
  return path
})

// 获取需要显示的菜单路由
const menuRoutes = computed(() => {
  // 从 router 中获取动态添加的路由
  const routes = router.getRoutes()
  
  // 过滤掉不需要在菜单中显示的路由
  return routes.filter(route => {
    return route.meta?.title && !route.meta?.hidden
  })
})
</script>

四、路由懒加载:让首屏飞起来

4.1 基础懒加载

// 标准写法
const UserList = () => import('@/views/user/List.vue')

// 带 loading 的写法
const UserList = () => ({
  component: import('@/views/user/List.vue'),
  loading: LoadingComponent,
  error: ErrorComponent,
  delay: 200,
  timeout: 3000
})

4.2 路由分组(chunk)

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // 将 Vue 相关打包在一起
          'vendor-vue': ['vue', 'vue-router', 'pinia'],
          // 将 UI 库单独打包
          'vendor-element': ['element-plus'],
          // 将工具库打包
          'vendor-utils': ['axios', 'dayjs', 'lodash-es'],
          // 将路由页面按模块分组
          'routes-user': [
            './src/views/user/List.vue',
            './src/views/user/Role.vue'
          ],
          'routes-product': [
            './src/views/product/List.vue',
            './src/views/product/Category.vue'
          ]
        }
      }
    }
  }
})

4.3 预加载策略

<!-- index.html 中添加预加载链接 -->
<link rel="prefetch" href="/assets/js/dashboard.xxx.js">
// 使用 webpack/vite 的魔法注释
const UserList = () => import(
  /* webpackChunkName: "user-list" */
  /* webpackPrefetch: true */
  '@/views/user/List.vue'
)

五、实战:后台管理系统完整路由模块

5.1 项目结构

src/
├── router/
│   ├── index.ts                 # 路由主文件
│   ├── modules/                 # 路由模块
│   │   ├── user.ts              # 用户模块路由
│   │   ├── product.ts           # 商品模块路由
│   │   └── order.ts             # 订单模块路由
│   ├── guards/                  # 路由守卫
│   │   ├── auth.ts              # 认证守卫
│   │   ├── permission.ts        # 权限守卫
│   │   └── progress.ts          # 进度条守卫
│   └── utils/                   # 路由工具
│       ├── dynamicRoutes.ts     # 动态路由生成
│       └── permissions.ts       # 权限过滤
├── layout/
│   ├── index.vue                # 主布局
│   ├── Sidebar.vue              # 侧边栏
│   └── Header.vue               # 头部
└── views/
    ├── login/
    │   └── index.vue
    ├── dashboard/
    │   └── index.vue
    ├── user/
    │   ├── List.vue
    │   └── Role.vue
    └── error/
        ├── 401.vue
        ├── 403.vue
        └── 404.vue

5.2 完整路由配置

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { Router, RouteRecordRaw } from 'vue-router'
import { useUserStore } from '@/stores/modules/user'
import { useAppStore } from '@/stores/modules/app'
import { ElMessage } from 'element-plus'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'

// 配置进度条
NProgress.configure({ showSpinner: false })

// 静态路由
export const constantRoutes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/login/index.vue'),
    meta: { title: '登录', requiresAuth: false }
  },
  {
    path: '/401',
    name: 'Unauthorized',
    component: () => import('@/views/error/401.vue'),
    meta: { title: '未授权', requiresAuth: false }
  },
  {
    path: '/403',
    name: 'Forbidden',
    component: () => import('@/views/error/403.vue'),
    meta: { title: '无权限', requiresAuth: false }
  },
  {
    path: '/404',
    name: 'NotFound',
    component: () => import('@/views/error/404.vue'),
    meta: { title: '页面不存在', requiresAuth: false }
  },
  {
    path: '/',
    component: () => import('@/layout/index.vue'),
    redirect: '/dashboard',
    meta: { requiresAuth: true },
    children: [
      {
        path: 'dashboard',
        name: 'Dashboard',
        component: () => import('@/views/dashboard/index.vue'),
        meta: { 
          title: '仪表盘', 
          icon: 'Odometer',
          affix: true,
          requiresAuth: true 
        }
      }
    ]
  }
]

// 动态路由(需要权限)
export const asyncRoutes: RouteRecordRaw[] = [
  {
    path: '/user',
    component: () => import('@/layout/index.vue'),
    meta: { title: '用户管理', icon: 'User', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'UserList',
        component: () => import('@/views/user/List.vue'),
        meta: { 
          title: '用户列表', 
          permissions: ['user:list'],
          keepAlive: true,
          requiresAuth: true 
        }
      },
      {
        path: 'role',
        name: 'RoleList',
        component: () => import('@/views/user/Role.vue'),
        meta: { 
          title: '角色管理', 
          permissions: ['role:list'],
          requiresAuth: true 
        }
      },
      {
        path: 'permission',
        name: 'PermissionList',
        component: () => import('@/views/user/Permission.vue'),
        meta: { 
          title: '权限管理', 
          permissions: ['permission:list'],
          requiresAuth: true 
        }
      }
    ]
  },
  {
    path: '/product',
    component: () => import('@/layout/index.vue'),
    meta: { title: '商品管理', icon: 'Goods', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'ProductList',
        component: () => import('@/views/product/List.vue'),
        meta: { 
          title: '商品列表', 
          permissions: ['product:list'],
          keepAlive: true,
          requiresAuth: true 
        }
      },
      {
        path: 'category',
        name: 'CategoryList',
        component: () => import('@/views/product/Category.vue'),
        meta: { 
          title: '分类管理', 
          permissions: ['category:list'],
          requiresAuth: true 
        }
      },
      {
        path: 'detail/:id',
        name: 'ProductDetail',
        component: () => import('@/views/product/Detail.vue'),
        meta: { 
          title: '商品详情', 
          hidden: true,  // 不在菜单中显示
          activeMenu: '/product/list', // 高亮商品列表菜单
          requiresAuth: true 
        }
      }
    ]
  },
  {
    path: '/order',
    component: () => import('@/layout/index.vue'),
    meta: { title: '订单管理', icon: 'Document', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'OrderList',
        component: () => import('@/views/order/List.vue'),
        meta: { 
          title: '订单列表', 
          permissions: ['order:list'],
          keepAlive: true,
          requiresAuth: true 
        }
      },
      {
        path: 'refund',
        name: 'RefundList',
        component: () => import('@/views/order/Refund.vue'),
        meta: { 
          title: '退款管理', 
          permissions: ['order:refund'],
          requiresAuth: true 
        }
      }
    ]
  },
  {
    path: '/settings',
    component: () => import('@/layout/index.vue'),
    meta: { title: '系统设置', icon: 'Setting', requiresAuth: true, roles: ['admin'] },
    children: [
      {
        path: 'profile',
        name: 'Profile',
        component: () => import('@/views/settings/Profile.vue'),
        meta: { title: '个人设置', requiresAuth: true }
      },
      {
        path: 'account',
        name: 'Account',
        component: () => import('@/views/settings/Account.vue'),
        meta: { title: '账号管理', roles: ['admin'], requiresAuth: true }
      }
    ]
  }
]

// 创建路由实例
const router = createRouter({
  history: createWebHistory(),
  routes: constantRoutes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { top: 0 }
    }
  }
})

// 标记是否已添加动态路由
let hasAddedRoutes = false

// 生成动态路由
async function generateDynamicRoutes(permissions: string[], roles: string[]) {
  // 根据权限过滤路由
  const filterRoutes = (routes: RouteRecordRaw[]): RouteRecordRaw[] => {
    return routes.filter(route => {
      // 检查角色权限
      if (route.meta?.roles && !route.meta.roles.some((role: string) => roles.includes(role))) {
        return false
      }
      
      // 检查按钮权限
      if (route.meta?.permissions) {
        const hasPermission = route.meta.permissions.some((perm: string) => 
          permissions.includes(perm)
        )
        if (!hasPermission) return false
      }
      
      // 递归过滤子路由
      if (route.children) {
        route.children = filterRoutes(route.children)
        if (route.children.length === 0 && route.meta?.permissions) {
          return false
        }
      }
      
      return true
    })
  }
  
  const accessibleRoutes = filterRoutes(asyncRoutes)
  
  // 动态添加路由
  accessibleRoutes.forEach(route => {
    router.addRoute(route)
  })
  
  // 添加404兜底路由
  router.addRoute({
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: () => import('@/views/error/404.vue')
  })
  
  return accessibleRoutes
}

// 全局前置守卫
router.beforeEach(async (to, from, next) => {
  // 开始进度条
  NProgress.start()
  
  const userStore = useUserStore()
  const appStore = useAppStore()
  const hasToken = userStore.token
  
  // 设置页面标题
  if (to.meta.title) {
    document.title = `${to.meta.title} - ${appStore.siteTitle}`
  }
  
  if (hasToken) {
    // 已登录
    if (to.path === '/login') {
      // 跳转到首页
      next({ path: '/' })
      NProgress.done()
    } else {
      // 检查是否已获取用户信息
      if (userStore.userInfo === null) {
        try {
          // 获取用户信息
          await userStore.fetchUserInfo()
          
          // 生成动态路由
          const routes = await generateDynamicRoutes(
            userStore.permissions,
            userStore.roles
          )
          
          // 保存路由到 store(用于生成菜单)
          userStore.setRoutes(routes)
          
          // 解决动态路由刷新后404问题
          next({ ...to, replace: true })
        } catch (error) {
          console.error('路由初始化失败:', error)
          await userStore.logout()
          next(`/login?redirect=${to.path}`)
          NProgress.done()
        }
      } else {
        // 检查路由权限
        if (to.meta.requiresAuth) {
          // 检查角色权限
          if (to.meta.roles && !to.meta.roles.some(role => userStore.roles.includes(role))) {
            next('/403')
            NProgress.done()
            return
          }
          
          // 检查按钮权限
          if (to.meta.permissions) {
            const hasPermission = to.meta.permissions.some(perm => 
              userStore.permissions.includes(perm)
            )
            if (!hasPermission) {
              next('/403')
              NProgress.done()
              return
            }
          }
        }
        next()
      }
    }
  } else {
    // 未登录
    if (to.meta.requiresAuth) {
      next(`/login?redirect=${to.path}`)
      NProgress.done()
    } else {
      next()
    }
  }
})

// 全局后置守卫
router.afterEach(() => {
  // 结束进度条
  NProgress.done()
})

// 重置路由(用于退出登录)
export function resetRouter() {
  // 获取所有动态添加的路由
  const routes = router.getRoutes()
  routes.forEach(route => {
    const name = route.name as string
    // 排除静态路由
    if (!constantRoutes.some(r => r.name === name)) {
      router.removeRoute(name)
    }
  })
  hasAddedRoutes = false
}

export default router

5.3 登录页面实现

<!-- views/login/index.vue -->
<template>
  <div class="login-container">
    <el-form
      ref="loginFormRef"
      :model="loginForm"
      :rules="loginRules"
      class="login-form"
    >
      <h3 class="title">后台管理系统</h3>
      
      <el-form-item prop="username">
        <el-input
          v-model="loginForm.username"
          placeholder="用户名"
          :prefix-icon="User"
          size="large"
        />
      </el-form-item>
      
      <el-form-item prop="password">
        <el-input
          v-model="loginForm.password"
          type="password"
          placeholder="密码"
          :prefix-icon="Lock"
          size="large"
          show-password
          @keyup.enter="handleLogin"
        />
      </el-form-item>
      
      <el-form-item>
        <el-button
          :loading="loading"
          type="primary"
          size="large"
          class="login-btn"
          @click="handleLogin"
        >
          登录
        </el-button>
      </el-form-item>
      
      <div class="tips">
        <span>测试账号:admin / 123456</span>
        <span class="ml-10">普通账号:user / 123456</span>
      </div>
    </el-form>
  </div>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/modules/user'

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

const loginForm = reactive({
  username: 'admin',
  password: '123456'
})

const loginRules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
  ]
}

const loginFormRef = ref()
const loading = ref(false)

const handleLogin = async () => {
  if (!loginFormRef.value) return
  
  await loginFormRef.value.validate(async (valid: boolean) => {
    if (!valid) return
    
    loading.value = true
    try {
      const success = await userStore.login(loginForm)
      if (success) {
        const redirect = route.query.redirect as string || '/'
        router.push(redirect)
        ElMessage.success('登录成功')
      }
    } catch (error) {
      console.error('登录失败:', error)
    } finally {
      loading.value = false
    }
  })
}
</script>

<style scoped lang="scss">
.login-container {
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  
  .login-form {
    width: 400px;
    padding: 40px;
    background: #fff;
    border-radius: 8px;
    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
    
    .title {
      text-align: center;
      margin-bottom: 30px;
      color: #333;
    }
    
    .login-btn {
      width: 100%;
    }
    
    .tips {
      text-align: center;
      color: #999;
      font-size: 12px;
      
      span {
        display: inline-block;
      }
      
      .ml-10 {
        margin-left: 10px;
      }
    }
  }
}
</style>

5.4 按钮级权限指令

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

// 权限指令 v-permission="['user:add']"
const permissionDirective: Directive = {
  mounted(el, binding) {
    const { value } = binding
    const userStore = useUserStore()
    
    if (value && Array.isArray(value) && value.length > 0) {
      const hasPermission = value.some(perm => 
        userStore.permissions.includes(perm)
      )
      
      if (!hasPermission) {
        el.parentNode?.removeChild(el)
      }
    }
  }
}

export function setupPermissionDirective(app: App) {
  app.directive('permission', permissionDirective)
}
<!-- 在组件中使用 -->
<template>
  <div>
    <!-- 只有拥有 user:add 权限才能看到添加按钮 -->
    <el-button v-permission="['user:add']" type="primary">
      添加用户
    </el-button>
    
    <!-- 拥有任一权限即可 -->
    <el-button v-permission="['user:edit', 'user:delete']">
      操作
    </el-button>
  </div>
</template>

六、进阶:路由缓存与标签页

6.1 多标签页功能

// stores/modules/tabs.ts
import { defineStore } from 'pinia'
import type { RouteLocationNormalized } from 'vue-router'

interface TabItem {
  name: string
  title: string
  path: string
  query?: Record<string, any>
  params?: Record<string, any>
}

export const useTabsStore = defineStore('tabs', {
  state: () => ({
    visitedTabs: [] as TabItem[],
    activeTab: ''
  }),
  
  actions: {
    addTab(route: RouteLocationNormalized) {
      // 过滤掉不需要缓存的路由
      if (route.meta?.hidden || route.meta?.noCache) return
      
      const tab: TabItem = {
        name: route.name as string,
        title: route.meta?.title as string,
        path: route.path,
        query: route.query,
        params: route.params
      }
      
      const exists = this.visitedTabs.some(item => item.path === tab.path)
      if (!exists) {
        this.visitedTabs.push(tab)
      }
      
      this.activeTab = tab.path
    },
    
    removeTab(path: string) {
      const index = this.visitedTabs.findIndex(tab => tab.path === path)
      if (index > -1) {
        this.visitedTabs.splice(index, 1)
      }
      
      // 如果删除的是当前激活的标签,跳转到上一个标签
      if (this.activeTab === path) {
        const lastTab = this.visitedTabs[index - 1] || this.visitedTabs[0]
        if (lastTab) {
          this.activeTab = lastTab.path
          return lastTab
        }
      }
      return null
    },
    
    closeOtherTabs(path: string) {
      this.visitedTabs = this.visitedTabs.filter(tab => tab.path === path)
      this.activeTab = path
    },
    
    closeAllTabs() {
      this.visitedTabs = []
      this.activeTab = ''
    }
  }
})

七、常见问题与解决方案

7.1 动态路由刷新后404

// 问题:刷新页面后,动态添加的路由丢失
// 解决:在路由守卫中重新添加

router.beforeEach(async (to, from, next) => {
  // ... 省略其他代码
  
  if (!hasAddedRoutes && userStore.userInfo) {
    // 重新添加动态路由
    await generateDynamicRoutes(userStore.permissions, userStore.roles)
    // 关键:replace 当前路由,重新触发守卫
    next({ ...to, replace: true })
    return
  }
  
  next()
})

7.2 路由权限缓存

// 使用 sessionStorage 缓存用户路由
const cacheKey = `user-routes-${userStore.userId}`

// 保存
sessionStorage.setItem(cacheKey, JSON.stringify(accessibleRoutes))

// 恢复
const cachedRoutes = sessionStorage.getItem(cacheKey)
if (cachedRoutes) {
  const routes = JSON.parse(cachedRoutes)
  routes.forEach(route => router.addRoute(route))
}

八、总结

一个完整的权限路由系统包含:

  1. 静态路由:登录页、404页等公共页面
  2. 动态路由:根据权限动态添加
  3. 路由守卫:登录拦截、权限校验
  4. 菜单生成:根据路由自动生成侧边栏
  5. 权限指令:按钮级权限控制
  6. 路由缓存:标签页、keep-alive

核心代码量统计

  • 路由配置文件:~200行
  • 动态路由逻辑:~100行
  • 路由守卫:~150行
  • 菜单组件
❌