七、【前端路由篇】掌控全局:Vue Router 实现页面导航、动态路由与权限控制

发布于:2025-05-27 ⋅ 阅读:(17) ⋅ 点赞:(0)

前言

在单页应用 (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) 的页面。

  1. 创建项目详情页组件:
    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(() => {
      // 从路由参数中获取 projectId
      if (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()
  2. 在路由配置中添加动态路由:
    打开 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
  3. 在项目列表页添加跳转链接 (演示):
    为了能方便地跳转到详情页,我们修改 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 对象的键必须与动态段名称一致。
  4. 测试动态路由:

    • 启动前端开发服务器 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 来实现一个简单的登录检查。

  1. 模拟登录状态:
    为了演示,我们暂时使用 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' // <--- 在这里显式导入 ElMessage
    
    const 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') // 存储一个假的 token
    
      ElMessage.success('登录成功!')
    
      const redirectPath = route.query.redirect as string | undefined
      router.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-menu
              default-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' // 导入 useRouter
    
    const 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>
    
    
  2. 配置全局前置守卫 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') // 检查是否存在 token
      const 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.requiresAuth
    console.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().paramsprops: true 在组件中获取了路由参数。
  • 编程式导航: 使用 useRouter().push()<router-link> 实现了页面间的跳转。
  • 导航守卫 (全局前置守卫 beforeEach): 实现了一个基于 localStorage 中 token 的简单登录权限控制,当用户未登录时访问受保护页面会自动重定向到登录页,并在登录成功后可以跳回原页面。
  • 路由元信息 (meta): 学习了如何使用 meta 字段为路由附加自定义信息 (如 title, requiresAuth),并在导航守卫中利用这些信息来增强逻辑。
  • 页面标题动态更新: 在导航守卫中根据路由元信息动态设置了浏览器标签页的标题。

通过这些知识,你现在可以构建出导航逻辑更复杂、用户体验更好的单页应用了。Vue Router 还有更多高级特性,如滚动行为、过渡效果、数据获取等,可以在官方文档中进一步探索。

在下一篇文章中,我们将引入状态管理库 Pinia,学习如何更有效地管理我们应用中的全局状态,例如用户信息、加载状态等,这对于构建大型复杂应用至关重要。


网站公告

今日签到

点亮在社区的每一天
去签到