一、项目架构概述
本项目基于 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 支持数组索引直接赋值触发更新