feat: device cards

This commit is contained in:
yangsy
2025-09-02 23:35:54 +08:00
parent ced07b17ff
commit 9bf8229bf6
16 changed files with 2211 additions and 11 deletions

View File

@@ -0,0 +1,361 @@
<script setup lang="ts">
import type { NdmSecurityBoxCircuit } from '@/apis/domains';
import type { NdmSecurityBoxResultVO } from '@/apis/models';
import { rebootSecurityBox, turnStatus } from '@/apis/requests';
import { PowerOutline, FlashOutline } from '@vicons/ionicons5';
import { NCard, NGrid, NGridItem, NPopover, NSwitch, NIcon, NFlex, NPopconfirm, NButton } from 'naive-ui';
import { computed, toRefs } from 'vue';
/**
* 安防箱电路状态卡片组件
*
* 功能描述:
* 1. 显示安防箱的电路状态信息(电压、电流、状态)
* 2. 提供电路开关控制功能
* 3. 支持浅色和深色主题模式
* 4. 响应式布局显示电路信息
*/
const props = defineProps<{
stationCode: string;
ndmSecurityBox: NdmSecurityBoxResultVO;
circuits?: NdmSecurityBoxCircuit[];
}>();
const { stationCode, ndmSecurityBox, circuits } = toRefs(props);
const validCircuits = computed(() => {
if (!circuits.value || circuits.value.length === 0) {
return [];
}
return circuits.value;
});
/**
* 获取电路状态样式类
* @param circuit 电路信息
* @returns CSS类名
*/
const getCircuitStatusClass = (circuit: NdmSecurityBoxCircuit) => {
if (circuit.status === 1) {
return 'circuit-on';
} else if (circuit.status === 0) {
return 'circuit-off';
}
return 'circuit-unknown';
};
/**
* 获取电路状态文本
* @param circuit 电路信息
* @returns 状态文本
*/
const getCircuitStatusText = (circuit: NdmSecurityBoxCircuit) => {
if (circuit.status === 1) {
return '开启';
} else if (circuit.status === 0) {
return '关闭';
}
return '未知';
};
/**
* 处理电路开关切换
* @param circuitIndex 电路索引
* @param newStatus 新状态
*/
const handleCircuitToggle = async (circuitIndex: number, newStatus: boolean) => {
if (!ndmSecurityBox.value.ipAddress) {
window.$message.error('设备IP地址不存在');
return;
}
try {
const status = newStatus ? 1 : 0;
await turnStatus(stationCode.value, ndmSecurityBox.value.ipAddress, circuitIndex, status);
window.$message.success(`电路${circuitIndex + 1}${newStatus ? '开启' : '关闭'}成功 下次更新诊断数据时将刷新状态`);
} catch (error) {
window.$message.error(`电路${circuitIndex + 1}操作失败`);
console.error('电路开关操作失败:', error);
}
};
const onClickReboot = async () => {
if (!ndmSecurityBox.value.ipAddress) {
window.$message.error('设备IP地址不存在');
return;
}
try {
await rebootSecurityBox(stationCode.value, ndmSecurityBox.value.ipAddress);
window.$message.success('设备重启成功');
} catch (error) {
window.$message.error('设备重启失败');
console.error('设备重启失败:', error);
}
};
</script>
<template>
<NCard v-if="validCircuits.length > 0" size="small" hoverable>
<template #header>
<NFlex :align="'center'">
<div>电路状态</div>
<NPopconfirm :positive-text="'确认'" :negative-text="'取消'" @positive-click="onClickReboot">
<template #trigger>
<NButton secondary size="small">重合闸</NButton>
</template>
确定要执行重合闸操作吗此操作将重启安防箱设备
</NPopconfirm>
</NFlex>
</template>
<div class="circuit-layout">
<NGrid :cols="Math.min(validCircuits.length, 4)" :x-gap="12" :y-gap="12" class="circuit-grid">
<NGridItem v-for="(circuit, index) in validCircuits" :key="index">
<!-- 电路信息弹窗 -->
<NPopover trigger="hover" placement="top">
<template #trigger>
<div class="circuit-item" :class="getCircuitStatusClass(circuit)">
<div class="circuit-header">
<NIcon size="16" class="circuit-icon">
<PowerOutline />
</NIcon>
<span class="circuit-name">电路{{ index + 1 }}</span>
</div>
<div class="circuit-status">
<div class="status-indicator" :class="getCircuitStatusClass(circuit)"></div>
<span class="status-text">{{ getCircuitStatusText(circuit) }}</span>
</div>
<div class="circuit-control">
<NPopconfirm :positive-text="'确认'" :negative-text="'取消'" @positive-click="() => handleCircuitToggle(index, circuit.status !== 1)">
<template #trigger>
<NSwitch :value="circuit.status === 1" size="small" />
</template>
确定要{{ circuit.status === 1 ? '关闭' : '开启' }}电路{{ index + 1 }}吗?
</NPopconfirm>
</div>
</div>
</template>
<!-- 弹窗详细信息 -->
<div class="circuit-popover-content">
<div class="circuit-info-row">
<span class="label">电路:</span>
<span class="value">电路{{ index + 1 }}</span>
</div>
<div class="circuit-info-row">
<span class="label">状态:</span>
<span class="value" :class="getCircuitStatusClass(circuit)">
{{ getCircuitStatusText(circuit) }}
</span>
</div>
<div class="circuit-info-row">
<NFlex :align="'center'" :size="4">
<NIcon size="12" style="color: #f0a020">
<FlashOutline />
</NIcon>
<span class="label">电压:</span>
</NFlex>
<span class="value">{{ circuit.voltage }}V</span>
</div>
<div class="circuit-info-row">
<NFlex :align="'center'" :size="4">
<NIcon size="12" style="color: #2080f0">
<FlashOutline />
</NIcon>
<span class="label">电流:</span>
</NFlex>
<span class="value">{{ circuit.current }}A</span>
</div>
</div>
</NPopover>
</NGridItem>
</NGrid>
</div>
</NCard>
</template>
<style scoped lang="scss">
/*
* 安防箱电路状态卡片样式
*
* 设计理念:
* 1. 清晰的电路状态指示
* 2. 直观的开关控制界面
* 3. 良好的交互体验
* 4. 亮色/暗色模式适配
*/
.circuit-layout {
.circuit-grid {
max-width: 100%;
}
/*
* 电路项样式 - 核心视觉元素
*
* 尺寸设计120x80px适合显示电路信息和控制开关
* 状态指示:通过边框颜色和状态指示器区分电路状态
* 交互效果:悬停时轻微上移和阴影效果
*/
.circuit-item {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
width: 120px;
height: 80px;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--card-color);
cursor: pointer;
transition: all 0.2s ease;
/* 悬停效果 */
&:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 电路头部 - 图标和名称 */
.circuit-header {
display: flex;
align-items: center;
gap: 6px;
.circuit-icon {
color: var(--text-color-3);
}
.circuit-name {
font-size: 12px;
font-weight: 500;
color: var(--text-color-2);
}
}
/* 电路状态显示 */
.circuit-status {
display: flex;
align-items: center;
gap: 6px;
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-text {
font-size: 11px;
color: var(--text-color-3);
}
}
/* 电路控制开关 */
.circuit-control {
display: flex;
justify-content: flex-end;
}
/* 电路开启状态 - 绿色主题 */
&.circuit-on {
border-color: #18a058;
.status-indicator {
background-color: #18a058;
}
&:hover {
background-color: rgba(24, 160, 88, 0.05);
}
}
/* 电路关闭状态 - 红色主题 */
&.circuit-off {
border-color: #d03050;
.status-indicator {
background-color: #d03050;
}
&:hover {
background-color: rgba(208, 48, 80, 0.05);
}
}
/* 电路未知状态 - 黄色主题 */
&.circuit-unknown {
border-color: #f0a020;
.status-indicator {
background-color: #f0a020;
}
&:hover {
background-color: rgba(240, 160, 32, 0.05);
}
}
}
}
/*
* 电路信息弹窗内容样式
*
* 功能:显示电路的详细信息,包括状态、电压、电流
* 布局:标签-值的两列布局,右对齐数值便于对比
*/
.circuit-popover-content {
min-width: 140px;
.circuit-info-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
&:last-child {
margin-bottom: 0;
}
/* 信息标签样式 */
.label {
font-size: 12px;
color: var(--text-color-3);
margin-right: 12px;
}
/* 信息值样式 */
.value {
font-size: 12px;
font-weight: 500;
color: var(--text-color-2);
/* 状态值的颜色区分 */
&.circuit-on {
color: #18a058;
}
&.circuit-off {
color: #d03050;
}
&.circuit-unknown {
color: #f0a020;
}
}
}
}
/*
* 暗色模式适配
*/
@media (prefers-color-scheme: dark) {
.circuit-layout {
.circuit-item {
&:hover {
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.1);
}
}
}
}
</style>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { NCard, NFlex, NText, NIcon, NTag } from 'naive-ui';
import { computed, toRefs } from 'vue';
import { Fan } from '@vicons/fa';
import { ThermometerOutline, WaterOutline, LockClosedOutline, ShieldCheckmarkOutline } from '@vicons/ionicons5';
const props = defineProps<{
fanSpeeds?: number[];
temperature?: number;
humidity?: number;
switches?: number[];
}>();
const { fanSpeeds, temperature, humidity, switches } = toRefs(props);
const cardShow = computed(() => {
return Object.values(props).some((value) => !!value);
});
// 门禁状态 (switches[0]: 0=关闭/失效, 1=打开/生效)
const accessControlStatus = computed(() => {
if (!switches?.value || switches.value.length === 0) return null;
return switches.value[0] === 1 ? '打开' : '关闭';
});
// 防雷状态 (switches[1]: 0=关闭/失效, 1=打开/生效)
const lightningProtectionStatus = computed(() => {
if (!switches?.value || switches.value.length < 2) return null;
return switches.value[1] === 1 ? '生效' : '失效';
});
// 获取状态标签类型
const getStatusTagType = (status: string | null) => {
if (['打开', '生效'].includes(status ?? '')) return 'success';
if (['关闭', '失效'].includes(status ?? '')) return 'error';
return 'default';
};
// 格式化风扇转速
const formatFanSpeeds = 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="cardShow" size="small" title="安防箱状态" hoverable>
<NFlex vertical :size="16">
<!-- 温度 -->
<NFlex v-if="temperature !== undefined" :align="'center'" :size="12">
<NIcon size="16" style="color: #f0a020">
<ThermometerOutline />
</NIcon>
<NText style="width: 60px; font-size: 14px">温度</NText>
<NText style="flex: 1; font-size: 14px; color: #666"> {{ temperature }}°C </NText>
</NFlex>
<!-- 湿度 -->
<NFlex v-if="humidity !== undefined" :align="'center'" :size="12">
<NIcon size="16" style="color: #2080f0">
<WaterOutline />
</NIcon>
<NText style="width: 60px; font-size: 14px">湿度</NText>
<NText style="flex: 1; font-size: 14px; color: #666"> {{ humidity }}% </NText>
</NFlex>
<!-- 风扇转速 -->
<NFlex v-if="formatFanSpeeds" :align="'center'" :size="12">
<NIcon size="16" style="color: #18a058">
<Fan />
</NIcon>
<NText style="width: 60px; font-size: 14px">风扇</NText>
<NText style="flex: 1; font-size: 14px; color: #666">
{{ formatFanSpeeds }}
</NText>
</NFlex>
<!-- 门禁状态 -->
<NFlex v-if="accessControlStatus" :align="'center'" :size="12">
<NIcon size="16" style="color: #722ed1">
<LockClosedOutline />
</NIcon>
<NText style="width: 60px; font-size: 14px">门禁</NText>
<div style="flex: 1">
<NTag :type="getStatusTagType(accessControlStatus)" size="small">
{{ accessControlStatus }}
</NTag>
</div>
</NFlex>
<!-- 防雷状态 -->
<NFlex v-if="lightningProtectionStatus" :align="'center'" :size="12">
<NIcon size="16" style="color: #d4380d">
<ShieldCheckmarkOutline />
</NIcon>
<NText style="width: 60px; font-size: 14px">防雷</NText>
<div style="flex: 1">
<NTag :type="getStatusTagType(lightningProtectionStatus)" size="small">
{{ lightningProtectionStatus }}
</NTag>
</div>
</NFlex>
</NFlex>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import type { NdmCameraResultVO } from '@/apis/models';
import { computed, ref, toRefs } from 'vue';
import DeviceHeaderCard from './device-header-card.vue';
import { NCard, NFlex, NTabPane, NTabs } from 'naive-ui';
import { destr } from 'destr';
import type { NdmDecoderDiagInfo } from '@/apis/domains';
const props = defineProps<{
stationCode: string;
ndmCamera: NdmCameraResultVO;
}>();
const { stationCode, ndmCamera } = toRefs(props);
const lastDiagInfo = computed(() => {
const result = destr<NdmDecoderDiagInfo>(ndmCamera.value.lastDiagInfo);
if (!result) return null;
if (typeof result !== 'object') return null;
return result;
});
const selectedTab = ref('设备状态');
</script>
<template>
<NCard size="small">
<NTabs v-model:value="selectedTab" size="small">
<NTabPane name="设备状态" tab="设备状态">
<NFlex vertical>
<DeviceHeaderCard :device="ndmCamera" />
<div v-if="false">{{ lastDiagInfo }}</div>
</NFlex>
</NTabPane>
<NTabPane name="诊断记录" tab="诊断记录"></NTabPane>
<!-- <NTabPane name="设备配置" tab="设备配置"></NTabPane> -->
</NTabs>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { NdmDecoderResultVO } from '@/apis/models';
import { computed, ref, toRefs } from 'vue';
import DeviceHeaderCard from './device-header-card.vue';
import { NCard, NFlex, NTabPane, NTabs } from 'naive-ui';
import { destr } from 'destr';
import type { NdmDecoderDiagInfo } from '@/apis/domains';
import DeviceHardwareCard from './device-hardware-card.vue';
import DeviceCommonCard from './device-common-card.vue';
const props = defineProps<{
stationCode: string;
ndmDecoder: NdmDecoderResultVO;
}>();
const { stationCode, ndmDecoder } = toRefs(props);
const lastDiagInfo = computed(() => {
const result = destr<NdmDecoderDiagInfo>(ndmDecoder.value.lastDiagInfo);
if (!result) return null;
if (typeof result !== 'object') return null;
return result;
});
const commonInfo = computed<Record<string, string> | undefined>(() => {
const info = lastDiagInfo.value?.stCommonInfo;
if (info) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { CPU使用率, 内存使用率, ...rest } = info;
return rest;
}
return info;
});
const cpuUsage = computed(() => lastDiagInfo.value?.stCommonInfo.CPU使用率);
const memUsage = computed(() => lastDiagInfo.value?.stCommonInfo.内存使用率);
const selectedTab = ref('设备状态');
</script>
<template>
<NCard size="small">
<NTabs v-model:value="selectedTab" size="small">
<NTabPane name="设备状态" tab="设备状态">
<NFlex vertical>
<DeviceHeaderCard :device="ndmDecoder" />
<DeviceCommonCard :common-info="commonInfo" />
<DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" />
</NFlex>
</NTabPane>
<NTabPane name="诊断记录" tab="诊断记录"></NTabPane>
<!-- <NTabPane name="设备配置" tab="设备配置"></NTabPane> -->
<!-- <NTabPane name="原始数据" tab="原始数据">
<pre>{{ { ...ndmDecoder, lastDiagInfo } }}</pre>
</NTabPane> -->
</NTabs>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

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

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import { NCard, NProgress, NFlex, NText, NIcon, type ProgressStatus } from 'naive-ui';
import { TimeOutline, HardwareChipOutline, ServerOutline, FolderOutline } from '@vicons/ionicons5';
import { computed, toRefs } from 'vue';
const props = defineProps<{
cpuUsage?: string;
memUsage?: string;
diskUsage?: string;
runningTime?: string;
}>();
const { cpuUsage, memUsage, diskUsage, runningTime } = toRefs(props);
const cardShow = computed(() => {
return cpuUsage?.value || memUsage?.value || diskUsage?.value || runningTime?.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 ?? '未知';
});
const getProgressStatus = (percent: number): ProgressStatus => {
if (percent >= 90) return 'error';
if (percent >= 70) return 'warning';
return 'success';
};
</script>
<template>
<NCard v-if="cardShow" size="small" title="硬件占用率" hoverable>
<NFlex vertical :size="16">
<!-- CPU 使用率 -->
<NFlex v-if="cpuUsage" :align="'center'" :size="12">
<NIcon size="16" style="color: #18a058">
<HardwareChipOutline />
</NIcon>
<NText style="width: 60px; font-size: 14px">CPU</NText>
<NProgress :percentage="cpuPercent" :status="getProgressStatus(cpuPercent)" style="flex: 1">
<div>{{ cpuPercent }}%</div>
</NProgress>
</NFlex>
<!-- 内存使用率 -->
<NFlex v-if="memUsage" :align="'center'" :size="12">
<NIcon size="16" style="color: #2080f0">
<ServerOutline />
</NIcon>
<NText style="width: 60px; font-size: 14px">内存</NText>
<NProgress :percentage="memPercent" :status="getProgressStatus(memPercent)" style="flex: 1">
<div>{{ memPercent }}%</div>
</NProgress>
</NFlex>
<!-- 硬盘使用率 -->
<NFlex v-if="diskUsage" :align="'center'" :size="12">
<NIcon size="16" style="color: #f0a020">
<FolderOutline />
</NIcon>
<NText style="width: 60px; font-size: 14px">硬盘</NText>
<NProgress :percentage="diskPercent" :status="getProgressStatus(diskPercent)" style="flex: 1">
<div>{{ diskPercent }}%</div>
</NProgress>
</NFlex>
<!-- 运行时间 -->
<NFlex v-if="runningTime" :align="'center'" :size="12">
<NIcon size="16" style="color: #722ed1">
<TimeOutline />
</NIcon>
<NText style="width: 100px; font-size: 14px">系统运行时间</NText>
<NText style="flex: 1; font-size: 14px; color: #666">
{{ formattedRunningTime }}
</NText>
</NFlex>
</NFlex>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import type { NdmDeviceResultVO } from '@/apis/models';
import { DeviceTypeName, getDeviceTypeVal } from '@/enums/device-type';
import { NCard, NFlex, NTag } from 'naive-ui';
import { computed, toRefs } from 'vue';
const props = defineProps<{
device: NdmDeviceResultVO;
}>();
// const emit = defineEmits<{}>();
const { device } = toRefs(props);
const type = computed(() => DeviceTypeName[getDeviceTypeVal(device.value.deviceType)]);
const name = computed(() => device.value.name ?? '-');
const ipAddr = computed(() => device.value.ipAddress ?? '-');
const gbCode = computed(() => Reflect.get(device.value, 'gbCode') as string | undefined);
const status = computed(() => device.value.deviceStatus);
</script>
<template>
<NCard size="small" hoverable>
<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>
</NFlex>
<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>
<span>上游设备</span>
<span>{{ device.linkDescription ?? '暂无' }}</span>
</div> -->
<div v-if="device.snmpEnabled">
<span>上次诊断时间</span>
<span>{{ device.lastDiagTime ?? '暂无' }}</span>
</div>
</div>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { NdmKeyboardResultVO } from '@/apis/models';
import { ref, toRefs } from 'vue';
import DeviceHeaderCard from './device-header-card.vue';
import { NCard, NFlex, NTabPane, NTabs } from 'naive-ui';
const props = defineProps<{
stationCode: string;
ndmKeyboard: NdmKeyboardResultVO;
}>();
const { stationCode, ndmKeyboard } = toRefs(props);
const selectedTab = ref('设备状态');
</script>
<template>
<NCard size="small">
<NTabs v-model:value="selectedTab" size="small">
<NTabPane name="设备状态" tab="设备状态">
<NFlex vertical>
<DeviceHeaderCard :device="ndmKeyboard" />
</NFlex>
</NTabPane>
<NTabPane name="诊断记录" tab="诊断记录"></NTabPane>
<!-- <NTabPane name="设备配置" tab="设备配置"></NTabPane> -->
</NTabs>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import type { NdmNvrResultVO } from '@/apis/models';
import { computed, ref, toRefs } from 'vue';
import DeviceHeaderCard from './device-header-card.vue';
import { NCard, NFlex, NTabPane, NTabs } from 'naive-ui';
import { destr } from 'destr';
import type { NdmNvrDiagInfo } from '@/apis/domains';
import DeviceHardwareCard from './device-hardware-card.vue';
import DeviceCommonCard from './device-common-card.vue';
import NvrDiskCard from './nvr-disk-card.vue';
import NvrRecordDiagCard from './nvr-record-diag-card.vue';
const props = defineProps<{
stationCode: string;
ndmNvr: NdmNvrResultVO;
}>();
const { stationCode, ndmNvr } = toRefs(props);
const lastDiagInfo = computed(() => {
const result = destr<NdmNvrDiagInfo>(ndmNvr.value.lastDiagInfo);
if (!result) return null;
if (typeof result !== 'object') return null;
return result;
});
const cpuUsage = computed(() => lastDiagInfo.value?.stCommonInfo.CPU使用率);
const memUsage = computed(() => lastDiagInfo.value?.stCommonInfo.内存使用率);
const commonInfo = computed<Record<string, string> | undefined>(() => {
const info = lastDiagInfo.value?.stCommonInfo;
if (info) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { CPU使用率, 内存使用率, ...rest } = info;
return rest;
}
return info;
});
const diskHealth = computed(() => lastDiagInfo.value?.info.diskHealth);
const groupInfoList = computed(() => lastDiagInfo.value?.info.groupInfoList);
const isCluster = computed(() => {
const { ipAddress, clusterList } = ndmNvr.value;
if (!clusterList?.trim()) return false;
if (clusterList === ipAddress) return false;
return true;
});
const selectedTab = ref('设备状态');
</script>
<template>
<NCard size="small">
<NTabs v-model:value="selectedTab" size="small">
<NTabPane name="设备状态" tab="设备状态">
<NFlex vertical>
<DeviceHeaderCard :device="ndmNvr" />
<DeviceCommonCard :common-info="commonInfo" />
<DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" />
<NvrDiskCard :disk-health="diskHealth" :group-info-list="groupInfoList" />
<!-- 切换选择的录像机集群时props会改变但是录像诊断组件需要发起请求获取诊断数据请求函数没有响应性因此添加显式的key触发组件的更新 -->
<NvrRecordDiagCard v-if="isCluster" :station-code="stationCode" :ndm-nvr="ndmNvr" :key="ndmNvr.id" />
</NFlex>
</NTabPane>
<NTabPane name="诊断记录" tab="诊断记录"></NTabPane>
<!-- <NTabPane name="设备配置" tab="设备配置"></NTabPane> -->
<!-- <NTabPane name="原始数据" tab="原始数据">
<pre>{{ { ...ndmNvr, lastDiagInfo } }}</pre>
</NTabPane> -->
</NTabs>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,398 @@
<script setup lang="ts">
import type { NdmNvrDiagInfo } from '@/apis/domains';
import { NCard, NFlex, NProgress, NText, NIcon, NPopover, NGrid, NGridItem } from 'naive-ui';
import { HardwareChipOutline, CheckmarkCircleOutline, CloseCircleOutline, WarningOutline } from '@vicons/ionicons5';
import { computed, toRefs } from 'vue';
const props = defineProps<{
diskHealth?: NdmNvrDiagInfo['info']['diskHealth'];
groupInfoList?: NdmNvrDiagInfo['info']['groupInfoList'];
}>();
const { diskHealth, groupInfoList } = toRefs(props);
const cardShow = computed(() => diskHealth.value || groupInfoList.value);
// 计算硬盘健康状态统计
const diskHealthStats = computed(() => {
if (!diskHealth.value) return null;
const total = diskHealth.value.length;
const healthy = diskHealth.value.filter((status) => status === 0).length;
const warning = diskHealth.value.filter((status) => status === 1).length;
const error = diskHealth.value.filter((status) => status === 2).length;
return { total, healthy, warning, error };
});
// 获取硬盘状态样式类
const getDiskStatusClass = (status: number) => {
if (status === 0) return 'disk-healthy';
if (status === 1) return 'disk-warning';
return 'disk-error';
};
// 获取硬盘状态文本
const getDiskStatusText = (status: number) => {
if (status === 0) return '正常';
if (status === 1) return '警告';
return '故障';
};
// 格式化存储大小
const formatSize = (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 getUsagePercent = (freeSize: number, totalSize: number) => {
if (totalSize === 0) return 0;
return Math.round(((totalSize - freeSize) / totalSize) * 100);
};
// 获取使用率状态
const getUsageStatus = (percent: number) => {
if (percent >= 90) return 'error';
if (percent >= 70) return 'warning';
return 'success';
};
</script>
<template>
<NCard v-if="cardShow" size="small" title="磁盘状态" hoverable>
<!-- 硬盘健康状态展示 -->
<NFlex v-if="diskHealth && diskHealthStats" vertical :size="16">
<!-- 硬盘健康统计 -->
<div class="disk-health-summary">
<NFlex :align="'center'" :size="12">
<NIcon size="16" style="color: #18a058">
<HardwareChipOutline />
</NIcon>
<NText style="font-size: 14px; font-weight: 600">硬盘健康状态</NText>
<NText style="font-size: 12px; color: var(--text-color-3)"> ({{ diskHealthStats.total }}个硬盘槽位) </NText>
</NFlex>
<NFlex :size="16" style="margin-top: 8px">
<div class="health-stat">
<NIcon size="14" style="color: #18a058">
<CheckmarkCircleOutline />
</NIcon>
<span class="stat-text">正常: {{ diskHealthStats.healthy }}</span>
</div>
<div v-if="diskHealthStats.warning > 0" class="health-stat">
<NIcon size="14" style="color: #f0a020">
<WarningOutline />
</NIcon>
<span class="stat-text">警告: {{ diskHealthStats.warning }}</span>
</div>
<div v-if="diskHealthStats.error > 0" class="health-stat">
<NIcon size="14" style="color: #d03050">
<CloseCircleOutline />
</NIcon>
<span class="stat-text">故障: {{ diskHealthStats.error }}</span>
</div>
</NFlex>
</div>
<!-- 硬盘槽位状态网格 -->
<div class="disk-slots-grid">
<NGrid :cols="12" :x-gap="4" :y-gap="4">
<NGridItem v-for="(status, index) in diskHealth" :key="index">
<NPopover trigger="hover" placement="top">
<template #trigger>
<div class="disk-slot" :class="getDiskStatusClass(status)">
<div class="slot-number">{{ index + 1 }}</div>
<div class="slot-indicator"></div>
</div>
</template>
<div class="disk-popover-content">
<div class="disk-info-row">
<span class="label">硬盘槽位:</span>
<span class="value">{{ index + 1 }}</span>
</div>
<div class="disk-info-row">
<span class="label">状态:</span>
<span class="value" :class="getDiskStatusClass(status)">
{{ getDiskStatusText(status) }}
</span>
</div>
</div>
</NPopover>
</NGridItem>
</NGrid>
</div>
</NFlex>
<!-- 磁盘阵列信息展示 -->
<NFlex v-if="groupInfoList && groupInfoList.length > 0" vertical :size="16" :style="{ marginTop: diskHealth ? '24px' : '0' }">
<div class="disk-group-header">
<NFlex :align="'center'" :size="12">
<NIcon size="16" style="color: #2080f0">
<HardwareChipOutline />
</NIcon>
<NText style="font-size: 14px; font-weight: 600">磁盘阵列信息</NText>
<NText style="font-size: 12px; color: var(--text-color-3)"> ({{ groupInfoList.length }}个磁盘阵列) </NText>
</NFlex>
</div>
<div v-for="(group, index) in groupInfoList" :key="index" class="disk-group-item">
<div class="group-header">
<span class="group-title">磁盘阵列 {{ index + 1 }}</span>
<span class="group-status" :class="group.state === 1 ? 'status-normal' : 'status-error'">
{{ group.stateValue }}
</span>
</div>
<NFlex vertical :size="8" style="margin-top: 12px">
<!-- 存储使用率 -->
<NFlex :align="'center'" :size="12">
<NText style="width: 80px; font-size: 14px">存储使用率</NText>
<NProgress :percentage="getUsagePercent(group.freeSize, group.totalSize)" :status="getUsageStatus(getUsagePercent(group.freeSize, group.totalSize))" style="flex: 1">
<div>{{ getUsagePercent(group.freeSize, group.totalSize) }}%</div>
</NProgress>
</NFlex>
<!-- 存储详情 -->
<div class="storage-details">
<div class="storage-item">
<span class="storage-label">总容量:</span>
<span class="storage-value">{{ formatSize(group.totalSize) }}</span>
</div>
<div class="storage-item">
<span class="storage-label">已用:</span>
<span class="storage-value">{{ formatSize(group.totalSize - group.freeSize) }}</span>
</div>
<div class="storage-item">
<span class="storage-label">可用:</span>
<span class="storage-value">{{ formatSize(group.freeSize) }}</span>
</div>
</div>
</NFlex>
</div>
</NFlex>
</NCard>
</template>
<style scoped lang="scss">
// 硬盘健康状态样式
.disk-health-summary {
padding: 12px;
background: var(--card-color);
border: 1px solid var(--border-color);
border-radius: 6px;
}
.health-stat {
display: flex;
align-items: center;
gap: 4px;
.stat-text {
font-size: 12px;
color: var(--text-color-2);
}
}
// 硬盘槽位网格样式
.disk-slots-grid {
padding: 12px;
background: var(--card-color);
border: 1px solid var(--border-color);
border-radius: 6px;
}
.disk-slot {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 32px;
height: 28px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--card-color);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.slot-number {
font-size: 10px;
font-weight: 500;
color: var(--text-color-2);
line-height: 1;
}
.slot-indicator {
width: 4px;
height: 4px;
border-radius: 50%;
margin-top: 2px;
}
// 健康状态
&.disk-healthy {
border-color: #18a058;
.slot-indicator {
background-color: #18a058;
}
&:hover {
background-color: rgba(24, 160, 88, 0.05);
}
}
// 警告状态
&.disk-warning {
border-color: #f0a020;
.slot-indicator {
background-color: #f0a020;
}
&:hover {
background-color: rgba(240, 160, 32, 0.05);
}
}
// 故障状态
&.disk-error {
border-color: #d03050;
.slot-indicator {
background-color: #d03050;
}
&:hover {
background-color: rgba(208, 48, 80, 0.05);
}
}
}
// 磁盘阵列信息样式
.disk-group-header {
padding: 12px;
background: var(--card-color);
border: 1px solid var(--border-color);
border-radius: 6px;
}
.disk-group-item {
padding: 16px;
background: var(--card-color);
border: 1px solid var(--border-color);
border-radius: 6px;
.group-header {
display: flex;
justify-content: space-between;
align-items: center;
.group-title {
font-size: 14px;
font-weight: 600;
color: var(--text-color-1);
}
.group-status {
font-size: 12px;
font-weight: 500;
padding: 2px 8px;
border-radius: 12px;
&.status-normal {
color: #18a058;
background-color: rgba(24, 160, 88, 0.1);
}
&.status-error {
color: #d03050;
background-color: rgba(208, 48, 80, 0.1);
}
}
}
}
.storage-details {
display: flex;
gap: 16px;
margin-top: 8px;
.storage-item {
display: flex;
flex-direction: column;
gap: 2px;
.storage-label {
font-size: 11px;
color: var(--text-color-3);
}
.storage-value {
font-size: 12px;
font-weight: 500;
color: var(--text-color-2);
}
}
}
// 弹窗内容样式
.disk-popover-content {
min-width: 120px;
.disk-info-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
&:last-child {
margin-bottom: 0;
}
.label {
font-size: 12px;
color: var(--text-color-3);
margin-right: 12px;
}
.value {
font-size: 12px;
font-weight: 500;
color: var(--text-color-2);
&.disk-healthy {
color: #18a058;
}
&.disk-warning {
color: #f0a020;
}
&.disk-error {
color: #d03050;
}
}
}
}
// 暗色模式适配
@media (prefers-color-scheme: dark) {
.disk-slot {
&:hover {
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.1);
}
}
}
</style>

View File

@@ -0,0 +1,310 @@
<script lang="ts">
import { groupBy } from 'es-toolkit';
import { useMutation } from '@tanstack/vue-query';
type NvrRecordDiag = {
gbCode: string;
recordName: string;
recordDuration: {
startTime: string;
endTime: string;
};
lostRecordList: {
startTime: string;
endTime: string;
}[];
};
// 过滤出丢失的录像时间段
const filterLostRecordList = (recordCheckRawData: any): NvrRecordDiag[] => {
const rawData = (recordCheckRawData as any[]).map((item) => {
return {
...item,
diagInfo: JSON.parse(item.diagInfo),
};
});
// 按国标码分组
const groupedData = groupBy(rawData, (item) => item.gbCode);
// 拆分keys和values
const gbCodeList = Object.keys(groupedData);
const groupedDiagRecordsList = Object.values(groupedData);
// 初始化
let recordChunks = gbCodeList.map((gbCode, index) => ({
gbCode,
recordName: groupedDiagRecordsList[index].at(-1).name,
videoList: [] as any[],
lostVideoList: [] as any[],
}));
// 合并同一gbCode的录像时间记录
groupedDiagRecordsList.forEach((records, index) => {
records.forEach((record) => {
recordChunks[index].videoList.push(...record.diagInfo.recordList);
});
});
recordChunks = recordChunks.filter((chunk) => chunk.videoList.length > 0);
// 计算丢失的录像时间段
recordChunks.forEach((chunk) => {
chunk.videoList.forEach((video, index, videoList) => {
if (videoList[index + 1]) {
// 如果时间不连续,则初步判断录像出现丢失
if (videoList[index + 1].startTime !== video.endTime) {
chunk.lostVideoList.push({
startTime: video.endTime,
endTime: videoList[index + 1].startTime,
});
}
}
});
});
return recordChunks.map((chunk) => ({
gbCode: chunk.gbCode,
recordName: chunk.recordName,
recordDuration: {
startTime: chunk.videoList[0].startTime,
endTime: chunk.videoList.at(-1).endTime,
},
lostRecordList: chunk.lostVideoList,
}));
};
</script>
<script setup lang="ts">
import type { ClientChannel, NdmNvrResultVO, NdmRecordCheck } from '@/apis/models';
import { getChannelList as getChannelListApi, getRecordCheckByParentId as getRecordCheckByParentIdApi, reloadAllRecordCheck as reloadAllRecordCheckApi } from '@/apis/requests';
import { NCard, NFlex, NText, NTag, NTimeline, NTimelineItem, NIcon, NEmpty, NStatistic, NGrid, NGridItem, NCollapse, NCollapseItem, NButton, NPopconfirm } from 'naive-ui';
import { VideocamOutline, TimeOutline, WarningOutline, CheckmarkCircleOutline, RefreshOutline } from '@vicons/ionicons5';
import { computed, onMounted, ref, toRefs } from 'vue';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
dayjs.extend(duration);
const props = defineProps<{
stationCode: string;
ndmNvr: NdmNvrResultVO;
}>();
const { stationCode, ndmNvr } = toRefs(props);
const clientChannelList = ref<ClientChannel[]>([]);
const recordCheckList = ref<NdmRecordCheck[]>([]);
const lostRecordList = computed(() => filterLostRecordList(recordCheckList.value));
// 时间格式化函数
const formatTime = (timeStr: string) => {
return dayjs(timeStr).format('MM-DD HH:mm:ss');
};
const formatDuration = (startTime: string, endTime: string) => {
const start = dayjs(startTime);
const end = dayjs(endTime);
const diff = end.diff(start);
const dur = dayjs.duration(diff);
if (dur.asHours() >= 1) {
return `${Math.floor(dur.asHours())}小时${dur.minutes()}分钟`;
} else if (dur.asMinutes() >= 1) {
return `${dur.minutes()}分钟${dur.seconds()}`;
} else {
return `${dur.seconds()}`;
}
};
/**
* 录像诊断统计信息计算
* 基于lostRecordList数据计算各项统计指标用于在UI顶部展示概览信息
*/
const recordStats = computed(() => {
// 总通道数:参与录像诊断的通道总数
const totalChannels = lostRecordList.value.length;
// 有缺失通道数:存在录像缺失的通道数量
const channelsWithLoss = lostRecordList.value.filter((item) => item.lostRecordList.length > 0).length;
// 总缺失次数:所有通道的录像缺失次数总和
const totalLossCount = lostRecordList.value.reduce((sum, item) => sum + item.lostRecordList.length, 0);
// 计算总缺失时长(毫秒)
let totalLossDuration = 0;
lostRecordList.value.forEach((item) => {
item.lostRecordList.forEach((loss) => {
// 计算每个缺失时间段的持续时长并累加
totalLossDuration += dayjs(loss.endTime).diff(dayjs(loss.startTime));
});
});
// 格式化总缺失时长为可读格式
// 根据时长大小选择合适的显示单位(小时分钟 或 分钟秒)
const formattedTotalDuration =
dayjs.duration(totalLossDuration).asHours() >= 1
? `${Math.floor(dayjs.duration(totalLossDuration).asHours())}小时${dayjs.duration(totalLossDuration).minutes()}分钟`
: `${dayjs.duration(totalLossDuration).minutes()}分钟${dayjs.duration(totalLossDuration).seconds()}`;
return {
totalChannels, // 总通道数
channelsWithLoss, // 有缺失通道数
totalLossCount, // 总缺失次数
formattedTotalDuration, // 格式化的总缺失时长
};
});
const {} = useMutation({
mutationFn: async () => {
const channelList = await getChannelListApi(stationCode.value, ndmNvr.value);
return channelList;
},
onSuccess: (channelList) => {
clientChannelList.value = channelList;
},
});
const { mutate: getRecordCheckByParentId, isPending: loading } = useMutation({
mutationFn: async () => {
const recordCheckList = await getRecordCheckByParentIdApi(stationCode.value, ndmNvr.value, 90);
return recordCheckList;
},
onSuccess: (checkList) => {
recordCheckList.value = checkList;
},
});
const { mutate: reloadAllRecordCheck, isPending: reloading } = useMutation({
mutationFn: async () => {
await reloadAllRecordCheckApi(stationCode.value, 90);
},
onSuccess: () => {
window.$message.success('正在逐步刷新中,请稍后点击刷新按钮查看');
},
});
onMounted(() => {
getRecordCheckByParentId();
});
</script>
<template>
<NCard size="small" hoverable>
<template #header>
<NFlex :align="'center'" :size="24">
<div>录像诊断</div>
<NPopconfirm @positive-click="() => reloadAllRecordCheck()">
<template #trigger>
<NButton secondary size="small" :loading="reloading">
<span>点击更新所有通道录像诊断</span>
</NButton>
</template>
<template #default>
<span>确认更新所有通道录像诊断吗?</span>
</template>
</NPopconfirm>
</NFlex>
</template>
<template #header-extra>
<NButton size="small" quaternary circle :loading="loading" @click="() => getRecordCheckByParentId()">
<template #icon>
<NIcon>
<RefreshOutline />
</NIcon>
</template>
</NButton>
</template>
<!-- 统计信息 -->
<NFlex vertical :size="16">
<NGrid :cols="4" :x-gap="12">
<NGridItem>
<NStatistic label="总通道数" :value="recordStats.totalChannels" />
</NGridItem>
<NGridItem>
<NStatistic label="有缺失通道" :value="recordStats.channelsWithLoss" />
</NGridItem>
<NGridItem>
<NStatistic label="缺失次数" :value="recordStats.totalLossCount" />
</NGridItem>
<NGridItem>
<NStatistic label="缺失时长" :value="recordStats.formattedTotalDuration" />
</NGridItem>
</NGrid>
<!-- 通道录像缺失详情 -->
<div v-if="lostRecordList.length > 0">
<NCollapse>
<NCollapseItem v-for="channel in lostRecordList" :key="channel.gbCode" :name="channel.gbCode">
<template #header>
<NFlex align="center" :size="8">
<NIcon size="16" :color="channel.lostRecordList.length > 0 ? '#f5222d' : '#52c41a'">
<VideocamOutline />
</NIcon>
<NText strong>{{ channel.recordName }}</NText>
<NTag :type="channel.lostRecordList.length > 0 ? 'error' : 'success'" size="small">
{{ channel.lostRecordList.length > 0 ? `${channel.lostRecordList.length}次缺失` : '正常' }}
</NTag>
</NFlex>
</template>
<template #header-extra>
<NFlex align="center" :size="4">
<NIcon size="14" color="#666">
<TimeOutline />
</NIcon>
<NText depth="3" style="font-size: 12px"> {{ formatTime(channel.recordDuration.startTime) }} ~ {{ formatTime(channel.recordDuration.endTime) }} </NText>
</NFlex>
</template>
<div style="padding-left: 24px">
<!-- 录像缺失时间轴 -->
<div v-if="channel.lostRecordList.length > 0">
<NText depth="2" style="margin-bottom: 12px; display: block"> 录像缺失时间段: </NText>
<NTimeline>
<NTimelineItem v-for="(loss, index) in channel.lostRecordList" :key="index" type="error" :time="formatTime(loss.startTime)">
<template #icon>
<NIcon>
<WarningOutline />
</NIcon>
</template>
<template #default>
<NFlex vertical :size="4">
<NText> 缺失时段:{{ formatTime(loss.startTime) }} ~ {{ formatTime(loss.endTime) }} </NText>
<NText depth="3" style="font-size: 12px"> 持续时长:{{ formatDuration(loss.startTime, loss.endTime) }} </NText>
</NFlex>
</template>
</NTimelineItem>
</NTimeline>
</div>
<!-- 无缺失状态 -->
<div v-else>
<NFlex align="center" :size="8" style="padding: 16px 0">
<NIcon size="16" color="#52c41a">
<CheckmarkCircleOutline />
</NIcon>
<NText type="success">该通道录像完整,无缺失时间段</NText>
</NFlex>
</div>
</div>
</NCollapseItem>
</NCollapse>
</div>
<!-- 空状态 -->
<div v-else>
<NEmpty description="暂无录像诊断数据" style="padding: 40px 0" />
</div>
</NFlex>
</NCard>
</template>
<style scoped lang="scss">
// 自定义样式
.n-collapse-item {
margin-bottom: 8px;
}
.n-timeline {
padding-left: 8px;
}
.n-statistic {
text-align: center;
}
</style>

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import type { NdmDecoderResultVO, NdmNvrResultVO, NdmSecurityBoxResultVO } from '@/apis/models';
import { computed, ref, toRefs } from 'vue';
import DeviceHeaderCard from './device-header-card.vue';
import { NCard, NFlex, NTabPane, NTabs } from 'naive-ui';
import { destr } from 'destr';
import type { NdmDecoderDiagInfo, NdmNvrDiagInfo, NdmSecurityBoxDiagInfo } from '@/apis/domains';
import DeviceHardwareCard from './device-hardware-card.vue';
import DeviceCommonCard from './device-common-card.vue';
import BoxInfoCard from './box-info-card.vue';
import BoxCircuitCard from './box-circuit-card.vue';
const props = defineProps<{
stationCode: string;
ndmSecurityBox: NdmSecurityBoxResultVO;
}>();
const { stationCode, ndmSecurityBox } = toRefs(props);
const lastDiagInfo = computed(() => {
const result = destr<NdmSecurityBoxDiagInfo>(ndmSecurityBox.value.lastDiagInfo);
if (!result) return null;
if (typeof result !== 'object') return null;
return result;
});
const commonInfo = computed<Record<string, string> | undefined>(() => {
const info = lastDiagInfo.value?.stCommonInfo;
if (info) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { CPU使用率, 内存使用率, ...rest } = info;
return rest;
}
return info;
});
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);
const selectedTab = ref('设备状态');
</script>
<template>
<NCard size="small">
<NTabs v-model:value="selectedTab" size="small">
<NTabPane name="设备状态" tab="设备状态">
<NFlex vertical>
<DeviceHeaderCard :device="ndmSecurityBox" />
<DeviceCommonCard :common-info="commonInfo" />
<DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" />
<BoxInfoCard :fan-speeds="fanSpeeds" :temperature="temperature" :humidity="humidity" :switches="switches" />
<BoxCircuitCard :station-code="stationCode" :ndm-security-box="ndmSecurityBox" :circuits="circuits" />
</NFlex>
</NTabPane>
<NTabPane name="诊断记录" tab="诊断记录"></NTabPane>
<!-- <NTabPane name="设备配置" tab="设备配置"></NTabPane> -->
<!-- <NTabPane name="原始数据" tab="原始数据">
<pre>{{ { ...ndmSecurityBox, lastDiagInfo } }}</pre>
</NTabPane> -->
</NTabs>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import type { NdmServerDiagInfo } from '@/apis/domains';
import type { NdmServerResultVO } from '@/apis/models';
import { destr } from 'destr';
import { NCard, NFlex, NTabPane, NTabs } from 'naive-ui';
import { computed, ref, toRefs } from 'vue';
import DeviceHeaderCard from './device-header-card.vue';
import DeviceHardwareCard from './device-hardware-card.vue';
const props = defineProps<{
stationCode: string;
ndmServer: NdmServerResultVO;
}>();
const { stationCode, ndmServer } = toRefs(props);
const lastDiagInfo = computed(() => {
const result = destr<NdmServerDiagInfo>(ndmServer.value.lastDiagInfo);
if (!result) return null;
if (typeof result !== 'object') return null;
return result;
});
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.系统运行时间);
const selectedTab = ref('设备状态');
</script>
<template>
<NCard size="small">
<NTabs v-model:value="selectedTab" size="small">
<NTabPane name="设备状态">
<NFlex vertical>
<DeviceHeaderCard :device="ndmServer" />
<DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" :disk-usage="diskUsage" :running-time="runningTime" />
</NFlex>
</NTabPane>
<NTabPane name="诊断记录" tab="诊断记录"></NTabPane>
<!-- <NTabPane name="设备配置" tab="设备配置"></NTabPane> -->
<!-- <NTabPane name="原始数据" tab="原始数据">
<pre>{{ { ...ndmServer, lastDiagInfo } }}</pre>
</NTabPane> -->
</NTabs>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -38,11 +38,13 @@ const selectedTab = ref('设备状态');
<DeviceHeaderCard :device="ndmSwitch" />
<DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" />
<SwitchPortCard :port-info-list="portInfoList" />
<pre style="width: 500px; height: 400px; overflow: auto">{{ lastDiagInfo }}</pre>
</NFlex>
</NTabPane>
<NTabPane name="诊断记录" tab="诊断记录"></NTabPane>
<NTabPane name="设备配置" tab="设备配置"></NTabPane>
<!-- <NTabPane name="设备配置" tab="设备配置"></NTabPane> -->
<!-- <NTabPane name="原始数据" tab="原始数据">
<pre>{{ { ...ndmSwitch, lastDiagInfo } }}</pre>
</NTabPane> -->
</NTabs>
</NCard>
</template>

View File

@@ -0,0 +1,517 @@
<script lang="ts">
const transformPortSpeed = (portInfo: NdmSwitchPortInfo, type: 'in' | 'out' | 'total'): string => {
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s'];
const { inBytes, lastInBytes, outBytes, lastOutBytes, inFlow, outFlow, flow } = portInfo;
let result: number = 0;
if (inFlow && outFlow && flow) {
if (type === 'in') {
result = inFlow;
}
if (type === 'out') {
result = outFlow;
}
if (type === 'total') {
result = flow;
}
} else {
let dInBytes = 0;
let dOutBytes = 0;
if (inBytes < lastInBytes) {
dInBytes = inBytes + JAVA_UNSIGNED_INTEGER_MAX_VALUE - lastInBytes;
} else {
dInBytes = inBytes - lastInBytes;
}
if (outBytes < lastOutBytes) {
dOutBytes = outBytes + JAVA_UNSIGNED_INTEGER_MAX_VALUE - lastOutBytes;
} else {
dOutBytes = outBytes - lastOutBytes;
}
if (type === 'in') {
result = dInBytes;
}
if (type === 'out') {
result = dOutBytes;
}
if (type === 'total') {
result = dInBytes + dOutBytes;
}
result /= NDM_SWITCH_PROBE_INTERVAL;
}
let index = 0;
while (result >= 1024 && index < units.length - 1) {
result /= 1024;
index++;
}
return `${result.toFixed(3)} ${units[index]}`;
};
</script>
<script setup lang="ts">
/**
* 交换机端口布局组件
*
* 功能描述:
* 1. 解析端口名称格式x/y/z按槽位x/y进行分组
* 2. 实现两行纵向优先的Grid布局显示端口
* 3. 提供端口状态的可视化指示(在线/离线/未知)
* 4. 通过悬停弹窗显示端口详细信息和速率数据
*
* 技术特点:
* - 基于 Vue 3 Composition API
* - 使用 NaiveUI 组件库
* - 支持亮色/暗色主题切换
* - 响应式数据处理和布局
*/
// 类型定义导入
import type { NdmSwitchPortInfo } from '@/apis/domains';
// 常量导入
import { JAVA_UNSIGNED_INTEGER_MAX_VALUE, NDM_SWITCH_PROBE_INTERVAL } from '@/constants';
// NaiveUI 组件导入
import { NCard, NGrid, NGridItem, NPopover } from 'naive-ui';
// Vue 3 响应式 API
import { computed, toRefs } from 'vue';
/**
* 组件属性定义
*
* @param portInfoList 端口信息列表
* 数据格式NdmSwitchPortInfo[]
* 每个端口包含portName, upDown, inFlow, outFlow, inBytes, outBytes 等字段
*
* 端口名称格式:"x/y/z"
* - x: 槽位号
* - y: 子卡号
* - z: 接口序号
*
* 示例数据:
* [
* { portName: "1/0/1", upDown: 1, inFlow: 1024, outFlow: 2048, ... },
* { portName: "1/0/2", upDown: 0, inFlow: 0, outFlow: 0, ... },
* { portName: "2/0/1", upDown: 2, inFlow: 512, outFlow: 1024, ... }
* ]
*/
const props = defineProps<{
portInfoList: NdmSwitchPortInfo[];
}>();
const { portInfoList } = toRefs(props);
/**
* 解析端口名称并按槽位分组
*
* 示例输入数据:
* portInfoList = [
* { portName: "1/0/3", upDown: 1, ... },
* { portName: "1/0/1", upDown: 0, ... },
* { portName: "2/0/2", upDown: 1, ... },
* { portName: "1/1/1", upDown: 2, ... },
* { portName: "2/0/1", upDown: 1, ... }
* ]
*/
const groupedPorts = computed(() => {
// 第一步:创建分组容器
// 使用 Map 数据结构存储分组结果key 为槽位标识value 为端口数组
const groups = new Map<string, NdmSwitchPortInfo[]>();
// 第二步:遍历所有端口,按槽位进行分组
portInfoList.value.forEach((port) => {
// 解析端口名称格式 "x/y/z" -> ["x", "y", "z"]
// 例如:"1/0/3" -> ["1", "0", "3"]
const parts = port.portName.split('/');
if (parts.length >= 3) {
// 组合槽位键:槽位号/子卡号 (parts[0]/parts[1])
// 例如:"1" + "/" + "0" = "1/0"
const slotKey = `${parts[0]}/${parts[1]}`;
// 如果该槽位组不存在,创建新的空数组
if (!groups.has(slotKey)) {
groups.set(slotKey, []);
}
// 将端口添加到对应的槽位组中
groups.get(slotKey)!.push(port);
}
});
// 此时 groups 的结构示例:
// Map {
// "1/0" => [{ portName: "1/0/3" }, { portName: "1/0/1" }],
// "2/0" => [{ portName: "2/0/2" }, { portName: "2/0/1" }],
// "1/1" => [{ portName: "1/1/1" }]
// }
// 第三步:对槽位组进行排序并处理每组内的端口排序
return Array.from(groups.entries()) // 转换为数组:[["1/0", [...]], ["2/0", [...]], ["1/1", [...]]]
.sort(([a], [b]) => {
// 解析槽位键进行数值排序
// 例如:"1/0" -> [1, 0], "2/0" -> [2, 0]
const [slotA, subA] = a.split('/').map(Number);
const [slotB, subB] = b.split('/').map(Number);
// 首先按槽位号排序,如果相同则按子卡号排序
// 排序结果:"1/0" -> "1/1" -> "2/0"
return slotA - slotB || subA - subB;
})
.map(([slotKey, ports]) => ({
slotKey, // 槽位标识,如 "1/0"
ports: ports.sort((a, b) => {
// 对每个槽位组内的端口按接口序号排序
// 提取接口序号:"1/0/3" -> "3" -> 3
const portNumA = parseInt(a.portName.split('/')[2]);
const portNumB = parseInt(b.portName.split('/')[2]);
// 按接口序号升序排列端口1 -> 端口2 -> 端口3
return portNumA - portNumB;
}),
}));
// 最终返回结果示例:
// [
// {
// slotKey: "1/0",
// ports: [{ portName: "1/0/1" }, { portName: "1/0/3" }]
// },
// {
// slotKey: "1/1",
// ports: [{ portName: "1/1/1" }]
// },
// {
// slotKey: "2/0",
// ports: [{ portName: "2/0/1" }, { portName: "2/0/2" }]
// }
// ]
});
/**
* 获取端口状态样式类
*
* @param port 端口信息对象
* @returns 对应的CSS类名
*
* 状态映射:
* - upDown = 1: 端口启用 -> 'port-up' (绿色)
* - upDown = 2: 端口禁用 -> 'port-down' (红色)
* - 其他值: 状态未知 -> 'port-unknown' (黄色)
*
* 示例:
* getPortStatusClass({upDown: 1}) -> 'port-up'
* getPortStatusClass({upDown: 2}) -> 'port-down'
* getPortStatusClass({upDown: 0}) -> 'port-unknown'
*/
const getPortStatusClass = (port: NdmSwitchPortInfo) => {
if (port.upDown === 1) {
return 'port-up';
} else if (port.upDown === 2) {
return 'port-down';
}
return 'port-unknown';
};
</script>
<template>
<!-- 主容器使用 NaiveUI 卡片组件包装整个端口布局 -->
<NCard v-if="portInfoList.length > 0" size="small" title="端口数据" hoverable>
<div class="switch-port-layout">
<!--
槽位组遍历根据 groupedPorts 计算属性渲染每个槽位组
数据结构示例
groupedPorts = [
{ slotKey: "1/0", ports: [port1, port2, ...] },
{ slotKey: "1/1", ports: [port3, port4, ...] },
{ slotKey: "2/0", ports: [port5, port6, ...] }
]
-->
<div v-for="group in groupedPorts" :key="group.slotKey" class="slot-group">
<!-- 槽位标题显示槽位标识和端口数量统计 -->
<div class="slot-header">
<span class="slot-title">槽位 {{ group.slotKey }}</span>
<span class="port-count">({{ group.ports.length }}个端口)</span>
</div>
<NGrid :cols="Math.ceil(group.ports.length / 2)" :x-gap="8" :y-gap="8" class="port-grid">
<!--
布局映射逻辑实现两行纵向优先的Grid布局
示例假设有6个端口 [port1, port2, port3, port4, port5, port6]
portIndex: 0 1 2 3 4 5
gridRow: 1 2 1 2 1 2 ((portIndex % 2) + 1)
gridColumn: 1 1 2 2 3 3 (Math.floor(portIndex / 2) + 1)
最终布局效果
第1行port1 port3 port5
第2行port2 port4 port6
这样实现了纵向优先填充的两行布局
-->
<NGridItem
v-for="(port, portIndex) in group.ports"
:key="port.portName"
:style="{
gridRow: (portIndex % 2) + 1, // 奇偶索引分别占第1、2行
gridColumn: Math.floor(portIndex / 2) + 1, // 每两个端口占一列
}"
>
<!--
端口信息弹窗使用 NaiveUI Popover 组件显示详细信息
触发方式hover悬停触发
位置top在端口项上方显示
-->
<NPopover trigger="hover" placement="top">
<template #trigger>
<!--
端口项显示
- 动态应用状态样式类port-up/port-down/port-unknown
- 显示端口接口序号从完整端口名 x/y/z 中提取 z 部分
- 状态指示器圆点
-->
<div class="port-item" :class="getPortStatusClass(port)">
<div class="port-name">{{ port.portName.split('/')[2] }}</div>
<div class="port-status-indicator"></div>
</div>
</template>
<!--
弹窗内容显示端口的详细信息
信息包括
1. 完整端口名称
2. 端口状态启用/禁用/未知
3. 上行速率使用 transformPortSpeed 函数计算
4. 下行速率使用 transformPortSpeed 函数计算
5. 总速率使用 transformPortSpeed 函数计算
-->
<div class="port-popover-content">
<div class="port-info-row">
<span class="label">端口:</span>
<span class="value">{{ port.portName }}</span>
</div>
<div class="port-info-row">
<span class="label">状态:</span>
<span class="value" :class="getPortStatusClass(port)">
{{ port.upDown === 1 ? '启用' : port.upDown === 2 ? '禁用' : '未知' }}
</span>
</div>
<div class="port-info-row">
<span class="label">上行速率:</span>
<span class="value">{{ transformPortSpeed(port, 'out') }}</span>
</div>
<div class="port-info-row">
<span class="label">下行速率:</span>
<span class="value">{{ transformPortSpeed(port, 'in') }}</span>
</div>
<div class="port-info-row">
<span class="label">总速率:</span>
<span class="value">{{ transformPortSpeed(port, 'total') }}</span>
</div>
</div>
</NPopover>
</NGridItem>
</NGrid>
</div>
</div>
</NCard>
</template>
<style scoped lang="scss">
/*
* 交换机端口布局样式
*
* 设计理念:
* 1. 简约清晰的视觉风格
* 2. 直观的端口状态指示
* 3. 良好的交互体验
* 4. 亮色/暗色模式适配
*/
.switch-port-layout {
/* 槽位组容器 - 每个槽位之间保持适当间距 */
.slot-group {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0; // 最后一个槽位组不需要下边距
}
}
/* 槽位标题头部 - 显示槽位信息和端口数量 */
.slot-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-color); // 使用主题变量适配亮暗模式
.slot-title {
font-weight: 600;
font-size: 14px;
color: var(--text-color-1); // 主要文本颜色
}
.port-count {
font-size: 12px;
color: var(--text-color-3); // 次要文本颜色
}
}
/* 端口网格容器 - 限制最大宽度防止过度拉伸 */
.port-grid {
max-width: 100%;
}
/*
* 端口项样式 - 核心视觉元素
*
* 尺寸设计48x36px适合显示端口号和状态指示器
* 状态指示:通过边框颜色和圆点颜色区分端口状态
* 交互效果:悬停时轻微上移和阴影效果
*/
.port-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 48px; // 固定宽度确保布局一致性
height: 36px; // 固定高度确保视觉平衡
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--card-color);
cursor: pointer;
transition: all 0.2s ease; // 平滑的交互动画
/* 悬停效果 - 提升交互体验 */
&:hover {
transform: translateY(-1px); // 轻微上移
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); // 添加阴影
}
/* 端口名称显示 - 只显示接口序号部分 */
.port-name {
font-size: 11px;
font-weight: 500;
color: var(--text-color-2);
line-height: 1; // 紧凑行高
}
/* 端口状态指示器 - 小圆点显示状态 */
.port-status-indicator {
width: 6px;
height: 6px;
border-radius: 50%;
margin-top: 2px;
}
/* 端口启用状态 - 绿色主题 */
&.port-up {
border-color: #18a058; // NaiveUI 成功色
.port-status-indicator {
background-color: #18a058;
}
&:hover {
background-color: rgba(24, 160, 88, 0.05); // 浅绿色背景
}
}
/* 端口禁用状态 - 红色主题 */
&.port-down {
border-color: #d03050; // NaiveUI 错误色
.port-status-indicator {
background-color: #d03050;
}
&:hover {
background-color: rgba(208, 48, 80, 0.05); // 浅红色背景
}
}
/* 端口未知状态 - 黄色主题 */
&.port-unknown {
border-color: #f0a020; // NaiveUI 警告色
.port-status-indicator {
background-color: #f0a020;
}
&:hover {
background-color: rgba(240, 160, 32, 0.05); // 浅黄色背景
}
}
}
}
/*
* 端口信息弹窗内容样式
*
* 功能:显示端口的详细信息,包括状态和速率数据
* 布局:标签-值的两列布局,右对齐数值便于对比
*/
.port-popover-content {
min-width: 160px; // 确保弹窗有足够宽度显示内容
.port-info-row {
display: flex;
justify-content: space-between; // 标签和值分别左右对齐
align-items: center;
margin-bottom: 6px;
&:last-child {
margin-bottom: 0; // 最后一行不需要下边距
}
/* 信息标签样式 - 统一的次要文本样式 */
.label {
font-size: 12px;
color: var(--text-color-3); // 次要文本颜色
margin-right: 12px;
}
/* 信息值样式 - 突出显示的数据 */
.value {
font-size: 12px;
font-weight: 500; // 稍微加粗突出数值
color: var(--text-color-2);
/* 状态值的颜色区分 - 与端口项状态颜色保持一致 */
&.port-up {
color: #18a058; // 启用状态:绿色
}
&.port-down {
color: #d03050; // 禁用状态:红色
}
&.port-unknown {
color: #f0a020; // 未知状态:黄色
}
}
}
}
/*
* 暗色模式适配
*
* 针对系统暗色模式的特殊样式调整
* 主要调整阴影效果,使其在暗色背景下更加明显
*/
@media (prefers-color-scheme: dark) {
.switch-port-layout {
.port-item {
&:hover {
/* 暗色模式下使用白色阴影替代黑色阴影 */
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.1);
}
}
}
}
</style>

View File

@@ -9,7 +9,7 @@ import ServerCard from '@/components/device-page/server-card.vue';
import SwitchCard from '@/components/device-page/switch-card.vue';
import { useDeviceSelection } from '@/composables/device';
import { useLineDevicesQuery } from '@/composables/query';
import { DeviceType, getDeviceTypeVal, type DeviceTypeVal } from '@/enums/device-type';
import { DeviceType, type DeviceTypeVal } from '@/enums/device-type';
import { useLineDevicesStore } from '@/stores/line-devices';
import { useStationStore } from '@/stores/station';
import { ChevronBack } from '@vicons/ionicons5';
@@ -93,28 +93,27 @@ watch(
<!-- 内容区域 -->
<template v-if="selectedDevice && selectedStationCode">
<NScrollbar x-scrollable style="height: 100%; padding-right: 24px">
<template v-if="getDeviceTypeVal(selectedDevice.deviceType) === DeviceType.Camera">
<template v-if="selectedDeviceType === DeviceType.Camera">
<CameraCard :station-code="selectedStationCode" :ndm-camera="selectedDevice" />
</template>
<template v-if="getDeviceTypeVal(selectedDevice.deviceType) === DeviceType.Decoder">
<template v-if="selectedDeviceType === DeviceType.Decoder">
<DecoderCard :station-code="selectedStationCode" :ndm-decoder="selectedDevice" />
</template>
<template v-if="getDeviceTypeVal(selectedDevice.deviceType) === DeviceType.Keyboard">
<template v-if="selectedDeviceType === DeviceType.Keyboard">
<KeyboardCard :station-code="selectedStationCode" :ndm-keyboard="selectedDevice" />
</template>
<template v-if="getDeviceTypeVal(selectedDevice.deviceType) === DeviceType.Nvr">
<template v-if="selectedDeviceType === DeviceType.Nvr">
<NvrCard :station-code="selectedStationCode" :ndm-nvr="selectedDevice" />
</template>
<template v-if="getDeviceTypeVal(selectedDevice.deviceType) === DeviceType.SecurityBox">
<template v-if="selectedDeviceType === DeviceType.SecurityBox">
<SecurityBoxCard :station-code="selectedStationCode" :ndm-security-box="selectedDevice" />
</template>
<template v-if="([DeviceType.MediaServer, DeviceType.VideoServer] as DeviceTypeVal[]).includes(getDeviceTypeVal(selectedDevice.deviceType))">
<template v-if="([DeviceType.MediaServer, DeviceType.VideoServer] as DeviceTypeVal[]).includes(selectedDeviceType)">
<ServerCard :station-code="selectedStationCode" :ndm-server="selectedDevice" />
</template>
<template v-if="getDeviceTypeVal(selectedDevice.deviceType) === DeviceType.Switch">
<template v-if="selectedDeviceType === DeviceType.Switch">
<SwitchCard :station-code="selectedStationCode" :ndm-switch="selectedDevice" />
</template>
<!-- <pre style="width: 500px; height: 300px; overflow: scroll">{{ selectedDevice }}</pre> -->
</NScrollbar>
</template>
<template v-else>