本节介绍智能旅行助手的核心功能实现,包括预算计算、加载进度条、行程编辑、导出功能和侧边导航。
13.6.1 预算计算功能
在规划旅行时,预算是一个非常重要的考虑因素。用户需要知道这次旅行大概要花多少钱,钱都花在哪里。我们的智能旅行助手提供了自动预算计算功能,将费用分为四大类:景点门票、酒店住宿、餐饮和交通。
预算计算的逻辑在哪里实现呢?我们选择在后端的 PlannerAgent 中实现。为什么不在前端计算?因为预算的估算需要基于景点的门票价格、酒店的价格范围、餐饮的标准等信息,这些信息都是 PlannerAgent 在生成行程时已经获取的。如果在前端计算,就需要重复这些逻辑,而且可能不准确。
在 PlannerAgent 的提示词中,我们明确要求 LLM 生成预算信息:
PLANNER_AGENT_PROMPT = """
你是行程规划专家。
**输出格式:**
严格按照以下JSON格式返回:
{
...
"budget": {
"total_attractions": 180,
"total_hotels": 1200,
"total_meals": 480,
"total_transportation": 200,
"total": 2060
}
}
**规划要求:**
...
7. 包含预算信息,根据景点门票、酒店价格、餐饮标准和交通方式估算
"""
LLM 会根据行程中的景点、酒店、餐饮安排,估算每一项的费用。比如,如果行程中包含故宫(门票 60 元)、天坛(门票 15 元)、颐和园(门票 30 元),那么景点门票总费用就是 105 元。如果是 3 天 2 晚的行程,酒店是经济型(每晚 300 元),那么酒店总费用就是 600 元。
在前端,我们使用 Ant Design Vue 的 Statistic 组件来展示预算信息。这个组件专门用于展示统计数据,支持数字动画、前缀后缀、自定义样式等:
<a-card v-if="tripPlan.budget" title="💰 预算明细">
<a-row :gutter="16">
<a-col :span="6">
<a-statistic title="景点门票" :value="tripPlan.budget.total_attractions" suffix="元" />
</a-col>
<a-col :span="6">
<a-statistic title="酒店住宿" :value="tripPlan.budget.total_hotels" suffix="元" />
</a-col>
<a-col :span="6">
<a-statistic title="餐饮费用" :value="tripPlan.budget.total_meals" suffix="元" />
</a-col>
<a-col :span="6">
<a-statistic title="交通费用" :value="tripPlan.budget.total_transportation" suffix="元" />
</a-col>
</a-row>
<a-divider />
<a-row>
<a-col :span="24" style="text-align: center;">
<a-statistic
title="预估总费用"
:value="tripPlan.budget.total"
suffix="元"
:value-style="{ color: '#cf1322',fontSize: '32px',fontWeight: 'bold' }"
/>
</a-col>
</a-row>
</a-card>
这段代码使用了栅格布局(a-row和a-col),将四项费用并排显示。每项费用使用一个a-statistic组件,显示标题和数值。最后用一个分隔线(a-divider)隔开,下面显示总费用,使用红色大字体突出显示。
注意v-if="tripPlan.budget"这个条件渲染。因为预算信息是可选的(在 Pydantic 模型中定义为Optional[Budget]),如果 LLM 没有生成预算信息,这个卡片就不会显示。这体现了前端对数据的容错处理。
13.6.2 加载进度条
生成旅行计划是一个耗时的操作。后端需要依次调用 AttractionSearchAgent、WeatherQueryAgent、HotelAgent 和 PlannerAgent,每个 Agent 都要调用 LLM 和外部 API。整个过程可能需要 10-30 秒。如果用户点击"开始规划"按钮后,页面没有任何反馈,用户会以为系统卡住了,可能会刷新页面或重复点击。
为了提升用户体验,我们添加了加载进度条和状态提示。现在只是模拟进度,可以让用户知道系统正在工作。
const loading = ref(false)
const loadingProgress = ref(0)
const loadingStatus = ref('')
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
loadingStatus.value = '✅ 完成!'
router.push({ name: 'result', state: { tripPlan: response } })
} catch (error) {
clearInterval(progressInterval)
message.error('生成计划失败')
} finally {
loading.value = false
}
}
13.6.3 行程编辑功能
AI 生成的旅行计划虽然很智能,但可能不完全符合用户的个人需求。比如,用户可能不喜欢某个景点,想删除它;或者想调整景点的游览顺序。我们提供了行程编辑功能,让用户可以自定义行程。
编辑功能的核心是状态管理。我们需要维护两个状态:当前的行程计划和原始的行程计划。当用户进入编辑模式时,我们保存原始计划的副本。如果用户取消编辑,就恢复原始计划。如果用户保存修改,就更新当前计划:
const editMode = ref(false)
const originalPlan = ref<TripPlan | null>(null)
// 进入编辑模式
const toggleEditMode = () => {
editMode.value = true
originalPlan.value = JSON.parse(JSON.stringify(tripPlan.value))
}
注意这里使用了JSON.parse(JSON.stringify(...))来深拷贝对象。为什么不直接赋值?因为 JavaScript 中对象是引用类型,如果直接赋值,originalPlan和tripPlan会指向同一个对象,修改一个会影响另一个。深拷贝可以创建一个完全独立的副本。
移动景点的逻辑是交换数组中两个元素的位置:
// 移动景点
const moveAttraction = (dayIndex: number,attractionIndex: number,direction: 'up' | 'down') => {
const attractions = tripPlan.value.days[dayIndex].attractions
const newIndex = direction === 'up' ? attractionIndex - 1 : attractionIndex + 1
if (newIndex >= 0 && newIndex < attractions.length) {
[attractions[attractionIndex],attractions[newIndex]] =
[attractions[newIndex],attractions[attractionIndex]]
}
}
这里使用了 ES6 的解构赋值语法来交换两个元素。[a,b] = [b,a]是一个很优雅的交换方式,不需要临时变量。
删除景点使用数组的splice方法:
// 删除景点
const deleteAttraction = (dayIndex: number,attractionIndex: number) => {
tripPlan.value.days[dayIndex].attractions.splice(attractionIndex,1)
}
保存修改时,我们需要重新初始化地图,因为景点的位置可能发生了变化:
// 保存修改
const saveChanges = () => {
editMode.value = false
message.success('修改已保存')
initMap() // 重新初始化地图
}
// 取消编辑
const cancelEdit = () => {
if (originalPlan.value) {
tripPlan.value = originalPlan.value
}
editMode.value = false
}
在模板中,我们根据editMode的值显示不同的 UI。编辑模式下,每个景点旁边会显示上移、下移、删除按钮:
<div v-if="editMode" class="edit-buttons">
<a-button size="small" @click="moveAttraction(dayIndex,index,'up')">上移</a-button>
<a-button size="small" @click="moveAttraction(dayIndex,index,'down')">下移</a-button>
<a-button size="small" danger @click="deleteAttraction(dayIndex,index)">删除</a-button>
</div>
13.6.4 导出功能
用户生成了满意的旅行计划后,可能想保存下来或分享给朋友。我们提供了两种导出方式:导出为图片和导出为 PDF。
导出功能的核心是html2canvas库。这个库可以把 DOM 元素转换成 Canvas,然后我们可以把 Canvas 导出为图片。但这里有一个技术难点:地图是用 Canvas 渲染的,而html2canvas在处理嵌套 Canvas 时存在兼容性问题。
我们尝试了多种解决方案,包括将地图 Canvas 转换成图片后再导出,但由于高德地图的 Canvas 渲染机制和跨域限制,这个方案并没有完全解决问题。在实际项目中,可能需要考虑以下替代方案:
- 使用高德地图的静态地图 API:调用
maps_staticmap工具生成静态地图图片,替代动态地图 - 分开导出:地图和行程内容分开导出,最后在后端合并
- 使用截图服务:使用 Puppeteer 等无头浏览器在服务端截图
- 简化导出内容:导出时隐藏地图,只导出文字内容
目前的实现中,我们采用了简化方案,在导出时暂时隐藏地图部分,只导出行程的文字内容和景点信息。虽然这不是最理想的方案,但可以保证导出功能的可用性。
导出为图片的逻辑很简单:
import html2canvas from 'html2canvas'
const exportAsImage = async () => {
const element = document.getElementById('trip-plan-content')
if (!element) return
const canvas = await html2canvas(element,{
backgroundColor: '#ffffff',
scale: 2,
useCORS: true
})
const link = document.createElement('a')
link.download = `${tripPlan.value.city}旅行计划.png`
link.href = canvas.toDataURL('image/png')
link.click()
message.success('导出成功!')
}
scale: 2表示使用 2 倍分辨率,这样导出的图片更清晰。useCORS: true允许跨域加载图片,这对于景点图片(来自 Unsplash)很重要。
导出为 PDF 需要额外的步骤:先转换成 Canvas,再转换成图片,最后添加到 PDF 中:
import jsPDF from 'jspdf'
const exportAsPDF = async () => {
// 先截取地图
await captureMapImage()
const element = document.getElementById('trip-plan-content')
if (!element) return
const canvas = await html2canvas(element,{
backgroundColor: '#ffffff',
scale: 2,
useCORS: true,
allowTaint: true
})
// 恢复地图
restoreMap()
const pdf = new jsPDF('p','mm','a4')
const imgData = canvas.toDataURL('image/png')
const imgWidth = 210 // A4宽度
const imgHeight = (canvas.height * imgWidth) / canvas.width
pdf.addImage(imgData,'PNG',0,0,imgWidth,imgHeight)
pdf.save(`${tripPlan.value.city}旅行计划.pdf`)
message.success('导出成功!')
}
这里需要计算图片的高度,保持宽高比。A4 纸的宽度是 210mm,我们根据 Canvas 的宽高比计算出对应的高度。
13.6.5 侧边导航与锚点跳转
Result 页面的内容很多,包括行程概览、预算明细、地图、每日行程、天气信息等。如果用户想快速跳转到某个部分,需要滚动很长的距离。我们提供了侧边导航和锚点跳转功能,让用户可以快速定位。
侧边导航使用 Ant Design Vue 的 Menu 组件:
<a-menu
v-model:selectedKeys="[activeSection]"
mode="inline"
@click="scrollToSection"
>
<a-menu-item key="overview">📋 行程概览</a-menu-item>
<a-menu-item key="budget">💰 预算明细</a-menu-item>
<a-menu-item key="map">🗺️ 地图</a-menu-item>
<a-menu-item key="days">📅 每日行程</a-menu-item>
<a-menu-item key="weather">🌤️ 天气</a-menu-item>
</a-menu>
点击菜单项时,调用scrollToSection函数:
const activeSection = ref('overview')
// 滚动到指定区域
const scrollToSection = ({ key }: { key: string }) => {
activeSection.value = key
const element = document.getElementById(key)
if (element) {
element.scrollIntoView({ behavior: 'smooth',block: 'start' })
}
}
scrollIntoView是浏览器原生的 API,可以让元素滚动到可视区域。behavior: 'smooth'表示平滑滚动,而不是瞬间跳转。block: 'start'表示元素的顶部对齐到可视区域的顶部。
在页面的各个部分,我们需要添加对应的 id:
<div id="overview">
<!-- 行程概览内容 -->
</div>
<div id="budget">
<!-- 预算明细内容 -->
</div>
<div id="map">
<!-- 地图内容 -->
</div>
这样,当用户点击侧边导航的某个菜单项时,页面会平滑滚动到对应的部分。
通过这些功能的实现,我们的智能旅行助手不仅能够生成旅行计划,还提供了丰富的交互功能:预算计算让用户了解费用,加载进度条让等待不再焦虑,行程编辑让计划更符合个人需求,导出功能让计划可以分享和保存,侧边导航让长页面易于浏览。这些功能的组合,构成了一个完整、易用、实用的 Web 应用。