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

📅 2026/7/31 17:40:34
AI智能旅游规划系统 - 前端技术笔记
一、项目架构概述本项目基于 Vue 3 Vite 构建移动端 H5 应用使用 Vant 4 组件库采用 Vue 3script setup组合式 API 开发。核心技术栈Vue 3.5组合式 API script setupVue Router 4.3路由管理Vant 4.10移动端 UI 组件库Vite 8构建工具AxiosHTTP 请求原生 fetch APISSE 流式请求依赖配置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}fromviteimportvuefromvitejs/plugin-vueexportdefaultdefineConfig({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-cacheproxyRes.headers[connection]keep-alive}})}}}}})// 备注SSE 响应需要设置 cache-control: no-cache 和 connection: keep-alive// 否则浏览器会缓存或断开连接导致流式响应失败三、应用入口与组件注册3.1 main.js创建 Vue 应用实例全局注册路由和 Vant 组件。import{createApp}fromvueimportvant/lib/index.css// Vant 样式import./style.css// 全局样式importAppfrom./App.vueimportrouterfrom./router// 按需引入 Vant 组件import{Button,Card,Tabbar,TabbarItem,Icon,NavBar,NoticeBar,Field,Popup,Picker,Grid,GridItem,Loading,Empty,Collapse,CollapseItem,Tag,Cell,CellGroup}fromvantconstappcreateApp(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}fromvue-routerimportHomefrom../views/Home.vueimportChatfrom../views/Chat.vueimportProfilefrom../views/Profile.vueimportDetailfrom../views/Detail.vueconstroutes[{path:/,name:Home,component:Home},// 首页{path:/chat,name:Chat,component:Chat},// AI 对话页{path:/profile,name:Profile,component:Profile},// 个人中心{path:/detail,name:Detail,component:Detail}// 行程详情]constroutercreateRouter({history:createWebHistory(),// HTML5 History 模式routes})exportdefaultrouter// 备注createWebHistory 使用 HTML5 History API// URL 更美观无 # 号需要后端配合处理 404五、根组件 App.vue5.1 底部导航栏使用 Vant 的 Tabbar 组件实现底部导航配合 Router 的to属性实现路由跳转。script setup import { ref } from vue const active ref(0) // 当前激活的 tab 索引 /script template router-view / !-- 路由出口 -- van-tabbar v-modelactive active-color#1989fa van-tabbar-item iconhome-o to/首页/van-tabbar-item van-tabbar-item iconchat-o to/chat对话/van-tabbar-item van-tabbar-item iconfriends-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 流式请求。importaxiosfromaxios// 普通请求封装 constinstanceaxios.create({baseURL:/,// 使用 Vite 代理的根路径timeout:300000,// 5 分钟超时AI 响应可能较慢headers:{Content-Type:application/json}})// 请求拦截器instance.interceptors.request.use(configconfig)// 响应拦截器直接返回 data 层instance.interceptors.response.use(responseresponse.data,// 自动解包 response.dataerrorPromise.reject(error))// 导出常用方法exportconstpost(url,data{})instance.post(url,data)exportconstget(url,params{})instance.get(url,{params})// SSE 流式请求封装 constBACKEND_BASE_URLhttp://127.0.0.1:3200// 直接访问后端绕过代理exportasyncfunctionfetchStream(url,data,onChunk,onComplete,onError){try{constresponseawaitfetch(${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})constreaderresponse.body.getReader()// 获取 ReadableStreamconstdecodernewTextDecoder()// 二进制转字符串while(true){const{done,value}awaitreader.read()// 读取数据块if(done)breakconstchunkdecoder.decode(value,{stream:true})constlineschunk.split(\n).filter(ll.trim())for(constlineoflines){if(line.startsWith(data:)){constjsonStrline.substring(5)if(jsonStrjsonStr![DONE]){try{constjsonDataJSON.parse(jsonStr)if(jsonData.typechunk){onChunk(jsonData.content)// 数据分片回调}elseif(jsonData.donetrue){onComplete()// 完成回调return}elseif(jsonData.error){onError(jsonData.error)// 错误回调return}}catch(err){// JSON 解析失败忽略继续处理}}}}}onComplete()// 兜底如果没收到 done 事件}catch(error){onError(error.message||请求失败)}}// 备注SSE 解析流程// 1. fetch 返回 Response.bodyReadableStream// 2. getReader() 获取读取器// 3. read() 循环读取数据块// 4. 按 \n 分割行过滤空行// 5. 查找 data: 前缀解析 JSON// 6. 根据 type 字段分发事件七、首页 Home.vue7.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 classpage-container van-nav-bar title智能旅游助手 / !-- 通知条 -- van-notice-bar left-iconvolume-o 基于 AI 的智能景点介绍与行程规划系统 /van-notice-bar !-- 规划表单卡片 -- div classcard div classsection-title规划你的旅程/div van-field clickshowCityPicker true v-modelformData.city label目的地 placeholder请选择城市 right-iconarrow / van-field v-modelformData.budget label预算元 placeholder请输入预算金额 / van-field v-modelformData.days label天数 placeholder请输入天数 / van-button typeprimary sizelarge round clickhandleSubmit 开始规划 /van-button /div !-- 热门目的地网格 -- van-grid :gutter12 :column-num4 van-grid-item v-forcity in hotCities :keycity clickselectedCity(city) div classcity-tag :class{ active: formData.city city }{{ city }}/div /van-grid-item /van-grid !-- 城市选择弹出层 -- van-popup v-model:showshowCityPicker positionbottom round van-picker title选择目的地 :columnscolumns confirmonConfirm cancelshowCityPicker false / /van-popup /div /template // 备注使用 van-popup van-picker 实现底部弹出选择器 // van-grid 实现热门城市标签网格布局八、行程详情页 Detail.vue8.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 classpage-container van-nav-bar title推荐结果 left-arrow clickgoBack / div classpage-content !-- 加载状态 -- div v-ifloading classloading-container van-loading size48px typespinner / /div !-- 错误状态 -- div v-else-iferror van-empty description请求失败请重试 van-button clickfetchRecommend typeprimary重新加载/van-button /van-empty /div !-- 成功状态展示行程 -- div v-else-iftripData?.success div classcard h2{{ tripData.city }} · {{ tripData.days }}天的行程/h2 /div !-- 折叠面板展示每日行程 -- van-collapse v-modelactiveNames van-collapse-item v-forday in tripData.dailyItineraries :keyday.day :title第 day.day 天 :nameday.day SpotItem :dataday.morning / SpotItem :dataday.afternoon / SpotItem :dataday.evening / /van-collapse-item /van-collapse /div /div /div /template // 备注van-collapse 折叠面板嵌套 SpotItem 子组件 // 分别展示上午/下午/晚上的行程详情九、AI 对话页 Chat.vue9.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 classpage-container chat-page !-- 顶部导航 -- van-nav-bar titleAI旅游助手 left-text返回 left-arrow fixed click-leftrouter.back() / !-- 聊天容器 -- div classchat-container refchatContainer !-- 空状态显示常见问题 -- div v-ifmessages.length 0 classchat-empty van-empty description开始和AI旅游助手对话 / div classquick-questions div classquick-title常见问题/div div classquick-tags van-tag v-forquestion in quickQuestions :keyquestion classquick-tag mark sizelarge {{ question }} /van-tag /div /div /div !-- 有消息时显示消息列表 -- div v-else classmessage-list ChatBubble v-formessage in messages :keymessage.id :messagemessage / div v-ifisStreaming classstreaming-indicator van-loading typespinner size20px / spanAI正在思考中.../span /div /div /div !-- 输入框 -- div classchat-input-area van-field v-modelinputMessage placeholder输入您的问题... :disabledisStreaming template #button van-button :disabled!inputMessage.trim() clicksendMessage sizesmall typeprimary发送/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 classchat-bubble :classmessage.role user ? user-message : ai-message div classbubble-content span{{ message.content }}/span /div div classmessage-time v-ifshowTime{{ 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 classspot-item v-ifdata div classspot-name{{ data.spot || data.name || 待定 }}/div div classspot-details div classdetail-row v-ifdata.duration van-icon nameclock-o size14 / span{{ data.duration }}/span /div div classdetail-row v-ifdata.ticket van-icon nameticket-o size14 / span{{ data.ticket }}/span /div div classdetail-row v-ifdata.transportation van-icon namelogistics size14 / span{{ data.transportation }}/span /div /div div classspot-desc v-ifdata.description{{ data.description }}/div /div div classspot-item empty v-else van-empty description暂无安排 :image-size40 / /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错误解决直接使用绝对 URLhttp://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 支持数组索引直接赋值触发更新