| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434 |
- <template>
- <div class="result-container">
- <!-- 页面头部 -->
- <div class="page-header">
- <a-button class="back-button" size="large" @click="goBack">
- ← 返回首页
- </a-button>
- <a-space size="middle">
- <a-button v-if="!editMode" @click="toggleEditMode" type="default">
- ✏️ 编辑行程
- </a-button>
- <a-button v-else @click="saveChanges" type="primary">
- 💾 保存修改
- </a-button>
- <a-button v-if="editMode" @click="cancelEdit" type="default">
- ❌ 取消编辑
- </a-button>
- <!-- 导出按钮 -->
- <a-dropdown v-if="!editMode">
- <template #overlay>
- <a-menu>
- <a-menu-item key="image" @click="exportAsImage">
- 📷 导出为图片
- </a-menu-item>
- <a-menu-item key="pdf" @click="exportAsPDF">
- 📄 导出为PDF
- </a-menu-item>
- </a-menu>
- </template>
- <a-button type="default">
- 📥 导出行程 <DownOutlined />
- </a-button>
- </a-dropdown>
- </a-space>
- </div>
- <div v-if="tripPlan" class="content-wrapper">
- <!-- 侧边导航 -->
- <div class="side-nav">
- <a-affix :offset-top="80">
- <a-menu mode="inline" :selected-keys="[activeSection]" @click="scrollToSection">
- <a-menu-item key="overview">
- <span>📋 行程概览</span>
- </a-menu-item>
- <a-menu-item key="budget" v-if="tripPlan.budget">
- <span>💰 预算明细</span>
- </a-menu-item>
- <a-menu-item key="map">
- <span>📍 景点地图</span>
- </a-menu-item>
- <a-sub-menu key="days" title="📅 每日行程">
- <a-menu-item v-for="(day, index) in tripPlan.days" :key="`day-${index}`">
- 第{{ day.day_index + 1 }}天
- </a-menu-item>
- </a-sub-menu>
- <a-menu-item key="weather" v-if="tripPlan.weather_info && tripPlan.weather_info.length > 0">
- <span>🌤️ 天气信息</span>
- </a-menu-item>
- </a-menu>
- </a-affix>
- </div>
- <!-- 主内容区 -->
- <div class="main-content">
- <!-- 顶部信息区:左侧概览+预算,右侧地图 -->
- <div class="top-info-section">
- <!-- 左侧:行程概览和预算明细 -->
- <div class="left-info">
- <!-- 行程概览 -->
- <a-card id="overview" :title="`${tripPlan.city}旅行计划`" :bordered="false" class="overview-card">
- <div class="overview-content">
- <div class="info-item">
- <span class="info-label">📅 日期:</span>
- <span class="info-value">{{ tripPlan.start_date }} 至 {{ tripPlan.end_date }}</span>
- </div>
- <div class="info-item">
- <span class="info-label">💡 建议:</span>
- <span class="info-value">{{ tripPlan.overall_suggestions }}</span>
- </div>
- </div>
- </a-card>
- <!-- 预算明细 -->
- <a-card id="budget" v-if="tripPlan.budget" title="💰 预算明细" :bordered="false" class="budget-card">
- <div class="budget-grid">
- <div class="budget-item">
- <div class="budget-label">景点门票</div>
- <div class="budget-value">¥{{ tripPlan.budget.total_attractions }}</div>
- </div>
- <div class="budget-item">
- <div class="budget-label">酒店住宿</div>
- <div class="budget-value">¥{{ tripPlan.budget.total_hotels }}</div>
- </div>
- <div class="budget-item">
- <div class="budget-label">餐饮费用</div>
- <div class="budget-value">¥{{ tripPlan.budget.total_meals }}</div>
- </div>
- <div class="budget-item">
- <div class="budget-label">交通费用</div>
- <div class="budget-value">¥{{ tripPlan.budget.total_transportation }}</div>
- </div>
- </div>
- <div class="budget-total">
- <span class="total-label">预估总费用</span>
- <span class="total-value">¥{{ tripPlan.budget.total }}</span>
- </div>
- </a-card>
- </div>
- <!-- 右侧:地图 -->
- <div class="right-map">
- <a-card id="map" title="📍 景点地图" :bordered="false" class="map-card">
- <div id="amap-container" style="width: 100%; height: 100%"></div>
- </a-card>
- </div>
- </div>
- <!-- 每日行程:可折叠 -->
- <a-card title="📅 每日行程" :bordered="false" class="days-card">
- <a-collapse v-model:activeKey="activeDays" accordion>
- <a-collapse-panel
- v-for="(day, index) in tripPlan.days"
- :key="index"
- :id="`day-${index}`"
- >
- <template #header>
- <div class="day-header">
- <span class="day-title">第{{ day.day_index + 1 }}天</span>
- <span class="day-date">{{ day.date }}</span>
- </div>
- </template>
- <!-- 行程基本信息 -->
- <div class="day-info">
- <div class="info-row">
- <span class="label">📝 行程描述:</span>
- <span class="value">{{ day.description }}</span>
- </div>
- <div class="info-row">
- <span class="label">🚗 交通方式:</span>
- <span class="value">{{ day.transportation }}</span>
- </div>
- <div class="info-row">
- <span class="label">🏨 住宿:</span>
- <span class="value">{{ day.accommodation }}</span>
- </div>
- </div>
- <!-- 景点安排 -->
- <a-divider orientation="left">🎯 景点安排</a-divider>
- <a-list
- :data-source="day.attractions"
- :grid="{ gutter: 16, column: 2 }"
- >
- <template #renderItem="{ item, index }">
- <a-list-item>
- <a-card :title="item.name" size="small" class="attraction-card">
- <!-- 编辑模式下的操作按钮 -->
- <template #extra v-if="editMode">
- <a-space>
- <a-button
- size="small"
- @click="moveAttraction(day.day_index, index, 'up')"
- :disabled="index === 0"
- >
- ↑
- </a-button>
- <a-button
- size="small"
- @click="moveAttraction(day.day_index, index, 'down')"
- :disabled="index === day.attractions.length - 1"
- >
- ↓
- </a-button>
- <a-button
- size="small"
- danger
- @click="deleteAttraction(day.day_index, index)"
- >
- 🗑️
- </a-button>
- </a-space>
- </template>
- <!-- 景点图片 -->
- <div class="attraction-image-wrapper">
- <img
- :src="getAttractionImage(item.name, index)"
- :alt="item.name"
- class="attraction-image"
- @error="handleImageError"
- />
- <div class="attraction-badge">
- <span class="badge-number">{{ index + 1 }}</span>
- </div>
- <div v-if="item.ticket_price" class="price-tag">
- ¥{{ item.ticket_price }}
- </div>
- </div>
- <!-- 编辑模式下可编辑的字段 -->
- <div v-if="editMode">
- <p><strong>地址:</strong></p>
- <a-input v-model:value="item.address" size="small" style="margin-bottom: 8px" />
- <p><strong>游览时长(分钟):</strong></p>
- <a-input-number v-model:value="item.visit_duration" :min="10" :max="480" size="small" style="width: 100%; margin-bottom: 8px" />
- <p><strong>描述:</strong></p>
- <a-textarea v-model:value="item.description" :rows="2" size="small" style="margin-bottom: 8px" />
- </div>
- <!-- 查看模式 -->
- <div v-else>
- <p><strong>地址:</strong> {{ item.address }}</p>
- <p><strong>游览时长:</strong> {{ item.visit_duration }}分钟</p>
- <p><strong>描述:</strong> {{ item.description }}</p>
- <p v-if="item.rating"><strong>评分:</strong> {{ item.rating }}⭐</p>
- </div>
- </a-card>
- </a-list-item>
- </template>
- </a-list>
- <!-- 酒店推荐 -->
- <a-divider v-if="day.hotel" orientation="left">🏨 住宿推荐</a-divider>
- <a-card v-if="day.hotel" size="small" class="hotel-card">
- <template #title>
- <span class="hotel-title">{{ day.hotel.name }}</span>
- </template>
- <a-descriptions :column="2" size="small">
- <a-descriptions-item label="地址">{{ day.hotel.address }}</a-descriptions-item>
- <a-descriptions-item label="类型">{{ day.hotel.type }}</a-descriptions-item>
- <a-descriptions-item label="价格范围">{{ day.hotel.price_range }}</a-descriptions-item>
- <a-descriptions-item label="评分">{{ day.hotel.rating }}⭐</a-descriptions-item>
- <a-descriptions-item label="距离" :span="2">{{ day.hotel.distance }}</a-descriptions-item>
- </a-descriptions>
- </a-card>
- <!-- 餐饮安排 -->
- <a-divider orientation="left">🍽️ 餐饮安排</a-divider>
- <a-descriptions :column="1" bordered size="small">
- <a-descriptions-item
- v-for="meal in day.meals"
- :key="meal.type"
- :label="getMealLabel(meal.type)"
- >
- {{ meal.name }}
- <span v-if="meal.description"> - {{ meal.description }}</span>
- </a-descriptions-item>
- </a-descriptions>
- </a-collapse-panel>
- </a-collapse>
- </a-card>
- <a-card id="weather" v-if="tripPlan.weather_info && tripPlan.weather_info.length > 0" title="天气信息" style="margin-top: 20px" :bordered="false">
- <a-list
- :data-source="tripPlan.weather_info"
- :grid="{ gutter: 16, column: 3 }"
- >
- <template #renderItem="{ item }">
- <a-list-item>
- <a-card size="small" class="weather-card">
- <div class="weather-date">{{ item.date }}</div>
- <div class="weather-info-row">
- <span class="weather-icon">☀️</span>
- <div>
- <div class="weather-label">白天</div>
- <div class="weather-value">{{ item.day_weather }} {{ item.day_temp }}°C</div>
- </div>
- </div>
- <div class="weather-info-row">
- <span class="weather-icon">🌙</span>
- <div>
- <div class="weather-label">夜间</div>
- <div class="weather-value">{{ item.night_weather }} {{ item.night_temp }}°C</div>
- </div>
- </div>
- <div class="weather-wind">
- 💨 {{ item.wind_direction }} {{ item.wind_power }}
- </div>
- </a-card>
- </a-list-item>
- </template>
- </a-list>
- </a-card>
- </div>
- </div>
- <a-empty v-else description="没有找到旅行计划数据">
- <template #image>
- <div style="font-size: 80px;">🗺️</div>
- </template>
- <template #description>
- <span style="color: #999;">暂无旅行计划数据,请先创建行程</span>
- </template>
- <a-button type="primary" @click="goBack">返回首页创建行程</a-button>
- </a-empty>
- <!-- 回到顶部按钮 -->
- <a-back-top :visibility-height="300">
- <div class="back-top-button">
- ↑
- </div>
- </a-back-top>
- </div>
- </template>
- <script setup lang="ts">
- import { ref, onMounted, nextTick } from 'vue'
- import { useRouter } from 'vue-router'
- import { message } from 'ant-design-vue'
- import { DownOutlined } from '@ant-design/icons-vue'
- import AMapLoader from '@amap/amap-jsapi-loader'
- import html2canvas from 'html2canvas'
- import jsPDF from 'jspdf'
- import type { TripPlan } from '@/types'
- const router = useRouter()
- const tripPlan = ref<TripPlan | null>(null)
- const editMode = ref(false)
- const originalPlan = ref<TripPlan | null>(null)
- const attractionPhotos = ref<Record<string, string>>({})
- const activeSection = ref('overview')
- const activeDays = ref<number[]>([0]) // 默认展开第一天
- let map: any = null
- onMounted(async () => {
- const data = sessionStorage.getItem('tripPlan')
- if (data) {
- tripPlan.value = JSON.parse(data)
- // 加载景点图片
- await loadAttractionPhotos()
- // 等待DOM渲染完成后初始化地图
- await nextTick()
- initMap()
- }
- })
- const goBack = () => {
- router.push('/')
- }
- // 滚动到指定区域
- const scrollToSection = ({ key }: { key: string }) => {
- activeSection.value = key
- const element = document.getElementById(key)
- if (element) {
- element.scrollIntoView({ behavior: 'smooth', block: 'start' })
- }
- }
- // 切换编辑模式
- const toggleEditMode = () => {
- editMode.value = true
- // 保存原始数据用于取消编辑
- originalPlan.value = JSON.parse(JSON.stringify(tripPlan.value))
- message.info('进入编辑模式')
- }
- // 保存修改
- const saveChanges = () => {
- editMode.value = false
- // 更新sessionStorage
- if (tripPlan.value) {
- sessionStorage.setItem('tripPlan', JSON.stringify(tripPlan.value))
- }
- message.success('修改已保存')
- // 重新初始化地图以反映更改
- if (map) {
- map.destroy()
- }
- nextTick(() => {
- initMap()
- })
- }
- // 取消编辑
- const cancelEdit = () => {
- if (originalPlan.value) {
- tripPlan.value = JSON.parse(JSON.stringify(originalPlan.value))
- }
- editMode.value = false
- message.info('已取消编辑')
- }
- // 删除景点
- const deleteAttraction = (dayIndex: number, attrIndex: number) => {
- if (!tripPlan.value) return
- const day = tripPlan.value.days[dayIndex]
- if (day.attractions.length <= 1) {
- message.warning('每天至少需要保留一个景点')
- return
- }
- day.attractions.splice(attrIndex, 1)
- message.success('景点已删除')
- }
- // 移动景点顺序
- const moveAttraction = (dayIndex: number, attrIndex: number, direction: 'up' | 'down') => {
- if (!tripPlan.value) return
- const day = tripPlan.value.days[dayIndex]
- const attractions = day.attractions
- if (direction === 'up' && attrIndex > 0) {
- [attractions[attrIndex], attractions[attrIndex - 1]] = [attractions[attrIndex - 1], attractions[attrIndex]]
- } else if (direction === 'down' && attrIndex < attractions.length - 1) {
- [attractions[attrIndex], attractions[attrIndex + 1]] = [attractions[attrIndex + 1], attractions[attrIndex]]
- }
- }
- const getMealLabel = (type: string): string => {
- const labels: Record<string, string> = {
- breakfast: '早餐',
- lunch: '午餐',
- dinner: '晚餐',
- snack: '小吃'
- }
- return labels[type] || type
- }
- // 加载所有景点图片
- const loadAttractionPhotos = async () => {
- if (!tripPlan.value) return
- const promises: Promise<void>[] = []
- tripPlan.value.days.forEach(day => {
- day.attractions.forEach(attraction => {
- const promise = fetch(`http://localhost:8000/api/poi/photo?name=${encodeURIComponent(attraction.name)}`)
- .then(res => res.json())
- .then(data => {
- if (data.success && data.data.photo_url) {
- attractionPhotos.value[attraction.name] = data.data.photo_url
- }
- })
- .catch(err => {
- console.error(`获取${attraction.name}图片失败:`, err)
- })
- promises.push(promise)
- })
- })
- await Promise.all(promises)
- }
- // 获取景点图片
- const getAttractionImage = (name: string, index: number): string => {
- // 如果已加载真实图片,返回真实图片
- if (attractionPhotos.value[name]) {
- return attractionPhotos.value[name]
- }
- // 返回一个纯色占位图(避免跨域问题)
- const colors = [
- { start: '#667eea', end: '#764ba2' },
- { start: '#f093fb', end: '#f5576c' },
- { start: '#4facfe', end: '#00f2fe' },
- { start: '#43e97b', end: '#38f9d7' },
- { start: '#fa709a', end: '#fee140' }
- ]
- const colorIndex = index % colors.length
- const { start, end } = colors[colorIndex]
- // 使用base64编码避免中文问题
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300">
- <defs>
- <linearGradient id="grad${index}" x1="0%" y1="0%" x2="100%" y2="100%">
- <stop offset="0%" style="stop-color:${start};stop-opacity:1" />
- <stop offset="100%" style="stop-color:${end};stop-opacity:1" />
- </linearGradient>
- </defs>
- <rect width="400" height="300" fill="url(#grad${index})"/>
- <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>
- </svg>`
- return `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svg)))}`
- }
- // 图片加载失败时的处理
- const handleImageError = (event: Event) => {
- const img = event.target as HTMLImageElement
- // 使用灰色占位图
- 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'
- }
- // 导出为图片
- const exportAsImage = async () => {
- try {
- message.loading({ content: '正在生成图片...', key: 'export', duration: 0 })
- const element = document.querySelector('.main-content') as HTMLElement
- if (!element) {
- throw new Error('未找到内容元素')
- }
- // 创建一个独立的容器
- const exportContainer = document.createElement('div')
- exportContainer.style.width = element.offsetWidth + 'px'
- exportContainer.style.backgroundColor = '#f5f7fa'
- exportContainer.style.padding = '20px'
- // 复制所有内容
- exportContainer.innerHTML = element.innerHTML
- // 处理地图截图
- const mapContainer = document.getElementById('amap-container')
- if (mapContainer && map) {
- const mapCanvas = mapContainer.querySelector('canvas')
- if (mapCanvas) {
- const mapSnapshot = mapCanvas.toDataURL('image/png')
- const exportMapContainer = exportContainer.querySelector('#amap-container')
- if (exportMapContainer) {
- exportMapContainer.innerHTML = `<img src="${mapSnapshot}" style="width:100%;height:100%;object-fit:cover;" />`
- }
- }
- }
- // 移除所有ant-card类,替换为纯div
- const cards = exportContainer.querySelectorAll('.ant-card')
- cards.forEach((card) => {
- const cardEl = card as HTMLElement
- try {
- cardEl.className = '' // 移除所有类
- cardEl.style.setProperty('background-color', '#ffffff')
- cardEl.style.setProperty('border-radius', '12px')
- cardEl.style.setProperty('box-shadow', '0 4px 12px rgba(0, 0, 0, 0.1)')
- cardEl.style.setProperty('margin-bottom', '20px')
- cardEl.style.setProperty('overflow', 'hidden')
- } catch (err) {
- console.error('设置卡片样式失败:', err)
- }
- })
- // 处理卡片头部
- const cardHeads = exportContainer.querySelectorAll('.ant-card-head')
- cardHeads.forEach((head) => {
- const headEl = head as HTMLElement
- try {
- headEl.style.setProperty('background-color', '#667eea')
- headEl.style.setProperty('color', '#ffffff')
- headEl.style.setProperty('padding', '16px 24px')
- headEl.style.setProperty('font-size', '18px')
- headEl.style.setProperty('font-weight', '600')
- } catch (err) {
- console.error('设置卡片头部样式失败:', err)
- }
- })
- // 处理卡片内容
- const cardBodies = exportContainer.querySelectorAll('.ant-card-body')
- cardBodies.forEach((body) => {
- const bodyEl = body as HTMLElement
- bodyEl.style.setProperty('background-color', '#ffffff')
- bodyEl.style.setProperty('padding', '24px')
- })
- // 处理酒店卡片头部
- const hotelCards = exportContainer.querySelectorAll('.hotel-card')
- hotelCards.forEach((card) => {
- const head = card.querySelector('.ant-card-head') as HTMLElement
- if (head) {
- head.style.setProperty('background-color', '#1976d2')
- }
- (card as HTMLElement).style.setProperty('background-color', '#e3f2fd')
- })
- // 处理天气卡片
- const weatherCards = exportContainer.querySelectorAll('.weather-card')
- weatherCards.forEach((card) => {
- (card as HTMLElement).style.setProperty('background-color', '#e0f7fa')
- })
- // 处理预算总计
- const budgetTotal = exportContainer.querySelector('.budget-total')
- if (budgetTotal) {
- const el = budgetTotal as HTMLElement
- el.style.setProperty('background-color', '#667eea')
- el.style.setProperty('color', '#ffffff')
- el.style.setProperty('padding', '20px')
- el.style.setProperty('border-radius', '12px')
- el.style.setProperty('margin-bottom', '20px')
- }
- // 处理预算项
- const budgetItems = exportContainer.querySelectorAll('.budget-item')
- budgetItems.forEach((item) => {
- const el = item as HTMLElement
- el.style.setProperty('background-color', '#f5f7fa')
- el.style.setProperty('padding', '16px')
- el.style.setProperty('border-radius', '8px')
- el.style.setProperty('margin-bottom', '12px')
- })
- // 添加到body(隐藏)
- exportContainer.style.position = 'absolute'
- exportContainer.style.left = '-9999px'
- document.body.appendChild(exportContainer)
- const canvas = await html2canvas(exportContainer, {
- backgroundColor: '#f5f7fa',
- scale: 2,
- logging: false,
- useCORS: true,
- allowTaint: true
- })
- // 移除容器
- document.body.removeChild(exportContainer)
- // 转换为图片并下载
- const link = document.createElement('a')
- link.download = `旅行计划_${tripPlan.value?.city}_${new Date().getTime()}.png`
- link.href = canvas.toDataURL('image/png')
- link.click()
- message.success({ content: '图片导出成功!', key: 'export' })
- } catch (error: any) {
- console.error('导出图片失败:', error)
- message.error({ content: `导出图片失败: ${error.message}`, key: 'export' })
- }
- }
- // 导出为PDF
- const exportAsPDF = async () => {
- try {
- message.loading({ content: '正在生成PDF...', key: 'export', duration: 0 })
- const element = document.querySelector('.main-content') as HTMLElement
- if (!element) {
- throw new Error('未找到内容元素')
- }
- // 创建一个独立的容器
- const exportContainer = document.createElement('div')
- exportContainer.style.width = element.offsetWidth + 'px'
- exportContainer.style.backgroundColor = '#f5f7fa'
- exportContainer.style.padding = '20px'
- // 复制所有内容
- exportContainer.innerHTML = element.innerHTML
- // 处理地图截图
- const mapContainer = document.getElementById('amap-container')
- if (mapContainer && map) {
- const mapCanvas = mapContainer.querySelector('canvas')
- if (mapCanvas) {
- const mapSnapshot = mapCanvas.toDataURL('image/png')
- const exportMapContainer = exportContainer.querySelector('#amap-container')
- if (exportMapContainer) {
- exportMapContainer.innerHTML = `<img src="${mapSnapshot}" style="width:100%;height:100%;object-fit:cover;" />`
- }
- }
- }
- // 移除所有ant-card类,替换为纯div
- const cards = exportContainer.querySelectorAll('.ant-card')
- cards.forEach((card) => {
- const cardEl = card as HTMLElement
- try {
- cardEl.className = ''
- cardEl.style.setProperty('background-color', '#ffffff')
- cardEl.style.setProperty('border-radius', '12px')
- cardEl.style.setProperty('box-shadow', '0 4px 12px rgba(0, 0, 0, 0.1)')
- cardEl.style.setProperty('margin-bottom', '20px')
- cardEl.style.setProperty('overflow', 'hidden')
- } catch (err) {
- console.error('设置卡片样式失败:', err)
- }
- })
- // 处理卡片头部
- const cardHeads = exportContainer.querySelectorAll('.ant-card-head')
- cardHeads.forEach((head) => {
- const headEl = head as HTMLElement
- try {
- headEl.style.setProperty('background-color', '#667eea')
- headEl.style.setProperty('color', '#ffffff')
- headEl.style.setProperty('padding', '16px 24px')
- headEl.style.setProperty('font-size', '18px')
- headEl.style.setProperty('font-weight', '600')
- } catch (err) {
- console.error('设置卡片头部样式失败:', err)
- }
- })
- // 处理卡片内容
- const cardBodies = exportContainer.querySelectorAll('.ant-card-body')
- cardBodies.forEach((body) => {
- const bodyEl = body as HTMLElement
- bodyEl.style.setProperty('background-color', '#ffffff')
- bodyEl.style.setProperty('padding', '24px')
- })
- // 处理酒店卡片头部
- const hotelCards = exportContainer.querySelectorAll('.hotel-card')
- hotelCards.forEach((card) => {
- const head = card.querySelector('.ant-card-head') as HTMLElement
- if (head) {
- head.style.setProperty('background-color', '#1976d2')
- }
- (card as HTMLElement).style.setProperty('background-color', '#e3f2fd')
- })
- // 处理天气卡片
- const weatherCards = exportContainer.querySelectorAll('.weather-card')
- weatherCards.forEach((card) => {
- (card as HTMLElement).style.setProperty('background-color', '#e0f7fa')
- })
- // 处理预算总计
- const budgetTotal = exportContainer.querySelector('.budget-total')
- if (budgetTotal) {
- const el = budgetTotal as HTMLElement
- el.style.setProperty('background-color', '#667eea')
- el.style.setProperty('color', '#ffffff')
- el.style.setProperty('padding', '20px')
- el.style.setProperty('border-radius', '12px')
- el.style.setProperty('margin-bottom', '20px')
- }
- // 处理预算项
- const budgetItems = exportContainer.querySelectorAll('.budget-item')
- budgetItems.forEach((item) => {
- const el = item as HTMLElement
- el.style.setProperty('background-color', '#f5f7fa')
- el.style.setProperty('padding', '16px')
- el.style.setProperty('border-radius', '8px')
- el.style.setProperty('margin-bottom', '12px')
- })
- // 添加到body(隐藏)
- exportContainer.style.position = 'absolute'
- exportContainer.style.left = '-9999px'
- document.body.appendChild(exportContainer)
- const canvas = await html2canvas(exportContainer, {
- backgroundColor: '#f5f7fa',
- scale: 2,
- logging: false,
- useCORS: true,
- allowTaint: true
- })
- // 移除容器
- document.body.removeChild(exportContainer)
- const imgData = canvas.toDataURL('image/png')
- const pdf = new jsPDF({
- orientation: 'portrait',
- unit: 'mm',
- format: 'a4'
- })
- const imgWidth = 210 // A4宽度(mm)
- const imgHeight = (canvas.height * imgWidth) / canvas.width
- // 如果内容高度超过一页,分页处理
- let heightLeft = imgHeight
- let position = 0
- pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)
- heightLeft -= 297 // A4高度
- while (heightLeft > 0) {
- position = heightLeft - imgHeight
- pdf.addPage()
- pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)
- heightLeft -= 297
- }
- pdf.save(`旅行计划_${tripPlan.value?.city}_${new Date().getTime()}.pdf`)
- message.success({ content: 'PDF导出成功!', key: 'export' })
- } catch (error: any) {
- console.error('导出PDF失败:', error)
- message.error({ content: `导出PDF失败: ${error.message}`, key: 'export' })
- }
- }
- // 截取地图图片
- const captureMapImage = async () => {
- if (!map) return
- try {
- // 获取地图容器
- const mapContainer = document.getElementById('amap-container')
- if (!mapContainer) return
- // 使用高德地图的截图功能
- const mapCanvas = mapContainer.querySelector('canvas')
- if (mapCanvas) {
- // 创建一个img元素替换地图容器
- const img = document.createElement('img')
- img.src = mapCanvas.toDataURL('image/png')
- img.style.width = '100%'
- img.style.height = '500px'
- img.style.objectFit = 'cover'
- img.id = 'map-snapshot'
- // 隐藏原地图,显示截图
- mapContainer.style.display = 'none'
- mapContainer.parentElement?.appendChild(img)
- }
- } catch (error) {
- console.error('截取地图失败:', error)
- }
- }
- // 恢复地图
- const restoreMap = () => {
- const mapContainer = document.getElementById('amap-container')
- const snapshot = document.getElementById('map-snapshot')
- if (mapContainer) {
- mapContainer.style.display = 'block'
- }
- if (snapshot) {
- snapshot.remove()
- }
- }
- // 初始化地图
- const initMap = async () => {
- try {
- const AMap = await AMapLoader.load({
- key: import.meta.env.VITE_AMAP_WEB_JS_KEY, // 高德地图Web端(JS API) Key
- version: '2.0',
- plugins: ['AMap.Marker', 'AMap.Polyline', 'AMap.InfoWindow']
- })
- // 创建地图实例
- map = new AMap.Map('amap-container', {
- zoom: 12,
- center: [116.397128, 39.916527], // 默认中心点(北京)
- viewMode: '3D'
- })
- // 添加景点标记
- addAttractionMarkers(AMap)
- message.success('地图加载成功')
- } catch (error) {
- console.error('地图加载失败:', error)
- message.error('地图加载失败')
- }
- }
- // 添加景点标记
- const addAttractionMarkers = (AMap: any) => {
- if (!tripPlan.value) return
- const markers: any[] = []
- const allAttractions: any[] = []
- // 收集所有景点
- tripPlan.value.days.forEach((day, dayIndex) => {
- day.attractions.forEach((attraction, attrIndex) => {
- if (attraction.location && attraction.location.longitude && attraction.location.latitude) {
- allAttractions.push({
- ...attraction,
- dayIndex,
- attrIndex
- })
- }
- })
- })
- // 创建标记
- allAttractions.forEach((attraction, index) => {
- const marker = new AMap.Marker({
- position: [attraction.location.longitude, attraction.location.latitude],
- title: attraction.name,
- label: {
- content: `<div style="background: #4CAF50; color: white; padding: 4px 8px; border-radius: 4px; font-size: 12px;">${index + 1}</div>`,
- offset: new AMap.Pixel(0, -30)
- }
- })
- // 创建信息窗口
- const infoWindow = new AMap.InfoWindow({
- content: `
- <div style="padding: 10px;">
- <h4 style="margin: 0 0 8px 0;">${attraction.name}</h4>
- <p style="margin: 4px 0;"><strong>地址:</strong> ${attraction.address}</p>
- <p style="margin: 4px 0;"><strong>游览时长:</strong> ${attraction.visit_duration}分钟</p>
- <p style="margin: 4px 0;"><strong>描述:</strong> ${attraction.description}</p>
- <p style="margin: 4px 0; color: #1890ff;"><strong>第${attraction.dayIndex + 1}天 景点${attraction.attrIndex + 1}</strong></p>
- </div>
- `,
- offset: new AMap.Pixel(0, -30)
- })
- // 点击标记显示信息窗口
- marker.on('click', () => {
- infoWindow.open(map, marker.getPosition())
- })
- markers.push(marker)
- })
- // 添加标记到地图
- map.add(markers)
- // 自动调整视野以包含所有标记
- if (allAttractions.length > 0) {
- map.setFitView(markers)
- }
- // 绘制路线
- drawRoutes(AMap, allAttractions)
- }
- // 绘制路线
- const drawRoutes = (AMap: any, attractions: any[]) => {
- if (attractions.length < 2) return
- // 按天分组绘制路线
- const dayGroups: any = {}
- attractions.forEach(attr => {
- if (!dayGroups[attr.dayIndex]) {
- dayGroups[attr.dayIndex] = []
- }
- dayGroups[attr.dayIndex].push(attr)
- })
- // 为每天的景点绘制路线
- Object.values(dayGroups).forEach((dayAttractions: any) => {
- if (dayAttractions.length < 2) return
- const path = dayAttractions.map((attr: any) => [
- attr.location.longitude,
- attr.location.latitude
- ])
- const polyline = new AMap.Polyline({
- path: path,
- strokeColor: '#1890ff',
- strokeWeight: 4,
- strokeOpacity: 0.8,
- strokeStyle: 'solid',
- showDir: true // 显示方向箭头
- })
- map.add(polyline)
- })
- }
- </script>
- <style scoped>
- .result-container {
- min-height: 100vh;
- background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
- padding: 40px 20px;
- }
- .page-header {
- max-width: 1200px;
- margin: 0 auto 30px;
- display: flex;
- justify-content: space-between;
- align-items: center;
- animation: fadeInDown 0.6s ease-out;
- }
- .back-button {
- border-radius: 8px;
- font-weight: 500;
- }
- /* 内容布局 */
- .content-wrapper {
- max-width: 1400px;
- margin: 0 auto;
- display: flex;
- gap: 24px;
- }
- .side-nav {
- width: 240px;
- flex-shrink: 0;
- }
- .side-nav :deep(.ant-menu) {
- border-radius: 12px;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
- background: white;
- }
- .side-nav :deep(.ant-menu-item) {
- margin: 4px 8px;
- border-radius: 8px;
- transition: all 0.3s ease;
- }
- .side-nav :deep(.ant-menu-item-selected) {
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white;
- }
- .side-nav :deep(.ant-menu-item:hover) {
- background: rgba(102, 126, 234, 0.1);
- }
- .main-content {
- flex: 1;
- min-width: 0;
- }
- /* 景点图片样式 */
- .attraction-image-wrapper {
- position: relative;
- margin-bottom: 12px;
- border-radius: 8px;
- overflow: hidden;
- }
- .attraction-image {
- width: 100%;
- height: 200px;
- object-fit: cover;
- transition: transform 0.3s ease;
- }
- .attraction-image-wrapper:hover .attraction-image {
- transform: scale(1.05);
- }
- .attraction-badge {
- position: absolute;
- top: 12px;
- left: 12px;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white;
- width: 36px;
- height: 36px;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- font-weight: bold;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
- }
- .badge-number {
- font-size: 18px;
- }
- .price-tag {
- position: absolute;
- top: 12px;
- right: 12px;
- background: rgba(255, 77, 79, 0.9);
- color: white;
- padding: 4px 12px;
- border-radius: 12px;
- font-weight: bold;
- font-size: 14px;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
- }
- /* 天气卡片样式 */
- .weather-card {
- background: linear-gradient(135deg, #e0f7fa 0%, #b2ebf2 100%);
- border: none !important;
- transition: all 0.3s ease;
- }
- .weather-card:hover {
- transform: translateY(-4px);
- box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
- }
- .weather-date {
- font-size: 16px;
- font-weight: bold;
- color: #00796b;
- margin-bottom: 12px;
- text-align: center;
- }
- .weather-info-row {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-bottom: 8px;
- }
- .weather-icon {
- font-size: 24px;
- }
- .weather-label {
- font-size: 12px;
- color: #666;
- }
- .weather-value {
- font-size: 16px;
- font-weight: 600;
- color: #00796b;
- }
- .weather-wind {
- margin-top: 8px;
- padding-top: 8px;
- border-top: 1px solid rgba(0, 121, 107, 0.2);
- text-align: center;
- color: #00796b;
- font-size: 14px;
- }
- /* 回到顶部按钮 */
- .back-top-button {
- width: 50px;
- height: 50px;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 24px;
- font-weight: bold;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
- cursor: pointer;
- transition: all 0.3s ease;
- }
- .back-top-button:hover {
- transform: scale(1.1);
- box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
- }
- /* 酒店卡片样式 */
- .hotel-card {
- background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
- border: none !important;
- }
- .hotel-card :deep(.ant-card-head) {
- background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
- }
- .hotel-title {
- color: white !important;
- font-weight: 600;
- }
- /* 顶部信息区布局 */
- .top-info-section {
- display: flex;
- gap: 20px;
- margin-bottom: 20px;
- }
- .left-info {
- flex: 0 0 400px;
- display: flex;
- flex-direction: column;
- gap: 20px;
- }
- .right-map {
- flex: 1;
- }
- /* 行程概览卡片 */
- .overview-card {
- height: fit-content;
- }
- .overview-content {
- display: flex;
- flex-direction: column;
- gap: 12px;
- }
- .info-item {
- display: flex;
- flex-direction: column;
- gap: 4px;
- }
- .info-label {
- font-size: 14px;
- font-weight: 600;
- color: #666;
- }
- .info-value {
- font-size: 15px;
- color: #333;
- line-height: 1.6;
- }
- /* 预算卡片 */
- .budget-card {
- height: fit-content;
- }
- .budget-grid {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: 16px;
- margin-bottom: 16px;
- }
- .budget-item {
- text-align: center;
- padding: 12px;
- background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
- border-radius: 8px;
- border: 1px solid #e8e8e8;
- }
- .budget-label {
- font-size: 13px;
- color: #666;
- margin-bottom: 8px;
- }
- .budget-value {
- font-size: 20px;
- font-weight: 700;
- color: #1890ff;
- }
- .budget-total {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 16px;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- border-radius: 8px;
- color: white;
- }
- .total-label {
- font-size: 16px;
- font-weight: 600;
- }
- .total-value {
- font-size: 28px;
- font-weight: 700;
- }
- /* 地图卡片 */
- .map-card {
- height: 100%;
- min-height: 500px;
- }
- .map-card :deep(.ant-card-body) {
- height: calc(100% - 57px);
- padding: 0;
- }
- /* 每日行程卡片 */
- .days-card {
- margin-top: 20px;
- }
- .day-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- width: 100%;
- }
- .day-title {
- font-size: 18px;
- font-weight: 600;
- color: #333;
- }
- .day-date {
- font-size: 14px;
- color: #999;
- }
- .day-info {
- margin-bottom: 20px;
- padding: 16px;
- background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
- border-radius: 8px;
- border: 1px solid #e8e8e8;
- }
- .info-row {
- display: flex;
- gap: 12px;
- margin-bottom: 8px;
- }
- .info-row:last-child {
- margin-bottom: 0;
- }
- .info-row .label {
- font-weight: 600;
- color: #666;
- min-width: 100px;
- }
- .info-row .value {
- color: #333;
- flex: 1;
- }
- /* 卡片样式优化 */
- :deep(.ant-card) {
- border-radius: 12px;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
- margin-bottom: 20px;
- transition: all 0.3s ease;
- animation: fadeInUp 0.6s ease-out;
- }
- :deep(.ant-card:hover) {
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
- }
- :deep(.ant-card-head) {
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white !important;
- border-radius: 12px 12px 0 0;
- font-weight: 600;
- }
- :deep(.ant-card-head-title) {
- color: white !important;
- font-size: 18px;
- }
- :deep(.ant-card-head-title span) {
- color: white !important;
- }
- /* Collapse样式 */
- :deep(.ant-collapse) {
- border: none;
- background: transparent;
- }
- :deep(.ant-collapse-item) {
- margin-bottom: 16px;
- border: 1px solid #e8e8e8;
- border-radius: 12px;
- overflow: hidden;
- }
- :deep(.ant-collapse-header) {
- background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
- padding: 16px 20px !important;
- font-weight: 600;
- }
- :deep(.ant-collapse-content) {
- border-top: 1px solid #e8e8e8;
- }
- :deep(.ant-collapse-content-box) {
- padding: 20px;
- }
- /* 统计卡片样式 */
- :deep(.ant-statistic-title) {
- font-size: 14px;
- color: #666;
- margin-bottom: 8px;
- }
- :deep(.ant-statistic-content) {
- font-size: 24px;
- font-weight: 600;
- color: #1890ff;
- }
- /* 景点卡片样式 */
- :deep(.ant-list-item) {
- transition: all 0.3s ease;
- }
- :deep(.ant-list-item:hover) {
- transform: scale(1.02);
- }
- /* 动画 */
- @keyframes fadeInDown {
- from {
- opacity: 0;
- transform: translateY(-20px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
- }
- @keyframes fadeInUp {
- from {
- opacity: 0;
- transform: translateY(20px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
- }
- /* 响应式设计 */
- @media (max-width: 768px) {
- .result-container {
- padding: 20px 10px;
- }
- .page-header {
- flex-direction: column;
- gap: 16px;
- }
- }
- </style>
|