AI智能旅游规划系统 - 前端技术笔记

一、项目架构概述

本项目基于 Vue 3 + Vite 构建移动端 H5 应用,使用 Vant 4 组件库,采用 Vue 3 <script setup> 组合式 API 开发。

核心技术栈:

  • Vue 3.5(组合式 API + <script setup>
  • Vue Router 4.3(路由管理)
  • Vant 4.10(移动端 UI 组件库)
  • Vite 8(构建工具)
  • Axios(HTTP 请求)
  • 原生 fetch API(SSE 流式请求)

依赖配置(package.json):

javascript 复制代码
// 运行时依赖
"vue": "^3.5.39",              // 核心框架
"vue-router": "^4.3.0",         // 路由
"vant": "^4.10.0",              // UI 组件库
"axios": "^1.18.1"              // HTTP 客户端

// 开发依赖
"vite": "^8.1.1",               // 构建工具
"@vitejs/plugin-vue": "^6.0.7"  // Vue 支持插件

二、Vite 构建配置

2.1 vite.config.js

配置开发服务器代理,将 /api 请求转发到后端,解决跨域问题。

javascript 复制代码
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    proxy: {
      '/api': {                              // 代理路径前缀
        target: 'http://127.0.0.1:3200',    // 后端地址
        configure: (proxy) => {
          // 处理 SSE 流式响应的响应头
          proxy.on('proxyRes', (proxyRes) => {
            if (proxyRes.headers['content-type']?.includes('text/event-stream')) {
              proxyRes.headers['cache-control'] = 'no-cache'
              proxyRes.headers['connection'] = 'keep-alive'
            }
          })
        }
      }
    }
  }
})
// 备注:SSE 响应需要设置 cache-control: no-cache 和 connection: keep-alive,
// 否则浏览器会缓存或断开连接导致流式响应失败

三、应用入口与组件注册

3.1 main.js

创建 Vue 应用实例,全局注册路由和 Vant 组件。

javascript 复制代码
import { createApp } from 'vue'
import 'vant/lib/index.css'      // Vant 样式
import './style.css'             // 全局样式
import App from './App.vue'
import router from './router'

// 按需引入 Vant 组件
import { Button, Card, Tabbar, TabbarItem,
    Icon, NavBar, NoticeBar, Field, Popup,
    Picker, Grid, GridItem, Loading, Empty,
    Collapse, CollapseItem, Tag, Cell, CellGroup } from 'vant'

const app = createApp(App)

app.use(router)                    // 注册路由
// 注册所有用到的组件
app.use(Tabbar).use(TabbarItem)
app.use(NavBar).use(Icon)
app.use(NoticeBar).use(Field)
app.use(Popup).use(Picker)
app.use(Collapse).use(CollapseItem)
app.use(Grid).use(GridItem)
app.use(Loading).use(Empty).use(Tag)
app.use(Button).use(Card)
app.use(Cell).use(CellGroup)

app.mount('#app')
// 备注:Vant 组件需通过 app.use() 注册才能使用,
// 支持链式调用如 app.use(A).use(B)

3.2 全局样式 style.css

定义 CSS 变量和全局样式,统一设计规范。

css 复制代码
:root {
  /* 主题色 */
  --primary: #1989fa;
  --success: #52c41a;
  --warning: #faad14;
  --danger: #ff4d4f;

  /* 文字色 */
  --text: #323233;
  --text-secondary: #646566;
  --text-placeholder: #969799;

  /* 背景色 */
  --bg: #f5f5f5;
  --bg-white: #fff;

  /* 边框 */
  --border: #ebedf0;

  /* 阴影 */
  --shadow: 0 2px 12px rgba(0, 0, 0, 0.08);

  font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC',
    'Microsoft YaHei', sans-serif;
  font-size: 14px;
  line-height: 1.5;
  color: var(--text);
  background-color: var(--bg);
}

* { box-sizing: border-box; }

#app {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}
// 备注:使用 CSS 变量统一主题色,方便后续换肤

四、路由配置

4.1 router/index.js

使用 Vue Router 4 配置路由表,管理页面跳转。

javascript 复制代码
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import Chat from '../views/Chat.vue'
import Profile from '../views/Profile.vue'
import Detail from '../views/Detail.vue'

const routes = [
  { path: '/', name: 'Home', component: Home },       // 首页
  { path: '/chat', name: 'Chat', component: Chat },   // AI 对话页
  { path: '/profile', name: 'Profile', component: Profile }, // 个人中心
  { path: '/detail', name: 'Detail', component: Detail }    // 行程详情
]

const router = createRouter({
  history: createWebHistory(),  // HTML5 History 模式
  routes
})

export default router
// 备注:createWebHistory 使用 HTML5 History API,
// URL 更美观(无 # 号),需要后端配合处理 404

五、根组件 App.vue

5.1 底部导航栏

使用 Vant 的 Tabbar 组件实现底部导航,配合 Router 的 to 属性实现路由跳转。

vue 复制代码
<script setup>
import { ref } from 'vue'
const active = ref(0)  // 当前激活的 tab 索引
</script>

<template>
  <router-view />  <!-- 路由出口 -->
  <van-tabbar v-model="active" active-color="#1989fa">
    <van-tabbar-item icon="home-o" to="/">首页</van-tabbar-item>
    <van-tabbar-item icon="chat-o" to="/chat">对话</van-tabbar-item>
    <van-tabbar-item icon="friends-o" to="/profile">我的</van-tabbar-item>
  </van-tabbar>
</template>
// 备注:van-tabbar-item 的 to 属性等同于 router.push(),
// 点击自动跳转并高亮对应 tab

六、HTTP 请求封装

6.1 utils/request.js

二次封装 Axios 和原生 fetch,分别处理普通请求和 SSE 流式请求。

javascript 复制代码
import axios from 'axios'

// ===== 普通请求封装 =====
const instance = axios.create({
  baseURL: '/',                   // 使用 Vite 代理的根路径
  timeout: 300000,               // 5 分钟超时(AI 响应可能较慢)
  headers: { 'Content-Type': 'application/json' }
})

// 请求拦截器
instance.interceptors.request.use(config => config)

// 响应拦截器:直接返回 data 层
instance.interceptors.response.use(
  response => response.data,      // 自动解包 response.data
  error => Promise.reject(error)
)

// 导出常用方法
export const post = (url, data = {}) => instance.post(url, data)
export const get = (url, params = {}) => instance.get(url, { params })

// ===== SSE 流式请求封装 =====
const BACKEND_BASE_URL = 'http://127.0.0.1:3200'  // 直接访问后端(绕过代理)

export async function fetchStream(url, data, onChunk, onComplete, onError) {
  try {
    const response = await fetch(`${BACKEND_BASE_URL}/api/travel/${url}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    })

    if (!response.ok) throw new Error(`HTTP error: ${response.status}`)

    const reader = response.body.getReader()         // 获取 ReadableStream
    const decoder = new TextDecoder()                // 二进制转字符串

    while (true) {
      const { done, value } = await reader.read()    // 读取数据块
      if (done) break

      const chunk = decoder.decode(value, { stream: true })
      const lines = chunk.split('\n').filter(l => l.trim())

      for (const line of lines) {
        if (line.startsWith('data:')) {
          const jsonStr = line.substring(5)
          if (jsonStr && jsonStr !== '[DONE]') {
            try {
              const jsonData = JSON.parse(jsonStr)
              if (jsonData.type === 'chunk') {
                onChunk(jsonData.content)            // 数据分片回调
              } else if (jsonData.done === true) {
                onComplete()                         // 完成回调
                return
              } else if (jsonData.error) {
                onError(jsonData.error)              // 错误回调
                return
              }
            } catch (err) {
              // JSON 解析失败忽略,继续处理
            }
          }
        }
      }
    }
    onComplete()  // 兜底:如果没收到 done 事件
  } catch (error) {
    onError(error.message || '请求失败')
  }
}
// 备注:SSE 解析流程:
// 1. fetch 返回 Response.body(ReadableStream)
// 2. getReader() 获取读取器
// 3. read() 循环读取数据块
// 4. 按 \n 分割行,过滤空行
// 5. 查找 data: 前缀,解析 JSON
// 6. 根据 type 字段分发事件

七、首页 Home.vue

7.1 页面结构

包含通知条、规划表单、快捷入口、热门目的地四部分。

vue 复制代码
<script setup>
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'

const router = useRouter()

// 表单数据
const formData = reactive({
  city: '',
  budget: null,
  days: null
})

// 城市选择器
const showCityPicker = ref(false)
const allCities = ['北京', '上海', '广州', '深圳', '成都', '杭州', ...]
const columns = allCities.map(city => ({ text: city, value: city }))
const hotCities = ['北京', '上海', '广州', '深圳', '成都', '杭州', '西安', '重庆']

// 确认选择城市
const onConfirm = ({ selectedOptions }) => {
  formData.city = selectedOptions[0].value
  showCityPicker.value = false
}

// 表单提交(校验 + 跳转)
const handleSubmit = () => {
  if (!formData.city) {
    showToast('请选择目的地')              // Vant 轻提示
    return
  }
  if (!formData.budget || formData.budget < 100) {
    showToast('预算不能低于100元')
    return
  }
  if (!formData.days || formData.days < 1 || formData.days > 30) {
    showToast('天数必须在1到30天之间')
    return
  }
  // 路由跳转,通过 query 传递参数
  router.push({
    path: '/detail',
    query: {
      city: formData.city,
      budget: formData.budget,
      days: formData.days
    }
  })
}

// 快捷入口
const goPage = (path) => router.push(path)
const selectedCity = (city) => { formData.city = city }
</script>

模板关键部分:

vue 复制代码
<template>
  <div class="page-container">
    <van-nav-bar title="智能旅游助手" />

    <!-- 通知条 -->
    <van-notice-bar left-icon="volume-o">
      基于 AI 的智能景点介绍与行程规划系统
    </van-notice-bar>

    <!-- 规划表单卡片 -->
    <div class="card">
      <div class="section-title">规划你的旅程</div>
      <van-field @click="showCityPicker = true" v-model="formData.city"
        label="目的地" placeholder="请选择城市" right-icon="arrow" />
      <van-field v-model="formData.budget" label="预算(元)" placeholder="请输入预算金额" />
      <van-field v-model="formData.days" label="天数" placeholder="请输入天数" />
      <van-button type="primary" size="large" round @click="handleSubmit">
        开始规划
      </van-button>
    </div>

    <!-- 热门目的地网格 -->
    <van-grid :gutter="12" :column-num="4">
      <van-grid-item v-for="city in hotCities" :key="city" @click="selectedCity(city)">
        <div class="city-tag" :class="{ active: formData.city === city }">{{ city }}</div>
      </van-grid-item>
    </van-grid>

    <!-- 城市选择弹出层 -->
    <van-popup v-model:show="showCityPicker" position="bottom" round>
      <van-picker title="选择目的地" :columns="columns"
        @confirm="onConfirm" @cancel="showCityPicker = false" />
    </van-popup>
  </div>
</template>
// 备注:使用 van-popup + van-picker 实现底部弹出选择器,
// van-grid 实现热门城市标签网格布局

八、行程详情页 Detail.vue

8.1 数据加载与展示

通过路由参数获取查询条件,调用后端接口获取行程数据。

vue 复制代码
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { post } from '../utils/request'
import SpotItem from '../components/SpotItem.vue'

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

const loading = ref(false)     // 加载状态
const error = ref(false)       // 错误状态
const tripData = ref(null)     // 行程数据
const activeNames = ref([])    // 当前展开的折叠面板

// 返回上一页
const goBack = () => router.back()

// 获取推荐数据
const fetchRecommend = async () => {
  loading.value = true
  error.value = false
  try {
    const params = {
      city: route.query.city || '北京',    // 从路由参数获取
      budget: parseInt(route.query.budget) || 5000,
      days: parseInt(route.query.days) || 2
    }
    const response = await post('/api/travel/recommend', params)
    tripData.value = response.data       // 后端返回的 data 字段
  } catch (err) {
    error.value = true
    console.error('获取推荐失败:', err)
  } finally {
    loading.value = false
  }
}

onMounted(() => { fetchRecommend() })
// 备注:onMounted 生命周期自动发起请求,
// 支持 loading/error/success 三态切换
</script>

模板关键部分:

vue 复制代码
<template>
  <div class="page-container">
    <van-nav-bar title="推荐结果" left-arrow @click="goBack" />
    <div class="page-content">

      <!-- 加载状态 -->
      <div v-if="loading" class="loading-container">
        <van-loading size="48px" type="spinner" />
      </div>

      <!-- 错误状态 -->
      <div v-else-if="error">
        <van-empty description="请求失败,请重试">
          <van-button @click="fetchRecommend" type="primary">重新加载</van-button>
        </van-empty>
      </div>

      <!-- 成功状态:展示行程 -->
      <div v-else-if="tripData?.success">
        <div class="card">
          <h2>{{ tripData.city }} · {{ tripData.days }}天的行程</h2>
        </div>

        <!-- 折叠面板展示每日行程 -->
        <van-collapse v-model="activeNames">
          <van-collapse-item
            v-for="day in tripData.dailyItineraries"
            :key="day.day"
            :title="'第' + day.day + '天'"
            :name="day.day"
          >
            <SpotItem :data="day.morning" />
            <SpotItem :data="day.afternoon" />
            <SpotItem :data="day.evening" />
          </van-collapse-item>
        </van-collapse>
      </div>

    </div>
  </div>
</template>
// 备注:van-collapse 折叠面板嵌套 SpotItem 子组件,
// 分别展示上午/下午/晚上的行程详情

九、AI 对话页 Chat.vue

9.1 SSE 流式对话实现

核心功能是调用后端 SSE 接口,实时展示 AI 回复(打字机效果)。

vue 复制代码
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { fetchStream } from '../utils/request'
import ChatBubble from '../components/ChatBubble.vue'

const router = useRouter()
const chatContainer = ref(null)  // 聊天容器 DOM

// 状态管理
const messages = ref([])       // 消息列表
const inputMessage = ref('')   // 输入框内容
const isStreaming = ref(false) // 是否正在流式响应
const quickQuestions = ref([   // 常见问题
  '北京有哪些必去的景点?',
  '上海美食推荐',
  '成都三日游攻略',
  '如何选择旅行保险?'
])

// 滚动到底部
const scrollToBottom = () => {
  if (chatContainer.value) {
    chatContainer.value.scrollTop = chatContainer.value.scrollHeight
  }
}

// 发送消息
const sendMessage = () => {
  const msg = inputMessage.value.trim()
  if (!msg) { showToast('请输入问题'); return }
  if (isStreaming.value) return       // 防止重复发送

  messages.value.push({                // 添加用户消息
    id: Date.now() + 1,
    role: 'user',
    content: msg,
    timestamp: new Date().toISOString()
  })
  inputMessage.value = ''
  fetchAIResponse(msg)
}

// 调用 SSE 接口获取 AI 回复
const fetchAIResponse = (userMsg) => {
  isStreaming.value = true

  // 先添加一条空的 AI 消息占位
  messages.value.push({
    id: Date.now() + 2,
    role: 'ai',
    content: '',
    timestamp: new Date().toISOString()
  })

  let fullResponse = ''

  fetchStream('chat', { message: userMsg },
    // 分片回调:实时追加内容
    (chunk) => {
      fullResponse += chunk
      const lastMsg = messages.value[messages.value.length - 1]
      if (lastMsg?.role === 'ai') {
        lastMsg.content = fullResponse  // 直接修改响应式对象触发更新
      }
      scrollToBottom()
    },
    // 完成回调
    () => {
      isStreaming.value = false
      scrollToBottom()
    },
    // 错误回调
    (errMsg) => {
      const lastMsg = messages.value[messages.value.length - 1]
      if (lastMsg?.role === 'ai') {
        lastMsg.content = `抱歉,AI发生错误:${errMsg}`
      }
      isStreaming.value = false
      showToast('AI回复错误,请稍后重试')
      scrollToBottom()
    }
  )
}
// 备注:流式对话核心思路:
// 1. 先创建一条空 AI 消息
// 2. fetchStream 逐分片回调
// 3. 每次回调直接修改最后一条消息的 content
// 4. Vue 响应式自动触发视图更新,实现打字机效果
</script>

模板关键部分:

vue 复制代码
<template>
  <div class="page-container chat-page">
    <!-- 顶部导航 -->
    <van-nav-bar title="AI旅游助手" left-text="返回" left-arrow fixed @click-left="router.back()" />

    <!-- 聊天容器 -->
    <div class="chat-container" ref="chatContainer">
      <!-- 空状态:显示常见问题 -->
      <div v-if="messages.length === 0" class="chat-empty">
        <van-empty description="开始和AI旅游助手对话" />
        <div class="quick-questions">
          <div class="quick-title">常见问题</div>
          <div class="quick-tags">
            <van-tag v-for="question in quickQuestions" :key="question"
              class="quick-tag" mark size="large">
              {{ question }}
            </van-tag>
          </div>
        </div>
      </div>

      <!-- 有消息时:显示消息列表 -->
      <div v-else class="message-list">
        <ChatBubble v-for="message in messages" :key="message.id" :message="message" />
        <div v-if="isStreaming" class="streaming-indicator">
          <van-loading type="spinner" size="20px" />
          <span>AI正在思考中...</span>
        </div>
      </div>
    </div>

    <!-- 输入框 -->
    <div class="chat-input-area">
      <van-field v-model="inputMessage" placeholder="输入您的问题..." :disabled="isStreaming">
        <template #button>
          <van-button :disabled="!inputMessage.trim()" @click="sendMessage"
            size="small" type="primary">发送</van-button>
        </template>
      </van-field>
    </div>
  </div>
</template>

十、子组件封装

10.1 ChatBubble.vue(消息气泡)

区分用户消息和 AI 消息的气泡样式,支持时间显示。

vue 复制代码
<script setup>
import { computed } from 'vue'

const props = defineProps({
  message: { type: Object, required: true }
})

const showTime = computed(() => props.message.timestamp && props.message.content)

const formatTime = computed(() => {
  if (!props.message.timestamp) return ''
  const date = new Date(props.message.timestamp)
  return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
})
</script>

<template>
  <div class="chat-bubble" :class="message.role === 'user' ? 'user-message' : 'ai-message'">
    <div class="bubble-content">
      <span>{{ message.content }}</span>
    </div>
    <div class="message-time" v-if="showTime">{{ formatTime }}</div>
  </div>
</template>

<style scoped>
.chat-bubble { display: flex; flex-direction: column; max-width: 75%; }

/* 用户消息:右侧蓝色渐变 */
.user-message { align-self: flex-end; }
.user-message .bubble-content {
  background: linear-gradient(135deg, #1989fa, #1677d9);
  color: #fff;
  border-bottom-right-radius: 4px;
}

/* AI 消息:左侧白色 */
.ai-message { align-self: flex-start; }
.ai-message .bubble-content {
  background: #fff;
  border: 1px solid #e5e5e5;
  border-bottom-left-radius: 4px;
}

.bubble-content {
  padding: 10px 14px;
  border-radius: 16px;
  font-size: 15px;
  line-height: 1.6;
  word-break: break-word;    // 长单词断行
  white-space: pre-wrap;     // 保留换行
}
</style>
// 备注:通过 :class 动态绑定区分用户/AI 样式,
// white-space: pre-wrap 保留 AI 回复中的换行格式

10.2 SpotItem.vue(景点详情)

展示单个时间段(上午/下午/晚上)的景点信息。

vue 复制代码
<script setup>
defineProps({
  data: { type: Object, default: () => ({}) }
})
</script>

<template>
  <div class="spot-item" v-if="data">
    <div class="spot-name">{{ data.spot || data.name || '待定' }}</div>
    <div class="spot-details">
      <div class="detail-row" v-if="data.duration">
        <van-icon name="clock-o" size="14" />
        <span>{{ data.duration }}</span>
      </div>
      <div class="detail-row" v-if="data.ticket">
        <van-icon name="ticket-o" size="14" />
        <span>{{ data.ticket }}</span>
      </div>
      <div class="detail-row" v-if="data.transportation">
        <van-icon name="logistics" size="14" />
        <span>{{ data.transportation }}</span>
      </div>
    </div>
    <div class="spot-desc" v-if="data.description">{{ data.description }}</div>
  </div>
  <div class="spot-item empty" v-else>
    <van-empty description="暂无安排" :image-size="40" />
  </div>
</template>
// 备注:v-if/v-else 处理有数据和无数据两种状态,
// v-for 内部的 v-if 控制子字段是否显示

十一、核心流程图

首页 → 详情页流程:

用户填写表单 → 前端校验(城市/预算/天数)→ Router.push 携带 query 参数 → Detail 页 onMounted → 读取 route.query → POST /api/travel/recommend → 显示 loading → 渲染 Collapse 折叠面板 → 展示每日行程

AI 对话流程:

用户输入 → 校验非空 → 添加用户消息 → 调用 fetchStream → 后端 SSE 响应 → ReadableStream 逐分片解析 → 识别 chunk 类型 → 实时修改最后一条 AI 消息 → Vue 响应式更新视图 → 完成后设置 isStreaming = false


十二、踩坑记录

1. SSE 请求路径问题

  • 问题:Vite 代理对 SSE 支持不完善,ERR_ABORTED 错误
  • 解决:直接使用绝对 URL http://127.0.0.1:3200 绕过代理

2. SSE 解析问题

  • 问题:后端发送 data: {...} 格式,前端直接 JSON.parse 失败
  • 解决:先去掉 data: 前缀再解析,支持 [DONE] 结束标志

3. 字段映射问题

  • 问题:AI 返回字段名与前端/后端 VO 不一致导致数据为空
  • 解决:在 Prompt 中明确指定 JSON 结构,确保字段名统一

4. 响应式更新问题

  • 问题:直接修改数组元素属性不触发视图更新
  • 解决:使用 messages.value[lastIndex].content = xxx 直接修改响应式对象,Vue 3 支持数组索引直接赋值触发更新
相关推荐
名为沙丁鱼的猫7293 小时前
【审计日志组件实践复盘】前端工程化 + 后端DDD架构(AOP切面拦截日志) + MBG
前端·架构
陈随易10 小时前
bm2,MoonBit实现的pm2替代品
前端·后端·程序员
摇滚侠10 小时前
《RocketMQ 官网》阅读笔记 RocketMQ 普通消息 延时消息 顺序消息 事务消息
笔记·rocketmq
a11177611 小时前
农业数字孪生大屏网页 html开源
前端·html
圣光SG12 小时前
web操作题练习(六)
前端
闪亮的路灯13 小时前
威联通QTS使用自带web服务期代理前端(类似nginx)
前端·nginx·威联通
Privasa-隐私实验室13 小时前
全程无死角安全测评|从开机上锁、硬核加密到彻底销毁,Privasa 全生命周期隐私防护详解
笔记·安全·本地存储·隐私安全·aes-256
kyriewen13 小时前
我用AI写了半年代码——回头看,这5个能力正在退化
前端·javascript·ai编程