airport-qingdao-vue3/src/components/car/detail/TrackPlayback.vue
2025-07-16 11:30:24 +08:00

742 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- TrackPlayback.vue轨迹回放页面 -->
<template>
<div class="track-playback-content">
<!-- 左侧搜索+列表 -->
<div class="left-list">
<el-input
v-model="search"
class="search-input"
placeholder="请输入任务号/车辆名"
:suffix-icon="Search"
/>
<el-scrollbar class="task-list">
<div
v-for="item in filteredTasks"
:key="item.id"
class="task-item"
:class="{ active: item.id === activeId }"
@click="selectTask(item)"
>
<!-- 选中三角角标 -->
<div v-if="item.id === activeId" class="corner-triangle">
</div>
<!-- 第一行:蓝色圆点+编号+任务名 -->
<div class="task-row1">
<span class="dot"></span>
<span class="task-no">{{ item.no }}</span>
<span class="task-name">{{ item.name }}</span>
</div>
<!-- 第二行:时间段 -->
<div class="task-row2">{{ item.time }}</div>
<!-- 第三行:起点——>终点 -->
<div class="task-row3">
<span class="point start">起点</span>
<span class="label">{{ item.start }}</span>
<!-- <span class="arrow">——&gt;</span> -->
<img class="arrow" src="@/assets/images/arrow.png" alt="arrow">
<span class="point end">终点</span>
<span class="label">{{ item.end }}</span>
</div>
</div>
</el-scrollbar>
</div>
<!-- 右侧地图及轨迹详情 -->
<div class="right-map">
<div class="map-container">
<div class="map-img-placeholder"></div>
<!-- 轨迹详情卡片(左上角) -->
<div class="track-detail-panel">
<!-- 第一行 -->
<div class="panel-header">
<span class="dot"></span>
<span class="panel-title">轨迹详情</span>
<el-button size="small" type="primary" class="replay-btn" @click="handleReplay">回放</el-button>
</div>
<!-- 第二行 -->
<div class="panel-info">
<div class="info-row">
<span class="carno">{{ activeTask?.licensePlate || activeVehicleId || "未选择车辆" }}</span>
<span class="info-item">最大时速 <b>{{ trackDetails.maxSpeed }}km/h</b></span>
<span class="info-item">平均时速 <b>{{ trackDetails.averageSpeed }}km/h</b></span>
<span class="info-item">总里程 <b>{{ trackDetails.totalDistance }}km</b></span>
<span class="info-item">耗时 <b>{{ trackDetails.totalTime }}min</b></span>
<span class="info-item warn">冲突告警 <b>{{ trackDetails.warnings }}</b></span>
<span class="info-item prewarn">冲突预警 <b>{{ trackDetails.preWarnings }}</b></span>
</div>
</div>
<!-- 第三行:进度条 -->
<div class="panel-progress">
<div class="progress-bar-wrap"
@mousemove="handleProgressHover"
@mouseleave="showTooltip = false"
@click="handleProgressClick">
<div class="progress-bar-bg"></div>
<div class="progress-bar-fg" :style="{width: progress+'%'}"></div>
<!-- 进度条拖动点 -->
<div class="progress-thumb" :style="{left: progress+'%'}" @mousedown="startDrag"></div>
<!-- 预警/告警红旗 -->
<div
v-for="flag in flags"
:key="flag.label"
class="progress-flag"
:style="{left: flag.percent+'%'}"
:title="flag.label"
>
<svg width="14" height="18" viewBox="0 0 14 18">
<polygon points="2,2 12,5 2,8" fill="#E34D59"/>
<rect x="1" y="2" width="2" height="14" fill="#E34D59"/>
</svg>
</div>
<!-- 鼠标悬浮提示 -->
<div v-if="showTooltip" class="progress-tooltip" :style="{left: tooltipLeft+'px'}">
{{ tooltipTime }}
</div>
</div>
<div class="progress-bottom">
<span class="start-time">{{ trackDetails.startTime || "未开始" }}</span>
<div class="speed-select">
<el-dropdown @command="setSpeed">
<span class="el-dropdown-link">
{{ speed }}x <i class="el-icon-arrow-down"></i>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="1">1x</el-dropdown-item>
<el-dropdown-item command="2">2x</el-dropdown-item>
<el-dropdown-item command="4">4x</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<span class="end-time">{{ trackDetails.endTime || "未结束" }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, defineProps, watchEffect } from "vue";
import { Search } from "@element-plus/icons-vue";
import {
listCarRunInfo,
exportCarRunInfo,
listCarRunInfoByVehicleId,
listCarRunInfoByLicensePlate,
getLatestLocationByVehicleId,
getLatestLocationByLicensePlate
} from "@/api/monitor/carRunInfo";
import { ElMessage } from "element-plus";
// 查询参数
const queryParams = ref({
pageNum: 1,
pageSize: 10,
timeRange: [],
vehicleId: '',
licensePlate: ''
});
// 数据列表
const runInfoList = ref([]);
const loading = ref(false);
const total = ref(0);
// 车辆信息模拟数据
const carInfo = {
img: "https://cdn.jsdelivr.net/gh/duogongneng/testcdn/car-demo.jpg",
no: "QN001",
status: "在线",
statusClass: "online",
type: "货运车",
driver: "张三",
phone: "15689742356",
};
// 任务模拟数据(将由接口数据替换)
const tasks = ref([]);
const search = ref("");
const activeId = ref(0);
// 接收vehicle属性
const props = defineProps({
vehicle: {
type: Object,
default: () => ({})
}
});
// 轨迹详情数据
const trackDetails = ref({
maxSpeed: "0",
averageSpeed: "0",
totalDistance: "0",
totalTime: "0",
warnings: "0",
preWarnings: "0",
startTime: "",
endTime: ""
});
const activeVehicleId = ref("");
// 监听vehicle属性变化
watchEffect(() => {
if (props.vehicle?.carId) {
activeVehicleId.value = props.vehicle.carId;
// 更新查询参数
queryParams.value.vehicleId = props.vehicle.carId;
}
});
// 获取车辆运动信息列表数据
async function getRunInfoList() {
loading.value = true;
try {
const response = await listCarRunInfo(queryParams.value);
// console.log("-----------------------------------,", response);
if (response.code === 200) {
runInfoList.value = response.rows || [];
total.value = response.total;
// 转换数据格式为任务列表
// 根据车辆ID对数据进行分组处理
const groupByVehicle = {};
runInfoList.value.forEach(item => {
if (!groupByVehicle[item.vehicleId]) {
groupByVehicle[item.vehicleId] = [];
}
groupByVehicle[item.vehicleId].push(item);
});
// 将分组后的数据转换为任务列表格式
const tasksList = [];
Object.keys(groupByVehicle).forEach((vehicleId, index) => {
const vehicleData = groupByVehicle[vehicleId];
const firstPoint = vehicleData[0];
const lastPoint = vehicleData[vehicleData.length - 1];
// 计算平均速度
const avgSpeed = (vehicleData.reduce((sum, point) => sum + point.speed, 0) / vehicleData.length).toFixed(2);
tasksList.push({
id: parseInt(vehicleId),
no: vehicleId,
name: `车辆${firstPoint.licensePlate}轨迹`,
time: `${firstPoint.timestamp}--${lastPoint.timestamp}`,
start: `经度${firstPoint.longitude},纬度${firstPoint.latitude}`,
end: `经度${lastPoint.longitude},纬度${lastPoint.latitude}`,
status: "已完成",
speed: `${avgSpeed}km/h`,
licensePlate: firstPoint.licensePlate,
points: vehicleData
});
});
tasks.value = tasksList;
// 设置默认选中第一项
if (tasks.value.length > 0) {
activeId.value = tasks.value[0].id;
}
} else {
ElMessage.error(response.msg || '获取车辆运动信息列表失败');
}
} catch (error) {
console.error('获取车辆运动信息列表异常', error);
ElMessage.error('获取车辆运动信息列表异常');
} finally {
loading.value = false;
}
}
// 根据车辆ID获取轨迹详情
async function getTrackDetailByVehicleId(vehicleId) {
if (!vehicleId) return;
try {
const response = await listCarRunInfoByVehicleId(vehicleId, {});
if (response.code === 200) {
const trackData = response.data || [];
// 处理轨迹数据...
console.log('车辆轨迹数据:', trackData);
// 这里可以处理轨迹点、告警点等
// 假设返回的数据格式与tasks中的points一致
processTrackPoints(trackData);
} else {
ElMessage.error(response.msg || '获取车辆轨迹详情失败');
}
} catch (error) {
console.error('获取车辆轨迹详情异常', error);
}
}
// 根据车牌号获取车辆最新位置
async function getLatestLocation(licensePlate) {
if (!licensePlate) return;
try {
const response = await getLatestLocationByLicensePlate(licensePlate);
if (response.code === 200) {
const locationData = response.data;
// 处理位置数据...
console.log('车辆最新位置:', locationData);
}
} catch (error) {
console.error('获取车辆最新位置异常', error);
}
}
const filteredTasks = computed(() => {
if (!search.value) return tasks.value;
return tasks.value.filter(
(t) =>
t.name.includes(search.value) ||
t.no.includes(search.value) ||
t.id.toString().includes(search.value)
);
});
const activeTask = computed(() => tasks.value.find((t) => t.id === activeId.value));
function selectTask(item) {
activeId.value = item.id;
// 从任务中提取轨迹数据
if (item.points && item.points.length > 0) {
processTrackPoints(item.points);
}
}
// 处理轨迹点数据
function processTrackPoints(points) {
// 按时间排序
points.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
// 计算最大速度
const maxSpeed = Math.max(...points.map(p => p.speed)).toFixed(2);
// 计算平均速度
const avgSpeed = (points.reduce((sum, p) => sum + p.speed, 0) / points.length).toFixed(2);
// 计算总距离 (简单估算,实际应该使用更复杂的地理距离计算)
let totalDistance = 0;
for (let i = 1; i < points.length; i++) {
const prev = points[i-1];
const curr = points[i];
// 使用直线距离简单估算
const dist = Math.sqrt(
Math.pow(curr.longitude - prev.longitude, 2) +
Math.pow(curr.latitude - prev.latitude, 2)
) * 111000; // 粗略转换为米
totalDistance += dist;
}
const distanceKm = (totalDistance / 1000).toFixed(2);
// 计算总时间
const startTime = new Date(points[0].timestamp);
const endTime = new Date(points[points.length-1].timestamp);
const totalMinutes = Math.round((endTime - startTime) / (60 * 1000));
// 更新轨迹详情
trackDetails.value = {
maxSpeed: maxSpeed,
averageSpeed: avgSpeed,
totalDistance: distanceKm,
totalTime: totalMinutes > 0 ? totalMinutes.toString() : "1",
warnings: "0", // 实际应从数据中获取告警信息
preWarnings: "0", // 实际应从数据中获取预警信息
startTime: points[0].timestamp,
endTime: points[points.length-1].timestamp
};
// 处理告警标记点 (示例,实际应根据数据判断)
flags.value = [
{
percent: 20,
label: "告警 " + points[Math.floor(points.length * 0.2)].timestamp
},
{
percent: 60,
label: "预警 " + points[Math.floor(points.length * 0.6)].timestamp
}
];
}
// 回放按钮点击处理函数
function handleReplay() {
// 获取当前选中任务
const activeTask = tasks.value.find(t => t.id === activeId.value);
if (activeTask) {
if (activeTask.points && activeTask.points.length > 0) {
// 如果任务已包含轨迹点数据,直接处理
processTrackPoints(activeTask.points);
ElMessage.success('开始轨迹回放');
} else {
// 如果没有轨迹点数据根据车辆ID获取
getTrackDetailByVehicleId(activeTask.id);
}
} else if (queryParams.value.vehicleId) {
// 使用查询参数中的车辆ID
getTrackDetailByVehicleId(queryParams.value.vehicleId);
} else if (props.vehicle?.carId) {
// 使用传入的车辆ID
getTrackDetailByVehicleId(props.vehicle.carId);
} else {
// 获取列表数据
getRunInfoList();
ElMessage.info('请先选择一个车辆任务');
}
}
// 导出车辆运动信息
async function handleExport() {
try {
await exportCarRunInfo(queryParams.value);
ElMessage.success('导出成功');
} catch (error) {
console.error('导出车辆运动信息异常', error);
ElMessage.error('导出车辆运动信息异常');
}
}
const progress = ref(30); // 进度百分比
const speed = ref(1);
const showTooltip = ref(false);
const tooltipTime = ref("");
const tooltipLeft = ref(0);
// 预警/告警红旗模拟数据(将由接口数据替换)
const flags = ref([]);
// 拖动进度条
let dragging = false;
function startDrag(e) {
dragging = true;
document.addEventListener("mousemove", onDrag);
document.addEventListener("mouseup", stopDrag);
}
function onDrag(e) {
if (!dragging) return;
const bar = document.querySelector(".progress-bar-wrap");
const rect = bar.getBoundingClientRect();
let percent = ((e.clientX - rect.left) / rect.width) * 100;
percent = Math.max(0, Math.min(100, percent));
progress.value = percent;
}
function stopDrag() {
dragging = false;
document.removeEventListener("mousemove", onDrag);
document.removeEventListener("mouseup", stopDrag);
}
// 鼠标悬浮显示时分秒
function handleProgressHover(e) {
const bar = e.currentTarget;
const rect = bar.getBoundingClientRect();
let percent = ((e.clientX - rect.left) / rect.width) * 100;
percent = Math.max(0, Math.min(100, percent));
tooltipLeft.value = e.clientX - rect.left;
// 假设总时长20分钟
const totalSec = 20 * 60;
const sec = Math.round((percent / 100) * totalSec);
const mm = String(Math.floor(sec / 60)).padStart(2, "0");
const ss = String(sec % 60).padStart(2, "0");
tooltipTime.value = `${mm}:${ss}`;
showTooltip.value = true;
}
function handleProgressClick(e) {
const bar = e.currentTarget;
const rect = bar.getBoundingClientRect();
let percent = ((e.clientX - rect.left) / rect.width) * 100;
percent = Math.max(0, Math.min(100, percent));
progress.value = percent;
}
function setSpeed(val) {
speed.value = val;
}
// 页面加载时获取数据
onMounted(() => {
getRunInfoList();
// getLatestLocation("鲁B579");
});
</script>
<style scoped lang="scss">
.track-playback-content {
display: flex;
gap: 20px;
flex: 1;
}
.left-list {
width: 25%;
border-radius: 8px;
padding: 16px 8px;
display: flex;
flex-direction: column;
}
.search-input {
margin-bottom: 10px;
}
.task-list {
max-height: 400px;
}
.task-item {
position: relative;
padding: 16px 16px 14px 16px;
border-radius: 8px;
margin-bottom: 14px;
cursor: pointer;
background: #343744;
color: #fff;
border: 2px solid transparent;
transition: border 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.task-item.active {
border: 2px solid #347ae2;
box-shadow: 0 2px 8px rgba(52,122,226,0.08);
}
.corner-triangle {
background: url('@/assets/images/choice.png') no-repeat 100% 100%;
position: absolute;
right: 0;
top: 0;
width: 38px;
height: 33px;
z-index: 2;
}
.task-row1 {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: bold;
margin-bottom: 4px;
.dot {
width: 8px;
height: 8px;
background: #347ae2;
border-radius: 50%;
display: inline-block;
margin-right: 2px;
}
.task-no {
color: #347ae2;
font-weight: bold;
}
.task-name {
color: #fff;
}
}
.task-row2 {
font-size: 13px;
color: #b0b8c9;
margin-bottom: 6px;
}
.task-row3 {
display: flex;
align-items: center;
gap: 8px;
.label{
font-size: 13px;
color:#fff;
}
.point {
padding: 0px 5px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
background: transparent;
border: 1.5px solid;
&.start {
border-color: #347ae2;
background: rgba(52, 122, 226, 0.20);
color: #347ae2;
}
&.end {
border-color: #27ae60;
background: rgba(39, 174, 96, 0.20);
color: #27ae60;
}
}
.arrow {
color: #b0b8c9;
font-size: 15px;
font-weight: bold;
}
}
.right-map {
flex: 1;
border: 1px solid #343744;
border-radius: 8px;
padding: 20px;
display: flex;
flex-direction: column;
min-width: 0;
}
.map-container {
flex: 1;
border-radius: 8px;
min-height: 400px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.map-img-placeholder {
width: 100%;
height: 100%;
border-radius: 12px;
}
.track-detail-panel {
position: absolute;
top: 24px;
left: 24px;
min-width: 520px;
background: rgba(41,44,56,0.8);
border-radius: 10px;
box-shadow: 0 2px 12px rgba(0,0,0,0.18);
padding: 18px 28px 18px 20px;
z-index: 10;
.panel-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
.dot {
width: 10px;
height: 10px;
background: #347ae2;
border-radius: 50%;
display: inline-block;
}
.panel-title {
font-size: 16px;
font-weight: bold;
color: #4ea1ff;
margin-right: 12px;
}
.replay-btn {
margin-left: auto;
}
}
.panel-info {
margin-bottom: 10px;
.info-row {
display: flex;
flex-wrap: wrap;
gap: 18px;
align-items: center;
font-size: 14px;
.carno {
color: #4ea1ff;
font-weight: bold;
font-size: 15px;
}
.info-item {
color: #b0b8c9;
b { color: #fff; font-weight: 500; }
&.warn b { color: #e34d59; }
&.prewarn b { color: #f7b500; }
}
}
}
.panel-progress {
margin-top: 8px;
.progress-bar-wrap {
position: relative;
height: 8px;
background: transparent;
margin-bottom: 8px;
cursor: pointer;
user-select: none;
}
.progress-bar-bg {
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
background: #343744;
border-radius: 4px;
}
.progress-bar-fg {
position: absolute;
left: 0; top: 0; bottom: 0;
background: linear-gradient(90deg, #347ae2, #4ea1ff);
border-radius: 4px;
height: 8px;
z-index: 1;
}
.progress-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 16px;
height: 16px;
background: #fff;
border: 3px solid #347ae2;
border-radius: 50%;
z-index: 2;
cursor: pointer;
box-shadow: 0 2px 8px rgba(52,122,226,0.12);
}
.progress-flag {
position: absolute;
top: -14px;
z-index: 3;
width: 14px;
height: 18px;
display: flex;
align-items: flex-end;
justify-content: center;
pointer-events: auto;
}
.progress-tooltip {
position: absolute;
top: -32px;
background: #23263a;
color: #fff;
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(52,122,226,0.10);
z-index: 10;
pointer-events: none;
}
.progress-bottom {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
color: #b0b8c9;
.speed-select {
margin: 0 16px;
.el-dropdown-link {
color: #4ea1ff;
cursor: pointer;
font-weight: bold;
}
}
.start-time, .end-time {
font-size: 13px;
color: #b0b8c9;
}
}
}
}
</style>