|
|
|
@ -50,6 +50,9 @@
|
|
|
|
<button class="prev" :disabled="store.currentStep <= 0" @click="handlePrev">← 上一站</button>
|
|
|
|
<button class="prev" :disabled="store.currentStep <= 0" @click="handlePrev">← 上一站</button>
|
|
|
|
<button class="next" :disabled="store.currentStep >= store.points.length - 1" @click="handleNext">下一站 →</button>
|
|
|
|
<button class="next" :disabled="store.currentStep >= store.points.length - 1" @click="handleNext">下一站 →</button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<button class="wl-share-btn" @click="handleShare" v-if="authStore.isAuthenticated">
|
|
|
|
|
|
|
|
🔗 分享行程
|
|
|
|
|
|
|
|
</button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 中间:详情面板 -->
|
|
|
|
<!-- 中间:详情面板 -->
|
|
|
|
@ -156,20 +159,57 @@
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Share Modal -->
|
|
|
|
|
|
|
|
<div v-if="showShareModal" class="share-modal-overlay" @click.self="closeShareModal">
|
|
|
|
|
|
|
|
<div class="share-modal">
|
|
|
|
|
|
|
|
<div class="share-modal-header">
|
|
|
|
|
|
|
|
<h3>🔗 分享行程</h3>
|
|
|
|
|
|
|
|
<button class="close-btn" @click="closeShareModal">✕</button>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div class="share-modal-body">
|
|
|
|
|
|
|
|
<template v-if="shareLoading">
|
|
|
|
|
|
|
|
<div class="share-loading">
|
|
|
|
|
|
|
|
<div class="share-spinner"></div>
|
|
|
|
|
|
|
|
<p>正在生成分享链接...</p>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
<template v-else-if="shareUrl">
|
|
|
|
|
|
|
|
<p class="share-success">✅ 分享链接已生成!</p>
|
|
|
|
|
|
|
|
<div class="share-url-box">
|
|
|
|
|
|
|
|
<input type="text" :value="shareUrl" readonly class="share-url-input" />
|
|
|
|
|
|
|
|
<button class="copy-btn" @click="copyShareUrl">📋 复制</button>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<p class="share-hint">任何人打开此链接都可以查看该行程(只读模式)</p>
|
|
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
<template v-else-if="shareError">
|
|
|
|
|
|
|
|
<p class="share-error">❌ {{ shareError }}</p>
|
|
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</template>
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
<script setup>
|
|
|
|
import { ref, computed, watch } from 'vue'
|
|
|
|
import { ref, computed, watch } from 'vue'
|
|
|
|
import { useItineraryStore } from '../stores/itinerary'
|
|
|
|
import { useItineraryStore } from '../stores/itinerary'
|
|
|
|
|
|
|
|
import { useAuthStore } from '../stores/auth'
|
|
|
|
import MapView from './MapView.vue'
|
|
|
|
import MapView from './MapView.vue'
|
|
|
|
import { exportToHtml } from '../services/exportHtmlService'
|
|
|
|
import { exportToHtml } from '../services/exportHtmlService'
|
|
|
|
|
|
|
|
|
|
|
|
const store = useItineraryStore()
|
|
|
|
const store = useItineraryStore()
|
|
|
|
|
|
|
|
const authStore = useAuthStore()
|
|
|
|
const mapView = ref(null)
|
|
|
|
const mapView = ref(null)
|
|
|
|
const showRoute = ref(true)
|
|
|
|
const showRoute = ref(true)
|
|
|
|
const isFetchingRealRoute = ref(false)
|
|
|
|
const isFetchingRealRoute = ref(false)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Share state
|
|
|
|
|
|
|
|
const showShareModal = ref(false)
|
|
|
|
|
|
|
|
const shareLoading = ref(false)
|
|
|
|
|
|
|
|
const shareUrl = ref('')
|
|
|
|
|
|
|
|
const shareError = ref('')
|
|
|
|
|
|
|
|
|
|
|
|
const point = computed(() => store.currentPoint)
|
|
|
|
const point = computed(() => store.currentPoint)
|
|
|
|
|
|
|
|
|
|
|
|
function getSchemeLabel(i) {
|
|
|
|
function getSchemeLabel(i) {
|
|
|
|
@ -260,6 +300,101 @@ function exportCurrentScheme() {
|
|
|
|
exportToHtml(scheme, `${scheme.name || '行程规划'}.html`)
|
|
|
|
exportToHtml(scheme, `${scheme.name || '行程规划'}.html`)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Share functions
|
|
|
|
|
|
|
|
async function handleShare() {
|
|
|
|
|
|
|
|
if (!authStore.isAuthenticated) {
|
|
|
|
|
|
|
|
alert('请先登录后再分享')
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
showShareModal.value = true
|
|
|
|
|
|
|
|
shareLoading.value = true
|
|
|
|
|
|
|
|
shareUrl.value = ''
|
|
|
|
|
|
|
|
shareError.value = ''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
// 先保存行程到后端(如果还没有保存)
|
|
|
|
|
|
|
|
const idx = store.activeSchemeIndex
|
|
|
|
|
|
|
|
let planData
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (idx >= 0 && idx < store.quickSchemes.length) {
|
|
|
|
|
|
|
|
planData = {
|
|
|
|
|
|
|
|
...store.quickSchemes[idx],
|
|
|
|
|
|
|
|
points: JSON.parse(JSON.stringify(store.points))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
planData = {
|
|
|
|
|
|
|
|
name: '我的行程',
|
|
|
|
|
|
|
|
route: store.points.map(p => p.name).join(' → '),
|
|
|
|
|
|
|
|
days: store.points.length,
|
|
|
|
|
|
|
|
totalKm: store.totalKm,
|
|
|
|
|
|
|
|
totalDriveTime: store.totalDriveTime,
|
|
|
|
|
|
|
|
budget: '—',
|
|
|
|
|
|
|
|
highlights: [],
|
|
|
|
|
|
|
|
points: JSON.parse(JSON.stringify(store.points))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 保存行程
|
|
|
|
|
|
|
|
const saveRes = await fetch('http://localhost:3001/api/plans', {
|
|
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
|
|
'Authorization': `Bearer ${authStore.token}`
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
|
|
name: planData.name,
|
|
|
|
|
|
|
|
description: planData.route,
|
|
|
|
|
|
|
|
data: planData
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!saveRes.ok) {
|
|
|
|
|
|
|
|
throw new Error('保存行程失败')
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const saveData = await saveRes.json()
|
|
|
|
|
|
|
|
const planId = saveData.plan?.id || saveData.id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 创建分享链接
|
|
|
|
|
|
|
|
const shareRes = await fetch('http://localhost:3001/api/shares', {
|
|
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
|
|
'Authorization': `Bearer ${authStore.token}`
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
body: JSON.stringify({ planId })
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!shareRes.ok) {
|
|
|
|
|
|
|
|
const errData = await shareRes.json()
|
|
|
|
|
|
|
|
throw new Error(errData.error || '创建分享失败')
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const shareData = await shareRes.json()
|
|
|
|
|
|
|
|
console.log('[handleShare] 服务器返回的分享数据:', JSON.stringify(shareData, null, 2))
|
|
|
|
|
|
|
|
shareUrl.value = shareData.shareUrl
|
|
|
|
|
|
|
|
console.log('[handleShare] 最终分享链接:', shareUrl.value)
|
|
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
|
|
console.error('分享失败:', err)
|
|
|
|
|
|
|
|
shareError.value = err.message || '分享失败,请稍后重试'
|
|
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
|
|
shareLoading.value = false
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function closeShareModal() {
|
|
|
|
|
|
|
|
showShareModal.value = false
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function copyShareUrl() {
|
|
|
|
|
|
|
|
navigator.clipboard.writeText(shareUrl.value).then(() => {
|
|
|
|
|
|
|
|
alert('分享链接已复制到剪贴板')
|
|
|
|
|
|
|
|
}).catch(() => {
|
|
|
|
|
|
|
|
alert('复制失败,请手动复制链接')
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 监听 points 和 phase 变化,当进入工作台且有有效数据时自动获取真实路径
|
|
|
|
// 监听 points 和 phase 变化,当进入工作台且有有效数据时自动获取真实路径
|
|
|
|
watch([() => store.points, () => store.phase], ([newPoints, newPhase]) => {
|
|
|
|
watch([() => store.points, () => store.phase], ([newPoints, newPhase]) => {
|
|
|
|
console.log('[Workbench] watch 触发 - phase:', newPhase, 'points 长度:', newPoints.length)
|
|
|
|
console.log('[Workbench] watch 触发 - phase:', newPhase, 'points 长度:', newPoints.length)
|
|
|
|
@ -372,6 +507,159 @@ watch([() => store.points, () => store.phase], ([newPoints, newPhase]) => {
|
|
|
|
cursor: not-allowed;
|
|
|
|
cursor: not-allowed;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* Share Modal */
|
|
|
|
|
|
|
|
.share-modal-overlay {
|
|
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
|
|
top: 0;
|
|
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
|
|
right: 0;
|
|
|
|
|
|
|
|
bottom: 0;
|
|
|
|
|
|
|
|
background: rgba(0, 0, 0, 0.5);
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
|
|
z-index: 2000;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.share-modal {
|
|
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
|
|
|
width: 480px;
|
|
|
|
|
|
|
|
max-width: 90%;
|
|
|
|
|
|
|
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
|
|
|
|
|
|
|
animation: modalFadeIn 0.2s ease-out;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@keyframes modalFadeIn {
|
|
|
|
|
|
|
|
from {
|
|
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
|
|
transform: scale(0.95);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
to {
|
|
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
|
|
transform: scale(1);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.share-modal-header {
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
padding: 16px 20px;
|
|
|
|
|
|
|
|
border-bottom: 1px solid #e9ecef;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.share-modal-header h3 {
|
|
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
|
|
color: #2d3436;
|
|
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.close-btn {
|
|
|
|
|
|
|
|
width: 28px;
|
|
|
|
|
|
|
|
height: 28px;
|
|
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
|
|
background: #f0f0f0;
|
|
|
|
|
|
|
|
color: #636e72;
|
|
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
|
|
transition: all 0.2s;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.close-btn:hover {
|
|
|
|
|
|
|
|
background: #e9ecef;
|
|
|
|
|
|
|
|
color: #2d3436;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.share-modal-body {
|
|
|
|
|
|
|
|
padding: 20px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.share-loading {
|
|
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
|
|
padding: 30px 0;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.share-spinner {
|
|
|
|
|
|
|
|
width: 40px;
|
|
|
|
|
|
|
|
height: 40px;
|
|
|
|
|
|
|
|
border: 3px solid #e8e4ff;
|
|
|
|
|
|
|
|
border-top-color: #6c5ce7;
|
|
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
|
|
animation: spin 1s linear infinite;
|
|
|
|
|
|
|
|
margin: 0 auto 16px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@keyframes spin {
|
|
|
|
|
|
|
|
to { transform: rotate(360deg); }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.share-loading p {
|
|
|
|
|
|
|
|
color: #636e72;
|
|
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.share-success {
|
|
|
|
|
|
|
|
color: #00b894;
|
|
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
|
|
margin: 0 0 16px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.share-url-box {
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.share-url-input {
|
|
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
|
|
padding: 10px 12px;
|
|
|
|
|
|
|
|
border: 2px solid #e9ecef;
|
|
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
|
|
background: #f8f9fa;
|
|
|
|
|
|
|
|
color: #2d3436;
|
|
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.copy-btn {
|
|
|
|
|
|
|
|
padding: 10px 16px;
|
|
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
|
|
border: 2px solid #6c5ce7;
|
|
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
|
|
color: #6c5ce7;
|
|
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
|
|
transition: all 0.2s;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.copy-btn:hover {
|
|
|
|
|
|
|
|
background: #6c5ce7;
|
|
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.share-hint {
|
|
|
|
|
|
|
|
color: #636e72;
|
|
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.share-error {
|
|
|
|
|
|
|
|
color: #d63031;
|
|
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
|
|
padding: 20px 0;
|
|
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Wrap layout */
|
|
|
|
/* Wrap layout */
|
|
|
|
.wb-wrap {
|
|
|
|
.wb-wrap {
|
|
|
|
display: grid;
|
|
|
|
display: grid;
|
|
|
|
@ -458,10 +746,30 @@ watch([() => store.points, () => store.phase], ([newPoints, newPhase]) => {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.wl-nav button:disabled {
|
|
|
|
.wl-nav button:disabled {
|
|
|
|
opacity: 0.3;
|
|
|
|
opacity: 0.4;
|
|
|
|
cursor: not-allowed;
|
|
|
|
cursor: not-allowed;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.wl-share-btn {
|
|
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
|
|
padding: 10px;
|
|
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
|
|
border: 2px solid rgba(0, 184, 148, 0.6);
|
|
|
|
|
|
|
|
background: rgba(0, 184, 148, 0.15);
|
|
|
|
|
|
|
|
color: #95d5b2;
|
|
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
|
|
transition: all 0.2s;
|
|
|
|
|
|
|
|
margin-top: 12px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.wl-share-btn:hover {
|
|
|
|
|
|
|
|
background: rgba(0, 184, 148, 0.3);
|
|
|
|
|
|
|
|
border-color: #00b894;
|
|
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Middle detail panel */
|
|
|
|
/* Middle detail panel */
|
|
|
|
.wb-mid {
|
|
|
|
.wb-mid {
|
|
|
|
background: #fff;
|
|
|
|
background: #fff;
|
|
|
|
|