13.5 前端开发详解

配套代码:code/chapter13

13.5.1 前后端分离的 Web 架构

在开始前端开发之前,我们需要理解现代 Web 应用的架构模式。在早期的 Web 开发中,前端和后端是混在一起的,比如 PHP、JSP 这样的技术,HTML 模板和业务逻辑代码写在同一个文件里。这种方式在小项目中很方便,但在大型项目中会遇到很多问题:前端和后端开发者需要频繁协调,代码难以复用,测试困难。

现代 Web 应用普遍采用前后端分离的架构。后端只负责提供 API 接口,返回 JSON 格式的数据。前端是一个独立的应用,通过 HTTP 请求调用后端 API,获取数据后渲染页面。这种架构有几个明显的优势:前端和后端可以独立开发、独立部署、独立测试;前端可以是 Web 应用、移动应用或桌面应用,都使用同一套后端 API;前端可以使用现代的框架和工具链,提供更好的用户体验。

在我们的智能旅行助手项目中,后端是用 Python 和 FastAPI 实现的,提供了一个核心 API 接口POST /api/trip/plan,接收旅行需求,返回旅行计划。前端是用 Vue 3 和 TypeScript 实现的,是一个单页应用(SPA),用户在浏览器中填写表单,点击"开始规划"按钮,前端发送 HTTP 请求到后端,等待响应,然后渲染结果页面。整个过程中,页面不会刷新,用户体验很流畅。

前端技术栈的选择需要考虑几个因素:开发效率、性能、生态系统、学习曲线。如表 13.2 所示,该项目选择了以下技术栈:

表 13.2 前端技术栈

项目的目录结构是这样的:

frontend/
├── src/
│   ├── views/              # 页面组件
│   │   ├── Home.vue        # 首页(表单)
│   │   └── Result.vue      # 结果页
│   ├── services/           # API服务
│   │   └── api.ts
│   ├── types/              # 类型定义
│   │   └── index.ts
│   ├── router/             # 路由配置
│   │   └── index.ts
│   ├── App.vue
│   └── main.ts
├── package.json
├── vite.config.ts
└── tsconfig.json

其中views目录存放页面组件,services目录存放 API 调用逻辑,types目录存放 TypeScript 类型定义,router目录存放路由配置。

13.5.2 类型定义

在 13.2 节中,我们在后端使用 Pydantic 定义了数据模型,比如Location、Attraction、DayPlan、TripPlan等。在前端,我们需要定义对应的 TypeScript 类型。

让我们看看如何定义这些类型。首先是最基础的Location类型,表示经纬度坐标:

// frontend/src/types/index.ts
export interface Location {
  longitude: number
  latitude: number
}

这个类型定义和后端的 Pydantic 模型完全对应。注意 TypeScript 使用interface关键字定义类型,字段类型用冒号分隔,不需要默认值。

接下来是Attraction类型,表示景点信息:

export interface Attraction {
  name: string
  address: string
  location: Location
  visit_duration: number
  description: string
  category?: string
  rating?: number
  image_url?: string
  ticket_price?: number
}

注意这里使用了Location类型作为字段类型,这就是嵌套类型。问号?表示可选字段,对应后端 Pydantic 模型中的Optional。

类似地,我们定义Meal、Hotel、Budget、WeatherInfo等类型。最后是顶层的TripPlan类型:

export interface TripPlan {
  city: string
  start_date: string
  end_date: string
  days: DayPlan[]
  weather_info: WeatherInfo[]
  overall_suggestions: string
  budget?: Budget
}

还有请求类型TripPlanRequest,对应后端的请求模型:

export interface TripPlanRequest {
  city: string
  start_date: string
  end_date: string
  days: number
  preferences: string
  budget: string
  transportation: string
  accommodation: string
}

这些类型定义有什么用呢?首先,当我们调用 API 时,TypeScript 会检查我们传递的数据是否符合TripPlanRequest类型。如果我们不小心把days写成了字符串,TypeScript 会立即报错。其次,当我们接收 API 响应时,TypeScript 会检查响应数据是否符合TripPlan类型。如果后端返回的数据结构发生变化,前端会立即发现。最后,IDE 可以根据类型定义提供代码补全,我们输入tripPlan.时,IDE 会自动列出所有可用的字段。

13.5.3 API 服务封装

有了类型定义,我们就可以封装 API 调用了。我们创建一个api.ts文件,使用 Axios 来发送 HTTP 请求:

import axios from 'axios'
import type { TripPlanRequest,TripPlan } from '../types'

const api = axios.create({
  baseURL: 'http://localhost:8000/api',
  timeout: 120000, // 2分钟超时
  headers: {
    'Content-Type': 'application/json'
  }
})

这里我们创建了一个 Axios 实例,配置了基础 URL、超时时间和请求头。为什么超时时间设置为 2 分钟?因为生成旅行计划需要调用多个 Agent,每个 Agent 都要调用 LLM 和外部 API,整个过程可能需要 10-30 秒。如果超时时间太短,请求会被中断。

接下来我们添加拦截器。拦截器可以在请求发送前和响应接收后执行一些通用逻辑,比如日志记录、错误处理、认证等:

// 请求拦截器
api.interceptors.request.use(
  config => {
    console.log('发送请求:',config)
    return config
  },
  error => Promise.reject(error)
)

// 响应拦截器
api.interceptors.response.use(
  response => {
    console.log('收到响应:',response)
    return response
  },
  error => {
    console.error('请求失败:',error)
    return Promise.reject(error)
  }
)

最后我们定义 API 函数,这是前端调用后端的唯一入口:

// 生成旅行计划
export const generateTripPlan = async (request: TripPlanRequest): Promise<TripPlan> => {
  const response = await api.post<TripPlan>('/trip/plan',request)
  return response.data
}

注意这个函数的类型签名:参数是TripPlanRequest类型,返回值是Promise<TripPlan>类型。这意味着 TypeScript 会检查调用者传递的参数是否符合要求,也会检查返回值的使用是否正确。

13.5.4 Home 表单设计

Home 页面是用户的入口,包含一个表单,让用户填写旅行需求。我们使用 Vue 3 的 Composition API 来组织代码:

<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { generateTripPlan } from '@/services/api'
import type { TripPlanRequest } from '@/types'

const router = useRouter()
const loading = ref(false)
const loadingProgress = ref(0)
const loadingStatus = ref('')

const formData = ref<TripPlanRequest>({
  city: '',
  start_date: '',
  end_date: '',
  days: 3,
  preferences: '历史文化',
  budget: '中等',
  transportation: '公共交通',
  accommodation: '经济型酒店'
})
</script>

这里我们使用ref来创建响应式变量。formData是表单数据,类型是TripPlanRequest。loading表示是否正在加载,loadingProgress表示加载进度,loadingStatus表示加载状态文本。

表单提交的逻辑是这样的:

const handleSubmit = async () => {
  loading.value = true
  loadingProgress.value = 0
  
  // 模拟进度更新
  const progressInterval = setInterval(() => {
    if (loadingProgress.value < 90) {
      loadingProgress.value += 10
      if (loadingProgress.value <= 30) loadingStatus.value = '🔍 正在搜索景点...'
      else if (loadingProgress.value <= 50) loadingStatus.value = '🌤️ 正在查询天气...'
      else if (loadingProgress.value <= 70) loadingStatus.value = '🏨 正在推荐酒店...'
      else loadingStatus.value = '📋 正在生成行程计划...'
    }
  },500)
  
  try {
    const response = await generateTripPlan(formData.value)
    clearInterval(progressInterval)
    loadingProgress.value = 100
    router.push({ name: 'result',state: { tripPlan: response } })
  } catch (error) {
    clearInterval(progressInterval)
    message.error('生成计划失败,请重试')
  } finally {
    loading.value = false
  }
}

这段代码做了几件事。首先,设置loading为 true,显示加载状态。然后,启动一个定时器,每 500 毫秒更新一次进度条和状态文本。这是一个模拟的进度,因为我们无法准确知道后端的处理进度。但这样可以让用户知道系统正在工作,而不是卡住了。

接着,调用generateTripPlan函数发送 API 请求。这是一个异步操作,我们使用await等待响应。如果请求成功,清除定时器,设置进度为 100%,然后跳转到结果页面,并把旅行计划数据传递过去。如果请求失败,显示错误消息。最后,无论成功还是失败,都设置loading为 false,隐藏加载状态。

模板部分使用 Ant Design Vue 的组件:

<template>
  <div class="home-container">
    <div class="page-header">
      <h1 class="page-title">✈️ 智能旅行助手</h1>
      <p class="page-subtitle">基于AI的个性化旅行规划</p>
    </div>
    
    <a-card class="form-card">
      <a-form :model="formData" @finish="handleSubmit">
        <a-form-item label="目的地城市" name="city" :rules="[{ required: true }]">
          <a-input v-model:value="formData.city" placeholder="如:北京" />
        </a-form-item>
        
        <!-- 更多表单项... -->
        
        <a-form-item>
          <a-button type="primary" html-type="submit" size="large" :loading="loading">
            开始规划
          </a-button>
        </a-form-item>
        
        <!-- 加载进度条 -->
        <a-form-item v-if="loading">
          <a-progress :percent="loadingProgress" status="active" />
          <p>{{ loadingStatus }}</p>
        </a-form-item>
      </a-form>
    </a-card>
  </div>
</template>

注意v-model:value指令,它实现了双向数据绑定。当用户在输入框中输入内容时,formData.city会自动更新。当formData.city的值改变时,输入框的内容也会自动更新。

13.5.5 Result 页面展示

Result 页面是整个应用的核心,展示生成的旅行计划。这个页面包含几个部分:行程概览、预算明细、地图可视化、每日行程详情、天气信息。

首先是地图可视化。我们使用高德地图 JS API 在地图上标注景点位置:

import AMapLoader from '@amap/amap-jsapi-loader'

const initMap = async () => {
  const AMap = await AMapLoader.load({
    key: 'your_amap_web_key',
    version: '2.0'
  })
  
  map = new AMap.Map('amap-container',{
    zoom: 12,
    center: [116.397128,39.916527]
  })
  
  // 添加景点标记
  tripPlan.value.days.forEach((day) => {
    day.attractions.forEach((attraction,index) => {
      const marker = new AMap.Marker({
        position: [attraction.location.longitude,attraction.location.latitude],
        title: attraction.name,
        label: { content: `${index + 1}`,direction: 'top' }
      })
      map.add(marker)
    })
  })
}

这段代码首先加载高德地图 SDK,然后创建地图实例,最后遍历所有景点,为每个景点创建一个标记(Marker)。标记的位置是景点的经纬度坐标,这些坐标是从后端的Attraction对象中获取的。

导出功能使用html2canvas和jsPDF库。html2canvas可以把 DOM 元素转换成 Canvas,然后我们可以把 Canvas 导出为图片或 PDF:

import html2canvas from 'html2canvas'
import jsPDF from 'jspdf'

// 导出为图片
const exportAsImage = async () => {
  const element = document.getElementById('trip-plan-content')
  const canvas = await html2canvas(element,{ scale: 2 })
  const link = document.createElement('a')
  link.download = `${tripPlan.value.city}旅行计划.png`
  link.href = canvas.toDataURL()
  link.click()
}

// 导出为PDF
const exportAsPDF = async () => {
  const element = document.getElementById('trip-plan-content')
  const canvas = await html2canvas(element,{ scale: 2 })
  const imgData = canvas.toDataURL('image/png')
  const pdf = new jsPDF('p','mm','a4')
  const imgWidth = 210
  const imgHeight = (canvas.height * imgWidth) / canvas.width
  pdf.addImage(imgData,'PNG',0,0,imgWidth,imgHeight)
  pdf.save(`${tripPlan.value.city}旅行计划.pdf`)
}

通过这些前端技术,我们实现了一个完整的 Web 应用。用户可以在浏览器中填写表单,提交请求,等待 AI 生成旅行计划,然后查看详细的行程安排,在地图上看到景点位置,还可以导出为图片或 PDF。整个过程流畅自然,这就是现代 Web 应用的魅力。