310 lines
12 KiB
Vue
310 lines
12 KiB
Vue
<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>
|