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 DeviceHardwareCard from './device-hardware-card.vue';
|
||||||
import DeviceHeaderCard from './device-header-card.vue';
|
import DeviceHeaderCard from './device-header-card.vue';
|
||||||
import NvrDiskCard from './nvr-disk-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 SecurityBoxCircuitCard from './security-box-circuit-card.vue';
|
||||||
import SecurityBoxCircuitLinkModal from './security-box-circuit-link-modal.vue';
|
import SecurityBoxCircuitLinkModal from './security-box-circuit-link-modal.vue';
|
||||||
import SecurityBoxEnvCard from './security-box-env-card.vue';
|
import SecurityBoxEnvCard from './security-box-env-card.vue';
|
||||||
import SwitchPortCard from './switch-port-card.vue';
|
import SwitchPortCard from './switch-port-card.vue';
|
||||||
import SwitchPortLinkModal from './switch-port-link-modal.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">
|
<script setup lang="ts">
|
||||||
import type { NdmNvrDiagInfo, NdmNvrResultVO, Station } from '@/apis';
|
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 destr from 'destr';
|
||||||
import { NFlex } from 'naive-ui';
|
import { NFlex } from 'naive-ui';
|
||||||
import { computed, toRefs } from 'vue';
|
import { computed, toRefs } from 'vue';
|
||||||
@@ -46,6 +47,9 @@ const diskArray = computed(() => lastDiagInfo.value?.info?.groupInfoList);
|
|||||||
<DeviceCommonCard :common-info="commonInfo" />
|
<DeviceCommonCard :common-info="commonInfo" />
|
||||||
<DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" />
|
<DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" />
|
||||||
<NvrDiskCard :disk-health="diskHealth" :disk-array="diskArray" />
|
<NvrDiskCard :disk-health="diskHealth" :disk-array="diskArray" />
|
||||||
|
<template v-if="isNvrCluster(ndmDevice)">
|
||||||
|
<NvrRecordCheckCard :ndm-device="ndmDevice" :station="station" />
|
||||||
|
</template>
|
||||||
</NFlex>
|
</NFlex>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user