feat: 新版录像诊断卡片
- 按天聚合录像缺失片段,渲染为不同颜色 - 支持查看某天的录像缺失详情
This commit is contained in:
@@ -2,10 +2,22 @@ import DeviceCommonCard from './device-common-card.vue';
|
||||
import DeviceHardwareCard from './device-hardware-card.vue';
|
||||
import DeviceHeaderCard from './device-header-card.vue';
|
||||
import NvrDiskCard from './nvr-disk-card.vue';
|
||||
import NvrRecordCheckCard from './nvr-record-check-card.vue';
|
||||
import SecurityBoxCircuitCard from './security-box-circuit-card.vue';
|
||||
import SecurityBoxCircuitLinkModal from './security-box-circuit-link-modal.vue';
|
||||
import SecurityBoxEnvCard from './security-box-env-card.vue';
|
||||
import SwitchPortCard from './switch-port-card.vue';
|
||||
import SwitchPortLinkModal from './switch-port-link-modal.vue';
|
||||
|
||||
export { DeviceCommonCard, DeviceHardwareCard, DeviceHeaderCard, NvrDiskCard, SecurityBoxCircuitCard, SecurityBoxCircuitLinkModal, SecurityBoxEnvCard, SwitchPortCard, SwitchPortLinkModal };
|
||||
export {
|
||||
DeviceCommonCard,
|
||||
DeviceHardwareCard,
|
||||
DeviceHeaderCard,
|
||||
NvrDiskCard,
|
||||
NvrRecordCheckCard,
|
||||
SecurityBoxCircuitCard,
|
||||
SecurityBoxCircuitLinkModal,
|
||||
SecurityBoxEnvCard,
|
||||
SwitchPortCard,
|
||||
SwitchPortLinkModal,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
<script lang="ts">
|
||||
const DAY_RANGE_VALUE = 24 * 60 * 60 * 1000;
|
||||
|
||||
const formatDuration = (ms: number, options?: { withinDay?: boolean }) => {
|
||||
const { withinDay = false } = options ?? {};
|
||||
const duration = dayjs.duration(ms);
|
||||
if (withinDay) {
|
||||
if (duration.asDays() > 1) {
|
||||
throw new Error('时长不能超过24小时');
|
||||
}
|
||||
}
|
||||
const days = duration.days();
|
||||
const hours = duration.hours();
|
||||
const minutes = duration.minutes();
|
||||
const seconds = duration.seconds();
|
||||
let result = '';
|
||||
if (days > 0) {
|
||||
result += `${days}天`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
result += `${hours}小时`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
result += `${minutes}分钟`;
|
||||
}
|
||||
if (seconds > 0) {
|
||||
result += `${seconds}秒`;
|
||||
}
|
||||
if (result === '') {
|
||||
result = '0秒';
|
||||
}
|
||||
return result;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
batchExportRecordCheckApi,
|
||||
getChannelListApi,
|
||||
getRecordCheckApi,
|
||||
pageDefParameterApi,
|
||||
reloadAllRecordCheckApi,
|
||||
reloadRecordCheckApi,
|
||||
type NdmNvrResultVO,
|
||||
type RecordInfo,
|
||||
type RecordItem,
|
||||
type Station,
|
||||
} from '@/apis';
|
||||
import { useSettingStore } from '@/stores';
|
||||
import { downloadByData, parseErrorFeedback } from '@/utils';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
|
||||
import { refDebounced } from '@vueuse/core';
|
||||
import { isCancel } from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import destr from 'destr';
|
||||
import { DownloadIcon, RotateCwIcon } from 'lucide-vue-next';
|
||||
import { NButton, NCard, NDataTable, NFlex, NIcon, NInput, NModal, NPagination, NPopconfirm, NPopover, NRadioButton, NRadioGroup, NTooltip, useThemeVars, type DataTableColumns } from 'naive-ui';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onBeforeUnmount, ref, toRefs, watch } from 'vue';
|
||||
|
||||
type DailyLossItem = {
|
||||
date: string;
|
||||
total: number; // 缺失时长,单位:ms
|
||||
percent: number; // 缺失比例,范围:0-100
|
||||
chunks: (RecordItem & { startValue: number; endValue: number })[];
|
||||
};
|
||||
|
||||
type NdmRecordCheckAggregated = {
|
||||
gbCode: string;
|
||||
channelName: string;
|
||||
range: RecordItem;
|
||||
dailyLoss: DailyLossItem[];
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
ndmDevice: NdmNvrResultVO;
|
||||
station: Station;
|
||||
}>();
|
||||
|
||||
const { ndmDevice, station } = toRefs(props);
|
||||
|
||||
const settingStore = useSettingStore();
|
||||
const { activeRequests } = storeToRefs(settingStore);
|
||||
|
||||
const themeVars = useThemeVars();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const filterType = ref<'all' | 'some' | 'none'>('all');
|
||||
|
||||
const abortController = ref<AbortController>(new AbortController());
|
||||
|
||||
const NVR_RECORD_CHECK_KEY = 'nvr-record-check-query';
|
||||
|
||||
const DAY_OFFSET = 90;
|
||||
|
||||
const {
|
||||
data: recordChecks,
|
||||
isFetching: loading,
|
||||
refetch: refetchRecordChecks,
|
||||
} = useQuery({
|
||||
queryKey: computed(() => [NVR_RECORD_CHECK_KEY, ndmDevice.value.id, ndmDevice.value.lastDiagTime]),
|
||||
enabled: computed(() => activeRequests.value),
|
||||
refetchInterval: 30 * 1000,
|
||||
gcTime: 0,
|
||||
queryFn: async ({ signal }) => {
|
||||
const checks = await getRecordCheckApi(ndmDevice.value, DAY_OFFSET, [], { stationCode: station.value.code, signal });
|
||||
return checks;
|
||||
},
|
||||
});
|
||||
|
||||
watch(activeRequests, (active) => {
|
||||
if (!active) queryClient.cancelQueries({ queryKey: [NVR_RECORD_CHECK_KEY] });
|
||||
});
|
||||
|
||||
const { mutate: reloadAllRecordCheck, isPending: reloading } = useMutation({
|
||||
mutationFn: async () => {
|
||||
abortController.value.abort();
|
||||
abortController.value = new AbortController();
|
||||
await reloadAllRecordCheckApi(DAY_OFFSET, { stationCode: station.value.code, signal: abortController.value.signal });
|
||||
},
|
||||
onSuccess: () => {
|
||||
window.$message.success('正在逐步刷新中,请稍后点击刷新按钮查看');
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isCancel(error)) return;
|
||||
console.error(error);
|
||||
const errorFeedback = parseErrorFeedback(error);
|
||||
window.$message.error(errorFeedback);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: reloadRecordCheckByGbId } = useMutation({
|
||||
mutationFn: async (params: { gbCode: string }) => {
|
||||
abortController.value.abort();
|
||||
abortController.value = new AbortController();
|
||||
const channelList = await getChannelListApi(ndmDevice.value, { stationCode: station.value.code, signal: abortController.value.signal });
|
||||
const channel = channelList.find((channel) => channel.code === params.gbCode);
|
||||
if (!channel) throw new Error('通道不存在');
|
||||
window.$message.loading('刷新耗时较长, 请不要多次刷新, 并耐心等待...', {
|
||||
duration: 1000 * 60 * 60 * 24 * 300,
|
||||
});
|
||||
const isSuccess = await reloadRecordCheckApi(channel, DAY_OFFSET, { stationCode: station.value.code, signal: abortController.value.signal });
|
||||
window.$message.destroyAll();
|
||||
if (isSuccess) {
|
||||
window.$message.success('刷新成功');
|
||||
} else {
|
||||
window.$message.error('刷新失败');
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetchRecordChecks();
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isCancel(error)) return;
|
||||
console.error(error);
|
||||
const errorFeedback = parseErrorFeedback(error);
|
||||
window.$message.error(errorFeedback);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: exportRecordCheck, isPending: exporting } = useMutation({
|
||||
mutationFn: async () => {
|
||||
abortController.value.abort();
|
||||
abortController.value = new AbortController();
|
||||
const { records = [] } = await pageDefParameterApi(
|
||||
{
|
||||
model: {
|
||||
key: 'NVR_GAP_SECONDS',
|
||||
},
|
||||
extra: {},
|
||||
current: 1,
|
||||
size: 1,
|
||||
sort: 'id',
|
||||
order: 'descending',
|
||||
},
|
||||
{
|
||||
signal: abortController.value.signal,
|
||||
},
|
||||
);
|
||||
const gapSeconds = parseInt(records.at(0)?.value ?? '5');
|
||||
|
||||
abortController.value.abort();
|
||||
abortController.value = new AbortController();
|
||||
const data = await batchExportRecordCheckApi(
|
||||
{
|
||||
checkDuration: DAY_OFFSET,
|
||||
gapSeconds,
|
||||
stationCode: [station.value.code],
|
||||
},
|
||||
{
|
||||
signal: abortController.value.signal,
|
||||
},
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const time = dayjs().format('YYYY-MM-DD_HH-mm-ss');
|
||||
downloadByData(data, `${station.value.name}_录像缺失记录_${time}.xlsx`);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isCancel(error)) return;
|
||||
console.error(error);
|
||||
const errorFeedback = parseErrorFeedback(error);
|
||||
window.$message.error(errorFeedback);
|
||||
},
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
abortController.value.abort();
|
||||
});
|
||||
|
||||
// 按天聚合录像缺失片段
|
||||
|
||||
const ndmRecordChecksAggregated = computed(() => {
|
||||
// 1. 解析diagInfo字段
|
||||
const parsedChecks = (recordChecks.value ?? []).map((check) => {
|
||||
return { ...check, diagInfo: destr<RecordInfo>(check.diagInfo) };
|
||||
});
|
||||
|
||||
// 2. 按gbCode分组
|
||||
// 原始数据的基本单元是一个通道在一天内的录像诊断,
|
||||
// 所以我们要将相同通道的诊断数据组织到一起,于是形成一个Map结构
|
||||
const recordChecksMap = new Map<string, typeof parsedChecks>();
|
||||
parsedChecks.forEach((check) => {
|
||||
const { gbCode } = check;
|
||||
if (!recordChecksMap.has(gbCode)) {
|
||||
recordChecksMap.set(gbCode, []);
|
||||
}
|
||||
recordChecksMap.get(gbCode)?.push(check);
|
||||
});
|
||||
|
||||
// 3. 按天进行聚合
|
||||
// 我们的最终目标是从每个通道的录像记录中解析出缺失的录像片段,
|
||||
// 并按天来组织这些片段,形成NdmRecordCheckAggregated结构
|
||||
const aggregated = Array.from(recordChecksMap.entries()).map<NdmRecordCheckAggregated>(([gbCode, checks]) => {
|
||||
// 首先,将该通道的所有录像记录合并到一个数组中,
|
||||
// 并对这些记录进行排序,确保按时间顺序排列
|
||||
const records = checks
|
||||
.flatMap((check) => {
|
||||
return check.diagInfo.recordList.map((record) => {
|
||||
const startValue = dayjs(record.startTime).valueOf();
|
||||
const endValue = dayjs(record.endTime).valueOf();
|
||||
const startTime = dayjs(record.startTime).format('YYYY-MM-DD HH:mm:ss');
|
||||
const endTime = dayjs(record.endTime).format('YYYY-MM-DD HH:mm:ss');
|
||||
return { startValue, endValue, startTime, endTime };
|
||||
});
|
||||
})
|
||||
.sort(({ startValue: startValue1 }, { startValue: startValue2 }) => {
|
||||
return startValue1 - startValue2;
|
||||
});
|
||||
|
||||
const tomorrow = dayjs().add(1, 'day');
|
||||
|
||||
// 由于DAY_OFFSET实际上不包含今天,而获取的数据又是包含今天的,
|
||||
// 所以实际的时间范围是 DAY_OFFSET + 1 天
|
||||
const dateLength = DAY_OFFSET + 1;
|
||||
|
||||
// 初始化每日缺失记录,
|
||||
// 在处理完成后,如果有一天的数据没有变化,就说明这一天没有缺失录像
|
||||
const dailyLoss = Array.from({ length: dateLength }).map<NdmRecordCheckAggregated['dailyLoss'][number]>((_, index) => {
|
||||
return {
|
||||
date: tomorrow.subtract(dateLength - index, 'day').format('YYYY-MM-DD'),
|
||||
total: 0,
|
||||
percent: 0,
|
||||
chunks: [],
|
||||
};
|
||||
});
|
||||
|
||||
// 开始解析按天组织的缺失录像片段,
|
||||
// 缺失片段的持续时间很可能是跨天甚至是跨越多天的,所以为了将缺失片段分配到每一天,我们采用「游标 + 切片」的设计
|
||||
// 首先,确定时间范围的开始和结束点
|
||||
const rangeStart = dayjs(dailyLoss.at(0)?.date).startOf('day').valueOf();
|
||||
const rangeEnd = dayjs(dailyLoss.at(-1)?.date).add(1, 'day').startOf('day').valueOf();
|
||||
// 初始化时间游标,从第一天的开始时间开始
|
||||
let timeCursor = rangeStart;
|
||||
records.forEach((record) => {
|
||||
const recordStart = record.startValue;
|
||||
const recordEnd = record.endValue;
|
||||
|
||||
// 如果timeCursor < recordStart,说明 [timeCursor, recordStart] 这段时间的录像是缺失的,
|
||||
// 而这一段缺失有可能是跨天的,我们需要进行处理
|
||||
while (timeCursor < recordStart) {
|
||||
// 当前游标所属的日期
|
||||
const cursorDate = dayjs(timeCursor).format('YYYY-MM-DD');
|
||||
// 当前游标所属日期的末尾(下一天的开始时间)
|
||||
const cursorDateEnd = dayjs(cursorDate).add(1, 'day').startOf('day').valueOf();
|
||||
|
||||
// 确定这一段缺失的终点,
|
||||
// 要么是 [timeCursor, recordStart](没跨天),
|
||||
// 要么是 [timeCursor, cursorDateEnd](跨天),
|
||||
// 我们取较小的那个
|
||||
const sliceEnd = Math.min(recordStart, cursorDateEnd);
|
||||
// 只要这段缺失有效,就记下它
|
||||
if (timeCursor < sliceEnd) {
|
||||
const loss = dailyLoss.find((loss) => loss.date === cursorDate);
|
||||
if (!!loss) {
|
||||
const startValue = timeCursor;
|
||||
const endValue = sliceEnd;
|
||||
const startTime = dayjs(startValue).format('YYYY-MM-DD HH:mm:ss');
|
||||
const endTime = dayjs(endValue).format('YYYY-MM-DD HH:mm:ss');
|
||||
loss.chunks.push({ startValue, endValue, startTime, endTime });
|
||||
loss.total += endValue - startValue;
|
||||
loss.percent = (loss.total / DAY_RANGE_VALUE) * 100;
|
||||
}
|
||||
// 推进游标
|
||||
timeCursor = sliceEnd;
|
||||
} else {
|
||||
// 假设这段缺失无效,说明这一天的数据有错乱,
|
||||
// 我们推进游标到下一天的开始时间
|
||||
timeCursor = cursorDateEnd;
|
||||
}
|
||||
}
|
||||
|
||||
// 上面我们处理了 [timeCursor, recordStart] 这段时间的缺失,
|
||||
// 而 [recordStart, recordEnd] 这段时间的录像是完整的,
|
||||
// 所以我们可以直接推进游标到 recordEnd
|
||||
// 使用 Math.max 是为了防止两段录像记录交叉从而导致游标又发生回退
|
||||
timeCursor = Math.max(timeCursor, recordEnd);
|
||||
});
|
||||
|
||||
// 现在我们处理完了所有的录像记录,但如果游标还没有到rangeEnd,
|
||||
// 说明还有一段缺失的录像记录没有被处理到,
|
||||
// 我们需要将这一段缺失记录分配到最后一天
|
||||
while (timeCursor < rangeEnd) {
|
||||
const cursorDate = dayjs(timeCursor).format('YYYY-MM-DD');
|
||||
const cursorDateEnd = dayjs(cursorDate).add(1, 'day').startOf('day').valueOf();
|
||||
const sliceEnd = Math.min(rangeEnd, cursorDateEnd);
|
||||
if (timeCursor < sliceEnd) {
|
||||
const loss = dailyLoss.find((loss) => loss.date === cursorDate);
|
||||
if (!!loss) {
|
||||
const startValue = timeCursor;
|
||||
const endValue = sliceEnd;
|
||||
const startTime = dayjs(startValue).format('YYYY-MM-DD HH:mm:ss');
|
||||
const endTime = dayjs(endValue).format('YYYY-MM-DD HH:mm:ss');
|
||||
loss.chunks.push({ startValue, endValue, startTime, endTime });
|
||||
loss.total += endValue - startValue;
|
||||
loss.percent = (loss.total / DAY_RANGE_VALUE) * 100;
|
||||
}
|
||||
timeCursor = sliceEnd;
|
||||
} else {
|
||||
timeCursor = cursorDateEnd;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
gbCode: gbCode,
|
||||
channelName: checks.at(-1)?.name ?? '',
|
||||
range: {
|
||||
startTime: records.at(0)?.startTime ?? '',
|
||||
endTime: records.at(-1)?.endTime ?? '',
|
||||
},
|
||||
dailyLoss: dailyLoss,
|
||||
};
|
||||
});
|
||||
|
||||
// 最后我们把所有的gbCode按照字典序进行排序
|
||||
return aggregated.sort((check1, check2) => {
|
||||
return check1.gbCode.localeCompare(check2.gbCode);
|
||||
});
|
||||
});
|
||||
|
||||
const searchInput = ref<string>('');
|
||||
const searchInputDebounced = refDebounced(searchInput, 100);
|
||||
|
||||
const ndmRecordChecksSearched = computed(() => {
|
||||
if (!searchInputDebounced.value.trim()) {
|
||||
return ndmRecordChecksAggregated.value;
|
||||
}
|
||||
return ndmRecordChecksAggregated.value.filter(({ channelName }) => {
|
||||
return channelName.includes(searchInputDebounced.value);
|
||||
});
|
||||
});
|
||||
|
||||
const ndmRecordChecksFiltered = computed(() => {
|
||||
// 最后一天就是「今天」,录像不可能完整,slice的时候别算进去
|
||||
return ndmRecordChecksSearched.value.filter(({ dailyLoss }) => {
|
||||
if (filterType.value === 'all') {
|
||||
return true;
|
||||
} else if (filterType.value === 'some') {
|
||||
// return dailyLoss.slice(0, -1).some(({ percent }) => percent > 0);
|
||||
for (let i = 0; i < dailyLoss.length - 1; i++) {
|
||||
if ((dailyLoss[i]?.percent ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} else if (filterType.value === 'none') {
|
||||
// return dailyLoss.slice(0, -1).every(({ percent }) => percent === 0);
|
||||
for (let i = 0; i < dailyLoss.length - 1; i++) {
|
||||
if ((dailyLoss[i]?.percent ?? 0) !== 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
const page = ref(1);
|
||||
const pageSize = ref(10);
|
||||
|
||||
const ndmRecordChecksPaged = computed(() => {
|
||||
const startIndex = (page.value - 1) * pageSize.value;
|
||||
const endIndex = page.value * pageSize.value;
|
||||
return ndmRecordChecksFiltered.value.slice(startIndex, endIndex);
|
||||
});
|
||||
|
||||
// 当设备ID、最后诊断时间或筛选类型变化时,重置分页为第一页
|
||||
watch([() => ndmDevice.value.id, () => ndmDevice.value.lastDiagTime, filterType, searchInputDebounced], () => {
|
||||
page.value = 1;
|
||||
});
|
||||
|
||||
// 当设备ID变化时,重置搜索内容,并将筛选类型重置为「全部」
|
||||
watch([() => ndmDevice.value.id], () => {
|
||||
searchInput.value = '';
|
||||
filterType.value = 'all';
|
||||
});
|
||||
|
||||
// 录像诊断块的交互
|
||||
|
||||
const dailyCheckContext = ref<{
|
||||
show: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
info?: DailyLossItem;
|
||||
}>({
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
// 为了提升性能,不循环渲染Popover,而改为manual模式,
|
||||
// 但是当鼠标移动到Popover上时,将触发录像诊断div块的mouseleave事件,从而导致Popover隐藏。
|
||||
// 为了解决这个问题,当鼠标移出录像诊断块,延迟100ms后再隐藏Popover,
|
||||
// 在延时期间,如果鼠标再次移入录像诊断块或移入Popover,则取消隐藏Popover的延迟操作,
|
||||
// 当鼠标离开Popover,再次延时隐藏Popover。
|
||||
const popoverTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const showDailyCheckPopover = (event: MouseEvent, dailyLossItem: DailyLossItem) => {
|
||||
if (!!popoverTimer.value) {
|
||||
clearTimeout(popoverTimer.value);
|
||||
popoverTimer.value = null;
|
||||
}
|
||||
|
||||
const { target } = event;
|
||||
if (!target) return;
|
||||
const { width, left, top } = (target as HTMLDivElement).getBoundingClientRect();
|
||||
dailyCheckContext.value = {
|
||||
show: true,
|
||||
x: left + width / 2,
|
||||
y: top,
|
||||
info: dailyLossItem,
|
||||
};
|
||||
};
|
||||
|
||||
const hideDailyCheckPopover = () => {
|
||||
popoverTimer.value = setTimeout(() => {
|
||||
dailyCheckContext.value.show = false;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const onMouseEnterDailyCheckPopover = () => {
|
||||
if (!!popoverTimer.value) {
|
||||
clearTimeout(popoverTimer.value);
|
||||
popoverTimer.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseLeaveDailyCheckPopover = () => {
|
||||
hideDailyCheckPopover();
|
||||
};
|
||||
|
||||
// 录像缺失详情弹窗
|
||||
|
||||
const showDailyLossModal = ref(false);
|
||||
|
||||
const onClickDailyCheck = () => {
|
||||
const { info } = dailyCheckContext.value;
|
||||
if (!info) return;
|
||||
const { total } = info;
|
||||
if (total === 0) return;
|
||||
showDailyLossModal.value = true;
|
||||
};
|
||||
|
||||
const columns: DataTableColumns<DailyLossItem['chunks'][number]> = [
|
||||
{ title: '开始时间', key: 'startTime' },
|
||||
{ title: '结束时间', key: 'endTime' },
|
||||
{
|
||||
title: '持续时间',
|
||||
key: 'duration',
|
||||
render: ({ startValue, endValue }) => {
|
||||
return formatDuration(endValue - startValue, { withinDay: true });
|
||||
},
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard hoverable size="small">
|
||||
<template #header>
|
||||
<NFlex align="center" :size="24">
|
||||
<div>录像诊断</div>
|
||||
<NPopconfirm @positive-click="() => reloadAllRecordCheck()">
|
||||
<template #trigger>
|
||||
<NButton secondary size="small" :loading="reloading">更新所有通道录像诊断</NButton>
|
||||
</template>
|
||||
<template #default>
|
||||
<span>确认更新所有通道录像诊断吗?</span>
|
||||
</template>
|
||||
</NPopconfirm>
|
||||
</NFlex>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<NFlex>
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<NButton size="small" quaternary circle :loading="loading" @click="() => refetchRecordChecks()">
|
||||
<template #icon>
|
||||
<NIcon :component="RotateCwIcon" />
|
||||
</template>
|
||||
</NButton>
|
||||
</template>
|
||||
<template #default>
|
||||
<span>刷新数据</span>
|
||||
</template>
|
||||
</NTooltip>
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<NButton size="small" quaternary circle :loading="exporting" @click="() => exportRecordCheck()">
|
||||
<template #icon>
|
||||
<NIcon :component="DownloadIcon" />
|
||||
</template>
|
||||
</NButton>
|
||||
</template>
|
||||
<template #default>
|
||||
<span>导出录像诊断</span>
|
||||
</template>
|
||||
</NTooltip>
|
||||
</NFlex>
|
||||
</template>
|
||||
<template #default>
|
||||
<NFlex justify="flex-end" align="center" :wrap="false" style="width: 100%; margin-bottom: 6px">
|
||||
<NInput v-model:value="searchInput" placeholder="搜索通道名称" clearable />
|
||||
<NRadioGroup size="small" v-model:value="filterType">
|
||||
<NRadioButton label="全部" :value="'all'" />
|
||||
<NRadioButton label="有缺失" :value="'some'" />
|
||||
<NRadioButton label="无缺失" :value="'none'" />
|
||||
</NRadioGroup>
|
||||
</NFlex>
|
||||
<template v-for="{ gbCode, channelName, range, dailyLoss } in ndmRecordChecksPaged" :key="gbCode">
|
||||
<div style="display: flex; justify-content: space-between">
|
||||
<div>
|
||||
<span>{{ channelName }}</span>
|
||||
<span>{{ '\u3000' }}</span>
|
||||
<span>{{ range.startTime }} - {{ range.endTime }}</span>
|
||||
</div>
|
||||
<NPopconfirm trigger="click" @positive-click="() => reloadRecordCheckByGbId({ gbCode })">
|
||||
<template #trigger>
|
||||
<NButton ghost size="tiny" type="info">刷新</NButton>
|
||||
</template>
|
||||
<template #default>
|
||||
<span>是否确认刷新?</span>
|
||||
</template>
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
<div
|
||||
style="position: relative; height: 24px; margin: 2px 0; background-color: #ccc; display: grid"
|
||||
:style="{
|
||||
gridTemplateRows: `1fr`,
|
||||
gridTemplateColumns: `repeat(${dailyLoss.length}, 1fr)`,
|
||||
}"
|
||||
>
|
||||
<template v-for="({ date, total, percent, chunks }, index) in dailyLoss" :key="date">
|
||||
<div
|
||||
style="border-width: 0 1px; border-style: solid"
|
||||
:style="{
|
||||
cursor: percent > 0 ? 'pointer' : 'default',
|
||||
borderColor: themeVars.baseColor,
|
||||
backgroundColor: (() => {
|
||||
// 如果是最后一天(今天),且录像的确持续到了最后一天,则不设置背景颜色
|
||||
if (index === dailyLoss.length - 1) {
|
||||
if (dayjs(dailyLoss.at(-1)?.date).startOf('day').diff(dayjs(range.endTime)) < 0) {
|
||||
return 'transparent';
|
||||
}
|
||||
}
|
||||
// 不缺失,设置为绿色
|
||||
if (percent === 0) {
|
||||
return `rgb(24, 160, 88)`;
|
||||
}
|
||||
// 将缺失占比映射到范围为 [0.2, 1] 的红色透明度通道
|
||||
const opacity = 0.2 + (1 - 0.2) * (percent / 100);
|
||||
return `rgba(208, 48, 80, ${opacity})`;
|
||||
})(),
|
||||
}"
|
||||
@mouseenter="(event) => showDailyCheckPopover(event, { date, total, percent, chunks })"
|
||||
@mouseleave="hideDailyCheckPopover"
|
||||
@click="onClickDailyCheck"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template #action>
|
||||
<NFlex justify="flex-end">
|
||||
<NPagination size="small" :page="page" :page-size="pageSize" :page-count="Math.ceil(ndmRecordChecksFiltered.length / pageSize)" @update:page="(p) => (page = p)">
|
||||
<template #prefix>
|
||||
<span>{{ `共 ${ndmRecordChecksFiltered.length} 个通道` }}</span>
|
||||
</template>
|
||||
</NPagination>
|
||||
</NFlex>
|
||||
</template>
|
||||
</NCard>
|
||||
|
||||
<NPopover
|
||||
trigger="manual"
|
||||
:show="dailyCheckContext.show"
|
||||
:x="dailyCheckContext.x"
|
||||
:y="dailyCheckContext.y"
|
||||
:show-arrow="false"
|
||||
@mouseenter="onMouseEnterDailyCheckPopover"
|
||||
@mouseleave="onMouseLeaveDailyCheckPopover"
|
||||
>
|
||||
<template #default>
|
||||
<template v-if="!!dailyCheckContext.info">
|
||||
<div>日期:{{ dailyCheckContext.info.date }}</div>
|
||||
<div>缺失时长:{{ formatDuration(dailyCheckContext.info.total, { withinDay: true }) }}</div>
|
||||
<div>缺失比例:{{ dailyCheckContext.info.percent.toFixed(2) }}%</div>
|
||||
<div v-if="dailyCheckContext.info.percent > 0" style="font-size: xx-small; opacity: 0.5; cursor: pointer" @click="onClickDailyCheck">点击查看详情</div>
|
||||
</template>
|
||||
</template>
|
||||
</NPopover>
|
||||
|
||||
<NModal v-model:show="showDailyLossModal" preset="card" title="录像缺失详情" style="width: 600px">
|
||||
<template #default>
|
||||
<template v-if="!!dailyCheckContext.info">
|
||||
<div style="margin-bottom: 16px; font-weight: bold">{{ dailyCheckContext.info.date }} 共缺失 {{ dailyCheckContext.info.chunks.length }} 个录像片段</div>
|
||||
<NDataTable :columns="columns" :data="dailyCheckContext.info.chunks" :pagination="{ pageSize: 10 }" size="small" :min-height="400" :max-height="400" />
|
||||
</template>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { NdmNvrDiagInfo, NdmNvrResultVO, Station } from '@/apis';
|
||||
import { DeviceCommonCard, DeviceHardwareCard, DeviceHeaderCard, NvrDiskCard } from '@/components';
|
||||
import { DeviceCommonCard, DeviceHardwareCard, DeviceHeaderCard, NvrDiskCard, NvrRecordCheckCard } from '@/components';
|
||||
import { isNvrCluster } from '@/helpers';
|
||||
import destr from 'destr';
|
||||
import { NFlex } from 'naive-ui';
|
||||
import { computed, toRefs } from 'vue';
|
||||
@@ -46,6 +47,9 @@ const diskArray = computed(() => lastDiagInfo.value?.info?.groupInfoList);
|
||||
<DeviceCommonCard :common-info="commonInfo" />
|
||||
<DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" />
|
||||
<NvrDiskCard :disk-health="diskHealth" :disk-array="diskArray" />
|
||||
<template v-if="isNvrCluster(ndmDevice)">
|
||||
<NvrRecordCheckCard :ndm-device="ndmDevice" :station="station" />
|
||||
</template>
|
||||
</NFlex>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user