feat: device cards
This commit is contained in:
361
src/components/device-page/box-circuit-card.vue
Normal file
361
src/components/device-page/box-circuit-card.vue
Normal 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>
|
||||||
107
src/components/device-page/box-info-card.vue
Normal file
107
src/components/device-page/box-info-card.vue
Normal 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>
|
||||||
41
src/components/device-page/camera-card.vue
Normal file
41
src/components/device-page/camera-card.vue
Normal 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>
|
||||||
60
src/components/device-page/decoder-card.vue
Normal file
60
src/components/device-page/decoder-card.vue
Normal 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>
|
||||||
26
src/components/device-page/device-common-card.vue
Normal file
26
src/components/device-page/device-common-card.vue
Normal 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>
|
||||||
95
src/components/device-page/device-hardware-card.vue
Normal file
95
src/components/device-page/device-hardware-card.vue
Normal 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>
|
||||||
57
src/components/device-page/device-header-card.vue
Normal file
57
src/components/device-page/device-header-card.vue
Normal 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>
|
||||||
31
src/components/device-page/keyboard-card.vue
Normal file
31
src/components/device-page/keyboard-card.vue
Normal 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>
|
||||||
75
src/components/device-page/nvr-card.vue
Normal file
75
src/components/device-page/nvr-card.vue
Normal 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>
|
||||||
398
src/components/device-page/nvr-disk-card.vue
Normal file
398
src/components/device-page/nvr-disk-card.vue
Normal 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>
|
||||||
310
src/components/device-page/nvr-record-diag-card.vue
Normal file
310
src/components/device-page/nvr-record-diag-card.vue
Normal 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>
|
||||||
71
src/components/device-page/security-box-card.vue
Normal file
71
src/components/device-page/security-box-card.vue
Normal 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>
|
||||||
50
src/components/device-page/server-card.vue
Normal file
50
src/components/device-page/server-card.vue
Normal 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>
|
||||||
@@ -38,11 +38,13 @@ const selectedTab = ref('设备状态');
|
|||||||
<DeviceHeaderCard :device="ndmSwitch" />
|
<DeviceHeaderCard :device="ndmSwitch" />
|
||||||
<DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" />
|
<DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" />
|
||||||
<SwitchPortCard :port-info-list="portInfoList" />
|
<SwitchPortCard :port-info-list="portInfoList" />
|
||||||
<pre style="width: 500px; height: 400px; overflow: auto">{{ lastDiagInfo }}</pre>
|
|
||||||
</NFlex>
|
</NFlex>
|
||||||
</NTabPane>
|
</NTabPane>
|
||||||
<NTabPane name="诊断记录" tab="诊断记录"></NTabPane>
|
<NTabPane name="诊断记录" tab="诊断记录"></NTabPane>
|
||||||
<NTabPane name="设备配置" tab="设备配置"></NTabPane>
|
<!-- <NTabPane name="设备配置" tab="设备配置"></NTabPane> -->
|
||||||
|
<!-- <NTabPane name="原始数据" tab="原始数据">
|
||||||
|
<pre>{{ { ...ndmSwitch, lastDiagInfo } }}</pre>
|
||||||
|
</NTabPane> -->
|
||||||
</NTabs>
|
</NTabs>
|
||||||
</NCard>
|
</NCard>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
517
src/components/device-page/switch-port-card.vue
Normal file
517
src/components/device-page/switch-port-card.vue
Normal 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>
|
||||||
@@ -9,7 +9,7 @@ import ServerCard from '@/components/device-page/server-card.vue';
|
|||||||
import SwitchCard from '@/components/device-page/switch-card.vue';
|
import SwitchCard from '@/components/device-page/switch-card.vue';
|
||||||
import { useDeviceSelection } from '@/composables/device';
|
import { useDeviceSelection } from '@/composables/device';
|
||||||
import { useLineDevicesQuery } from '@/composables/query';
|
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 { useLineDevicesStore } from '@/stores/line-devices';
|
||||||
import { useStationStore } from '@/stores/station';
|
import { useStationStore } from '@/stores/station';
|
||||||
import { ChevronBack } from '@vicons/ionicons5';
|
import { ChevronBack } from '@vicons/ionicons5';
|
||||||
@@ -93,28 +93,27 @@ watch(
|
|||||||
<!-- 内容区域 -->
|
<!-- 内容区域 -->
|
||||||
<template v-if="selectedDevice && selectedStationCode">
|
<template v-if="selectedDevice && selectedStationCode">
|
||||||
<NScrollbar x-scrollable style="height: 100%; padding-right: 24px">
|
<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" />
|
<CameraCard :station-code="selectedStationCode" :ndm-camera="selectedDevice" />
|
||||||
</template>
|
</template>
|
||||||
<template v-if="getDeviceTypeVal(selectedDevice.deviceType) === DeviceType.Decoder">
|
<template v-if="selectedDeviceType === DeviceType.Decoder">
|
||||||
<DecoderCard :station-code="selectedStationCode" :ndm-decoder="selectedDevice" />
|
<DecoderCard :station-code="selectedStationCode" :ndm-decoder="selectedDevice" />
|
||||||
</template>
|
</template>
|
||||||
<template v-if="getDeviceTypeVal(selectedDevice.deviceType) === DeviceType.Keyboard">
|
<template v-if="selectedDeviceType === DeviceType.Keyboard">
|
||||||
<KeyboardCard :station-code="selectedStationCode" :ndm-keyboard="selectedDevice" />
|
<KeyboardCard :station-code="selectedStationCode" :ndm-keyboard="selectedDevice" />
|
||||||
</template>
|
</template>
|
||||||
<template v-if="getDeviceTypeVal(selectedDevice.deviceType) === DeviceType.Nvr">
|
<template v-if="selectedDeviceType === DeviceType.Nvr">
|
||||||
<NvrCard :station-code="selectedStationCode" :ndm-nvr="selectedDevice" />
|
<NvrCard :station-code="selectedStationCode" :ndm-nvr="selectedDevice" />
|
||||||
</template>
|
</template>
|
||||||
<template v-if="getDeviceTypeVal(selectedDevice.deviceType) === DeviceType.SecurityBox">
|
<template v-if="selectedDeviceType === DeviceType.SecurityBox">
|
||||||
<SecurityBoxCard :station-code="selectedStationCode" :ndm-security-box="selectedDevice" />
|
<SecurityBoxCard :station-code="selectedStationCode" :ndm-security-box="selectedDevice" />
|
||||||
</template>
|
</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" />
|
<ServerCard :station-code="selectedStationCode" :ndm-server="selectedDevice" />
|
||||||
</template>
|
</template>
|
||||||
<template v-if="getDeviceTypeVal(selectedDevice.deviceType) === DeviceType.Switch">
|
<template v-if="selectedDeviceType === DeviceType.Switch">
|
||||||
<SwitchCard :station-code="selectedStationCode" :ndm-switch="selectedDevice" />
|
<SwitchCard :station-code="selectedStationCode" :ndm-switch="selectedDevice" />
|
||||||
</template>
|
</template>
|
||||||
<!-- <pre style="width: 500px; height: 300px; overflow: scroll">{{ selectedDevice }}</pre> -->
|
|
||||||
</NScrollbar>
|
</NScrollbar>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|||||||
Reference in New Issue
Block a user