Compare commits
11 Commits
b1b2892ff7
...
1c71151a6b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c71151a6b | ||
|
|
68c5d12e14 | ||
|
|
399fb6e9c1 | ||
|
|
352cdc0142 | ||
|
|
d53e107ebc | ||
|
|
fd851bb8d6 | ||
|
|
837b243838 | ||
|
|
403c8d703e | ||
|
|
89ff378eb7 | ||
|
|
7bdda5d546 | ||
|
|
5edd86ee80 |
@@ -65,6 +65,9 @@ src/
|
|||||||
vimp-log-page.vue # 视频平台日志页面
|
vimp-log-page.vue # 视频平台日志页面
|
||||||
permission/
|
permission/
|
||||||
permission-page.vue # 权限管理页面
|
permission-page.vue # 权限管理页面
|
||||||
|
system/
|
||||||
|
changelog/
|
||||||
|
changelog-page.vue # 更新记录页面
|
||||||
error/
|
error/
|
||||||
not-found-page.vue # 404 页面
|
not-found-page.vue # 404 页面
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const versionInfo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await writeFile('./public/manifest.json', JSON.stringify(versionInfo, null, 2));
|
await writeFile('./public/manifest.json', `${JSON.stringify(versionInfo, null, 2)}\n`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('写入manifest失败:', error);
|
console.error('写入manifest失败:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
306
public/changelogs.json
Normal file
306
public/changelogs.json
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"version": "0.39.0",
|
||||||
|
"date": "2026-03-02",
|
||||||
|
"changes": {
|
||||||
|
"feats": [{ "content": "新版录像记录诊断卡片" }, { "content": "新增平台更新记录页面" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.38.5",
|
||||||
|
"date": "2026-02-06",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [{ "content": "修复视频平台和上级调用日志的默认查询没有携带logType参数的问题" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.38.4",
|
||||||
|
"date": "2026-02-05",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [{ "content": "修复告警记录导出未添加条件筛选" }, { "content": "将各查询页的默认分页size从10调整为20" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.38.3",
|
||||||
|
"date": "2026-01-30",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [{ "content": "修复录像诊断导出面板统一使用批量接口" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.38.2",
|
||||||
|
"date": "2026-01-29",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [{ "content": "修复服务状态和推流统计卡片的渲染条件" }, { "content": "用 useQuery 重构录像诊断卡片" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.38.1",
|
||||||
|
"date": "2026-01-28",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [{ "content": "下游设备配置添加权限校验" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.38.0",
|
||||||
|
"date": "2026-01-28",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [{ "content": "新增批量导出录像诊断功能并优化导出体验" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.37.2",
|
||||||
|
"date": "2026-01-27",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [{ "content": "修复设备树选中状态与路由同步的逻辑,修复选中的设备类型被异常还原的问题" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.37.1",
|
||||||
|
"date": "2026-01-27",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [{ "content": "完善设备卡片标签页切换逻辑" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.37.0",
|
||||||
|
"date": "2026-01-22",
|
||||||
|
"changes": {
|
||||||
|
"feats": [{ "content": "添加权限查询和管理机制" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.36.2",
|
||||||
|
"date": "2026-01-21",
|
||||||
|
"changes": {
|
||||||
|
"feats": [{ "content": "车站卡片布局列数自适应" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.36.1",
|
||||||
|
"date": "2026-01-21",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [{ "content": "重构内部状态管理" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.36.0",
|
||||||
|
"date": "2026-01-16",
|
||||||
|
"changes": {
|
||||||
|
"feats": [{ "content": "设备告警记录页面添加告警恢复状态和确认状态筛选" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.35.2",
|
||||||
|
"date": "2026-01-15",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [
|
||||||
|
{ "content": "优化查询链的耗时和错误日志输出" },
|
||||||
|
{ "content": "优化车站状态页面的操作栏交互逻辑" },
|
||||||
|
{ "content": "简化录像诊断卡片逻辑" },
|
||||||
|
{ "content": "抽离未读告警状态,不再持久化" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.35.1",
|
||||||
|
"date": "2026-01-13",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [{ "content": "修复设备硬件占用率卡片中showCard计算属性未获取原始值的问题" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.35.0",
|
||||||
|
"date": "2026-01-13",
|
||||||
|
"changes": {
|
||||||
|
"feats": [{ "content": "更新图标" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.34.1",
|
||||||
|
"date": "2026-01-08",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [
|
||||||
|
{ "content": "修复数据表格标题错误" },
|
||||||
|
{ "content": "修复当API接口定义中没有响应数据时会意外抛出空数据异常的问题" },
|
||||||
|
{ "content": "未登录时启用离线开发模式后添加默认用户信息" },
|
||||||
|
{ "content": "修正告警页路由路径错误" },
|
||||||
|
{ "content": "将请求封装重构为函数模式" },
|
||||||
|
{ "content": "修复请求实例选择逻辑错误" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.34.0",
|
||||||
|
"date": "2026-01-04",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [{ "content": "将表单中的“操作类型”标签改为“日志类型”" }, { "content": "移除操作参数和操作结果列" }, { "content": "修复操作类型列渲染错误的问题" }],
|
||||||
|
"feats": [{ "content": "上级调用日志添加更多数据" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.33.0",
|
||||||
|
"date": "2026-01-04",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [{ "content": "优化服务状态卡片的渲染条件" }, { "content": "添加防止设备自关联的校验" }],
|
||||||
|
"feats": [{ "content": "新增流媒体推流统计卡片" }, { "content": "新增告警画面截图相关设置" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.32.0",
|
||||||
|
"date": "2025-12-30",
|
||||||
|
"changes": {
|
||||||
|
"feats": [{ "content": "新增告警画面截图相关设置" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.31.0",
|
||||||
|
"date": "2025-12-30",
|
||||||
|
"changes": {
|
||||||
|
"feats": [{ "content": "新增告警忽略管理页面" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.30.0",
|
||||||
|
"date": "2025-12-28",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [{ "content": "调整路由结构,使告警板块支持子路由" }, { "content": "修复跳转设备时未检查deviceId存在性的问题" }],
|
||||||
|
"feats": [{ "content": "新增告警忽略管理页面" }, { "content": "支持查看摄像机告警画面截图" }, { "content": "查询页面卸载时取消未完成的请求" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.29.0",
|
||||||
|
"date": "2025-12-26",
|
||||||
|
"changes": {
|
||||||
|
"feats": [{ "content": "扩展交换机端口诊断信息" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.28.1",
|
||||||
|
"date": "2025-12-26",
|
||||||
|
"changes": {
|
||||||
|
"feats": [{ "content": "当下游设备不存在时自动解除关联" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.28.0",
|
||||||
|
"date": "2025-12-26",
|
||||||
|
"changes": {
|
||||||
|
"feats": [{ "content": "告警记录支持点击设备跳转到设备详情" }, { "content": "设备关联与解除关联" }, { "content": "扩展设备树功能" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.6",
|
||||||
|
"date": "2025-12-25",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [{ "content": "移除所有设备更新表单中的上游设备字段" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.5",
|
||||||
|
"date": "2025-12-25",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [{ "content": "修复设备管理逻辑中错误处理的loading状态和取消逻辑的顺序" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.4",
|
||||||
|
"date": "2025-12-24",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [{ "content": "修复优化请求封装后获取摄像机画面截图请求异常的问题" }, { "content": "简化设备树的自动定位逻辑" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.3",
|
||||||
|
"date": "2025-12-23",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [{ "content": "次渲染全线设备树时不再区分是否从路由跳转而来,补全遗漏的取消监听" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.2",
|
||||||
|
"date": "2025-12-23",
|
||||||
|
"changes": {
|
||||||
|
"feats": [{ "content": "摄像机卡片添加摄像机类型和建议安装区域" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.26.3",
|
||||||
|
"date": "2025-12-19",
|
||||||
|
"changes": {
|
||||||
|
"feats": [{ "content": "调用新的设备告警日志导出接口" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.26.2",
|
||||||
|
"date": "2025-12-19",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [{ "content": "视频平台日志页面补全遗漏的操作类型字段" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.26.1",
|
||||||
|
"date": "2025-12-19",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [
|
||||||
|
{ "content": "修复由动画属性导致设备树在特定场景下无法自行滚动及展开节点失效的问题" },
|
||||||
|
{ "content": "设备树仅在非车站模式下显示收起和定位按钮" },
|
||||||
|
{ "content": "修复设备更新面板中错误的表单校验逻辑" },
|
||||||
|
{ "content": "简化设备树节点双击和点击事件的逻辑并添加注释" }
|
||||||
|
],
|
||||||
|
"feats": [{ "content": "细化设备树自动定位的触发条件" }, { "content": "渲染全线设备树时自动定位到所选设备" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.26.0",
|
||||||
|
"date": "2025-12-17",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [
|
||||||
|
{ "content": "简化设备树节点双击和点击事件的逻辑并添加注释" },
|
||||||
|
{ "content": "修复设备更新面板中错误的表单校验逻辑" },
|
||||||
|
{ "content": "426d92a - fix: 在导入和删除IndexedDB数据时停止轮询并启用离线开发模式以保证数据一致性" }
|
||||||
|
],
|
||||||
|
"feats": [{ "content": "新增设备树管理功能" }, { "content": "新增流媒体/信令服务状态卡片" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.25.0",
|
||||||
|
"date": "2025-12-11",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [{ "content": "改进设备卡片的布局" }, { "content": "改进内部状态管理" }],
|
||||||
|
"feats": [
|
||||||
|
{ "content": "全面优化平台数据轮询机制,提升平台性能" },
|
||||||
|
{ "content": "支持修改设备" },
|
||||||
|
{ "content": "告警轮询中获取完整告警数据" },
|
||||||
|
{ "content": "车站告警详情支持导出完整的今日告警列表" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "更早版本~0.25.0",
|
||||||
|
"date": "~2025-12-11",
|
||||||
|
"changes": {
|
||||||
|
"fixes": [
|
||||||
|
{ "content": "修复安防箱部分开关状态错误" },
|
||||||
|
{ "content": "优化版本更新机制" },
|
||||||
|
{ "content": "优化交互时的数据查询机制" },
|
||||||
|
{ "content": "优化获取摄像机告警时画面截图的交互体验" },
|
||||||
|
{ "content": "修复更改显示器息屏计划时的错误请求" },
|
||||||
|
{ "content": "修复开启实时告警刷新时的交互错误" },
|
||||||
|
{ "content": "修复404异常时的页面跳转错误" },
|
||||||
|
{ "content": "......." }
|
||||||
|
],
|
||||||
|
"feats": [
|
||||||
|
{ "content": "新增同步摄像机功能" },
|
||||||
|
{ "content": "支持多选车站导出设备列表" },
|
||||||
|
{ "content": "新增车站状态页面的操作栏" },
|
||||||
|
{ "content": "支持忽略摄像机告警" },
|
||||||
|
{ "content": "新增报警主机设备" },
|
||||||
|
{ "content": "设备告警页面支持实时刷新" },
|
||||||
|
{ "content": "新增支持获取摄像机告警时的画面截图" },
|
||||||
|
{ "content": "新增支持手动诊断设备" },
|
||||||
|
{ "content": "......" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"version": "",
|
"version": "0.39.0",
|
||||||
"buildTime": ""
|
"buildTime": "2026-03-02 15:14:53"
|
||||||
}
|
}
|
||||||
|
|||||||
13
src/apis/domain/version/changelog.ts
Normal file
13
src/apis/domain/version/changelog.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export interface ChangeLogDescription {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Changelog {
|
||||||
|
version: string;
|
||||||
|
date: string;
|
||||||
|
changes: {
|
||||||
|
breaks?: ChangeLogDescription[];
|
||||||
|
fixes?: ChangeLogDescription[];
|
||||||
|
feats?: ChangeLogDescription[];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
|
export * from './changelog';
|
||||||
export * from './version-info';
|
export * from './version-info';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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 NvrRecordCard from './nvr-record-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';
|
||||||
@@ -14,7 +14,7 @@ export {
|
|||||||
DeviceHardwareCard,
|
DeviceHardwareCard,
|
||||||
DeviceHeaderCard,
|
DeviceHeaderCard,
|
||||||
NvrDiskCard,
|
NvrDiskCard,
|
||||||
NvrRecordCard,
|
NvrRecordCheckCard,
|
||||||
SecurityBoxCircuitCard,
|
SecurityBoxCircuitCard,
|
||||||
SecurityBoxCircuitLinkModal,
|
SecurityBoxCircuitLinkModal,
|
||||||
SecurityBoxEnvCard,
|
SecurityBoxEnvCard,
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { getChannelListApi, getRecordCheckApi, reloadAllRecordCheckApi, reloadRecordCheckApi, type NdmNvrResultVO, type RecordItem, type Station } from '@/apis';
|
|
||||||
import { exportRecordDiagCsv, transformRecordChecks } from '@/helpers';
|
|
||||||
import { useSettingStore } from '@/stores';
|
|
||||||
import { parseErrorFeedback } from '@/utils';
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
|
|
||||||
import { isCancel } from 'axios';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { DownloadIcon, RotateCwIcon } from 'lucide-vue-next';
|
|
||||||
import { NButton, NCard, NFlex, NIcon, NPagination, NPopconfirm, NPopover, NRadioButton, NRadioGroup, NTooltip, useThemeVars } from 'naive-ui';
|
|
||||||
import { storeToRefs } from 'pinia';
|
|
||||||
import { computed, onBeforeUnmount, ref, toRefs, watch } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
ndmDevice: NdmNvrResultVO;
|
|
||||||
station: Station;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const settingStore = useSettingStore();
|
|
||||||
const { activeRequests } = storeToRefs(settingStore);
|
|
||||||
|
|
||||||
const themeVars = useThemeVars();
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { ndmDevice, station } = toRefs(props);
|
|
||||||
|
|
||||||
const lossInput = ref<number>(0);
|
|
||||||
|
|
||||||
const abortController = ref<AbortController>(new AbortController());
|
|
||||||
|
|
||||||
const NVR_RECORD_CHECK_KEY = 'nvr_record_check_query';
|
|
||||||
|
|
||||||
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, 90, [], { stationCode: station.value.code, signal });
|
|
||||||
return checks;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
watch(activeRequests, (active) => {
|
|
||||||
if (!active) {
|
|
||||||
queryClient.cancelQueries({ queryKey: [NVR_RECORD_CHECK_KEY] });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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({
|
|
||||||
mutationFn: async () => {
|
|
||||||
abortController.value.abort();
|
|
||||||
abortController.value = new AbortController();
|
|
||||||
await reloadAllRecordCheckApi(90, { 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 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({
|
|
||||||
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, 90, { 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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
abortController.value.abort();
|
|
||||||
});
|
|
||||||
</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 @click="onExportRecordCheck">
|
|
||||||
<template #icon>
|
|
||||||
<NIcon :component="DownloadIcon" />
|
|
||||||
</template>
|
|
||||||
</NButton>
|
|
||||||
</template>
|
|
||||||
<template #default>
|
|
||||||
<span>导出录像诊断</span>
|
|
||||||
</template>
|
|
||||||
</NTooltip>
|
|
||||||
</NFlex>
|
|
||||||
</template>
|
|
||||||
<template #default>
|
|
||||||
<NFlex justify="flex-end" style="margin-bottom: 6px">
|
|
||||||
<NRadioGroup size="small" v-model:value="lossInput">
|
|
||||||
<NRadioButton label="全部" :value="0" />
|
|
||||||
<NRadioButton label="有缺失" :value="1" />
|
|
||||||
<NRadioButton label="无缺失" :value="2" />
|
|
||||||
</NRadioGroup>
|
|
||||||
</NFlex>
|
|
||||||
<template v-for="{ gbCode, channelName, recordDuration, lostChunks } in pagedRecordDiags" :key="gbCode">
|
|
||||||
<div style="display: flex; justify-content: space-between">
|
|
||||||
<div>
|
|
||||||
<span>{{ channelName }}</span>
|
|
||||||
<span>{{ '\u3000' }}</span>
|
|
||||||
<span>{{ recordDuration.startTime }} - {{ recordDuration.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" :style="{ backgroundColor: lostChunks.length > 0 ? themeVars.infoColor : themeVars.successColor }">
|
|
||||||
<template v-for="{ startTime, endTime } in lostChunks" :key="`${startTime}-${endTime}`">
|
|
||||||
<NPopover trigger="hover">
|
|
||||||
<template #trigger>
|
|
||||||
<div style="position: absolute; height: 100%; cursor: pointer; background-color: #eee" :style="getLostChunkDOMStyle({ startTime, endTime }, recordDuration)" />
|
|
||||||
</template>
|
|
||||||
<template #default>
|
|
||||||
<div>开始时间:{{ dayjs(startTime).format('YYYY-MM-DD HH:mm:ss') }}</div>
|
|
||||||
<div>结束时间:{{ dayjs(endTime).format('YYYY-MM-DD HH:mm:ss') }}</div>
|
|
||||||
</template>
|
|
||||||
</NPopover>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<template #action>
|
|
||||||
<NFlex justify="flex-end">
|
|
||||||
<NPagination size="small" :page="page" :page-size="pageSize" :page-count="Math.ceil(recordDiags.length / pageSize)" @update:page="(p) => (page = p)">
|
|
||||||
<template #prefix>
|
|
||||||
<span>{{ `共 ${recordDiags.length} 个通道` }}</span>
|
|
||||||
</template>
|
|
||||||
</NPagination>
|
|
||||||
</NFlex>
|
|
||||||
</template>
|
|
||||||
</NCard>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
|
||||||
@@ -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,6 @@
|
|||||||
<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, NvrRecordCard } from '@/components';
|
import { DeviceCommonCard, DeviceHardwareCard, DeviceHeaderCard, NvrDiskCard, NvrRecordCheckCard } from '@/components';
|
||||||
import { isNvrCluster } from '@/helpers';
|
import { isNvrCluster } from '@/helpers';
|
||||||
import destr from 'destr';
|
import destr from 'destr';
|
||||||
import { NFlex } from 'naive-ui';
|
import { NFlex } from 'naive-ui';
|
||||||
@@ -47,7 +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" />
|
||||||
<NvrRecordCard v-if="isNvrCluster(ndmDevice)" :ndm-device="ndmDevice" :station="station" />
|
<template v-if="isNvrCluster(ndmDevice)">
|
||||||
|
<NvrRecordCheckCard :ndm-device="ndmDevice" :station="station" />
|
||||||
|
</template>
|
||||||
</NFlex>
|
</NFlex>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ import destr from 'destr';
|
|||||||
import { isFunction } from 'es-toolkit';
|
import { isFunction } from 'es-toolkit';
|
||||||
import localforage from 'localforage';
|
import localforage from 'localforage';
|
||||||
import { DownloadIcon, Trash2Icon, UploadIcon } from 'lucide-vue-next';
|
import { DownloadIcon, Trash2Icon, UploadIcon } from 'lucide-vue-next';
|
||||||
import { NButton, NButtonGroup, NDivider, NDrawer, NDrawerContent, NDropdown, NFlex, NFormItem, NIcon, NInput, NInputNumber, NModal, NSwitch, NText, type DropdownOption } from 'naive-ui';
|
import { NButton, NButtonGroup, NDivider, NDrawer, NDrawerContent, NDropdown, NFlex, NFormItem, NIcon, NInput, NInputNumber, NModal, NSwitch, NText, NTooltip, type DropdownOption } from 'naive-ui';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const show = defineModel<boolean>('show', { default: false });
|
const show = defineModel<boolean>('show', { default: false });
|
||||||
|
|
||||||
@@ -284,6 +287,11 @@ const onDrawerAfterLeave = () => {
|
|||||||
abortControllers.value.retentionDays.abort();
|
abortControllers.value.retentionDays.abort();
|
||||||
abortControllers.value.snapStatus.abort();
|
abortControllers.value.snapStatus.abort();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onClickVersion = () => {
|
||||||
|
show.value = false;
|
||||||
|
router.push({ path: '/changelog' });
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -383,7 +391,16 @@ const onDrawerAfterLeave = () => {
|
|||||||
</NFlex>
|
</NFlex>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<NFlex vertical justify="flex-end" align="center" style="width: 100%; font-size: 12px; gap: 4px">
|
<NFlex vertical justify="flex-end" align="center" style="width: 100%; font-size: 12px; gap: 4px">
|
||||||
<NText :depth="3">平台版本: {{ versionInfo.version }} ({{ versionInfo.buildTime }})</NText>
|
<NTooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<div @click="onClickVersion">
|
||||||
|
<NText :depth="3" style="cursor: pointer">平台版本: {{ versionInfo.version }} ({{ versionInfo.buildTime }})</NText>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #default>
|
||||||
|
<NText :depth="3">点击可查看平台更新记录</NText>
|
||||||
|
</template>
|
||||||
|
</NTooltip>
|
||||||
</NFlex>
|
</NFlex>
|
||||||
</template>
|
</template>
|
||||||
</NDrawerContent>
|
</NDrawerContent>
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import type { Station } from '@/apis';
|
|
||||||
import type { NvrRecordDiag } from './record-check';
|
|
||||||
import { downloadByData, formatDuration } from '@/utils';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
|
|
||||||
export const exportRecordDiagCsv = (recordDiags: NvrRecordDiag[], stationName: Station['name']) => {
|
|
||||||
const csvHeader = '通道名称,开始时间,结束时间,持续时长\n';
|
|
||||||
const csvRows = recordDiags
|
|
||||||
.map((channel) => {
|
|
||||||
if (channel.lostChunks.length === 0) {
|
|
||||||
return `${channel.channelName},,,`;
|
|
||||||
}
|
|
||||||
return channel.lostChunks
|
|
||||||
.map((loss) => {
|
|
||||||
const duration = formatDuration(loss.startTime, loss.endTime);
|
|
||||||
const startTime = dayjs(loss.startTime).format('YYYY-MM-DD HH:mm:ss');
|
|
||||||
const endTime = dayjs(loss.endTime).format('YYYY-MM-DD HH:mm:ss');
|
|
||||||
return `${channel.channelName},${startTime},${endTime},${duration}`;
|
|
||||||
})
|
|
||||||
.join('\n');
|
|
||||||
})
|
|
||||||
.join('\n');
|
|
||||||
const csvContent = csvHeader.concat(csvRows);
|
|
||||||
const time = dayjs().format('YYYY-MM-DD_HH-mm-ss');
|
|
||||||
downloadByData(csvContent, `${stationName}_录像缺失记录_${time}.csv`, 'text/csv;charset=utf-8', '\ufeff');
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
export * from './device-alarm';
|
export * from './device-alarm';
|
||||||
export * from './export-record-diag-csv';
|
|
||||||
export * from './nvr-cluster';
|
export * from './nvr-cluster';
|
||||||
export * from './record-check';
|
|
||||||
export * from './switch-port';
|
export * from './switch-port';
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
import type { NdmRecordCheck, RecordInfo, RecordItem } from '@/apis';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import destr from 'destr';
|
|
||||||
import { groupBy } from 'es-toolkit';
|
|
||||||
|
|
||||||
export type NvrRecordDiag = {
|
|
||||||
gbCode: string;
|
|
||||||
channelName: string;
|
|
||||||
recordDuration: RecordItem;
|
|
||||||
lostChunks: RecordItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// 解析出丢失的录像时间段
|
|
||||||
export const transformRecordChecks = (rawRecordChecks: NdmRecordCheck[]): NvrRecordDiag[] => {
|
|
||||||
// 解析diagInfo
|
|
||||||
const parsedRecordChecks = rawRecordChecks.map((recordCheck) => ({
|
|
||||||
...recordCheck,
|
|
||||||
diagInfo: destr<RecordInfo>(recordCheck.diagInfo),
|
|
||||||
}));
|
|
||||||
// 按国标码分组
|
|
||||||
const recordChecksByGbCode = groupBy(parsedRecordChecks, (recordCheck) => recordCheck.gbCode);
|
|
||||||
// 提取分组后的国标码和录像诊断记录
|
|
||||||
const channelGbCodes = Object.keys(recordChecksByGbCode);
|
|
||||||
const recordChecksList = Object.values(recordChecksByGbCode);
|
|
||||||
// 初始化每个通道的录像诊断数据结构
|
|
||||||
const recordDiags = channelGbCodes.map((gbCode, index) => ({
|
|
||||||
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 filteredRecordDiags = recordDiags.filter((recordDiag) => recordDiag.records.length > 0);
|
|
||||||
// 计算每个通道丢失的录像时间片段
|
|
||||||
filteredRecordDiags.forEach((recordDiag) => {
|
|
||||||
recordDiag.records.forEach((record, index, records) => {
|
|
||||||
const nextRecordItem = records.at(index + 1);
|
|
||||||
if (!!nextRecordItem) {
|
|
||||||
// 如果下一段录像的开始时间不等于当前录像的结束时间,则判定为丢失
|
|
||||||
const nextStartTime = nextRecordItem.startTime;
|
|
||||||
const currEndTime = record.endTime;
|
|
||||||
if (nextStartTime !== currEndTime) {
|
|
||||||
recordDiag.lostChunks.push({
|
|
||||||
startTime: currEndTime,
|
|
||||||
endTime: nextStartTime,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return recordDiags.map((recordDiag) => {
|
|
||||||
const firstRecord = recordDiag.records.at(0);
|
|
||||||
const startTime = firstRecord ? dayjs(firstRecord.startTime).format('YYYY-MM-DD HH:mm:ss') : '';
|
|
||||||
const lastRecord = recordDiag.records.at(-1);
|
|
||||||
const endTime = lastRecord ? dayjs(lastRecord.endTime).format('YYYY-MM-DD HH:mm:ss') : '';
|
|
||||||
return {
|
|
||||||
gbCode: recordDiag.gbCode,
|
|
||||||
channelName: recordDiag.channelName,
|
|
||||||
recordDuration: { startTime, endTime },
|
|
||||||
lostChunks: recordDiag.lostChunks,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
58
src/pages/system/changelog/changelog-page.vue
Normal file
58
src/pages/system/changelog/changelog-page.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Changelog } from '@/apis';
|
||||||
|
import { useQuery } from '@tanstack/vue-query';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { NH1, NH2, NH3, NLi, NP, NScrollbar, NText, NUl } from 'naive-ui';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const CHENGELOGS_QUERY_KEY = 'changelogs-query';
|
||||||
|
|
||||||
|
const { data: changelogs = [] } = useQuery({
|
||||||
|
queryKey: computed(() => [CHENGELOGS_QUERY_KEY]),
|
||||||
|
queryFn: async ({ signal }) => {
|
||||||
|
const response = await axios.get<Changelog[]>(`changelogs.json?t=${Date.now()}`, { signal });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NScrollbar content-style="padding: 32px 24px 56px 56px" style="width: 100%; height: 100%">
|
||||||
|
<NH1>平台更新记录</NH1>
|
||||||
|
<template v-for="{ version, date, changes } in changelogs" :key="version">
|
||||||
|
<NH2>{{ version }}</NH2>
|
||||||
|
<NP>
|
||||||
|
<NText code>{{ date }}</NText>
|
||||||
|
</NP>
|
||||||
|
|
||||||
|
<template v-if="(changes.breaks?.length ?? 0) > 0">
|
||||||
|
<NH3>重大变更</NH3>
|
||||||
|
<template v-for="({ content }, index) in changes.breaks" :key="index">
|
||||||
|
<NUl>
|
||||||
|
<NLi>{{ content }}</NLi>
|
||||||
|
</NUl>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="(changes.fixes?.length ?? 0) > 0">
|
||||||
|
<NH3>修复</NH3>
|
||||||
|
<template v-for="({ content }, index) in changes.fixes" :key="index">
|
||||||
|
<NUl>
|
||||||
|
<NLi>{{ content }}</NLi>
|
||||||
|
</NUl>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="(changes.feats?.length ?? 0) > 0">
|
||||||
|
<NH3>新增</NH3>
|
||||||
|
<template v-for="({ content }, index) in changes.feats" :key="index">
|
||||||
|
<NUl>
|
||||||
|
<NLi>{{ content }}</NLi>
|
||||||
|
</NUl>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</NScrollbar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -56,9 +56,13 @@ const router = createRouter({
|
|||||||
return { path: '/404' };
|
return { path: '/404' };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'changelog',
|
||||||
|
component: () => import('@/pages/system/changelog/changelog-page.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
component: () => import('@/pages/error/not-found-page.vue'),
|
component: () => import('@/pages/system/error/not-found-page.vue'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NdmPermissionResultVO, Station } from '@/apis';
|
import type { NdmPermissionResultVO, Station } from '@/apis';
|
||||||
import { NDM_PERMISSION_STORE_ID } from '@/constants';
|
import { NDM_PERMISSION_STORE_ID } from '@/constants';
|
||||||
import { PERMISSION_TYPE_NAMES, type PermissionType } from '@/enums';
|
import { PERMISSION_TYPE_NAMES, type PermissionType } from '@/enums';
|
||||||
import { useStationStore } from '@/stores';
|
import { useSettingStore, useStationStore } from '@/stores';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { objectEntries } from '@vueuse/core';
|
import { objectEntries } from '@vueuse/core';
|
||||||
@@ -12,12 +12,21 @@ export const usePermissionStore = defineStore(
|
|||||||
NDM_PERMISSION_STORE_ID,
|
NDM_PERMISSION_STORE_ID,
|
||||||
() => {
|
() => {
|
||||||
const stationStore = useStationStore();
|
const stationStore = useStationStore();
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
|
||||||
const permissionRecords = ref<NdmPermissionResultVO[] | null>(null);
|
const permissionRecords = ref<NdmPermissionResultVO[] | null>(null);
|
||||||
|
|
||||||
const permissions = computed<Permissions>(() => {
|
const permissions = computed<Permissions>(() => {
|
||||||
const result: Permissions = {};
|
const result: Permissions = {};
|
||||||
|
|
||||||
|
// 如果启用了mock用户,则授予所有车站全部权限
|
||||||
|
if (settingStore.mockUser) {
|
||||||
|
stationStore.stations.forEach((station) => {
|
||||||
|
result[station.code] = [...objectEntries(PERMISSION_TYPE_NAMES).map(([permType]) => permType)];
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
const records = permissionRecords.value;
|
const records = permissionRecords.value;
|
||||||
|
|
||||||
// 如果权限记录不存在,则不做权限配置
|
// 如果权限记录不存在,则不做权限配置
|
||||||
|
|||||||
@@ -41,6 +41,41 @@ const getConfigureFn = (opts?: { ws?: boolean }): ProxyOptions['configure'] => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const line01ApiProxyList: ProxyItem[] = [
|
||||||
|
{ key: '/0175/api', target: 'http://10.14.0.10:18760', rewrite: ['/0175/api', '/api'] },
|
||||||
|
{ key: '/0176/api', target: 'http://10.14.97.10:18760', rewrite: ['/0176/api', '/api'] },
|
||||||
|
{ key: '/0168/api', target: 'http://10.14.116.10:18760', rewrite: ['/0168/api', '/api'] },
|
||||||
|
{ key: '/0181/api', target: 'http://10.14.120.10:18760', rewrite: ['/0181/api', '/api'] },
|
||||||
|
{ key: '/0101/api', target: 'http://10.14.1.10:18760', rewrite: ['/0101/api', '/api'] },
|
||||||
|
{ key: '/0102/api', target: 'http://10.14.3.10:18760', rewrite: ['/0102/api', '/api'] },
|
||||||
|
{ key: '/0103/api', target: 'http://10.14.5.10:18760', rewrite: ['/0103/api', '/api'] },
|
||||||
|
{ key: '/0104/api', target: 'http://10.14.7.10:18760', rewrite: ['/0104/api', '/api'] },
|
||||||
|
{ key: '/0105/api', target: 'http://10.14.9.10:18760', rewrite: ['/0105/api', '/api'] },
|
||||||
|
{ key: '/0106/api', target: 'http://10.14.11.10:18760', rewrite: ['/0106/api', '/api'] },
|
||||||
|
{ key: '/0107/api', target: 'http://10.14.13.10:18760', rewrite: ['/0107/api', '/api'] },
|
||||||
|
{ key: '/0108/api', target: 'http://10.14.15.10:18760', rewrite: ['/0108/api', '/api'] },
|
||||||
|
{ key: '/0109/api', target: 'http://10.14.17.10:18760', rewrite: ['/0109/api', '/api'] },
|
||||||
|
{ key: '/0110/api', target: 'http://10.14.19.10:18760', rewrite: ['/0110/api', '/api'] },
|
||||||
|
{ key: '/0111/api', target: 'http://10.14.21.10:18760', rewrite: ['/0111/api', '/api'] },
|
||||||
|
{ key: '/0112/api', target: 'http://10.14.23.10:18760', rewrite: ['/0112/api', '/api'] },
|
||||||
|
{ key: '/0113/api', target: 'http://10.14.25.10:18760', rewrite: ['/0113/api', '/api'] },
|
||||||
|
{ key: '/0114/api', target: 'http://10.14.27.10:18760', rewrite: ['/0114/api', '/api'] },
|
||||||
|
{ key: '/0115/api', target: 'http://10.14.29.10:18760', rewrite: ['/0115/api', '/api'] },
|
||||||
|
{ key: '/0116/api', target: 'http://10.14.31.10:18760', rewrite: ['/0116/api', '/api'] },
|
||||||
|
{ key: '/0117/api', target: 'http://10.14.33.10:18760', rewrite: ['/0117/api', '/api'] },
|
||||||
|
{ key: '/0118/api', target: 'http://10.14.35.10:18760', rewrite: ['/0118/api', '/api'] },
|
||||||
|
{ key: '/0119/api', target: 'http://10.14.37.10:18760', rewrite: ['/0119/api', '/api'] },
|
||||||
|
{ key: '/0120/api', target: 'http://10.14.39.10:18760', rewrite: ['/0120/api', '/api'] },
|
||||||
|
{ key: '/0121/api', target: 'http://10.14.41.10:18760', rewrite: ['/0121/api', '/api'] },
|
||||||
|
{ key: '/0122/api', target: 'http://10.14.43.10:18760', rewrite: ['/0122/api', '/api'] },
|
||||||
|
{ key: '/0123/api', target: 'http://10.14.45.10:18760', rewrite: ['/0123/api', '/api'] },
|
||||||
|
{ key: '/0124/api', target: 'http://10.14.47.10:18760', rewrite: ['/0124/api', '/api'] },
|
||||||
|
{ key: '/0125/api', target: 'http://10.14.49.10:18760', rewrite: ['/0125/api', '/api'] },
|
||||||
|
{ key: '/0126/api', target: 'http://10.14.51.10:18760', rewrite: ['/0126/api', '/api'] },
|
||||||
|
{ key: '/0127/api', target: 'http://10.14.53.10:18760', rewrite: ['/0127/api', '/api'] },
|
||||||
|
{ key: '/0128/api', target: 'http://10.14.55.10:18760', rewrite: ['/0128/api', '/api'] },
|
||||||
|
];
|
||||||
|
|
||||||
const line04ApiProxyList: ProxyItem[] = [
|
const line04ApiProxyList: ProxyItem[] = [
|
||||||
{ key: '/0475/api', target: 'http://10.15.128.10:18760', rewrite: ['/0475/api', '/api'] },
|
{ key: '/0475/api', target: 'http://10.15.128.10:18760', rewrite: ['/0475/api', '/api'] },
|
||||||
{ key: '/0480/api', target: 'http://10.15.244.10:18760', rewrite: ['/0480/api', '/api'] },
|
{ key: '/0480/api', target: 'http://10.15.244.10:18760', rewrite: ['/0480/api', '/api'] },
|
||||||
@@ -103,6 +138,11 @@ const line10ApiProxyList: ProxyItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const apiProxyList: ProxyItem[] = [
|
const apiProxyList: ProxyItem[] = [
|
||||||
|
// { key: '/minio', target: 'http://10.14.0.10:9000', rewrite: ['/minio', ''] },
|
||||||
|
// { key: '/api', target: 'http://10.14.0.10:18760' },
|
||||||
|
// { key: '/ws', target: 'ws://10.14.0.10:18103', ws: true },
|
||||||
|
...line01ApiProxyList,
|
||||||
|
|
||||||
// { key: '/minio', target: 'http://10.15.128.10:9000', rewrite: ['/minio', ''] },
|
// { key: '/minio', target: 'http://10.15.128.10:9000', rewrite: ['/minio', ''] },
|
||||||
// { key: '/api', target: 'http://10.15.128.10:18760' },
|
// { key: '/api', target: 'http://10.15.128.10:18760' },
|
||||||
// { key: '/ws', target: 'ws://10.15.128.10:18103', ws: true },
|
// { key: '/ws', target: 'ws://10.15.128.10:18103', ws: true },
|
||||||
|
|||||||
Reference in New Issue
Block a user