<template>
|
<map-board :open="!!current" :title="current?.name" :sub="current?.idCard" @close="close">
|
<template #side-head>
|
<el-input v-model="keyword" placeholder="搜索分拣员" clearable size="small" />
|
<div class="hint">最近 15 分钟有 GPS 视为在线 · {{ list.length }} 人</div>
|
</template>
|
<template #side>
|
<div
|
v-for="item in visibleList"
|
:key="item.idCard"
|
class="list-item"
|
:class="{ 'is-active': current?.idCard === item.idCard }"
|
@click="select(item)"
|
>
|
<i />
|
<div>
|
<div class="list-item__name">{{ item.name || item.idCard }}</div>
|
<div class="list-item__meta">{{ formatTime(item.uploadTime) }} · {{ item.speed || 0 }} km/h</div>
|
</div>
|
</div>
|
<el-empty v-if="!visibleList.length" description="当前没有在线分拣员" :image-size="64" />
|
</template>
|
<template #legend>
|
<div class="legend"><i style="background:#2563eb" />在线分拣员</div>
|
<div class="legend"><i style="background:#ef4444" />轨迹回放</div>
|
</template>
|
<template #map>
|
<div class="mapDiv">
|
<tdt-map :center="center" :zoom="zoom" :controls="controls" :loadConfig="MAP_LOAD">
|
<tdt-marker
|
v-for="item in visibleList"
|
:key="item.idCard"
|
:position="pos(item)"
|
:icon="SORTER_ICON"
|
:title="item.name"
|
@click="select(item)"
|
/>
|
<tdt-polyline v-if="trackPath.length" :path="trackPath" color="#16a34a" :weight="4" :opacity="0.85" />
|
<tdt-marker v-if="playPos" :position="playPos" :icon="PLAY_ICON" title="回放位置" />
|
</tdt-map>
|
</div>
|
</template>
|
<template #panel>
|
<el-tabs v-model="tab">
|
<el-tab-pane label="基本信息" name="info">
|
<el-descriptions :column="1" border size="small">
|
<el-descriptions-item label="姓名">{{ current?.name }}</el-descriptions-item>
|
<el-descriptions-item label="身份证">{{ current?.idCard }}</el-descriptions-item>
|
<el-descriptions-item label="手机">{{ current?.phone || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="工作站点">{{ current?.workSiteId || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="当前速度">{{ current?.speed || 0 }} km/h</el-descriptions-item>
|
<el-descriptions-item label="定位时间">{{ formatTime(current?.uploadTime) }}</el-descriptions-item>
|
</el-descriptions>
|
</el-tab-pane>
|
<el-tab-pane label="健康上报" name="health">
|
<el-table :data="health.recent" size="small" height="220" stripe>
|
<el-table-column label="时间" min-width="140">
|
<template #default="{ row }">{{ formatTime(row.uploadTime) }}</template>
|
</el-table-column>
|
<el-table-column prop="temperature" label="体温" width="64" />
|
<el-table-column prop="heartRate" label="心率" width="64" />
|
<el-table-column label="血压" width="80">
|
<template #default="{ row }">{{ row.bloodPressureHigh || '-' }}/{{ row.bloodPressureLow || '-' }}</template>
|
</el-table-column>
|
<el-table-column prop="bloodOxygen" label="血氧" width="64" />
|
<el-table-column prop="dataType" label="类型" width="64" />
|
</el-table>
|
<div class="block-title">异常上报</div>
|
<el-table :data="health.abnormal" size="small" height="160" stripe>
|
<el-table-column label="时间" min-width="140">
|
<template #default="{ row }">{{ formatTime(row.uploadTime) }}</template>
|
</el-table-column>
|
<el-table-column prop="temperature" label="体温" width="64" />
|
<el-table-column prop="heartRate" label="心率" width="64" />
|
<el-table-column prop="dataType" label="类型" width="64" />
|
</el-table>
|
<div class="block-title">健康告警</div>
|
<el-table :data="health.alarms" size="small" height="160" stripe>
|
<el-table-column label="时间" min-width="140">
|
<template #default="{ row }">{{ formatTime(row.uploadTime) }}</template>
|
</el-table-column>
|
<el-table-column prop="alarmType" label="类型" min-width="80" />
|
<el-table-column prop="alarmLevel" label="级别" width="70" />
|
<el-table-column prop="content" label="内容" min-width="120" show-overflow-tooltip />
|
</el-table>
|
</el-tab-pane>
|
<el-tab-pane label="历史轨迹" name="track">
|
<track-toolbar
|
v-model:speed="speed"
|
:playing="playing"
|
:progress="progress"
|
:has-track="trackPoints.length > 1"
|
:start-time="trackStart"
|
:end-time="trackEnd"
|
@play="play"
|
@pause="pause"
|
@stop="stop"
|
@query="onQueryTrack"
|
/>
|
<div class="hint">默认最近 1 小时,回放默认 5 倍速</div>
|
</el-tab-pane>
|
</el-tabs>
|
</template>
|
</map-board>
|
</template>
|
|
<script lang="ts" setup>
|
defineOptions({ name: 'maps-sorter' });
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
import { TdtMap, TdtMarker, TdtPolyline, type LngLat } from 'vue-tianditu2';
|
import MapBoard from '../components/map-board.vue';
|
import TrackToolbar from '../components/track-toolbar.vue';
|
import { DEFAULT_CENTER, MAP_LOAD, PLAY_ICON, SORTER_ICON, formatTime, lastHours, useMapsApi } from '../hooks/use-map';
|
import { useTrackPlayback, type TrackPoint } from '../hooks/use-track';
|
|
const api = useMapsApi();
|
const list = ref<any[]>([]);
|
const keyword = ref('');
|
const current = ref<any>(null);
|
const center = ref<LngLat>(DEFAULT_CENTER);
|
const zoom = ref(12);
|
const tab = ref('info');
|
const health = ref<{ recent: any[]; abnormal: any[]; alarms: any[] }>({ recent: [], abnormal: [], alarms: [] });
|
const trackPoints = ref<TrackPoint[]>([]);
|
const trackStart = ref('');
|
const trackEnd = ref('');
|
const { playing, speed, progress, playPos, path, play, pause, stop, reset } = useTrackPlayback(trackPoints);
|
const trackPath = computed(() => path());
|
const controls = ['Zoom', 'Scale', { name: 'MapType', position: 'topright' }];
|
let timer: number;
|
|
const visibleList = computed(() => {
|
if (!keyword.value) return list.value;
|
const k = keyword.value.toLowerCase();
|
return list.value.filter(e => [e.name, e.idCard, e.phone].some(v => String(v || '').toLowerCase().includes(k)));
|
});
|
|
function pos(item: any): LngLat {
|
return [Number(item.longitude), Number(item.latitude)];
|
}
|
|
function fmt(d: Date) {
|
const p = (n: number) => String(n).padStart(2, '0');
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
}
|
|
async function loadOnline() {
|
list.value = (await api.sorterOnline()) || [];
|
if (!current.value && list.value[0]) {
|
center.value = pos(list.value[0]);
|
zoom.value = 13;
|
}
|
}
|
|
async function select(item: any) {
|
current.value = item;
|
center.value = pos(item);
|
zoom.value = 16;
|
tab.value = 'info';
|
reset();
|
const { start, end } = lastHours(1);
|
trackStart.value = fmt(start);
|
trackEnd.value = fmt(end);
|
const [h, track] = await Promise.all([
|
api.sorterHealth(item.idCard),
|
api.sorterTrack({ idCard: item.idCard, startTime: trackStart.value, endTime: trackEnd.value })
|
]);
|
health.value = h || { recent: [], abnormal: [], alarms: [] };
|
trackPoints.value = track || [];
|
if (trackPoints.value.length) {
|
playPos.value = pos(trackPoints.value[0] as any);
|
}
|
}
|
|
async function onQueryTrack(range: [string, string]) {
|
if (!current.value) return;
|
trackStart.value = range[0];
|
trackEnd.value = range[1];
|
reset();
|
trackPoints.value = (await api.sorterTrack({
|
idCard: current.value.idCard,
|
startTime: range[0],
|
endTime: range[1]
|
})) || [];
|
}
|
|
function close() {
|
current.value = null;
|
reset();
|
trackPoints.value = [];
|
}
|
|
onMounted(async () => {
|
await loadOnline();
|
timer = window.setInterval(loadOnline, 30000);
|
});
|
|
onBeforeUnmount(() => clearInterval(timer));
|
</script>
|
|
<style lang="scss" scoped>
|
.hint {
|
margin-top: 8px;
|
font-size: 12px;
|
color: var(--el-text-color-secondary);
|
line-height: 1.4;
|
}
|
|
.list-item {
|
display: flex;
|
gap: 8px;
|
padding: 10px 12px;
|
cursor: pointer;
|
border-bottom: 1px solid var(--el-border-color-extra-light);
|
|
i {
|
width: 8px;
|
height: 8px;
|
margin-top: 6px;
|
border-radius: 50%;
|
background: #22c55e;
|
flex-shrink: 0;
|
}
|
|
&:hover,
|
&.is-active {
|
background: var(--el-color-primary-light-9);
|
}
|
|
&__name {
|
font-size: 13px;
|
font-weight: 600;
|
}
|
|
&__meta {
|
margin-top: 2px;
|
font-size: 12px;
|
color: var(--el-text-color-secondary);
|
}
|
}
|
|
.legend {
|
display: flex;
|
align-items: center;
|
gap: 6px;
|
margin: 2px 0;
|
font-size: 12px;
|
|
i {
|
width: 8px;
|
height: 8px;
|
border-radius: 50%;
|
}
|
}
|
|
.block-title {
|
margin: 12px 0 6px;
|
font-size: 13px;
|
font-weight: 600;
|
}
|
|
.mapDiv {
|
width: 100%;
|
height: 100%;
|
}
|
</style>
|