一、项目架构概述
本项目基于 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):
// 运行时依赖"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请求转发到后端,解决跨域问题。
import{defineConfig}from'vite'importvuefrom'@vitejs/plugin-vue'exportdefaultdefineConfig({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 组件。
import{createApp}from'vue'import'vant/lib/index.css'// Vant 样式import'./style.css'// 全局样式importAppfrom'./App.vue'importrouterfrom'./router'// 按需引入 Vant 组件import{Button,Card,Tabbar,TabbarItem,Icon,NavBar,NoticeBar,Field,Popup,Picker,Grid,GridItem,Loading,Empty,Collapse,CollapseItem,Tag,Cell,CellGroup}from'vant'constapp=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 变量和全局样式,统一设计规范。
: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 12pxrgba(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 配置路由表,管理页面跳转。
import{createRouter,createWebHistory}from'vue-router'importHomefrom'../views/Home.vue'importChatfrom'../views/Chat.vue'importProfilefrom'../views/Profile.vue'importDetailfrom'../views/Detail.vue'constroutes=[{path:'/',name:'Home',component:Home},// 首页{path:'/chat',name:'Chat',component:Chat},// AI 对话页{path:'/profile',name:'Profile',component:Profile},// 个人中心{path:'/detail',name:'Detail',component:Detail}// 行程详情]constrouter=createRouter({history:createWebHistory(),// HTML5 History 模式routes})exportdefaultrouter// 备注:createWebHistory 使用 HTML5 History API,// URL 更美观(无 # 号),需要后端配合处理 404五、根组件 App.vue
5.1 底部导航栏
使用 Vant 的 Tabbar 组件实现底部导航,配合 Router 的to属性实现路由跳转。
<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 流式请求。
importaxiosfrom'axios'// ===== 普通请求封装 =====constinstance=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.dataerror=>Promise.reject(error))// 导出常用方法exportconstpost=(url,data={})=>instance.post(url,data)exportconstget=(url,params={})=>instance.get(url,{params})// ===== SSE 流式请求封装 =====constBACKEND_BASE_URL='http://127.0.0.1:3200'// 直接访问后端(绕过代理)exportasyncfunctionfetchStream(url,data,onChunk,onComplete,onError){try{constresponse=awaitfetch(`${BACKEND_BASE_URL}/api/travel/${url}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)})if(!response.ok)thrownewError(`HTTP error:${response.status}`)constreader=response.body.getReader()// 获取 ReadableStreamconstdecoder=newTextDecoder()// 二进制转字符串while(true){const{done,value}=awaitreader.read()// 读取数据块if(done)breakconstchunk=decoder.decode(value,{stream:true})constlines=chunk.split('\n').filter(l=>l.trim())for(constlineoflines){if(line.startsWith('data:')){constjsonStr=line.substring(5)if(jsonStr&&jsonStr!=='[DONE]'){try{constjsonData=JSON.parse(jsonStr)if(jsonData.type==='chunk'){onChunk(jsonData.content)// 数据分片回调}elseif(jsonData.done===true){onComplete()// 完成回调return}elseif(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 页面结构
包含通知条、规划表单、快捷入口、热门目的地四部分。
<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>模板关键部分:
<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 数据加载与展示
通过路由参数获取查询条件,调用后端接口获取行程数据。
<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>模板关键部分:
<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 回复(打字机效果)。
<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>模板关键部分:
<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 消息的气泡样式,支持时间显示。
<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(景点详情)
展示单个时间段(上午/下午/晚上)的景点信息。
<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 支持数组索引直接赋值触发更新