You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
168 lines
7.7 KiB
168 lines
7.7 KiB
<template>
|
|
<div class="timeline-panel">
|
|
<div class="sidebar-header">
|
|
<span>📋 行程时间线</span>
|
|
<small v-if="store.mode === 'edit'">拖拽排序 · 点击编辑</small>
|
|
<small v-else>点击节点查看详情</small>
|
|
</div>
|
|
|
|
<div class="timeline-list" ref="timelineList">
|
|
<div
|
|
v-for="(point, idx) in store.points"
|
|
:key="point.id"
|
|
class="timeline-item"
|
|
:class="{ active: idx === store.currentStep, completed: idx < store.currentStep, 'no-drag': idx === 0 || idx === store.points.length - 1 }"
|
|
:data-idx="idx"
|
|
@click="handleClick(idx)"
|
|
>
|
|
<div class="tl-line">
|
|
<div class="tl-dot" :class="getDotClass(idx)"></div>
|
|
<div v-if="idx < store.points.length - 1" class="tl-connector" :class="{ passed: idx < store.currentStep }"></div>
|
|
</div>
|
|
<div class="tl-content">
|
|
<div class="tl-day">{{ point.day }}</div>
|
|
<div class="tl-name">{{ point.icon }} {{ point.name }}</div>
|
|
<div class="tl-meta" v-if="point.km && point.km !== '返程'">
|
|
<span>{{ point.km }}</span>
|
|
<span v-if="point.driveTime"> · {{ point.driveTime }}</span>
|
|
</div>
|
|
</div>
|
|
<div v-if="store.mode === 'edit' && idx > 0 && idx < store.points.length - 1" class="tl-actions">
|
|
<span class="drag-handle" title="拖拽排序">⋮⋮</span>
|
|
<button class="action-btn" @click.stop="toggleReplace(idx)" title="替换">🔄</button>
|
|
<button class="action-btn delete-btn" @click.stop="handleDelete(idx)" title="删除">✖</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="nav-controls" v-if="store.mode === 'preview'">
|
|
<button class="nav-btn prev" :disabled="store.currentStep <= 0" @click="handlePrev">← 上一站</button>
|
|
<button class="nav-btn next" :disabled="store.currentStep >= store.points.length - 1" @click="handleNext">下一站 →</button>
|
|
</div>
|
|
|
|
<div class="stats-footer">
|
|
<div class="stat-item">
|
|
<span class="stat-label">总里程</span>
|
|
<span class="stat-value">{{ store.totalKm }}km</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">驾驶</span>
|
|
<span class="stat-value">{{ store.totalDriveTime }}h</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">天数</span>
|
|
<span class="stat-value">{{ store.travelDays }}天</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, nextTick } from 'vue'
|
|
import { useItineraryStore } from '../stores/itinerary'
|
|
import Sortable from 'sortablejs'
|
|
|
|
const store = useItineraryStore()
|
|
const emit = defineEmits(['replace', 'animate', 'prevStep', 'nextStep'])
|
|
const timelineList = ref(null)
|
|
let sortableInstance = null
|
|
|
|
const getDotClass = (idx) => {
|
|
if (idx === 0) return 'start'
|
|
if (idx === store.points.length - 1) return 'end'
|
|
if (idx === store.currentStep) return 'active'
|
|
if (idx < store.currentStep) return 'passed'
|
|
return ''
|
|
}
|
|
|
|
const handleClick = (idx) => {
|
|
const prevStep = store.currentStep
|
|
store.setCurrentStep(idx)
|
|
if (store.mode === 'preview') emit('animate', idx, prevStep)
|
|
}
|
|
|
|
const handleDelete = (idx) => {
|
|
if (confirm(`确认删除「${store.points[idx].name}」?`)) store.deletePoint(idx)
|
|
}
|
|
|
|
const handlePrev = () => { emit('prevStep') }
|
|
const handleNext = () => { emit('nextStep') }
|
|
|
|
const toggleReplace = (idx) => {
|
|
store.selectedForReplace = idx
|
|
store.showReplacementPanel = !store.showReplacementPanel
|
|
}
|
|
|
|
const initSortable = () => {
|
|
if (!timelineList.value) return
|
|
if (sortableInstance) { sortableInstance.destroy(); sortableInstance = null }
|
|
if (store.mode !== 'edit') return
|
|
|
|
sortableInstance = Sortable.create(timelineList.value, {
|
|
animation: 150,
|
|
handle: '.drag-handle',
|
|
ghostClass: 'sortable-ghost',
|
|
chosenClass: 'sortable-chosen',
|
|
dragClass: 'sortable-drag',
|
|
filter: '.no-drag',
|
|
onEnd: (evt) => {
|
|
if (evt.oldIndex === evt.newIndex) return
|
|
store.reorderPoints(evt.oldIndex, evt.newIndex)
|
|
}
|
|
})
|
|
}
|
|
|
|
onMounted(() => { nextTick(() => initSortable()) })
|
|
|
|
// Re-init when mode changes or points change
|
|
import { watch } from 'vue'
|
|
watch(() => store.mode, () => { nextTick(() => initSortable()) })
|
|
watch(() => store.points.length, () => { nextTick(() => initSortable()) })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.timeline-panel { display: flex; flex-direction: column; height: 100%; background: #1b4332; color: #fff; }
|
|
.sidebar-header { padding: 16px; border-bottom: 1px solid rgba(255,255,255,0.1); display: flex; flex-direction: column; gap: 4px; }
|
|
.sidebar-header span { font-size: 16px; font-weight: 600; }
|
|
.sidebar-header small { color: rgba(255,255,255,0.6); font-size: 12px; }
|
|
.timeline-list { flex: 1; overflow-y: auto; padding: 8px 0; }
|
|
.timeline-item { display: flex; align-items: flex-start; padding: 8px 16px; cursor: pointer; transition: all 0.2s; position: relative; }
|
|
.timeline-item:hover { background: rgba(255,255,255,0.05); }
|
|
.timeline-item.active { background: rgba(244,162,97,0.15); }
|
|
.timeline-item.completed .tl-content { opacity: 0.7; }
|
|
.tl-line { position: relative; width: 20px; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; margin-top: 4px; }
|
|
.tl-dot { width: 12px; height: 12px; border-radius: 50%; background: #52796f; border: 2px solid rgba(255,255,255,0.3); transition: all 0.3s; z-index: 2; }
|
|
.tl-dot.start { background: #2a9d8f; border-color: #2a9d8f; }
|
|
.tl-dot.end { background: #e76f51; border-color: #e76f51; }
|
|
.tl-dot.active { background: #f4a261; border-color: #f4a261; transform: scale(1.3); }
|
|
.tl-dot.passed { background: #2a9d8f; border-color: #2a9d8f; }
|
|
.tl-connector { width: 2px; height: 40px; background: #52796f; margin-top: 2px; transition: all 0.3s; }
|
|
.tl-connector.passed { background: #2a9d8f; }
|
|
.tl-content { flex: 1; min-width: 0; }
|
|
.tl-day { font-size: 11px; color: rgba(255,255,255,0.5); margin-bottom: 2px; }
|
|
.tl-name { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.timeline-item.active .tl-name { color: #f4a261; }
|
|
.tl-meta { font-size: 11px; color: rgba(255,255,255,0.5); margin-top: 2px; }
|
|
.tl-actions { display: flex; gap: 4px; margin-left: 8px; flex-shrink: 0; }
|
|
.drag-handle { color: rgba(255,255,255,0.4); cursor: grab; font-size: 12px; user-select: none; padding: 0 4px; }
|
|
.drag-handle:active { cursor: grabbing; }
|
|
.action-btn { background: none; border: none; color: rgba(255,255,255,0.5); cursor: pointer; padding: 2px 4px; border-radius: 4px; font-size: 12px; transition: all 0.2s; }
|
|
.action-btn:hover { background: rgba(255,255,255,0.1); color: #fff; }
|
|
.delete-btn:hover { color: #e76f51; }
|
|
.nav-controls { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid rgba(255,255,255,0.1); }
|
|
.nav-btn { flex: 1; padding: 10px 12px; border: none; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.2s; color: #fff; }
|
|
.nav-btn.prev { background: #40916c; }
|
|
.nav-btn.next { background: #f4a261; color: #1b4332; }
|
|
.nav-btn:hover:not(:disabled) { transform: translateY(-1px); }
|
|
.nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
|
.stats-footer { display: flex; border-top: 1px solid rgba(255,255,255,0.1); padding: 12px 16px; gap: 12px; }
|
|
.stat-item { flex: 1; text-align: center; }
|
|
.stat-label { display: block; font-size: 10px; color: rgba(255,255,255,0.5); text-transform: uppercase; }
|
|
.stat-value { display: block; font-size: 14px; font-weight: 600; color: #f4a261; margin-top: 2px; }
|
|
|
|
/* Sortable styles */
|
|
:deep(.sortable-ghost) { opacity: 0.4; background: rgba(244,162,97,0.2) !important; }
|
|
:deep(.sortable-chosen) { background: rgba(244,162,97,0.3) !important; }
|
|
:deep(.sortable-drag) { opacity: 0.8; box-shadow: 0 4px 12px rgba(0,0,0,0.3); }
|
|
</style>
|