Files
ndm-web-client/src/components/device-page/device-tree.vue
yangsy 168d11c71b style
2025-12-01 14:43:58 +08:00

310 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
const deviceTabPanes = Object.keys(DeviceType).map((key) => {
const name = DeviceType[key as DeviceTypeKey];
return {
name,
tab: DeviceTypeName[name],
};
});
</script>
<script setup lang="ts">
import type { LineDevices, NdmDeviceResultVO, NdmNvrResultVO, Station } from '@/apis';
import { isNvrCluster } from '@/helper';
import { DeviceType, DeviceTypeName, tryGetDeviceTypeVal, type DeviceTypeKey, type DeviceTypeVal } from '@/enums';
import { destr } from 'destr';
import {
NButton,
NFlex,
NInput,
NRadio,
NRadioGroup,
NTab,
NTabs,
NTag,
NTree,
useThemeVars,
type TagProps,
type TreeInst,
type TreeOption,
type TreeOverrideNodeClickBehavior,
type TreeProps,
} from 'naive-ui';
import { computed, h, ref, toRefs, useTemplateRef, watch, type CSSProperties } from 'vue';
const themeVars = useThemeVars();
const props = defineProps<{
stationList: Station[];
lineDevices: LineDevices;
selectedStationCode?: string;
selectedDeviceType: DeviceTypeVal;
selectedDevice?: NdmDeviceResultVO;
}>();
const emit = defineEmits<{
'select-device': [device: NdmDeviceResultVO, stationCode: string];
}>();
const { stationList, lineDevices, selectedStationCode, selectedDeviceType, selectedDevice } = toRefs(props);
const activeTab = ref<DeviceTypeVal>(DeviceType.Camera);
watch(
() => selectedDeviceType.value,
(newType) => (activeTab.value = newType),
{ immediate: true },
);
const selectedKeys = computed(() => (selectedDevice.value?.id ? [selectedDevice.value.id] : undefined));
// 树节点交互
const onSelectDevice = (device: NdmDeviceResultVO, stationCode: string) => {
emit('select-device', device, stationCode);
};
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: MouseEvent) => {
if (option['device']) {
payload.stopPropagation();
const device = option['device'] as NdmDeviceResultVO;
const stationCode = option['stationCode'] as string;
onSelectDevice(device, stationCode);
}
},
};
};
// ========== 设备树数据 ==========
const lineDeviceTreeData = computed<Record<string, TreeOption[]>>(() => {
const treeData: Record<string, TreeOption[]> = {};
deviceTabPanes.forEach(({ name: paneName /* , tab: paneTab */ }) => {
treeData[paneName] = stationList.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 === DeviceType.Nvr) {
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: () => renderStationNodeSuffix(onlineDevices?.length ?? 0, offlineDevices?.length ?? 0, devices?.length ?? 0),
children: nvrClusters.map<TreeOption>((nvrCluster) => {
return {
label: `${nvrCluster.name}`,
key: nvrCluster.id,
prefix: () => renderDeviceNodePrefix(nvrCluster, stationCode),
suffix: () => `${nvrCluster.ipAddress}`,
children: nvrSingletons.map<TreeOption>((nvr) => {
return {
label: `${nvr.name}`,
key: nvr.id,
prefix: () => renderDeviceNodePrefix(nvr, stationCode),
suffix: () => `${nvr.ipAddress}`,
// 当选择设备时,能获取到设备的所有信息,以及设备所属的车站
device: nvr,
stationCode,
};
}),
// 当选择设备时,能获取到设备的所有信息,以及设备所属的车站
device: nvrCluster,
stationCode,
};
}),
};
}
return {
label: stationName,
key: stationCode,
prefix: () => renderStationNodePrefix(station),
suffix: () => renderStationNodeSuffix(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,
prefix: () => renderDeviceNodePrefix(device, stationCode),
suffix: () => `${device.ipAddress}`,
// 当选择设备时,能获取到设备的所有信息,以及设备所属的车站
device,
stationCode,
};
}) ?? [],
};
});
});
return treeData;
});
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 renderStationNodeSuffix = (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 renderDeviceStatusTag = (device: NdmDeviceResultVO) => {
const { deviceStatus } = device;
// const tagType: TagProps['type'] = deviceStatus === '10' ? 'success' : deviceStatus === '20' ? 'error' : 'warning';
// const tagText = device.deviceStatus === '10' ? '在线' : device.deviceStatus === '20' ? '离线' : '未知';
// return h(NTag, { type: tagType, bordered: false, size: 'tiny' }, { default: () => '◉' }); // ⁕∘∙⋅﹒▪●
const color = deviceStatus === '10' ? themeVars.value.successColor : deviceStatus === '20' ? themeVars.value.errorColor : themeVars.value.warningColor;
return h('div', { style: { color } }, { default: () => '◉' }); // ⁕∘∙⋅﹒▪●
};
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();
// 选择设备
onSelectDevice(device, stationCode);
},
},
() => '查看',
);
};
return h(NFlex, { size: 'small' }, { default: () => [renderViewDeviceButton(device, stationCode), renderDeviceStatusTag(device)] });
};
// ========== 设备树搜索 ==========
const searchInput = ref('');
const statusInput = ref('');
// 设备树将搜索框和单选框的值都交给NTree的pattern属性
// 但是如果一个车站下没有匹配的设备,那么这个车站节点也不会显示
const searchPattern = computed(() => {
const search = searchInput.value;
const status = statusInput.value;
if (!search && !status) return ''; // 如果pattern非空会导致NTree组件认为筛选完成UI上发生全量匹配
return JSON.stringify({ search: searchInput.value, status: statusInput.value });
});
const searchFilter = (pattern: string, node: TreeOption): boolean => {
const { search, status } = destr<{ search: string; status: string }>(pattern);
const device = node['device'] as NdmDeviceResultVO | undefined;
const { name, ipAddress, deviceId, deviceStatus } = device ?? {};
const searchMatched = (name ?? '').includes(search) || (ipAddress ?? '').includes(search) || (deviceId ?? '').includes(search);
const statusMatched = status === '' || status === deviceStatus;
return searchMatched && statusMatched;
};
// ========== 设备树交互 ==========
const expandedKeys = ref<string[]>();
const deviceTreeInst = useTemplateRef<TreeInst>('deviceTreeInst');
const onClickFoldDeviceTree = () => {
expandedKeys.value = [];
};
const onClickLocateDeviceTree = () => {
const stationCode = selectedStationCode.value;
const device = selectedDevice.value;
if (!stationCode || !device?.id) return;
const deviceTypeVal = tryGetDeviceTypeVal(device.deviceType);
if (!!deviceTypeVal) {
activeTab.value = deviceTypeVal;
}
const expanded = [stationCode];
if (activeTab.value === DeviceType.Nvr) {
const nvrs = lineDevices.value[stationCode]?.[DeviceType.Nvr];
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();
};
const scrollDeviceTreeToSelectedDevice = () => {
setTimeout(() => {
const inst = deviceTreeInst.value;
inst?.scrollTo({ key: selectedDevice?.value?.id, behavior: 'smooth' });
}, 350);
};
</script>
<template>
<div style="height: 100%; display: flex; flex-direction: column">
<div style="padding: 12px; flex-shrink: 0">
<NInput v-model:value="searchInput" placeholder="搜索设备名称、设备ID或IP地址" clearable />
<NFlex justify="space-between" align="center">
<NRadioGroup v-model:value="statusInput">
<NRadio value="">全部</NRadio>
<NRadio value="10">在线</NRadio>
<NRadio value="20">离线</NRadio>
</NRadioGroup>
<NFlex :align="'center'">
<NButton text size="tiny" type="info" @click="onClickFoldDeviceTree">收起</NButton>
<NButton text size="tiny" type="info" @click="onClickLocateDeviceTree">定位</NButton>
</NFlex>
</NFlex>
</div>
<div style="min-height: 0; overflow: hidden; flex: 1; display: flex">
<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
:ref="'deviceTreeInst'"
v-model:expanded-keys="expandedKeys"
:selected-keys="selectedKeys"
:data="activeTab ? lineDeviceTreeData[activeTab] : []"
:show-irrelevant-nodes="false"
:pattern="searchPattern"
:filter="searchFilter"
:override-default-node-click-behavior="override"
:node-props="nodeProps"
:default-expand-all="false"
block-line
block-node
show-line
virtual-scroll
style="height: 100%"
/>
</div>
</div>
</div>
</template>
<style scoped></style>