Compare commits

...

5 Commits

Author SHA1 Message Date
yangsy
9ca5630c87 refactor: 移除未使用的测试组件 2025-12-12 20:15:16 +08:00
yangsy
d73861442c refactor: 移除设置面板中未使用的导入 2025-12-12 14:55:57 +08:00
yangsy
3bfef2b14a fix: 修复报警主机API未导出的问题 2025-12-12 13:38:18 +08:00
yangsy
5bfda437a6 feat: 新增流媒体/信令服务状态卡片 2025-12-12 10:44:00 +08:00
yangsy
77406f1932 style: 车站卡片文字排版 2025-12-11 15:55:27 +08:00
11 changed files with 106 additions and 481 deletions

View File

@@ -1,3 +1,4 @@
export * from './client-channel';
export * from './media-server-status';
export * from './record-info';
export * from './record-item';

View File

@@ -0,0 +1,5 @@
export interface MediaServerStatus {
id: string;
ip: string;
online: boolean;
}

View File

@@ -0,0 +1 @@
export * from './ndm-alarm-host';

View File

@@ -1,2 +1,3 @@
export * from './ndm-security-box';
export * from './ndm-service-available';
export * from './ndm-switch';

View File

@@ -0,0 +1,25 @@
import { ndmClient, userClient, type MediaServerStatus, type Station } from '@/apis';
export const isMediaServerAliveApi = async (options?: { stationCode?: Station['code']; signal?: AbortSignal }) => {
const { stationCode, signal } = options ?? {};
const client = stationCode ? ndmClient : userClient;
const prefix = stationCode ? `/${stationCode}` : '';
const endpoint = `${prefix}/api/ndm/ndmServiceAvailable/mediaServer/isAlive`;
const resp = await client.get<MediaServerStatus[]>(endpoint, { signal });
const [err, data] = resp;
if (err) throw err;
if (!data) throw new Error(`${data}`);
return data;
};
export const isSipServerAliveApi = async (options?: { stationCode?: Station['code']; signal?: AbortSignal }) => {
const { stationCode, signal } = options ?? {};
const client = stationCode ? ndmClient : userClient;
const prefix = stationCode ? `/${stationCode}` : '';
const endpoint = `${prefix}/api/ndm/ndmServiceAvailable/sipServer/isAlive`;
const resp = await client.get<boolean>(endpoint, { signal });
const [err, data] = resp;
if (err) throw err;
if (data === null) throw new Error(`${data}`);
return data;
};

View File

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

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { isMediaServerAliveApi, isSipServerAliveApi, type NdmServerResultVO, type Station } from '@/apis';
import { DEVICE_TYPE_LITERALS, tryGetDeviceType } from '@/enums';
import { useSettingStore } from '@/stores';
import { useQuery } from '@tanstack/vue-query';
import { NCard, NTag } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, toRefs } from 'vue';
const props = defineProps<{
ndmDevice: NdmServerResultVO;
station: Station;
}>();
const settingStore = useSettingStore();
const { offlineDev } = storeToRefs(settingStore);
const { ndmDevice, station } = toRefs(props);
const deviceType = computed(() => tryGetDeviceType(ndmDevice.value.deviceType));
const { data: isMediaServerAlive } = useQuery({
queryKey: computed(() => ['media-server-alive-query', ndmDevice.value.id, ndmDevice.value.lastDiagTime]),
enabled: computed(() => !offlineDev.value && deviceType.value === DEVICE_TYPE_LITERALS.ndmMediaServer),
queryFn: async ({ signal }) => {
const alives = await isMediaServerAliveApi({ stationCode: station.value.code, signal });
return alives.find((alive) => alive.ip === ndmDevice.value.ipAddress);
},
refetchInterval: 30 * 1000,
});
const { data: isSipServerAlive } = useQuery({
queryKey: computed(() => ['video-server-alive-query', ndmDevice.value.id, ndmDevice.value.lastDiagTime]),
enabled: computed(() => !offlineDev.value && deviceType.value === DEVICE_TYPE_LITERALS.ndmVideoServer),
queryFn: async ({ signal }) => {
return await isSipServerAliveApi({ stationCode: station.value.code, signal });
},
refetchInterval: 30 * 1000,
});
</script>
<template>
<NCard hoverable size="small">
<template #header>
<span>服务状态</span>
</template>
<template #default>
<div v-if="deviceType === DEVICE_TYPE_LITERALS.ndmMediaServer">
<span>流媒体服务状态</span>
<template v-if="isMediaServerAlive">
<NTag size="small" :type="isMediaServerAlive.online ? 'success' : 'error'">{{ isMediaServerAlive.online ? '在线' : '离线' }}</NTag>
</template>
<span v-else>-</span>
</div>
<div v-if="deviceType === DEVICE_TYPE_LITERALS.ndmVideoServer">
<span>信令服务状态</span>
<template v-if="isSipServerAlive">
<NTag size="small" :type="isSipServerAlive ? 'success' : 'error'">{{ isSipServerAlive ? '在线' : '离线' }}</NTag>
</template>
<span v-else>-</span>
</div>
</template>
</NCard>
</template>
<style scoped lang="scss"></style>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { NdmServerDiagInfo, NdmServerResultVO, Station } from '@/apis';
import { DeviceHardwareCard, DeviceHeaderCard } from '@/components';
import { type NdmServerDiagInfo, type NdmServerResultVO, type Station } from '@/apis';
import { DeviceHardwareCard, DeviceHeaderCard, ServerAlive } from '@/components';
import destr from 'destr';
import { NFlex } from 'naive-ui';
import { computed, toRefs } from 'vue';
@@ -29,6 +29,7 @@ const runningTime = computed(() => lastDiagInfo.value?.commInfo?.系统运行时
<NFlex vertical>
<DeviceHeaderCard :ndm-device="ndmDevice" :station="station" />
<DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" :disk-usage="diskUsage" :running-time="runningTime" />
<ServerAlive :ndm-device="ndmDevice" :station="station" />
</NFlex>
</template>

View File

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

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { LineAlarms, LineDevices, NdmDeviceResultVO, Station, VersionInfo } from '@/apis';
import type { LineAlarms, LineDevices, Station, VersionInfo } from '@/apis';
import { ThemeSwitch } from '@/components';
import { NDM_ALARM_STORE_ID, NDM_DEVICE_STORE_ID, NDM_STATION_STORE_ID } from '@/constants';
import { usePollingStore, useSettingStore } from '@/stores';

View File

@@ -135,9 +135,9 @@ const onSelectDropdownOption = (key: string, option: DropdownOption) => {
<NFlex justify="flex-end" align="center" :size="2">
<div>
<span :style="{ color: onlineDeviceCount > 0 ? themeVars.successColor : '' }">在线{{ onlineDeviceCount }}</span>
<span :style="{ color: onlineDeviceCount > 0 ? themeVars.successColor : '' }">在线 {{ onlineDeviceCount }} </span>
<span> · </span>
<span :style="{ color: offlineDeviceCount > 0 ? themeVars.errorColor : '' }">离线 {{ offlineDeviceCount }}</span>
<span :style="{ color: offlineDeviceCount > 0 ? themeVars.errorColor : '' }">离线 {{ offlineDeviceCount }} </span>
</div>
<!-- 占位按钮对齐布局 -->
<NButton quaternary size="tiny" :focusable="false" style="visibility: hidden">