fix: 修复各种加载问题

refactor
Lxy 1 week ago
parent 91cf8f5d44
commit f94177c6ed

@ -219,7 +219,12 @@ const animateCar = (targetIdx, fromStep = null) => {
} }
watch(() => store.currentStep, () => { updateMarkers(); updateRoute(!!animationFrameId) }) watch(() => store.currentStep, () => { updateMarkers(); updateRoute(!!animationFrameId) })
watch(() => store.points, () => { updateMarkers(); updateRoute() }, { deep: true }) watch(() => store.points, (newPoints) => {
console.log('[MapView] watch store.points 触发,长度:', newPoints.length)
console.log('[MapView] 站点列表:', newPoints.map(p => p.name))
updateMarkers()
updateRoute()
}, { deep: true })
watch(() => store.mode, () => { updateRoute() }) watch(() => store.mode, () => { updateRoute() })
watch(() => store.hoveredReplacement, (opt) => { updatePreviewLine(opt) }) watch(() => store.hoveredReplacement, (opt) => { updatePreviewLine(opt) })
watch(() => store.routeSegments, () => { updateRoute() }, { deep: true }) watch(() => store.routeSegments, () => { updateRoute() }, { deep: true })

@ -1,14 +1,13 @@
<template> <template>
<div class="quick-plan"> <div class="quick-plan">
<!-- ========== List View ========== --> <!-- ========== Main View ========== -->
<template v-if="viewMode === 'list'">
<div class="qp-header"> <div class="qp-header">
<h1> 快速规划</h1> <h1> 快速规划</h1>
<p>输入目的地和天数立即生成多个旅行方案</p> <p>输入目的地和天数立即生成多个旅行方案</p>
</div> </div>
<!-- Input form --> <!-- Input form -->
<div v-if="allSchemes.length === 0" class="form-wrap"> <div v-if="!isGenerating && allSchemes.length === 0" class="form-wrap">
<div class="form-row"> <div class="form-row">
<div class="fg"><label>目的地</label><input v-model="destination" placeholder="如:云南、贵州、四川" :disabled="isGenerating" value="四川"></div> <div class="fg"><label>目的地</label><input v-model="destination" placeholder="如:云南、贵州、四川" :disabled="isGenerating" value="四川"></div>
<div class="fg"><label>出行天数</label> <div class="fg"><label>出行天数</label>
@ -47,104 +46,117 @@
</transition> </transition>
</div> </div>
<!-- Regenerate button --> <!-- Scheme list with regenerate button -->
<div v-if="allSchemes.length > 0 && !isGenerating" class="plan-list"> <div v-if="allSchemes.length > 0 && !isGenerating" class="plan-list">
<div class="pl-head"><h2>为你生成了 {{ allSchemes.length }} 个方案</h2><button class="btn-sm outline" @click="generatePlan" :disabled="isGenerating">重新生成</button></div> <div class="pl-head">
<h2>为你生成了 {{ allSchemes.length }} 个方案</h2>
<button class="btn-sm outline" @click="regeneratePlan" :disabled="isGenerating">🔄 重新生成</button>
</div>
<div class="plan-card" v-for="(scheme, i) in allSchemes" :key="'current-' + i" @click="selectScheme(i, 'current')"> <!-- Scheme cards -->
<div class="pc-top"><span class="pc-badge" :class="getBadgeClass(i)">{{ getSchemeLabel(i) }}</span><span class="pc-title">{{ scheme.name }}</span></div> <div class="plan-card" v-for="(scheme, i) in allSchemes" :key="'scheme-' + i">
<div class="pc-meta"><span>{{ scheme.days }}</span><span>~{{ scheme.totalKm }}km</span><span>{{ scheme.budget }}</span></div> <div class="pc-top">
<div class="pc-route">{{ scheme.route }}</div> <span class="pc-badge" :class="getBadgeClass(i)">{{ getSchemeLabel(i) }}</span>
<div class="pc-tags"><span v-for="(h, j) in scheme.highlights" :key="j" class="pc-tag">{{ h }}</span></div> <span class="pc-title">{{ scheme.name }}</span>
<div class="pc-actions"><button class="pc-btn">选择此方案</button></div>
</div> </div>
<div class="pc-meta">
<span>{{ scheme.days }}</span>
<span>~{{ scheme.totalKm }}km</span>
<span>{{ scheme.budget }}</span>
</div> </div>
<div class="pc-route">{{ scheme.route }}</div>
<!-- History section --> <div class="pc-tags">
<div v-if="historySchemes.length > 0 && !isGenerating" class="history-section"> <span v-for="(h, j) in (scheme.highlights || []).slice(0, 3)" :key="j" class="pc-tag">{{ h }}</span>
<h3 @click="historyExpanded = !historyExpanded" style="cursor:pointer;">
历史方案 ({{ historySchemes.length }})
<span class="toggle-icon">{{ historyExpanded ? '▲' : '▼' }}</span>
</h3>
<transition name="expand">
<div v-if="historyExpanded">
<div v-for="(group, gi) in historySchemes" :key="'history-' + gi" class="history-group">
<div class="group-label"> {{ gi + 1 }} </div>
<div v-for="(scheme, si) in group.schemes" :key="si" class="history-card" @click="selectScheme(si, 'history', gi)">
<div class="card-badge">{{ getSchemeLabel(si) }}</div>
<h5>{{ scheme.name }}</h5>
<div class="card-stats"><span>{{ scheme.days }}</span><span>~{{ scheme.totalKm }}km</span></div>
<div class="card-action">查看详情</div>
</div> </div>
<div class="pc-actions">
<button class="pc-btn preview" @click.stop="openPreview(i)">预览</button>
<button class="pc-btn select" @click.stop="selectScheme(i)">选择此方案</button>
</div> </div>
</div> </div>
</transition>
</div> </div>
</template>
<!-- Preview Modal -->
<!-- ========== Detail View ========== --> <Teleport to="body">
<template v-else-if="viewMode === 'detail'"> <div v-if="previewIndex >= 0" class="modal-overlay" @click="closePreview">
<div class="detail-view"> <div class="modal-content" @click.stop>
<div class="detail-header"> <div class="modal-header">
<button class="back-btn" @click="goBackToList"></button> <div>
<h2>{{ currentDetailScheme.name }}</h2> <span class="pc-badge" :class="getBadgeClass(previewIndex)">{{ getSchemeLabel(previewIndex) }}</span>
<div class="detail-meta"> <h2>{{ previewScheme.name }}</h2>
<span>{{ currentDetailScheme.days }}</span> </div>
<span>{{ currentDetailScheme.route }}</span> <button class="modal-close" @click="closePreview">×</button>
<span>预算 {{ currentDetailScheme.budget }}</span>
</div>
<button class="use-btn" @click="useThisScheme">使</button>
</div>
<!-- Scheme switcher -->
<div class="scheme-switcher">
<div class="switcher-label">切换方案:</div>
<div class="switcher-btns">
<template v-if="currentDetailSource === 'current'">
<button v-for="(s, i) in allSchemes" :key="'switch-' + i" :class="['switch-btn', { active: currentDetailIndex === i }]" @click="selectScheme(i, 'current')">
{{ getSchemeLabel(i) }}
</button>
</template>
<template v-else>
<button v-for="(s, i) in (historySchemes[currentDetailHistoryGroup]?.schemes || [])" :key="'switch-h-' + i" :class="['switch-btn', { active: currentDetailIndex === i }]" @click="selectScheme(i, 'history', currentDetailHistoryGroup)">
{{ getSchemeLabel(i) }}
</button>
</template>
</div> </div>
<div class="modal-meta">
<span>{{ previewScheme.days }}</span>
<span>{{ previewScheme.route }}</span>
<span>预算 {{ previewScheme.budget }}</span>
</div> </div>
<!-- Detail content --> <div class="modal-scroll">
<div class="detail-content"> <!-- Highlights -->
<div class="detail-highlights"> <div class="modal-section">
<h3>亮点</h3> <h3> 亮点</h3>
<ul> <ul>
<li v-for="(h, i) in currentDetailScheme.highlights" :key="i">{{ h }}</li> <li v-for="(h, i) in (previewScheme.highlights || [])" :key="i">{{ h }}</li>
</ul> </ul>
</div> </div>
<div v-if="currentDetailScheme.daysDetail" class="detail-days"> <!-- Daily schedule -->
<h3>每日行程</h3> <div v-if="previewScheme.daysDetail && previewScheme.daysDetail.length > 0" class="modal-section">
<div v-for="(day, di) in currentDetailScheme.daysDetail" :key="di" class="day-card"> <h3>📅 每日行程</h3>
<div v-for="(day, di) in previewScheme.daysDetail" :key="di" class="day-card">
<div class="day-header"> <div class="day-header">
<span class="day-badge">Day {{ di + 1 }}</span> <span class="day-badge">Day {{ di + 1 }}</span>
<span class="day-title">{{ day.location }}</span> <span class="day-title">{{ day.location }}</span>
<span class="day-km">{{ day.km }}km · 驾车{{ day.driveTime }}h</span>
</div> </div>
<div class="day-content">
<p class="day-desc">{{ day.desc }}</p> <p class="day-desc">{{ day.desc }}</p>
<div v-if="day.spots && day.spots.length > 0" class="day-spots">
<span v-for="(spot, si) in day.spots" :key="si" class="spot-tag">{{ spot }}</span> <!-- Schedule -->
<div v-if="day.schedule && day.schedule.length > 0" class="day-schedule">
<div v-for="(item, si) in day.schedule" :key="si" class="schedule-item">
<span class="schedule-time">{{ item.time }}</span>
<div class="schedule-content">
<strong>{{ item.title }}</strong>
<p>{{ item.content }}</p>
<span class="schedule-desc">{{ item.desc }}</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Waypoints -->
<div v-if="day.waypoints && day.waypoints.length > 0" class="day-waypoints">
<span class="waypoint-label">途径推荐</span>
<span v-for="(wp, wi) in day.waypoints" :key="wi" class="waypoint-tag">
{{ wp.icon }} {{ wp.name }}
</span>
</div> </div>
<div v-if="currentDetailScheme.tips" class="detail-tips"> <!-- Foods -->
<h3>旅行贴士</h3> <div v-if="day.foods && day.foods.length > 0" class="day-foods">
<p>{{ currentDetailScheme.tips }}</p> <span class="food-label">美食推荐</span>
<span v-for="(food, fi) in day.foods" :key="fi" class="food-tag">
{{ food.icon || '🍜' }} {{ food.name }}
</span>
</div>
</div> </div>
</div> </div>
<!-- Tips -->
<div v-if="previewScheme.tips" class="modal-section">
<h3>💡 旅行贴士</h3>
<p>{{ previewScheme.tips }}</p>
</div> </div>
</template> </div>
<div class="modal-footer">
<button class="btn-sm outline" @click="closePreview"></button>
<button class="btn-sm primary" @click="selectScheme(previewIndex); closePreview()">选择此方案</button>
</div>
</div>
</div>
</Teleport>
</div> </div>
</template> </template>
@ -155,7 +167,6 @@ import { quickPlan } from '../services/aiService'
const store = useItineraryStore() const store = useItineraryStore()
const viewMode = ref('list')
const allSchemes = computed(() => store.quickSchemes) const allSchemes = computed(() => store.quickSchemes)
const historySchemes = computed(() => store.historySchemeGroups) const historySchemes = computed(() => store.historySchemeGroups)
const historyExpanded = ref(false) const historyExpanded = ref(false)
@ -171,16 +182,11 @@ const isGenerating = ref(false)
const thinkingText = ref('') const thinkingText = ref('')
const thinkingExpanded = ref(true) const thinkingExpanded = ref(true)
const currentDetailIndex = ref(-1) // Preview modal
const currentDetailSource = ref('current') const previewIndex = ref(-1)
const currentDetailHistoryGroup = ref(-1) const previewScheme = computed(() => {
if (previewIndex.value >= 0 && previewIndex.value < allSchemes.value.length) {
const currentDetailScheme = computed(() => { return allSchemes.value[previewIndex.value] || {}
if (currentDetailSource.value === 'current' && currentDetailIndex.value >= 0) {
return allSchemes.value[currentDetailIndex.value] || {}
}
if (currentDetailSource.value === 'history' && currentDetailHistoryGroup.value >= 0) {
return historySchemes.value[currentDetailHistoryGroup.value]?.schemes?.[currentDetailIndex.value] || {}
} }
return {} return {}
}) })
@ -214,7 +220,19 @@ async function generatePlan() {
thinkingText.value = text thinkingText.value = text
}) })
console.log('[generatePlan] AI 返回结果:', result)
console.log('[generatePlan] schemes 数量:', result.schemes?.length)
const newSchemes = result.schemes || [] const newSchemes = result.schemes || []
// points
newSchemes.forEach((scheme, idx) => {
console.log(`[generatePlan] 方案 ${idx} - ${scheme.name}:`, {
pointsCount: scheme.points?.length,
points: scheme.points?.map(p => p.name)
})
})
const currentSchemes = [...allSchemes.value] const currentSchemes = [...allSchemes.value]
const currentHistory = [...historySchemes.value] const currentHistory = [...historySchemes.value]
@ -231,41 +249,56 @@ async function generatePlan() {
currentHistory.push({ schemes: excess, timestamp: Date.now() }) currentHistory.push({ schemes: excess, timestamp: Date.now() })
} }
console.log('[generatePlan] 保存方案总数:', currentSchemes.length)
store.saveSchemesToStore(currentSchemes, currentHistory) store.saveSchemesToStore(currentSchemes, currentHistory)
} catch (error) { } catch (error) {
thinkingText.value = '生成失败:' + (error.message || '请重试') thinkingText.value = '生成失败:' + (error.message || '请重试')
console.error('[generatePlan] 生成失败:', error)
} finally { } finally {
isGenerating.value = false isGenerating.value = false
} }
} }
function selectScheme(index, source, historyGroup) { function regeneratePlan() {
currentDetailIndex.value = index // Clear current schemes and regenerate with same conditions
currentDetailSource.value = source store.saveSchemesToStore([], [...historySchemes.value])
currentDetailHistoryGroup.value = historyGroup ?? -1 generatePlan()
viewMode.value = 'detail'
} }
function useThisScheme() { function selectScheme(index) {
const scheme = currentDetailScheme.value const scheme = allSchemes.value[index]
if (!scheme) return if (!scheme) {
console.error('[selectScheme] 方案不存在index:', index)
return
}
console.log('[selectScheme] 选择方案:', scheme.name)
console.log('[selectScheme] 方案 points 数量:', scheme.points?.length)
console.log('[selectScheme] 方案 points 列表:', scheme.points?.map(p => p.name))
// store
store.saveSchemesToStore(allSchemes.value, historySchemes.value) store.saveSchemesToStore(allSchemes.value, historySchemes.value)
store.setActiveSchemeIndex(index)
const idx = currentDetailSource.value === 'current' ? currentDetailIndex.value : -1 // 使
store.setActiveSchemeIndex(idx) const savedScheme = store.quickSchemes[index]
console.log('[selectScheme] 深拷贝后的方案 points 数量:', savedScheme?.points?.length)
store.loadFromAI(scheme) store.loadFromAI(savedScheme)
store.setPhase('workbench') store.setPhase('workbench')
} }
function goBackToList() { function openPreview(index) {
viewMode.value = 'list' previewIndex.value = index
}
function closePreview() {
previewIndex.value = -1
} }
onMounted(() => { onMounted(() => {
if (store.quickSchemes.length > 0) { if (store.quickSchemes.length > 0) {
viewMode.value = 'list' // Stay in list view to show existing schemes
} }
}) })
</script> </script>
@ -456,12 +489,13 @@ onMounted(() => {
} }
.btn-sm { .btn-sm {
padding: 6px 16px; padding: 8px 18px;
border-radius: 6px; border-radius: 8px;
font-size: 12px; font-size: 13px;
cursor: pointer; cursor: pointer;
border: none; border: none;
font-weight: 500; font-weight: 600;
transition: all 0.2s;
} }
.btn-sm.outline { .btn-sm.outline {
@ -475,6 +509,16 @@ onMounted(() => {
color: #6c5ce7; color: #6c5ce7;
} }
.btn-sm.primary {
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
color: #fff;
}
.btn-sm.primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(108,92,231,0.3);
}
.plan-card { .plan-card {
background: #fff; background: #fff;
border-radius: 14px; border-radius: 14px;
@ -483,7 +527,6 @@ onMounted(() => {
box-shadow: 0 2px 8px rgba(0,0,0,0.04); box-shadow: 0 2px 8px rgba(0,0,0,0.04);
border: 2px solid #eee; border: 2px solid #eee;
transition: all 0.2s; transition: all 0.2s;
cursor: pointer;
} }
.plan-card:hover { .plan-card:hover {
@ -503,6 +546,7 @@ onMounted(() => {
padding: 3px 10px; padding: 3px 10px;
border-radius: 6px; border-radius: 6px;
font-weight: 700; font-weight: 700;
flex-shrink: 0;
} }
.pc-badge.a { background: #fff3e0; color: #e17055; } .pc-badge.a { background: #fff3e0; color: #e17055; }
@ -548,248 +592,166 @@ onMounted(() => {
} }
.pc-actions { .pc-actions {
text-align: right; display: flex;
justify-content: flex-end;
gap: 10px;
} }
.pc-btn { .pc-btn {
padding: 8px 22px; padding: 8px 20px;
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
color: #fff;
border: none;
border-radius: 8px; border-radius: 8px;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
} border: none;
/* History */
.history-section {
margin-top: 24px;
}
.history-section h3 {
font-size: 16px;
color: #2d3436;
margin-bottom: 12px;
}
.toggle-icon {
font-size: 12px;
margin-left: 8px;
color: #999;
}
.history-group {
background: #fff;
border-radius: 12px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
margin-bottom: 12px;
}
.group-label {
font-size: 12px;
font-weight: 600;
color: #999;
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 1px solid #f0f0f0;
}
.history-card {
background: #f8f9fa;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.history-card:last-child { margin-bottom: 0; } .pc-btn.preview {
.history-card:hover {
border-color: #6c5ce7;
background: #fff; background: #fff;
border: 1.5px solid #6c5ce7;
color: #6c5ce7;
} }
.history-card h5 { .pc-btn.preview:hover {
margin: 8px 0 6px; background: #f0f0f5;
font-size: 14px;
color: #333;
} }
.card-badge { .pc-btn.select {
display: inline-block; background: linear-gradient(135deg, #6c5ce7, #a29bfe);
background: #6c5ce7;
color: #fff; color: #fff;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
} }
.card-stats { .pc-btn.select:hover {
display: flex; transform: translateY(-1px);
gap: 12px; box-shadow: 0 4px 12px rgba(108,92,231,0.3);
font-size: 12px;
color: #666;
} }
.card-action { /* Modal Overlay */
margin-top: 8px; .modal-overlay {
font-size: 12px; position: fixed;
color: #6c5ce7; top: 0;
font-weight: 600; left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
} }
/* Detail View */ .modal-content {
.detail-view { background: #fff;
border-radius: 20px;
width: 100%;
max-width: 800px;
max-height: 85vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #f5f7fa; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
} animation: modalIn 0.3s ease;
.detail-header {
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
color: #fff;
padding: 20px 20px 24px;
position: relative;
border-radius: 16px;
margin-bottom: 16px;
} }
.detail-header h2 { @keyframes modalIn {
margin: 12px 0 8px; from {
font-size: 22px; opacity: 0;
transform: scale(0.95) translateY(-20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
} }
.detail-meta { .modal-header {
display: flex; display: flex;
gap: 16px; justify-content: space-between;
font-size: 14px; align-items: flex-start;
opacity: 0.9; padding: 24px 28px 16px;
margin-bottom: 12px; border-bottom: 1px solid #eee;
flex-wrap: wrap;
} }
.back-btn { .modal-header h2 {
background: rgba(255,255,255,0.2); font-size: 20px;
border: none; font-weight: 700;
color: #fff; color: #2d3436;
padding: 8px 14px; margin: 8px 0 0;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
} }
.back-btn:hover { background: rgba(255,255,255,0.3); } .modal-close {
width: 32px;
.use-btn { height: 32px;
padding: 12px 24px;
background: #fff;
color: #6c5ce7;
border: none; border: none;
border-radius: 10px; background: #f0f0f5;
font-size: 15px; border-radius: 50%;
font-weight: 600; font-size: 20px;
color: #636e72;
cursor: pointer; cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s; transition: all 0.2s;
flex-shrink: 0;
} }
.use-btn:hover { .modal-close:hover {
transform: scale(1.02); background: #6c5ce7;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); color: #fff;
}
.scheme-switcher {
background: #fff;
padding: 12px 20px;
border-radius: 12px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.switcher-label {
font-size: 13px;
font-weight: 600;
color: #666;
margin-bottom: 8px;
} }
.switcher-btns { .modal-meta {
display: flex; display: flex;
gap: 8px; gap: 16px;
flex-wrap: wrap; padding: 12px 28px;
} font-size: 14px;
.switch-btn {
padding: 6px 14px;
border: 1.5px solid #dfe6e9;
background: #fff;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
color: #636e72; color: #636e72;
background: #f8f9fa;
flex-wrap: wrap;
} }
.switch-btn:hover { .modal-scroll {
border-color: #6c5ce7; overflow-y: auto;
color: #6c5ce7; padding: 20px 28px;
} flex: 1;
.switch-btn.active {
background: #6c5ce7;
color: #fff;
border-color: #6c5ce7;
}
.detail-content {
max-width: 800px;
margin: 0 auto;
width: 100%;
} }
.detail-highlights, .detail-days h3, .detail-tips h3 { .modal-section {
background: #fff; margin-bottom: 20px;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
} }
.detail-highlights h3, .detail-days h3, .detail-tips h3 { .modal-section h3 {
margin: 0 0 12px;
font-size: 16px; font-size: 16px;
font-weight: 700;
color: #2d3436; color: #2d3436;
margin: 0 0 12px;
} }
.detail-highlights ul { .modal-section ul {
margin: 0; margin: 0;
padding-left: 20px; padding-left: 20px;
} }
.detail-highlights li { .modal-section li {
margin-bottom: 6px; margin-bottom: 6px;
color: #555; color: #555;
line-height: 1.6; line-height: 1.6;
} }
.day-card { .day-card {
background: #fff; background: #f8f9fa;
border-radius: 12px; border-radius: 12px;
padding: 16px; padding: 16px;
margin-bottom: 12px; margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
} }
.day-header { .day-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
margin-bottom: 10px; margin-bottom: 8px;
flex-wrap: wrap;
} }
.day-badge { .day-badge {
@ -799,6 +761,7 @@ onMounted(() => {
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
flex-shrink: 0;
} }
.day-title { .day-title {
@ -807,6 +770,12 @@ onMounted(() => {
color: #333; color: #333;
} }
.day-km {
font-size: 12px;
color: #999;
margin-left: auto;
}
.day-desc { .day-desc {
margin: 0 0 10px; margin: 0 0 10px;
font-size: 14px; font-size: 14px;
@ -814,28 +783,71 @@ onMounted(() => {
line-height: 1.6; line-height: 1.6;
} }
.day-spots { .day-schedule {
margin-bottom: 10px;
}
.schedule-item {
display: flex;
gap: 10px;
margin-bottom: 8px;
}
.schedule-time {
font-size: 12px;
font-weight: 600;
color: #6c5ce7;
min-width: 50px;
flex-shrink: 0;
}
.schedule-content strong {
font-size: 14px;
color: #2d3436;
}
.schedule-content p {
margin: 4px 0;
font-size: 13px;
color: #555;
line-height: 1.5;
}
.schedule-desc {
font-size: 12px;
color: #999;
font-style: italic;
}
.day-waypoints, .day-foods {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
align-items: center;
margin-top: 8px;
} }
.spot-tag { .waypoint-label, .food-label {
background: #f0f0f5; font-size: 12px;
font-weight: 600;
color: #636e72; color: #636e72;
padding: 2px 10px; }
border-radius: 4px;
.waypoint-tag, .food-tag {
background: #fff;
border: 1px solid #dfe6e9;
padding: 3px 8px;
border-radius: 6px;
font-size: 12px; font-size: 12px;
color: #555;
} }
.detail-tips { .modal-footer {
background: #fff8e1; display: flex;
border-left: 3px solid #f39c12; justify-content: flex-end;
padding: 12px; gap: 10px;
border-radius: 0 8px 8px 0; padding: 16px 28px;
font-size: 13px; border-top: 1px solid #eee;
color: #856404;
line-height: 1.5;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@ -846,5 +858,14 @@ onMounted(() => {
.form-row { .form-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.modal-content {
max-height: 95vh;
}
.modal-header, .modal-meta, .modal-scroll, .modal-footer {
padding-left: 16px;
padding-right: 16px;
}
} }
</style> </style>

@ -24,7 +24,6 @@
{{ getSchemeLabel(i) }} {{ getSchemeLabel(i) }}
</button> </button>
</div> </div>
<button class="wb-back-btn" @click="goBack"> </button>
</div> </div>
<!-- Main content --> <!-- Main content -->
@ -60,6 +59,18 @@
<p>{{ point.desc }}</p> <p>{{ point.desc }}</p>
</div> </div>
</div> </div>
<!-- 风景照片画廊 -->
<div v-if="point.gallery && point.gallery.length > 0" class="wm-gallery">
<h4>📸 风景照片</h4>
<div class="gallery-grid">
<div v-for="(img, i) in point.gallery" :key="i" class="gallery-item">
<img :src="img.url" :alt="img.title" @error="handleGalleryImageError" />
<div class="gallery-caption">{{ img.title }}</div>
</div>
</div>
</div>
<div class="wm-stats"> <div class="wm-stats">
<div class="wm-stat"> <div class="wm-stat">
<div class="ws-val">{{ point.km || '—' }}</div> <div class="ws-val">{{ point.km || '—' }}</div>
@ -74,21 +85,49 @@
<div class="ws-lbl">第几天</div> <div class="ws-lbl">第几天</div>
</div> </div>
</div> </div>
<!-- 途径推荐 -->
<div v-if="point.waypoints && point.waypoints.length > 0" class="wm-sec wm-waypoints">
<h4>🗺 途径推荐</h4>
<div class="waypoint-list">
<div v-for="(wp, i) in point.waypoints" :key="i" class="waypoint-item">
<div class="wp-icon">{{ wp.icon || '📍' }}</div>
<div class="wp-info">
<div class="wp-name">{{ wp.name }}</div>
<div class="wp-desc">{{ wp.desc }}</div>
</div>
</div>
</div>
</div>
<div class="wm-sec" v-if="point.schedule.length"> <div class="wm-sec" v-if="point.schedule.length">
<h4>📅 行程安排</h4> <h4>📅 行程安排</h4>
<div class="wm-si" v-for="(s, i) in point.schedule" :key="i"> <div class="wm-si" v-for="(s, i) in point.schedule" :key="i">
<span class="si-time">{{ s.time }}</span><span>{{ s.content }}</span> <span class="si-time">{{ s.time }}</span>
<div class="si-content">
<div class="si-title">{{ s.title || s.content }}</div>
<div v-if="s.desc" class="si-desc">{{ s.desc }}</div>
</div> </div>
</div> </div>
<div class="wm-sec" v-if="point.foods && point.foods.length"> </div>
<!-- 美食推荐 -->
<div v-if="point.foods && point.foods.length > 0" class="wm-sec wm-foods-detailed">
<h4>🍽 美食推荐</h4> <h4>🍽 美食推荐</h4>
<div class="wm-foods"> <div class="foods-grid">
<span v-for="(f, i) in point.foods" :key="i">{{ f }}</span> <div v-for="(f, i) in point.foods" :key="i" class="food-item">
<div class="food-icon">{{ typeof f === 'string' ? '🍜' : (f.icon || '🍜') }}</div>
<div class="food-info">
<div class="food-name">{{ typeof f === 'string' ? f : f.name }}</div>
<div v-if="typeof f === 'object' && f.desc" class="food-desc">{{ f.desc }}</div>
</div> </div>
</div> </div>
</div>
</div>
<div class="wm-sec" v-if="point.hotel"> <div class="wm-sec" v-if="point.hotel">
<h4>🏨 住宿推荐</h4> <h4>🏨 住宿推荐</h4>
<div style="font-size:13px;color:#636e72">{{ point.hotel }}</div> <div class="hotel-info">{{ point.hotel }}</div>
</div> </div>
<div class="wm-sec" v-if="point.tips"> <div class="wm-sec" v-if="point.tips">
<h4>💡 注意事项</h4> <h4>💡 注意事项</h4>
@ -105,6 +144,9 @@
<div class="wb-right"> <div class="wb-right">
<MapView ref="mapView" /> <MapView ref="mapView" />
<div class="map-controls"> <div class="map-controls">
<button class="route-toggle" @click="fetchRealRoute" :disabled="isFetchingRealRoute">
{{ isFetchingRealRoute ? '加载中...' : '🛣️ 真实路径' }}
</button>
<button class="route-toggle" @click="toggleRoute" :class="{ active: showRoute }"> <button class="route-toggle" @click="toggleRoute" :class="{ active: showRoute }">
{{ showRoute ? '隐藏路线' : '显示路线' }} {{ showRoute ? '隐藏路线' : '显示路线' }}
</button> </button>
@ -115,13 +157,14 @@
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import { useItineraryStore } from '../stores/itinerary' import { useItineraryStore } from '../stores/itinerary'
import MapView from './MapView.vue' import MapView from './MapView.vue'
const store = useItineraryStore() const store = useItineraryStore()
const mapView = ref(null) const mapView = ref(null)
const showRoute = ref(true) const showRoute = ref(true)
const isFetchingRealRoute = ref(false)
const point = computed(() => store.currentPoint) const point = computed(() => store.currentPoint)
@ -159,8 +202,20 @@ const handleImageError = (e) => {
e.target.style.display = 'none' e.target.style.display = 'none'
} }
const goBack = () => { const handleGalleryImageError = (e) => {
store.resetToModeSelection() e.target.parentElement.style.display = 'none'
}
const fetchRealRoute = async () => {
if (isFetchingRealRoute.value || store.points.length < 2) return
isFetchingRealRoute.value = true
try {
await store.fetchRealRoutes()
} catch (e) {
console.error('获取真实路线失败:', e)
} finally {
isFetchingRealRoute.value = false
}
} }
const toggleRoute = () => { const toggleRoute = () => {
@ -173,6 +228,16 @@ const toggleRoute = () => {
} }
} }
} }
// points phase
watch([() => store.points, () => store.phase], ([newPoints, newPhase]) => {
console.log('[Workbench] watch 触发 - phase:', newPhase, 'points 长度:', newPoints.length)
console.log('[Workbench] 站点列表:', newPoints.map(p => p.name))
if (newPhase === 'workbench' && newPoints.length >= 2 && store.routeSegments.length === 0) {
console.log('[Workbench] 自动获取真实路径')
fetchRealRoute()
}
}, { deep: true })
</script> </script>
<style scoped> <style scoped>
@ -254,22 +319,6 @@ const toggleRoute = () => {
color: #6c5ce7; color: #6c5ce7;
} }
.wb-back-btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
border: 1.5px solid #6c5ce7;
background: #fff;
color: #6c5ce7;
transition: all 0.2s;
}
.wb-back-btn:hover {
background: #6c5ce7;
color: #fff;
}
/* Wrap layout */ /* Wrap layout */
.wb-wrap { .wb-wrap {
display: grid; display: grid;
@ -400,6 +449,52 @@ const toggleRoute = () => {
margin: 0; margin: 0;
} }
/* 风景照片画廊 */
.wm-gallery {
padding: 14px 16px;
border-top: 1px solid #f0f0f0;
}
.wm-gallery h4 {
font-size: 13px;
margin-bottom: 10px;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.gallery-item {
position: relative;
border-radius: 8px;
overflow: hidden;
aspect-ratio: 4/3;
}
.gallery-item img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.gallery-item:hover img {
transform: scale(1.05);
}
.gallery-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 6px 8px;
background: linear-gradient(transparent, rgba(0,0,0,0.7));
color: #fff;
font-size: 11px;
}
.wm-stats { .wm-stats {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@ -448,6 +543,108 @@ const toggleRoute = () => {
min-width: 32px; min-width: 32px;
} }
.si-content {
flex: 1;
}
.si-title {
font-weight: 500;
margin-bottom: 2px;
}
.si-desc {
font-size: 11px;
color: #636e72;
margin-top: 4px;
}
/* 途径推荐 */
.wm-waypoints {
background: #f8f9fa;
}
.waypoint-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.waypoint-item {
display: flex;
gap: 10px;
padding: 8px;
background: #fff;
border-radius: 8px;
align-items: center;
}
.wp-icon {
font-size: 20px;
flex-shrink: 0;
}
.wp-info {
flex: 1;
}
.wp-name {
font-size: 13px;
font-weight: 600;
color: #2d3436;
}
.wp-desc {
font-size: 11px;
color: #636e72;
margin-top: 2px;
}
/* 美食推荐详细版 */
.wm-foods-detailed .foods-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.food-item {
display: flex;
gap: 8px;
padding: 10px;
background: #f8f9fa;
border-radius: 8px;
align-items: center;
}
.food-icon {
font-size: 24px;
flex-shrink: 0;
}
.food-info {
flex: 1;
}
.food-name {
font-size: 12px;
font-weight: 600;
color: #2d3436;
}
.food-desc {
font-size: 11px;
color: #636e72;
margin-top: 2px;
}
.hotel-info {
font-size: 13px;
color: #636e72;
padding: 10px;
background: #f0f4ff;
border-radius: 8px;
border-left: 3px solid #6c5ce7;
}
.wm-foods, .wm-spots { .wm-foods, .wm-spots {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

@ -78,8 +78,28 @@ JSON 格式如下:
"desc": "该地点简短描述", "desc": "该地点简短描述",
"km": "0km", "km": "0km",
"driveTime": "—", "driveTime": "—",
"schedule": [{"time": "上午", "content": "行程安排"}], "schedule": [
"foods": ["当地美食"], {
"time": "上午",
"title": "标题",
"content": "行程安排简述",
"desc": "详细描述"
}
],
"foods": [
{
"name": "美食名称",
"icon": "🍜",
"desc": "美食简介"
}
],
"waypoints": [
{
"name": "途径地点名称",
"icon": "📍",
"desc": "简短介绍"
}
],
"hotel": "推荐住宿", "hotel": "推荐住宿",
"tips": "注意事项" "tips": "注意事项"
} }
@ -91,9 +111,14 @@ JSON 格式如下:
要求 要求
1. 3 个方案风格不同经典路线深度游小众路线 1. 3 个方案风格不同经典路线深度游小众路线
2. lat/lng 必须是真实中国地理坐标 2. lat/lng 必须是真实中国地理坐标
3. 行程安排要合理每天 1-2 个地点 3. 行程安排要详细每天至少 3 个时间段上午下午晚上
4. daysDetail 数组长度必须等于 days每天一个对象 4. 美食推荐至少 4 种当地特色美食
5. 直接输出 JSON不要其他内容` 5. 途径推荐 2-3 个从上一站到当前站之间可以顺路游览的地点
6. schedule title content 必填desc 为可选的详细说明
7. foods 中每个对象必须包含 nameicondesc
8. waypoints 中每个对象必须包含 nameicondesc
9. daysDetail 数组长度必须等于 days每天一个对象
10. 直接输出 JSON不要其他内容`
const CUSTOM_PLAN_PROMPT = `你是一个专业的旅行规划助手。用户已经输入了他/她的行程安排,你需要: const CUSTOM_PLAN_PROMPT = `你是一个专业的旅行规划助手。用户已经输入了他/她的行程安排,你需要:
1. 解析用户的行程安排 1. 解析用户的行程安排
@ -423,6 +448,12 @@ export async function quickPlan(userInput, onThinking = null) {
请为我生成 3 个不同风格的旅行方案` 请为我生成 3 个不同风格的旅行方案`
const MAX_RETRIES = 3
let lastError = null
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
console.log(`[quickPlan] 尝试 ${attempt}/${MAX_RETRIES}`)
const content = await streamAIRequest([ const content = await streamAIRequest([
{ role: 'system', content: QUICK_PLAN_PROMPT }, { role: 'system', content: QUICK_PLAN_PROMPT },
{ role: 'user', content: userMessage } { role: 'user', content: userMessage }
@ -434,7 +465,28 @@ export async function quickPlan(userInput, onThinking = null) {
throw new Error('AI 返回结果缺少 schemes') throw new Error('AI 返回结果缺少 schemes')
} }
// 验证每个 scheme 都有 points
for (let i = 0; i < parsed.schemes.length; i++) {
const scheme = parsed.schemes[i]
if (!scheme.points || !Array.isArray(scheme.points) || scheme.points.length === 0) {
throw new Error(`方案 ${i + 1} (${scheme.name || '未知'}) 缺少 points 数据`)
}
console.log(`[quickPlan] 方案 ${i + 1} 验证通过: ${scheme.name}, points: ${scheme.points.length}`)
}
console.log(`[quickPlan] 解析成功,方案数: ${parsed.schemes.length}`)
return parsed return parsed
} catch (e) {
lastError = e
console.warn(`[quickPlan] 尝试 ${attempt} 失败:`, e.message)
if (attempt < MAX_RETRIES) {
// 短暂延迟后重试
await new Promise(resolve => setTimeout(resolve, 1000 * attempt))
}
}
}
throw new Error(`AI 规划失败(已重试 ${MAX_RETRIES} 次):${lastError?.message || '未知错误'}`)
} }
export async function customPlan(userInput, onThinking = null) { export async function customPlan(userInput, onThinking = null) {
@ -446,6 +498,12 @@ ${userInput.itinerary}
请评估我的行程是否合理并给出优化建议和两个方案优化版和原版` 请评估我的行程是否合理并给出优化建议和两个方案优化版和原版`
const MAX_RETRIES = 3
let lastError = null
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
console.log(`[customPlan] 尝试 ${attempt}/${MAX_RETRIES}`)
const content = await streamAIRequest([ const content = await streamAIRequest([
{ role: 'system', content: CUSTOM_PLAN_PROMPT }, { role: 'system', content: CUSTOM_PLAN_PROMPT },
{ role: 'user', content: userMessage } { role: 'user', content: userMessage }
@ -457,5 +515,16 @@ ${userInput.itinerary}
throw new Error('AI 返回结果缺少 evaluation') throw new Error('AI 返回结果缺少 evaluation')
} }
console.log(`[customPlan] 解析成功`)
return parsed return parsed
} catch (e) {
lastError = e
console.warn(`[customPlan] 尝试 ${attempt} 失败:`, e.message)
if (attempt < MAX_RETRIES) {
await new Promise(resolve => setTimeout(resolve, 1000 * attempt))
}
}
}
throw new Error(`AI 评估失败(已重试 ${MAX_RETRIES} 次):${lastError?.message || '未知错误'}`)
} }

@ -115,10 +115,12 @@ export const useItineraryStore = defineStore('itinerary', () => {
} }
async function fetchRealRoutes() { async function fetchRealRoutes() {
if (isFetchingRoutes.value) return if (isFetchingRoutes.value || points.value.length < 2) return
isFetchingRoutes.value = true isFetchingRoutes.value = true
try { try {
console.log('[fetchRealRoutes] 开始获取真实路线,站点数:', points.value.length)
routeSegments.value = await getMultiRoute(points.value) routeSegments.value = await getMultiRoute(points.value)
console.log('[fetchRealRoutes] 获取到路线段数:', routeSegments.value.length)
detourWarnings.value = detectDetour(routeSegments.value) detourWarnings.value = detectDetour(routeSegments.value)
if (detourWarnings.value.length > 0) { if (detourWarnings.value.length > 0) {
showOptimizationAlert.value = true showOptimizationAlert.value = true
@ -188,13 +190,25 @@ export const useItineraryStore = defineStore('itinerary', () => {
} }
function loadFromAI(scheme) { function loadFromAI(scheme) {
console.log('[loadFromAI] 开始加载方案:', scheme.name)
console.log('[loadFromAI] scheme.points:', scheme.points?.length, '个站点')
console.log('[loadFromAI] scheme.points 详情:', JSON.stringify(scheme.points?.map(p => ({ name: p.name, lat: p.lat, lng: p.lng }))))
if (!scheme.points || !Array.isArray(scheme.points) || scheme.points.length === 0) {
console.error('[loadFromAI] 方案缺少 points 数据,使用示例数据')
resetToSample()
return
}
try {
const icons = ['🏙️', '🌿', '🏘️', '🏔️', '🏯', '💧', '🌾', '⛰️', '🏖️', '🛕', '🌸', '🏝️', '✈️'] const icons = ['🏙️', '🌿', '🏘️', '🏔️', '🏯', '💧', '🌾', '⛰️', '🏖️', '🛕', '🌸', '🏝️', '✈️']
const newPoints = scheme.points.map((p, i) => ({ const newPoints = scheme.points.map((p, i) => {
const point = {
id: 'p_' + Date.now() + '_' + i, id: 'p_' + Date.now() + '_' + i,
name: p.name, name: p.name || '未知地点',
lat: p.lat, lat: p.lat || 0,
lng: p.lng, lng: p.lng || 0,
day: p.day || `Day ${i}`, day: p.day || `Day ${i + 1}`,
badge: p.badge || (i === 0 ? 'START' : (i === scheme.points.length - 1 ? 'END' : `D${i}`)), badge: p.badge || (i === 0 ? 'START' : (i === scheme.points.length - 1 ? 'END' : `D${i}`)),
km: p.km || (i === 0 ? '0km' : `${Math.round(Math.random() * 300 + 50)}km`), km: p.km || (i === 0 ? '0km' : `${Math.round(Math.random() * 300 + 50)}km`),
driveTime: p.driveTime || (i === 0 ? '—' : `${Math.round(Math.random() * 5 + 1)}h`), driveTime: p.driveTime || (i === 0 ? '—' : `${Math.round(Math.random() * 5 + 1)}h`),
@ -205,23 +219,60 @@ export const useItineraryStore = defineStore('itinerary', () => {
`https://picsum.photos/400/300?random=${Date.now() + i * 3 + 1}` `https://picsum.photos/400/300?random=${Date.now() + i * 3 + 1}`
], ],
heroImage: p.heroImage || `https://picsum.photos/800/400?random=${Date.now() + i * 3 + 2}`, heroImage: p.heroImage || `https://picsum.photos/800/400?random=${Date.now() + i * 3 + 2}`,
schedule: p.schedule || [ schedule: (p.schedule || [
{ time: '上午', content: '抵达目的地,开始探索' }, { time: '上午', content: '抵达目的地,开始探索' },
{ time: '下午', content: '游览主要景点' }, { time: '下午', content: '游览主要景点' },
{ time: '晚上', content: '品尝当地美食' } { time: '晚上', content: '品尝当地美食' }
], ]).map(s => ({
foods: p.foods || ['当地特色美食'], time: s.time,
title: s.title || s.content,
content: s.content,
desc: s.desc || ''
})),
foods: (p.foods || ['当地特色美食']).map(f => {
if (typeof f === 'string') return { name: f, icon: '🍜', desc: '' }
return { name: f.name || f, icon: f.icon || '🍜', desc: f.desc || '' }
}),
hotel: p.hotel || '推荐酒店', hotel: p.hotel || '推荐酒店',
tips: p.tips || '注意安全,提前预订' tips: p.tips || '注意安全,提前预订',
})) }
// 安全生成 waypoints
try {
point.waypoints = p.waypoints || generateWaypoints(p.name, i)
} catch (e) {
console.warn(`[loadFromAI] 生成 waypoints 失败 for ${p.name}:`, e)
point.waypoints = []
}
// 安全生成 gallery
try {
point.gallery = p.gallery || generateGallery(p.name, p.lat, p.lng, i)
} catch (e) {
console.warn(`[loadFromAI] 生成 gallery 失败 for ${p.name}:`, e)
point.gallery = []
}
return point
})
console.log('[loadFromAI] 加载完成,站点数:', newPoints.length)
console.log('[loadFromAI] 站点列表:', newPoints.map(p => p.name))
points.value = newPoints points.value = newPoints
console.log('[loadFromAI] points.value 赋值后长度:', points.value.length)
console.log('[loadFromAI] points.value 站点列表:', points.value.map(p => p.name))
currentStep.value = 0 currentStep.value = 0
routeSegments.value = [] routeSegments.value = []
showOptimizationAlert.value = false showOptimizationAlert.value = false
showReplacementPanel.value = false showReplacementPanel.value = false
selectedForReplace.value = null selectedForReplace.value = null
hoveredReplacement.value = null hoveredReplacement.value = null
} catch (error) {
console.error('[loadFromAI] 加载方案失败:', error)
resetToSample()
}
} }
function loadSchemeByIndex(index) { function loadSchemeByIndex(index) {
@ -231,8 +282,13 @@ export const useItineraryStore = defineStore('itinerary', () => {
} }
function saveSchemesToStore(schemes, historyGroups) { function saveSchemesToStore(schemes, historyGroups) {
console.log('[saveSchemesToStore] 保存方案数:', schemes.length)
schemes.forEach((s, i) => {
console.log(`[saveSchemesToStore] 方案 ${i} - ${s.name}: points=${s.points?.length}`)
})
quickSchemes.value = JSON.parse(JSON.stringify(schemes)) quickSchemes.value = JSON.parse(JSON.stringify(schemes))
historySchemeGroups.value = JSON.parse(JSON.stringify(historyGroups)) historySchemeGroups.value = JSON.parse(JSON.stringify(historyGroups))
console.log('[saveSchemesToStore] quickSchemes.value 长度:', quickSchemes.value.length)
} }
function setActiveSchemeIndex(index) { function setActiveSchemeIndex(index) {
@ -262,3 +318,88 @@ function calcDist(lat1, lng1, lat2, lng2) {
const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLng/2)**2 const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLng/2)**2
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
} }
// 生成途径推荐(基于目的地名称)
function generateWaypoints(name, index) {
const waypointDB = {
'昆明': [
{ name: '滇池', desc: '云南最大淡水湖,冬季可观红嘴鸥', icon: '💧' },
{ name: '石林', desc: '世界自然遗产,喀斯特地貌奇观', icon: '⛰️' }
],
'大理': [
{ name: '洱海', desc: '高原明珠,骑行环湖绝佳路线', icon: '💧' },
{ name: '喜洲古镇', desc: '白族民居典范,体验扎染文化', icon: '🏘️' }
],
'丽江': [
{ name: '束河古镇', desc: '比大研古镇更安静,纳西文化', icon: '🏘️' },
{ name: '拉市海', desc: '湿地公园,骑马茶马古道体验', icon: '🌾' }
],
'香格里拉': [
{ name: '松赞林寺', desc: '云南最大藏传佛教寺庙', icon: '🛕' },
{ name: '纳帕海', desc: '季节性湖泊,草原风光', icon: '🌾' }
],
'成都': [
{ name: '锦里古街', desc: '三国文化与成都民俗', icon: '🏘️' },
{ name: '大熊猫基地', desc: '近距离观赏国宝', icon: '🐼' }
],
'重庆': [
{ name: '洪崖洞', desc: '巴渝传统建筑,夜景绝佳', icon: '🏙️' },
{ name: '长江索道', desc: '万里长江第一条跨江索道', icon: '🚠' }
]
}
return waypointDB[name] || []
}
// 生成风景照片画廊(使用 Unsplash API 获取当地景点图片)
function generateGallery(name, lat, lng, index) {
const landmarks = {
'昆明': [
{ title: '滇池风光', query: 'dianchi lake kunming' },
{ title: '翠湖公园', query: 'green lake park kunming' },
{ title: '石林奇观', query: 'stone forest yunnan' },
{ title: '昆明市区', query: 'kunming city china' }
],
'大理': [
{ title: '洱海日出', query: 'erhai lake dali sunrise' },
{ title: '大理古城', query: 'dali ancient town' },
{ title: '苍山雪景', query: 'cangshan mountain dali' },
{ title: '喜洲白族民居', query: 'xizhou bai architecture' }
],
'丽江': [
{ title: '丽江古城', query: 'lijiang old town' },
{ title: '玉龙雪山', query: 'jade dragon snow mountain' },
{ title: '束河古镇', query: 'shuhe ancient town' },
{ title: '泸沽湖', query: 'lugu lake yunnan' }
],
'香格里拉': [
{ title: '普达措国家公园', query: 'potatso national park' },
{ title: '松赞林寺', query: 'songzanlin monastery' },
{ title: '梅里雪山', query: 'meili snow mountain' },
{ title: '虎跳峡', query: 'tiger leaping gorge' }
],
'成都': [
{ title: '武侯祠', query: 'wuhou temple chengdu' },
{ title: '锦里古街', query: 'jinli street chengdu' },
{ title: '都江堰', query: 'dujiangyan irrigation' },
{ title: '青城山', query: 'qingcheng mountain' }
],
'重庆': [
{ title: '洪崖洞夜景', query: 'hongyadong chongqing night' },
{ title: '长江索道', query: 'yangtze river cableway' },
{ title: '解放碑', query: 'jiefangbei chongqing' },
{ title: '武隆天坑', query: 'wulong karst chongqing' }
]
}
const locationLandmarks = landmarks[name] || [
{ title: `${name}风光`, query: `${name} china travel` },
{ title: `${name}景点`, query: `${name} scenic` },
{ title: `${name}美食`, query: `${name} food china` },
{ title: `${name}文化`, query: `${name} culture` }
]
return locationLandmarks.map((landmark, i) => ({
title: landmark.title,
url: `https://source.unsplash.com/400x300/?${encodeURIComponent(landmark.query)}&sig=${index * 10 + i}`
}))
}

Loading…
Cancel
Save