450 lines
13 KiB
Vue
450 lines
13 KiB
Vue
![]() |
<template>
|
|||
|
<div class="track-player">
|
|||
|
<!-- 地图容器 -->
|
|||
|
<div id="amap-container" class="map-container"></div>
|
|||
|
<div class="content_top">
|
|||
|
<div class="content_layout">
|
|||
|
<div v-if="deviceGroups && deviceGroups.length > 0">
|
|||
|
<el-timeline style="margin-left: -50px;">
|
|||
|
<!-- 日期分组循环 -->
|
|||
|
<el-timeline-item v-for="(group, groupIndex) in deviceGroups" :key="groupIndex"
|
|||
|
:timestamp="group.date" placement="top" icon="Time" color="rgba(0, 198, 250, 1)">
|
|||
|
<!-- 设备项循环 -->
|
|||
|
<div class="device-item">
|
|||
|
<!-- 设备名称 + 时间 -->
|
|||
|
<div class="device-header">
|
|||
|
<span class="device-name">{{ group.deviceName }}</span>
|
|||
|
<span class="device-time">{{ formatTimestampToHM(group.detailList[0].timestamp) }}</span>
|
|||
|
</div>
|
|||
|
<!-- 地点信息 -->
|
|||
|
<div class="device-place">
|
|||
|
<div class="d_f">
|
|||
|
<div class="Stime">初始地点:</div>
|
|||
|
<div class="Sloation">{{
|
|||
|
group.startLocation }}</div>
|
|||
|
</div>
|
|||
|
<div class="d_f">
|
|||
|
<div class="Stime">结束地点:</div>
|
|||
|
<div class="Sloation">{{
|
|||
|
group.endLocation }}</div>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
<!-- 轨迹播放按钮 -->
|
|||
|
<div class="control_btt">
|
|||
|
<el-button class="control_btn" @click="handlePlayPause(group)">轨迹播放</el-button>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</el-timeline-item>
|
|||
|
</el-timeline>
|
|||
|
</div>
|
|||
|
<div v-if="deviceGroups == null" class="nodata">
|
|||
|
<img src="@/assets/images/nodata.png" alt="" class="nodataImg">
|
|||
|
<div class="title">暂无数据</div>
|
|||
|
</div>
|
|||
|
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</template>
|
|||
|
|
|||
|
<script setup lang="ts">
|
|||
|
import api from "@/api/controlCenter/historyjectory/index"
|
|||
|
import { formatTimestampToHM } from "@/utils/function"
|
|||
|
const route = useRoute();
|
|||
|
// -------------------------- 状态管理 --------------------------
|
|||
|
// 地图实例
|
|||
|
const mapRef = ref<AMap.Map | null>(null);
|
|||
|
// 轨迹线实例
|
|||
|
const polylineRef = ref<AMap.Polyline | null>(null);
|
|||
|
// 移动标记点实例
|
|||
|
const markerRef = ref<AMap.Marker | null>(null);
|
|||
|
// 轨迹数据(初始为空,从设备列表动态获取)
|
|||
|
const trackPoints = ref<[number, number][]>([]); // 移除硬编码默认值
|
|||
|
// 设备分组数据(包含各设备的轨迹点)
|
|||
|
const deviceGroups = ref([])
|
|||
|
// 播放状态
|
|||
|
const isPlaying = ref<boolean>(false);
|
|||
|
const isLoading = ref<boolean>(true);
|
|||
|
const playSpeed = ref<number>(1);
|
|||
|
const currentIndex = ref<number>(0);
|
|||
|
const playTimer = ref<number | null>(null);
|
|||
|
|
|||
|
// -------------------------- 地图初始化 --------------------------
|
|||
|
const initMap = () => {
|
|||
|
try {
|
|||
|
if (!window.AMap) {
|
|||
|
throw new Error('高德地图API加载失败');
|
|||
|
}
|
|||
|
// 创建地图实例:不设置 center 和 zoom,由 setBounds 自动调整
|
|||
|
const map = new AMap.Map('amap-container', {
|
|||
|
resizeEnable: true, // 允许地图自适应容器大小
|
|||
|
// 移除 center、zoom 等固定视角的配置
|
|||
|
});
|
|||
|
mapRef.value = map;
|
|||
|
// 创建轨迹线(初始无数据)
|
|||
|
const polyline = new AMap.Polyline({
|
|||
|
path: [],
|
|||
|
strokeColor: '#3886ff',
|
|||
|
strokeWeight: 10,
|
|||
|
strokeOpacity: 0.8,
|
|||
|
});
|
|||
|
map.add(polyline);
|
|||
|
polylineRef.value = polyline;
|
|||
|
// 创建标记点(初始位置临时设为 [0, 0],播放时会覆盖)
|
|||
|
const marker = new AMap.Marker({
|
|||
|
position: [0, 0],
|
|||
|
icon: new AMap.Icon({
|
|||
|
size: new AMap.Size(40, 40),
|
|||
|
image: 'https://webapi.amap.com/images/car.png',
|
|||
|
imageSize: new AMap.Size(40, 40),
|
|||
|
}),
|
|||
|
anchor: 'center',
|
|||
|
});
|
|||
|
map.add(marker);
|
|||
|
markerRef.value = marker;
|
|||
|
|
|||
|
isLoading.value = false;
|
|||
|
fetchTrackData(); // 数据请求移到地图初始化后
|
|||
|
} catch (error) {
|
|||
|
console.error('地图初始化失败:', error);
|
|||
|
isLoading.value = false;
|
|||
|
}
|
|||
|
};
|
|||
|
// 单独的接口请求函数
|
|||
|
const fetchTrackData = () => {
|
|||
|
const data = {
|
|||
|
id: route.params.id as string
|
|||
|
};
|
|||
|
api.getLocationHistoryDetail(data).then((res) => {
|
|||
|
if (res.code === 200) {
|
|||
|
deviceGroups.value = res.data;
|
|||
|
// 数据加载完成后,自动播放第一个设备的轨迹
|
|||
|
if (deviceGroups.value&&deviceGroups.value.length > 0) {
|
|||
|
const firstGroup = deviceGroups.value[0];
|
|||
|
const firstDetailList = firstGroup.detailList;
|
|||
|
// 检查轨迹列表是否有数据
|
|||
|
if (firstDetailList && firstDetailList.length > 0) {
|
|||
|
const firstPoint = firstDetailList[0];
|
|||
|
const centerLng = Number(firstPoint.longitude);
|
|||
|
const centerLat = Number(firstPoint.latitude);
|
|||
|
// 验证经纬度有效性
|
|||
|
if (!isNaN(centerLng) && !isNaN(centerLat) && mapRef.value) {
|
|||
|
// 设置地图中心为第一个轨迹点
|
|||
|
mapRef.value.setCenter([centerLng, centerLat]);
|
|||
|
// 设置合适的缩放级别(16为街道级,清晰无需手动放大)
|
|||
|
mapRef.value.setZoom(10);
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
} else {
|
|||
|
ElMessage.error('获取轨迹数据失败');
|
|||
|
}
|
|||
|
})
|
|||
|
};
|
|||
|
|
|||
|
// -------------------------- 轨迹播放核心逻辑 --------------------------
|
|||
|
// 平滑移动标记点
|
|||
|
const smoothMoveMarker = (
|
|||
|
start: [number, number],
|
|||
|
end: [number, number],
|
|||
|
duration: number
|
|||
|
) => {
|
|||
|
const startLngLat = new AMap.LngLat(start[0], start[1]);
|
|||
|
const endLngLat = new AMap.LngLat(end[0], end[1]);
|
|||
|
const distance = startLngLat.distance(endLngLat);
|
|||
|
const speed = distance / duration;
|
|||
|
let startTime = Date.now();
|
|||
|
const moveFrame = () => {
|
|||
|
const elapsed = Date.now() - startTime;
|
|||
|
const progress = Math.min(elapsed / duration, 1);
|
|||
|
const currentLng = start[0] + (end[0] - start[0]) * progress;
|
|||
|
const currentLat = start[1] + (end[1] - start[1]) * progress;
|
|||
|
markerRef.value?.setPosition([currentLng, currentLat]);
|
|||
|
if (progress < 1) {
|
|||
|
playTimer.value = requestAnimationFrame(moveFrame);
|
|||
|
} else {
|
|||
|
currentIndex.value += 1;
|
|||
|
if (currentIndex.value < trackPoints.value.length - 1) {
|
|||
|
playNextSegment();
|
|||
|
} else {
|
|||
|
// 播放完成
|
|||
|
isPlaying.value = false;
|
|||
|
currentIndex.value = 0;
|
|||
|
}
|
|||
|
}
|
|||
|
};
|
|||
|
|
|||
|
playTimer.value = requestAnimationFrame(moveFrame);
|
|||
|
};
|
|||
|
|
|||
|
// 播放下一段轨迹
|
|||
|
const playNextSegment = () => {
|
|||
|
if (currentIndex.value >= trackPoints.value.length - 1) return;
|
|||
|
const startPoint = trackPoints.value[currentIndex.value];
|
|||
|
const endPoint = trackPoints.value[currentIndex.value + 1];
|
|||
|
const baseDuration = 500;
|
|||
|
const duration = baseDuration / playSpeed.value;
|
|||
|
smoothMoveMarker(startPoint, endPoint, duration);
|
|||
|
};
|
|||
|
|
|||
|
// 重置播放状态(切换设备时调用)
|
|||
|
const resetPlayState = () => {
|
|||
|
// 停止当前播放
|
|||
|
if (isPlaying.value && playTimer.value) {
|
|||
|
cancelAnimationFrame(playTimer.value);
|
|||
|
}
|
|||
|
// 重置状态
|
|||
|
isPlaying.value = false;
|
|||
|
currentIndex.value = 0;
|
|||
|
};
|
|||
|
|
|||
|
// 播放/暂停切换(接收设备的trackPoints)
|
|||
|
const handlePlayPause = (group: any) => {
|
|||
|
const trackPointList = group.detailList;
|
|||
|
const validTrackPoints = trackPointList.map((point: any) => {
|
|||
|
const lng = Number(point.longitude); // 经度
|
|||
|
const lat = Number(point.latitude); // 纬度
|
|||
|
// 验证经纬度有效性(避免NaN)
|
|||
|
return isNaN(lng) || isNaN(lat) ? null : [lng, lat] as [number, number];
|
|||
|
}).filter((point: any) => point !== null); // 过滤无效点
|
|||
|
|
|||
|
if (validTrackPoints.length < 2) {
|
|||
|
ElMessage.warning('有效轨迹点不足2个,无法播放');
|
|||
|
return;
|
|||
|
}
|
|||
|
|
|||
|
// 3. 重置播放状态并赋值轨迹数据
|
|||
|
resetPlayState();
|
|||
|
trackPoints.value = validTrackPoints;
|
|||
|
|
|||
|
// 4. 开始播放
|
|||
|
isPlaying.value = true;
|
|||
|
playNextSegment();
|
|||
|
};
|
|||
|
|
|||
|
// -------------------------- 生命周期与监听 --------------------------
|
|||
|
onMounted(() => {
|
|||
|
initMap();
|
|||
|
});
|
|||
|
|
|||
|
onUnmounted(() => {
|
|||
|
if (playTimer.value) {
|
|||
|
cancelAnimationFrame(playTimer.value);
|
|||
|
}
|
|||
|
mapRef.value?.remove(polylineRef.value!);
|
|||
|
mapRef.value?.remove(markerRef.value!);
|
|||
|
});
|
|||
|
|
|||
|
// 监听轨迹数据变化(更新轨迹线和地图范围)
|
|||
|
watch(trackPoints, (newPoints) => {
|
|||
|
if (!mapRef.value || !polylineRef.value || !markerRef.value) return;
|
|||
|
if (newPoints.length === 0) return;
|
|||
|
// 1. 更新轨迹线
|
|||
|
polylineRef.value.setPath(newPoints);
|
|||
|
// 2. 获取第一个轨迹点(作为地图中心)
|
|||
|
const firstPoint = newPoints[0]; // [经度, 纬度]
|
|||
|
const centerLngLat = new AMap.LngLat(firstPoint[0], firstPoint[1]);
|
|||
|
// 3. 地图聚焦到第一个点,并设置固定zoom(16为街道级,足够大,无需手动放大)
|
|||
|
mapRef.value.setCenter(centerLngLat); // 设中心
|
|||
|
mapRef.value.setZoom(15); // 固定缩放级别(1-20,越大越近)
|
|||
|
// 4. 标记点移到第一个点
|
|||
|
markerRef.value.setPosition(firstPoint);
|
|||
|
}, { deep: true });
|
|||
|
</script>
|
|||
|
|
|||
|
<style scoped>
|
|||
|
/* 样式保持不变 */
|
|||
|
:deep .el-timeline-item__tail {
|
|||
|
border-left: 1px solid rgba(0, 198, 250, 0.2);
|
|||
|
}
|
|||
|
|
|||
|
:deep .el-timeline-item__timestamp {
|
|||
|
color: rgba(2, 124, 251, 1);
|
|||
|
font-size: 16px;
|
|||
|
}
|
|||
|
|
|||
|
.track-player {
|
|||
|
width: 100%;
|
|||
|
height: 90vh;
|
|||
|
position: relative;
|
|||
|
}
|
|||
|
|
|||
|
.map-container {
|
|||
|
width: 100%;
|
|||
|
height: 100%;
|
|||
|
}
|
|||
|
|
|||
|
.content_top {
|
|||
|
width: 300px;
|
|||
|
border-radius: 4px;
|
|||
|
box-shadow: 0px 0px 6px 0px rgba(0, 34, 96, 0.1);
|
|||
|
background: #fff;
|
|||
|
height: 88vh;
|
|||
|
position: absolute;
|
|||
|
z-index: 1;
|
|||
|
top: 10px;
|
|||
|
left: 10px;
|
|||
|
overflow-y: auto;
|
|||
|
}
|
|||
|
|
|||
|
.Stime {
|
|||
|
color: rgba(56, 64, 79, 0.6);
|
|||
|
white-space: nowrap;
|
|||
|
}
|
|||
|
|
|||
|
.Sloation {
|
|||
|
color: rgba(56, 64, 79, 1);
|
|||
|
width: 150px;
|
|||
|
display: inline-block;
|
|||
|
}
|
|||
|
|
|||
|
.d_f {
|
|||
|
display: flex;
|
|||
|
}
|
|||
|
|
|||
|
.content_layout {
|
|||
|
padding: 20px;
|
|||
|
}
|
|||
|
|
|||
|
/* 时间线整体 */
|
|||
|
.el-timeline {
|
|||
|
margin-left: 20px;
|
|||
|
/* 调整时间线与左侧的间距 */
|
|||
|
}
|
|||
|
|
|||
|
/* 时间戳(日期) */
|
|||
|
.el-timeline-item__timestamp {
|
|||
|
font-weight: bold;
|
|||
|
margin-bottom: 8px;
|
|||
|
color: rgba(2, 124, 251, 1);
|
|||
|
}
|
|||
|
|
|||
|
/* 设备项容器 */
|
|||
|
.device-item {
|
|||
|
background-color: #f9fafc;
|
|||
|
border-radius: 4px;
|
|||
|
padding: 10px;
|
|||
|
margin-bottom: 10px;
|
|||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
|||
|
}
|
|||
|
|
|||
|
/* 设备名称 + 时间 行 */
|
|||
|
.device-header {
|
|||
|
display: flex;
|
|||
|
justify-content: space-between;
|
|||
|
align-items: center;
|
|||
|
margin-bottom: 6px;
|
|||
|
}
|
|||
|
|
|||
|
/* 设备名称 */
|
|||
|
.device-name {
|
|||
|
font-weight: 500;
|
|||
|
}
|
|||
|
|
|||
|
/* 设备时间 */
|
|||
|
.device-time {
|
|||
|
color: rgba(97, 128, 183, 1);
|
|||
|
font-size: 14px;
|
|||
|
}
|
|||
|
|
|||
|
/* 设备型号 */
|
|||
|
.device-model {
|
|||
|
color: #999;
|
|||
|
font-size: 13px;
|
|||
|
margin-bottom: 6px;
|
|||
|
}
|
|||
|
|
|||
|
/* 地点信息 */
|
|||
|
.device-place p {
|
|||
|
margin: 4px 0;
|
|||
|
font-size: 13px;
|
|||
|
color: #333;
|
|||
|
}
|
|||
|
|
|||
|
.date_group {
|
|||
|
margin-bottom: 10px;
|
|||
|
padding: 20px;
|
|||
|
}
|
|||
|
|
|||
|
.date_header {
|
|||
|
padding: 8px 12px;
|
|||
|
border-radius: 4px 4px 0 0;
|
|||
|
font-weight: 500;
|
|||
|
}
|
|||
|
|
|||
|
.device_item {
|
|||
|
padding: 10px 12px;
|
|||
|
background-color: #f7f8fc;
|
|||
|
border-radius: 4px;
|
|||
|
display: flex;
|
|||
|
flex-direction: column;
|
|||
|
position: relative;
|
|||
|
}
|
|||
|
|
|||
|
.device_info {
|
|||
|
margin-bottom: 8px;
|
|||
|
}
|
|||
|
|
|||
|
.device_name {
|
|||
|
font-weight: 500;
|
|||
|
font-size: 14px;
|
|||
|
margin-bottom: 4px;
|
|||
|
display: flex;
|
|||
|
justify-content: space-between;
|
|||
|
}
|
|||
|
|
|||
|
.device_model {
|
|||
|
font-size: 12px;
|
|||
|
color: #666;
|
|||
|
margin-bottom: 4px;
|
|||
|
}
|
|||
|
|
|||
|
.time_info {
|
|||
|
font-size: 15px;
|
|||
|
color: rgba(97, 128, 183, 1);
|
|||
|
}
|
|||
|
|
|||
|
.place_info {
|
|||
|
font-size: 13px;
|
|||
|
color: #666;
|
|||
|
line-height: 2;
|
|||
|
}
|
|||
|
|
|||
|
.place_info p {
|
|||
|
margin: 2px 0;
|
|||
|
white-space: nowrap;
|
|||
|
overflow: hidden;
|
|||
|
text-overflow: ellipsis;
|
|||
|
}
|
|||
|
|
|||
|
.control_btn {
|
|||
|
font-size: 14px;
|
|||
|
padding: 4px 12px;
|
|||
|
background: rgba(2, 124, 251, 0.06);
|
|||
|
color: #027cfb;
|
|||
|
border: none;
|
|||
|
border-radius: 4px;
|
|||
|
align-self: flex-end;
|
|||
|
cursor: pointer;
|
|||
|
transition: background 0.2s;
|
|||
|
}
|
|||
|
|
|||
|
.control_btt {
|
|||
|
text-align: end;
|
|||
|
|
|||
|
}
|
|||
|
.nodata{
|
|||
|
text-align: center;
|
|||
|
transform: translate(-1%,100%);
|
|||
|
left:50%;
|
|||
|
height: 30vh;
|
|||
|
}
|
|||
|
.nodataImg{
|
|||
|
width: 130px;
|
|||
|
}
|
|||
|
.title{
|
|||
|
color: #666;
|
|||
|
padding-top: 20px;
|
|||
|
}
|
|||
|
</style>
|