Result.vue 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391
  1. <template>
  2. <div class="result-container">
  3. <!-- 页面头部 -->
  4. <div class="page-header">
  5. <a-button class="back-button" size="large" @click="goBack">
  6. ← 返回首页
  7. </a-button>
  8. <a-space size="middle">
  9. <a-button v-if="!editMode" @click="toggleEditMode" type="default">
  10. ✏️ 编辑行程
  11. </a-button>
  12. <a-button v-else @click="saveChanges" type="primary">
  13. 💾 保存修改
  14. </a-button>
  15. <a-button v-if="editMode" @click="cancelEdit" type="default">
  16. ❌ 取消编辑
  17. </a-button>
  18. <!-- 导出按钮 -->
  19. <a-dropdown v-if="!editMode">
  20. <template #overlay>
  21. <a-menu>
  22. <a-menu-item key="image" @click="exportAsImage">
  23. 📷 导出为图片
  24. </a-menu-item>
  25. <a-menu-item key="pdf" @click="exportAsPDF">
  26. 📄 导出为PDF
  27. </a-menu-item>
  28. </a-menu>
  29. </template>
  30. <a-button type="default">
  31. 📥 导出行程 <DownOutlined />
  32. </a-button>
  33. </a-dropdown>
  34. </a-space>
  35. </div>
  36. <div v-if="tripPlan" class="content-wrapper">
  37. <!-- 侧边导航 -->
  38. <div class="side-nav">
  39. <a-affix :offset-top="80">
  40. <a-menu mode="inline" :selected-keys="[activeSection]" @click="scrollToSection">
  41. <a-menu-item key="overview">
  42. <span>📋 行程概览</span>
  43. </a-menu-item>
  44. <a-menu-item key="budget" v-if="tripPlan.budget">
  45. <span>💰 预算明细</span>
  46. </a-menu-item>
  47. <a-menu-item key="map">
  48. <span>📍 景点地图</span>
  49. </a-menu-item>
  50. <a-sub-menu key="days" title="📅 每日行程">
  51. <a-menu-item v-for="(day, index) in tripPlan.days" :key="`day-${index}`">
  52. 第{{ day.day_index + 1 }}天
  53. </a-menu-item>
  54. </a-sub-menu>
  55. <a-menu-item key="weather" v-if="tripPlan.weather_info && tripPlan.weather_info.length > 0">
  56. <span>🌤️ 天气信息</span>
  57. </a-menu-item>
  58. </a-menu>
  59. </a-affix>
  60. </div>
  61. <!-- 主内容区 -->
  62. <div class="main-content">
  63. <!-- 顶部信息区:左侧概览+预算,右侧地图 -->
  64. <div class="top-info-section">
  65. <!-- 左侧:行程概览和预算明细 -->
  66. <div class="left-info">
  67. <!-- 行程概览 -->
  68. <a-card id="overview" :title="`${tripPlan.city}旅行计划`" :bordered="false" class="overview-card">
  69. <div class="overview-content">
  70. <div class="info-item">
  71. <span class="info-label">📅 日期:</span>
  72. <span class="info-value">{{ tripPlan.start_date }} 至 {{ tripPlan.end_date }}</span>
  73. </div>
  74. <div class="info-item">
  75. <span class="info-label">💡 建议:</span>
  76. <span class="info-value">{{ tripPlan.overall_suggestions }}</span>
  77. </div>
  78. </div>
  79. </a-card>
  80. <!-- 预算明细 -->
  81. <a-card id="budget" v-if="tripPlan.budget" title="💰 预算明细" :bordered="false" class="budget-card">
  82. <div class="budget-grid">
  83. <div class="budget-item">
  84. <div class="budget-label">景点门票</div>
  85. <div class="budget-value">¥{{ tripPlan.budget.total_attractions }}</div>
  86. </div>
  87. <div class="budget-item">
  88. <div class="budget-label">酒店住宿</div>
  89. <div class="budget-value">¥{{ tripPlan.budget.total_hotels }}</div>
  90. </div>
  91. <div class="budget-item">
  92. <div class="budget-label">餐饮费用</div>
  93. <div class="budget-value">¥{{ tripPlan.budget.total_meals }}</div>
  94. </div>
  95. <div class="budget-item">
  96. <div class="budget-label">交通费用</div>
  97. <div class="budget-value">¥{{ tripPlan.budget.total_transportation }}</div>
  98. </div>
  99. </div>
  100. <div class="budget-total">
  101. <span class="total-label">预估总费用</span>
  102. <span class="total-value">¥{{ tripPlan.budget.total }}</span>
  103. </div>
  104. </a-card>
  105. </div>
  106. <!-- 右侧:地图 -->
  107. <div class="right-map">
  108. <a-card id="map" title="📍 景点地图" :bordered="false" class="map-card">
  109. <div id="amap-container" style="width: 100%; height: 100%"></div>
  110. </a-card>
  111. </div>
  112. </div>
  113. <!-- 每日行程:可折叠 -->
  114. <a-card title="📅 每日行程" :bordered="false" class="days-card">
  115. <a-collapse v-model:activeKey="activeDays" accordion>
  116. <a-collapse-panel
  117. v-for="(day, index) in tripPlan.days"
  118. :key="index"
  119. :id="`day-${index}`"
  120. >
  121. <template #header>
  122. <div class="day-header">
  123. <span class="day-title">第{{ day.day_index + 1 }}天</span>
  124. <span class="day-date">{{ day.date }}</span>
  125. </div>
  126. </template>
  127. <!-- 行程基本信息 -->
  128. <div class="day-info">
  129. <div class="info-row">
  130. <span class="label">📝 行程描述:</span>
  131. <span class="value">{{ day.description }}</span>
  132. </div>
  133. <div class="info-row">
  134. <span class="label">🚗 交通方式:</span>
  135. <span class="value">{{ day.transportation }}</span>
  136. </div>
  137. <div class="info-row">
  138. <span class="label">🏨 住宿:</span>
  139. <span class="value">{{ day.accommodation }}</span>
  140. </div>
  141. </div>
  142. <!-- 景点安排 -->
  143. <a-divider orientation="left">🎯 景点安排</a-divider>
  144. <a-list
  145. :data-source="day.attractions"
  146. :grid="{ gutter: 16, column: 2 }"
  147. >
  148. <template #renderItem="{ item, index }">
  149. <a-list-item>
  150. <a-card :title="item.name" size="small" class="attraction-card">
  151. <!-- 编辑模式下的操作按钮 -->
  152. <template #extra v-if="editMode">
  153. <a-space>
  154. <a-button
  155. size="small"
  156. @click="moveAttraction(day.day_index, index, 'up')"
  157. :disabled="index === 0"
  158. >
  159. </a-button>
  160. <a-button
  161. size="small"
  162. @click="moveAttraction(day.day_index, index, 'down')"
  163. :disabled="index === day.attractions.length - 1"
  164. >
  165. </a-button>
  166. <a-button
  167. size="small"
  168. danger
  169. @click="deleteAttraction(day.day_index, index)"
  170. >
  171. 🗑️
  172. </a-button>
  173. </a-space>
  174. </template>
  175. <!-- 景点图片 -->
  176. <div class="attraction-image-wrapper">
  177. <img
  178. :src="getAttractionImage(item.name, index)"
  179. :alt="item.name"
  180. class="attraction-image"
  181. @error="handleImageError"
  182. />
  183. <div class="attraction-badge">
  184. <span class="badge-number">{{ index + 1 }}</span>
  185. </div>
  186. <div v-if="item.ticket_price" class="price-tag">
  187. ¥{{ item.ticket_price }}
  188. </div>
  189. </div>
  190. <!-- 编辑模式下可编辑的字段 -->
  191. <div v-if="editMode">
  192. <p><strong>地址:</strong></p>
  193. <a-input v-model:value="item.address" size="small" style="margin-bottom: 8px" />
  194. <p><strong>游览时长(分钟):</strong></p>
  195. <a-input-number v-model:value="item.visit_duration" :min="10" :max="480" size="small" style="width: 100%; margin-bottom: 8px" />
  196. <p><strong>描述:</strong></p>
  197. <a-textarea v-model:value="item.description" :rows="2" size="small" style="margin-bottom: 8px" />
  198. </div>
  199. <!-- 查看模式 -->
  200. <div v-else>
  201. <p><strong>地址:</strong> {{ item.address }}</p>
  202. <p><strong>游览时长:</strong> {{ item.visit_duration }}分钟</p>
  203. <p><strong>描述:</strong> {{ item.description }}</p>
  204. <p v-if="item.rating"><strong>评分:</strong> {{ item.rating }}⭐</p>
  205. </div>
  206. </a-card>
  207. </a-list-item>
  208. </template>
  209. </a-list>
  210. <!-- 酒店推荐 -->
  211. <a-divider v-if="day.hotel" orientation="left">🏨 住宿推荐</a-divider>
  212. <a-card v-if="day.hotel" size="small" class="hotel-card">
  213. <template #title>
  214. <span class="hotel-title">{{ day.hotel.name }}</span>
  215. </template>
  216. <a-descriptions :column="2" size="small">
  217. <a-descriptions-item label="地址">{{ day.hotel.address }}</a-descriptions-item>
  218. <a-descriptions-item label="类型">{{ day.hotel.type }}</a-descriptions-item>
  219. <a-descriptions-item label="价格范围">{{ day.hotel.price_range }}</a-descriptions-item>
  220. <a-descriptions-item label="评分">{{ day.hotel.rating }}⭐</a-descriptions-item>
  221. <a-descriptions-item label="距离" :span="2">{{ day.hotel.distance }}</a-descriptions-item>
  222. </a-descriptions>
  223. </a-card>
  224. <!-- 餐饮安排 -->
  225. <a-divider orientation="left">🍽️ 餐饮安排</a-divider>
  226. <a-descriptions :column="1" bordered size="small">
  227. <a-descriptions-item
  228. v-for="meal in day.meals"
  229. :key="meal.type"
  230. :label="getMealLabel(meal.type)"
  231. >
  232. {{ meal.name }}
  233. <span v-if="meal.description"> - {{ meal.description }}</span>
  234. </a-descriptions-item>
  235. </a-descriptions>
  236. </a-collapse-panel>
  237. </a-collapse>
  238. </a-card>
  239. <a-card id="weather" v-if="tripPlan.weather_info && tripPlan.weather_info.length > 0" title="天气信息" style="margin-top: 20px" :bordered="false">
  240. <a-list
  241. :data-source="tripPlan.weather_info"
  242. :grid="{ gutter: 16, column: 3 }"
  243. >
  244. <template #renderItem="{ item }">
  245. <a-list-item>
  246. <a-card size="small" class="weather-card">
  247. <div class="weather-date">{{ item.date }}</div>
  248. <div class="weather-info-row">
  249. <span class="weather-icon">☀️</span>
  250. <div>
  251. <div class="weather-label">白天</div>
  252. <div class="weather-value">{{ item.day_weather }} {{ item.day_temp }}°C</div>
  253. </div>
  254. </div>
  255. <div class="weather-info-row">
  256. <span class="weather-icon">🌙</span>
  257. <div>
  258. <div class="weather-label">夜间</div>
  259. <div class="weather-value">{{ item.night_weather }} {{ item.night_temp }}°C</div>
  260. </div>
  261. </div>
  262. <div class="weather-wind">
  263. 💨 {{ item.wind_direction }} {{ item.wind_power }}
  264. </div>
  265. </a-card>
  266. </a-list-item>
  267. </template>
  268. </a-list>
  269. </a-card>
  270. </div>
  271. </div>
  272. <a-empty v-else description="没有找到旅行计划数据">
  273. <template #image>
  274. <div style="font-size: 80px;">🗺️</div>
  275. </template>
  276. <template #description>
  277. <span style="color: #999;">暂无旅行计划数据,请先创建行程</span>
  278. </template>
  279. <a-button type="primary" @click="goBack">返回首页创建行程</a-button>
  280. </a-empty>
  281. <!-- 回到顶部按钮 -->
  282. <a-back-top :visibility-height="300">
  283. <div class="back-top-button">
  284. </div>
  285. </a-back-top>
  286. </div>
  287. </template>
  288. <script setup lang="ts">
  289. import { ref, onMounted, nextTick } from 'vue'
  290. import { useRouter } from 'vue-router'
  291. import { message } from 'ant-design-vue'
  292. import { DownOutlined } from '@ant-design/icons-vue'
  293. import AMapLoader from '@amap/amap-jsapi-loader'
  294. import html2canvas from 'html2canvas'
  295. import jsPDF from 'jspdf'
  296. import type { TripPlan } from '@/types'
  297. const router = useRouter()
  298. const tripPlan = ref<TripPlan | null>(null)
  299. const editMode = ref(false)
  300. const originalPlan = ref<TripPlan | null>(null)
  301. const attractionPhotos = ref<Record<string, string>>({})
  302. const activeSection = ref('overview')
  303. const activeDays = ref<number[]>([0]) // 默认展开第一天
  304. let map: any = null
  305. onMounted(async () => {
  306. const data = sessionStorage.getItem('tripPlan')
  307. if (data) {
  308. tripPlan.value = JSON.parse(data)
  309. // 加载景点图片
  310. await loadAttractionPhotos()
  311. // 等待DOM渲染完成后初始化地图
  312. await nextTick()
  313. initMap()
  314. }
  315. })
  316. const goBack = () => {
  317. router.push('/')
  318. }
  319. // 滚动到指定区域
  320. const scrollToSection = ({ key }: { key: string }) => {
  321. activeSection.value = key
  322. const element = document.getElementById(key)
  323. if (element) {
  324. element.scrollIntoView({ behavior: 'smooth', block: 'start' })
  325. }
  326. }
  327. // 切换编辑模式
  328. const toggleEditMode = () => {
  329. editMode.value = true
  330. // 保存原始数据用于取消编辑
  331. originalPlan.value = JSON.parse(JSON.stringify(tripPlan.value))
  332. message.info('进入编辑模式')
  333. }
  334. // 保存修改
  335. const saveChanges = () => {
  336. editMode.value = false
  337. // 更新sessionStorage
  338. if (tripPlan.value) {
  339. sessionStorage.setItem('tripPlan', JSON.stringify(tripPlan.value))
  340. }
  341. message.success('修改已保存')
  342. // 重新初始化地图以反映更改
  343. if (map) {
  344. map.destroy()
  345. }
  346. nextTick(() => {
  347. initMap()
  348. })
  349. }
  350. // 取消编辑
  351. const cancelEdit = () => {
  352. if (originalPlan.value) {
  353. tripPlan.value = JSON.parse(JSON.stringify(originalPlan.value))
  354. }
  355. editMode.value = false
  356. message.info('已取消编辑')
  357. }
  358. // 删除景点
  359. const deleteAttraction = (dayIndex: number, attrIndex: number) => {
  360. if (!tripPlan.value) return
  361. const day = tripPlan.value.days[dayIndex]
  362. if (day.attractions.length <= 1) {
  363. message.warning('每天至少需要保留一个景点')
  364. return
  365. }
  366. day.attractions.splice(attrIndex, 1)
  367. message.success('景点已删除')
  368. }
  369. // 移动景点顺序
  370. const moveAttraction = (dayIndex: number, attrIndex: number, direction: 'up' | 'down') => {
  371. if (!tripPlan.value) return
  372. const day = tripPlan.value.days[dayIndex]
  373. const attractions = day.attractions
  374. if (direction === 'up' && attrIndex > 0) {
  375. [attractions[attrIndex], attractions[attrIndex - 1]] = [attractions[attrIndex - 1], attractions[attrIndex]]
  376. } else if (direction === 'down' && attrIndex < attractions.length - 1) {
  377. [attractions[attrIndex], attractions[attrIndex + 1]] = [attractions[attrIndex + 1], attractions[attrIndex]]
  378. }
  379. }
  380. const getMealLabel = (type: string): string => {
  381. const labels: Record<string, string> = {
  382. breakfast: '早餐',
  383. lunch: '午餐',
  384. dinner: '晚餐',
  385. snack: '小吃'
  386. }
  387. return labels[type] || type
  388. }
  389. // 加载所有景点图片
  390. const loadAttractionPhotos = async () => {
  391. if (!tripPlan.value) return
  392. const promises: Promise<void>[] = []
  393. tripPlan.value.days.forEach(day => {
  394. day.attractions.forEach(attraction => {
  395. const promise = fetch(`http://localhost:8000/api/poi/photo?name=${encodeURIComponent(attraction.name)}`)
  396. .then(res => res.json())
  397. .then(data => {
  398. if (data.success && data.data.photo_url) {
  399. attractionPhotos.value[attraction.name] = data.data.photo_url
  400. }
  401. })
  402. .catch(err => {
  403. console.error(`获取${attraction.name}图片失败:`, err)
  404. })
  405. promises.push(promise)
  406. })
  407. })
  408. await Promise.all(promises)
  409. }
  410. // 获取景点图片
  411. const getAttractionImage = (name: string, index: number): string => {
  412. // 如果已加载真实图片,返回真实图片
  413. if (attractionPhotos.value[name]) {
  414. return attractionPhotos.value[name]
  415. }
  416. // 返回一个纯色占位图(避免跨域问题)
  417. const colors = [
  418. { start: '#667eea', end: '#764ba2' },
  419. { start: '#f093fb', end: '#f5576c' },
  420. { start: '#4facfe', end: '#00f2fe' },
  421. { start: '#43e97b', end: '#38f9d7' },
  422. { start: '#fa709a', end: '#fee140' }
  423. ]
  424. const colorIndex = index % colors.length
  425. const { start, end } = colors[colorIndex]
  426. // 使用base64编码避免中文问题
  427. const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300">
  428. <defs>
  429. <linearGradient id="grad${index}" x1="0%" y1="0%" x2="100%" y2="100%">
  430. <stop offset="0%" style="stop-color:${start};stop-opacity:1" />
  431. <stop offset="100%" style="stop-color:${end};stop-opacity:1" />
  432. </linearGradient>
  433. </defs>
  434. <rect width="400" height="300" fill="url(#grad${index})"/>
  435. <text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="sans-serif" font-size="24" font-weight="bold" fill="white">${name}</text>
  436. </svg>`
  437. return `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svg)))}`
  438. }
  439. // 图片加载失败时的处理
  440. const handleImageError = (event: Event) => {
  441. const img = event.target as HTMLImageElement
  442. // 使用灰色占位图
  443. img.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="400" height="300"%3E%3Crect width="400" height="300" fill="%23f0f0f0"/%3E%3Ctext x="50%25" y="50%25" dominant-baseline="middle" text-anchor="middle" font-family="sans-serif" font-size="18" fill="%23999"%3E图片加载失败%3C/text%3E%3C/svg%3E'
  444. }
  445. // 导出为图片
  446. const exportAsImage = async () => {
  447. try {
  448. message.loading({ content: '正在生成图片...', key: 'export', duration: 0 })
  449. const element = document.querySelector('.main-content') as HTMLElement
  450. if (!element) {
  451. throw new Error('未找到内容元素')
  452. }
  453. // 创建一个独立的容器
  454. const exportContainer = document.createElement('div')
  455. exportContainer.style.width = element.offsetWidth + 'px'
  456. exportContainer.style.backgroundColor = '#f5f7fa'
  457. exportContainer.style.padding = '20px'
  458. // 复制所有内容
  459. exportContainer.innerHTML = element.innerHTML
  460. // 处理地图截图
  461. const mapContainer = document.getElementById('amap-container')
  462. if (mapContainer && map) {
  463. const mapCanvas = mapContainer.querySelector('canvas')
  464. if (mapCanvas) {
  465. const mapSnapshot = mapCanvas.toDataURL('image/png')
  466. const exportMapContainer = exportContainer.querySelector('#amap-container')
  467. if (exportMapContainer) {
  468. exportMapContainer.innerHTML = `<img src="${mapSnapshot}" style="width:100%;height:100%;object-fit:cover;" />`
  469. }
  470. }
  471. }
  472. // 移除所有ant-card类,替换为纯div
  473. const cards = exportContainer.querySelectorAll('.ant-card')
  474. cards.forEach((card) => {
  475. const cardEl = card as HTMLElement
  476. try {
  477. cardEl.className = '' // 移除所有类
  478. cardEl.style.setProperty('background-color', '#ffffff')
  479. cardEl.style.setProperty('border-radius', '12px')
  480. cardEl.style.setProperty('box-shadow', '0 4px 12px rgba(0, 0, 0, 0.1)')
  481. cardEl.style.setProperty('margin-bottom', '20px')
  482. cardEl.style.setProperty('overflow', 'hidden')
  483. } catch (err) {
  484. console.error('设置卡片样式失败:', err)
  485. }
  486. })
  487. // 处理卡片头部
  488. const cardHeads = exportContainer.querySelectorAll('.ant-card-head')
  489. cardHeads.forEach((head) => {
  490. const headEl = head as HTMLElement
  491. try {
  492. headEl.style.setProperty('background-color', '#667eea')
  493. headEl.style.setProperty('color', '#ffffff')
  494. headEl.style.setProperty('padding', '16px 24px')
  495. headEl.style.setProperty('font-size', '18px')
  496. headEl.style.setProperty('font-weight', '600')
  497. } catch (err) {
  498. console.error('设置卡片头部样式失败:', err)
  499. }
  500. })
  501. // 处理卡片内容
  502. const cardBodies = exportContainer.querySelectorAll('.ant-card-body')
  503. cardBodies.forEach((body) => {
  504. const bodyEl = body as HTMLElement
  505. bodyEl.style.setProperty('background-color', '#ffffff')
  506. bodyEl.style.setProperty('padding', '24px')
  507. })
  508. // 处理酒店卡片头部
  509. const hotelCards = exportContainer.querySelectorAll('.hotel-card')
  510. hotelCards.forEach((card) => {
  511. const head = card.querySelector('.ant-card-head') as HTMLElement
  512. if (head) {
  513. head.style.setProperty('background-color', '#1976d2')
  514. }
  515. (card as HTMLElement).style.setProperty('background-color', '#e3f2fd')
  516. })
  517. // 处理天气卡片
  518. const weatherCards = exportContainer.querySelectorAll('.weather-card')
  519. weatherCards.forEach((card) => {
  520. (card as HTMLElement).style.setProperty('background-color', '#e0f7fa')
  521. })
  522. // 处理预算总计
  523. const budgetTotal = exportContainer.querySelector('.budget-total')
  524. if (budgetTotal) {
  525. const el = budgetTotal as HTMLElement
  526. el.style.setProperty('background-color', '#667eea')
  527. el.style.setProperty('color', '#ffffff')
  528. el.style.setProperty('padding', '20px')
  529. el.style.setProperty('border-radius', '12px')
  530. el.style.setProperty('margin-bottom', '20px')
  531. }
  532. // 处理预算项
  533. const budgetItems = exportContainer.querySelectorAll('.budget-item')
  534. budgetItems.forEach((item) => {
  535. const el = item as HTMLElement
  536. el.style.setProperty('background-color', '#f5f7fa')
  537. el.style.setProperty('padding', '16px')
  538. el.style.setProperty('border-radius', '8px')
  539. el.style.setProperty('margin-bottom', '12px')
  540. })
  541. // 添加到body(隐藏)
  542. exportContainer.style.position = 'absolute'
  543. exportContainer.style.left = '-9999px'
  544. document.body.appendChild(exportContainer)
  545. const canvas = await html2canvas(exportContainer, {
  546. backgroundColor: '#f5f7fa',
  547. scale: 2,
  548. logging: false,
  549. useCORS: true,
  550. allowTaint: true
  551. })
  552. // 移除容器
  553. document.body.removeChild(exportContainer)
  554. // 转换为图片并下载
  555. const link = document.createElement('a')
  556. link.download = `旅行计划_${tripPlan.value?.city}_${new Date().getTime()}.png`
  557. link.href = canvas.toDataURL('image/png')
  558. link.click()
  559. message.success({ content: '图片导出成功!', key: 'export' })
  560. } catch (error: any) {
  561. console.error('导出图片失败:', error)
  562. message.error({ content: `导出图片失败: ${error.message}`, key: 'export' })
  563. }
  564. }
  565. // 导出为PDF
  566. const exportAsPDF = async () => {
  567. try {
  568. message.loading({ content: '正在生成PDF...', key: 'export', duration: 0 })
  569. const element = document.querySelector('.main-content') as HTMLElement
  570. if (!element) {
  571. throw new Error('未找到内容元素')
  572. }
  573. // 创建一个独立的容器
  574. const exportContainer = document.createElement('div')
  575. exportContainer.style.width = element.offsetWidth + 'px'
  576. exportContainer.style.backgroundColor = '#f5f7fa'
  577. exportContainer.style.padding = '20px'
  578. // 复制所有内容
  579. exportContainer.innerHTML = element.innerHTML
  580. // 处理地图截图
  581. const mapContainer = document.getElementById('amap-container')
  582. if (mapContainer && map) {
  583. const mapCanvas = mapContainer.querySelector('canvas')
  584. if (mapCanvas) {
  585. const mapSnapshot = mapCanvas.toDataURL('image/png')
  586. const exportMapContainer = exportContainer.querySelector('#amap-container')
  587. if (exportMapContainer) {
  588. exportMapContainer.innerHTML = `<img src="${mapSnapshot}" style="width:100%;height:100%;object-fit:cover;" />`
  589. }
  590. }
  591. }
  592. // 移除所有ant-card类,替换为纯div
  593. const cards = exportContainer.querySelectorAll('.ant-card')
  594. cards.forEach((card) => {
  595. const cardEl = card as HTMLElement
  596. try {
  597. cardEl.className = ''
  598. cardEl.style.setProperty('background-color', '#ffffff')
  599. cardEl.style.setProperty('border-radius', '12px')
  600. cardEl.style.setProperty('box-shadow', '0 4px 12px rgba(0, 0, 0, 0.1)')
  601. cardEl.style.setProperty('margin-bottom', '20px')
  602. cardEl.style.setProperty('overflow', 'hidden')
  603. } catch (err) {
  604. console.error('设置卡片样式失败:', err)
  605. }
  606. })
  607. // 处理卡片头部
  608. const cardHeads = exportContainer.querySelectorAll('.ant-card-head')
  609. cardHeads.forEach((head) => {
  610. const headEl = head as HTMLElement
  611. try {
  612. headEl.style.setProperty('background-color', '#667eea')
  613. headEl.style.setProperty('color', '#ffffff')
  614. headEl.style.setProperty('padding', '16px 24px')
  615. headEl.style.setProperty('font-size', '18px')
  616. headEl.style.setProperty('font-weight', '600')
  617. } catch (err) {
  618. console.error('设置卡片头部样式失败:', err)
  619. }
  620. })
  621. // 处理卡片内容
  622. const cardBodies = exportContainer.querySelectorAll('.ant-card-body')
  623. cardBodies.forEach((body) => {
  624. const bodyEl = body as HTMLElement
  625. bodyEl.style.setProperty('background-color', '#ffffff')
  626. bodyEl.style.setProperty('padding', '24px')
  627. })
  628. // 处理酒店卡片头部
  629. const hotelCards = exportContainer.querySelectorAll('.hotel-card')
  630. hotelCards.forEach((card) => {
  631. const head = card.querySelector('.ant-card-head') as HTMLElement
  632. if (head) {
  633. head.style.setProperty('background-color', '#1976d2')
  634. }
  635. (card as HTMLElement).style.setProperty('background-color', '#e3f2fd')
  636. })
  637. // 处理天气卡片
  638. const weatherCards = exportContainer.querySelectorAll('.weather-card')
  639. weatherCards.forEach((card) => {
  640. (card as HTMLElement).style.setProperty('background-color', '#e0f7fa')
  641. })
  642. // 处理预算总计
  643. const budgetTotal = exportContainer.querySelector('.budget-total')
  644. if (budgetTotal) {
  645. const el = budgetTotal as HTMLElement
  646. el.style.setProperty('background-color', '#667eea')
  647. el.style.setProperty('color', '#ffffff')
  648. el.style.setProperty('padding', '20px')
  649. el.style.setProperty('border-radius', '12px')
  650. el.style.setProperty('margin-bottom', '20px')
  651. }
  652. // 处理预算项
  653. const budgetItems = exportContainer.querySelectorAll('.budget-item')
  654. budgetItems.forEach((item) => {
  655. const el = item as HTMLElement
  656. el.style.setProperty('background-color', '#f5f7fa')
  657. el.style.setProperty('padding', '16px')
  658. el.style.setProperty('border-radius', '8px')
  659. el.style.setProperty('margin-bottom', '12px')
  660. })
  661. // 添加到body(隐藏)
  662. exportContainer.style.position = 'absolute'
  663. exportContainer.style.left = '-9999px'
  664. document.body.appendChild(exportContainer)
  665. const canvas = await html2canvas(exportContainer, {
  666. backgroundColor: '#f5f7fa',
  667. scale: 2,
  668. logging: false,
  669. useCORS: true,
  670. allowTaint: true
  671. })
  672. // 移除容器
  673. document.body.removeChild(exportContainer)
  674. const imgData = canvas.toDataURL('image/png')
  675. const pdf = new jsPDF({
  676. orientation: 'portrait',
  677. unit: 'mm',
  678. format: 'a4'
  679. })
  680. const imgWidth = 210 // A4宽度(mm)
  681. const imgHeight = (canvas.height * imgWidth) / canvas.width
  682. // 如果内容高度超过一页,分页处理
  683. let heightLeft = imgHeight
  684. let position = 0
  685. pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)
  686. heightLeft -= 297 // A4高度
  687. while (heightLeft > 0) {
  688. position = heightLeft - imgHeight
  689. pdf.addPage()
  690. pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)
  691. heightLeft -= 297
  692. }
  693. pdf.save(`旅行计划_${tripPlan.value?.city}_${new Date().getTime()}.pdf`)
  694. message.success({ content: 'PDF导出成功!', key: 'export' })
  695. } catch (error: any) {
  696. console.error('导出PDF失败:', error)
  697. message.error({ content: `导出PDF失败: ${error.message}`, key: 'export' })
  698. }
  699. }
  700. // 初始化地图
  701. const initMap = async () => {
  702. try {
  703. const AMap = await AMapLoader.load({
  704. key: import.meta.env.VITE_AMAP_WEB_JS_KEY, // 高德地图Web端(JS API) Key
  705. version: '2.0',
  706. plugins: ['AMap.Marker', 'AMap.Polyline', 'AMap.InfoWindow']
  707. })
  708. // 创建地图实例
  709. map = new AMap.Map('amap-container', {
  710. zoom: 12,
  711. center: [116.397128, 39.916527], // 默认中心点(北京)
  712. viewMode: '3D'
  713. })
  714. // 添加景点标记
  715. addAttractionMarkers(AMap)
  716. message.success('地图加载成功')
  717. } catch (error) {
  718. console.error('地图加载失败:', error)
  719. message.error('地图加载失败')
  720. }
  721. }
  722. // 添加景点标记
  723. const addAttractionMarkers = (AMap: any) => {
  724. if (!tripPlan.value) return
  725. const markers: any[] = []
  726. const allAttractions: any[] = []
  727. // 收集所有景点
  728. tripPlan.value.days.forEach((day, dayIndex) => {
  729. day.attractions.forEach((attraction, attrIndex) => {
  730. if (attraction.location && attraction.location.longitude && attraction.location.latitude) {
  731. allAttractions.push({
  732. ...attraction,
  733. dayIndex,
  734. attrIndex
  735. })
  736. }
  737. })
  738. })
  739. // 创建标记
  740. allAttractions.forEach((attraction, index) => {
  741. const marker = new AMap.Marker({
  742. position: [attraction.location.longitude, attraction.location.latitude],
  743. title: attraction.name,
  744. label: {
  745. content: `<div style="background: #4CAF50; color: white; padding: 4px 8px; border-radius: 4px; font-size: 12px;">${index + 1}</div>`,
  746. offset: new AMap.Pixel(0, -30)
  747. }
  748. })
  749. // 创建信息窗口
  750. const infoWindow = new AMap.InfoWindow({
  751. content: `
  752. <div style="padding: 10px;">
  753. <h4 style="margin: 0 0 8px 0;">${attraction.name}</h4>
  754. <p style="margin: 4px 0;"><strong>地址:</strong> ${attraction.address}</p>
  755. <p style="margin: 4px 0;"><strong>游览时长:</strong> ${attraction.visit_duration}分钟</p>
  756. <p style="margin: 4px 0;"><strong>描述:</strong> ${attraction.description}</p>
  757. <p style="margin: 4px 0; color: #1890ff;"><strong>第${attraction.dayIndex + 1}天 景点${attraction.attrIndex + 1}</strong></p>
  758. </div>
  759. `,
  760. offset: new AMap.Pixel(0, -30)
  761. })
  762. // 点击标记显示信息窗口
  763. marker.on('click', () => {
  764. infoWindow.open(map, marker.getPosition())
  765. })
  766. markers.push(marker)
  767. })
  768. // 添加标记到地图
  769. map.add(markers)
  770. // 自动调整视野以包含所有标记
  771. if (allAttractions.length > 0) {
  772. map.setFitView(markers)
  773. }
  774. // 绘制路线
  775. drawRoutes(AMap, allAttractions)
  776. }
  777. // 绘制路线
  778. const drawRoutes = (AMap: any, attractions: any[]) => {
  779. if (attractions.length < 2) return
  780. // 按天分组绘制路线
  781. const dayGroups: any = {}
  782. attractions.forEach(attr => {
  783. if (!dayGroups[attr.dayIndex]) {
  784. dayGroups[attr.dayIndex] = []
  785. }
  786. dayGroups[attr.dayIndex].push(attr)
  787. })
  788. // 为每天的景点绘制路线
  789. Object.values(dayGroups).forEach((dayAttractions: any) => {
  790. if (dayAttractions.length < 2) return
  791. const path = dayAttractions.map((attr: any) => [
  792. attr.location.longitude,
  793. attr.location.latitude
  794. ])
  795. const polyline = new AMap.Polyline({
  796. path: path,
  797. strokeColor: '#1890ff',
  798. strokeWeight: 4,
  799. strokeOpacity: 0.8,
  800. strokeStyle: 'solid',
  801. showDir: true // 显示方向箭头
  802. })
  803. map.add(polyline)
  804. })
  805. }
  806. </script>
  807. <style scoped>
  808. .result-container {
  809. min-height: 100vh;
  810. background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
  811. padding: 40px 20px;
  812. }
  813. .page-header {
  814. max-width: 1200px;
  815. margin: 0 auto 30px;
  816. display: flex;
  817. justify-content: space-between;
  818. align-items: center;
  819. animation: fadeInDown 0.6s ease-out;
  820. }
  821. .back-button {
  822. border-radius: 8px;
  823. font-weight: 500;
  824. }
  825. /* 内容布局 */
  826. .content-wrapper {
  827. max-width: 1400px;
  828. margin: 0 auto;
  829. display: flex;
  830. gap: 24px;
  831. }
  832. .side-nav {
  833. width: 240px;
  834. flex-shrink: 0;
  835. }
  836. .side-nav :deep(.ant-menu) {
  837. border-radius: 12px;
  838. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
  839. background: white;
  840. }
  841. .side-nav :deep(.ant-menu-item) {
  842. margin: 4px 8px;
  843. border-radius: 8px;
  844. transition: all 0.3s ease;
  845. }
  846. .side-nav :deep(.ant-menu-item-selected) {
  847. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  848. color: white;
  849. }
  850. .side-nav :deep(.ant-menu-item:hover) {
  851. background: rgba(102, 126, 234, 0.1);
  852. }
  853. .main-content {
  854. flex: 1;
  855. min-width: 0;
  856. }
  857. /* 景点图片样式 */
  858. .attraction-image-wrapper {
  859. position: relative;
  860. margin-bottom: 12px;
  861. border-radius: 8px;
  862. overflow: hidden;
  863. }
  864. .attraction-image {
  865. width: 100%;
  866. height: 200px;
  867. object-fit: cover;
  868. transition: transform 0.3s ease;
  869. }
  870. .attraction-image-wrapper:hover .attraction-image {
  871. transform: scale(1.05);
  872. }
  873. .attraction-badge {
  874. position: absolute;
  875. top: 12px;
  876. left: 12px;
  877. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  878. color: white;
  879. width: 36px;
  880. height: 36px;
  881. border-radius: 50%;
  882. display: flex;
  883. align-items: center;
  884. justify-content: center;
  885. font-weight: bold;
  886. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
  887. }
  888. .badge-number {
  889. font-size: 18px;
  890. }
  891. .price-tag {
  892. position: absolute;
  893. top: 12px;
  894. right: 12px;
  895. background: rgba(255, 77, 79, 0.9);
  896. color: white;
  897. padding: 4px 12px;
  898. border-radius: 12px;
  899. font-weight: bold;
  900. font-size: 14px;
  901. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
  902. }
  903. /* 天气卡片样式 */
  904. .weather-card {
  905. background: linear-gradient(135deg, #e0f7fa 0%, #b2ebf2 100%);
  906. border: none !important;
  907. transition: all 0.3s ease;
  908. }
  909. .weather-card:hover {
  910. transform: translateY(-4px);
  911. box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
  912. }
  913. .weather-date {
  914. font-size: 16px;
  915. font-weight: bold;
  916. color: #00796b;
  917. margin-bottom: 12px;
  918. text-align: center;
  919. }
  920. .weather-info-row {
  921. display: flex;
  922. align-items: center;
  923. gap: 12px;
  924. margin-bottom: 8px;
  925. }
  926. .weather-icon {
  927. font-size: 24px;
  928. }
  929. .weather-label {
  930. font-size: 12px;
  931. color: #666;
  932. }
  933. .weather-value {
  934. font-size: 16px;
  935. font-weight: 600;
  936. color: #00796b;
  937. }
  938. .weather-wind {
  939. margin-top: 8px;
  940. padding-top: 8px;
  941. border-top: 1px solid rgba(0, 121, 107, 0.2);
  942. text-align: center;
  943. color: #00796b;
  944. font-size: 14px;
  945. }
  946. /* 回到顶部按钮 */
  947. .back-top-button {
  948. width: 50px;
  949. height: 50px;
  950. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  951. color: white;
  952. border-radius: 50%;
  953. display: flex;
  954. align-items: center;
  955. justify-content: center;
  956. font-size: 24px;
  957. font-weight: bold;
  958. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
  959. cursor: pointer;
  960. transition: all 0.3s ease;
  961. }
  962. .back-top-button:hover {
  963. transform: scale(1.1);
  964. box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
  965. }
  966. /* 酒店卡片样式 */
  967. .hotel-card {
  968. background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
  969. border: none !important;
  970. }
  971. .hotel-card :deep(.ant-card-head) {
  972. background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
  973. }
  974. .hotel-title {
  975. color: white !important;
  976. font-weight: 600;
  977. }
  978. /* 顶部信息区布局 */
  979. .top-info-section {
  980. display: flex;
  981. gap: 20px;
  982. margin-bottom: 20px;
  983. }
  984. .left-info {
  985. flex: 0 0 400px;
  986. display: flex;
  987. flex-direction: column;
  988. gap: 20px;
  989. }
  990. .right-map {
  991. flex: 1;
  992. }
  993. /* 行程概览卡片 */
  994. .overview-card {
  995. height: fit-content;
  996. }
  997. .overview-content {
  998. display: flex;
  999. flex-direction: column;
  1000. gap: 12px;
  1001. }
  1002. .info-item {
  1003. display: flex;
  1004. flex-direction: column;
  1005. gap: 4px;
  1006. }
  1007. .info-label {
  1008. font-size: 14px;
  1009. font-weight: 600;
  1010. color: #666;
  1011. }
  1012. .info-value {
  1013. font-size: 15px;
  1014. color: #333;
  1015. line-height: 1.6;
  1016. }
  1017. /* 预算卡片 */
  1018. .budget-card {
  1019. height: fit-content;
  1020. }
  1021. .budget-grid {
  1022. display: grid;
  1023. grid-template-columns: repeat(2, 1fr);
  1024. gap: 16px;
  1025. margin-bottom: 16px;
  1026. }
  1027. .budget-item {
  1028. text-align: center;
  1029. padding: 12px;
  1030. background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
  1031. border-radius: 8px;
  1032. border: 1px solid #e8e8e8;
  1033. }
  1034. .budget-label {
  1035. font-size: 13px;
  1036. color: #666;
  1037. margin-bottom: 8px;
  1038. }
  1039. .budget-value {
  1040. font-size: 20px;
  1041. font-weight: 700;
  1042. color: #1890ff;
  1043. }
  1044. .budget-total {
  1045. display: flex;
  1046. justify-content: space-between;
  1047. align-items: center;
  1048. padding: 16px;
  1049. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  1050. border-radius: 8px;
  1051. color: white;
  1052. }
  1053. .total-label {
  1054. font-size: 16px;
  1055. font-weight: 600;
  1056. }
  1057. .total-value {
  1058. font-size: 28px;
  1059. font-weight: 700;
  1060. }
  1061. /* 地图卡片 */
  1062. .map-card {
  1063. height: 100%;
  1064. min-height: 500px;
  1065. }
  1066. .map-card :deep(.ant-card-body) {
  1067. height: calc(100% - 57px);
  1068. padding: 0;
  1069. }
  1070. /* 每日行程卡片 */
  1071. .days-card {
  1072. margin-top: 20px;
  1073. }
  1074. .day-header {
  1075. display: flex;
  1076. justify-content: space-between;
  1077. align-items: center;
  1078. width: 100%;
  1079. }
  1080. .day-title {
  1081. font-size: 18px;
  1082. font-weight: 600;
  1083. color: #333;
  1084. }
  1085. .day-date {
  1086. font-size: 14px;
  1087. color: #999;
  1088. }
  1089. .day-info {
  1090. margin-bottom: 20px;
  1091. padding: 16px;
  1092. background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
  1093. border-radius: 8px;
  1094. border: 1px solid #e8e8e8;
  1095. }
  1096. .info-row {
  1097. display: flex;
  1098. gap: 12px;
  1099. margin-bottom: 8px;
  1100. }
  1101. .info-row:last-child {
  1102. margin-bottom: 0;
  1103. }
  1104. .info-row .label {
  1105. font-weight: 600;
  1106. color: #666;
  1107. min-width: 100px;
  1108. }
  1109. .info-row .value {
  1110. color: #333;
  1111. flex: 1;
  1112. }
  1113. /* 卡片样式优化 */
  1114. :deep(.ant-card) {
  1115. border-radius: 12px;
  1116. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
  1117. margin-bottom: 20px;
  1118. transition: all 0.3s ease;
  1119. animation: fadeInUp 0.6s ease-out;
  1120. }
  1121. :deep(.ant-card:hover) {
  1122. box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
  1123. }
  1124. :deep(.ant-card-head) {
  1125. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  1126. color: white !important;
  1127. border-radius: 12px 12px 0 0;
  1128. font-weight: 600;
  1129. }
  1130. :deep(.ant-card-head-title) {
  1131. color: white !important;
  1132. font-size: 18px;
  1133. }
  1134. :deep(.ant-card-head-title span) {
  1135. color: white !important;
  1136. }
  1137. /* Collapse样式 */
  1138. :deep(.ant-collapse) {
  1139. border: none;
  1140. background: transparent;
  1141. }
  1142. :deep(.ant-collapse-item) {
  1143. margin-bottom: 16px;
  1144. border: 1px solid #e8e8e8;
  1145. border-radius: 12px;
  1146. overflow: hidden;
  1147. }
  1148. :deep(.ant-collapse-header) {
  1149. background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
  1150. padding: 16px 20px !important;
  1151. font-weight: 600;
  1152. }
  1153. :deep(.ant-collapse-content) {
  1154. border-top: 1px solid #e8e8e8;
  1155. }
  1156. :deep(.ant-collapse-content-box) {
  1157. padding: 20px;
  1158. }
  1159. /* 统计卡片样式 */
  1160. :deep(.ant-statistic-title) {
  1161. font-size: 14px;
  1162. color: #666;
  1163. margin-bottom: 8px;
  1164. }
  1165. :deep(.ant-statistic-content) {
  1166. font-size: 24px;
  1167. font-weight: 600;
  1168. color: #1890ff;
  1169. }
  1170. /* 景点卡片样式 */
  1171. :deep(.ant-list-item) {
  1172. transition: all 0.3s ease;
  1173. }
  1174. :deep(.ant-list-item:hover) {
  1175. transform: scale(1.02);
  1176. }
  1177. /* 动画 */
  1178. @keyframes fadeInDown {
  1179. from {
  1180. opacity: 0;
  1181. transform: translateY(-20px);
  1182. }
  1183. to {
  1184. opacity: 1;
  1185. transform: translateY(0);
  1186. }
  1187. }
  1188. @keyframes fadeInUp {
  1189. from {
  1190. opacity: 0;
  1191. transform: translateY(20px);
  1192. }
  1193. to {
  1194. opacity: 1;
  1195. transform: translateY(0);
  1196. }
  1197. }
  1198. /* 响应式设计 */
  1199. @media (max-width: 768px) {
  1200. .result-container {
  1201. padding: 20px 10px;
  1202. }
  1203. .page-header {
  1204. flex-direction: column;
  1205. gap: 16px;
  1206. }
  1207. }
  1208. </style>