refactor(record-check): 优化分组逻辑并改进缺失片段交互
- 移除 es-toolkit 依赖,使用原生 Map 进行分组以保留顺序 - 将录像片段过滤选项从数字改为语义化字符串 - 修复录像时间轴缺失片段悬停交互,避免嵌套 Popover - 重置分页时同步设备 ID 和诊断时间变化
This commit is contained in:
@@ -1,15 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getChannelListApi, getRecordCheckApi, reloadAllRecordCheckApi, reloadRecordCheckApi, type NdmNvrResultVO, type RecordItem, type Station } from '@/apis';
|
import { getChannelListApi, getRecordCheckApi, reloadAllRecordCheckApi, reloadRecordCheckApi, type NdmNvrResultVO, type NdmRecordCheck, type RecordItem, type Station } from '@/apis';
|
||||||
import { exportRecordDiagCsv, transformRecordChecks } from '@/helpers';
|
import { exportRecordDiagCsv, transformRecordChecks } from '@/helpers';
|
||||||
import { useSettingStore } from '@/stores';
|
import { useSettingStore } from '@/stores';
|
||||||
import { parseErrorFeedback } from '@/utils';
|
import { parseErrorFeedback } from '@/utils';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
|
||||||
import { isCancel } from 'axios';
|
import axios, { isCancel } from 'axios';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { DownloadIcon, RotateCwIcon } from 'lucide-vue-next';
|
import { DownloadIcon, RotateCwIcon } from 'lucide-vue-next';
|
||||||
import { NButton, NCard, NFlex, NIcon, NPagination, NPopconfirm, NPopover, NRadioButton, NRadioGroup, NTooltip, useThemeVars } from 'naive-ui';
|
import { NButton, NCard, NFlex, NIcon, NPagination, NPopconfirm, NPopover, NRadioButton, NRadioGroup, NTooltip, useThemeVars } from 'naive-ui';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { computed, onBeforeUnmount, ref, toRefs, watch } from 'vue';
|
import { computed, onBeforeUnmount, ref, toRefs, watch, type CSSProperties } from 'vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
ndmDevice: NdmNvrResultVO;
|
ndmDevice: NdmNvrResultVO;
|
||||||
@@ -25,7 +25,7 @@ const queryClient = useQueryClient();
|
|||||||
|
|
||||||
const { ndmDevice, station } = toRefs(props);
|
const { ndmDevice, station } = toRefs(props);
|
||||||
|
|
||||||
const lossInput = ref<number>(0);
|
const filterType = ref<'all' | 'some' | 'none'>('all');
|
||||||
|
|
||||||
const abortController = ref<AbortController>(new AbortController());
|
const abortController = ref<AbortController>(new AbortController());
|
||||||
|
|
||||||
@@ -41,8 +41,9 @@ const {
|
|||||||
refetchInterval: 30 * 1000,
|
refetchInterval: 30 * 1000,
|
||||||
gcTime: 0,
|
gcTime: 0,
|
||||||
queryFn: async ({ signal }) => {
|
queryFn: async ({ signal }) => {
|
||||||
const checks = await getRecordCheckApi(ndmDevice.value, 90, [], { stationCode: station.value.code, signal });
|
// const checks = await getRecordCheckApi(ndmDevice.value, 90, [], { stationCode: station.value.code, signal });
|
||||||
return checks;
|
// return checks;
|
||||||
|
return (await axios.get<NdmRecordCheck[]>('./public/record-check.json', { signal })).data;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
watch(activeRequests, (active) => {
|
watch(activeRequests, (active) => {
|
||||||
@@ -51,19 +52,6 @@ watch(activeRequests, (active) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const recordDiags = computed(() => {
|
|
||||||
return transformRecordChecks(recordChecks.value ?? []).filter((recordDiag) => {
|
|
||||||
if (lossInput.value === 0) {
|
|
||||||
return true;
|
|
||||||
} else if (lossInput.value === 1) {
|
|
||||||
return recordDiag.lostChunks.length > 0;
|
|
||||||
} else if (lossInput.value === 2) {
|
|
||||||
return recordDiag.lostChunks.length === 0;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutate: reloadAllRecordCheck, isPending: reloading } = useMutation({
|
const { mutate: reloadAllRecordCheck, isPending: reloading } = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
abortController.value.abort();
|
abortController.value.abort();
|
||||||
@@ -81,29 +69,6 @@ const { mutate: reloadAllRecordCheck, isPending: reloading } = useMutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onExportRecordCheck = () => {
|
|
||||||
exportRecordDiagCsv(recordDiags.value, station.value.name);
|
|
||||||
};
|
|
||||||
|
|
||||||
const page = ref(1);
|
|
||||||
const pageSize = ref(10);
|
|
||||||
|
|
||||||
const pagedRecordDiags = computed(() => {
|
|
||||||
const startIndex = (page.value - 1) * pageSize.value;
|
|
||||||
const endIndex = page.value * pageSize.value;
|
|
||||||
return recordDiags.value.slice(startIndex, endIndex);
|
|
||||||
});
|
|
||||||
|
|
||||||
const getLostChunkDOMStyle = (lostChunk: RecordItem, duration: RecordItem) => {
|
|
||||||
const chunk = dayjs(lostChunk.endTime).diff(dayjs(lostChunk.startTime));
|
|
||||||
const offset = dayjs(lostChunk.startTime).diff(dayjs(duration.startTime));
|
|
||||||
const total = dayjs(duration.endTime).diff(dayjs(duration.startTime));
|
|
||||||
return {
|
|
||||||
left: `${(offset / total) * 100}%`,
|
|
||||||
width: `${(chunk / total) * 100}%`,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const { mutate: reloadRecordCheckByGbId } = useMutation({
|
const { mutate: reloadRecordCheckByGbId } = useMutation({
|
||||||
mutationFn: async (params: { gbCode: string }) => {
|
mutationFn: async (params: { gbCode: string }) => {
|
||||||
abortController.value.abort();
|
abortController.value.abort();
|
||||||
@@ -133,9 +98,68 @@ const { mutate: reloadRecordCheckByGbId } = useMutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const onExportRecordCheck = () => {
|
||||||
|
exportRecordDiagCsv(recordDiags.value, station.value.name);
|
||||||
|
};
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
abortController.value.abort();
|
abortController.value.abort();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const recordDiags = computed(() => {
|
||||||
|
return transformRecordChecks(recordChecks.value ?? []).filter((recordDiag) => {
|
||||||
|
if (filterType.value === 'all') {
|
||||||
|
return true;
|
||||||
|
} else if (filterType.value === 'some') {
|
||||||
|
return recordDiag.lostChunks.length > 0;
|
||||||
|
} else if (filterType.value === 'none') {
|
||||||
|
return recordDiag.lostChunks.length === 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = ref(10);
|
||||||
|
|
||||||
|
watch([() => ndmDevice.value.id, () => ndmDevice.value.lastDiagTime, filterType], () => {
|
||||||
|
page.value = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const pagedRecordDiags = computed(() => {
|
||||||
|
const startIndex = (page.value - 1) * pageSize.value;
|
||||||
|
const endIndex = page.value * pageSize.value;
|
||||||
|
return recordDiags.value.slice(startIndex, endIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getLostChunkDOMStyle = (lostChunk: RecordItem, duration: RecordItem): CSSProperties => {
|
||||||
|
const chunk = dayjs(lostChunk.endTime).diff(dayjs(lostChunk.startTime));
|
||||||
|
const offset = dayjs(lostChunk.startTime).diff(dayjs(duration.startTime));
|
||||||
|
const total = dayjs(duration.endTime).diff(dayjs(duration.startTime));
|
||||||
|
return {
|
||||||
|
left: `${(offset / total) * 100}%`,
|
||||||
|
width: `${(chunk / total) * 100}%`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const lostChunkPopoverContext = ref<{ show: boolean; x: number; y: number; lostDuration?: RecordItem }>({ show: false, x: 0, y: 0 });
|
||||||
|
|
||||||
|
const onMouseEnterLostChunk = (event: MouseEvent, lostChunk: RecordItem) => {
|
||||||
|
const { target: lostChunkDiv } = event;
|
||||||
|
const { width, left, top } = (lostChunkDiv as HTMLDivElement).getBoundingClientRect();
|
||||||
|
const { startTime, endTime } = lostChunk;
|
||||||
|
lostChunkPopoverContext.value = {
|
||||||
|
show: true,
|
||||||
|
x: left + width / 2,
|
||||||
|
y: top,
|
||||||
|
lostDuration: { startTime, endTime },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseLeaveLostChunk = () => {
|
||||||
|
lostChunkPopoverContext.value.show = false;
|
||||||
|
lostChunkPopoverContext.value.lostDuration = undefined;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -183,10 +207,10 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
<template #default>
|
<template #default>
|
||||||
<NFlex justify="flex-end" style="margin-bottom: 6px">
|
<NFlex justify="flex-end" style="margin-bottom: 6px">
|
||||||
<NRadioGroup size="small" v-model:value="lossInput">
|
<NRadioGroup size="small" v-model:value="filterType">
|
||||||
<NRadioButton label="全部" :value="0" />
|
<NRadioButton label="全部" :value="'all'" />
|
||||||
<NRadioButton label="有缺失" :value="1" />
|
<NRadioButton label="有缺失" :value="'some'" />
|
||||||
<NRadioButton label="无缺失" :value="2" />
|
<NRadioButton label="无缺失" :value="'none'" />
|
||||||
</NRadioGroup>
|
</NRadioGroup>
|
||||||
</NFlex>
|
</NFlex>
|
||||||
<template v-for="{ gbCode, channelName, recordDuration, lostChunks } in pagedRecordDiags" :key="gbCode">
|
<template v-for="{ gbCode, channelName, recordDuration, lostChunks } in pagedRecordDiags" :key="gbCode">
|
||||||
@@ -206,16 +230,18 @@ onBeforeUnmount(() => {
|
|||||||
</NPopconfirm>
|
</NPopconfirm>
|
||||||
</div>
|
</div>
|
||||||
<div style="position: relative; height: 24px; margin: 2px 0" :style="{ backgroundColor: lostChunks.length > 0 ? themeVars.infoColor : themeVars.successColor }">
|
<div style="position: relative; height: 24px; margin: 2px 0" :style="{ backgroundColor: lostChunks.length > 0 ? themeVars.infoColor : themeVars.successColor }">
|
||||||
<template v-for="{ startTime, endTime } in lostChunks" :key="`${startTime}-${endTime}`">
|
<template v-if="!!recordDuration.startTime && !!recordDuration.endTime">
|
||||||
<NPopover trigger="hover">
|
<template v-for="lostChunk in lostChunks" :key="`${lostChunk.startTime}-${lostChunk.endTime}`">
|
||||||
<template #trigger>
|
<div
|
||||||
<div style="position: absolute; height: 100%; cursor: pointer; background-color: #eee" :style="getLostChunkDOMStyle({ startTime, endTime }, recordDuration)" />
|
style="position: absolute; height: 100%; cursor: pointer; background-color: #eee"
|
||||||
</template>
|
:style="getLostChunkDOMStyle(lostChunk, recordDuration)"
|
||||||
<template #default>
|
@mouseenter="(event) => onMouseEnterLostChunk(event, lostChunk)"
|
||||||
<div>开始时间:{{ dayjs(startTime).format('YYYY-MM-DD HH:mm:ss') }}</div>
|
@mouseleave="onMouseLeaveLostChunk"
|
||||||
<div>结束时间:{{ dayjs(endTime).format('YYYY-MM-DD HH:mm:ss') }}</div>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</NPopover>
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div style="position: absolute; width: 100%; height: 100%; cursor: pointer; background-color: #eee" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -230,6 +256,12 @@ onBeforeUnmount(() => {
|
|||||||
</NFlex>
|
</NFlex>
|
||||||
</template>
|
</template>
|
||||||
</NCard>
|
</NCard>
|
||||||
|
|
||||||
|
<NPopover v-if="lostChunkPopoverContext.lostDuration" trigger="manual" :show="lostChunkPopoverContext.show" :x="lostChunkPopoverContext.x" :y="lostChunkPopoverContext.y">
|
||||||
|
<div>录像缺失</div>
|
||||||
|
<div>开始时间:{{ dayjs(lostChunkPopoverContext.lostDuration.startTime).format('YYYY-MM-DD HH:mm:ss') }}</div>
|
||||||
|
<div>结束时间:{{ dayjs(lostChunkPopoverContext.lostDuration.endTime).format('YYYY-MM-DD HH:mm:ss') }}</div>
|
||||||
|
</NPopover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
<style scoped lang="scss"></style>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import type { NdmRecordCheck, RecordInfo, RecordItem } from '@/apis';
|
import type { NdmRecordCheck, RecordInfo, RecordItem } from '@/apis';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import destr from 'destr';
|
import destr from 'destr';
|
||||||
import { groupBy } from 'es-toolkit';
|
|
||||||
|
|
||||||
export type NvrRecordDiag = {
|
export type NvrRecordDiag = {
|
||||||
gbCode: string;
|
gbCode: string;
|
||||||
channelName: string;
|
channelName: string;
|
||||||
@@ -17,24 +15,28 @@ export const transformRecordChecks = (rawRecordChecks: NdmRecordCheck[]): NvrRec
|
|||||||
...recordCheck,
|
...recordCheck,
|
||||||
diagInfo: destr<RecordInfo>(recordCheck.diagInfo),
|
diagInfo: destr<RecordInfo>(recordCheck.diagInfo),
|
||||||
}));
|
}));
|
||||||
// 按国标码分组
|
// 使用 Map 按国标码分组,保留插入顺序
|
||||||
const recordChecksByGbCode = groupBy(parsedRecordChecks, (recordCheck) => recordCheck.gbCode);
|
const recordChecksMap = new Map<string, typeof parsedRecordChecks>();
|
||||||
// 提取分组后的国标码和录像诊断记录
|
parsedRecordChecks.forEach((recordCheck) => {
|
||||||
const channelGbCodes = Object.keys(recordChecksByGbCode);
|
if (!recordChecksMap.has(recordCheck.gbCode)) {
|
||||||
const recordChecksList = Object.values(recordChecksByGbCode);
|
recordChecksMap.set(recordCheck.gbCode, []);
|
||||||
// 初始化每个通道的录像诊断数据结构
|
}
|
||||||
const recordDiags = channelGbCodes.map((gbCode, index) => ({
|
recordChecksMap.get(recordCheck.gbCode)!.push(recordCheck);
|
||||||
gbCode,
|
|
||||||
channelName: recordChecksList.at(index)?.at(-1)?.name ?? '',
|
|
||||||
records: [] as RecordItem[],
|
|
||||||
lostChunks: [] as RecordItem[],
|
|
||||||
}));
|
|
||||||
// 写入同一gbCode的录像片段
|
|
||||||
recordChecksList.forEach((recordChecks, index) => {
|
|
||||||
recordChecks.forEach((recordCheck) => {
|
|
||||||
recordDiags.at(index)?.records.push(...recordCheck.diagInfo.recordList);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 初始化每个通道的录像诊断数据结构
|
||||||
|
const recordDiags = Array.from(recordChecksMap.entries())
|
||||||
|
.map(([gbCode, recordChecks]) => {
|
||||||
|
return {
|
||||||
|
gbCode,
|
||||||
|
channelName: recordChecks.at(-1)?.name ?? '',
|
||||||
|
records: recordChecks.flatMap((check) => check.diagInfo.recordList),
|
||||||
|
lostChunks: [] as RecordItem[],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((diag1, diag2) => {
|
||||||
|
return diag1.gbCode.localeCompare(diag2.gbCode);
|
||||||
|
});
|
||||||
// 过滤掉没有录像记录的通道
|
// 过滤掉没有录像记录的通道
|
||||||
const filteredRecordDiags = recordDiags.filter((recordDiag) => recordDiag.records.length > 0);
|
const filteredRecordDiags = recordDiags.filter((recordDiag) => recordDiag.records.length > 0);
|
||||||
// 计算每个通道丢失的录像时间片段
|
// 计算每个通道丢失的录像时间片段
|
||||||
|
|||||||
Reference in New Issue
Block a user