七、【前端路由篇】掌控全局:Vue Router 实现页面导航、动态路由与权限控制
【前端路由篇】掌控全局:Vue Router 实现页面导航、动态路由与权限控制
- 前言
- 第一步:动态路由匹配 - 实现项目详情页
- 第二步:导航守卫 - 实现简单的登录权限控制
- 第三步:优化路由配置和使用路由元信息
- 总结
前言
在单页应用 (SPA) 中,路由系统是灵魂。它负责根据用户在浏览器地址栏输入的 URL,或者用户在页面上的点击操作,来决定渲染哪个组件,从而实现页面内容的切换,而无需重新加载整个 HTML 页面。
Vue Router 是 Vue.js 官方的路由管理器,它与 Vue.js 核心深度集成,使得构建 SPA 变得非常简单。
在上一篇中,我们已经实现了:
- 将
Layout.vue
作为大部分页面的父级路由组件。 - 通过
children
属性定义嵌套路由。 - 使用
<RouterView />
作为子路由的渲染出口。 - 通过
<el-menu router>
属性让 Element Plus 菜单与路由联动。
本篇将在此基础上,探讨以下进阶内容:
- 动态路由匹配: 如何处理像
/project/detail/1
这样带有动态参数的 URL。 - 编程式导航: 如何在 JavaScript 代码中(例如点击按钮后)进行页面跳转。
- 导航守卫: 实现路由级别的权限控制,例如用户未登录时自动跳转到登录页。
- 路由元信息: 如何给路由添加自定义数据,如页面标题、权限要求等。
第一步:动态路由匹配 - 实现项目详情页
在我们的测试平台中,用户经常需要查看某个特定项目的详情。例如,点击项目列表中的某个项目,应该跳转到类似 /project/detail/1
(1 代表项目 ID) 的页面。
-
创建项目详情页组件:
在src/views/project/
目录下创建一个新文件ProjectDetailView.vue
。
内容暂时简单些:
<!-- test-platform/frontend/src/views/project/ProjectDetailView.vue --> <template><div class="project-detail"><h1>项目详情</h1><p v-if="projectId">当前项目 ID: {{ projectId }}</p><p v-else>正在加载项目信息...</p><!-- 后续将展示项目的详细信息,如模块列表、用例统计等 --><el-button @click="goBack">返回项目列表</el-button></div> </template><script setup lang="ts"> import { ref, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router'const route = useRoute() // 获取当前路由信息对象 const router = useRouter() // 获取路由实例const projectId = ref<string | null>(null)onMounted(() => {// 从路由参数中获取 projectIdif (route.params.id) {projectId.value = route.params.id as string// 在这里可以根据 projectId 发起 API 请求获取项目详情数据console.log('获取项目ID为', projectId.value, '的详情数据')} })const goBack = () => {router.push('/project/list') } </script><style scoped> .project-detail {padding: 20px; } </style>
代码解释:
useRoute()
: 这是 Vue Router 4 (Vue3)提供的 Composition API,用于在组件内部访问当前激活的路由对象。它包含了路径、参数、查询等信息。useRouter()
: 用于获取路由器的实例,可以用来进行编程式导航。route.params.id
: 当我们配置动态路由段:id
时,匹配到的值会作为参数存储在route.params
对象中,属性名就是动态段的名称。onMounted
: 我们在组件挂载后尝试获取路由参数id
。goBack
: 演示了编程式导航router.push()
。
-
在路由配置中添加动态路由:
打开src/router/index.ts
,在Layout
组件的children
数组中添加项目详情页的路由配置。// test-platform/frontend/src/router/index.ts import { createRouter, createWebHistory } from 'vue-router' import HomeView from '../views/HomeView.vue' import Layout from '@/layout/index.vue'const router = createRouter({history: createWebHistory(import.meta.env.BASE_URL),routes: [{path: '/',component: Layout,redirect: '/dashboard',children: [// ... (dashboard, projectList, projectCreate 等路由保持不变) ...{path: 'dashboard',name: 'dashboard',component: HomeView,meta: { title: '仪表盘' }},{path: '/project/list', // 之前是 'project/list',如果是父路由component是Layout,子path开头带'/'会从根路径开始匹配name: 'projectList',component: () => import('../views/project/ProjectListView.vue'),meta: { title: '项目列表' }},{path: '/project/create',name: 'projectCreate',component: () => import('../views/project/ProjectCreateView.vue'),meta: { title: '新建项目' }},// 新增动态路由{path: '/project/detail/:id', // :id 就是动态路径参数name: 'projectDetail',component: () => import('../views/project/ProjectDetailView.vue'),meta: { title: '项目详情' },props: true // 可选:将路由参数作为 props 传递给组件},{path: '/testcases',name: 'testcases',component: () => import('../views/project/TestCaseListView.vue'),meta: { title: '用例管理' }},{path: '/reports',name: 'reports',component: () => import('../views/project/ReportListView.vue'),meta: { title: '测试报告' }}]},{path: '/login',name: 'login',component: () => import('../views/LoginView.vue'),meta: { title: '登录' }},// ... (NotFound 路由)] })// ... (router.beforeEach 保持不变或暂时注释掉) ... export default router
代码解释:
path: '/project/detail/:id'
:- 路径中的
:id
部分被称为动态段 (dynamic segment)。它会匹配该位置的任何非空字符串,并将匹配到的值作为参数。 - 例如,
/project/detail/1
和/project/detail/my-project
都会匹配这个路由。
- 路径中的
name: 'projectDetail'
: 给路由命名,方便编程式导航。props: true
: 这是一个非常有用的选项。当设置为true
时,路由参数route.params
会被自动作为 props 传递给ProjectDetailView.vue
组件。这意味着在组件中你可以直接通过defineProps<{ id: string }>()
来接收id
,而无需使用useRoute().params.id
。
-
在项目列表页添加跳转链接 (演示):
为了能方便地跳转到详情页,我们修改ProjectListView.vue
,添加一个模拟的项目列表和跳转链接。<!-- test-platform/frontend/src/views/project/ProjectListView.vue --> <template><div class="project-list"><h2>项目列表</h2><el-table :data="mockProjects" style="width: 100%"><el-table-column prop="id" label="ID" width="180" /><el-table-column prop="name" label="项目名称" width="180" /><el-table-column label="操作"><template #default="scope"><!-- 使用 router-link 进行声明式导航 --><router-link :to="`/project/detail/${scope.row.id}`"><el-button size="small" type="primary">查看详情</el-button></router-link><!-- 或者使用编程式导航 --><!-- <el-button size="small" type="primary" @click="goToDetail(scope.row.id)">查看详情 (编程式)</el-button> --></template></el-table-column></el-table></div> </template><script setup lang="ts"> import { ref } from 'vue' // import { useRouter } from 'vue-router' // 如果使用编程式导航// const router = useRouter() // 如果使用编程式导航const mockProjects = ref([{ id: 1, name: '电商平台测试项目' },{ id: 2, name: '内部管理系统升级' },{ id: 3, name: 'APP V3.0 自动化' }, ])// const goToDetail = (id: number) => { // 如果使用编程式导航 // router.push(`/project/detail/${id}`) // // 或者使用命名的路由 // // router.push({ name: 'projectDetail', params: { id: id.toString() } }) // } </script><style scoped> .project-list {padding: 20px; } </style>
代码解释:
- 我们用 Element Plus 的
el-table
来展示一个模拟的项目列表。 <router-link :to="
/project/detail/${scope.row.id}">
:router-link
是 Vue Router提供的用于声明式导航的组件。它会被渲染成一个<a>
标签。:to
属性绑定了一个动态的路径,根据当前行项目的id
生成。
- 编程式导航示例 (注释部分):
router.push(
/project/detail/${id})
:直接跳转到指定路径。router.push({ name: 'projectDetail', params: { id: id.toString() } })
:通过路由名称和参数对象进行跳转,这种方式更健壮,即使路径改变了,只要名称不变,代码就无需修改。params
对象的键必须与动态段名称一致。
- 我们用 Element Plus 的
-
测试动态路由:
- 启动前端开发服务器
npm run dev
。 - 访问项目列表页 (
http://localhost:5173/project/list
)。 - 点击任意项目的“查看详情”按钮。
- 页面应该跳转到对应的详情页,URL 类似
/project/detail/1
,并且页面上会显示正确的项目 ID。 - 在详情页点击“返回项目列表”按钮,应该能回到列表页。
- 启动前端开发服务器
第二步:导航守卫 - 实现简单的登录权限控制
导航守卫允许你在路由跳转发生前、发生时或发生后执行一些逻辑。最常见的用途就是权限控制:如果用户未登录,访问需要授权的页面时,自动重定向到登录页。
Vue Router 提供了多种导航守卫:
- 全局前置守卫 (Global Before Guards):
router.beforeEach
,在任何路由跳转前都会被调用。 - 全局解析守卫 (Global Resolve Guards):
router.resolve
,在导航被确认之前,同时在所有组件内守卫和异步路由组件被解析之后,解析守卫就被正确调用。 - 全局后置钩子 (Global After Hooks):
router.afterEach
,在路由跳转完成后被调用,它不像守卫那样有next
函数,不能改变导航本身。 - 路由独享守卫 (Per-Route Guards): 直接在路由配置对象上定义
beforeEnter
。 - 组件内守卫 (In-Component Guards): 在组件选项中定义
beforeRouteEnter
,beforeRouteUpdate
,beforeRouteLeave
。
我们将使用全局前置守卫 router.beforeEach
来实现一个简单的登录检查。
-
模拟登录状态:
为了演示,我们暂时使用localStorage
来模拟用户的登录状态。在实际项目中,这通常由后端返回的 token 和状态管理库 (如 Pinia) 来管理。我们可以在
LoginView.vue
中添加一个模拟登录的逻辑:<!-- test-platform/frontend/src/views/LoginView.vue --> <template><div class="login-container"><h1>用户登录</h1><el-form ref="loginFormRef" :model="loginForm" label-width="80px" class="login-form"><el-form-item label="用户名" prop="username"><el-input v-model="loginForm.username" placeholder="任意用户名"></el-input></el-form-item><el-form-item label="密码" prop="password"><el-input v-model="loginForm.password" type="password" placeholder="任意密码"></el-input></el-form-item><el-form-item><el-button type="primary" @click="handleLogin">登录</el-button></el-form-item></el-form></div> </template><script setup lang="ts"> import { reactive, ref } from 'vue' import { useRouter, useRoute } from 'vue-router' // 确保导入了 useRoute import type { FormInstance } from 'element-plus' import { ElMessage } from 'element-plus' // <--- 在这里显式导入 ElMessageconst router = useRouter() const route = useRoute() // 获取当前路由信息,用于 redirect const loginFormRef = ref<FormInstance>() const loginForm = reactive({username: 'admin',password: 'password' })const handleLogin = async () => {// 实际项目中这里会调用后端 API 进行验证console.log('模拟登录:', loginForm.username, loginForm.password)// 模拟登录成功localStorage.setItem('user-token', 'fake-token-value') // 存储一个假的 tokenElMessage.success('登录成功!')const redirectPath = route.query.redirect as string | undefinedrouter.push(redirectPath || '/') // 跳转到仪表盘或之前尝试访问的页面 } </script><style scoped> .login-container {display: flex;flex-direction: column;align-items: center;justify-content: center;height: 100vh;background-color: #f0f2f5; } .login-form {width: 350px;padding: 30px;background-color: #fff;border-radius: 6px;box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1); }h1 {margin-bottom: 20px; } </style>
同时,在
/frontend/src/layout/index.vue
的退出登录处添加清除 token 的逻辑:<!-- test-platform/frontend/src/layout/index.vue --> <template><el-container class="app-layout"><el-header class="app-header"><div class="logo-title"><img src="@/assets/logo.svg" alt="Logo" class="logo" /><span class="title">测试平台</span></div><div class="user-info"><!-- 用户信息和退出登录等 --><el-dropdown @command="handleCommand"> <!-- 添加 @command 事件 --><span class="el-dropdown-link">欢迎, Admin <el-icon class="el-icon--right"><arrow-down /></el-icon></span><template #dropdown><el-dropdown-menu><el-dropdown-item>个人中心</el-dropdown-item><el-dropdown-item>修改密码</el-dropdown-item><el-dropdown-item divided>退出登录</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></el-header><el-container class="app-body"><el-aside width="200px" class="app-aside"><el-menudefault-active="1"class="el-menu-vertical-demo"router><el-menu-item index="/"><el-icon><HomeFilled /></el-icon><span>首页</span></el-menu-item><el-sub-menu index="/project"><template #title><el-icon><Folder /></el-icon><span>项目管理</span></template><el-menu-item index="/project/list">项目列表</el-menu-item><el-menu-item index="/project/create">新建项目</el-menu-item></el-sub-menu><el-menu-item index="/testcases"><el-icon><List /></el-icon><span>用例管理</span></el-menu-item><el-menu-item index="/reports"><el-icon><DataAnalysis /></el-icon><span>测试报告</span></el-menu-item></el-menu></el-aside><el-main class="app-main"><RouterView /> <!-- 子路由的出口 --></el-main></el-container></el-container> </template><script setup lang="ts"> import { RouterView } from 'vue-router' // 导入需要的 Element Plus 图标 import { ArrowDown, HomeFilled, Folder, List, DataAnalysis } from '@element-plus/icons-vue' import { ElMessage, ElMessageBox } from 'element-plus' // 导入 ElMessage import { useRouter } from 'vue-router' // 导入 useRouterconst router = useRouter() // 获取路由实例const handleCommand = (command: string | number | object) => {if (command === 'logout') {ElMessageBox.confirm('确定要退出登录吗?', '提示', {confirmButtonText: '确定',cancelButtonText: '取消',type: 'warning',}).then(() => {// 清除登录状态 (例如 token)localStorage.removeItem('user-token')ElMessage({type: 'success',message: '退出成功',})// 跳转到登录页router.push('/login')}).catch(() => {// 用户取消操作})} else if (command === 'profile') {// router.push('/profile') // 跳转到个人中心} else if (command === 'changePassword') {// router.push('/change-password') // 跳转到修改密码} } </script><style scoped lang="scss"> // 使用 SCSS 方便样式编写 .app-layout {height: 100vh; // 整个布局占满视口高度background-color: #f0f2f5; }.app-header {background-color: #fff;color: #333;display: flex;justify-content: space-between;align-items: center;padding: 0 20px;border-bottom: 1px solid #e6e6e6;.logo-title {display: flex;align-items: center;.logo {height: 40px; // 根据你的logo调整margin-right: 10px;}.title {font-size: 20px;font-weight: bold;}}.user-info {.el-dropdown-link {cursor: pointer;display: flex;align-items: center;}} }.app-body {height: calc(100vh - 60px); // 减去 Header 的高度 (假设 Header 高度为 60px) }.app-aside {background-color: #fff;border-right: 1px solid #e6e6e6;.el-menu {border-right: none; // 移除 el-menu 默认的右边框,因为 el-aside 已经有边框了height: 100%; // 菜单占满侧边栏高度} }.app-main {padding: 20px;background-color: #fff; // 主内容区背景margin: 15px; // 添加一些外边距,使其看起来不那么拥挤border-radius: 4px; // 轻微圆角box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); // 轻微阴影 } </style>
-
配置全局前置守卫
router.beforeEach
:
打开src/router/index.ts
,在createRouter
之后,export default router
之前添加守卫逻辑。// test-platform/frontend/src/router/index.ts // ... (imports 和 router 创建代码) ...const router = createRouter({ /* ... routes ... */ })// 全局前置守卫 router.beforeEach((to, from, next) => {const isAuthenticated = !!localStorage.getItem('user-token') // 检查是否存在 tokenconst pageTitle = to.meta.title ? `${to.meta.title} - 测试平台` : '测试平台'document.title = pageTitle;if (to.name !== 'login' && !isAuthenticated) {// 如果用户未认证,且目标路由不是登录页,则重定向到登录页console.log('用户未登录,跳转到登录页')next({ name: 'login', query: { redirect: to.fullPath } }) // 携带当前尝试访问的路径,以便登录后跳回} else if (to.name === 'login' && isAuthenticated) {// 如果用户已认证,且尝试访问登录页,则重定向到仪表盘console.log('用户已登录,访问登录页,跳转到首页')next({ name: 'dashboard' })}else {// 其他情况,正常放行next()} })export default router
代码解释:
router.beforeEach((to, from, next) => { ... })
: 定义了一个全局前置守卫。const isAuthenticated = !!localStorage.getItem('user-token')
: 简单地检查localStorage
中是否存在user-token
来判断用户是否已“登录”。!!
将值转换为布尔类型。document.title = ...
: 我们顺便在这里根据路由的meta.title
更新了页面标题。if (to.name !== 'login' && !isAuthenticated)
:- 如果目标路由的名称不是
login
(即用户想访问非登录页面),并且用户未认证 (!isAuthenticated
)。 - 则调用
next({ name: 'login', query: { redirect: to.fullPath } })
。name: 'login'
:跳转到名为login
的路由。query: { redirect: to.fullPath }
:在 URL 查询参数中添加一个redirect
参数,值为用户原本想访问的完整路径 (to.fullPath
)。这样,在登录成功后,我们可以读取这个redirect
参数,将用户导航回他们最初想去的页面。
- 如果目标路由的名称不是
else if (to.name === 'login' && isAuthenticated)
:- 如果用户已经认证了,但又尝试访问登录页。
- 则调用
next({ name: 'dashboard' })
,直接跳转到仪表盘页面,避免重复登录。
else { next() }
: 其他所有情况 (例如,用户已认证且访问非登录页,或者用户未认证但访问的是登录页),都调用next()
正常放行,允许导航继续。- 重要:
next()
函数在守卫中必须被调用一次,否则导航会挂起。
第三步:优化路由配置和使用路由元信息
路由元信息 (meta
) 允许你为路由附加任意数据,这些数据可以在导航守卫或组件内部访问。
我们已经在路由配置中使用了 meta: { title: '页面标题' }
。还可以添加其他信息,例如:
requiresAuth: true
: 标记该路由是否需要认证。roles: ['admin', 'editor']
: 标记访问该路由需要的用户角色 (用于更细粒度的权限控制)。icon: 'el-icon-xxx'
: 用于在动态生成菜单时指定图标。
示例:使用 requiresAuth
改进导航守卫
我们可以给需要登录才能访问的路由添加 meta: { requiresAuth: true }
标记。
// test-platform/frontend/src/router/index.ts
// ...children: [{path: 'dashboard',name: 'dashboard',component: HomeView,meta: { title: '仪表盘', requiresAuth: true } // 添加 requiresAuth},{path: '/project/list',name: 'projectList',component: () => import('../views/project/ProjectListView.vue'),meta: { title: '项目列表', requiresAuth: true }},// ... 其他需要认证的路由也添加 requiresAuth: true{path: '/project/detail/:id',name: 'projectDetail',component: () => import('../views/project/ProjectDetailView.vue'),meta: { title: '项目详情', requiresAuth: true },props: true},]
// ...{path: '/login',name: 'login',component: () => import('../views/LoginView.vue'),meta: { title: '登录' } // 登录页不需要 requiresAuth},
// ...
然后修改导航守卫的逻辑:
// test-platform/frontend/src/router/index.ts -> beforeEach
router.beforeEach((to, from, next) => {const isAuthenticated = !!localStorage.getItem('user-token')const pageTitle = to.meta.title ? `${to.meta.title} - 测试平台` : '测试平台'document.title = pageTitle;if (to.meta.requiresAuth && !isAuthenticated) { // 检查 meta.requiresAuthconsole.log('路由需要认证,但用户未登录,跳转到登录页')next({ name: 'login', query: { redirect: to.fullPath } })} else if (to.name === 'login' && isAuthenticated) {console.log('用户已登录,访问登录页,跳转到首页')next({ name: 'dashboard' })}else {next()}
})
这种方式使得权限逻辑更清晰:只有标记了 requiresAuth: true
的路由才会进行登录检查。
总结
在这篇文章中,我们深入学习了 Vue Router 的一些核心和高级功能:
- ✅ 动态路由匹配: 使用动态段 (如
:id
) 创建了项目详情页的路由,并通过useRoute().params
或props: true
在组件中获取了路由参数。 - ✅ 编程式导航: 使用
useRouter().push()
和<router-link>
实现了页面间的跳转。 - ✅ 导航守卫 (全局前置守卫
beforeEach
): 实现了一个基于localStorage
中 token 的简单登录权限控制,当用户未登录时访问受保护页面会自动重定向到登录页,并在登录成功后可以跳回原页面。 - ✅ 路由元信息 (
meta
): 学习了如何使用meta
字段为路由附加自定义信息 (如title
,requiresAuth
),并在导航守卫中利用这些信息来增强逻辑。 - ✅ 页面标题动态更新: 在导航守卫中根据路由元信息动态设置了浏览器标签页的标题。
通过这些知识,你现在可以构建出导航逻辑更复杂、用户体验更好的单页应用了。Vue Router 还有更多高级特性,如滚动行为、过渡效果、数据获取等,可以在官方文档中进一步探索。
在下一篇文章中,我们将引入状态管理库 Pinia,学习如何更有效地管理我们应用中的全局状态,例如用户信息、加载状态等,这对于构建大型复杂应用至关重要。