healthapp
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

13 KiB

02-路由和布局设计

目标

配置 Vue Router 路由系统,实现页面布局和导航守卫。


前置要求

  • 项目结构已初始化
  • Vue Router 已安装

实施步骤

步骤 1:创建用户 Store

创建 src/stores/user.ts

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useUserStore = defineStore('user', () => {
  const token = ref(localStorage.getItem('token') || '')
  const userInfo = ref<any>(null)

  const isLoggedIn = computed(() => !!token.value)
  const surveyCompleted = computed(() => userInfo.value?.survey_completed || false)

  function setToken(newToken: string) {
    token.value = newToken
    localStorage.setItem('token', newToken)
  }

  function setUserInfo(info: any) {
    userInfo.value = info
  }

  function logout() {
    token.value = ''
    userInfo.value = null
    localStorage.removeItem('token')
  }

  return {
    token,
    userInfo,
    isLoggedIn,
    surveyCompleted,
    setToken,
    setUserInfo,
    logout,
  }
})

步骤 2:创建路由配置

创建 src/router/index.ts

import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { useUserStore } from '@/stores/user'

// 布局组件
import MainLayout from '@/components/common/MainLayout.vue'
import AuthLayout from '@/components/common/AuthLayout.vue'

const routes: RouteRecordRaw[] = [
  // 认证相关页面
  {
    path: '/auth',
    component: AuthLayout,
    children: [
      {
        path: 'login',
        name: 'Login',
        component: () => import('@/views/auth/Login.vue'),
        meta: { title: '登录' }
      },
      {
        path: 'register',
        name: 'Register',
        component: () => import('@/views/auth/Register.vue'),
        meta: { title: '注册' }
      },
    ],
  },
  // 健康调查(新用户必经)
  {
    path: '/survey',
    component: MainLayout,
    meta: { requiresAuth: true },
    children: [
      {
        path: '',
        name: 'Survey',
        component: () => import('@/views/survey/Index.vue'),
        meta: { title: '健康调查' }
      },
    ],
  },
  // 体质测评
  {
    path: '/constitution',
    component: MainLayout,
    meta: { requiresAuth: true, requiresSurvey: true },
    children: [
      {
        path: '',
        name: 'Constitution',
        component: () => import('@/views/constitution/Index.vue'),
        meta: { title: '体质测评' }
      },
      {
        path: 'result',
        name: 'ConstitutionResult',
        component: () => import('@/views/constitution/Result.vue'),
        meta: { title: '体质结果' }
      },
    ],
  },
  // 主要功能页面
  {
    path: '/',
    component: MainLayout,
    meta: { requiresAuth: true, requiresSurvey: true },
    children: [
      {
        path: '',
        redirect: '/chat'
      },
      {
        path: 'chat',
        name: 'Chat',
        component: () => import('@/views/chat/Index.vue'),
        meta: { title: 'AI问诊' }
      },
      {
        path: 'chat/:id',
        name: 'ChatDetail',
        component: () => import('@/views/chat/Detail.vue'),
        meta: { title: '对话详情' }
      },
      {
        path: 'profile',
        name: 'Profile',
        component: () => import('@/views/profile/Index.vue'),
        meta: { title: '个人中心' }
      },
      {
        path: 'health-record',
        name: 'HealthRecord',
        component: () => import('@/views/profile/HealthRecord.vue'),
        meta: { title: '健康档案' }
      },
    ],
  },
  // 404
  {
    path: '/:pathMatch(.*)*',
    redirect: '/'
  },
]

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

// 路由守卫
router.beforeEach((to, from, next) => {
  const userStore = useUserStore()

  // 设置页面标题
  document.title = (to.meta.title as string) + ' - 健康AI助手' || '健康AI助手'

  // 检查是否需要登录
  if (to.meta.requiresAuth && !userStore.isLoggedIn) {
    next({ path: '/auth/login', query: { redirect: to.fullPath } })
    return
  }

  // 检查是否需要完成调查
  if (to.meta.requiresSurvey && !userStore.surveyCompleted) {
    // 如果已登录但未完成调查,跳转到调查页
    if (to.path !== '/survey') {
      next('/survey')
      return
    }
  }

  // 已登录用户访问登录页,跳转到首页
  if ((to.path === '/auth/login' || to.path === '/auth/register') && userStore.isLoggedIn) {
    next('/')
    return
  }

  next()
})

export default router

步骤 3:创建认证布局组件

创建 src/components/common/AuthLayout.vue

<template>
  <div class="auth-layout">
    <div class="auth-container">
      <div class="auth-header">
        <img src="@/assets/logo.svg" alt="Logo" class="logo" />
        <h1>健康AI助手</h1>
        <p>您的智能健康管家</p>
      </div>
      <div class="auth-content">
        <router-view />
      </div>
    </div>
  </div>
</template>

<style scoped>
.auth-layout {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}

.auth-container {
  width: 400px;
  padding: 40px;
  background: #fff;
  border-radius: 16px;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}

.auth-header {
  text-align: center;
  margin-bottom: 30px;
}

.logo {
  width: 64px;
  height: 64px;
  margin-bottom: 16px;
}

.auth-header h1 {
  font-size: 24px;
  color: #333;
  margin-bottom: 8px;
}

.auth-header p {
  font-size: 14px;
  color: #999;
}
</style>

步骤 4:创建主布局组件

创建 src/components/common/MainLayout.vue

<template>
  <el-container class="main-layout">
    <!-- 侧边栏 -->
    <el-aside :width="isCollapsed ? '64px' : '220px'" class="sidebar">
      <div class="logo-container">
        <img src="@/assets/logo.svg" alt="Logo" class="logo" />
        <span v-if="!isCollapsed" class="logo-text">健康AI助手</span>
      </div>

      <el-menu
        :default-active="activeMenu"
        :collapse="isCollapsed"
        router
        class="sidebar-menu"
      >
        <el-menu-item index="/chat">
          <el-icon><ChatDotRound /></el-icon>
          <span>AI问诊</span>
        </el-menu-item>
        <el-menu-item index="/constitution">
          <el-icon><User /></el-icon>
          <span>体质测评</span>
        </el-menu-item>
        <el-menu-item index="/health-record">
          <el-icon><Document /></el-icon>
          <span>健康档案</span>
        </el-menu-item>
        <el-menu-item index="/profile">
          <el-icon><Setting /></el-icon>
          <span>个人中心</span>
        </el-menu-item>
      </el-menu>

      <div class="sidebar-footer">
        <el-button
          :icon="isCollapsed ? 'Expand' : 'Fold'"
          text
          @click="toggleCollapse"
        />
      </div>
    </el-aside>

    <!-- 主内容区 -->
    <el-container>
      <!-- 顶部导航 -->
      <el-header class="header">
        <div class="header-left">
          <h2>{{ currentTitle }}</h2>
        </div>
        <div class="header-right">
          <el-dropdown @command="handleCommand">
            <div class="user-info">
              <el-avatar :size="32" :src="userStore.userInfo?.avatar">
                {{ userStore.userInfo?.nickname?.charAt(0) || 'U' }}
              </el-avatar>
              <span class="username">{{ userStore.userInfo?.nickname || '用户' }}</span>
              <el-icon><ArrowDown /></el-icon>
            </div>
            <template #dropdown>
              <el-dropdown-menu>
                <el-dropdown-item command="profile">个人中心</el-dropdown-item>
                <el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
              </el-dropdown-menu>
            </template>
          </el-dropdown>
        </div>
      </el-header>

      <!-- 页面内容 -->
      <el-main class="main-content">
        <router-view />
      </el-main>
    </el-container>
  </el-container>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessageBox } from 'element-plus'
import {
  ChatDotRound,
  User,
  Document,
  Setting,
  ArrowDown,
} from '@element-plus/icons-vue'

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

const isCollapsed = ref(false)

const activeMenu = computed(() => route.path)
const currentTitle = computed(() => route.meta.title as string || '健康AI助手')

const toggleCollapse = () => {
  isCollapsed.value = !isCollapsed.value
}

const handleCommand = (command: string) => {
  if (command === 'logout') {
    ElMessageBox.confirm('确定要退出登录吗?', '提示', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning',
    }).then(() => {
      userStore.logout()
      router.push('/auth/login')
    })
  } else if (command === 'profile') {
    router.push('/profile')
  }
}
</script>

<style scoped>
.main-layout {
  height: 100vh;
}

.sidebar {
  background: #304156;
  display: flex;
  flex-direction: column;
  transition: width 0.3s;
}

.logo-container {
  height: 60px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 16px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}

.logo {
  width: 32px;
  height: 32px;
}

.logo-text {
  margin-left: 12px;
  font-size: 16px;
  font-weight: bold;
  color: #fff;
  white-space: nowrap;
}

.sidebar-menu {
  flex: 1;
  border-right: none;
  background: transparent;
}

.sidebar-menu:not(.el-menu--collapse) {
  width: 100%;
}

:deep(.el-menu-item) {
  color: rgba(255, 255, 255, 0.7);
}

:deep(.el-menu-item:hover),
:deep(.el-menu-item.is-active) {
  color: #fff;
  background-color: rgba(255, 255, 255, 0.1);
}

.sidebar-footer {
  padding: 16px;
  text-align: center;
  border-top: 1px solid rgba(255, 255, 255, 0.1);
}

.header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 20px;
  background: #fff;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}

.header-left h2 {
  font-size: 18px;
  font-weight: 500;
  color: #333;
}

.user-info {
  display: flex;
  align-items: center;
  cursor: pointer;
}

.username {
  margin: 0 8px;
  font-size: 14px;
  color: #666;
}

.main-content {
  background: #f5f7fa;
  padding: 20px;
  overflow-y: auto;
}
</style>

步骤 5:创建 Logo 占位

创建 src/assets/logo.svg

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
  <circle cx="32" cy="32" r="30" fill="#667eea"/>
  <path d="M32 16c-8.8 0-16 7.2-16 16s7.2 16 16 16 16-7.2 16-16-7.2-16-16-16zm0 28c-6.6 0-12-5.4-12-12s5.4-12 12-12 12 5.4 12 12-5.4 12-12 12z" fill="#fff"/>
  <path d="M32 24v16M24 32h16" stroke="#fff" stroke-width="3" stroke-linecap="round"/>
</svg>

步骤 6:更新 App.vue

更新 src/App.vue

<template>
  <router-view />
</template>

<script setup lang="ts">
import { onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { getUserProfile } from '@/api/user'

const userStore = useUserStore()

onMounted(async () => {
  // 如果已登录,获取用户信息
  if (userStore.isLoggedIn) {
    try {
      const userInfo = await getUserProfile()
      userStore.setUserInfo(userInfo)
    } catch (error) {
      // Token 可能已失效
      userStore.logout()
    }
  }
})
</script>

步骤 7:创建用户 API

创建 src/api/user.ts

import request from './request'

export const getUserProfile = () => {
  return request.get('/user/profile')
}

export const updateUserProfile = (data: any) => {
  return request.put('/user/profile', data)
}

export const getHealthProfile = () => {
  return request.get('/user/health-profile')
}

需要创建的文件清单

文件路径 说明
src/stores/user.ts 用户状态管理
src/router/index.ts 路由配置
src/components/common/AuthLayout.vue 认证布局
src/components/common/MainLayout.vue 主布局
src/assets/logo.svg Logo 图标
src/App.vue 根组件(更新)
src/api/user.ts 用户 API

路由结构说明

路径 组件 说明
/auth/login Login.vue 登录页
/auth/register Register.vue 注册页
/survey Survey/Index.vue 健康调查
/constitution Constitution/Index.vue 体质测评
/constitution/result Constitution/Result.vue 体质结果
/chat Chat/Index.vue AI问诊列表
/chat/:id Chat/Detail.vue 对话详情
/profile Profile/Index.vue 个人中心
/health-record Profile/HealthRecord.vue 健康档案

验收标准

  • 路由配置正确加载
  • 未登录自动跳转登录页
  • 登录布局显示正常
  • 主布局侧边栏显示正常
  • 路由守卫逻辑正确

预计耗时

25-30 分钟


下一步

完成后进入 03-Web前端开发/03-用户认证页面.md