refactor: 重构项目结构

- 优化 `车站-设备-告警`  轮询机制
- 改进设备卡片的布局
- 支持修改设备
- 告警轮询中获取完整告警数据
- 车站告警详情支持导出完整的 `今日告警列表`
- 支持将状态持久化到 `IndexedDB`
- 新增轮询控制 (调试模式)
- 新增离线开发模式 (调试模式)
- 新增 `IndexedDB` 数据控制 (调试模式)
This commit is contained in:
yangsy
2025-12-11 13:42:22 +08:00
commit 37781216b2
278 changed files with 17988 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { NCard, NDescriptions, NDescriptionsItem } from 'naive-ui';
import { computed, toRefs } from 'vue';
const props = defineProps<{
commonInfo?: Record<string, string>;
}>();
const { commonInfo } = toRefs(props);
const showCard = computed(() => !!commonInfo.value);
const commonInfoEntries = computed(() => Object.entries(commonInfo.value ?? {}));
</script>
<template>
<NCard v-if="showCard" hoverable size="small">
<template #header>
<div>设备信息</div>
</template>
<NDescriptions bordered label-placement="left" :columns="2">
<template v-for="item in commonInfoEntries" :key="item[0]">
<NDescriptionsItem :label="item[0]">{{ item[1] }}</NDescriptionsItem>
</template>
</NDescriptions>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import { ClockCircleOutlined, CodeOutlined, FireOutlined, SaveOutlined } from '@vicons/antd';
import { NCard, NFlex, NIcon, NProgress, type ProgressStatus } from 'naive-ui';
import { computed, toRefs } from 'vue';
const props = defineProps<{
cpuUsage?: string;
memUsage?: string;
diskUsage?: string;
runningTime?: string;
}>();
const { cpuUsage, memUsage, diskUsage, runningTime } = toRefs(props);
const showCard = computed(() => {
return Object.values(props).some((value) => !!value);
});
const cpuPercent = computed(() => {
if (!cpuUsage?.value) return 0;
return parseFloat(cpuUsage.value.replace('%', ''));
});
const memPercent = computed(() => {
if (!memUsage?.value) return 0;
return parseFloat(memUsage.value.replace('%', ''));
});
const diskPercent = computed(() => {
if (!diskUsage?.value) return 0;
return parseFloat(diskUsage.value.replace('%', ''));
});
const formattedRunningTime = computed(() => {
return (runningTime?.value ?? '-').replace('days', '天');
});
const getProgressStatus = (percent: number): ProgressStatus => {
if (percent >= 90) return 'error';
if (percent >= 70) return 'warning';
return 'success';
};
</script>
<template>
<NCard v-if="showCard" hoverable size="small">
<template #header>
<span>硬件占用率</span>
</template>
<template #default>
<NFlex vertical>
<NFlex v-if="cpuUsage" style="width: 100%" align="center" :wrap="false">
<NIcon :component="FireOutlined" />
<span style="word-break: keep-all">CPU</span>
<NProgress :percentage="cpuPercent" :status="getProgressStatus(cpuPercent)">{{ cpuPercent }}%</NProgress>
</NFlex>
<NFlex v-if="memUsage" style="width: 100%" align="center" :wrap="false">
<NIcon :component="CodeOutlined" />
<span style="word-break: keep-all">内存</span>
<NProgress :percentage="memPercent" :status="getProgressStatus(memPercent)">{{ memPercent }}%</NProgress>
</NFlex>
<NFlex v-if="diskUsage" style="width: 100%" align="center" :wrap="false">
<NIcon :component="SaveOutlined" />
<span style="word-break: keep-all">磁盘</span>
<NProgress :percentage="diskPercent" :status="getProgressStatus(diskPercent)">{{ diskPercent }}%</NProgress>
</NFlex>
<NFlex v-if="runningTime" style="width: 100%" align="center" :wrap="false">
<NIcon :component="ClockCircleOutlined" />
<span>系统运行时间</span>
<span>{{ formattedRunningTime }}</span>
</NFlex>
</NFlex>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
import { detailDeviceApi, probeDeviceApi, type NdmDeviceResultVO, type Station } from '@/apis';
import { DEVICE_TYPE_NAMES, tryGetDeviceType } from '@/enums';
import { useDeviceStore } from '@/stores';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { ApiOutlined, ReloadOutlined } from '@vicons/antd';
import { isCancel } from 'axios';
import { NButton, NCard, NFlex, NIcon, NTag, NTooltip } from 'naive-ui';
import { computed, onBeforeUnmount, ref, toRefs } from 'vue';
const props = defineProps<{
ndmDevice: NdmDeviceResultVO;
station: Station;
}>();
const { ndmDevice, station } = toRefs(props);
const type = computed(() => {
const deviceType = tryGetDeviceType(ndmDevice.value.deviceType);
if (!deviceType) return '-';
return DEVICE_TYPE_NAMES[deviceType];
});
const name = computed(() => ndmDevice.value.name ?? '-');
const status = computed(() => ndmDevice.value.deviceStatus);
const ipAddr = computed(() => ndmDevice.value.ipAddress ?? '-');
const gbCode = computed(() => Reflect.get(ndmDevice.value, 'gbCode') as string | undefined);
const canOpenMgmtPage = computed(() => {
return Object.keys(ndmDevice.value).includes('manageUrl');
});
const canProbe = computed(() => !!ndmDevice.value.snmpEnabled);
const onClickOpenMgmtPage = () => {
if (canOpenMgmtPage.value) {
let target = '';
const manageUrl = ndmDevice.value.manageUrl;
const ipAddress = ndmDevice.value.ipAddress;
if (manageUrl) {
target = manageUrl;
} else if (ipAddress) {
target = `http://${ipAddress}`;
}
if (target) {
window.open(target, '_blank');
return;
}
window.$message.warning('未找到设备IP地址');
}
};
const abortController = ref<AbortController>(new AbortController());
const { mutate: probeDevice, isPending: probing } = useMutation({
mutationFn: async () => {
abortController.value.abort();
abortController.value = new AbortController();
await probeDeviceApi(ndmDevice.value, { stationCode: station.value.code, signal: abortController.value.signal });
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
const { mutate: detailDevice, isPending: loading } = useMutation({
mutationFn: async () => {
abortController.value.abort();
abortController.value = new AbortController();
return await detailDeviceApi(ndmDevice.value, { stationCode: station.value.code, signal: abortController.value.signal });
},
onSuccess: (device) => {
if (device) {
useDeviceStore().patchDevice(station.value.code, device);
}
},
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">
<NTag v-if="status === '10'" size="small" type="success">在线</NTag>
<NTag v-else-if="status === '20'" size="small" type="error">离线</NTag>
<NTag v-else size="small" type="warning">-</NTag>
<div>{{ name }}</div>
<NButton v-if="canOpenMgmtPage" ghost size="tiny" type="default" :focusable="false" @click="onClickOpenMgmtPage">管理</NButton>
</NFlex>
</template>
<template #header-extra>
<NTooltip v-if="canProbe" trigger="hover">
<template #trigger>
<NButton size="small" quaternary circle :loading="probing" @click="() => probeDevice()">
<template #icon>
<NIcon :component="ApiOutlined" />
</template>
</NButton>
</template>
<template #default>
<span>请求最新诊断</span>
</template>
</NTooltip>
<NTooltip trigger="hover">
<template #trigger>
<NButton size="small" quaternary circle :loading="loading" @click="() => detailDevice()">
<template #icon>
<NIcon :component="ReloadOutlined" />
</template>
</NButton>
</template>
<template #default>
<span>刷新设备</span>
</template>
</NTooltip>
</template>
<template #default>
<div style="font-size: small; color: #666">
<div>
<span>设备类型</span>
<span>{{ type }}</span>
</div>
<div>
<span>IP地址</span>
<span>{{ ipAddr }}</span>
</div>
<div v-if="gbCode">
<span>国标编码</span>
<span>{{ gbCode }}</span>
</div>
<div v-if="!!ndmDevice.description">
<span>设备描述</span>
<span>{{ ndmDevice.description }}</span>
</div>
<div v-if="ndmDevice.snmpEnabled">
<span>上次诊断时间</span>
<span>{{ ndmDevice.lastDiagTime ?? '-' }}</span>
</div>
</div>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,10 @@
import DeviceCommonCard from './device-common-card.vue';
import DeviceHardwareCard from './device-hardware-card.vue';
import DeviceHeaderCard from './device-header-card.vue';
import NvrDiskCard from './nvr-disk-card.vue';
import NvrRecordCard from './nvr-record-card.vue';
import SecurityBoxCircuitCard from './security-box-circuit-card.vue';
import SecurityBoxEnvCard from './security-box-env-card.vue';
import SwitchPortCard from './switch-port-card.vue';
export { DeviceCommonCard, DeviceHardwareCard, DeviceHeaderCard, NvrDiskCard, NvrRecordCard, SecurityBoxCircuitCard, SecurityBoxEnvCard, SwitchPortCard };

View File

@@ -0,0 +1,168 @@
<script setup lang="ts">
import type { NdmNvrDiagInfo } from '@/apis';
import { NCard, NDescriptions, NDescriptionsItem, NFlex, NPopover, NProgress, NTag, useThemeVars, type ProgressProps, type TagProps } from 'naive-ui';
import { computed, toRefs } from 'vue';
const props = defineProps<{
diskHealth: NonNullable<NdmNvrDiagInfo['info']>['diskHealth'];
diskArray: NonNullable<NdmNvrDiagInfo['info']>['groupInfoList'];
}>();
const themeVars = useThemeVars();
const { diskHealth, diskArray } = toRefs(props);
const hasDiskHealth = computed(() => !!diskHealth.value && diskHealth.value.length > 0);
const hasDiskArray = computed(() => !!diskArray.value && diskArray.value.length > 0);
const showCard = computed(() => hasDiskHealth.value || hasDiskArray.value);
const healthList = computed(() => diskHealth.value ?? []);
const arrayList = computed(() => diskArray.value ?? []);
const getDiskHealthClassName = (health: number) => {
if (health === 0) return 'disk-healthy';
if (health === 1) return 'disk-warning';
return 'disk-error';
};
const getDiskHealthText = (health: number) => {
if (health === 0) return '正常';
if (health === 1) return '警告';
return '故障';
};
const getArrayTagType = (state?: number): TagProps['type'] => {
if (state === 1) return 'success';
return 'error';
};
const formatArraySize = (megabytes: number): string => {
const units = ['MB', 'GB', 'TB'];
let index = 0;
while (megabytes >= 1024 && index < units.length - 1) {
megabytes /= 1024;
index++;
}
return `${megabytes.toFixed(2)} ${units[index]}`;
};
const getArrayUsagePercentage = (freeSize: number, totalSize: number) => {
if (totalSize === 0) return 0;
return Math.round(((totalSize - freeSize) / totalSize) * 100);
};
const getArrayProgressStatus = (percentage: number): ProgressProps['status'] => {
if (percentage >= 90) return 'error';
if (percentage >= 70) return 'warning';
return 'success';
};
</script>
<template>
<NCard v-if="showCard" hoverable size="small">
<template #header>
<span>磁盘状态</span>
</template>
<template #default>
<NFlex vertical>
<NCard v-if="hasDiskHealth" size="small">
<template #header>
<div style="font-size: small">磁盘健康状态{{ healthList.length }}个硬盘槽位</div>
</template>
<template #default>
<div style="display: grid; grid-template-rows: repeat(3, auto)" :style="{ 'grid-template-columns': `repeat(${Math.max(healthList.length / 3, 12)}, 1fr)` }">
<template v-for="(health, index) in healthList" :key="index">
<NPopover :delay="300">
<template #trigger>
<!-- 最外层div宽度100% -->
<div class="disk-health" style="height: 40px; box-sizing: border-box; display: flex; cursor: pointer" :class="getDiskHealthClassName(health)">
<!-- 将磁盘号和状态指示器包裹起来 用于居中布局 -->
<div style="margin: auto; display: flex; flex-direction: column; align-items: center">
<div style="font-size: xx-small">{{ index + 1 }}</div>
<div class="indicator" style="width: 8px; height: 8px; border-radius: 50%" :class="getDiskHealthClassName(health)"></div>
</div>
</div>
</template>
<template #default>
<NDescriptions bordered size="small" label-placement="left" :column="1">
<NDescriptionsItem label="硬盘槽位">{{ index + 1 }}</NDescriptionsItem>
<NDescriptionsItem label="状态">{{ getDiskHealthText(health) }}</NDescriptionsItem>
</NDescriptions>
</template>
</NPopover>
</template>
</div>
</template>
</NCard>
<NCard v-if="hasDiskArray" size="small">
<template #header>
<div style="font-size: small">磁盘阵列状态{{ arrayList.length }}个阵列</div>
</template>
<template #default>
<template v-for="({ state, stateValue, freeSize, totalSize }, index) in arrayList" :key="index">
<NFlex vertical>
<NFlex align="center">
<div>磁盘阵列{{ index + 1 }}</div>
<NTag round size="small" :bordered="false" :type="getArrayTagType(state)">{{ stateValue }}</NTag>
</NFlex>
<NFlex align="center" :wrap="false">
<div style="word-break: keep-all">存储使用率</div>
<NProgress :percentage="getArrayUsagePercentage(freeSize ?? 0, totalSize ?? 0)" :status="getArrayProgressStatus(getArrayUsagePercentage(freeSize ?? 0, totalSize ?? 0))">
{{ getArrayUsagePercentage(freeSize ?? 0, totalSize ?? 0) }}%
</NProgress>
</NFlex>
<NFlex>
<span>总容量{{ formatArraySize(totalSize ?? 0) }}</span>
<span>已用{{ formatArraySize((totalSize ?? 0) - (freeSize ?? 0)) }}</span>
<span>可用{{ formatArraySize(freeSize ?? 0) }}</span>
</NFlex>
</NFlex>
</template>
</template>
</NCard>
</NFlex>
</template>
</NCard>
</template>
<style scoped lang="scss">
.disk-health {
&.disk-healthy {
&:hover {
background-color: #18a05816;
}
}
&.disk-warning {
&:hover {
background-color: #d0305016;
}
}
&.disk-error {
&:hover {
background-color: #f0a02016;
}
}
&:hover {
transform: translateY(-2px);
transition: transform 0.2s ease-in-out;
box-shadow: v-bind('themeVars.boxShadow1');
}
.indicator {
&.disk-healthy {
background-color: #18a058;
}
&.disk-warning {
background-color: #d03050;
}
&.disk-error {
background-color: #f0a020;
}
}
}
</style>

View File

@@ -0,0 +1,246 @@
<script setup lang="ts">
import { getChannelListApi, getRecordCheckApi, reloadAllRecordCheckApi, reloadRecordCheckApi, type NdmNvrResultVO, type NdmRecordCheck, type RecordItem, type Station } from '@/apis';
import { exportRecordDiagCsv, transformRecordChecks } from '@/helpers';
import { useStationStore } from '@/stores';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { DownloadOutlined, ReloadOutlined } from '@vicons/antd';
import { isCancel } from 'axios';
import dayjs from 'dayjs';
import { NButton, NCard, NFlex, NIcon, NPagination, NPopconfirm, NPopover, NRadioButton, NRadioGroup, NTooltip, useThemeVars } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, onBeforeUnmount, onMounted, ref, toRefs, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmNvrResultVO;
station: Station;
}>();
const themeVars = useThemeVars();
const stationStore = useStationStore();
const { stations } = storeToRefs(stationStore);
const { ndmDevice, station } = toRefs(props);
const recordChecks = ref<NdmRecordCheck[]>([]);
const lossInput = ref<number>(0);
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 abortController = ref<AbortController>(new AbortController());
const { mutate: getRecordCheckByParentId, isPending: loading } = useMutation({
mutationFn: async () => {
abortController.value.abort();
abortController.value = new AbortController();
const checks = await getRecordCheckApi(ndmDevice.value, 90, [], { stationCode: station.value.code, signal: abortController.value.signal });
return checks;
},
onSuccess: (checks) => {
recordChecks.value = checks;
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
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 = () => {
const code = station.value.code;
const stationName = stations.value.find((station) => station.code === code)?.name ?? '';
exportRecordDiagCsv(recordDiags.value, stationName);
};
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: () => {
getRecordCheckByParentId();
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
onMounted(() => {
getRecordCheckByParentId();
});
watch(
() => ndmDevice.value.id,
(devieDbId) => {
if (devieDbId) {
getRecordCheckByParentId();
}
},
);
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="() => getRecordCheckByParentId()">
<template #icon>
<NIcon :component="ReloadOutlined" />
</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="DownloadOutlined" />
</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>

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
import { probeDeviceApi, rebootSecurityBoxApi, turnCitcuitStatusApi, type NdmSecurityBoxCircuit, type NdmSecurityBoxResultVO, type Station } from '@/apis';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { PoweroffOutlined } from '@vicons/antd';
import { watchImmediate } from '@vueuse/core';
import { NButton, NCard, NDescriptions, NDescriptionsItem, NFlex, NIcon, NPopconfirm, NPopover, NSwitch, NTag, useThemeVars, type TagProps } from 'naive-ui';
import { computed, ref, toRefs } from 'vue';
const props = defineProps<{
circuits?: NdmSecurityBoxCircuit[];
ndmDevice: NdmSecurityBoxResultVO;
station: Station;
}>();
const themeVars = useThemeVars();
const { circuits, ndmDevice, station } = toRefs(props);
const showCard = computed(() => !!circuits.value && circuits.value.length > 0);
const boxCircuits = ref<NdmSecurityBoxCircuit[]>([]);
watchImmediate(circuits, (newCircuits) => {
boxCircuits.value = newCircuits?.map((circuit) => ({ ...circuit })) ?? [];
});
const getCircuitStatusTagType = (circuit: NdmSecurityBoxCircuit): TagProps['type'] => {
const { status } = circuit;
return status === 0 ? 'error' : status === 1 ? 'success' : 'warning';
};
const getCircuitStatusText = (circuit: NdmSecurityBoxCircuit) => {
const { status } = circuit;
return status === 0 ? '关闭' : status === 1 ? '开启' : '-';
};
const getCircuitStatusClassName = (circuit: NdmSecurityBoxCircuit) => {
const { status } = circuit;
return status === 0 ? 'circuit-off' : status === 1 ? 'circuit-on' : 'circuit-unknown';
};
const { mutate: turnStatus, isPending: turning } = useMutation({
mutationFn: async (params: { circuitIndex: number; newStatus: boolean }) => {
const { circuitIndex, newStatus } = params;
if (!ndmDevice.value.ipAddress) {
throw new Error('设备IP地址不存在');
}
const status = newStatus ? 1 : 0;
await turnCitcuitStatusApi(ndmDevice.value.ipAddress, circuitIndex, status, { stationCode: station.value.code });
await probeDeviceApi(ndmDevice.value, { stationCode: station.value.code });
return status;
},
onSuccess: (status, { circuitIndex }) => {
const circuit = boxCircuits.value.at(circuitIndex);
if (circuit) circuit.status = status;
},
onError: (error) => {
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
const { mutate: reboot, isPending: rebooting } = useMutation({
mutationFn: async () => {
if (!ndmDevice.value.ipAddress) {
throw new Error('设备IP地址不存在');
}
await rebootSecurityBoxApi(ndmDevice.value.ipAddress, { stationCode: station.value.code });
},
onSuccess: () => {
window.$message.success('设备重启成功');
},
onError: (error) => {
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
</script>
<template>
<NCard v-if="showCard" hoverable size="small">
<template #header>
<NFlex align="center">
<span>电路状态</span>
<NPopconfirm :positive-text="'确认'" :negative-text="'取消'" @positive-click="() => reboot()">
<template #trigger>
<NButton secondary size="small" :loading="rebooting">重合闸</NButton>
</template>
<span>确定要执行重合闸操作吗?</span>
</NPopconfirm>
</NFlex>
</template>
<template #default>
<div style="display: grid" :style="{ 'grid-template-columns': `repeat(${Math.min(boxCircuits.length, 4)}, 1fr)` }">
<template v-for="(circuit, index) in boxCircuits" :key="index">
<NPopover :delay="300">
<template #trigger>
<NFlex justify="center" align="center" :size="0">
<NFlex vertical class="pointer-cursor circuit" style="padding: 12px" :class="getCircuitStatusClassName(circuit)">
<NFlex align="center">
<NTag class="pointer-cursor" size="small" :type="getCircuitStatusTagType(circuit)">
<template #icon>
<NIcon :component="PoweroffOutlined" />
</template>
<template #default>
<span>{{ getCircuitStatusText(circuit) }}</span>
</template>
</NTag>
<span>电路{{ index + 1 }}</span>
</NFlex>
<NFlex justify="end" align="center">
<NPopconfirm :positive-text="'确认'" :negative-text="'取消'" @positive-click="() => turnStatus({ circuitIndex: index, newStatus: circuit.status !== 1 })">
<template #trigger>
<NSwitch size="small" :value="circuit.status === 1" :loading="turning" />
</template>
<template #default>
<span>确定要{{ circuit.status === 1 ? '关闭' : '开启' }}电路{{ index + 1 }}吗?</span>
</template>
</NPopconfirm>
</NFlex>
</NFlex>
</NFlex>
</template>
<template #default>
<NDescriptions bordered size="small" label-placement="left" :column="1">
<NDescriptionsItem label="电压">{{ circuit.voltage }}V</NDescriptionsItem>
<NDescriptionsItem label="电流">{{ circuit.current }}A</NDescriptionsItem>
</NDescriptions>
</template>
</NPopover>
</template>
</div>
</template>
</NCard>
</template>
<style scoped lang="scss">
.pointer-cursor {
cursor: pointer;
}
.circuit {
&.circuit-on {
&:hover {
background-color: #18a05816;
}
}
&.circuit-off {
&:hover {
background-color: #d0305016;
}
}
&.circuit-unknown {
&:hover {
background-color: #f0a02016;
}
}
&:hover {
transform: translateY(-2px);
transition: transform 0.2s ease-in-out;
box-shadow: v-bind('themeVars.boxShadow1');
}
}
</style>

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import { ThunderboltOutlined } from '@vicons/antd';
import { ApertureOutline, LockOpenOutline, ThermometerOutline, WaterOutline } from '@vicons/ionicons5';
import { NCard, NFlex, NIcon, NTag } from 'naive-ui';
import { computed, toRefs } from 'vue';
const props = defineProps<{
fanSpeeds?: number[];
temperature?: number;
humidity?: number;
switches?: number[];
}>();
const { fanSpeeds, temperature, humidity, switches } = toRefs(props);
const showCard = computed(() => {
return Object.values(props).some((value) => !!value);
});
// 门禁状态
const accessControlStatus = computed(() => {
if (!switches?.value || switches.value.length < 1) return null;
const status = switches.value.at(0)!;
return status === 0 ? '开门' : status === 1 ? '关门' : '-';
});
// 防雷状态
const lightningProtectionStatus = computed(() => {
if (!switches?.value || switches.value.length < 2) return null;
const status = switches.value.at(1)!;
return status === 0 ? '正常' : status === 1 ? '失效' : '-';
});
const getStatusTagType = (status: string | null) => {
if (['正常'].includes(status ?? '')) return 'success';
if (['失效'].includes(status ?? '')) return 'error';
return 'default';
};
const formattedFanSpeeds = computed(() => {
if (!fanSpeeds?.value || fanSpeeds.value.length === 0) return null;
return fanSpeeds.value.map((speed, index) => `风扇${index + 1}: ${speed} RPM`).join(', ');
});
</script>
<template>
<NCard v-if="showCard" hoverable size="small">
<template #header>
<span>安防箱状态</span>
</template>
<template #default>
<NFlex vertical>
<NTag>
<template #icon>
<NIcon :component="ThermometerOutline" />
</template>
<template #default>
<span>温度: {{ temperature }}</span>
</template>
</NTag>
<NTag>
<template #icon>
<NIcon :component="WaterOutline" />
</template>
<template #default>
<span>湿度: {{ humidity }}%</span>
</template>
</NTag>
<NTag>
<template #icon>
<NIcon :component="ApertureOutline" />
</template>
<template #default>
<span>风扇: {{ formattedFanSpeeds }}</span>
</template>
</NTag>
<NTag :type="getStatusTagType(accessControlStatus)">
<template #icon>
<NIcon :component="LockOpenOutline" />
</template>
<template #default>
<span>门禁: {{ accessControlStatus }}</span>
</template>
</NTag>
<NTag :type="getStatusTagType(lightningProtectionStatus)">
<template #icon>
<NIcon :component="ThunderboltOutlined" />
</template>
<template #default>
<span>防雷: {{ lightningProtectionStatus }}</span>
</template>
</NTag>
</NFlex>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,146 @@
<script setup lang="ts">
import type { NdmSwitchPortInfo } from '@/apis';
import { getPortStatusValue, transformPortSpeed } from '@/helpers';
import { NCard, NDescriptions, NDescriptionsItem, NPopover, useThemeVars } from 'naive-ui';
import { computed, toRefs } from 'vue';
const props = defineProps<{
ports?: NdmSwitchPortInfo[];
}>();
const themeVars = useThemeVars();
const { ports } = toRefs(props);
const showCard = computed(() => !!ports.value);
const switchSlots = computed(() => {
// 解析端口名称,将端口按槽位进行分组
const groupMap = new Map<string, NdmSwitchPortInfo[]>();
ports.value?.forEach((port) => {
const parts = port.portName.split('/');
if (parts.length >= 3) {
const slotName = `${parts[0]}/${parts[1]}`;
if (!groupMap.has(slotName)) {
groupMap.set(slotName, []);
}
groupMap.get(slotName)!.push(port);
}
});
// 将Map转换为entries
const entries = Array.from(groupMap.entries());
// 按槽位进行排序
const sorted = entries.sort(([aSlotName], [bSlotName]) => {
const [mainA = 0, subA = 0] = aSlotName.split('/').map((value) => parseInt(value));
const [mainB = 0, subB = 0] = bSlotName.split('/').map((value) => parseInt(value));
return mainA - mainB || subA - subB;
});
// 按端口号进行排序
return sorted.map(([slotName, ports]) => ({
slotName,
ports: ports.sort((portA, portB) => {
const portNumA = parseInt(portA.portName.split('/').at(2) ?? '0');
const portNumB = parseInt(portB.portName.split('/').at(2) ?? '0');
return portNumA - portNumB;
}),
}));
});
const getPortClassName = (port: NdmSwitchPortInfo) => {
if (port.upDown === 1) {
return 'port-up';
}
if (port.upDown === 2) {
return 'port-down';
}
return 'port-unknown';
};
</script>
<template>
<NCard v-if="showCard" hoverable size="small">
<template #header>
<span>端口数据</span>
</template>
<template #default>
<!-- 遍历所有槽位 -->
<template v-for="{ slotName, ports } in switchSlots" :key="slotName">
<div style="padding: 8px 0">
<span>{{ slotName }}{{ ports.length }}个端口</span>
<!-- 端口布局 2 至少12列 纵向排序 -->
<div style="display: grid; grid-template-rows: repeat(2, auto); grid-auto-flow: column" :style="{ 'grid-template-columns': `repeat(${Math.max(ports.length / 2, 12)}, 1fr)` }">
<template v-for="(port, index) in ports" :key="port.portName">
<!-- 端口 -->
<NPopover :delay="300">
<template #trigger>
<!-- 最外层div宽度100% -->
<div class="port" style="height: 40px; box-sizing: border-box; display: flex; cursor: pointer" :class="getPortClassName(port)">
<!-- 将端口号和状态指示器包裹起来 用于居中布局 -->
<div style="margin: auto; display: flex; flex-direction: column; align-items: center">
<div style="font-size: xx-small">{{ index }}</div>
<div class="indicator" style="width: 8px; height: 8px; border-radius: 50%" :class="getPortClassName(port)"></div>
</div>
</div>
</template>
<template #default>
<NDescriptions bordered size="small" label-placement="left" :column="1">
<NDescriptionsItem label="端口名称">{{ port.portName }}</NDescriptionsItem>
<NDescriptionsItem label="状态">{{ getPortStatusValue(port) }}</NDescriptionsItem>
<NDescriptionsItem label="上行速率">{{ transformPortSpeed(port, 'in') }}</NDescriptionsItem>
<NDescriptionsItem label="下行速率">{{ transformPortSpeed(port, 'out') }}</NDescriptionsItem>
<NDescriptionsItem label="总速率">{{ transformPortSpeed(port, 'total') }}</NDescriptionsItem>
</NDescriptions>
</template>
</NPopover>
</template>
</div>
</div>
</template>
</template>
</NCard>
</template>
<style scoped lang="scss">
.port {
&.port-up {
&:hover {
background-color: #18a05816;
}
}
&.port-down {
&:hover {
background-color: #d0305016;
}
}
&.port-unknown {
&:hover {
background-color: #f0a02016;
}
}
&:hover {
transform: translateY(-2px);
transition: transform 0.2s ease-in-out;
box-shadow: v-bind('themeVars.boxShadow1');
}
.indicator {
&.port-up {
background-color: #18a058;
}
&.port-down {
background-color: #d03050;
}
&.port-unknown {
background-color: #f0a020;
}
}
}
</style>

View File

@@ -0,0 +1,152 @@
<script setup lang="ts">
import { pageDeviceAlarmLogApi, type NdmDeviceAlarmLogResultVO, type NdmDeviceResultVO, type PageParams, type Station } from '@/apis';
import { renderAlarmDateCell, renderAlarmTypeCell, renderFaultDescriptionCell, renderFaultLevelCell } from '@/helpers';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import { NCard, NDataTable, type DataTableColumns, type DataTableRowData, type PaginationProps } from 'naive-ui';
import { h, onBeforeUnmount, reactive, ref, toRefs, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmDeviceResultVO;
station: Station;
}>();
const range = defineModel<[number, number]>('range');
const loading = defineModel<boolean>('loading', { default: false });
const emit = defineEmits<{
exposeQueryFn: [queryFn: () => void];
}>();
const { ndmDevice, station } = toRefs(props);
const tableColumns: DataTableColumns<NdmDeviceAlarmLogResultVO> = [
{ title: '告警流水号', key: 'alarmNo' },
{
title: '告警时间',
key: 'alarmDate',
render: renderAlarmDateCell,
},
{
title: '告警类型',
key: 'alarmType',
align: 'center',
render: renderAlarmTypeCell,
},
{
title: '故障级别',
key: 'faultLevel',
align: 'center',
render: renderFaultLevelCell,
},
// { title: '故障编码', key: 'faultCode', align: 'center' },
{
title: '故障描述',
key: 'faultDescription',
render: (rowData) => renderFaultDescriptionCell(rowData, ndmDevice.value),
},
{
title: '是否恢复',
key: 'alarmCategory',
align: 'center',
render: (rowData) => {
return rowData.alarmCategory === '2' ? '是' : '否';
},
},
{ title: '恢复时间', key: 'updatedTime' },
];
const tableData = ref<DataTableRowData[]>([]);
const DEFAULT_PAGE_SIZE = 10;
const pagination = reactive<PaginationProps>({
size: 'small',
showSizePicker: true,
page: 1,
pageSize: DEFAULT_PAGE_SIZE,
pageSizes: [5, 10, 20, 50, 80, 100],
itemCount: 0,
prefix: ({ itemCount }) => {
return h('div', {}, { default: () => `${itemCount}` });
},
onUpdatePage: (page: number) => {
pagination.page = page;
getTableData();
},
onUpdatePageSize: (pageSize: number) => {
pagination.pageSize = pageSize;
pagination.page = 1;
getTableData();
},
});
const abortController = ref<AbortController>(new AbortController());
const { mutate: getTableData, isPending } = useMutation({
mutationFn: async () => {
if (!range.value) throw new Error('请选择时间范围');
const deviceId = ndmDevice.value.deviceId;
if (!deviceId) throw new Error('该设备未配置设备ID无法查询告警记录');
abortController.value?.abort();
abortController.value = new AbortController();
const alarmDate_ge = range.value[0];
const alarmDate_le = range.value[1];
const restParams: Omit<PageParams<{ id: string }>, 'model' | 'extra'> = {
current: pagination.page ?? 1,
size: pagination.pageSize ?? DEFAULT_PAGE_SIZE,
sort: 'id',
order: 'descending',
};
const pageResult = await pageDeviceAlarmLogApi(
{
model: { deviceId },
extra: { alarmDate_ge, alarmDate_le },
...restParams,
},
{
stationCode: station.value.code,
signal: abortController.value.signal,
},
);
return pageResult;
},
onSuccess: ({ records, size, total }) => {
pagination.pageSize = parseInt(size);
pagination.itemCount = parseInt(total);
tableData.value = records;
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
emit('exposeQueryFn', () => {
pagination.page = 1;
pagination.pageSize = DEFAULT_PAGE_SIZE;
getTableData();
});
watch(isPending, (pending) => {
loading.value = pending;
});
onBeforeUnmount(() => {
abortController.value.abort();
});
</script>
<template>
<NCard hoverable size="small">
<template #header>
<span>设备告警记录</span>
</template>
<template #default>
<NDataTable size="small" :loading="isPending" :columns="tableColumns" :data="tableData" :pagination="pagination" :single-line="false" remote flex-height style="height: 500px" />
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import { pageIcmpLogApi, type NdmDeviceResultVO, type NdmIcmpLogResultVO, type PageParams, type PageResult, type Station } from '@/apis';
import { formatDuration, parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import dayjs from 'dayjs';
import { NCard, NFlex, NPagination, NScrollbar, NSpin, NTimeline, NTimelineItem, type PaginationProps, type TimelineItemProps } from 'naive-ui';
import { computed, h, onBeforeUnmount, reactive, ref, toRefs, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmDeviceResultVO;
station: Station;
}>();
const range = defineModel<[number, number]>('range');
const loading = defineModel<boolean>('loading', { default: false });
const emit = defineEmits<{
exposeQueryFn: [queryFn: () => void];
}>();
const { ndmDevice, station } = toRefs(props);
const icmpLogs = ref<NdmIcmpLogResultVO[]>([]);
const lastItem = ref<NdmIcmpLogResultVO>();
const DEFAULT_PAGE_SIZE = 6;
const pagination = reactive<PaginationProps>({
size: 'small',
showSizePicker: true,
page: 1,
pageSize: DEFAULT_PAGE_SIZE,
pageSizes: [6, 10, 20, 50, 80, 100],
itemCount: 0,
prefix: ({ itemCount }) => {
return h('div', {}, { default: () => `${itemCount}` });
},
onUpdatePage: (page: number) => {
pagination.page = page;
getIcmpLogs();
},
onUpdatePageSize: (pageSize: number) => {
pagination.pageSize = pageSize;
pagination.page = 1;
getIcmpLogs();
},
});
const abortController = ref<AbortController>(new AbortController());
const { mutate: getIcmpLogs, isPending } = useMutation({
mutationFn: async () => {
if (!range.value) throw new Error('请选择时间范围');
abortController.value?.abort();
abortController.value = new AbortController();
const deviceId = ndmDevice.value.id;
if (!deviceId) throw new Error('该设备没有ID');
const createdTime_precisest = dayjs(range.value[0]).format('YYYY-MM-DD HH:mm:ss');
const createdTime_preciseed = dayjs(range.value[1]).format('YYYY-MM-DD HH:mm:ss');
const restParams: Omit<PageParams<{ id: string }>, 'model' | 'extra'> = {
current: pagination.page ?? 1,
size: pagination.pageSize ?? DEFAULT_PAGE_SIZE,
sort: 'id',
order: 'descending',
};
const currPageResult = await pageIcmpLogApi(
{
model: { deviceId: deviceId },
extra: { createdTime_precisest, createdTime_preciseed },
...restParams,
},
{
stationCode: station.value.code,
signal: abortController.value.signal,
},
);
let lastPageResult: PageResult<NdmIcmpLogResultVO> | undefined = undefined;
if ((pagination.page ?? 1) > 1) {
lastPageResult = await pageIcmpLogApi(
{
model: { deviceId: deviceId },
extra: { createdTime_precisest, createdTime_preciseed },
...restParams,
current: (pagination.page ?? 1) - 1,
},
{
stationCode: station.value.code,
signal: abortController.value.signal,
},
);
}
return { currPageResult, lastPageResult };
},
onSuccess: ({ currPageResult: { records, size, total }, lastPageResult }) => {
pagination.pageSize = parseInt(size);
pagination.itemCount = parseInt(total);
icmpLogs.value = records;
lastItem.value = lastPageResult?.records.at(-1);
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
emit('exposeQueryFn', () => {
pagination.page = 1;
pagination.pageSize = DEFAULT_PAGE_SIZE;
getIcmpLogs();
});
watch(isPending, (pending) => {
loading.value = pending;
});
const timelineItems = computed<TimelineItemProps[]>(() => {
const items: TimelineItemProps[] = [];
if (icmpLogs.value.length === 0 && !loading.value) {
const { deviceStatus } = ndmDevice.value;
const type: TimelineItemProps['type'] = deviceStatus === '10' ? 'success' : deviceStatus === '20' ? 'error' : 'warning';
const title = deviceStatus === '10' ? '在线' : deviceStatus === '20' ? '离线' : '未知';
const content = '至今' + (deviceStatus === '10' ? '在线' : deviceStatus === '20' ? '离线' : '未知');
items.push({ type, title, content });
return items;
}
let lastIcmpLog = lastItem.value;
for (const icmpLog of icmpLogs.value) {
const { deviceStatus, createdTime } = icmpLog;
if (!createdTime) continue;
const type: TimelineItemProps['type'] = deviceStatus === '10' ? 'success' : deviceStatus === '20' ? 'error' : 'warning';
const title = deviceStatus === '10' ? '在线' : deviceStatus === '20' ? '离线' : '未知';
const content = `持续时长:${lastIcmpLog ? formatDuration(createdTime, lastIcmpLog.createdTime) : '至今'}`;
const time = createdTime;
items.push({ type, title, content, time });
lastIcmpLog = icmpLog;
}
return items;
});
onBeforeUnmount(() => {
abortController.value.abort();
});
</script>
<template>
<NCard hoverable size="small" :content-style="{ 'max-height': '500px' }">
<template #header>
<span>设备状态记录</span>
</template>
<template #default>
<NSpin v-if="loading" style="width: 100%; height: 100%; display: flex"></NSpin>
<div v-else-if="timelineItems.length === 0" style="width: 100%; display: flex; color: #666">
<div style="margin: auto">暂无记录</div>
</div>
<NTimeline v-else-if="timelineItems.length <= DEFAULT_PAGE_SIZE">
<NTimelineItem v-for="{ type, title, content, time } in timelineItems" :key="time" :type="type" :title="title" :content="content" :time="time"></NTimelineItem>
</NTimeline>
<NScrollbar v-else x-scrollable>
<NTimeline>
<NTimelineItem v-for="{ type, title, content, time } in timelineItems" :key="time" :type="type" :title="title" :content="content" :time="time"></NTimelineItem>
</NTimeline>
</NScrollbar>
</template>
<template #footer>
<NFlex justify="end">
<NPagination
v-model:page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="pagination.pageSizes"
:item-count="pagination.itemCount"
size="small"
show-size-picker
@update-page="pagination.onUpdatePage"
@update:page-size="pagination.onUpdatePageSize"
/>
</NFlex>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,153 @@
<script lang="ts">
const getValueByFieldPath = (record: Record<string, any>, fieldPath?: string) => {
if (!fieldPath) return undefined;
return fieldPath.split('.').reduce((acc, cur) => acc?.[cur], record);
};
</script>
<script setup lang="ts">
import { pageSnmpLogApi, type NdmDeviceResultVO, type NdmSnmpLogResultVO, type PageParams, type Station } from '@/apis';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import dayjs from 'dayjs';
import destr from 'destr';
import { NCard, NDataTable, type DataTableColumns, type DataTableRowData, type PaginationProps } from 'naive-ui';
import { computed, h, onBeforeUnmount, reactive, ref, toRefs, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmDeviceResultVO;
station: Station;
cpuUsageField?: string;
memUsageField?: string;
diskUsageField?: string;
}>();
const range = defineModel<[number, number]>('range');
const loading = defineModel<boolean>('loading', { default: false });
const emit = defineEmits<{
exposeQueryFn: [queryFn: () => void];
}>();
const { ndmDevice, station, cpuUsageField, memUsageField, diskUsageField } = toRefs(props);
const tableColumns = computed(() => {
const columns: DataTableColumns<NdmSnmpLogResultVO> = [{ title: '诊断时间', key: 'createdTime' }];
if (cpuUsageField.value) {
columns.push({ title: 'CPU使用率(%)', key: 'cpuUsage' });
}
if (memUsageField.value) {
columns.push({ title: '内存使用率(%)', key: 'memUsage' });
}
if (diskUsageField.value) {
columns.push({ title: '磁盘使用率(%)', key: 'diskUsage' });
}
return columns;
});
const tableData = ref<DataTableRowData[]>([]);
const DEFAULT_PAGE_SIZE = 10;
const pagination = reactive<PaginationProps>({
size: 'small',
showSizePicker: true,
page: 1,
pageSize: DEFAULT_PAGE_SIZE,
pageSizes: [5, 10, 20, 50, 80, 100],
itemCount: 0,
prefix: ({ itemCount }) => {
return h('div', {}, { default: () => `${itemCount}` });
},
onUpdatePage: (page) => {
pagination.page = page;
getTableData();
},
onUpdatePageSize: (pageSize) => {
pagination.pageSize = pageSize;
pagination.page = 1;
getTableData();
},
});
const abortController = ref<AbortController>(new AbortController());
const { mutate: getTableData, isPending } = useMutation({
mutationFn: async () => {
if (!range.value) throw new Error('请选择时间范围');
abortController.value?.abort();
abortController.value = new AbortController();
const deviceId = ndmDevice.value.id;
if (!deviceId) throw new Error('该设备没有ID');
const createdTime_precisest = dayjs(range.value[0]).format('YYYY-MM-DD HH:mm:ss');
const createdTime_preciseed = dayjs(range.value[1]).format('YYYY-MM-DD HH:mm:ss');
const restParams: Omit<PageParams<{ id: string }>, 'model' | 'extra'> = {
current: pagination.page ?? 1,
size: pagination.pageSize ?? DEFAULT_PAGE_SIZE,
sort: 'id',
order: 'descending',
};
const pageResult = await pageSnmpLogApi(
{
model: { deviceId },
extra: { createdTime_precisest, createdTime_preciseed },
...restParams,
},
{
stationCode: station.value.code,
signal: abortController.value.signal,
},
);
return pageResult;
},
onSuccess: ({ records, size, total }) => {
pagination.pageSize = parseInt(size);
pagination.itemCount = parseInt(total);
tableData.value = records.map((record) => {
const diagInfoJsonString = record.diagInfo;
const diagInfo = destr(diagInfoJsonString);
if (!diagInfo) return {};
if (typeof diagInfo !== 'object') return {};
return {
createdTime: record.createdTime,
cpuUsage: getValueByFieldPath(diagInfo, cpuUsageField.value),
memUsage: getValueByFieldPath(diagInfo, memUsageField.value),
diskUsage: getValueByFieldPath(diagInfo, diskUsageField.value),
diagInfo,
};
});
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
emit('exposeQueryFn', () => {
pagination.page = 1;
pagination.pageSize = DEFAULT_PAGE_SIZE;
getTableData();
});
watch(isPending, (pending) => {
loading.value = pending;
});
onBeforeUnmount(() => {
abortController.value.abort();
});
</script>
<template>
<NCard hoverable size="small">
<template #header>
<span>设备硬件占用率记录</span>
</template>
<template #default>
<NDataTable size="small" :loading="isPending" :columns="tableColumns" :data="tableData" :pagination="pagination" :single-line="false" remote flex-height style="height: 500px" />
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { NButton, NCard, NDatePicker, NFlex, NGrid, NGridItem, NSelect, type SelectOption } from 'naive-ui';
import { toRefs } from 'vue';
const props = defineProps<{
options: SelectOption[];
}>();
const range = defineModel<[number, number]>('range');
const selected = defineModel<string[]>('selected', { default: [] });
const loading = defineModel<boolean>('loading', { default: false });
const emit = defineEmits<{
query: [params?: { range: [number, number]; selected: string[] }];
}>();
const { options } = toRefs(props);
const onQuery = () => {
emit('query');
};
</script>
<template>
<NCard size="small">
<template #default>
<NFlex justify="space-between" :wrap="false">
<NGrid :x-gap="8" :y-gap="8">
<NGridItem span="22">
<NDatePicker type="datetimerange" v-model:value="range" />
</NGridItem>
<NGridItem span="22">
<NSelect multiple v-model:value="selected" :options="options" />
</NGridItem>
</NGrid>
<NButton secondary :loading="loading" @click="onQuery">查询数据</NButton>
</NFlex>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,27 @@
import DeviceAlarmHistoryCard from './device-alarm-history-card.vue';
import DeviceIcmpHistoryCard from './device-icmp-history-card.vue';
import DeviceUsageHistoryCard from './device-usage-history-card.vue';
import HistoryDiagFilterCard from './history-diag-filter-card.vue';
import NvrDiskHistoryCard from './nvr-disk-history-card.vue';
import SecurityBoxRuntimeHistoryCard from './security-box-runtime-history-card.vue';
import SwitchPortHistoryCard from './switch-port-history-card.vue';
import type { ComponentInstance } from 'vue';
export type HistoryDiagFilterCardProps = ComponentInstance<typeof HistoryDiagFilterCard>['$props'];
export type DeviceIcmpHistoryCardProps = ComponentInstance<typeof DeviceIcmpHistoryCard>['$props'];
export type DeviceAlarmHistoryCardProps = ComponentInstance<typeof DeviceAlarmHistoryCard>['$props'];
export type DeviceUsageHistoryCardProps = ComponentInstance<typeof DeviceUsageHistoryCard>['$props'];
export type NvrDiskHistoryCardProps = ComponentInstance<typeof NvrDiskHistoryCard>['$props'];
export type SecurityBoxRuntimeHistoryCardProps = ComponentInstance<typeof SecurityBoxRuntimeHistoryCard>['$props'];
export type SwitchPortHistoryCardProps = ComponentInstance<typeof SwitchPortHistoryCard>['$props'];
export {
//
DeviceAlarmHistoryCard,
DeviceIcmpHistoryCard,
DeviceUsageHistoryCard,
HistoryDiagFilterCard,
NvrDiskHistoryCard,
SecurityBoxRuntimeHistoryCard,
SwitchPortHistoryCard,
};

View File

@@ -0,0 +1,155 @@
<script lang="ts">
type NvrDiskHealthRowData = {
createdTime: string;
diskHealthRatio: string;
diskHealth: number[];
};
</script>
<script setup lang="ts">
import { pageSnmpLogApi, type NdmNvrDiagInfo, type NdmNvrResultVO, type PageParams, type Station } from '@/apis';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import dayjs from 'dayjs';
import destr from 'destr';
import { NCard, NDataTable, type DataTableColumns, type DataTableRowData, type PaginationProps } from 'naive-ui';
import { h, onBeforeUnmount, reactive, ref, toRefs, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmNvrResultVO;
station: Station;
}>();
const range = defineModel<[number, number]>('range');
const loading = defineModel<boolean>('loading', { default: false });
const emit = defineEmits<{
exposeQueryFn: [queryFn: () => void];
}>();
const { ndmDevice, station } = toRefs(props);
const tableColumns: DataTableColumns<NvrDiskHealthRowData> = [
{ title: '诊断时间', key: 'createdTime' },
{ title: '磁盘健康度', key: 'diskHealthRatio' },
{
title: '描述',
key: 'desc',
render(rowData) {
const { diskHealth } = rowData;
const unhealthyDiskIndexes = diskHealth
.map((health, index) => ({ index, health }))
.filter(({ health }) => health !== 0)
.map(({ index }) => index);
if (unhealthyDiskIndexes.length === 0) return '正常';
return `磁盘${unhealthyDiskIndexes.join(', ')}异常`;
},
},
];
const tableData = ref<DataTableRowData[]>([]);
const DEFAULT_PAGE_SIZE = 10;
const pagination = reactive<PaginationProps>({
size: 'small',
showSizePicker: true,
page: 1,
pageSize: DEFAULT_PAGE_SIZE,
pageSizes: [5, 10, 20, 50, 80, 100],
itemCount: 0,
prefix: ({ itemCount }) => {
return h('div', {}, { default: () => `${itemCount}` });
},
onUpdatePage: (page: number) => {
pagination.page = page;
getTableData();
},
onUpdatePageSize: (pageSize: number) => {
pagination.pageSize = pageSize;
pagination.page = 1;
getTableData();
},
});
const abortController = ref<AbortController>(new AbortController());
const { mutate: getTableData, isPending } = useMutation({
mutationFn: async () => {
if (!range.value) throw new Error('请选择时间范围');
const deviceId = ndmDevice.value.id;
if (!deviceId) throw new Error('该设备没有ID');
abortController.value?.abort();
abortController.value = new AbortController();
const createdTime_precisest = dayjs(range.value[0]).format('YYYY-MM-DD HH:mm:ss');
const createdTime_preciseed = dayjs(range.value[1]).format('YYYY-MM-DD HH:mm:ss');
const restParams: Omit<PageParams<{ id: string }>, 'model' | 'extra'> = {
current: pagination.page ?? 1,
size: pagination.pageSize ?? DEFAULT_PAGE_SIZE,
sort: 'id',
order: 'descending',
};
const pageResult = await pageSnmpLogApi(
{
model: { deviceId },
extra: { createdTime_precisest, createdTime_preciseed },
...restParams,
},
{
stationCode: station.value.code,
signal: abortController.value.signal,
},
);
return pageResult;
},
onSuccess: ({ records, size, total }) => {
pagination.pageSize = parseInt(size);
pagination.itemCount = parseInt(total);
tableData.value = records.map((record) => {
const diagInfoJsonString = record.diagInfo;
const diagInfo = destr<NdmNvrDiagInfo>(diagInfoJsonString);
if (!diagInfo) return {};
if (typeof diagInfo !== 'object') return {};
const healthDiskCount = diagInfo.info?.diskHealth?.filter((health) => health === 0).length ?? 0;
const totalDiskCount = diagInfo.info?.diskHealth?.length ?? 0;
return {
createdTime: record.createdTime,
diskHealthRatio: `${healthDiskCount}/${totalDiskCount}`,
diskHealth: diagInfo.info?.diskHealth ?? [],
};
});
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
emit('exposeQueryFn', () => {
pagination.page = 1;
pagination.pageSize = DEFAULT_PAGE_SIZE;
getTableData();
});
watch(isPending, (pending) => {
loading.value = pending;
});
onBeforeUnmount(() => {
abortController.value.abort();
});
</script>
<template>
<NCard hoverable size="small">
<template #header>
<span>磁盘健康度记录</span>
</template>
<template #default>
<NDataTable size="small" :loading="isPending" :columns="tableColumns" :data="tableData" :pagination="pagination" :single-line="false" remote flex-height style="height: 500px" />
</template>
</NCard>
</template>
<style scoped></style>

View File

@@ -0,0 +1,230 @@
<script lang="ts">
type SecurityBoxCircuitRowData = { number: number } & NdmSecurityBoxCircuit;
type SecurityBoxRuntimeRowData = { createdTime: string; diagInfo: NdmSecurityBoxDiagInfo };
</script>
<script setup lang="ts">
import { pageSnmpLogApi, type NdmSecurityBoxCircuit, type NdmSecurityBoxDiagInfo, type NdmSecurityBoxResultVO, type PageParams, type Station } from '@/apis';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import dayjs from 'dayjs';
import destr from 'destr';
import { NButton, NCard, NDataTable, NModal, type DataTableColumns, type DataTableRowData, type PaginationProps } from 'naive-ui';
import { h, onBeforeUnmount, reactive, ref, toRefs, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmSecurityBoxResultVO;
station: Station;
}>();
const range = defineModel<[number, number]>('range');
const loading = defineModel<boolean>('loading', { default: false });
const emit = defineEmits<{
exposeQueryFn: [queryFn: () => void];
}>();
const { ndmDevice, station } = toRefs(props);
const showDetailModal = ref(false);
const detailTableColumns: DataTableColumns<SecurityBoxCircuitRowData> = [
{ title: '电路序号', key: 'number' },
{
title: '状态',
key: 'status',
render(rowData) {
const { status } = rowData;
if (status === 1) {
return '开启';
} else if (status === 0) {
return '关闭';
} else {
return '未知';
}
},
},
{
title: '电流(A)',
key: 'current',
render(rowData) {
return `${rowData.current.toFixed(3)}`;
},
},
{
title: '电压(V)',
key: 'voltage',
render(rowData) {
return `${rowData.voltage.toFixed(3)}`;
},
},
];
const detailTableData = ref<DataTableRowData[]>([]);
const tableColumns: DataTableColumns<SecurityBoxRuntimeRowData> = [
{ title: '诊断时间', key: 'createdTime' },
{
title: '温度(℃)',
key: 'temperature',
render(rowData) {
const { info } = rowData.diagInfo;
const boxInfo = info?.at(0);
if (!boxInfo) return '';
return boxInfo.temperature;
},
},
{
title: '湿度(%)',
key: 'humidity',
render(rowData) {
const { info } = rowData.diagInfo;
const boxInfo = info?.at(0);
if (!boxInfo) return '';
return boxInfo.humidity;
},
},
{
title: '风扇转速(rpm)',
key: 'fanSpeeds',
render(rowData) {
const { info } = rowData.diagInfo;
const boxInfo = info?.at(0);
if (!boxInfo) return '';
return h('pre', {}, { default: () => boxInfo.fanSpeeds?.join('\n') ?? '' });
},
},
// { title: '开关状态', key: 'switches' },
{
title: '电路状态',
key: 'circuits',
render(rowData) {
const { info } = rowData.diagInfo;
const boxInfo = info?.at(0);
if (!boxInfo) return '';
return h(
NButton,
{
text: true,
type: 'info',
size: 'small',
onClick: () => {
detailTableData.value = boxInfo.circuits?.map((circuit, index) => ({ ...circuit, number: index + 1 })) ?? [];
showDetailModal.value = true;
},
},
{
default: () => '查看',
},
);
},
},
];
const tableData = ref<DataTableRowData[]>([]);
const DEFAULT_PAGE_SIZE = 10;
const pagination = reactive<PaginationProps>({
size: 'small',
showSizePicker: true,
page: 1,
pageSize: DEFAULT_PAGE_SIZE,
pageSizes: [5, 10, 20, 50, 80, 100],
itemCount: 0,
prefix: ({ itemCount }) => {
return h('div', {}, { default: () => `${itemCount}` });
},
onUpdatePage: (page) => {
pagination.page = page;
getTableData();
},
onUpdatePageSize: (pageSize) => {
pagination.pageSize = pageSize;
pagination.page = 1;
getTableData();
},
});
const abortController = ref<AbortController>(new AbortController());
const { mutate: getTableData, isPending } = useMutation({
mutationFn: async () => {
if (!range.value) throw new Error('请选择时间范围');
abortController.value?.abort();
abortController.value = new AbortController();
const deviceId = ndmDevice.value.id;
if (!deviceId) throw new Error('该设备没有ID');
const createdTime_precisest = dayjs(range.value[0]).format('YYYY-MM-DD HH:mm:ss');
const createdTime_preciseed = dayjs(range.value[1]).format('YYYY-MM-DD HH:mm:ss');
const restParams: Omit<PageParams<{ id: string }>, 'model' | 'extra'> = {
current: pagination.page ?? 1,
size: pagination.pageSize ?? DEFAULT_PAGE_SIZE,
sort: 'id',
order: 'descending',
};
const pageResult = await pageSnmpLogApi(
{
model: { deviceId },
extra: { createdTime_precisest, createdTime_preciseed },
...restParams,
},
{
stationCode: station.value.code,
signal: abortController.value.signal,
},
);
return pageResult;
},
onSuccess: ({ records, size, total }) => {
pagination.pageSize = parseInt(size);
pagination.itemCount = parseInt(total);
tableData.value = records.map((record) => {
const diagInfoJsonString = record.diagInfo;
const diagInfo = destr<NdmSecurityBoxDiagInfo>(diagInfoJsonString);
if (!diagInfo) return {};
if (typeof diagInfo !== 'object') return {};
return {
createdTime: record.createdTime,
diagInfo,
};
});
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
emit('exposeQueryFn', () => {
pagination.page = 1;
pagination.pageSize = DEFAULT_PAGE_SIZE;
getTableData();
});
watch(isPending, (pending) => {
loading.value = pending;
});
onBeforeUnmount(() => {
abortController.value.abort();
});
</script>
<template>
<NCard hoverable size="small">
<template #header>
<span>运行情况记录</span>
</template>
<template #default>
<NDataTable size="small" :loading="isPending" :columns="tableColumns" :data="tableData" :pagination="pagination" :single-line="false" remote flex-height style="height: 500px" />
</template>
</NCard>
<NModal v-model:show="showDetailModal" :preset="'card'" :auto-focus="false" :transform-origin="'center'" :content-style="{ height: '100%' }" style="min-width: 1440px; height: 100vh">
<NDataTable size="small" :columns="detailTableColumns" :data="detailTableData" :single-line="false" :remote="false" flex-height style="height: 100%" />
</NModal>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,206 @@
<script lang="ts">
type SwitchPortRowData = {
createdTime: string;
diagInfo: NdmSwitchDiagInfo;
};
</script>
<script setup lang="ts">
import { pageSnmpLogApi, type NdmSwitchDiagInfo, type NdmSwitchPortInfo, type NdmSwitchResultVO, type PageParams, type Station } from '@/apis';
import { getPortStatusValue, transformPortSpeed } from '@/helpers';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import dayjs from 'dayjs';
import destr from 'destr';
import { NButton, NCard, NDataTable, NModal, type DataTableColumns, type DataTableRowData, type PaginationProps } from 'naive-ui';
import { h, onBeforeUnmount, reactive, ref, toRefs, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmSwitchResultVO;
station: Station;
}>();
const range = defineModel<[number, number]>('range');
const loading = defineModel<boolean>('loading', { default: false });
const emit = defineEmits<{
exposeQueryFn: [queryFn: () => void];
}>();
const { ndmDevice, station } = toRefs(props);
const showDetailModal = ref(false);
const detailTableColumns: DataTableColumns<NdmSwitchPortInfo> = [
{ title: '端口名称', key: 'portName' },
{
title: '状态',
key: 'upDown',
render(rowData) {
return getPortStatusValue(rowData);
},
},
{
title: '上行速率',
key: 'inFlow',
render(rowData) {
return transformPortSpeed(rowData, 'in');
},
},
{
title: '下行速率',
key: 'outFlow',
render(rowData) {
return transformPortSpeed(rowData, 'out');
},
},
{
title: '总速率',
key: 'totalFlow',
render(rowData) {
return transformPortSpeed(rowData, 'total');
},
},
];
const detailTableData = ref<DataTableRowData[]>([]);
const tableColumns: DataTableColumns<SwitchPortRowData> = [
{ title: '诊断时间', key: 'createdTime' },
{
title: '超载端口',
key: 'overFlowPorts',
render(rowData) {
const { diagInfo } = rowData;
const { info } = diagInfo;
const { overFlowPorts } = info ?? {};
return h('pre', {}, { default: () => overFlowPorts?.join('\n') ?? '' });
},
},
{
title: '操作',
key: 'action',
render(rowData) {
return h(
NButton,
{
text: true,
type: 'info',
onClick: () => {
const { diagInfo } = rowData;
if (!diagInfo) return;
showDetailModal.value = true;
detailTableData.value = diagInfo.info?.portInfoList ?? [];
},
},
{ default: () => '查看' },
);
},
},
];
const tableData = ref<DataTableRowData[]>([]);
const DEFAULT_PAGE_SIZE = 10;
const pagination = reactive<PaginationProps>({
size: 'small',
showSizePicker: true,
page: 1,
pageSize: DEFAULT_PAGE_SIZE,
pageSizes: [5, 10, 20, 50, 80, 100],
itemCount: 0,
prefix: ({ itemCount }) => {
return h('div', {}, { default: () => `${itemCount}` });
},
onUpdatePage: (page) => {
pagination.page = page;
getTableData();
},
onUpdatePageSize: (pageSize) => {
pagination.pageSize = pageSize;
pagination.page = 1;
getTableData();
},
});
const abortController = ref<AbortController>(new AbortController());
const { mutate: getTableData, isPending } = useMutation({
mutationFn: async () => {
if (!range.value) throw new Error('请选择时间范围');
abortController.value?.abort();
abortController.value = new AbortController();
const deviceId = ndmDevice.value.id;
if (!deviceId) throw new Error('该设备没有ID');
const createdTime_precisest = dayjs(range.value[0]).format('YYYY-MM-DD HH:mm:ss');
const createdTime_preciseed = dayjs(range.value[1]).format('YYYY-MM-DD HH:mm:ss');
const restParams: Omit<PageParams<{ id: string }>, 'model' | 'extra'> = {
current: pagination.page ?? 1,
size: pagination.pageSize ?? DEFAULT_PAGE_SIZE,
sort: 'id',
order: 'descending',
};
const pageResult = await pageSnmpLogApi(
{
model: { deviceId },
extra: { createdTime_precisest, createdTime_preciseed },
...restParams,
},
{
stationCode: station.value.code,
},
);
return pageResult;
},
onSuccess: ({ records, size, total }) => {
pagination.pageSize = parseInt(size);
pagination.itemCount = parseInt(total);
tableData.value = records.map((record) => {
const diagInfoJsonString = record.diagInfo;
const diagInfo = destr<NdmSwitchDiagInfo>(diagInfoJsonString);
if (!diagInfo) return {};
if (typeof diagInfo !== 'object') return {};
return {
createdTime: record.createdTime,
diagInfo,
};
});
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
emit('exposeQueryFn', () => {
pagination.page = 1;
pagination.pageSize = DEFAULT_PAGE_SIZE;
getTableData();
});
watch(isPending, (pending) => {
loading.value = pending;
});
onBeforeUnmount(() => {
abortController.value.abort();
});
</script>
<template>
<NCard hoverable size="small">
<template #header>
<span>端口速率记录</span>
</template>
<template #default>
<NDataTable size="small" :loading="isPending" :columns="tableColumns" :data="tableData" :pagination="pagination" :single-line="false" remote flex-height style="height: 500px" />
</template>
</NCard>
<NModal v-model:show="showDetailModal" :preset="'card'" :auto-focus="false" :transform-origin="'center'" :content-style="{ height: '100%' }" style="min-width: 1440px; height: 100vh">
<NDataTable size="small" :columns="detailTableColumns" :data="detailTableData" :single-line="false" :remote="false" flex-height style="height: 100%" />
</NModal>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,3 @@
export * from './current-diag';
export * from './history-diag';
export * from './raw-diag';

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { NdmDeviceResultVO } from '@/apis';
import destr from 'destr';
import { NCard } from 'naive-ui';
import { computed, toRefs } from 'vue';
const props = defineProps<{
ndmDevice: NdmDeviceResultVO;
}>();
const { ndmDevice } = toRefs(props);
const lastDiagInfo = computed(() => {
return destr(ndmDevice.value.lastDiagInfo);
});
</script>
<template>
<NCard hoverable size="small">
<template #default>
<pre>{{ { ...ndmDevice, lastDiagInfo } }}</pre>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,3 @@
import DeviceRawCard from './device-raw-card.vue';
export { DeviceRawCard };

View File

@@ -0,0 +1,9 @@
export * from './components';
export * from './ndm-alarm-host';
export * from './ndm-camera';
export * from './ndm-decoder';
export * from './ndm-keyboard';
export * from './ndm-nvr';
export * from './ndm-security-box';
export * from './ndm-server';
export * from './ndm-switch';

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import type { NdmAlarmHostResultVO, Station } from '@/apis';
import { AlarmHostCurrentDiag, AlarmHostHistoryDiag, AlarmHostUpdate, DeviceRawCard } from '@/components';
import { useSettingStore } from '@/stores';
import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, ref, toRefs, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const props = defineProps<{
ndmDevice: NdmAlarmHostResultVO;
station: Station;
}>();
const route = useRoute();
const router = useRouter();
const settingStore = useSettingStore();
const { debugModeEnabled } = storeToRefs(settingStore);
const { ndmDevice, station } = toRefs(props);
const showPageHeader = computed(() => {
return !!route.query['from'];
});
const onBack = () => {
router.push({ path: `${route.query['from']}` });
};
const activeTabName = ref('当前诊断');
const onTabChange = (name: string) => {
activeTabName.value = name;
};
watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => {
if (newDevice.id !== oldDevice.id || !enabled) {
activeTabName.value = '当前诊断';
}
});
</script>
<template>
<NCard hoverable style="height: 100%" :header-style="{ padding: '12px' }" :content-style="{ height: '100%', padding: '0', overflow: 'hidden' }">
<template #header>
<NPageHeader v-if="showPageHeader" @back="onBack" />
<NTabs :value="activeTabName" @update:value="onTabChange">
<NTab name="当前诊断">当前诊断</NTab>
<NTab name="历史诊断">历史诊断</NTab>
<NTab name="修改设备">修改设备</NTab>
<NTab v-if="debugModeEnabled" name="原始数据">原始数据</NTab>
</NTabs>
</template>
<template #default>
<NScrollbar x-scrollable :content-style="{ padding: '0 12px 12px 12px' }">
<template v-if="activeTabName === '当前诊断'">
<AlarmHostCurrentDiag :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '历史诊断'">
<AlarmHostHistoryDiag :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '修改设备'">
<AlarmHostUpdate :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '原始数据'">
<DeviceRawCard :ndm-device="ndmDevice" />
</template>
</NScrollbar>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { NFlex } from 'naive-ui';
import { DeviceCommonCard, DeviceHeaderCard } from '@/components';
import type { NdmAlarmHostResultVO, Station } from '@/apis';
import { computed, toRefs } from 'vue';
const props = defineProps<{
ndmDevice: NdmAlarmHostResultVO;
station: Station;
}>();
const { ndmDevice, station } = toRefs(props);
const commonInfo = computed(() => {
const { createdTime, updatedTime, manufacturer } = ndmDevice.value;
return {
创建时间: createdTime ?? '-',
更新时间: updatedTime ?? '-',
制造商: manufacturer ?? '-',
};
});
</script>
<template>
<NFlex vertical>
<DeviceHeaderCard :ndm-device="ndmDevice" :station="station" />
<DeviceCommonCard :common-info="commonInfo" />
</NFlex>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import type { NdmAlarmHostResultVO, Station } from '@/apis';
import { DeviceAlarmHistoryCard, DeviceIcmpHistoryCard, HistoryDiagFilterCard, type DeviceAlarmHistoryCardProps, type DeviceIcmpHistoryCardProps } from '@/components';
import dayjs from 'dayjs';
import { NFlex, type SelectOption } from 'naive-ui';
import { onMounted, ref, toRefs, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmAlarmHostResultVO;
station: Station;
}>();
const { ndmDevice, station } = toRefs(props);
const historyDiagOptions: SelectOption[] = [
{ label: '设备状态', value: 'icmp' },
{ label: '设备告警', value: 'alarm' },
];
const getWeekRange = (): [number, number] => {
const now = dayjs();
const todayEnd = now.endOf('date');
const weekAgo = now.subtract(1, 'week').startOf('date');
return [weekAgo.valueOf(), todayEnd.valueOf()];
};
const range = ref<[number, number]>(getWeekRange());
const selected = ref<string[]>([...historyDiagOptions.map((option) => `${option.value}`)]);
const loading = ref<boolean>(false);
const icmpLoading = ref<boolean>(false);
const alarmLoading = ref<boolean>(false);
watch([icmpLoading, alarmLoading], (loadings) => {
loading.value = loadings.some((loading) => loading);
});
const icmpHistoryQueryFn = ref<() => void>();
const onExposeIcmpHistoryQueryFn: DeviceIcmpHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
icmpHistoryQueryFn.value = queryFn;
};
const alarmHistoryQueryFn = ref<() => void>();
const onExposeAlarmHistoryQueryFn: DeviceAlarmHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
alarmHistoryQueryFn.value = queryFn;
};
const queryData = () => {
if (selected.value.includes('icmp')) icmpHistoryQueryFn.value?.();
if (selected.value.includes('alarm')) alarmHistoryQueryFn.value?.();
};
const onQuery = () => {
queryData();
};
onMounted(() => {
queryData();
});
</script>
<template>
<NFlex vertical>
<HistoryDiagFilterCard :options="historyDiagOptions" v-model:loading="loading" v-model:range="range" v-model:selected="selected" @query="onQuery" />
<DeviceIcmpHistoryCard
v-if="selected.includes('icmp')"
:ndm-device="ndmDevice"
:station="station"
v-model:range="range"
v-model:loading="icmpLoading"
@expose-query-fn="onExposeIcmpHistoryQueryFn"
/>
<DeviceAlarmHistoryCard
v-if="selected.includes('alarm')"
:ndm-device="ndmDevice"
:station="station"
v-model:range="range"
v-model:loading="alarmLoading"
@expose-query-fn="onExposeAlarmHistoryQueryFn"
/>
</NFlex>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,152 @@
<script setup lang="ts">
import { icmpEntityByDeviceId, type NdmAlarmHostResultVO, type NdmAlarmHostUpdateVO, type Station } from '@/apis';
import { detailAlarmHostApi, updateAlarmHostApi } from '@/apis/request/biz/alarm/ndm-alarm-host';
import { useDeviceStore } from '@/stores';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import destr from 'destr';
import { isString } from 'es-toolkit';
import { NButton, NCard, NFlex, NForm, NFormItem, NFormItemGi, NGrid, NInput, NSwitch, type FormInst, type FormRules } from 'naive-ui';
import { computed, onBeforeUnmount, ref, toRefs, useTemplateRef, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmAlarmHostResultVO;
station: Station;
}>();
const deviceStore = useDeviceStore();
const { ndmDevice, station } = toRefs(props);
const localDevice = ref<NdmAlarmHostUpdateVO>({ ...ndmDevice.value });
watch(ndmDevice, (newDevice) => {
localDevice.value = { ...newDevice };
});
const canEditDeviceId = computed(() => {
const { deviceId } = ndmDevice.value;
if (!isString(deviceId)) return true;
if (deviceId.length === 0) return true;
return false;
});
const validatorAbortController = ref<AbortController>(new AbortController());
const abortController = ref<AbortController>(new AbortController());
const formInst = useTemplateRef<FormInst>('formInst');
const formRules: FormRules = {
deviceId: {
trigger: ['input'],
asyncValidator: async (rule, value: string) => {
await validateDeviceIdDuplicated({ deviceId: value }).catch((error) => {
if (isCancel(error)) return;
throw error;
});
},
},
};
const { mutateAsync: validateDeviceIdDuplicated } = useMutation({
mutationFn: async (params: { deviceId?: string }) => {
const { deviceId } = params;
if (!deviceId) throw new Error('请输入设备ID');
const deviceIdPattern = /^\d{4}01\d{4}$/;
if (!deviceIdPattern.test(deviceId)) throw new Error('设备ID不符合规范');
validatorAbortController.value.abort();
validatorAbortController.value = new AbortController();
const icmpEntities = await icmpEntityByDeviceId(deviceId, {
stationCode: station.value.code,
signal: validatorAbortController.value.signal,
});
if (icmpEntities.length > 0) throw new Error('该设备ID已存在');
},
});
const { mutate: updateDevice, isPending: loading } = useMutation({
mutationFn: async () => {
await formInst.value?.validate().catch(() => {
window.$message.error('表单验证失败');
return;
});
abortController.value.abort();
abortController.value = new AbortController();
const stationCode = station.value.code;
const signal = abortController.value.signal;
await updateAlarmHostApi(localDevice.value, { stationCode, signal });
const result = await detailAlarmHostApi(`${localDevice.value.id}`, { stationCode, signal });
return result;
},
onSuccess: (newDevice) => {
localDevice.value = { ...newDevice };
deviceStore.patchDevice(station.value.code, { ...newDevice });
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
onBeforeUnmount(() => {
validatorAbortController.value.abort();
abortController.value.abort();
});
</script>
<template>
<NCard hoverable size="small">
<template #default>
<NForm size="small" :ref="'formInst'" :model="localDevice" :rules="formRules">
<NGrid>
<NFormItemGi span="8" label-placement="left" label="ICMP启用">
<NSwitch :value="destr(localDevice.icmpEnabled)" @update:value="(enabled: boolean) => (localDevice.icmpEnabled = enabled)" />
</NFormItemGi>
<NFormItemGi span="8" label-placement="left" label="SNMP启用">
<NSwitch :value="destr(localDevice.snmpEnabled)" @update:value="(enabled: boolean) => (localDevice.snmpEnabled = enabled)" />
</NFormItemGi>
</NGrid>
<NFormItem label-placement="left" label="设备ID" path="deviceId">
<NInput v-model:value="localDevice.deviceId" :disabled="!canEditDeviceId" />
</NFormItem>
<NFormItem label-placement="left" label="设备名称">
<NInput v-model:value="localDevice.name" />
</NFormItem>
<NFormItem label-placement="left" label="设备厂商">
<NInput v-model:value="localDevice.manufacturer" />
</NFormItem>
<NFormItem label-placement="left" label="型号">
<NInput v-model:value="localDevice.model" />
</NFormItem>
<NFormItem label-placement="left" label="管理URL">
<NInput v-model:value="localDevice.manageUrl" />
</NFormItem>
<NFormItem label-placement="left" label="管理用户名">
<NInput v-model:value="localDevice.manageUsername" />
</NFormItem>
<NFormItem label-placement="left" label="管理密码">
<NInput v-model:value="localDevice.managePassword" />
</NFormItem>
<NFormItem label-placement="left" label="设备描述">
<NInput v-model:value="localDevice.description" />
</NFormItem>
<NFormItem label-placement="left" label="上游设备">
<NInput v-model:value="localDevice.linkDescription" />
</NFormItem>
</NForm>
</template>
<template #action>
<NFlex justify="end">
<NButton secondary size="small" :loading="loading" @click="() => updateDevice()">更新</NButton>
</NFlex>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,6 @@
import AlarmHostCard from './alarm-host-card.vue';
import AlarmHostCurrentDiag from './alarm-host-current-diag.vue';
import AlarmHostHistoryDiag from './alarm-host-history-diag.vue';
import AlarmHostUpdate from './alarm-host-update.vue';
export { AlarmHostCard, AlarmHostCurrentDiag, AlarmHostHistoryDiag, AlarmHostUpdate };

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import type { NdmCameraResultVO, Station } from '@/apis';
import { CameraCurrentDiag, CameraHistoryDiag, CameraUpdate, DeviceRawCard } from '@/components';
import { useSettingStore } from '@/stores';
import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, ref, toRefs, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const props = defineProps<{
ndmDevice: NdmCameraResultVO;
station: Station;
}>();
const route = useRoute();
const router = useRouter();
const settingStore = useSettingStore();
const { debugModeEnabled } = storeToRefs(settingStore);
const { ndmDevice, station } = toRefs(props);
const showPageHeader = computed(() => {
return !!route.query['from'];
});
const onBack = () => {
router.push({ path: `${route.query['from']}` });
};
const activeTabName = ref('当前诊断');
const onTabChange = (name: string) => {
activeTabName.value = name;
};
watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => {
if (newDevice.id !== oldDevice.id || !enabled) {
activeTabName.value = '当前诊断';
}
});
</script>
<template>
<NCard hoverable style="height: 100%" :header-style="{ padding: '12px' }" :content-style="{ height: '100%', padding: '0', overflow: 'hidden' }">
<template #header>
<NPageHeader v-if="showPageHeader" @back="onBack" />
<NTabs :value="activeTabName" @update:value="onTabChange">
<NTab name="当前诊断">当前诊断</NTab>
<NTab name="历史诊断">历史诊断</NTab>
<NTab name="修改设备">修改设备</NTab>
<NTab v-if="debugModeEnabled" name="原始数据">原始数据</NTab>
</NTabs>
</template>
<template #default>
<NScrollbar x-scrollable :content-style="{ padding: '0 12px 12px 12px' }">
<template v-if="activeTabName === '当前诊断'">
<CameraCurrentDiag :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '历史诊断'">
<CameraHistoryDiag :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '修改设备'">
<CameraUpdate :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '原始数据'">
<DeviceRawCard :ndm-device="ndmDevice" />
</template>
</NScrollbar>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import type { NdmCameraResultVO, Station } from '@/apis';
import { DeviceCommonCard, DeviceHeaderCard } from '@/components';
import { NFlex } from 'naive-ui';
import { computed, toRefs } from 'vue';
const props = defineProps<{
ndmDevice: NdmCameraResultVO;
station: Station;
}>();
const { ndmDevice, station } = toRefs(props);
const commonInfo = computed(() => {
const {
createdTime,
updatedTime,
manufacturer,
gb28181Enabled,
onvifPort,
onvifUsername,
onvifPassword,
onvifMajorIndex,
onvifMinorIndex,
icmpEnabled,
community,
//
} = ndmDevice.value;
return {
创建时间: createdTime ?? '-',
更新时间: updatedTime ?? '-',
制造商: manufacturer ?? '-',
GB28181启用: `${!!gb28181Enabled ? '是' : '否'}`,
ONVIF端口: `${onvifPort ?? '-'}`,
ONVIF用户名: onvifUsername ?? '-',
ONVIF密码: onvifPassword ?? '-',
ONVIF主流索引: `${onvifMajorIndex ?? '-'}`,
ONVIF辅流索引: `${onvifMinorIndex ?? '-'}`,
ICMP启用: `${!!icmpEnabled ? '是' : '否'}`,
团体字符串: community ?? '-',
};
});
</script>
<template>
<NFlex vertical>
<DeviceHeaderCard :ndm-device="ndmDevice" :station="station" />
<DeviceCommonCard :common-info="commonInfo" />
</NFlex>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import type { NdmCameraResultVO, Station } from '@/apis';
import { DeviceAlarmHistoryCard, DeviceIcmpHistoryCard, HistoryDiagFilterCard, type DeviceAlarmHistoryCardProps, type DeviceIcmpHistoryCardProps } from '@/components';
import dayjs from 'dayjs';
import { NFlex, type SelectOption } from 'naive-ui';
import { onMounted, ref, toRefs, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmCameraResultVO;
station: Station;
}>();
const { ndmDevice, station } = toRefs(props);
const historyDiagOptions: SelectOption[] = [
{ label: '设备状态', value: 'icmp' },
{ label: '设备告警', value: 'alarm' },
];
const getWeekRange = (): [number, number] => {
const now = dayjs();
const todayEnd = now.endOf('date');
const weekAgo = now.subtract(1, 'week').startOf('date');
return [weekAgo.valueOf(), todayEnd.valueOf()];
};
const range = ref<[number, number]>(getWeekRange());
const selected = ref<string[]>([...historyDiagOptions.map((option) => `${option.value}`)]);
const loading = ref<boolean>(false);
const icmpLoading = ref<boolean>(false);
const alarmLoading = ref<boolean>(false);
watch([icmpLoading, alarmLoading], (loadings) => {
loading.value = loadings.some((loading) => loading);
});
const icmpHistoryQueryFn = ref<() => void>();
const onExposeIcmpHistoryQueryFn: DeviceIcmpHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
icmpHistoryQueryFn.value = queryFn;
};
const alarmHistoryQueryFn = ref<() => void>();
const onExposeAlarmHistoryQueryFn: DeviceAlarmHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
alarmHistoryQueryFn.value = queryFn;
};
const queryData = () => {
if (selected.value.includes('icmp')) icmpHistoryQueryFn.value?.();
if (selected.value.includes('alarm')) alarmHistoryQueryFn.value?.();
};
const onQuery = () => {
queryData();
};
onMounted(() => {
queryData();
});
</script>
<template>
<NFlex vertical>
<HistoryDiagFilterCard :options="historyDiagOptions" v-model:loading="loading" v-model:range="range" v-model:selected="selected" @query="onQuery" />
<DeviceIcmpHistoryCard
v-if="selected.includes('icmp')"
:ndm-device="ndmDevice"
:station="station"
v-model:range="range"
v-model:loading="icmpLoading"
@expose-query-fn="onExposeIcmpHistoryQueryFn"
/>
<DeviceAlarmHistoryCard
v-if="selected.includes('alarm')"
:ndm-device="ndmDevice"
:station="station"
v-model:range="range"
v-model:loading="alarmLoading"
@expose-query-fn="onExposeAlarmHistoryQueryFn"
/>
</NFlex>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import { detailCameraApi, icmpEntityByDeviceId, updateCameraApi, type NdmCameraResultVO, type NdmCameraUpdateVO, type Station } from '@/apis';
import { useDeviceStore } from '@/stores';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import destr from 'destr';
import { isString } from 'es-toolkit';
import { NButton, NCard, NFlex, NForm, NFormItem, NFormItemGi, NGrid, NInput, NSwitch, type FormInst, type FormRules } from 'naive-ui';
import { computed, onBeforeUnmount, ref, toRefs, useTemplateRef, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmCameraResultVO;
station: Station;
}>();
const deviceStore = useDeviceStore();
const { ndmDevice, station } = toRefs(props);
const localDevice = ref<NdmCameraUpdateVO>({ ...ndmDevice.value });
watch(ndmDevice, (newDevice) => {
localDevice.value = { ...newDevice };
});
const canEditDeviceId = computed(() => {
const { deviceId } = ndmDevice.value;
if (!isString(deviceId)) return true;
if (deviceId.length === 0) return true;
return false;
});
const validatorAbortController = ref<AbortController>(new AbortController());
const abortController = ref<AbortController>(new AbortController());
const formInst = useTemplateRef<FormInst>('formInst');
const formRules: FormRules = {
deviceId: {
trigger: ['input'],
asyncValidator: async (rule, value: string) => {
await validateDeviceIdDuplicated({ deviceId: value }).catch((error) => {
if (isCancel(error)) return;
throw error;
});
},
},
};
const { mutateAsync: validateDeviceIdDuplicated } = useMutation({
mutationFn: async (params: { deviceId?: string }) => {
const { deviceId } = params;
if (!deviceId) throw new Error('请输入设备ID');
const deviceIdPattern = /^\d{4}06\d{4}$/;
if (!deviceIdPattern.test(deviceId)) throw new Error('设备ID不符合规范');
validatorAbortController.value.abort();
validatorAbortController.value = new AbortController();
const icmpEntities = await icmpEntityByDeviceId(deviceId, {
stationCode: station.value.code,
signal: validatorAbortController.value.signal,
});
if (icmpEntities.length > 0) throw new Error('该设备ID已存在');
},
});
const { mutate: updateDevice, isPending: loading } = useMutation({
mutationFn: async () => {
await formInst.value?.validate().catch(() => {
window.$message.error('表单验证失败');
return;
});
abortController.value.abort();
abortController.value = new AbortController();
const stationCode = station.value.code;
const signal = abortController.value.signal;
await updateCameraApi(localDevice.value, { stationCode, signal });
const result = await detailCameraApi(`${localDevice.value.id}`, { stationCode, signal });
return result;
},
onSuccess: (newDevice) => {
localDevice.value = { ...newDevice };
deviceStore.patchDevice(station.value.code, { ...newDevice });
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
onBeforeUnmount(() => {
validatorAbortController.value.abort();
abortController.value.abort();
});
</script>
<template>
<NCard hoverable size="small">
<template #default>
<NForm size="small" :ref="'formInst'" :model="localDevice" :rules="formRules">
<NGrid>
<NFormItemGi span="8" label-placement="left" label="ICMP启用">
<NSwitch :value="destr(localDevice.icmpEnabled)" @update:value="(enabled: boolean) => (localDevice.icmpEnabled = enabled)" />
</NFormItemGi>
<NFormItemGi span="8" label-placement="left" label="SNMP启用">
<NSwitch :value="destr(localDevice.snmpEnabled)" @update:value="(enabled: boolean) => (localDevice.snmpEnabled = enabled)" />
</NFormItemGi>
</NGrid>
<NFormItem label-placement="left" label="设备ID" path="deviceId">
<NInput v-model:value="localDevice.deviceId" :disabled="!canEditDeviceId" />
</NFormItem>
<NFormItem label-placement="left" label="设备描述">
<NInput v-model:value="localDevice.description" />
</NFormItem>
<NFormItem label-placement="left" label="上游设备">
<NInput v-model:value="localDevice.linkDescription" />
</NFormItem>
</NForm>
</template>
<template #action>
<NFlex justify="end">
<NButton secondary size="small" :loading="loading" @click="() => updateDevice()">更新</NButton>
</NFlex>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,6 @@
import CameraCard from './camera-card.vue';
import CameraCurrentDiag from './camera-current-diag.vue';
import CameraHistoryDiag from './camera-history-diag.vue';
import CameraUpdate from './camera-update.vue';
export { CameraCard, CameraCurrentDiag, CameraHistoryDiag, CameraUpdate };

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import type { NdmDecoderResultVO, Station } from '@/apis';
import { DecoderCurrentDiag, DecoderHistoryDiag, DecoderUpdate, DeviceRawCard } from '@/components';
import { useSettingStore } from '@/stores';
import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, ref, toRefs, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const props = defineProps<{
ndmDevice: NdmDecoderResultVO;
station: Station;
}>();
const route = useRoute();
const router = useRouter();
const settingStore = useSettingStore();
const { debugModeEnabled } = storeToRefs(settingStore);
const { ndmDevice, station } = toRefs(props);
const showPageHeader = computed(() => {
return !!route.query['from'];
});
const onBack = () => {
router.push({ path: `${route.query['from']}` });
};
const activeTabName = ref('当前诊断');
const onTabChange = (name: string) => {
activeTabName.value = name;
};
watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => {
if (newDevice.id !== oldDevice.id || !enabled) {
activeTabName.value = '当前诊断';
}
});
</script>
<template>
<NCard hoverable style="height: 100%" :header-style="{ padding: '12px' }" :content-style="{ height: '100%', padding: '0', overflow: 'hidden' }">
<template #header>
<NPageHeader v-if="showPageHeader" @back="onBack" />
<NTabs :value="activeTabName" @update:value="onTabChange">
<NTab name="当前诊断">当前诊断</NTab>
<NTab name="历史诊断">历史诊断</NTab>
<NTab name="修改设备">修改设备</NTab>
<NTab v-if="debugModeEnabled" name="原始数据">原始数据</NTab>
</NTabs>
</template>
<template #default>
<NScrollbar x-scrollable :content-style="{ padding: '0 12px 12px 12px' }">
<template v-if="activeTabName === '当前诊断'">
<DecoderCurrentDiag :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '历史诊断'">
<DecoderHistoryDiag :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '修改设备'">
<DecoderUpdate :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '原始数据'">
<DeviceRawCard :ndm-device="ndmDevice" />
</template>
</NScrollbar>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import type { NdmDecoderDiagInfo, NdmDecoderResultVO, Station } from '@/apis';
import { DeviceCommonCard, DeviceHardwareCard, DeviceHeaderCard } from '@/components';
import destr from 'destr';
import { NFlex } from 'naive-ui';
import { computed, toRefs } from 'vue';
const props = defineProps<{
ndmDevice: NdmDecoderResultVO;
station: Station;
}>();
const { ndmDevice, station } = toRefs(props);
const lastDiagInfo = computed(() => {
const result = destr<any>(ndmDevice.value.lastDiagInfo);
if (!result) return null;
if (typeof result !== 'object') return null;
return result as NdmDecoderDiagInfo;
});
const commonInfo = computed(() => {
const { stCommonInfo } = lastDiagInfo.value ?? {};
const { 设备ID, 软件版本, 设备厂商, 设备别名, 设备型号, 硬件版本 } = stCommonInfo ?? {};
return {
设备ID: 设备ID ?? '-',
软件版本: 软件版本 ?? '-',
设备厂商: 设备厂商 ?? '-',
设备别名: 设备别名 ?? '-',
设备型号: 设备型号 ?? '-',
硬件版本: 硬件版本 ?? '-',
};
});
const cpuUsage = computed(() => lastDiagInfo.value?.stCommonInfo?.CPU使用率);
const memUsage = computed(() => lastDiagInfo.value?.stCommonInfo?.内存使用率);
</script>
<template>
<NFlex vertical>
<DeviceHeaderCard :ndm-device="ndmDevice" :station="station" />
<DeviceCommonCard :common-info="commonInfo" />
<DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" />
</NFlex>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { NdmDecoderResultVO, Station } from '@/apis';
import {
DeviceAlarmHistoryCard,
DeviceIcmpHistoryCard,
DeviceUsageHistoryCard,
HistoryDiagFilterCard,
type DeviceAlarmHistoryCardProps,
type DeviceIcmpHistoryCardProps,
type DeviceUsageHistoryCardProps,
} from '@/components';
import dayjs from 'dayjs';
import { NFlex, type SelectOption } from 'naive-ui';
import { onMounted, ref, toRefs, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmDecoderResultVO;
station: Station;
}>();
const { ndmDevice, station } = toRefs(props);
const historyDiagOptions: SelectOption[] = [
{ label: '设备状态', value: 'icmp' },
{ label: '设备告警', value: 'alarm' },
{ label: '硬件占用', value: 'usage' },
];
const getWeekRange = (): [number, number] => {
const now = dayjs();
const todayEnd = now.endOf('date');
const weekAgo = now.subtract(1, 'week').startOf('date');
return [weekAgo.valueOf(), todayEnd.valueOf()];
};
const range = ref<[number, number]>(getWeekRange());
const selected = ref([...historyDiagOptions.map((option) => `${option.value}`)]);
const loading = ref<boolean>(false);
const icmpLoading = ref<boolean>(false);
const alarmLoading = ref<boolean>(false);
watch([icmpLoading, alarmLoading], (loadings) => {
loading.value = loadings.some((loading) => loading);
});
const icmpHistoryQueryFn = ref<() => void>();
const onExposeIcmpHistoryQueryFn: DeviceIcmpHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
icmpHistoryQueryFn.value = queryFn;
};
const alarmHistoryQueryFn = ref<() => void>();
const onExposeAlarmHistoryQueryFn: DeviceAlarmHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
alarmHistoryQueryFn.value = queryFn;
};
const usageHistoryQueryFn = ref<() => void>();
const onExposeUsageHistoryQueryFn: DeviceUsageHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
usageHistoryQueryFn.value = queryFn;
};
const queryData = () => {
if (selected.value.includes('icmp')) icmpHistoryQueryFn.value?.();
if (selected.value.includes('alarm')) alarmHistoryQueryFn.value?.();
if (selected.value.includes('usage')) usageHistoryQueryFn.value?.();
};
const onQuery = () => {
queryData();
};
onMounted(() => {
queryData();
});
</script>
<template>
<NFlex vertical>
<HistoryDiagFilterCard :options="historyDiagOptions" v-model:loading="loading" v-model:range="range" v-model:selected="selected" @query="onQuery" />
<DeviceIcmpHistoryCard
v-if="selected.includes('icmp')"
:ndm-device="ndmDevice"
:station="station"
v-model:range="range"
v-model:loading="icmpLoading"
@expose-query-fn="onExposeIcmpHistoryQueryFn"
/>
<DeviceAlarmHistoryCard
v-if="selected.includes('alarm')"
:ndm-device="ndmDevice"
:station="station"
v-model:range="range"
v-model:loading="alarmLoading"
@expose-query-fn="onExposeAlarmHistoryQueryFn"
/>
<DeviceUsageHistoryCard
v-if="selected.includes('usage')"
:ndm-device="ndmDevice"
:station="station"
:cpu-usage-field="'stCommonInfo.CPU使用率'"
:mem-usage-field="'stCommonInfo.内存使用率'"
v-model:range="range"
v-model:loading="alarmLoading"
@expose-query-fn="onExposeUsageHistoryQueryFn"
/>
</NFlex>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
import { detailDecoderApi, icmpEntityByDeviceId, updateDecoderApi, type NdmDecoderResultVO, type NdmDecoderUpdateVO, type Station } from '@/apis';
import { useDeviceStore } from '@/stores';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import destr from 'destr';
import { isString } from 'es-toolkit';
import { NButton, NCard, NFlex, NForm, NFormItem, NFormItemGi, NGrid, NInput, NSwitch, type FormInst, type FormRules } from 'naive-ui';
import { computed, onBeforeUnmount, ref, toRefs, useTemplateRef, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmDecoderResultVO;
station: Station;
}>();
const deviceStore = useDeviceStore();
const { ndmDevice, station } = toRefs(props);
const localDevice = ref<NdmDecoderUpdateVO>({ ...ndmDevice.value });
watch(ndmDevice, (newDevice) => {
localDevice.value = { ...newDevice };
});
const canEditDeviceId = computed(() => {
const { deviceId } = ndmDevice.value;
if (!isString(deviceId)) return true;
if (deviceId.length === 0) return true;
return false;
});
const validatorAbortController = ref<AbortController>(new AbortController());
const abortController = ref<AbortController>(new AbortController());
const formInst = useTemplateRef<FormInst>('formInst');
const formRules: FormRules = {
deviceId: {
trigger: ['input'],
asyncValidator: async (rule, value: string) => {
await validateDeviceIdDuplicated({ deviceId: value }).catch((error) => {
if (isCancel(error)) return;
throw error;
});
},
},
};
const { mutateAsync: validateDeviceIdDuplicated } = useMutation({
mutationFn: async (params: { deviceId?: string }) => {
const { deviceId } = params;
if (!deviceId) throw new Error('请输入设备ID');
const deviceIdPattern = /^\d{4}07\d{4}$/;
if (!deviceIdPattern.test(deviceId)) throw new Error('设备ID不符合规范');
validatorAbortController.value.abort();
validatorAbortController.value = new AbortController();
const icmpEntities = await icmpEntityByDeviceId(deviceId, {
stationCode: station.value.code,
signal: validatorAbortController.value.signal,
});
if (icmpEntities.length > 0) throw new Error('该设备ID已存在');
},
});
const { mutate: updateDevice, isPending: loading } = useMutation({
mutationFn: async () => {
await formInst.value?.validate().catch(() => {
window.$message.error('表单验证失败');
return;
});
abortController.value.abort();
abortController.value = new AbortController();
const stationCode = station.value.code;
const signal = abortController.value.signal;
await updateDecoderApi(localDevice.value, { stationCode, signal });
const result = await detailDecoderApi(`${localDevice.value.id}`, { stationCode, signal });
return result;
},
onSuccess: (newDevice) => {
localDevice.value = { ...newDevice };
deviceStore.patchDevice(station.value.code, { ...newDevice });
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
onBeforeUnmount(() => {
validatorAbortController.value.abort();
abortController.value.abort();
});
</script>
<template>
<NCard hoverable size="small">
<template #default>
<NForm size="small" ref="formInst" :model="localDevice" :rules="formRules">
<NGrid>
<NFormItemGi span="8" label-placement="left" label="ICMP启用">
<NSwitch :value="destr(localDevice.icmpEnabled)" @update:value="(enabled: boolean) => (localDevice.icmpEnabled = enabled)" />
</NFormItemGi>
<NFormItemGi span="8" label-placement="left" label="SNMP启用">
<NSwitch :value="destr(localDevice.snmpEnabled)" @update:value="(enabled: boolean) => (localDevice.snmpEnabled = enabled)" />
</NFormItemGi>
</NGrid>
<NFormItem label-placement="left" label="设备ID" path="device">
<NInput v-model:value="localDevice.deviceId" :disabled="!canEditDeviceId" />
</NFormItem>
<NFormItem label-placement="left" label="设备名称">
<NInput v-model:value="localDevice.name" />
</NFormItem>
<NFormItem label-placement="left" label="设备厂商">
<NInput v-model:value="localDevice.manufacturer" />
</NFormItem>
<NFormItem label-placement="left" label="型号">
<NInput v-model:value="localDevice.model" />
</NFormItem>
<NFormItem label-placement="left" label="管理URL">
<NInput v-model:value="localDevice.manageUrl" />
</NFormItem>
<NFormItem label-placement="left" label="管理用户名">
<NInput v-model:value="localDevice.manageUsername" />
</NFormItem>
<NFormItem label-placement="left" label="管理密码">
<NInput v-model:value="localDevice.managePassword" />
</NFormItem>
<NFormItem label-placement="left" label="团体字符串">
<NInput v-model:value="localDevice.community" />
</NFormItem>
<NFormItem label-placement="left" label="设备描述">
<NInput v-model:value="localDevice.description" />
</NFormItem>
<NFormItem label-placement="left" label="上游设备">
<NInput v-model:value="localDevice.linkDescription" />
</NFormItem>
</NForm>
</template>
<template #action>
<NFlex justify="end">
<NButton secondary size="small" :loading="loading" @click="() => updateDevice()">更新</NButton>
</NFlex>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,6 @@
import DecoderCard from './decoder-card.vue';
import DecoderCurrentDiag from './decoder-current-diag.vue';
import DecoderHistoryDiag from './decoder-history-diag.vue';
import DecoderUpdate from './decoder-update.vue';
export { DecoderCard, DecoderCurrentDiag, DecoderHistoryDiag, DecoderUpdate };

View File

@@ -0,0 +1,6 @@
import KeyboardCard from './keyboard-card.vue';
import KeyboardCurrentDiag from './keyboard-current-diag.vue';
import KeyboardHistoryDiag from './keyboard-history-diag.vue';
import KeyboardUpdate from './keyboard-update.vue';
export { KeyboardCard, KeyboardCurrentDiag, KeyboardHistoryDiag, KeyboardUpdate };

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import type { NdmKeyboardResultVO, Station } from '@/apis';
import { DeviceRawCard, KeyboardCurrentDiag, KeyboardHistoryDiag, KeyboardUpdate } from '@/components';
import { useSettingStore } from '@/stores';
import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { ref, toRefs, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const props = defineProps<{
ndmDevice: NdmKeyboardResultVO;
station: Station;
}>();
const route = useRoute();
const router = useRouter();
const settingStore = useSettingStore();
const { debugModeEnabled } = storeToRefs(settingStore);
const { ndmDevice, station } = toRefs(props);
const showPageHeader = computed(() => {
return !!route.query['from'];
});
const onBack = () => {
router.push({ path: `${route.query['from']}` });
};
const activeTabName = ref('当前诊断');
const onTabChange = (name: string) => {
activeTabName.value = name;
};
watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => {
if (newDevice.id !== oldDevice.id || !enabled) {
activeTabName.value = '当前诊断';
}
});
</script>
<template>
<NCard hoverable style="height: 100%" :header-style="{ padding: '12px' }" :content-style="{ height: '100%', padding: '0', overflow: 'hidden' }">
<template #header>
<NPageHeader v-if="showPageHeader" @back="onBack" />
<NTabs :value="activeTabName" @update:value="onTabChange">
<NTab name="当前诊断">当前诊断</NTab>
<NTab name="历史诊断">历史诊断</NTab>
<NTab name="修改设备">修改设备</NTab>
<NTab v-if="debugModeEnabled" name="原始数据">原始数据</NTab>
</NTabs>
</template>
<template #default>
<NScrollbar x-scrollable :content-style="{ padding: '0 12px 12px 12px' }">
<template v-if="activeTabName === '当前诊断'">
<KeyboardCurrentDiag :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '历史诊断'">
<KeyboardHistoryDiag :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '修改设备'">
<KeyboardUpdate :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '原始数据'">
<DeviceRawCard :ndm-device="ndmDevice" />
</template>
</NScrollbar>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { NdmKeyboardResultVO, Station } from '@/apis';
import { DeviceHeaderCard } from '@/components';
import { NFlex } from 'naive-ui';
import { toRefs } from 'vue';
const props = defineProps<{
ndmDevice: NdmKeyboardResultVO;
station: Station;
}>();
const { ndmDevice, station } = toRefs(props);
</script>
<template>
<NFlex vertical>
<DeviceHeaderCard :ndm-device="ndmDevice" :station="station" />
</NFlex>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import type { NdmKeyboardResultVO, Station } from '@/apis';
import { DeviceAlarmHistoryCard, DeviceIcmpHistoryCard, HistoryDiagFilterCard, type DeviceAlarmHistoryCardProps, type DeviceIcmpHistoryCardProps } from '@/components';
import dayjs from 'dayjs';
import { NFlex, type SelectOption } from 'naive-ui';
import { onMounted, ref, toRefs, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmKeyboardResultVO;
station: Station;
}>();
const { ndmDevice, station } = toRefs(props);
const historyDiagOptions: SelectOption[] = [
{ label: '设备状态', value: 'icmp' },
{ label: '设备告警', value: 'alarm' },
];
const getWeekRange = (): [number, number] => {
const now = dayjs();
const todayEnd = now.endOf('date');
const weekAgo = now.subtract(1, 'week').startOf('date');
return [weekAgo.valueOf(), todayEnd.valueOf()];
};
const range = ref<[number, number]>(getWeekRange());
const selected = ref<string[]>([...historyDiagOptions.map((option) => `${option.value}`)]);
const loading = ref<boolean>(false);
const icmpLoading = ref<boolean>(false);
const alarmLoading = ref<boolean>(false);
watch([icmpLoading, alarmLoading], (loadings) => {
loading.value = loadings.some((loading) => loading);
});
const icmpHistoryQueryFn = ref<() => void>();
const onExposeIcmpHistoryQueryFn: DeviceIcmpHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
icmpHistoryQueryFn.value = queryFn;
};
const alarmHistoryQueryFn = ref<() => void>();
const onExposeAlarmHistoryQueryFn: DeviceAlarmHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
alarmHistoryQueryFn.value = queryFn;
};
const queryData = () => {
if (selected.value.includes('icmp')) icmpHistoryQueryFn.value?.();
if (selected.value.includes('alarm')) alarmHistoryQueryFn.value?.();
};
const onQuery = () => {
queryData();
};
onMounted(() => {
queryData();
});
</script>
<template>
<NFlex vertical>
<HistoryDiagFilterCard :options="historyDiagOptions" v-model:loading="loading" v-model:range="range" v-model:selected="selected" @query="onQuery" />
<DeviceIcmpHistoryCard
v-if="selected.includes('icmp')"
:ndm-device="ndmDevice"
:station="station"
v-model:range="range"
v-model:loading="icmpLoading"
@expose-query-fn="onExposeIcmpHistoryQueryFn"
/>
<DeviceAlarmHistoryCard
v-if="selected.includes('alarm')"
:ndm-device="ndmDevice"
:station="station"
v-model:range="range"
v-model:loading="alarmLoading"
@expose-query-fn="onExposeAlarmHistoryQueryFn"
/>
</NFlex>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { detailKeyboardApi, icmpEntityByDeviceId, updateKeyboardApi, type NdmKeyboardResultVO, type NdmKeyboardUpdateVO, type Station } from '@/apis';
import { useDeviceStore } from '@/stores';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import destr from 'destr';
import { isString } from 'es-toolkit';
import { NButton, NCard, NFlex, NForm, NFormItem, NFormItemGi, NGrid, NInput, NSwitch, type FormInst, type FormRules } from 'naive-ui';
import { computed, onBeforeUnmount, ref, toRefs, useTemplateRef, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmKeyboardResultVO;
station: Station;
}>();
const deviceStore = useDeviceStore();
const { ndmDevice, station } = toRefs(props);
const localDevice = ref<NdmKeyboardUpdateVO>({ ...ndmDevice.value });
watch(ndmDevice, (newDevice) => {
localDevice.value = { ...newDevice };
});
const canEditDeviceId = computed(() => {
const { deviceId } = ndmDevice.value;
if (!isString(deviceId)) return true;
if (deviceId.length === 0) return true;
return false;
});
const validatorAbortController = ref<AbortController>(new AbortController());
const abortController = ref<AbortController>(new AbortController());
const formInst = useTemplateRef<FormInst>('formInst');
const formRules: FormRules = {
deviceId: {
trigger: ['input'],
asyncValidator: async (rule, value: string) => {
await validateDeviceIdDuplicated({ deviceId: value }).catch((error) => {
if (isCancel(error)) return;
throw error;
});
},
},
};
const { mutateAsync: validateDeviceIdDuplicated } = useMutation({
mutationFn: async (params: { deviceId?: string }) => {
const { deviceId } = params;
if (!deviceId) throw new Error('请输入设备ID');
const deviceIdPattern = /^\d{4}08\d{4}$/;
if (!deviceIdPattern.test(deviceId)) throw new Error('设备ID不符合规范');
validatorAbortController.value.abort();
validatorAbortController.value = new AbortController();
const icmpEntities = await icmpEntityByDeviceId(deviceId, {
stationCode: station.value.code,
signal: validatorAbortController.value.signal,
});
if (icmpEntities.length > 0) throw new Error('该设备ID已存在');
},
});
const { mutate: updateDevice, isPending: loading } = useMutation({
mutationFn: async () => {
await formInst.value?.validate().catch(() => {
window.$message.error('表单验证失败');
return;
});
abortController.value.abort();
abortController.value = new AbortController();
const stationCode = station.value.code;
const signal = abortController.value.signal;
await updateKeyboardApi(localDevice.value, { stationCode, signal });
const result = await detailKeyboardApi(`${localDevice.value.id}`, { stationCode, signal });
return result;
},
onSuccess: (newDevice) => {
localDevice.value = { ...newDevice };
deviceStore.patchDevice(station.value.code, { ...newDevice });
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
onBeforeUnmount(() => {
validatorAbortController.value.abort();
abortController.value.abort();
});
</script>
<template>
<NCard hoverable size="small">
<template #default>
<NForm size="small" ref="formInst" :model="localDevice" :rules="formRules">
<NGrid>
<NFormItemGi span="8" label-placement="left" label="ICMP启用">
<NSwitch :value="destr(localDevice.icmpEnabled)" @update:value="(enabled: boolean) => (localDevice.icmpEnabled = enabled)" />
</NFormItemGi>
<NFormItemGi span="8" label-placement="left" label="SNMP启用">
<NSwitch :value="destr(localDevice.snmpEnabled)" @update:value="(enabled: boolean) => (localDevice.snmpEnabled = enabled)" />
</NFormItemGi>
</NGrid>
<NFormItem label-placement="left" label="设备ID" path="deviceId">
<NInput v-model:value="localDevice.deviceId" :disabled="!canEditDeviceId" />
</NFormItem>
<NFormItem label-placement="left" label="设备名称">
<NInput v-model:value="localDevice.name" />
</NFormItem>
<NFormItem label-placement="left" label="设备厂商">
<NInput v-model:value="localDevice.manufacturer" />
</NFormItem>
<NFormItem label-placement="left" label="型号">
<NInput v-model:value="localDevice.model" />
</NFormItem>
<NFormItem label-placement="left" label="设备描述">
<NInput v-model:value="localDevice.description" />
</NFormItem>
<NFormItem label-placement="left" label="上游设备">
<NInput v-model:value="localDevice.linkDescription" />
</NFormItem>
</NForm>
</template>
<template #action>
<NFlex justify="end">
<NButton secondary size="small" :loading="loading" @click="() => updateDevice()">更新</NButton>
</NFlex>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,6 @@
import NvrCard from './nvr-card.vue';
import NvrCurrentDiag from './nvr-current-diag.vue';
import NvrHistoryDiag from './nvr-history-diag.vue';
import NvrUpdate from './nvr-update.vue';
export { NvrCard, NvrCurrentDiag, NvrHistoryDiag, NvrUpdate };

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import type { NdmNvrResultVO, Station } from '@/apis';
import { DeviceRawCard, NvrCurrentDiag, NvrHistoryDiag, NvrUpdate } from '@/components';
import { useSettingStore } from '@/stores';
import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, ref, toRefs, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const props = defineProps<{
ndmDevice: NdmNvrResultVO;
station: Station;
}>();
const route = useRoute();
const router = useRouter();
const settingStore = useSettingStore();
const { debugModeEnabled } = storeToRefs(settingStore);
const { ndmDevice, station } = toRefs(props);
const showPageHeader = computed(() => {
return !!route.query['from'];
});
const onBack = () => {
router.push({ path: `${route.query['from']}` });
};
const activeTabName = ref('当前诊断');
const onTabChange = (name: string) => {
activeTabName.value = name;
};
watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => {
if (newDevice.id !== oldDevice.id || !enabled) {
activeTabName.value = '当前诊断';
}
});
</script>
<template>
<NCard hoverable style="height: 100%" :header-style="{ padding: '12px' }" :content-style="{ height: '100%', padding: '0', overflow: 'hidden' }">
<template #header>
<NPageHeader v-if="showPageHeader" @back="onBack" />
<NTabs :value="activeTabName" @update:value="onTabChange">
<NTab name="当前诊断">当前诊断</NTab>
<NTab name="历史诊断">历史诊断</NTab>
<NTab name="修改设备">修改设备</NTab>
<NTab v-if="debugModeEnabled" name="原始数据">原始数据</NTab>
</NTabs>
</template>
<template #default>
<NScrollbar x-scrollable :content-style="{ padding: '0 12px 12px 12px' }">
<template v-if="activeTabName === '当前诊断'">
<NvrCurrentDiag :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '历史诊断'">
<NvrHistoryDiag :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '修改设备'">
<NvrUpdate :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '原始数据'">
<DeviceRawCard :ndm-device="ndmDevice" />
</template>
</NScrollbar>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import type { NdmNvrDiagInfo, NdmNvrResultVO, Station } from '@/apis';
import { DeviceCommonCard, DeviceHardwareCard, DeviceHeaderCard, NvrDiskCard, NvrRecordCard } from '@/components';
import { isNvrCluster } from '@/helpers';
import destr from 'destr';
import { NFlex } from 'naive-ui';
import { computed, toRefs } from 'vue';
const props = defineProps<{
ndmDevice: NdmNvrResultVO;
station: Station;
}>();
const { ndmDevice, station } = toRefs(props);
const lastDiagInfo = computed(() => {
const result = destr<any>(ndmDevice.value.lastDiagInfo);
if (!result) return null;
if (typeof result !== 'object') return null;
return result as NdmNvrDiagInfo;
});
const commonInfo = computed(() => {
const { stCommonInfo } = lastDiagInfo.value ?? {};
if (!stCommonInfo) return undefined;
const { 设备ID, 软件版本, 生产厂商, 设备别名, 设备型号, 硬件版本 } = stCommonInfo;
return {
设备ID: 设备ID ?? '-',
软件版本: 软件版本 ?? '-',
生产厂商: 生产厂商 ?? '-',
设备别名: 设备别名 ?? '-',
设备型号: 设备型号 ?? '-',
硬件版本: 硬件版本 ?? '-',
};
});
const cpuUsage = computed(() => lastDiagInfo.value?.stCommonInfo?.CPU使用率);
const memUsage = computed(() => lastDiagInfo.value?.stCommonInfo?.内存使用率);
const diskHealth = computed(() => lastDiagInfo.value?.info?.diskHealth);
const diskArray = computed(() => lastDiagInfo.value?.info?.groupInfoList);
</script>
<template>
<NFlex vertical>
<DeviceHeaderCard :ndm-device="ndmDevice" :station="station" />
<DeviceCommonCard :common-info="commonInfo" />
<DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" />
<NvrDiskCard :disk-health="diskHealth" :disk-array="diskArray" />
<NvrRecordCard v-if="isNvrCluster(ndmDevice)" :ndm-device="ndmDevice" :station="station" />
</NFlex>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import type { NdmNvrResultVO, Station } from '@/apis';
import {
DeviceAlarmHistoryCard,
DeviceIcmpHistoryCard,
DeviceUsageHistoryCard,
HistoryDiagFilterCard,
NvrDiskHistoryCard,
type DeviceAlarmHistoryCardProps,
type DeviceIcmpHistoryCardProps,
type DeviceUsageHistoryCardProps,
type NvrDiskHistoryCardProps,
} from '@/components';
import { isNvrCluster } from '@/helpers';
import dayjs from 'dayjs';
import { NFlex, type SelectOption } from 'naive-ui';
import { computed, onMounted, ref, toRefs, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmNvrResultVO;
station: Station;
}>();
const { ndmDevice, station } = toRefs(props);
const historyDiagOptions = computed<SelectOption[]>(() => {
const options: SelectOption[] = [
{ label: '设备状态', value: 'icmp' },
{ label: '设备告警', value: 'alarm' },
];
if (isNvrCluster(ndmDevice.value)) return options;
return [...options, { label: '硬件占用', value: 'usage' }, { label: '磁盘健康', value: 'disk' }];
});
const getWeekRange = (): [number, number] => {
const now = dayjs();
const todayEnd = now.endOf('date');
const weekAgo = now.subtract(1, 'week').startOf('date');
return [weekAgo.valueOf(), todayEnd.valueOf()];
};
const range = ref<[number, number]>(getWeekRange());
const selected = ref([...historyDiagOptions.value.map((option) => `${option.value}`)]);
const loading = ref<boolean>(false);
const icmpLoading = ref<boolean>(false);
const alarmLoading = ref<boolean>(false);
const diskLoading = ref<boolean>(false);
watch([icmpLoading, alarmLoading, diskLoading], (loadings) => {
loading.value = loadings.some((loading) => loading);
});
const icmpHistoryQueryFn = ref<() => void>();
const onExposeIcmpHistoryQueryFn: DeviceIcmpHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
icmpHistoryQueryFn.value = queryFn;
};
const alarmHistoryQueryFn = ref<() => void>();
const onExposeAlarmHistoryQueryFn: DeviceAlarmHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
alarmHistoryQueryFn.value = queryFn;
};
const usageHistoryQueryFn = ref<() => void>();
const onExposeUsageHistoryQueryFn: DeviceUsageHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
usageHistoryQueryFn.value = queryFn;
};
const diskHistoryQueryFn = ref<() => void>();
const onExposeDiskHistoryQueryFn: NvrDiskHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
diskHistoryQueryFn.value = queryFn;
};
const queryData = () => {
if (selected.value.includes('icmp')) icmpHistoryQueryFn.value?.();
if (selected.value.includes('alarm')) alarmHistoryQueryFn.value?.();
if (selected.value.includes('usage')) usageHistoryQueryFn.value?.();
if (selected.value.includes('disk')) diskHistoryQueryFn.value?.();
};
const onQuery = () => {
queryData();
};
onMounted(() => {
queryData();
});
</script>
<template>
<NFlex vertical>
<HistoryDiagFilterCard :options="historyDiagOptions" v-model:loading="loading" v-model:range="range" v-model:selected="selected" @query="onQuery" />
<DeviceIcmpHistoryCard
v-if="selected.includes('icmp')"
:ndm-device="ndmDevice"
:station="station"
v-model:range="range"
v-model:loading="icmpLoading"
@expose-query-fn="onExposeIcmpHistoryQueryFn"
/>
<DeviceAlarmHistoryCard
v-if="selected.includes('alarm')"
:ndm-device="ndmDevice"
:station="station"
v-model:range="range"
v-model:loading="alarmLoading"
@expose-query-fn="onExposeAlarmHistoryQueryFn"
/>
<DeviceUsageHistoryCard
v-if="selected.includes('usage')"
:ndm-device="ndmDevice"
:station="station"
:cpu-usage-field="'stCommonInfo.CPU使用率'"
:mem-usage-field="'stCommonInfo.内存使用率'"
v-model:range="range"
v-model:loading="alarmLoading"
@expose-query-fn="onExposeUsageHistoryQueryFn"
/>
<NvrDiskHistoryCard v-if="selected.includes('disk')" :ndm-device="ndmDevice" :station="station" v-model:range="range" v-model:loading="diskLoading" @expose-query-fn="onExposeDiskHistoryQueryFn" />
</NFlex>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
import { detailNvrApi, icmpEntityByDeviceId, updateNvrApi, type NdmNvrResultVO, type NdmNvrUpdateVO, type Station } from '@/apis';
import { useDeviceStore } from '@/stores';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import destr from 'destr';
import { isString } from 'es-toolkit';
import { NButton, NCard, NFlex, NForm, NFormItem, NFormItemGi, NGrid, NInput, NSwitch, type FormInst, type FormRules } from 'naive-ui';
import { computed, onBeforeUnmount, ref, toRefs, useTemplateRef, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmNvrResultVO;
station: Station;
}>();
const deviceStore = useDeviceStore();
const { ndmDevice, station } = toRefs(props);
const localDevice = ref<NdmNvrUpdateVO>({ ...ndmDevice.value });
watch(ndmDevice, (newDevice) => {
localDevice.value = { ...newDevice };
});
const canEditDeviceId = computed(() => {
const { deviceId } = ndmDevice.value;
if (!isString(deviceId)) return true;
if (deviceId.length === 0) return true;
return false;
});
const validatorAbortController = ref<AbortController>(new AbortController());
const abortController = ref<AbortController>(new AbortController());
const formInst = useTemplateRef<FormInst>('formInst');
const formRules: FormRules = {
deviceId: {
trigger: ['input'],
asyncValidator: async (rule, value: string) => {
await validateDeviceIdDuplicated({ deviceId: value }).catch((error) => {
if (isCancel(error)) return;
throw error;
});
},
},
};
const { mutateAsync: validateDeviceIdDuplicated } = useMutation({
mutationFn: async (params: { deviceId?: string }) => {
const { deviceId } = params;
if (!deviceId) throw new Error('请输入设备ID');
const deviceIdPattern = /^\d{4}05\d{4}$/;
if (!deviceIdPattern.test(deviceId)) throw new Error('设备ID不符合规范');
validatorAbortController.value.abort();
validatorAbortController.value = new AbortController();
const icmpEntities = await icmpEntityByDeviceId(deviceId, {
stationCode: station.value.code,
signal: validatorAbortController.value.signal,
});
if (icmpEntities.length > 0) throw new Error('该设备ID已存在');
},
});
const { mutate: updateDevice, isPending } = useMutation({
mutationFn: async () => {
await formInst.value?.validate().catch(() => {
window.$message.error('表单验证失败');
return;
});
abortController.value.abort();
abortController.value = new AbortController();
const stationCode = station.value.code;
const signal = abortController.value.signal;
await updateNvrApi(localDevice.value, { stationCode, signal });
const result = await detailNvrApi(`${localDevice.value.id}`, { stationCode, signal });
return result;
},
onSuccess: (newDevice) => {
localDevice.value = { ...newDevice };
deviceStore.patchDevice(station.value.code, { ...newDevice });
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
onBeforeUnmount(() => {
validatorAbortController.value.abort();
abortController.value.abort();
});
</script>
<template>
<NCard hoverable size="small">
<template #default>
<NForm size="small" ref="formInst" :model="localDevice" :rules="formRules">
<NGrid>
<NFormItemGi span="8" label-placement="left" label="ICMP启用">
<NSwitch :value="destr(localDevice.icmpEnabled)" @update:value="(enabled: boolean) => (localDevice.icmpEnabled = enabled)" />
</NFormItemGi>
<NFormItemGi span="8" label-placement="left" label="SNMP启用">
<NSwitch :value="destr(localDevice.snmpEnabled)" @update:value="(enabled: boolean) => (localDevice.snmpEnabled = enabled)" />
</NFormItemGi>
</NGrid>
<NFormItem label-placement="left" label="设备ID" path="deviceId">
<NInput v-model:value="localDevice.deviceId" :disabled="!canEditDeviceId" />
</NFormItem>
<NFormItem label-placement="left" label="设备名称">
<NInput v-model:value="localDevice.name" />
</NFormItem>
<NFormItem label-placement="left" label="设备厂商">
<NInput v-model:value="localDevice.manufacturer" />
</NFormItem>
<NFormItem label-placement="left" label="型号">
<NInput v-model:value="localDevice.model" />
</NFormItem>
<NFormItem label-placement="left" label="管理URL">
<NInput v-model:value="localDevice.manageUrl" />
</NFormItem>
<NFormItem label-placement="left" label="管理用户名">
<NInput v-model:value="localDevice.manageUsername" />
</NFormItem>
<NFormItem label-placement="left" label="管理密码">
<NInput v-model:value="localDevice.managePassword" />
</NFormItem>
<NFormItem label-placement="left" label="团体字符串">
<NInput v-model:value="localDevice.community" />
</NFormItem>
<NFormItem label-placement="left" label="设备描述">
<NInput v-model:value="localDevice.description" />
</NFormItem>
<NFormItem label-placement="left" label="上游设备">
<NInput v-model:value="localDevice.linkDescription" />
</NFormItem>
</NForm>
</template>
<template #action>
<NFlex justify="end">
<NButton secondary size="small" :loading="isPending" @click="() => updateDevice()">更新</NButton>
</NFlex>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,6 @@
import SecurityBoxCard from './security-box-card.vue';
import SecurityBoxCurrentDiag from './security-box-current-diag.vue';
import SecurityBoxHistoryDiag from './security-box-history-diag.vue';
import SecurityBoxUpdate from './security-box-update.vue';
export { SecurityBoxCard, SecurityBoxCurrentDiag, SecurityBoxHistoryDiag, SecurityBoxUpdate };

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import type { NdmSecurityBoxResultVO, Station } from '@/apis';
import { DeviceRawCard, SecurityBoxCurrentDiag, SecurityBoxHistoryDiag, SecurityBoxUpdate } from '@/components';
import { useSettingStore } from '@/stores';
import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, ref, toRefs, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const props = defineProps<{
ndmDevice: NdmSecurityBoxResultVO;
station: Station;
}>();
const route = useRoute();
const router = useRouter();
const settingStore = useSettingStore();
const { debugModeEnabled } = storeToRefs(settingStore);
const { ndmDevice, station } = toRefs(props);
const showPageHeader = computed(() => {
return !!route.query['from'];
});
const onBack = () => {
router.push({ path: `${route.query['from']}` });
};
const activeTabName = ref('当前诊断');
const onTabChange = (name: string) => {
activeTabName.value = name;
};
watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => {
if (newDevice.id !== oldDevice.id || !enabled) {
activeTabName.value = '当前诊断';
}
});
</script>
<template>
<NCard hoverable style="height: 100%" :header-style="{ padding: '12px' }" :content-style="{ height: '100%', padding: '0', overflow: 'hidden' }">
<template #header>
<NPageHeader v-if="showPageHeader" @back="onBack" />
<NTabs :value="activeTabName" @update:value="onTabChange">
<NTab name="当前诊断">当前诊断</NTab>
<NTab name="历史诊断">历史诊断</NTab>
<NTab name="修改设备">修改设备</NTab>
<NTab v-if="debugModeEnabled" name="原始数据">原始数据</NTab>
</NTabs>
</template>
<template #default>
<NScrollbar x-scrollable :content-style="{ padding: '0 12px 12px 12px' }">
<template v-if="activeTabName === '当前诊断'">
<SecurityBoxCurrentDiag :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '历史诊断'">
<SecurityBoxHistoryDiag :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '修改设备'">
<SecurityBoxUpdate :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '原始数据'">
<DeviceRawCard :ndm-device="ndmDevice" />
</template>
</NScrollbar>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import type { NdmSecurityBoxDiagInfo, NdmSecurityBoxResultVO, Station } from '@/apis';
import { DeviceCommonCard, DeviceHardwareCard, DeviceHeaderCard, SecurityBoxCircuitCard, SecurityBoxEnvCard } from '@/components';
import destr from 'destr';
import { NFlex } from 'naive-ui';
import { computed, toRefs } from 'vue';
const props = defineProps<{
ndmDevice: NdmSecurityBoxResultVO;
station: Station;
}>();
const { ndmDevice, station } = toRefs(props);
const lastDiagInfo = computed(() => {
const result = destr<any>(ndmDevice.value.lastDiagInfo);
if (!result) return null;
if (typeof result !== 'object') return null;
return result as NdmSecurityBoxDiagInfo;
});
const commonInfo = computed(() => {
const { stCommonInfo } = lastDiagInfo.value ?? {};
const { 设备ID, 软件版本, 设备厂商, 设备别名, 设备型号, 硬件版本 } = stCommonInfo ?? {};
return {
设备ID: 设备ID ?? '',
软件版本: 软件版本 ?? '',
设备厂商: 设备厂商 ?? '',
设备别名: 设备别名 ?? '',
设备型号: 设备型号 ?? '',
硬件版本: 硬件版本 ?? '',
};
});
const cpuUsage = computed(() => lastDiagInfo.value?.stCommonInfo?.CPU使用率);
const memUsage = computed(() => lastDiagInfo.value?.stCommonInfo?.内存使用率);
const fanSpeeds = computed(() => lastDiagInfo.value?.info?.at(0)?.fanSpeeds);
const temperature = computed(() => lastDiagInfo.value?.info?.at(0)?.temperature);
const humidity = computed(() => lastDiagInfo.value?.info?.at(0)?.humidity);
const switches = computed(() => lastDiagInfo.value?.info?.at(0)?.switches);
const circuits = computed(() => lastDiagInfo.value?.info?.at(0)?.circuits);
</script>
<template>
<NFlex vertical>
<DeviceHeaderCard :ndm-device="ndmDevice" :station="station" />
<DeviceCommonCard :common-info="commonInfo" />
<DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" />
<SecurityBoxEnvCard :fan-speeds="fanSpeeds" :temperature="temperature" :humidity="humidity" :switches="switches" />
<SecurityBoxCircuitCard :circuits="circuits" :ndm-device="ndmDevice" :station="station" />
</NFlex>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import type { NdmSecurityBoxResultVO, Station } from '@/apis';
import {
DeviceAlarmHistoryCard,
DeviceIcmpHistoryCard,
DeviceUsageHistoryCard,
HistoryDiagFilterCard,
SecurityBoxRuntimeHistoryCard,
type DeviceAlarmHistoryCardProps,
type DeviceIcmpHistoryCardProps,
type DeviceUsageHistoryCardProps,
type SecurityBoxRuntimeHistoryCardProps,
} from '@/components';
import dayjs from 'dayjs';
import { NFlex, type SelectOption } from 'naive-ui';
import { onMounted, ref, toRefs, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmSecurityBoxResultVO;
station: Station;
}>();
const { ndmDevice, station } = toRefs(props);
const historyDiagOptions: SelectOption[] = [
{ label: '设备状态', value: 'icmp' },
{ label: '设备告警', value: 'alarm' },
{ label: '硬件占用', value: 'usage' },
{ label: '运行情况', value: 'runtime' },
];
const getWeekRange = (): [number, number] => {
const now = dayjs();
const todayEnd = now.endOf('date');
const weekAgo = now.subtract(1, 'week').startOf('date');
return [weekAgo.valueOf(), todayEnd.valueOf()];
};
const range = ref<[number, number]>(getWeekRange());
const selected = ref<string[]>([...historyDiagOptions.map((option) => `${option.value}`)]);
const loading = ref<boolean>(false);
const icmpLoading = ref<boolean>(false);
const alarmLoading = ref<boolean>(false);
const usageLoading = ref<boolean>(false);
const runtimeLoading = ref<boolean>(false);
watch([icmpLoading, alarmLoading, usageLoading, runtimeLoading], (loadings) => {
loading.value = loadings.some((loading) => loading);
});
const icmpHistoryQueryFn = ref<() => void>();
const onExposeIcmpHistoryQueryFn: DeviceIcmpHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
icmpHistoryQueryFn.value = queryFn;
};
const alarmHistoryQueryFn = ref<() => void>();
const onExposeAlarmHistoryQueryFn: DeviceAlarmHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
alarmHistoryQueryFn.value = queryFn;
};
const usageHistoryQueryFn = ref<() => void>();
const onExposeUsageHistoryQueryFn: DeviceUsageHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
usageHistoryQueryFn.value = queryFn;
};
const runtimeHistoryQueryFn = ref<() => void>();
const onExposeRuntimeHistoryQueryFn: SecurityBoxRuntimeHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
runtimeHistoryQueryFn.value = queryFn;
};
const queryData = () => {
if (selected.value.includes('icmp')) icmpHistoryQueryFn.value?.();
if (selected.value.includes('alarm')) alarmHistoryQueryFn.value?.();
if (selected.value.includes('usage')) usageHistoryQueryFn.value?.();
if (selected.value.includes('runtime')) runtimeHistoryQueryFn.value?.();
};
const onQuery = () => {
queryData();
};
onMounted(() => {
queryData();
});
</script>
<template>
<NFlex vertical>
<HistoryDiagFilterCard :options="historyDiagOptions" v-model:loading="loading" v-model:range="range" v-model:selected="selected" @query="onQuery" />
<DeviceIcmpHistoryCard
v-if="selected.includes('icmp')"
:ndm-device="ndmDevice"
:station="station"
v-model:range="range"
v-model:loading="icmpLoading"
@expose-query-fn="onExposeIcmpHistoryQueryFn"
/>
<DeviceAlarmHistoryCard
v-if="selected.includes('alarm')"
:ndm-device="ndmDevice"
:station="station"
v-model:range="range"
v-model:loading="alarmLoading"
@expose-query-fn="onExposeAlarmHistoryQueryFn"
/>
<DeviceUsageHistoryCard
v-if="selected.includes('usage')"
:ndm-device="ndmDevice"
:station="station"
:cpu-usage-field="'stCommonInfo.CPU使用率'"
:mem-usage-field="'stCommonInfo.内存使用率'"
v-model:range="range"
v-model:loading="usageLoading"
@expose-query-fn="onExposeUsageHistoryQueryFn"
/>
<SecurityBoxRuntimeHistoryCard
v-if="selected.includes('runtime')"
:ndm-device="ndmDevice"
:station="station"
v-model:range="range"
v-model:loading="runtimeLoading"
@expose-query-fn="onExposeRuntimeHistoryQueryFn"
/>
</NFlex>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,145 @@
<script setup lang="ts">
import { detailSecurityBoxApi, icmpEntityByDeviceId, updateSecurityBoxApi, type NdmSecurityBoxResultVO, type NdmSecurityBoxUpdateVO, type Station } from '@/apis';
import { useDeviceStore } from '@/stores';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import destr from 'destr';
import { isString } from 'es-toolkit';
import { NButton, NCard, NFlex, NForm, NFormItem, NFormItemGi, NGrid, NInput, NSwitch, type FormInst, type FormRules } from 'naive-ui';
import { computed, onBeforeUnmount, ref, toRefs, useTemplateRef, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmSecurityBoxResultVO;
station: Station;
}>();
const deviceStore = useDeviceStore();
const { ndmDevice, station } = toRefs(props);
const localDevice = ref<NdmSecurityBoxUpdateVO>({ ...ndmDevice.value });
watch(ndmDevice, (newDevice) => {
localDevice.value = { ...newDevice };
});
const canEditDeviceId = computed(() => {
const { deviceId } = ndmDevice.value;
if (!isString(deviceId)) return true;
if (deviceId.length === 0) return true;
return false;
});
const validatorAbortController = ref<AbortController>(new AbortController());
const abortController = ref<AbortController>(new AbortController());
const formInst = useTemplateRef<FormInst>('formInst');
const formRules: FormRules = {
deviceId: {
trigger: ['input'],
asyncValidator: async (rule, value: string) => {
await validateDeviceIdDuplicated({ deviceId: value }).catch((error) => {
if (isCancel(error)) return;
throw error;
});
},
},
};
const { mutateAsync: validateDeviceIdDuplicated } = useMutation({
mutationFn: async (params: { deviceId?: string }) => {
const { deviceId } = params;
if (!deviceId) throw new Error('请输入设备ID');
const deviceIdPattern = /^\d{4}03\d{4}$/;
if (!deviceIdPattern.test(deviceId)) throw new Error('设备ID不符合规范');
validatorAbortController.value.abort();
validatorAbortController.value = new AbortController();
const icmpEntities = await icmpEntityByDeviceId(deviceId, {
stationCode: station.value.code,
signal: validatorAbortController.value.signal,
});
if (icmpEntities.length > 0) throw new Error('该设备ID已存在');
},
});
const { mutate: updateDevice, isPending } = useMutation({
mutationFn: async () => {
await formInst.value?.validate().catch(() => {
window.$message.error('表单验证失败');
return;
});
abortController.value.abort();
abortController.value = new AbortController();
const stationCode = station.value.code;
const signal = abortController.value.signal;
await updateSecurityBoxApi(localDevice.value, { stationCode, signal });
const result = await detailSecurityBoxApi(`${localDevice.value.id}`, { stationCode, signal });
return result;
},
onSuccess: (newDevice) => {
localDevice.value = { ...newDevice };
deviceStore.patchDevice(station.value.code, { ...newDevice });
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
onBeforeUnmount(() => {
validatorAbortController.value.abort();
abortController.value.abort();
});
</script>
<template>
<NCard hoverable size="small">
<template #default>
<NForm size="small" ref="formInst" :model="localDevice" :rules="formRules">
<NGrid>
<NFormItemGi span="8" label-placement="left" label="ICMP启用">
<NSwitch :value="destr(localDevice.icmpEnabled)" @update:value="(enabled: boolean) => (localDevice.icmpEnabled = enabled)" />
</NFormItemGi>
<NFormItemGi span="8" label-placement="left" label="SNMP启用">
<NSwitch :value="destr(localDevice.snmpEnabled)" @update:value="(enabled: boolean) => (localDevice.snmpEnabled = enabled)" />
</NFormItemGi>
</NGrid>
<NFormItem label-placement="left" label="设备ID" path="deviceId">
<NInput v-model:value="localDevice.deviceId" :disabled="!canEditDeviceId" />
</NFormItem>
<NFormItem label-placement="left" label="设备名称">
<NInput v-model:value="localDevice.name" />
</NFormItem>
<NFormItem label-placement="left" label="设备厂商">
<NInput v-model:value="localDevice.manufacturer" />
</NFormItem>
<NFormItem label-placement="left" label="型号">
<NInput v-model:value="localDevice.model" />
</NFormItem>
<NFormItem label-placement="left" label="团体字符串">
<NInput v-model:value="localDevice.community" />
</NFormItem>
<NFormItem label-placement="left" label="设备描述">
<NInput v-model:value="localDevice.description" />
</NFormItem>
<NFormItem label-placement="left" label="上游设备">
<NInput v-model:value="localDevice.linkDescription" />
</NFormItem>
</NForm>
</template>
<template #action>
<NFlex justify="end">
<NButton secondary size="small" :loading="isPending" @click="() => updateDevice()">更新</NButton>
</NFlex>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,6 @@
import ServerCard from './server-card.vue';
import ServerCurrentDiag from './server-current-diag.vue';
import ServerHistoryDiag from './server-history-diag.vue';
import ServerUpdate from './server-update.vue';
export { ServerCard, ServerCurrentDiag, ServerHistoryDiag, ServerUpdate };

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import type { NdmServerResultVO, Station } from '@/apis';
import { DeviceRawCard, ServerCurrentDiag, ServerHistoryDiag, ServerUpdate } from '@/components';
import { useSettingStore } from '@/stores';
import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, ref, toRefs, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const props = defineProps<{
ndmDevice: NdmServerResultVO;
station: Station;
}>();
const route = useRoute();
const router = useRouter();
const settingStore = useSettingStore();
const { debugModeEnabled } = storeToRefs(settingStore);
const { ndmDevice, station } = toRefs(props);
const showPageHeader = computed(() => {
return !!route.query['from'];
});
const onBack = () => {
router.push({ path: `${route.query['from']}` });
};
const activeTabName = ref('当前诊断');
const onTabChange = (name: string) => {
activeTabName.value = name;
};
watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => {
if (newDevice.id !== oldDevice.id || !enabled) {
activeTabName.value = '当前诊断';
}
});
</script>
<template>
<NCard hoverable style="height: 100%" :header-style="{ padding: '12px' }" :content-style="{ height: '100%', padding: '0', overflow: 'hidden' }">
<template #header>
<NPageHeader v-if="showPageHeader" @back="onBack" />
<NTabs :value="activeTabName" @update:value="onTabChange">
<NTab name="当前诊断">当前诊断</NTab>
<NTab name="历史诊断">历史诊断</NTab>
<NTab name="修改设备">修改设备</NTab>
<NTab v-if="debugModeEnabled" name="原始数据">原始数据</NTab>
</NTabs>
</template>
<template #default>
<NScrollbar x-scrollable :content-style="{ padding: '0 12px 12px 12px' }">
<template v-if="activeTabName === '当前诊断'">
<ServerCurrentDiag :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '历史诊断'">
<ServerHistoryDiag :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '修改设备'">
<ServerUpdate :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '原始数据'">
<DeviceRawCard :ndm-device="ndmDevice" />
</template>
</NScrollbar>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { NdmServerDiagInfo, NdmServerResultVO, Station } from '@/apis';
import { DeviceHardwareCard, DeviceHeaderCard } from '@/components';
import destr from 'destr';
import { NFlex } from 'naive-ui';
import { computed, toRefs } from 'vue';
const props = defineProps<{
ndmDevice: NdmServerResultVO;
station: Station;
}>();
const { ndmDevice, station } = toRefs(props);
const lastDiagInfo = computed(() => {
const result = destr<any>(ndmDevice.value.lastDiagInfo);
if (!result) return null;
if (typeof result !== 'object') return null;
return result as NdmServerDiagInfo;
});
const cpuUsage = computed(() => lastDiagInfo.value?.commInfo?.CPU使用率);
const memUsage = computed(() => lastDiagInfo.value?.commInfo?.内存使用率);
const diskUsage = computed(() => lastDiagInfo.value?.commInfo?.磁盘使用率);
const runningTime = computed(() => lastDiagInfo.value?.commInfo?.系统运行时间);
</script>
<template>
<NFlex vertical>
<DeviceHeaderCard :ndm-device="ndmDevice" :station="station" />
<DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" :disk-usage="diskUsage" :running-time="runningTime" />
</NFlex>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import type { NdmServerResultVO, Station } from '@/apis';
import {
DeviceAlarmHistoryCard,
DeviceIcmpHistoryCard,
DeviceUsageHistoryCard,
HistoryDiagFilterCard,
type DeviceAlarmHistoryCardProps,
type DeviceIcmpHistoryCardProps,
type DeviceUsageHistoryCardProps,
} from '@/components';
import dayjs from 'dayjs';
import { NFlex, type SelectOption } from 'naive-ui';
import { onMounted, ref, toRefs, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmServerResultVO;
station: Station;
}>();
const { ndmDevice, station } = toRefs(props);
const historyDiagOptions: SelectOption[] = [
{ label: '设备状态', value: 'icmp' },
{ label: '设备告警', value: 'alarm' },
{ label: '硬件占用', value: 'usage' },
];
const getWeekRange = (): [number, number] => {
const now = dayjs();
const todayEnd = now.endOf('date');
const weekAgo = now.subtract(1, 'week').startOf('date');
return [weekAgo.valueOf(), todayEnd.valueOf()];
};
const range = ref<[number, number]>(getWeekRange());
const selected = ref<string[]>([...historyDiagOptions.map((option) => `${option.value}`)]);
const loading = ref<boolean>(false);
const icmpLoading = ref<boolean>(false);
const alarmLoading = ref<boolean>(false);
const usageLoading = ref<boolean>(false);
watch([icmpLoading, alarmLoading, usageLoading], (loadings) => {
loading.value = loadings.some((loading) => loading);
});
const icmpHistoryQueryFn = ref<() => void>();
const onExposeIcmpHistoryQueryFn: DeviceIcmpHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
icmpHistoryQueryFn.value = queryFn;
};
const alarmHistoryQueryFn = ref<() => void>();
const onExposeAlarmHistoryQueryFn: DeviceAlarmHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
alarmHistoryQueryFn.value = queryFn;
};
const usageHistoryQueryFn = ref<() => void>();
const onExposeUsageHistoryQueryFn: DeviceUsageHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
usageHistoryQueryFn.value = queryFn;
};
const queryData = () => {
if (selected.value.includes('icmp')) icmpHistoryQueryFn.value?.();
if (selected.value.includes('alarm')) alarmHistoryQueryFn.value?.();
if (selected.value.includes('usage')) usageHistoryQueryFn.value?.();
};
const onQuery = () => {
queryData();
};
onMounted(() => {
queryData();
});
</script>
<template>
<NFlex vertical>
<HistoryDiagFilterCard :options="historyDiagOptions" v-model:loading="loading" v-model:range="range" v-model:selected="selected" @query="onQuery" />
<DeviceIcmpHistoryCard
v-if="selected.includes('icmp')"
:ndm-device="ndmDevice"
:station="station"
v-model:range="range"
v-model:loading="icmpLoading"
@expose-query-fn="onExposeIcmpHistoryQueryFn"
/>
<DeviceAlarmHistoryCard
v-if="selected.includes('alarm')"
:ndm-device="ndmDevice"
:station="station"
v-model:range="range"
v-model:loading="alarmLoading"
@expose-query-fn="onExposeAlarmHistoryQueryFn"
/>
<DeviceUsageHistoryCard
v-if="selected.includes('usage')"
:ndm-device="ndmDevice"
:station="station"
:cpu-usage-field="'commInfo.CPU使用率'"
:mem-usage-field="'commInfo.内存使用率'"
:disk-usage-field="'commInfo.磁盘使用率'"
v-model:range="range"
v-model:loading="usageLoading"
@expose-query-fn="onExposeUsageHistoryQueryFn"
/>
</NFlex>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,164 @@
<script setup lang="ts">
import {
detailMediaServerApi,
detailVideoServerApi,
icmpEntityByDeviceId,
updateMediaServerApi,
updateVideoServerApi,
type NdmMediaServerUpdateVO,
type NdmServerResultVO,
type NdmServerUpdateVO,
type NdmVideoServerUpdateVO,
type Station,
} from '@/apis';
import { DEVICE_TYPE_LITERALS, tryGetDeviceType } from '@/enums';
import { useDeviceStore } from '@/stores';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import destr from 'destr';
import { isString } from 'es-toolkit';
import { NButton, NCard, NFlex, NForm, NFormItem, NFormItemGi, NGrid, NInput, NSwitch, type FormInst, type FormRules } from 'naive-ui';
import { computed, onBeforeUnmount, ref, toRefs, useTemplateRef, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmServerResultVO;
station: Station;
}>();
const deviceStore = useDeviceStore();
const { ndmDevice, station } = toRefs(props);
const localDevice = ref<NdmServerUpdateVO>({ ...ndmDevice.value });
watch(ndmDevice, (newDevice) => {
localDevice.value = { ...newDevice };
});
const canEditDeviceId = computed(() => {
const { deviceId } = ndmDevice.value;
if (!isString(deviceId)) return true;
if (deviceId.length === 0) return true;
return false;
});
const validatorAbortController = ref<AbortController>(new AbortController());
const abortController = ref<AbortController>(new AbortController());
const formInst = useTemplateRef<FormInst>('formInst');
const formRules: FormRules = {
deviceId: {
trigger: ['input'],
asyncValidator: async (rule, value: string) => {
await validateDeviceIdDuplicated({ deviceId: value }).catch((error) => {
if (isCancel(error)) return;
throw error;
});
},
},
};
const { mutateAsync: validateDeviceIdDuplicated } = useMutation({
mutationFn: async (params: { deviceId?: string }) => {
const { deviceId } = params;
if (!deviceId) throw new Error('请输入设备ID');
const deviceIdPattern = /^\d{4}(09|11)\d{4}$/;
if (!deviceIdPattern.test(deviceId)) throw new Error('设备ID不符合规范');
validatorAbortController.value.abort();
validatorAbortController.value = new AbortController();
const icmpEntities = await icmpEntityByDeviceId(deviceId, {
stationCode: station.value.code,
signal: validatorAbortController.value.signal,
});
if (icmpEntities.length > 0) throw new Error('该设备ID已存在');
},
});
const { mutate: updateDevice, isPending } = useMutation({
mutationFn: async () => {
await formInst.value?.validate().catch(() => {
window.$message.error('表单验证失败');
return;
});
abortController.value.abort();
abortController.value = new AbortController();
const deviceType = tryGetDeviceType(localDevice.value.deviceType);
const stationCode = station.value.code;
const signal = abortController.value.signal;
if (deviceType === DEVICE_TYPE_LITERALS.ndmMediaServer) {
await updateMediaServerApi(localDevice.value as NdmMediaServerUpdateVO, { stationCode, signal });
return await detailMediaServerApi(`${localDevice.value.id}`, { stationCode, signal });
} else if (deviceType === DEVICE_TYPE_LITERALS.ndmVideoServer) {
await updateVideoServerApi(`${localDevice.value.id}`, localDevice.value as NdmVideoServerUpdateVO, { stationCode, signal });
return await detailVideoServerApi(`${localDevice.value.id}`, { stationCode, signal });
} else {
throw new Error('不是服务器设备');
}
},
onSuccess: (newDevice) => {
localDevice.value = { ...newDevice };
deviceStore.patchDevice(station.value.code, { ...newDevice });
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
onBeforeUnmount(() => {
validatorAbortController.value.abort();
abortController.value.abort();
});
</script>
<template>
<NCard hoverable size="small">
<template #default>
<NForm size="small" ref="formInst" :model="localDevice" :rules="formRules">
<NGrid>
<NFormItemGi span="8" label-placement="left" label="ICMP启用">
<NSwitch :value="destr(localDevice.icmpEnabled)" @update:value="(enabled: boolean) => (localDevice.icmpEnabled = enabled)" />
</NFormItemGi>
<NFormItemGi span="8" label-placement="left" label="SNMP启用">
<NSwitch :value="destr(localDevice.snmpEnabled)" @update:value="(enabled: boolean) => (localDevice.snmpEnabled = enabled)" />
</NFormItemGi>
</NGrid>
<NFormItem label-placement="left" label="设备ID" path="deviceId">
<NInput v-model:value="localDevice.deviceId" :disabled="!canEditDeviceId" />
</NFormItem>
<NFormItem label-placement="left" label="设备名称">
<NInput v-model:value="localDevice.name" />
</NFormItem>
<NFormItem label-placement="left" label="设备厂商">
<NInput v-model:value="localDevice.manufacturer" />
</NFormItem>
<NFormItem label-placement="left" label="型号">
<NInput v-model:value="localDevice.model" />
</NFormItem>
<NFormItem label-placement="left" label="团体字符串">
<NInput v-model:value="localDevice.community" />
</NFormItem>
<NFormItem label-placement="left" label="设备描述">
<NInput v-model:value="localDevice.description" />
</NFormItem>
<NFormItem label-placement="left" label="上游设备">
<NInput v-model:value="localDevice.linkDescription" />
</NFormItem>
</NForm>
</template>
<template #action>
<NFlex justify="end">
<NButton secondary size="small" :loading="isPending" @click="() => updateDevice()">更新</NButton>
</NFlex>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,6 @@
import SwitchCard from './switch-card.vue';
import SwitchCurrentDiag from './switch-current-diag.vue';
import SwitchHistoryDiag from './switch-history-diag.vue';
import SwitchUpdate from './switch-update.vue';
export { SwitchCard, SwitchCurrentDiag, SwitchHistoryDiag, SwitchUpdate };

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import type { NdmSwitchResultVO, Station } from '@/apis';
import { DeviceRawCard, SwitchCurrentDiag, SwitchHistoryDiag, SwitchUpdate } from '@/components';
import { useSettingStore } from '@/stores';
import { NCard, NPageHeader, NScrollbar, NTab, NTabs } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, ref, toRefs, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const props = defineProps<{
ndmDevice: NdmSwitchResultVO;
station: Station;
}>();
const route = useRoute();
const router = useRouter();
const settingStore = useSettingStore();
const { debugModeEnabled } = storeToRefs(settingStore);
const { ndmDevice, station } = toRefs(props);
const showPageHeader = computed(() => {
return !!route.query['from'];
});
const onBack = () => {
router.push({ path: `${route.query['from']}` });
};
const activeTabName = ref('当前诊断');
const onTabChange = (name: string) => {
activeTabName.value = name;
};
watch([ndmDevice, debugModeEnabled], ([newDevice, enabled], [oldDevice]) => {
if (newDevice.id !== oldDevice.id || !enabled) {
activeTabName.value = '当前诊断';
}
});
</script>
<template>
<NCard hoverable style="height: 100%" :header-style="{ padding: '12px' }" :content-style="{ height: '100%', padding: '0', overflow: 'hidden' }">
<template #header>
<NPageHeader v-if="showPageHeader" @back="onBack" />
<NTabs :value="activeTabName" @update:value="onTabChange">
<NTab name="当前诊断">当前诊断</NTab>
<NTab name="历史诊断">历史诊断</NTab>
<NTab name="修改设备">修改设备</NTab>
<NTab v-if="debugModeEnabled" name="原始数据">原始数据</NTab>
</NTabs>
</template>
<template #default>
<NScrollbar x-scrollable :content-style="{ padding: '0 12px 12px 12px' }">
<template v-if="activeTabName === '当前诊断'">
<SwitchCurrentDiag :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '历史诊断'">
<SwitchHistoryDiag :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '修改设备'">
<SwitchUpdate :ndm-device="ndmDevice" :station="station" />
</template>
<template v-if="activeTabName === '原始数据'">
<DeviceRawCard :ndm-device="ndmDevice" />
</template>
</NScrollbar>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { NdmSwitchDiagInfo, NdmSwitchResultVO, Station } from '@/apis';
import { DeviceHardwareCard, DeviceHeaderCard, SwitchPortCard } from '@/components';
import destr from 'destr';
import { NFlex } from 'naive-ui';
import { computed, toRefs } from 'vue';
const props = defineProps<{
ndmDevice: NdmSwitchResultVO;
station: Station;
}>();
const { ndmDevice, station } = toRefs(props);
const lastDiagInfo = computed(() => {
const result = destr<any>(ndmDevice.value.lastDiagInfo);
if (!result) return null;
if (typeof result !== 'object') return null;
return result as NdmSwitchDiagInfo;
});
const cpuUsage = computed(() => lastDiagInfo.value?.cpuRatio);
const memUsage = computed(() => lastDiagInfo.value?.memoryRatio);
const ports = computed(() => lastDiagInfo.value?.info?.portInfoList);
</script>
<template>
<NFlex vertical>
<DeviceHeaderCard :ndm-device="ndmDevice" :station="station" />
<DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" />
<SwitchPortCard :ports="ports" />
</NFlex>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import type { NdmSwitchResultVO, Station } from '@/apis';
import {
DeviceAlarmHistoryCard,
DeviceIcmpHistoryCard,
DeviceUsageHistoryCard,
HistoryDiagFilterCard,
SwitchPortHistoryCard,
type DeviceAlarmHistoryCardProps,
type DeviceIcmpHistoryCardProps,
type DeviceUsageHistoryCardProps,
type SwitchPortHistoryCardProps,
} from '@/components';
import dayjs from 'dayjs';
import { NFlex, type SelectOption } from 'naive-ui';
import { onMounted, ref, toRefs, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmSwitchResultVO;
station: Station;
}>();
const { ndmDevice, station } = toRefs(props);
const historyDiagOptions: SelectOption[] = [
{ label: '设备状态', value: 'icmp' },
{ label: '设备告警', value: 'alarm' },
{ label: '硬件占用', value: 'usage' },
{ label: '端口速率', value: 'port' },
];
const getWeekRange = (): [number, number] => {
const now = dayjs();
const todayEnd = now.endOf('date');
const weekAgo = now.subtract(1, 'week').startOf('date');
return [weekAgo.valueOf(), todayEnd.valueOf()];
};
const range = ref<[number, number]>(getWeekRange());
const selected = ref<string[]>([...historyDiagOptions.map((option) => `${option.value}`)]);
const loading = ref<boolean>(false);
const icmpLoading = ref<boolean>(false);
const alarmLoading = ref<boolean>(false);
const usageLoading = ref<boolean>(false);
const portLoading = ref<boolean>(false);
watch([icmpLoading, alarmLoading, usageLoading, portLoading], (loadings) => {
loading.value = loadings.some((loading) => loading);
});
const icmpHistoryQueryFn = ref<() => void>();
const onExposeIcmpHistoryQueryFn: DeviceIcmpHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
icmpHistoryQueryFn.value = queryFn;
};
const alarmHistoryQueryFn = ref<() => void>();
const onExposeAlarmHistoryQueryFn: DeviceAlarmHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
alarmHistoryQueryFn.value = queryFn;
};
const usageHistoryQueryFn = ref<() => void>();
const onExposeUsageHistoryQueryFn: DeviceUsageHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
usageHistoryQueryFn.value = queryFn;
};
const portHistoryQueryFn = ref<() => void>();
const onExposePortHistoryQueryFn: SwitchPortHistoryCardProps['onExposeQueryFn'] = (queryFn) => {
portHistoryQueryFn.value = queryFn;
};
const queryData = () => {
if (selected.value.includes('icmp')) icmpHistoryQueryFn.value?.();
if (selected.value.includes('alarm')) alarmHistoryQueryFn.value?.();
if (selected.value.includes('usage')) usageHistoryQueryFn.value?.();
if (selected.value.includes('port')) portHistoryQueryFn.value?.();
};
const onQuery = () => {
queryData();
};
onMounted(() => {
queryData();
});
</script>
<template>
<NFlex vertical>
<HistoryDiagFilterCard :options="historyDiagOptions" v-model:loading="loading" v-model:range="range" v-model:selected="selected" @query="onQuery" />
<DeviceIcmpHistoryCard
v-if="selected.includes('icmp')"
:ndm-device="ndmDevice"
:station="station"
v-model:range="range"
v-model:loading="icmpLoading"
@expose-query-fn="onExposeIcmpHistoryQueryFn"
/>
<DeviceAlarmHistoryCard
v-if="selected.includes('alarm')"
:ndm-device="ndmDevice"
:station="station"
v-model:range="range"
v-model:loading="alarmLoading"
@expose-query-fn="onExposeAlarmHistoryQueryFn"
/>
<DeviceUsageHistoryCard
v-if="selected.includes('usage')"
:ndm-device="ndmDevice"
:station="station"
:cpu-usage-field="'cpuRatio'"
:mem-usage-field="'memoryRatio'"
v-model:range="range"
v-model:loading="usageLoading"
@expose-query-fn="onExposeUsageHistoryQueryFn"
/>
<SwitchPortHistoryCard
v-if="selected.includes('port')"
:ndm-device="ndmDevice"
:station="station"
v-model:range="range"
v-model:loading="portLoading"
@expose-query-fn="onExposePortHistoryQueryFn"
/>
</NFlex>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
import { detailSwitchApi, icmpEntityByDeviceId, updateSwitchApi, type NdmSwitchResultVO, type NdmSwitchUpdateVO, type Station } from '@/apis';
import { useDeviceStore } from '@/stores';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import destr from 'destr';
import { isString } from 'es-toolkit';
import { NButton, NCard, NFlex, NForm, NFormItem, NFormItemGi, NGrid, NInput, NSwitch, type FormInst, type FormRules } from 'naive-ui';
import { computed, onBeforeUnmount, ref, toRefs, useTemplateRef, watch } from 'vue';
const props = defineProps<{
ndmDevice: NdmSwitchResultVO;
station: Station;
}>();
const deviceStore = useDeviceStore();
const { ndmDevice, station } = toRefs(props);
const localDevice = ref<NdmSwitchUpdateVO>({ ...ndmDevice.value });
watch(ndmDevice, (newDevice) => {
localDevice.value = { ...newDevice };
});
const canEditDeviceId = computed(() => {
const { deviceId } = ndmDevice.value;
if (!isString(deviceId)) return true;
if (deviceId.length === 0) return true;
return false;
});
const validatorAbortController = ref<AbortController>(new AbortController());
const abortController = ref<AbortController>(new AbortController());
const formInst = useTemplateRef<FormInst>('formInst');
const formRules: FormRules = {
deviceId: {
trigger: ['input'],
asyncValidator: async (rule, value: string) => {
await validateDeviceIdDuplicated({ deviceId: value }).catch((error) => {
if (isCancel(error)) return;
throw error;
});
},
},
};
const { mutateAsync: validateDeviceIdDuplicated } = useMutation({
mutationFn: async (params: { deviceId?: string }) => {
const { deviceId } = params;
if (!deviceId) throw new Error('请输入设备ID');
const deviceIdPattern = /^\d{4}04\d{4}$/;
if (!deviceIdPattern.test(deviceId)) throw new Error('设备ID不符合规范');
validatorAbortController.value.abort();
validatorAbortController.value = new AbortController();
const icmpEntities = await icmpEntityByDeviceId(deviceId, {
stationCode: station.value.code,
signal: validatorAbortController.value.signal,
});
if (icmpEntities.length > 0) throw new Error('该设备ID已存在');
},
});
const { mutate: updateDevice, isPending: loading } = useMutation({
mutationFn: async () => {
await formInst.value?.validate().catch(() => {
window.$message.error('表单验证失败');
return;
});
abortController.value.abort();
abortController.value = new AbortController();
const stationCode = station.value.code;
const signal = abortController.value.signal;
await updateSwitchApi(localDevice.value, { stationCode, signal });
const result = await detailSwitchApi(`${localDevice.value.id}`, { stationCode, signal });
return result;
},
onSuccess: (newDevice) => {
localDevice.value = { ...newDevice };
deviceStore.patchDevice(station.value.code, { ...newDevice });
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
onBeforeUnmount(() => {
validatorAbortController.value.abort();
abortController.value.abort();
});
</script>
<template>
<NCard hoverable size="small">
<template #default>
<NForm size="small" ref="formInst" :model="localDevice" :rules="formRules">
<NGrid>
<NFormItemGi span="8" label-placement="left" label="ICMP启用">
<NSwitch :value="destr(localDevice.icmpEnabled)" @update:value="(enabled: boolean) => (localDevice.icmpEnabled = enabled)" />
</NFormItemGi>
<NFormItemGi span="8" label-placement="left" label="SNMP启用">
<NSwitch :value="destr(localDevice.snmpEnabled)" @update:value="(enabled: boolean) => (localDevice.snmpEnabled = enabled)" />
</NFormItemGi>
</NGrid>
<NFormItem label-placement="left" label="设备ID" path="deviceId">
<NInput v-model:value="localDevice.deviceId" :disabled="!canEditDeviceId" />
</NFormItem>
<NFormItem label-placement="left" label="设备名称">
<NInput v-model:value="localDevice.name" />
</NFormItem>
<NFormItem label-placement="left" label="设备厂商">
<NInput v-model:value="localDevice.manufacturer" />
</NFormItem>
<NFormItem label-placement="left" label="型号">
<NInput v-model:value="localDevice.model" />
</NFormItem>
<NFormItem label-placement="left" label="管理URL">
<NInput v-model:value="localDevice.manageUrl" />
</NFormItem>
<NFormItem label-placement="left" label="管理用户名">
<NInput v-model:value="localDevice.manageUsername" />
</NFormItem>
<NFormItem label-placement="left" label="管理密码">
<NInput v-model:value="localDevice.managePassword" />
</NFormItem>
<NFormItem label-placement="left" label="团体字符串">
<NInput v-model:value="localDevice.community" />
</NFormItem>
<NFormItem label-placement="left" label="设备描述">
<NInput v-model:value="localDevice.description" />
</NFormItem>
<NFormItem label-placement="left" label="上游设备">
<NInput v-model:value="localDevice.linkDescription" />
</NFormItem>
</NForm>
</template>
<template #action>
<NFlex justify="end">
<NButton secondary size="small" :loading="loading" @click="() => updateDevice()">更新</NButton>
</NFlex>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import type {
NdmAlarmHostResultVO,
NdmCameraResultVO,
NdmDecoderResultVO,
NdmDeviceResultVO,
NdmKeyboardResultVO,
NdmNvrResultVO,
NdmSecurityBoxResultVO,
NdmServerResultVO,
NdmSwitchResultVO,
Station,
} from '@/apis';
import { DEVICE_TYPE_LITERALS, tryGetDeviceType } from '@/enums';
import { computed, defineAsyncComponent, toRefs } from 'vue';
const AlarmHostCard = defineAsyncComponent(() => import('@/components/device/device-card/ndm-alarm-host/alarm-host-card.vue'));
const CameraCard = defineAsyncComponent(() => import('@/components/device/device-card/ndm-camera/camera-card.vue'));
const DecoderCard = defineAsyncComponent(() => import('@/components/device/device-card/ndm-decoder/decoder-card.vue'));
const KeyboardCard = defineAsyncComponent(() => import('@/components/device/device-card/ndm-keyboard/keyboard-card.vue'));
const NvrCard = defineAsyncComponent(() => import('@/components/device/device-card/ndm-nvr/nvr-card.vue'));
const SecurityBoxCard = defineAsyncComponent(() => import('@/components/device/device-card/ndm-security-box/security-box-card.vue'));
const ServerCard = defineAsyncComponent(() => import('@/components/device/device-card/ndm-server/server-card.vue'));
const SwitchCard = defineAsyncComponent(() => import('@/components/device/device-card/ndm-switch/switch-card.vue'));
const props = defineProps<{
ndmDevice: NdmDeviceResultVO;
station: Station;
}>();
const { ndmDevice, station } = toRefs(props);
const deviceType = computed(() => tryGetDeviceType(ndmDevice.value.deviceType));
const ndmAlarmHost = computed(() => ndmDevice.value as NdmAlarmHostResultVO);
const ndmCamera = computed(() => ndmDevice.value as NdmCameraResultVO);
const ndmDecoder = computed(() => ndmDevice.value as NdmDecoderResultVO);
const ndmKeyboard = computed(() => ndmDevice.value as NdmKeyboardResultVO);
const ndmNvr = computed(() => ndmDevice.value as NdmNvrResultVO);
const ndmSecurityBox = computed(() => ndmDevice.value as NdmSecurityBoxResultVO);
const ndmServer = computed(() => ndmDevice.value as NdmServerResultVO);
const ndmSwitch = computed(() => ndmDevice.value as NdmSwitchResultVO);
</script>
<template>
<template v-if="deviceType === DEVICE_TYPE_LITERALS.ndmAlarmHost">
<AlarmHostCard :ndmDevice="ndmAlarmHost" :station="station" />
</template>
<template v-if="deviceType === DEVICE_TYPE_LITERALS.ndmCamera">
<CameraCard :ndmDevice="ndmCamera" :station="station" />
</template>
<template v-if="deviceType === DEVICE_TYPE_LITERALS.ndmDecoder">
<DecoderCard :ndmDevice="ndmDecoder" :station="station" />
</template>
<template v-if="deviceType === DEVICE_TYPE_LITERALS.ndmKeyboard">
<KeyboardCard :ndmDevice="ndmKeyboard" :station="station" />
</template>
<template v-if="deviceType === DEVICE_TYPE_LITERALS.ndmNvr">
<NvrCard :ndmDevice="ndmNvr" :station="station" />
</template>
<template v-if="deviceType === DEVICE_TYPE_LITERALS.ndmSecurityBox">
<SecurityBoxCard :ndmDevice="ndmSecurityBox" :station="station" />
</template>
<template v-if="deviceType === DEVICE_TYPE_LITERALS.ndmMediaServer || deviceType === DEVICE_TYPE_LITERALS.ndmVideoServer">
<ServerCard :ndmDevice="ndmServer" :station="station" />
</template>
<template v-if="deviceType === DEVICE_TYPE_LITERALS.ndmSwitch">
<SwitchCard :ndmDevice="ndmSwitch" :station="station" />
</template>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,3 @@
import DeviceRenderer from './device-renderer.vue';
export { DeviceRenderer };

View File

@@ -0,0 +1,475 @@
<script setup lang="ts">
import { initStationDevices, type NdmDeviceResultVO, type NdmNvrResultVO, type Station } from '@/apis';
import { useDeviceTree } from '@/composables';
import { DEVICE_TYPE_NAMES, DEVICE_TYPE_LITERALS, tryGetDeviceType, type DeviceType } from '@/enums';
import { isNvrCluster } from '@/helpers';
import { useDeviceStore, useStationStore } from '@/stores';
import { sleep } from '@/utils';
import { watchDebounced, watchImmediate } from '@vueuse/core';
import destr from 'destr';
import { isFunction } from 'es-toolkit';
import {
NButton,
NDropdown,
NFlex,
NInput,
NRadio,
NRadioGroup,
NTab,
NTabs,
NTag,
NTree,
useThemeVars,
type DropdownOption,
type TagProps,
type TreeInst,
type TreeOption,
type TreeOverrideNodeClickBehavior,
type TreeProps,
} from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, h, onMounted, ref, toRefs, useTemplateRef, watch, type CSSProperties } from 'vue';
const props = defineProps<{
station?: Station; // 支持渲染指定车站的设备树
}>();
const emit = defineEmits<{
selectDevice: [device: NdmDeviceResultVO, stationCode: Station['code']];
}>();
const { station } = toRefs(props);
const themeVars = useThemeVars();
const { selectedStationCode, selectedDeviceType, selectedDevice, initFromRoute, selectDevice, routeDevice } = useDeviceTree();
const onSelectDevice = (device: NdmDeviceResultVO, stationCode: Station['code']) => {
selectDevice(device, stationCode);
emit('selectDevice', device, stationCode);
};
const onRouteDevice = (device: NdmDeviceResultVO, stationCode: Station['code']) => {
routeDevice(device, stationCode, { path: '/device' });
emit('selectDevice', device, stationCode);
};
const stationStore = useStationStore();
const { stations } = storeToRefs(stationStore);
const deviceStore = useDeviceStore();
const { lineDevices } = storeToRefs(deviceStore);
onMounted(() => {
initFromRoute(lineDevices.value);
});
// lineDevices是shallowRef因此需要深度侦听才能获取内部变化
// 而单纯的深度侦听又可能会引发性能问题,因此尝试使用防抖侦听
watchDebounced(
lineDevices,
(newLineDevices) => {
initFromRoute(newLineDevices);
},
{
debounce: 500,
deep: true,
},
);
const deviceTabPanes = Object.values(DEVICE_TYPE_LITERALS).map((deviceType) => ({
name: deviceType,
tab: DEVICE_TYPE_NAMES[deviceType],
}));
const activeTab = ref<DeviceType>(deviceTabPanes.at(0)!.name);
watchImmediate(selectedDeviceType, (newDeviceType) => {
if (newDeviceType) {
activeTab.value = newDeviceType;
}
});
const selectedKeys = computed(() => (selectedDevice.value?.id ? [selectedDevice.value.id] : undefined));
watch([selectedKeys, selectedDevice, selectedStationCode], ([, device, code]) => {
if (device && code) {
onSelectDevice(device, code);
}
});
const contextmenu = ref<{ x: number; y: number; stationCode?: Station['code']; deviceType: DeviceType | null }>({ x: 0, y: 0, deviceType: null });
const showContextmenu = ref(false);
const contextmenuOptions: DropdownOption[] = [
{
label: '导出设备',
key: 'export-device',
onSelect: () => {
// 需要拿到当前选中的设备类型和车站编号
const { stationCode, deviceType } = contextmenu.value;
console.log(stationCode, deviceType);
showContextmenu.value = false;
},
},
];
const onSelectDropdownOption = (key: string, option: DropdownOption) => {
const onSelect = option['onSelect'];
if (isFunction(onSelect)) {
onSelect();
}
};
// ========== 设备树节点交互 ==========
const override: TreeOverrideNodeClickBehavior = ({ option }) => {
const hasChildren = (option.children?.length ?? 0) > 0;
const isDeviceNode = !!option['device'];
if (hasChildren || !isDeviceNode) {
return 'toggleExpand';
} else {
return 'none';
}
};
const nodeProps: TreeProps['nodeProps'] = ({ option }) => {
return {
onDblclick: (payload) => {
if (option['device']) {
payload.stopPropagation();
const device = option['device'] as NdmDeviceResultVO;
const stationCode = option['stationCode'] as string;
// 区分是否需要跳转路由
if (!station.value) {
onSelectDevice(device, stationCode);
} else {
onRouteDevice(device, station.value.code);
}
}
},
// TODO: 支持右键点击车站导出设备列表
onContextmenu: (payload) => {
payload.stopPropagation();
payload.preventDefault();
if (!option['device']) {
const { clientX, clientY } = payload;
const stationCode = option['stationCode'] as string;
const deviceType = option['deviceType'] as DeviceType;
contextmenu.value = { x: clientX, y: clientY, stationCode, deviceType };
showContextmenu.value = true;
}
},
};
};
// ========== 设备树数据 ==========
const renderStationNodePrefix = (station: Station) => {
const { online } = station;
const tagType: TagProps['type'] = online ? 'success' : 'error';
const tagText = online ? '在线' : '离线';
return h(NTag, { type: tagType, size: 'tiny' }, () => tagText);
};
const renderIcmpStatistics = (onlineCount: number, offlineCount: number, count: number) => {
return h('span', null, [
'(',
h('span', { style: { color: themeVars.value.successColor } }, `${onlineCount}`),
'/',
h('span', { style: { color: themeVars.value.errorColor } }, `${offlineCount}`),
'/',
`${count}`,
')',
]);
};
const renderDeviceNodePrefix = (device: NdmDeviceResultVO, stationCode: string) => {
const renderViewDeviceButton = (device: NdmDeviceResultVO, stationCode: string) => {
return h(
NButton,
{
text: true,
size: 'tiny',
type: 'info',
style: {
marginRight: 8,
} as CSSProperties,
onClick: (e: MouseEvent) => {
e.stopPropagation();
// 选择设备
// 区分是否需要跳转路由
if (!station.value) {
onSelectDevice(device, stationCode);
} else {
onRouteDevice(device, station.value.code);
}
},
},
() => '查看',
);
};
const renderDeviceStatusTag = (device: NdmDeviceResultVO) => {
const { deviceStatus } = device;
const color = deviceStatus === '10' ? themeVars.value.successColor : deviceStatus === '20' ? themeVars.value.errorColor : themeVars.value.warningColor;
return h('div', { style: { color } }, { default: () => '◉' });
};
return h(NFlex, { size: 'small' }, { default: () => [renderViewDeviceButton(device, stationCode), renderDeviceStatusTag(device)] });
};
// 全线设备树
const lineDeviceTreeData = computed<Record<string, TreeOption[]>>(() => {
const treeData: Record<string, TreeOption[]> = {};
deviceTabPanes.forEach(({ name: paneName /* , tab: paneTab */ }) => {
treeData[paneName] = stations.value.map<TreeOption>((station) => {
const { name: stationName, code: stationCode } = station;
const devices = lineDevices.value[stationCode]?.[paneName] ?? ([] as NdmDeviceResultVO[]);
const onlineDevices = devices?.filter((device) => device.deviceStatus === '10');
const offlineDevices = devices?.filter((device) => device.deviceStatus === '20');
// 对于录像机需要根据clusterList字段以分号分隔设备IP进一步形成子树结构
if (paneName === DEVICE_TYPE_LITERALS.ndmNvr) {
const nvrs = devices as NdmNvrResultVO[];
const nvrClusters: NdmNvrResultVO[] = [];
const nvrSingletons: NdmNvrResultVO[] = [];
for (const device of nvrs) {
if (isNvrCluster(device)) {
nvrClusters.push(device);
} else {
nvrSingletons.push(device);
}
}
return {
label: stationName,
key: stationCode,
prefix: () => renderStationNodePrefix(station),
suffix: () => renderIcmpStatistics(onlineDevices?.length ?? 0, offlineDevices?.length ?? 0, devices?.length ?? 0),
children: nvrClusters.map<TreeOption>((nvrCluster) => {
return {
label: `${nvrCluster.name}`,
key: nvrCluster.id ?? `${nvrCluster.name}`,
prefix: () => renderDeviceNodePrefix(nvrCluster, stationCode),
suffix: () => `${nvrCluster.ipAddress}`,
children: nvrSingletons.map<TreeOption>((nvr) => {
return {
label: `${nvr.name}`,
key: nvr.id ?? `${nvr.name}`,
prefix: () => renderDeviceNodePrefix(nvr, stationCode),
suffix: () => `${nvr.ipAddress}`,
// 当选择设备时,能获取到设备的所有信息,以及设备所属的车站
stationCode,
device: nvr,
};
}),
// 当选择设备时,能获取到设备的所有信息,以及设备所属的车站
stationCode,
device: nvrCluster,
};
}),
stationCode,
deviceType: activeTab.value,
};
}
return {
label: stationName,
key: stationCode,
prefix: () => renderStationNodePrefix(station),
suffix: () => renderIcmpStatistics(onlineDevices?.length ?? 0, offlineDevices?.length ?? 0, devices?.length ?? 0),
children:
lineDevices.value[stationCode]?.[paneName]?.map<TreeOption>((dev) => {
const device = dev as NdmDeviceResultVO;
return {
label: `${device.name}`,
key: device.id ?? `${device.name}`,
prefix: () => renderDeviceNodePrefix(device, stationCode),
suffix: () => `${device.ipAddress}`,
// 当选择设备时,能获取到设备的所有信息,以及设备所属的车站
stationCode,
device,
};
}) ?? [],
stationCode,
deviceType: activeTab.value,
};
});
});
return treeData;
});
// 车站设备树
const stationDeviceTreeData = computed<TreeOption[]>(() => {
const stationCode = station.value?.code;
if (!stationCode) return [];
return Object.values(DEVICE_TYPE_LITERALS).map<TreeOption>((deviceType) => {
const stationDevices = lineDevices.value[stationCode] ?? initStationDevices();
const onlineCount = stationDevices[deviceType].filter((device) => device.deviceStatus === '10').length;
const offlineCount = stationDevices[deviceType].filter((device) => device.deviceStatus === '20').length;
if (deviceType === DEVICE_TYPE_LITERALS.ndmNvr) {
const nvrs = stationDevices[deviceType] as NdmNvrResultVO[];
const clusters = nvrs.filter((nvr) => isNvrCluster(nvr));
const singletons = nvrs.filter((nvr) => !isNvrCluster(nvr));
return {
label: `${DEVICE_TYPE_NAMES[deviceType]}`,
key: deviceType,
suffix: () => renderIcmpStatistics(onlineCount, offlineCount, nvrs.length),
children: clusters.map<TreeOption>((device) => {
return {
label: `${device.name}`,
key: device.id ?? `${device.name}`,
prefix: () => renderDeviceNodePrefix(device, stationCode),
suffix: () => `${device.ipAddress}`,
children: singletons.map<TreeOption>((device) => {
return {
label: `${device.name}`,
key: device.id ?? `${device.name}`,
prefix: () => renderDeviceNodePrefix(device, stationCode),
suffix: () => `${device.ipAddress}`,
stationCode,
device,
};
}),
stationCode,
device,
};
}),
stationCode,
deviceType,
};
}
return {
label: `${DEVICE_TYPE_NAMES[deviceType]}`,
key: deviceType,
suffix: () => renderIcmpStatistics(onlineCount, offlineCount, stationDevices[deviceType].length),
children: stationDevices[deviceType].map<TreeOption>((device) => {
return {
label: `${device.name}`,
key: device.id ?? `${device.name}`,
prefix: () => renderDeviceNodePrefix(device, stationCode),
suffix: () => `${device.ipAddress}`,
stationCode,
device,
};
}),
stationCode,
deviceType,
};
});
});
// ========== 设备树搜索 ==========
const searchInput = ref('');
const statusInput = ref('');
// 设备树将搜索框和单选框的值都交给NTree的pattern属性
// 但是如果一个车站下没有匹配的设备,那么这个车站节点也不会显示
const searchPattern = computed(() => {
const search = searchInput.value;
const status = statusInput.value;
if (!search && !status) return ''; // 如果pattern非空会导致NTree组件认为筛选完成UI上发生全量匹配
return JSON.stringify({ search: searchInput.value, status: statusInput.value });
});
const searchFilter = (pattern: string, node: TreeOption): boolean => {
const { search, status } = destr<{ search: string; status: string }>(pattern);
const device = node['device'] as NdmDeviceResultVO | undefined;
const { name, ipAddress, deviceId, deviceStatus } = device ?? {};
const searchMatched = (name ?? '').includes(search) || (ipAddress ?? '').includes(search) || (deviceId ?? '').includes(search);
const statusMatched = status === '' || status === deviceStatus;
return searchMatched && statusMatched;
};
// ========== 设备树交互 ==========
const expandedKeys = ref<string[]>();
const deviceTreeInst = useTemplateRef<TreeInst>('deviceTreeInst');
const onFoldDeviceTree = () => {
expandedKeys.value = [];
};
const onLocateDeviceTree = () => {
const stationCode = selectedStationCode.value;
const device = selectedDevice.value;
if (!stationCode || !device?.id) return;
const deviceTypeVal = tryGetDeviceType(device.deviceType);
if (!!deviceTypeVal) {
activeTab.value = deviceTypeVal;
}
const expanded = [stationCode];
if (activeTab.value === DEVICE_TYPE_LITERALS.ndmNvr) {
const nvrs = lineDevices.value[stationCode]?.[DEVICE_TYPE_LITERALS.ndmNvr];
if (nvrs) {
const clusterKeys = nvrs.filter((nvr) => !!nvr.clusterList?.trim() && nvr.clusterList !== nvr.ipAddress).map((nvr) => String(nvr.id));
expanded.push(...clusterKeys);
}
}
expandedKeys.value = expanded;
// 由于数据量大所以开启虚拟滚动,
// 但是无法知晓NTree内部的虚拟列表容器何时创建完成所以使用setTimeout延迟固定时间后执行滚动
scrollDeviceTreeToSelectedDevice();
};
async function scrollDeviceTreeToSelectedDevice() {
await sleep(350);
const inst = deviceTreeInst.value;
inst?.scrollTo({ key: selectedDevice?.value?.id ?? `${selectedDevice.value?.name}`, behavior: 'smooth' });
}
</script>
<template>
<div style="height: 100%; display: flex; flex-direction: column">
<!-- 搜索和筛选 -->
<div style="padding: 12px; flex: 0 0 auto">
<NInput v-model:value="searchInput" placeholder="搜索设备名称、设备ID或IP地址" clearable />
<NFlex align="center">
<NRadioGroup v-model:value="statusInput">
<NRadio value="">全部</NRadio>
<NRadio value="10">在线</NRadio>
<NRadio value="20">离线</NRadio>
</NRadioGroup>
<NButton text size="tiny" type="info" @click="onFoldDeviceTree" style="margin-left: auto">收起</NButton>
<NButton text size="tiny" type="info" @click="onLocateDeviceTree">定位</NButton>
</NFlex>
</div>
<!-- 设备树 -->
<div style="overflow: hidden; flex: 1 1 auto; display: flex">
<template v-if="!station">
<div style="height: 100%; flex: 0 0 auto">
<NTabs v-model:value="activeTab" animated type="line" placement="left" style="height: 100%">
<NTab v-for="pane in deviceTabPanes" :key="pane.name" :name="pane.name" :tab="pane.tab"></NTab>
</NTabs>
</div>
<div style="min-width: 0; flex: 1 1 auto">
<NTree
style="height: 100%"
v-model:expanded-keys="expandedKeys"
block-line
block-node
show-line
virtual-scroll
:ref="'deviceTreeInst'"
:selected-keys="selectedKeys"
:data="lineDeviceTreeData[activeTab]"
:show-irrelevant-nodes="false"
:pattern="searchPattern"
:filter="searchFilter"
:override-default-node-click-behavior="override"
:node-props="nodeProps"
:default-expand-all="false"
/>
</div>
</template>
<template v-else>
<NTree
style="height: 100%"
block-line
block-node
show-line
virtual-scroll
:data="stationDeviceTreeData"
:show-irrelevant-nodes="false"
:pattern="searchPattern"
:filter="searchFilter"
:override-default-node-click-behavior="override"
:node-props="nodeProps"
:default-expand-all="false"
/>
</template>
</div>
</div>
<NDropdown
placement="bottom-start"
trigger="manual"
:show="showContextmenu"
:x="contextmenu.x"
:y="contextmenu.y"
:options="contextmenuOptions"
@select="onSelectDropdownOption"
@clickoutside="() => (showContextmenu = false)"
/>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,420 @@
<script setup lang="ts">
import { initStationDevices, type NdmDeviceResultVO, type NdmNvrResultVO, type Station } from '@/apis';
import { useDeviceTree } from '@/composables';
import { DEVICE_TYPE_NAMES, DEVICE_TYPE_LITERALS, tryGetDeviceType, type DeviceType } from '@/enums';
import { isNvrCluster } from '@/helpers';
import { useDeviceStore, useStationStore } from '@/stores';
import { sleep } from '@/utils';
import { watchDebounced, watchImmediate } from '@vueuse/core';
import destr from 'destr';
import {
NButton,
NFlex,
NInput,
NRadio,
NRadioGroup,
NTab,
NTabs,
NTag,
NTree,
useThemeVars,
type TagProps,
type TreeInst,
type TreeOption,
type TreeOverrideNodeClickBehavior,
type TreeProps,
} from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, h, onMounted, ref, toRefs, useTemplateRef, watch, type CSSProperties } from 'vue';
const props = defineProps<{
station?: Station; // 支持渲染指定车站的设备树
}>();
const emit = defineEmits<{
selectDevice: [device: NdmDeviceResultVO, stationCode: Station['code']];
}>();
const { station } = toRefs(props);
const themeVars = useThemeVars();
const { selectedStationCode, selectedDeviceType, selectedDevice, initFromRoute, selectDevice, routeDevice } = useDeviceTree();
const onSelectDevice = (device: NdmDeviceResultVO, stationCode: Station['code']) => {
selectDevice(device, stationCode);
emit('selectDevice', device, stationCode);
};
const onRouteDevice = (device: NdmDeviceResultVO, stationCode: Station['code']) => {
routeDevice(device, stationCode, { path: '/device' });
emit('selectDevice', device, stationCode);
};
const stationStore = useStationStore();
const { stations } = storeToRefs(stationStore);
const deviceStore = useDeviceStore();
const { lineDevices } = storeToRefs(deviceStore);
onMounted(() => {
initFromRoute(lineDevices.value);
});
// lineDevices是shallowRef因此需要深度侦听才能获取内部变化
// 而单纯的深度侦听又可能会引发性能问题,因此尝试使用防抖侦听
watchDebounced(
lineDevices,
(newLineDevices) => {
initFromRoute(newLineDevices);
},
{
debounce: 500,
deep: true,
},
);
const deviceTabPanes = Object.values(DEVICE_TYPE_LITERALS).map((deviceType) => ({
name: deviceType,
tab: DEVICE_TYPE_NAMES[deviceType],
}));
const activeTab = ref<DeviceType>(deviceTabPanes.at(0)!.name);
watchImmediate(selectedDeviceType, (newDeviceType) => {
if (newDeviceType) {
activeTab.value = newDeviceType;
}
});
const selectedKeys = computed(() => (selectedDevice.value?.id ? [selectedDevice.value.id] : undefined));
watch([selectedKeys, selectedDevice, selectedStationCode], ([, device, code]) => {
if (device && code) {
onSelectDevice(device, code);
}
});
// ========== 设备树节点交互 ==========
const override: TreeOverrideNodeClickBehavior = ({ option }) => {
const hasChildren = (option.children?.length ?? 0) > 0;
const isDeviceNode = !!option['device'];
if (hasChildren || !isDeviceNode) {
return 'toggleExpand';
} else {
return 'none';
}
};
const nodeProps: TreeProps['nodeProps'] = ({ option }) => {
return {
onDblclick: (payload) => {
if (option['device']) {
payload.stopPropagation();
const device = option['device'] as NdmDeviceResultVO;
const stationCode = option['stationCode'] as string;
// 区分是否需要跳转路由
if (!station.value) {
onSelectDevice(device, stationCode);
} else {
onRouteDevice(device, station.value.code);
}
}
},
};
};
// ========== 设备树数据 ==========
const renderStationNodePrefix = (station: Station) => {
const { online } = station;
const tagType: TagProps['type'] = online ? 'success' : 'error';
const tagText = online ? '在线' : '离线';
return h(NTag, { type: tagType, size: 'tiny' }, () => tagText);
};
const renderIcmpStatistics = (onlineCount: number, offlineCount: number, count: number) => {
return h('span', null, [
'(',
h('span', { style: { color: themeVars.value.successColor } }, `${onlineCount}`),
'/',
h('span', { style: { color: themeVars.value.errorColor } }, `${offlineCount}`),
'/',
`${count}`,
')',
]);
};
const renderDeviceNodePrefix = (device: NdmDeviceResultVO, stationCode: string) => {
const renderViewDeviceButton = (device: NdmDeviceResultVO, stationCode: string) => {
return h(
NButton,
{
text: true,
size: 'tiny',
type: 'info',
style: {
marginRight: 8,
} as CSSProperties,
onClick: (e: MouseEvent) => {
e.stopPropagation();
// 选择设备
// 区分是否需要跳转路由
if (!station.value) {
onSelectDevice(device, stationCode);
} else {
onRouteDevice(device, station.value.code);
}
},
},
() => '查看',
);
};
const renderDeviceStatusTag = (device: NdmDeviceResultVO) => {
const { deviceStatus } = device;
const color = deviceStatus === '10' ? themeVars.value.successColor : deviceStatus === '20' ? themeVars.value.errorColor : themeVars.value.warningColor;
return h('div', { style: { color } }, { default: () => '◉' });
};
return h(NFlex, { size: 'small' }, { default: () => [renderViewDeviceButton(device, stationCode), renderDeviceStatusTag(device)] });
};
// 全线设备树
const lineDeviceTreeData = computed<Record<string, TreeOption[]>>(() => {
const treeData: Record<string, TreeOption[]> = {};
deviceTabPanes.forEach(({ name: paneName /* , tab: paneTab */ }) => {
treeData[paneName] = stations.value.map<TreeOption>((station) => {
const { name: stationName, code: stationCode } = station;
const devices = lineDevices.value[stationCode]?.[paneName] ?? ([] as NdmDeviceResultVO[]);
const onlineDevices = devices?.filter((device) => device.deviceStatus === '10');
const offlineDevices = devices?.filter((device) => device.deviceStatus === '20');
// 对于录像机需要根据clusterList字段以分号分隔设备IP进一步形成子树结构
if (paneName === DEVICE_TYPE_LITERALS.ndmNvr) {
const nvrs = devices as NdmNvrResultVO[];
const nvrClusters: NdmNvrResultVO[] = [];
const nvrSingletons: NdmNvrResultVO[] = [];
for (const device of nvrs) {
if (isNvrCluster(device)) {
nvrClusters.push(device);
} else {
nvrSingletons.push(device);
}
}
return {
label: stationName,
key: stationCode,
prefix: () => renderStationNodePrefix(station),
suffix: () => renderIcmpStatistics(onlineDevices?.length ?? 0, offlineDevices?.length ?? 0, devices?.length ?? 0),
children: nvrClusters.map<TreeOption>((nvrCluster) => {
return {
label: `${nvrCluster.name}`,
key: nvrCluster.id ?? `${nvrCluster.name}`,
prefix: () => renderDeviceNodePrefix(nvrCluster, stationCode),
suffix: () => `${nvrCluster.ipAddress}`,
children: nvrSingletons.map<TreeOption>((nvr) => {
return {
label: `${nvr.name}`,
key: nvr.id ?? `${nvr.name}`,
prefix: () => renderDeviceNodePrefix(nvr, stationCode),
suffix: () => `${nvr.ipAddress}`,
// 当选择设备时,能获取到设备的所有信息,以及设备所属的车站
stationCode,
device: nvr,
};
}),
// 当选择设备时,能获取到设备的所有信息,以及设备所属的车站
stationCode,
device: nvrCluster,
};
}),
};
}
return {
label: stationName,
key: stationCode,
prefix: () => renderStationNodePrefix(station),
suffix: () => renderIcmpStatistics(onlineDevices?.length ?? 0, offlineDevices?.length ?? 0, devices?.length ?? 0),
children:
lineDevices.value[stationCode]?.[paneName]?.map<TreeOption>((dev) => {
const device = dev as NdmDeviceResultVO;
return {
label: `${device.name}`,
key: device.id ?? `${device.name}`,
prefix: () => renderDeviceNodePrefix(device, stationCode),
suffix: () => `${device.ipAddress}`,
// 当选择设备时,能获取到设备的所有信息,以及设备所属的车站
stationCode,
device,
};
}) ?? [],
};
});
});
return treeData;
});
// 车站设备树
const stationDeviceTreeData = computed<TreeOption[]>(() => {
const stationCode = station.value?.code;
if (!stationCode) return [];
return Object.values(DEVICE_TYPE_LITERALS).map<TreeOption>((deviceType) => {
const stationDevices = lineDevices.value[stationCode] ?? initStationDevices();
const onlineCount = stationDevices[deviceType].filter((device) => device.deviceStatus === '10').length;
const offlineCount = stationDevices[deviceType].filter((device) => device.deviceStatus === '20').length;
if (deviceType === DEVICE_TYPE_LITERALS.ndmNvr) {
const nvrs = stationDevices[deviceType] as NdmNvrResultVO[];
const clusters = nvrs.filter((nvr) => isNvrCluster(nvr));
const singletons = nvrs.filter((nvr) => !isNvrCluster(nvr));
return {
label: `${DEVICE_TYPE_NAMES[deviceType]}`,
key: deviceType,
suffix: () => renderIcmpStatistics(onlineCount, offlineCount, nvrs.length),
children: clusters.map<TreeOption>((device) => {
return {
label: `${device.name}`,
key: device.id ?? `${device.name}`,
prefix: () => renderDeviceNodePrefix(device, stationCode),
suffix: () => `${device.ipAddress}`,
children: singletons.map<TreeOption>((device) => {
return {
label: `${device.name}`,
key: device.id ?? `${device.name}`,
prefix: () => renderDeviceNodePrefix(device, stationCode),
suffix: () => `${device.ipAddress}`,
stationCode,
device,
};
}),
stationCode,
device,
};
}),
};
}
return {
label: `${DEVICE_TYPE_NAMES[deviceType]}`,
key: deviceType,
suffix: () => renderIcmpStatistics(onlineCount, offlineCount, stationDevices[deviceType].length),
children: stationDevices[deviceType].map<TreeOption>((device) => {
return {
label: `${device.name}`,
key: device.id ?? `${device.name}`,
prefix: () => renderDeviceNodePrefix(device, stationCode),
suffix: () => `${device.ipAddress}`,
stationCode,
device,
};
}),
};
});
});
// ========== 设备树搜索 ==========
const searchInput = ref('');
const statusInput = ref('');
// 设备树将搜索框和单选框的值都交给NTree的pattern属性
// 但是如果一个车站下没有匹配的设备,那么这个车站节点也不会显示
const searchPattern = computed(() => {
const search = searchInput.value;
const status = statusInput.value;
if (!search && !status) return ''; // 如果pattern非空会导致NTree组件认为筛选完成UI上发生全量匹配
return JSON.stringify({ search: searchInput.value, status: statusInput.value });
});
const searchFilter = (pattern: string, node: TreeOption): boolean => {
const { search, status } = destr<{ search: string; status: string }>(pattern);
const device = node['device'] as NdmDeviceResultVO | undefined;
const { name, ipAddress, deviceId, deviceStatus } = device ?? {};
const searchMatched = (name ?? '').includes(search) || (ipAddress ?? '').includes(search) || (deviceId ?? '').includes(search);
const statusMatched = status === '' || status === deviceStatus;
return searchMatched && statusMatched;
};
// ========== 设备树交互 ==========
const expandedKeys = ref<string[]>();
const deviceTreeInst = useTemplateRef<TreeInst>('deviceTreeInst');
const onFoldDeviceTree = () => {
expandedKeys.value = [];
};
const onLocateDeviceTree = () => {
const stationCode = selectedStationCode.value;
const device = selectedDevice.value;
if (!stationCode || !device?.id) return;
const deviceTypeVal = tryGetDeviceType(device.deviceType);
if (!!deviceTypeVal) {
activeTab.value = deviceTypeVal;
}
const expanded = [stationCode];
if (activeTab.value === DEVICE_TYPE_LITERALS.ndmNvr) {
const nvrs = lineDevices.value[stationCode]?.[DEVICE_TYPE_LITERALS.ndmNvr];
if (nvrs) {
const clusterKeys = nvrs.filter((nvr) => !!nvr.clusterList?.trim() && nvr.clusterList !== nvr.ipAddress).map((nvr) => String(nvr.id));
expanded.push(...clusterKeys);
}
}
expandedKeys.value = expanded;
// 由于数据量大所以开启虚拟滚动,
// 但是无法知晓NTree内部的虚拟列表容器何时创建完成所以使用setTimeout延迟固定时间后执行滚动
scrollDeviceTreeToSelectedDevice();
};
async function scrollDeviceTreeToSelectedDevice() {
await sleep(350);
const inst = deviceTreeInst.value;
inst?.scrollTo({ key: selectedDevice?.value?.id ?? `${selectedDevice.value?.name}`, behavior: 'smooth' });
}
</script>
<template>
<div style="height: 100%; display: flex; flex-direction: column">
<!-- 搜索和筛选 -->
<div style="padding: 12px; flex: 0 0 auto">
<NInput v-model:value="searchInput" placeholder="搜索设备名称、设备ID或IP地址" clearable />
<NFlex align="center">
<NRadioGroup v-model:value="statusInput">
<NRadio value="">全部</NRadio>
<NRadio value="10">在线</NRadio>
<NRadio value="20">离线</NRadio>
</NRadioGroup>
<NButton text size="tiny" type="info" @click="onFoldDeviceTree" style="margin-left: auto">收起</NButton>
<NButton text size="tiny" type="info" @click="onLocateDeviceTree">定位</NButton>
</NFlex>
</div>
<!-- 设备树 -->
<div style="overflow: hidden; flex: 1 1 auto; display: flex">
<template v-if="!station">
<div style="height: 100%; flex: 0 0 auto">
<NTabs v-model:value="activeTab" animated type="line" placement="left" style="height: 100%">
<NTab v-for="pane in deviceTabPanes" :key="pane.name" :name="pane.name" :tab="pane.tab"></NTab>
</NTabs>
</div>
<div style="min-width: 0; flex: 1 1 auto">
<NTree
style="height: 100%"
v-model:expanded-keys="expandedKeys"
block-line
block-node
show-line
virtual-scroll
:ref="'deviceTreeInst'"
:selected-keys="selectedKeys"
:data="lineDeviceTreeData[activeTab]"
:show-irrelevant-nodes="false"
:pattern="searchPattern"
:filter="searchFilter"
:override-default-node-click-behavior="override"
:node-props="nodeProps"
:default-expand-all="false"
/>
</div>
</template>
<template v-else>
<NTree
style="height: 100%"
block-line
block-node
show-line
virtual-scroll
:data="stationDeviceTreeData"
:show-irrelevant-nodes="false"
:pattern="searchPattern"
:filter="searchFilter"
:override-default-node-click-behavior="override"
:node-props="nodeProps"
:default-expand-all="false"
/>
</template>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,6 @@
import type { ComponentInstance } from 'vue';
import DeviceTree from './device-tree.vue';
export type DeviceTreeProps = ComponentInstance<typeof DeviceTree>['$props'];
export { DeviceTree };

View File

@@ -0,0 +1,3 @@
export * from './device-card';
export * from './device-renderer';
export * from './device-tree';

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import { useDialog, useLoadingBar, useMessage, useNotification } from 'naive-ui';
window.$dialog = useDialog();
window.$loadingBar = useLoadingBar();
window.$message = useMessage();
window.$notification = useNotification();
</script>
<template></template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,3 @@
import GlobalFeedback from './global-feedback.vue';
export { GlobalFeedback };

View File

@@ -0,0 +1,3 @@
export * from './global-feedback';
export * from './settings-drawer';
export * from './theme-switch';

View File

@@ -0,0 +1,3 @@
import SettingsDrawer from './settings-drawer.vue';
export { SettingsDrawer };

View File

@@ -0,0 +1,277 @@
<script setup lang="ts">
import type { LineAlarms, LineDevices, NdmDeviceResultVO, Station, VersionInfo } from '@/apis';
import { ThemeSwitch } from '@/components';
import { NDM_ALARM_STORE_ID, NDM_DEVICE_STORE_ID, NDM_STATION_STORE_ID } from '@/constants';
import { usePollingStore, useSettingStore } from '@/stores';
import { downloadByData, getAppEnvConfig, parseErrorFeedback, sleep } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { DeleteOutlined, ExportOutlined, ImportOutlined } from '@vicons/antd';
import { useEventListener } from '@vueuse/core';
import axios from 'axios';
import destr from 'destr';
import { isFunction } from 'es-toolkit';
import localforage from 'localforage';
import { NButton, NDivider, NDrawer, NDrawerContent, NDropdown, NFlex, NFormItem, NIcon, NInput, NInputNumber, NModal, NSwitch, NText, type DropdownOption } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const show = defineModel<boolean>('show', { default: false });
const settingsStore = useSettingStore();
const { menuCollpased, stationGridCols, debugModeEnabled, offlineDev } = storeToRefs(settingsStore);
const versionInfo = ref<VersionInfo>({ version: '', buildTime: '' });
const { mutate: getVersionInfo } = useMutation({
mutationFn: async () => {
const { data } = await axios.get<VersionInfo>(`/manifest.json?t=${Date.now()}`);
return data;
},
onSuccess: (data) => {
versionInfo.value = data;
},
onError: (error) => {
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
const showDebugCodeModal = ref(false);
const debugCode = ref('');
const enableDebugMode = () => {
const { debugCode: expectedDebugCode } = getAppEnvConfig();
if (debugCode.value !== expectedDebugCode) {
window.$message.error('调试授权码错误');
return;
}
showDebugCodeModal.value = false;
settingsStore.enableDebugMode();
};
const disableDebugMode = () => {
showDebugCodeModal.value = false;
settingsStore.disableDebugMode();
};
useEventListener('keydown', (event) => {
const { ctrlKey, altKey, code } = event;
if (ctrlKey && altKey && code === 'KeyD') {
showDebugCodeModal.value = true;
}
});
const expectToShowDebugCodeInput = ref(false);
const onModalAfterEnter = () => {
expectToShowDebugCodeInput.value = !debugModeEnabled.value;
};
const onModalAfterLeave = () => {
expectToShowDebugCodeInput.value = false;
debugCode.value = '';
};
const pollingStore = usePollingStore();
const { pollingEnabled } = storeToRefs(pollingStore);
const onPollingEnabledUpdate = (enabled: boolean) => {
if (enabled) {
pollingStore.startPolling();
} else {
pollingStore.stopPolling();
}
};
type IndexedDbStoreId = typeof NDM_STATION_STORE_ID | typeof NDM_DEVICE_STORE_ID | typeof NDM_ALARM_STORE_ID;
type IndexedDbStoreStates = {
[NDM_STATION_STORE_ID]: { stations: Station[] };
[NDM_DEVICE_STORE_ID]: { lineDevices: LineDevices };
[NDM_ALARM_STORE_ID]: { lineAlarms: LineAlarms; unreadLineAlarms: LineAlarms };
};
const exportFromIndexedDB = async <K extends IndexedDbStoreId>(storeId: K, options?: { errorMsg?: string }) => {
const { errorMsg } = options ?? {};
const data = await localforage.getItem<IndexedDbStoreStates[K]>(storeId);
if (!data) {
window.$message.error(errorMsg ?? '导出数据失败');
return;
}
downloadByData(JSON.stringify(data, null, 2), `${storeId}.json`);
};
const importToIndexedDB = async <K extends IndexedDbStoreId>(storeId: K, options?: { successMsg?: string; errorMsg?: string }) => {
const { successMsg, errorMsg } = options ?? {};
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.json';
fileInput.click();
fileInput.onchange = async () => {
const file = fileInput.files?.[0];
if (!file) {
window.$message.error(errorMsg ?? '导入数据失败');
return;
}
const reader = new FileReader();
reader.readAsText(file, 'utf-8');
reader.onload = async () => {
const data = destr<IndexedDbStoreStates[K]>(reader.result as string);
await localforage.setItem(storeId, data);
window.$message.success(successMsg ?? '导入数据成功');
await sleep(2000);
window.location.reload();
};
};
};
const deleteFromIndexedDB = async (storeId: IndexedDbStoreId) => {
await localforage.removeItem(storeId).catch((error) => {
window.$message.error(`${error}`);
return;
});
window.$message.success('删除成功');
await sleep(2000);
window.location.reload();
};
const exportDropdownOptions: DropdownOption[] = [
{
label: '导出车站',
key: 'exportStations',
onSelect: () => exportFromIndexedDB(NDM_STATION_STORE_ID),
},
{
label: '导出设备',
key: 'exportDevices',
onSelect: () => exportFromIndexedDB(NDM_DEVICE_STORE_ID),
},
{
label: '导出告警',
key: 'exportAlarms',
onSelect: () => exportFromIndexedDB(NDM_ALARM_STORE_ID),
},
];
const importDropdownOptions: DropdownOption[] = [
{
label: '导入车站',
key: 'importStations',
onSelect: () => importToIndexedDB(NDM_STATION_STORE_ID),
},
{
label: '导入设备',
key: 'importDevices',
onSelect: () => importToIndexedDB(NDM_DEVICE_STORE_ID),
},
{
label: '导入告警',
key: 'importAlarms',
onSelect: () => importToIndexedDB(NDM_ALARM_STORE_ID),
},
];
const deleteDropdownOptions: DropdownOption[] = [
{
label: '删除车站',
key: 'deleteStations',
onSelect: () => deleteFromIndexedDB(NDM_STATION_STORE_ID),
},
{
label: '删除设备',
key: 'deleteDevices',
onSelect: () => deleteFromIndexedDB(NDM_DEVICE_STORE_ID),
},
{
label: '删除告警',
key: 'deleteAlarms',
onSelect: () => deleteFromIndexedDB(NDM_ALARM_STORE_ID),
},
];
const onSelectDropdownOption = (key: string, option: DropdownOption) => {
const onSelect = option['onSelect'];
if (isFunction(onSelect)) {
onSelect();
}
};
onMounted(() => {
getVersionInfo();
});
</script>
<template>
<NDrawer v-model:show="show" :width="560" :auto-focus="false">
<NDrawerContent closable title="系统设置" :native-scrollbar="false">
<NFlex vertical>
<NDivider>主题</NDivider>
<NFormItem label="深色模式" label-placement="left">
<ThemeSwitch size="small" />
</NFormItem>
<NDivider>布局</NDivider>
<NFormItem label="折叠菜单" label-placement="left">
<NSwitch size="small" v-model:value="menuCollpased" />
</NFormItem>
<template v-if="route.path === '/station'">
<NFormItem label="车站列数" label-placement="left">
<NInputNumber v-model:value="stationGridCols" :min="1" :max="10" />
</NFormItem>
</template>
<template v-if="debugModeEnabled">
<NDivider title-placement="center">调试</NDivider>
<NFormItem label="启用轮询" label-placement="left">
<NSwitch size="small" :value="pollingEnabled" @update:value="onPollingEnabledUpdate" />
</NFormItem>
<NFormItem label="离线开发" label-placement="left">
<NSwitch size="small" v-model:value="offlineDev" />
</NFormItem>
<NFormItem label="本地数据库" label-placement="left">
<NFlex>
<NDropdown trigger="click" :options="exportDropdownOptions" @select="onSelectDropdownOption">
<NButton secondary size="small">
<template #icon>
<NIcon :component="ExportOutlined" />
</template>
<template #default>导出</template>
</NButton>
</NDropdown>
<NDropdown trigger="click" :options="importDropdownOptions" @select="onSelectDropdownOption">
<NButton secondary size="small">
<template #icon>
<NIcon :component="ImportOutlined" />
</template>
<template #default>导入</template>
</NButton>
</NDropdown>
<NDropdown trigger="click" :options="deleteDropdownOptions" @select="onSelectDropdownOption">
<NButton secondary size="small">
<template #icon>
<NIcon :component="DeleteOutlined" />
</template>
<template #default>删除</template>
</NButton>
</NDropdown>
</NFlex>
</NFormItem>
</template>
</NFlex>
<template #footer>
<NFlex vertical justify="flex-end" align="center" style="width: 100%; font-size: 12px; gap: 4px">
<NText :depth="3">平台版本: {{ versionInfo.version }} ({{ versionInfo.buildTime }})</NText>
</NFlex>
</template>
</NDrawerContent>
</NDrawer>
<NModal v-model:show="showDebugCodeModal" preset="dialog" type="info" @after-enter="onModalAfterEnter" @after-leave="onModalAfterLeave">
<template #header>
<NText v-if="!debugModeEnabled">请输入调试码</NText>
<NText v-else>确认关闭调试模式</NText>
</template>
<template #default>
<NInput v-if="expectToShowDebugCodeInput" v-model:value="debugCode" placeholder="输入调试码" @keyup.enter="enableDebugMode" />
</template>
<template #action>
<NButton @click="showDebugCodeModal = false">取消</NButton>
<NButton v-if="!debugModeEnabled" type="primary" @click="enableDebugMode">启用</NButton>
<NButton v-else type="primary" @click="disableDebugMode">确认</NButton>
</template>
</NModal>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,3 @@
import ThemeSwitch from './theme-switch.vue';
export { ThemeSwitch };

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import { useSettingStore } from '@/stores';
import { NIcon, NSwitch } from 'naive-ui';
import { storeToRefs } from 'pinia';
import type { ComponentInstance } from 'vue';
const settingsStore = useSettingStore();
const { darkThemeEnabled } = storeToRefs(settingsStore);
// 使外部能够获取NSwitch的类型提示
defineExpose({} as ComponentInstance<typeof NSwitch>);
</script>
<template>
<NSwitch v-model:value="darkThemeEnabled">
<template #unchecked-icon>
<NIcon>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="bzzmode-light" clip-path="url(#clip0_543_2115)">
<path id="fill1" d="M19 12C19 15.866 15.866 19 12 19C8.13401 19 5 15.866 5 12C5 8.13401 8.13401 5 12 5C15.866 5 19 8.13401 19 12Z" fill="transparent" />
<path
id="stroke1"
d="M19 12C19 15.866 15.866 19 12 19C8.13401 19 5 15.866 5 12C5 8.13401 8.13401 5 12 5C15.866 5 19 8.13401 19 12Z"
stroke-linecap="square"
stroke-width="2"
stroke="currentColor"
/>
<g id="bzzstroke2">
<path
d="M19.7819 19.7762 19.7791 19.779 19.7764 19.7762 19.7791 19.7734 19.7819 19.7762ZM23.0029 11.9961V12H22.999V11.9961H23.0029ZM19.7791 4.2168 19.7819 4.21956 19.7791 4.22232 19.7764 4.21956 19.7791 4.2168ZM11.999.996094H12.0029V1H11.999V.996094ZM4.22525 4.21956 4.22249 4.22232 4.21973 4.21956 4.22249 4.2168 4.22525 4.21956ZM1.00293 11.9961V12H.999023V11.9961H1.00293ZM4.22249 19.7734 4.22525 19.7762 4.22249 19.779 4.21973 19.7762 4.22249 19.7734ZM11.999 22.9961H12.0029V23H11.999V22.9961Z"
stroke-linecap="square"
id="stroke2"
stroke-width="2"
stroke="currentColor"
/>
</g>
</g>
</svg>
</NIcon>
</template>
<template #checked-icon>
<NIcon>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="bzxmode-dark">
<path
id="fill1"
d="M20.5387 14.8522C20.0408 14.9492 19.5263 15 19 15C14.5817 15 11 11.4183 11 7C11 5.54296 11.3194 4.17663 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21C15.9737 21 19.3459 18.4248 20.5387 14.8522Z"
fill="transparent"
/>
<path
id="stroke1"
d="M20.5387 14.8522C20.0408 14.9492 19.5263 15 19 15C14.5817 15 11 11.4183 11 7C11 5.54296 11.3194 4.17663 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21C15.9737 21 19.3459 18.4248 20.5387 14.8522Z"
stroke-width="2"
stroke="currentColor"
/>
<g id="bzxstroke2">
<path
d="M16.625 4 16.6692 4.08081 16.75 4.125 16.6692 4.16919 16.625 4.25 16.5808 4.16919 16.5 4.125 16.5808 4.08081 16.625 4ZM20.5 8.5 20.6768 8.82322 21 9 20.6768 9.17678 20.5 9.5 20.3232 9.17678 20 9 20.3232 8.82322 20.5 8.5Z"
id="stroke2"
stroke-width="2"
stroke="currentColor"
/>
</g>
</g>
</svg>
</NIcon>
</template>
</NSwitch>
</template>
<style scoped lang="scss"></style>

3
src/components/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './device';
export * from './global';
export * from './station';

View File

@@ -0,0 +1,144 @@
<script setup lang="ts">
import type { NdmDeviceAlarmLogResultVO, Station } from '@/apis';
import { ALARM_TYPES, DEVICE_TYPE_LITERALS, DEVICE_TYPE_NAMES, FAULT_LEVELS, tryGetDeviceType } from '@/enums';
import { renderAlarmDateCell, renderAlarmTypeCell, renderDeviceTypeCell, renderFaultLevelCell } from '@/helpers';
import { useAlarmStore } from '@/stores';
import { downloadByData } from '@/utils';
import dayjs from 'dayjs';
import { NButton, NDataTable, NFlex, NGrid, NGridItem, NModal, NStatistic, NTag, type DataTableBaseColumn, type DataTableRowData, type PaginationProps } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, h, reactive, ref, toRefs } from 'vue';
const props = defineProps<{
station?: Station;
}>();
const show = defineModel<boolean>('show', { default: false });
const { station } = toRefs(props);
const alarmStore = useAlarmStore();
const { lineAlarms } = storeToRefs(alarmStore);
const classifiedAlarmCounts = computed<{ label: string; count: number }[]>(() => {
const stationCode = station.value?.code;
if (!stationCode) return [];
const stationAlarms = lineAlarms.value[stationCode];
if (!stationAlarms) return [];
return Object.values(DEVICE_TYPE_LITERALS).map<{ label: string; count: number }>((deviceType) => {
return {
label: DEVICE_TYPE_NAMES[deviceType],
count: stationAlarms[deviceType].length,
};
});
});
const tableColumns = ref<DataTableBaseColumn<NdmDeviceAlarmLogResultVO>[]>([
{ title: '告警流水号', key: 'alarmNo' },
{ title: '告警时间', key: 'alarmDate', render: renderAlarmDateCell },
{ title: '设备类型', key: 'deviceType', render: renderDeviceTypeCell },
{ title: '设备名称', key: 'deviceName' },
{ title: '告警类型', key: 'alarmType', align: 'center', render: renderAlarmTypeCell },
{ title: '故障级别', key: 'faultLevel', align: 'center', render: renderFaultLevelCell },
// { title: '故障编码', key: 'faultCode', align: 'center' },
// { title: '故障位置', key: 'faultLocation' },
{ title: '故障描述', key: 'faultDescription' },
{ title: '修复建议', key: 'alarmRepairSuggestion' },
{ title: '是否恢复', key: 'alarmCategory', align: 'center', render: (rowData) => (rowData.alarmCategory === '2' ? '是' : '否') },
{ title: '恢复时间', key: 'updatedTime' },
{
title: '告警确认',
key: 'alarmConfirm',
align: 'center',
render: (rowData) => (rowData.alarmConfirm === '1' ? h(NTag, { type: 'default' }, { default: () => '已确认' }) : h(NTag, { type: 'warning' }, { default: () => '未确认' })),
},
// { title: '设备ID', key: 'deviceId' },
]);
const DEFAULT_PAGE_SIZE = 10;
const pagination = reactive<PaginationProps>({
size: 'small',
showSizePicker: true,
page: 1,
pageSize: DEFAULT_PAGE_SIZE,
pageSizes: [5, 10, 20, 50, 80, 100],
prefix: ({ itemCount }) => {
return h('div', {}, { default: () => `${itemCount}` });
},
onUpdatePage: (page: number) => {
pagination.page = page;
},
onUpdatePageSize: (pageSize: number) => {
pagination.pageSize = pageSize;
pagination.page = 1;
},
});
const tableData = computed<DataTableRowData[]>(() => {
const stationCode = station.value?.code;
if (!stationCode) return [];
const stationAlarms = lineAlarms.value[stationCode];
if (!stationAlarms) return [];
return stationAlarms['unclassified'];
});
const onAfterLeave = () => {
pagination.page = 1;
pagination.pageSize = 10;
};
const exportAlarms = () => {
const keys = tableColumns.value.map((column) => column.key);
const csvHeader = `${tableColumns.value.map((column) => column.title).join(',')}\n`;
let csvRows = '';
for (const row of tableData.value) {
const alarm = row as NdmDeviceAlarmLogResultVO;
const csvRow = `${keys
.map((key) => {
const fieldKey = key as keyof NdmDeviceAlarmLogResultVO;
if (fieldKey === 'alarmDate') return dayjs(Number(alarm[fieldKey])).format('YYYY-MM-DD HH:mm:ss');
if (fieldKey === 'deviceType') {
const deviceType = tryGetDeviceType(alarm[fieldKey]);
if (!deviceType) return '-';
return DEVICE_TYPE_NAMES[deviceType];
}
if (fieldKey === 'alarmType') return ALARM_TYPES[alarm[fieldKey] ?? ''];
if (fieldKey === 'faultLevel') return FAULT_LEVELS[alarm[fieldKey] ?? ''];
if (fieldKey === 'alarmCategory') return alarm[fieldKey] === '2' ? '是' : '否';
if (fieldKey === 'alarmConfirm') return alarm[fieldKey] === '1' ? '已确认' : '未确认';
return alarm[fieldKey];
})
.join(',')}\n`;
csvRows = csvRows.concat(csvRow);
}
const csvContent = csvHeader.concat(csvRows);
const time = dayjs().format('YYYY-MM-DD_HH-mm-ss');
downloadByData(csvContent, `${station.value?.name}_设备告警记录_${time}.csv`, 'text/csv;charset=utf-8', '\ufeff');
};
</script>
<template>
<NModal v-model:show="show" preset="card" style="width: 100vw; height: 100vh" :close-on-esc="false" :mask-closable="false" @after-leave="onAfterLeave">
<template #header>
<span>{{ `${station?.name} - 设备告警详情` }}</span>
</template>
<template #default>
<NFlex vertical :size="12" style="height: 100%">
<NGrid cols="9" style="flex: 0 0 auto">
<NGridItem v-for="item in classifiedAlarmCounts" :key="item.label" span="1">
<NStatistic :label="item.label + '告警'" :value="item.count" />
</NGridItem>
</NGrid>
<NFlex align="center" style="flex: 0 0 auto">
<div style="font-size: medium">今日设备告警列表</div>
<NButton type="primary" style="margin-left: auto" @click="exportAlarms">导出</NButton>
</NFlex>
<NDataTable flex-height style="height: 100%; min-height: 0; flex: 1 1 auto" :single-line="false" :columns="tableColumns" :data="tableData" :pagination="pagination" />
</NFlex>
</template>
</NModal>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,3 @@
import AlarmDetailModal from './alarm-detail-modal.vue';
export { AlarmDetailModal };

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { Station } from '@/apis';
import { DeviceTree } from '@/components';
import { NModal } from 'naive-ui';
import { toRefs } from 'vue';
const props = defineProps<{
station?: Station;
}>();
const show = defineModel<boolean>('show', { default: false });
const { station } = toRefs(props);
</script>
<template>
<NModal v-model:show="show" preset="card" style="width: 600px; height: 600px" :title="`${station?.name} - 设备详情`" :content-style="{ height: '100%', overflow: 'hidden' }">
<template #default>
<DeviceTree :station="station" />
</template>
</NModal>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,3 @@
import DeviceDetailModal from './device-detail-modal.vue';
export { DeviceDetailModal };

View File

@@ -0,0 +1,258 @@
<script lang="ts">
// 设备参数配置在系统中的key前缀
const DEVICE_PARAM_PREFIXES = {
Switch: 'SWITCH_',
Server: 'SERVER_',
Decoder: 'DECODER_',
Nvr: 'NVR_',
Box: 'BOX_',
Monitor: 'MONITOR_',
} as const;
type DeviceParamPrefix = (typeof DEVICE_PARAM_PREFIXES)[keyof typeof DEVICE_PARAM_PREFIXES];
// 渲染时的数据结构
interface DeviceParamItem {
id: string;
key: string;
name: string;
numValue?: number;
timeValue?: string;
suffix?: string;
step?: number;
min?: number;
max?: number;
}
// 一些参数值是零点几,一些参数值是好几十,需要根据参数名称中的关键词来做预处理
const parseNumericValue = (name: string, value: string) => {
let val = parseFloat(value);
const needMultiply = name.includes('流量') || name.includes('占用率');
if (needMultiply) val *= 100;
return val;
};
// 在保存参数时需要反向处理
const deparseNumericValue = (name: string, value: number) => {
let val = value;
const needMultiply = name.includes('流量') || name.includes('占用率');
if (needMultiply) val /= 100;
return val;
};
const getItemStep = (name: string) => {
if (name.includes('转速')) return 100;
return 1;
};
const getItemMax = (name: string) => {
if (name.includes('转速')) return 50000;
return 100;
};
const getItemSuffix = (name: string) => {
const percentLike = name.includes('流量') || name.includes('占用率') || name.includes('湿度');
const secondLike = name.includes('忽略丢失');
const currentLike = name.includes('电流');
const voltageLike = name.includes('电压');
const temperatureLike = name.includes('温');
const rpmLike = name.includes('转速');
if (percentLike) return '%';
if (secondLike) return '秒';
if (currentLike) return 'A';
if (voltageLike) return 'V';
if (temperatureLike) return '℃';
if (rpmLike) return '转/分';
return '';
};
const tabPanes = [
{
tab: '交换机阈值',
name: DEVICE_PARAM_PREFIXES.Switch,
},
{
tab: '服务器阈值',
name: DEVICE_PARAM_PREFIXES.Server,
},
{
tab: '解码器阈值',
name: DEVICE_PARAM_PREFIXES.Decoder,
},
{
tab: '录像机阈值',
name: DEVICE_PARAM_PREFIXES.Nvr,
},
{
tab: '安防箱阈值',
name: DEVICE_PARAM_PREFIXES.Box,
},
{
tab: '显示器计划',
name: DEVICE_PARAM_PREFIXES.Monitor,
},
];
</script>
<script setup lang="ts">
import { pageDefParameterApi, resetMonitorScheduleApi, updateDefParameterApi, type Station } from '@/apis';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { NFlex, NForm, NFormItemGi, NGrid, NInputNumber, NModal, NSpin, NTabPane, NTabs, NTimePicker } from 'naive-ui';
import { ref, toRefs } from 'vue';
const props = defineProps<{
station?: Station;
}>();
const show = defineModel<boolean>('show', { required: true });
const { station } = toRefs(props);
const activeTabName = ref<DeviceParamPrefix>(DEVICE_PARAM_PREFIXES.Switch);
const deviceParams = ref<DeviceParamItem[]>([]);
const { mutate: getDeviceParams, isPending: paramsLoading } = useMutation({
mutationFn: async (params: { deviceKeyPrefix: string }) => {
const { deviceKeyPrefix } = params;
const { records } = await pageDefParameterApi(
{
model: {},
extra: { key_likeRight: deviceKeyPrefix },
current: 1,
size: 1000,
sort: 'id',
order: 'descending',
},
{
stationCode: station.value?.code,
},
);
return records;
},
onSuccess: (records) => {
deviceParams.value = records.map<DeviceParamItem>((record) => {
if (record.key?.includes(DEVICE_PARAM_PREFIXES.Monitor)) {
return {
id: record.id ?? '',
key: record.key ?? '',
name: record.name ?? '',
timeValue: record.value ?? '',
};
}
return {
id: record.id ?? '',
key: record.key ?? '',
name: record.name ?? '',
numValue: parseNumericValue(record.name ?? '', record.value ?? '0'),
suffix: getItemSuffix(record.name ?? ''),
step: getItemStep(record.name ?? ''),
min: 0,
max: getItemMax(record.name ?? ''),
};
});
},
onError: (error) => {
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
const { mutate: saveDeviceParams } = useMutation({
mutationFn: async (params: { tabName: string; items: DeviceParamItem[] }) => {
const { tabName, items } = params;
for (const item of items) {
if (tabName.includes(DEVICE_PARAM_PREFIXES.Monitor)) {
await updateDefParameterApi(
{
id: item.id,
key: item.key,
name: item.name,
value: item.timeValue,
},
{
stationCode: station.value?.code,
},
);
} else {
await updateDefParameterApi(
{
id: item.id,
key: item.key,
name: item.name,
value: `${deparseNumericValue(item.name, item.numValue ?? 0)}`,
},
{
stationCode: station.value?.code,
},
);
}
}
await resetMonitorScheduleApi({ stationCode: station.value?.code });
},
onError: (error) => {
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
const onBeforeTabLeave = (name: string, oldName: string): boolean | Promise<boolean> => {
saveDeviceParams({ tabName: oldName, items: deviceParams.value });
getDeviceParams({ deviceKeyPrefix: name });
return true;
};
const onAfterModalEnter = () => {
getDeviceParams({ deviceKeyPrefix: activeTabName.value });
};
const onBeforeModalLeave = () => {
saveDeviceParams({ tabName: activeTabName.value, items: deviceParams.value });
activeTabName.value = DEVICE_PARAM_PREFIXES.Switch;
deviceParams.value = [];
};
</script>
<template>
<NModal
v-model:show="show"
preset="card"
style="width: 800px; height: 600px"
:title="`${station?.name} - 设备参数配置`"
:auto-focus="false"
:close-on-esc="false"
:mask-closable="false"
@after-enter="onAfterModalEnter"
@before-leave="onBeforeModalLeave"
>
<NTabs v-model:value="activeTabName" type="card" @before-leave="onBeforeTabLeave">
<NTabPane v-for="pane in tabPanes" :key="pane.name" :tab="pane.tab" :name="pane.name">
<NFlex v-if="paramsLoading" :justify="'center'" :align="'center'">
<NSpin :show="paramsLoading" description="加载设备参数中..." />
</NFlex>
<NForm v-else>
<NGrid cols="1">
<NFormItemGi v-for="item in deviceParams" :key="item.key" span="1" label-placement="left" :label="item.name">
<!-- 监视器计划配置渲染时间选择器其他配置项渲染数字输入框 -->
<template v-if="activeTabName === DEVICE_PARAM_PREFIXES.Monitor">
<NTimePicker v-model:formatted-value="item.timeValue" />
</template>
<template v-else>
<NInputNumber v-model:value="item.numValue" :step="item.step" :min="item.min" :max="item.max" style="width: 100%">
<template #suffix>
<span>{{ item.suffix }}</span>
</template>
</NInputNumber>
</template>
</NFormItemGi>
</NGrid>
</NForm>
</NTabPane>
</NTabs>
</NModal>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,3 @@
import DeviceParamConfigModal from './device-param-config.modal.vue';
export { DeviceParamConfigModal };

View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { exportIcmpByStationApi, type Station } from '@/apis';
import { DEVICE_TYPE_LITERALS } from '@/enums';
import { useDeviceStore } from '@/stores';
import { downloadByData, parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import dayjs from 'dayjs';
import { NButton, NFlex, NGrid, NGridItem, NModal, NRadio, NRadioGroup, NStatistic } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, ref, toRefs } from 'vue';
const props = defineProps<{
stations: Station[];
}>();
const emit = defineEmits<{
afterLeave: [];
}>();
const show = defineModel<boolean>('show');
const { stations } = toRefs(props);
const deviceStore = useDeviceStore();
const { lineDevices } = storeToRefs(deviceStore);
const status = ref('');
const abortController = ref<AbortController>(new AbortController());
const { mutate: exportIcmp, isPending: loading } = useMutation({
mutationFn: async (params: { status: string }) => {
abortController.value.abort();
abortController.value = new AbortController();
const data = await exportIcmpByStationApi(
stations.value.map((station) => station.code),
params.status,
{
signal: abortController.value.signal,
},
);
return data;
},
onSuccess: (data, variables) => {
const { status } = variables;
let fileName = '全部设备列表';
if (status === '10') {
fileName = '在线设备列表';
} else if (status === '20') {
fileName = '离线设备列表';
}
const time = dayjs().format('YYYY-MM-DD_HH-mm-ss');
downloadByData(data, `${fileName}_${time}.xlsx`);
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
const onAfterLeave = () => {
abortController.value.abort();
status.value = '';
emit('afterLeave');
};
const onlineDeviceCount = computed(() => {
let count = 0;
for (const station of stations.value) {
if (station.online) {
const stationDevices = lineDevices.value[station.code];
Object.values(DEVICE_TYPE_LITERALS).forEach((deviceType) => {
const onlineDeviceList = stationDevices?.[deviceType]?.filter((device) => device.deviceStatus === '10') ?? [];
count += onlineDeviceList.length;
});
}
}
return count;
});
const offlineDeviceCount = computed(() => {
let count = 0;
for (const station of stations.value) {
if (station.online) {
const stationDevices = lineDevices.value[station.code];
Object.values(DEVICE_TYPE_LITERALS).forEach((deviceType) => {
const onlineDeviceList = stationDevices?.[deviceType]?.filter((device) => device.deviceStatus === '20') ?? [];
count += onlineDeviceList.length;
});
}
}
return count;
});
const deviceCount = computed(() => onlineDeviceCount.value + offlineDeviceCount.value);
</script>
<template>
<NModal v-model:show="show" preset="card" title="导出设备列表" @after-leave="onAfterLeave" style="width: 800px; height: 300px">
<template #default>
<NGrid :cols="3" :x-gap="24" :y-gap="8">
<NGridItem>
<NStatistic label="全部设备" :value="deviceCount" />
</NGridItem>
<NGridItem>
<NStatistic label="在线设备" :value="onlineDeviceCount" :value-style="{ color: '#18a058' }" />
</NGridItem>
<NGridItem>
<NStatistic label="离线设备" :value="offlineDeviceCount" :value-style="{ color: '#d03050' }" />
</NGridItem>
</NGrid>
</template>
<template #action>
<NFlex justify="flex-end" align="center">
<NRadioGroup v-model:value="status">
<NRadio value="">全部</NRadio>
<NRadio value="10">在线</NRadio>
<NRadio value="20">离线</NRadio>
</NRadioGroup>
<NButton secondary :loading="loading" @click="() => exportIcmp({ status })">导出</NButton>
</NFlex>
</template>
</NModal>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,3 @@
import IcmpExportModal from './icmp-export-modal.vue';
export { IcmpExportModal };

View File

@@ -0,0 +1,7 @@
export * from './alarm-detail-modal';
export * from './device-detail-modal';
export * from './device-param-config-modal';
export * from './icmp-export-modal';
export * from './record-check-export-modal';
export * from './station-card';
export * from './sync-camera-result-modal';

View File

@@ -0,0 +1,3 @@
import RecordCheckExportModal from './record-check-export-modal.vue';
export { RecordCheckExportModal };

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import { getRecordCheckApi, type NdmNvrResultVO, type Station } from '@/apis';
import { exportRecordDiagCsv, isNvrCluster, transformRecordChecks } from '@/helpers';
import { useDeviceStore } from '@/stores';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import { NButton, NGrid, NGridItem, NModal, NScrollbar, NSpin } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, ref, toRefs } from 'vue';
const props = defineProps<{
stations: Station[];
}>();
const emit = defineEmits<{
afterLeave: [];
}>();
const show = defineModel<boolean>('show');
const deviceStore = useDeviceStore();
const { lineDevices } = storeToRefs(deviceStore);
const { stations } = toRefs(props);
const nvrClusterRecord = computed(() => {
const clusterMap: Record<Station['code'], { stationName: Station['name']; clusters: NdmNvrResultVO[] }> = {};
stations.value.forEach((station) => {
clusterMap[station.code] = {
stationName: station.name,
clusters: [],
};
const stationDevices = lineDevices.value[station.code];
const nvrs = stationDevices?.['ndmNvr'] ?? [];
nvrs.forEach((nvr) => {
if (isNvrCluster(nvr)) {
clusterMap[station.code]?.clusters?.push(nvr);
}
});
});
return clusterMap;
});
const abortController = ref<AbortController>(new AbortController());
const { mutate: exportRecordDiags, isPending: exporting } = useMutation({
mutationFn: async (params: { clusters: NdmNvrResultVO[]; stationCode: Station['code'] }) => {
const { clusters, stationCode } = params;
if (clusters.length === 0) {
const stationName = nvrClusterRecord.value[stationCode]?.stationName ?? '';
window.$message.info(`${stationName} 没有录像诊断数据`);
return;
}
const cluster = clusters.at(0);
if (!cluster) return;
abortController.value.abort();
abortController.value = new AbortController();
const checks = await getRecordCheckApi(cluster, 90, [], { stationCode: stationCode, signal: abortController.value.signal });
return checks;
},
onSuccess: (checks, { stationCode }) => {
if (!checks || checks.length === 0) return;
const recordDiags = transformRecordChecks(checks);
exportRecordDiagCsv(recordDiags, nvrClusterRecord.value[stationCode]?.stationName ?? '');
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
const onAfterLeave = () => {
emit('afterLeave');
};
</script>
<template>
<NModal v-model:show="show" preset="card" title="导出录像诊断" @after-leave="onAfterLeave" style="width: 800px">
<template #default>
<NScrollbar style="height: 300px">
<NSpin size="small" :show="exporting">
<NGrid :cols="6">
<template v-for="({ stationName, clusters }, code) in nvrClusterRecord" :key="code">
<NGridItem>
<NButton text type="info" style="height: 30px" @click="() => exportRecordDiags({ clusters, stationCode: code })">{{ stationName }}</NButton>
</NGridItem>
</template>
</NGrid>
</NSpin>
</NScrollbar>
</template>
</NModal>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,6 @@
import type { ComponentInstance } from 'vue';
import StationCard from './station-card.vue';
export type StationCardProps = ComponentInstance<typeof StationCard>['$props'];
export { StationCard };

View File

@@ -0,0 +1,164 @@
<script setup lang="ts">
import type { Station, StationAlarms, StationDevices } from '@/apis';
import { DEVICE_TYPE_LITERALS } from '@/enums';
import { EllipsisOutlined, MoreOutlined } from '@vicons/antd';
import axios from 'axios';
import { isFunction } from 'es-toolkit';
import { NButton, NCard, NCheckbox, NDropdown, NFlex, NIcon, NTag, NTooltip, useThemeVars, type DropdownOption } from 'naive-ui';
import { computed, toRefs } from 'vue';
const themeVars = useThemeVars();
const props = defineProps<{
station: Station;
devices: StationDevices;
alarms: StationAlarms;
selectable?: boolean;
}>();
const selected = defineModel<boolean>('selected', { default: false });
const emit = defineEmits<{
clickDetail: [type: 'device' | 'alarm', station: Station];
clickConfig: [station: Station];
}>();
const { station, devices, alarms, selectable } = toRefs(props);
const onlineDeviceCount = computed(() => {
return Object.values(DEVICE_TYPE_LITERALS).reduce((count, deviceType) => {
const onlineDevices = devices.value[deviceType].filter((device) => device.deviceStatus === '10');
return count + onlineDevices.length;
}, 0);
});
const offlineDeviceCount = computed(() => {
return Object.values(DEVICE_TYPE_LITERALS).reduce((count, deviceType) => {
const offlineDevices = devices.value[deviceType].filter((device) => device.deviceStatus === '20');
return count + offlineDevices.length;
}, 0);
});
const deviceCount = computed(() => {
return Object.values(DEVICE_TYPE_LITERALS).reduce((count, deviceType) => {
return count + devices.value[deviceType].length;
}, 0);
});
const alarmCount = computed(() => {
return alarms.value.unclassified.length;
});
const openVideoPlatform = async () => {
try {
const response = await axios.get<Record<string, string>>('/minio/ndm/ndm-vimps.json');
const url = response.data[station.value.code];
if (!url) {
window.$message.warning(`未找到车站编码 ${station.value.code} 对应的视频平台URL`);
return;
}
window.open(url, '_blank');
} catch (error) {
console.error('获取视频平台URL失败:', error);
window.$message.error('获取视频平台URL失败');
}
};
const openDeviceConfigModal = () => {
if (!station.value.online) {
window.$message.error('当前车站离线,无法查看');
return;
}
emit('clickConfig', station.value);
};
const dropdownOptions: DropdownOption[] = [
{
label: '视频平台',
key: 'video-platform',
onSelect: openVideoPlatform,
},
{
label: '设备配置',
key: 'device-config',
onSelect: openDeviceConfigModal,
},
];
const onSelectDropdownOption = (key: string, option: DropdownOption) => {
const onSelect = option['onSelect'];
if (isFunction(onSelect)) {
onSelect();
}
};
</script>
<template>
<NCard bordered hoverable size="medium" :header-style="{ padding: `6px` }" :content-style="{ padding: `0px 6px 6px 6px` }">
<template #header>
<template v-if="station.ip">
<NTooltip trigger="click">
<template #trigger>
<span style="font-size: smaller">{{ station.name }}</span>
</template>
<span>{{ station.ip }}</span>
</NTooltip>
</template>
<template v-else>
<span style="font-size: smaller">{{ station.name }}</span>
</template>
</template>
<template #header-extra>
<NFlex align="center" :size="4">
<NCheckbox v-if="selectable" v-model:checked="selected" :disabled="!station.online" />
<NTag :type="station.online ? 'success' : 'error'" size="small">
{{ station.online ? '在线' : '离线' }}
</NTag>
<NDropdown trigger="click" :options="dropdownOptions" @select="onSelectDropdownOption">
<NButton quaternary size="tiny" :focusable="false">
<template #icon>
<NIcon :component="MoreOutlined" />
</template>
</NButton>
</NDropdown>
</NFlex>
</template>
<template #default>
<NFlex vertical :size="6" :style="{ opacity: station.online ? '1' : '0.5' }">
<NFlex vertical :size="4">
<NFlex justify="flex-end" align="center" :size="2">
<span>{{ deviceCount }} 台设备</span>
<NButton quaternary size="tiny" :focusable="false" @click="() => emit('clickDetail', 'device', station)">
<template #icon>
<NIcon :component="EllipsisOutlined" />
</template>
</NButton>
</NFlex>
<NFlex justify="flex-end" align="center" :size="2">
<div>
<span :style="{ color: onlineDeviceCount > 0 ? themeVars.successColor : '' }">在线{{ onlineDeviceCount }}</span>
<span> · </span>
<span :style="{ color: offlineDeviceCount > 0 ? themeVars.errorColor : '' }">离线 {{ offlineDeviceCount }}</span>
</div>
<!-- 占位按钮对齐布局 -->
<NButton quaternary size="tiny" :focusable="false" style="visibility: hidden">
<template #icon>
<NIcon :component="EllipsisOutlined" />
</template>
</NButton>
</NFlex>
</NFlex>
<NFlex justify="flex-end" align="center" :size="2">
<span :style="{ color: alarmCount > 0 ? themeVars.warningColor : '' }">今日 {{ alarmCount }} 条告警</span>
<NButton quaternary size="tiny" :focusable="false" @click="() => emit('clickDetail', 'alarm', station)">
<template #icon>
<NIcon :component="EllipsisOutlined" />
</template>
</NButton>
</NFlex>
</NFlex>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,3 @@
import SyncCameraResultModal from './sync-camera-result-modal.vue';
export { SyncCameraResultModal };

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { watchDebounced } from '@vueuse/core';
import { NFlex, NIcon, NList, NListItem, NModal, NScrollbar, NStatistic, NText, NThing } from 'naive-ui';
import { computed, ref, toRefs } from 'vue';
import { useStationStore } from '@/stores';
import { storeToRefs } from 'pinia';
import type { Station, SyncCameraResult } from '@/apis';
import { DeleteFilled, EditFilled, PlusCircleFilled } from '@vicons/antd';
const props = defineProps<{
syncCameraResult: Record<Station['code'], SyncCameraResult>;
}>();
const emit = defineEmits<{
afterLeave: [];
}>();
const stationStore = useStationStore();
const { stations } = storeToRefs(stationStore);
const { syncCameraResult } = toRefs(props);
const show = ref(false);
watchDebounced(
[syncCameraResult],
([result]) => {
show.value = Object.keys(result).length > 0;
},
{
debounce: 500,
deep: true,
},
);
const onAfterLeave = () => {
emit('afterLeave');
};
const syncList = computed(() => {
return Object.values(syncCameraResult.value).map((sync) => {
const { stationCode, startTime, endTime, insertList, updateList, deleteList } = sync;
const stationName = stations.value.find((station) => station.code === stationCode)?.name;
return { stationName, startTime, endTime, insertList, updateList, deleteList };
});
});
</script>
<template>
<NModal v-model:show="show" preset="card" title="摄像机同步结果" style="width: 600px" @after-leave="onAfterLeave">
<NScrollbar style="max-height: 400px">
<NList hoverable clickable>
<NListItem v-for="{ stationName, endTime, insertList, updateList, deleteList } in syncList" :key="stationName">
<NThing title-independent>
<template #header>
<NText strong>{{ stationName }}</NText>
</template>
<template #header-extra>
<NText depth="3"> {{ endTime }} 完成 </NText>
</template>
<NFlex justify="space-around" :size="24" style="margin-top: 8px">
<NStatistic label="新增">
<template #prefix>
<NIcon :component="PlusCircleFilled" />
</template>
{{ insertList.length }}
</NStatistic>
<NStatistic label="更新">
<template #prefix>
<NIcon :component="EditFilled" />
</template>
{{ updateList.length }}
</NStatistic>
<NStatistic label="删除">
<template #prefix>
<NIcon :component="DeleteFilled" />
</template>
{{ deleteList.length }}
</NStatistic>
</NFlex>
</NThing>
</NListItem>
</NList>
</NScrollbar>
</NModal>
</template>
<style scoped lang="scss"></style>