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))
}
八、总结
一个完整的权限路由系统包含:
- 静态路由:登录页、404页等公共页面
- 动态路由:根据权限动态添加
- 路由守卫:登录拦截、权限校验
- 菜单生成:根据路由自动生成侧边栏
- 权限指令:按钮级权限控制
- 路由缓存:标签页、keep-alive
核心代码量统计:
- 路由配置文件:~200行
- 动态路由逻辑:~100行
- 路由守卫:~150行
- 菜单组件