From 4c390d209b45d516fbe2f7552d26048687c83972 Mon Sep 17 00:00:00 2001
From: chenyao <1219716595@qq.com>
Date: Wed, 05 Aug 2026 14:29:25 +0800
Subject: [PATCH] feat:tab切换太快的时候内容不对应

---
 applications/drone-command/src/components/map-container/common-cesium-map.vue |  428 +++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 files changed, 410 insertions(+), 18 deletions(-)

diff --git a/applications/drone-command/src/components/map-container/common-cesium-map.vue b/applications/drone-command/src/components/map-container/common-cesium-map.vue
index eec995b..f1c9be7 100644
--- a/applications/drone-command/src/components/map-container/common-cesium-map.vue
+++ b/applications/drone-command/src/components/map-container/common-cesium-map.vue
@@ -1,11 +1,51 @@
 <template>
-	<div :id="mapId" class="common-cesium-map"></div>
+	<div :id="mapId" class="common-cesium-map" @contextmenu.prevent>
+		<template v-if="props.list && props.list.length">
+			<div class="card">
+				<div class="timeline-prefix">
+					<slot name="timeline-prefix"></slot>
+				</div>
+				<div
+					ref="timelineRef"
+					class="card-content"
+					:class="{ 'is-dragging': timelineDragging }"
+					@mousedown="handleTimelineMouseDown"
+					@mousemove="handleTimelineMouseMove"
+					@mouseup="stopTimelineDrag"
+					@mouseleave="stopTimelineDrag"
+				>
+					<div class="timeline-track">
+						<div class="time-line"></div>
+						<div
+							class="card-item"
+							v-for="(item, index) in props.list"
+							@click="handleTimelineItemClick(item, index)"
+						>
+							<div class="time-point">
+								<img
+									class="active"
+									v-if="index === selectedImgIndex"
+									src="@/assets/images/common/point-active.png"
+									alt=""
+								/>
+								<img v-else src="@/assets/images/common/point.png" alt="" />
+							</div>
+							<div class="time-bottom">{{ item.createTime.slice(11) }}</div>
+						</div>
+					</div>
+				</div>
+			</div>
+		</template>
+	</div>
 </template>
 
 <script setup>
-import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import * as Cesium from 'cesium'
+
+import { onBeforeUnmount, onMounted, watch } from 'vue'
 import { PublicCesium } from '@/utils/cesium/publicCesium'
 import { loadJaAdminBoundary, removeJaAdminBoundary } from '@/utils/cesium/adminBoundary'
+import positionIcon from '@/assets/images/dataCenter/positionicon.png'
 
 const props = defineProps({
 	active: {
@@ -56,12 +96,26 @@
 		type: Number,
 		default: 10000,
 	},
+	list: {
+		type: Array,
+		default: () => [],
+	},
 })
 
-const emit = defineEmits(['ready', 'height-change', 'stage-change'])
+const emit = defineEmits(['ready', 'height-change', 'stage-change', 'point-change'])
 const mapId = props.domId || `common-cesium-map-${Math.random().toString(36).slice(2, 10)}`
-const viewInstance = ref(null)
-const viewer = ref(null)
+const timelineRef = ref(null)
+const timelineDragging = ref(false)
+let timelineStartX = 0
+let timelineStartScrollLeft = 0
+let timelineDidDrag = false
+let viewInstance = null
+let viewer = null
+const ADMIN_BOUNDARY_NAMES = {
+	boundary: 'jaBoundarySource',
+	line: 'jaBoundaryLineSource',
+	label: 'jaBoundaryLabelSource',
+}
 let adminBoundarySources = null
 let initialized = false
 let removeCameraListener = null
@@ -69,7 +123,7 @@
 const initMap = async () => {
 	if (initialized) return
 	initialized = true
-	viewInstance.value = new PublicCesium({
+	viewInstance = new PublicCesium({
 		dom: mapId,
 		flatMode: props.flatMode,
 		terrain: props.terrain,
@@ -77,16 +131,16 @@
 		boundary: props.boundary,
 		contour: props.contour,
 	})
-	viewer.value = viewInstance.value.getViewer()
+	viewer = viewInstance.getViewer()
 	if (props.showAdminBoundary) {
-		adminBoundarySources = await loadJaAdminBoundary(viewer.value, {
+		adminBoundarySources = await loadJaAdminBoundary(viewer, {
 			zoomTo: props.zoomToBoundary,
 		})
 	}
 	if (props.enableStageEmit) {
 		let lastStage = ''
 		const onCameraChanged = () => {
-			const height = viewer.value?.camera?.positionCartographic?.height
+			const height = viewer?.camera?.positionCartographic?.height
 			if (height == null) return
 			emit('height-change', height)
 			let stage = 'mid'
@@ -97,11 +151,146 @@
 				emit('stage-change', stage)
 			}
 		}
-		viewer.value?.camera?.changed?.addEventListener(onCameraChanged)
-		removeCameraListener = () => viewer.value?.camera?.changed?.removeEventListener(onCameraChanged)
+		viewer?.camera?.changed?.addEventListener(onCameraChanged)
+		removeCameraListener = () => viewer?.camera?.changed?.removeEventListener(onCameraChanged)
 	}
-	emit('ready', { viewer: viewer.value, publicCesium: viewInstance.value })
+	emit('ready', { viewer, publicCesium: viewInstance })
+	// 地图就绪时,若列表已加载则默认标记选中项(首个)
+	showSelectedMarker()
 }
+
+const selectedImgIndex = ref(0)
+// 航线高度(与 TrajectoryDiaLog 里 MOCK_FLIGHT_HEIGHT 保持一致),让标记悬浮在轨迹线上
+const FLIGHT_HEIGHT = 120
+const POSITION_MOVE_DURATION = 1000
+// 当前经纬度位置标记
+let positionEntity = null
+let positionAnimationFrame = null
+
+// 停止位置图标移动动画
+const stopPositionAnimation = () => {
+	if (positionAnimationFrame !== null) {
+		cancelAnimationFrame(positionAnimationFrame)
+		positionAnimationFrame = null
+	}
+}
+
+// 平滑移动位置图标
+const animatePositionMarker = (targetPosition, duration) => {
+	const currentPosition = positionEntity?.position?.getValue(viewer.clock.currentTime, new Cesium.Cartesian3())
+	if (!currentPosition) {
+		positionEntity.position = targetPosition
+		return
+	}
+	stopPositionAnimation()
+	const startTime = performance.now()
+	const nextPosition = new Cesium.Cartesian3()
+
+	// 更新位置图标移动进度
+	const updatePosition = currentTime => {
+		const progress = Math.min((currentTime - startTime) / duration, 1)
+		Cesium.Cartesian3.lerp(currentPosition, targetPosition, progress, nextPosition)
+		positionEntity.position.setValue(nextPosition)
+		viewer.scene.requestRender()
+		if (progress < 1) {
+			positionAnimationFrame = requestAnimationFrame(updatePosition)
+		} else {
+			positionAnimationFrame = null
+		}
+	}
+	positionAnimationFrame = requestAnimationFrame(updatePosition)
+}
+
+// 点击周期
+const clickWeekTime = (item, index, options = {}) => {
+	// clickImgSrc.value = item.url
+	selectedImgIndex.value = index
+	showPositionMarker(item, options)
+	// 通知父组件当前选中的轨迹点,用于右侧表格同步高亮
+	emit('point-change', { item, index })
+}
+
+// 开始拖动时间轴
+const handleTimelineMouseDown = event => {
+	if (event.button !== 0) return
+	timelineDragging.value = true
+	timelineDidDrag = false
+	timelineStartX = event.clientX
+	timelineStartScrollLeft = timelineRef.value?.scrollLeft ?? 0
+}
+
+// 横向拖动时间轴
+const handleTimelineMouseMove = event => {
+	if (!timelineDragging.value || !timelineRef.value) return
+	const offsetX = event.clientX - timelineStartX
+	if (Math.abs(offsetX) > 3) timelineDidDrag = true
+	if (!timelineDidDrag) return
+	timelineRef.value.scrollLeft = timelineStartScrollLeft - offsetX
+	event.preventDefault()
+}
+
+// 停止拖动时间轴
+const stopTimelineDrag = () => {
+	timelineDragging.value = false
+	setTimeout(() => {
+		timelineDidDrag = false
+	}, 0)
+}
+
+// 点击时间轴节点
+const handleTimelineItemClick = (item, index) => {
+	if (timelineDidDrag) return
+	clickWeekTime(item, index)
+}
+
+// 在地图上通过经纬度显示位置图标
+const showPositionMarker = (item, { animate = false, duration = POSITION_MOVE_DURATION } = {}) => {
+	if (!viewer) return
+	const longitude = Number(item?.longitude)
+	const latitude = Number(item?.latitude)
+	if (Number.isNaN(longitude) || Number.isNaN(latitude)) return
+
+	// 与航线相同的高度,让标记悬浮在轨迹线上,而不是贴地
+	const position = Cesium.Cartesian3.fromDegrees(longitude, latitude, FLIGHT_HEIGHT)
+	if (positionEntity) {
+		if (animate) {
+			animatePositionMarker(position, duration)
+		} else {
+			stopPositionAnimation()
+			positionEntity.position = position
+		}
+	} else {
+		positionEntity = viewer.entities.add({
+			position,
+			billboard: {
+				image: positionIcon,
+				width: 36,
+				height: 36,
+				verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
+				disableDepthTestDistance: Number.POSITIVE_INFINITY,
+			},
+		})
+	}
+}
+
+// 显示当前选中项的标记(默认首个),地图就绪和列表加载后都会调用
+const showSelectedMarker = () => {
+	const item = props.list?.[selectedImgIndex.value]
+	if (item) showPositionMarker(item)
+}
+
+// 列表数据到位后,默认选中并标记首个点
+watch(
+	() => props.list,
+	list => {
+		if (list && list.length) {
+			selectedImgIndex.value = 0
+			showSelectedMarker()
+			// 默认选中首个,同步通知父组件高亮表格首行
+			emit('point-change', { item: list[0], index: 0 })
+		}
+	}
+)
 
 watch(
 	() => props.active,
@@ -115,21 +304,224 @@
 })
 
 onBeforeUnmount(() => {
+	stopPositionAnimation()
 	if (removeCameraListener) removeCameraListener()
-	removeJaAdminBoundary(viewer.value, adminBoundarySources)
-	viewInstance.value?.viewerDestroy?.()
+	removeJaAdminBoundary(viewer, adminBoundarySources)
+	adminBoundarySources = null
+	positionEntity = null
+	viewInstance?.viewerDestroy?.()
 })
 
-const getMap = () => ({ viewer: viewer.value, publicCesium: viewInstance.value })
-const getViewer = () => viewer.value
-const getPublicCesium = () => viewInstance.value
+const setAdminBoundaryVisible = async visible => {
+	if (!viewer) return
+	const removeByNames = () => {
+		Object.values(ADMIN_BOUNDARY_NAMES).forEach(name => {
+			const sources = viewer.dataSources.getByName(name) || []
+			sources.forEach(source => viewer.dataSources.remove(source))
+		})
+	}
+	const resolveSources = () => {
+		if (adminBoundarySources) return adminBoundarySources
+		const boundarySource = viewer.dataSources.getByName(ADMIN_BOUNDARY_NAMES.boundary)?.[0] ?? null
+		const lineSource = viewer.dataSources.getByName(ADMIN_BOUNDARY_NAMES.line)?.[0] ?? null
+		const labelSource = viewer.dataSources.getByName(ADMIN_BOUNDARY_NAMES.label)?.[0] ?? null
+		if (boundarySource || lineSource || labelSource) {
+			adminBoundarySources = { boundarySource, lineSource, labelSource }
+		}
+		return adminBoundarySources
+	}
+	const setVisible = (source, next) => {
+		if (!source) return
+		source.show = next
+		source.entities?.values?.forEach(entity => {
+			entity.show = next
+		})
+	}
+	const sources = resolveSources()
+	if (!visible) {
+		if (sources) {
+			removeJaAdminBoundary(viewer, sources)
+			adminBoundarySources = null
+		}
+		removeByNames()
+		return
+	}
+	if (sources) {
+		const { boundarySource, lineSource, labelSource } = sources
+		setVisible(boundarySource, true)
+		setVisible(lineSource, true)
+		setVisible(labelSource, true)
+		return
+	}
+	adminBoundarySources = await loadJaAdminBoundary(viewer, {
+		zoomTo: false,
+	})
+}
 
-defineExpose({ getMap, getViewer, getPublicCesium })
+const zoomToAdminBoundary = async () => {
+	if (!viewer) return
+	const resolveSources = () => {
+		if (adminBoundarySources) return adminBoundarySources
+		const boundarySource = viewer.dataSources.getByName(ADMIN_BOUNDARY_NAMES.boundary)?.[0] ?? null
+		const lineSource = viewer.dataSources.getByName(ADMIN_BOUNDARY_NAMES.line)?.[0] ?? null
+		const labelSource = viewer.dataSources.getByName(ADMIN_BOUNDARY_NAMES.label)?.[0] ?? null
+		if (boundarySource || lineSource || labelSource) {
+			adminBoundarySources = { boundarySource, lineSource, labelSource }
+		}
+		return adminBoundarySources
+	}
+	let sources = resolveSources()
+	if (!sources?.boundarySource) {
+		sources = await loadJaAdminBoundary(viewer, { zoomTo: false })
+		adminBoundarySources = sources
+	}
+	const target = sources?.boundarySource
+	if (!target) return
+	await viewer.flyTo(target, {
+		duration: 0,
+		offset: new Cesium.HeadingPitchRange(0, Cesium.Math.toRadians(-75), 0),
+	})
+}
+
+const getMap = () => ({ viewer, publicCesium: viewInstance })
+const getViewer = () => viewer
+const getPublicCesium = () => viewInstance
+
+const flyToPoint = (options) => {
+	const { longitude, latitude, height } = options.position
+
+	viewer.camera.setView({
+		destination: Cesium.Cartesian3.fromDegrees(longitude, latitude, height),
+		orientation: {
+			heading: Cesium.Math.toRadians(0), // 方向
+			pitch: Cesium.Math.toRadians(-90), // 仰角
+			roll: Cesium.Math.toRadians(0) // 翻滚角
+		}
+	})
+}
+
+defineExpose({ getMap, getViewer, getPublicCesium, flyToPoint, clickWeekTime, setAdminBoundaryVisible, zoomToAdminBoundary })
 </script>
 
 <style scoped lang="scss">
 .common-cesium-map {
 	width: 100%;
 	height: 100%;
+	position: relative;
+	.card {
+		position: absolute;
+		bottom: 0px;
+		width: 100%;
+		height: 67px;
+		box-sizing: border-box;
+		background: rgba(0, 0, 0, 0.42);
+		border-radius: 0;
+
+		color: #d5d5d5;
+		font-size: 14px;
+
+		.timeline-prefix {
+			position: absolute;
+			left: 7.5%;
+			top: 50%;
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			z-index: 2;
+			transform: translate(-50%, -50%);
+		}
+
+		.card-content {
+			display: flex;
+			position: absolute;
+			left: 15%;
+			width: 70%;
+			height: 100%;
+			overflow-x: auto;
+			overflow-y: hidden;
+			white-space: nowrap;
+			cursor: grab;
+			scrollbar-width: none;
+
+			&::-webkit-scrollbar {
+				display: none;
+			}
+
+			&.is-dragging {
+				cursor: grabbing;
+				user-select: none;
+			}
+
+			.timeline-track {
+				position: relative;
+				display: flex;
+				flex: 0 0 auto;
+				height: 100%;
+				margin: 0 auto;
+			}
+
+			.card-item {
+				position: relative;
+				flex: 0 0 82px;
+				width: 82px;
+				text-align: center;
+				cursor: pointer;
+			}
+
+			&.is-dragging .card-item {
+				cursor: grabbing;
+			}
+
+			.time-top {
+				margin-top: 10px;
+				line-height: 1;
+				text-align: center;
+				cursor: pointer;
+			}
+
+			.active {
+				font-weight: 400;
+				color: #ffffff;
+			}
+
+			.time-point {
+				display: flex;
+				align-items: center;
+				justify-content: center;
+				position: absolute;
+				top: 22px;
+				left: 50%;
+				width: 18px;
+				height: 18px;
+				z-index: 1;
+				transform: translate(-50%, 0);
+
+				img {
+					width: 11.2px;
+					height: 11.2px;
+				}
+
+				img.active {
+					width: 16.89px;
+					height: 16.89px;
+				}
+			}
+
+			.time-bottom {
+				margin-top: 44px;
+			}
+
+			.time-line {
+				position: absolute;
+				left: 41px;
+				right: 41px;
+				bottom: 36px;
+				height: 1px;
+				background: rgba(237, 237, 237, 0.6);
+				z-index: 0;
+				pointer-events: none;
+			}
+		}
+	}
 }
 </style>

--
Gitblit v1.9.3