报警列表卡片,模式页面布局修改,估计播放,优化体验

This commit is contained in:
fengerli
2025-08-30 14:45:55 +08:00
parent e07a4fea01
commit 031f6135c1
18 changed files with 1302 additions and 392 deletions

View File

@ -0,0 +1,304 @@
<template>
<div class="p-2">
<el-row :gutter="20">
<!-- 部门树 -->
<el-col :lg="4" :xs="24" style="" class="main-tree">
<el-input v-model="deptName" placeholder="输入分组名称" prefix-icon="Search" clearable />
<el-tree ref="deptTreeRef" class="mt-2" node-key="id" :data="deptOptions"
:props="{ label: 'groupName', children: 'children' }" :expand-on-click-node="false"
:filter-node-method="filterNode" highlight-current default-expand-all @node-click="handleNodeClick"></el-tree>
</el-col>
<el-col :lg="20" :xs="24">
<transition :enter-active-class="proxy?.animate.searchAnimate.enter"
:leave-active-class="proxy?.animate.searchAnimate.leave">
<div v-show="showSearch" class="mb-[10px]">
<el-card>
<!-- =========搜索按钮操作======= -->
<div class="btn_search">
<el-button type="primary" plain icon="Download" @click="handleExport">导出</el-button>
<div style="position: absolute; right:30px; top:20px">
<el-input v-model="queryParams.content" placeholder="MAC/IMEI" clearable
style="width: 200px; margin-right: 20px;" @keyup.enter="handleQuery" @input="handleInput" />
<el-button type="primary" plain @click="toggleFilter">高级筛选</el-button>
</div>
</div>
<el-collapse accordion v-model="activeNames">
<el-collapse-item name="1">
<el-form ref="queryFormRef" :model="queryParams" :inline="true" class="queryFormRef">
<el-form-item label="设备类型" prop="deviceType">
<el-select v-model="queryParams.deviceType" placeholder="设备类型">
<el-option v-for="item in deviceTypeOptions" :key="item.value" :label="item.typeName"
:value="item.deviceTypeId" />
</el-select>
</el-form-item>
<el-form-item label="设备名称" prop="deviceName">
<el-input v-model="queryParams.deviceName" placeholder="请输入设备名称" clearable />
</el-form-item>
<el-form-item label="设备MAC" prop="deviceMac">
<el-input v-model="queryParams.deviceMac" placeholder="请输入设备MAC" clearable />
</el-form-item>
<el-form-item label="设备IMEI" prop="deviceImei">
<el-input v-model="queryParams.deviceImei" placeholder="请输入设备IMEI" clearable
@keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="轨迹时间" style="width: 308px">
<el-date-picker v-model="dateRange" value-format="YYYY-MM-DD HH:mm:ss" type="daterange"
range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期"
:default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)]"></el-date-picker>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">查询</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-collapse-item>
</el-collapse>
</el-card>
</div>
</transition>
<el-card class="Maplist">
<div>
<el-table v-loading="loading" border :data="deviceList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="50" align="center" />
<el-table-column label="设备名称" align="center" prop="deviceName" />
<el-table-column label="设备型号" align="center" prop="deviceType" />
<el-table-column label="设备ImeI" align="center" prop="deviceImei" />
<el-table-column label="设备MAC" align="center" prop="deviceMac" show-overflow-tooltip />
<el-table-column label="轨迹记录" align="center">
<template #default="scope">
<el-button link type="primary" @click="historyjectory(scope.row)">历史轨迹</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize"
:total="total" @pagination="getList" />
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup name="User" lang="ts">
import api from '@/api/controlCenter/historyjectory/index'
import apiGroup from '@/api/controlCenter/controlPanel/index'
import apiTypeAll from '@/api/equipmentManagement/device/index';
import { deviceQuery, deviceVO } from '@/api/controlCenter/historyjectory/types';
const router = useRouter();
const dateRange = ref(['', '']);
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const deviceList = ref<deviceVO[]>();
const loading = ref(true);
const showSearch = ref(true);
const ids = ref<Array<number | string>>([]);
const single = ref(true);
const multiple = ref(true);
const total = ref(0);
const deptName = ref();
const deptOptions = ref([])
const deptTreeRef = ref<ElTreeInstance>();
const queryFormRef = ref<ElFormInstance>();
const activeNames = ref([]);
const deviceTypeOptions = ref([]); //设备类型
const enabledDeptOptions = ref();
const debounceTimer = ref(null) // 用于防抖的定时器
const initData: PageData<'', deviceQuery> = {
queryParams: {
pageNum: 1,
pageSize: 10,
deviceName: '',
deviceMac: '',
deviceImei: '',
groupId: '',
deviceType: '',
startTime: '',
endTime: '',
content: ''
},
rules: undefined,
form: ''
};
const data = reactive<PageData<'', deviceQuery>>(initData);
const { queryParams } = toRefs<PageData<'', deviceQuery>>(data);
/** 通过条件过滤节点 */
const filterNode = (value: string, data: any) => {
if (!value) return true;
return data.groupName.indexOf(value) !== -1;
};
// 设备类型
const getDeviceType = () => {
apiTypeAll.deviceTypeAll().then(res => {
if (res.code == 200) {
deviceTypeOptions.value = res.data
}
}).catch(err => {
})
};
/** 根据名称筛选部门树 */
watchEffect(
() => {
deptTreeRef.value?.filter(deptName.value);
},
{
flush: 'post' // watchEffect会在DOM挂载或者更新之前就会触发此属性控制在DOM元素更新后运行
}
);
const toggleFilter = () => {
if (activeNames.value.length > 0) {
activeNames.value = [];
} else {
activeNames.value = ['1'];
}
};
/** 过滤禁用的部门 */
const filterDisabledDept = (deptList: any[]) => {
return deptList.filter((dept) => {
if (dept.disabled) {
return false;
}
if (dept.children && dept.children.length) {
dept.children = filterDisabledDept(dept.children);
}
return true;
});
};
/** 节点单击事件 */
const handleNodeClick = (data: any) => {
queryParams.value.groupId = data.id;
handleQuery();
};
const handleInput = () => {
if (debounceTimer.value) {
clearTimeout(debounceTimer.value)
}
// 300ms后执行查询避免输入过程中频繁调用接口
debounceTimer.value = setTimeout(() => {
handleQuery() // 调用查询接口的方法
}, 300)
};
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.value.pageNum = 1;
getList();
};
/** 重置按钮操作 */
const resetQuery = () => {
dateRange.value = ['', ''];
queryFormRef.value?.resetFields();
queryParams.value.pageNum = 1;
queryParams.value.groupId = undefined;
deptTreeRef.value?.setCurrentKey(undefined);
handleQuery();
};
/** 选择条数 */
const handleSelectionChange = (selection: deviceVO[]) => {
ids.value = selection;
single.value = selection.length != 1;
multiple.value = !selection.length;
};
onMounted(() => {
getDeptTree(); // 初始化部门数据
getList(); // 初始化列表数据
getDeviceType() //设备类型
});
/** 查询用户列表 */
const getList = async () => {
loading.value = false;
const [startTime, endTime] = dateRange.value;
queryParams.value = {
...queryParams.value,
startTime: startTime,
endTime: endTime
};
const res = await api.devicelocationHistory(queryParams.value)
loading.value = false;
deviceList.value = res.rows;
total.value = res.total;
};
/** 查询部结构 */
const getDeptTree = async () => {
const res = await apiGroup.devicegroupList('');
const allDeviceOption = {
id: '',
groupName: '全部设备',
disabled: false,
children: []
};
deptOptions.value = [allDeviceOption, ...res.data]
enabledDeptOptions.value = filterDisabledDept(res.data);
};
/** 导出按钮操作 */
const handleExport = () => {
proxy?.download(
'/api/device/locationHistoryExport',
{
...queryParams.value
},
`历史轨迹${new Date().getTime()}.xlsx`,
'get'
);
};
/**历史轨迹跳转 */
const historyjectory = (row: any) => {
const id = row.id;
router.push('/controlCenter/historyjectory/' + id);
};
</script>
<style lang="scss" scoped>
:deep .el-collapse-item__header {
display: none;
}
:deep .el-tree--highlight-current .el-tree-node.is-current>.el-tree-node__content {
color: rgba(2, 124, 251, 1);
background: transparent;
}
.main-tree {
border-radius: 4px;
box-shadow: 0px 0px 6px 0px rgba(0, 34, 96, 0.1);
background: rgba(255, 255, 255, 1);
width: 212px;
border: none;
padding-top: 10px;
}
.el-card {
border: none
}
.btn_search {
padding: 0px 15px 15px 0px;
// border-bottom: 1px solid rgba(235, 238, 248, 1);
}
.queryFormRef {
margin-top: 20px;
}
.green {
color: rgba(0, 165, 82, 1);
}
.red {
color: rgba(224, 52, 52, 1);
}
.Maplist {
height: 680px;
overflow: auto;
}
</style>

View File

@ -0,0 +1,449 @@
<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. 地图聚焦到第一个点并设置固定zoom16为街道级足够大无需手动放大
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>