feat: 设备树添加管理功能

- 新增设备导入、导出、删除功能及相关API
- 封装设备管理逻辑,拆分设备选择与设备管理逻辑
- 添加右键菜单支持设备管理操作
This commit is contained in:
yangsy
2025-12-17 13:50:26 +08:00
parent 073a29a83a
commit 03d5fb3fcd
13 changed files with 576 additions and 109 deletions

View File

@@ -5,10 +5,12 @@ import { DEVICE_TYPE_NAMES, DEVICE_TYPE_LITERALS, tryGetDeviceType, type DeviceT
import { isNvrCluster } from '@/helpers';
import { useDeviceStore, useStationStore } from '@/stores';
import { sleep } from '@/utils';
import { watchDebounced, watchImmediate } from '@vueuse/core';
import { watchImmediate } from '@vueuse/core';
import destr from 'destr';
import { isFunction } from 'es-toolkit';
import {
NButton,
NDropdown,
NFlex,
NInput,
NRadio,
@@ -18,6 +20,7 @@ import {
NTag,
NTree,
useThemeVars,
type DropdownOption,
type TagProps,
type TreeInst,
type TreeOption,
@@ -25,7 +28,7 @@ import {
type TreeProps,
} from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, h, onMounted, ref, toRefs, useTemplateRef, watch, type CSSProperties } from 'vue';
import { computed, h, onBeforeUnmount, ref, toRefs, useTemplateRef, watch, type CSSProperties } from 'vue';
const props = defineProps<{
station?: Station; // 支持渲染指定车站的设备树
@@ -39,7 +42,19 @@ const { station } = toRefs(props);
const themeVars = useThemeVars();
const { selectedStationCode, selectedDeviceType, selectedDevice, initFromRoute, selectDevice, routeDevice } = useDeviceTree();
const {
// 设备选择
selectedStationCode,
selectedDeviceType,
selectedDevice,
selectDevice,
routeDevice,
// 设备管理
exportDevice,
exportDeviceTemplate,
importDevice,
deleteDevice,
} = useDeviceTree();
const onSelectDevice = (device: NdmDeviceResultVO, stationCode: Station['code']) => {
selectDevice(device, stationCode);
@@ -56,23 +71,6 @@ 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],
@@ -91,6 +89,90 @@ watch([selectedKeys, selectedDevice, selectedStationCode], ([, device, code]) =>
}
});
const abortController = ref(new AbortController());
const contextmenu = ref<{ x: number; y: number; stationCode?: Station['code']; deviceType?: DeviceType | null; device?: NdmDeviceResultVO }>({ x: 0, y: 0, deviceType: null });
const showContextmenu = ref(false);
const contextmenuOptions = computed<DropdownOption[]>(() => [
{
label: '导出设备',
key: 'export-device',
show: !!contextmenu.value.deviceType,
onSelect: () => {
const { stationCode, deviceType } = contextmenu.value;
// console.log(stationCode, deviceType);
showContextmenu.value = false;
if (!stationCode || !deviceType) return;
abortController.value.abort();
abortController.value = new AbortController();
exportDevice({ deviceType, stationCode, signal: abortController.value.signal });
},
},
{
label: '导入设备',
key: 'import-device',
show: !!contextmenu.value.deviceType,
onSelect: () => {
const { stationCode, deviceType } = contextmenu.value;
// console.log(stationCode, deviceType);
showContextmenu.value = false;
if (!stationCode || !deviceType) return;
abortController.value.abort();
abortController.value = new AbortController();
importDevice({ deviceType, stationCode, signal: abortController.value.signal });
},
},
{
label: '下载导入模板',
key: 'export-template',
// 导出模板功能有缺陷,暂时不展示
show: false,
onSelect: () => {
const { stationCode, deviceType } = contextmenu.value;
// console.log(stationCode, deviceType);
showContextmenu.value = false;
if (!stationCode || !deviceType) return;
abortController.value.abort();
abortController.value = new AbortController();
exportDeviceTemplate({ deviceType, stationCode, signal: abortController.value.signal });
},
},
{
label: '删除设备',
key: 'delete-device',
show: !!contextmenu.value.device,
onSelect: () => {
const { stationCode, device } = contextmenu.value;
// console.log(stationCode, device);
showContextmenu.value = false;
if (!stationCode || !device) return;
const id = device.id;
const deviceType = tryGetDeviceType(device.deviceType);
if (!id || !deviceType) return;
window.$dialog.destroyAll();
window.$dialog.warning({
title: '删除设备',
content: `确认删除设备 ${device.name || device.deviceId || device.id} 吗?`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: () => {
abortController.value.abort();
abortController.value = new AbortController();
deleteDevice({ id, deviceType, stationCode, signal: abortController.value.signal });
},
});
},
},
]);
const onSelectDropdownOption = (key: string, option: DropdownOption) => {
const onSelect = option['onSelect'];
if (isFunction(onSelect)) {
onSelect();
}
};
onBeforeUnmount(() => {
abortController.value.abort();
});
// ========== 设备树节点交互 ==========
const override: TreeOverrideNodeClickBehavior = ({ option }) => {
const hasChildren = (option.children?.length ?? 0) > 0;
@@ -116,6 +198,16 @@ const nodeProps: TreeProps['nodeProps'] = ({ option }) => {
}
}
},
onContextmenu: (payload) => {
payload.stopPropagation();
payload.preventDefault();
const { clientX, clientY } = payload;
const stationCode = option['stationCode'] as Station['code'];
const deviceType = option['deviceType'] as DeviceType | undefined;
const device = option['device'] as NdmDeviceResultVO | undefined;
contextmenu.value = { x: clientX, y: clientY, stationCode, deviceType, device };
showContextmenu.value = true;
},
};
};
@@ -137,7 +229,7 @@ const renderIcmpStatistics = (onlineCount: number, offlineCount: number, count:
')',
]);
};
const renderDeviceNodePrefix = (device: NdmDeviceResultVO, stationCode: string) => {
const renderDeviceNodePrefix = (device: NdmDeviceResultVO, stationCode: Station['code']) => {
const renderViewDeviceButton = (device: NdmDeviceResultVO, stationCode: string) => {
return h(
NButton,
@@ -170,7 +262,7 @@ const renderDeviceNodePrefix = (device: NdmDeviceResultVO, stationCode: string)
return h(NFlex, { size: 'small' }, { default: () => [renderViewDeviceButton(device, stationCode), renderDeviceStatusTag(device)] });
};
// 全线设备树
const lineDeviceTreeData = computed<Record<string, TreeOption[]>>(() => {
const lineDeviceTreeData = computed<Record<Station['code'], TreeOption[]>>(() => {
const treeData: Record<string, TreeOption[]> = {};
deviceTabPanes.forEach(({ name: paneName /* , tab: paneTab */ }) => {
treeData[paneName] = stations.value.map<TreeOption>((station) => {
@@ -217,6 +309,8 @@ const lineDeviceTreeData = computed<Record<string, TreeOption[]>>(() => {
device: nvrCluster,
};
}),
stationCode,
deviceType: activeTab.value,
};
}
return {
@@ -237,6 +331,8 @@ const lineDeviceTreeData = computed<Record<string, TreeOption[]>>(() => {
device,
};
}) ?? [],
stationCode,
deviceType: activeTab.value,
};
});
});
@@ -278,6 +374,8 @@ const stationDeviceTreeData = computed<TreeOption[]>(() => {
device,
};
}),
stationCode,
deviceType,
};
}
return {
@@ -294,6 +392,8 @@ const stationDeviceTreeData = computed<TreeOption[]>(() => {
device,
};
}),
stationCode,
deviceType,
};
});
});
@@ -370,7 +470,13 @@ async function scrollDeviceTreeToSelectedDevice() {
</NFlex>
</div>
<!-- 设备树 -->
<div style="overflow: hidden; flex: 1 1 auto; display: flex">
<div
style="overflow: hidden; flex: 1 1 auto; display: flex"
:style="{
// 当右键菜单显示时,禁用设备树的点击事件,避免在打开菜单时仍能点击设备树节点
'pointer-events': showContextmenu ? 'none' : 'auto',
}"
>
<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%">
@@ -415,6 +521,17 @@ async function scrollDeviceTreeToSelectedDevice() {
</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>