Compare commits

..

4 Commits

Author SHA1 Message Date
yangsy
c7338c5474 feat: 当下游设备不存在时自动解除关联 2025-12-26 14:43:00 +08:00
yangsy
62c642643d feat: 告警记录支持点击设备跳转到设备详情 2025-12-26 13:42:11 +08:00
yangsy
cd0bc86803 feat: 设备关联与解除关联
- 支持配置交换机端口的下游关联设备
- 支持配置安防箱电路的下游关联设备
- 支持解除关联
- 删除设备时校验是否存在上/下游设备
2025-12-26 13:42:11 +08:00
yangsy
ed2a4f78ff feat: 扩展设备树功能
- 支持控制是否同步路由参数
- 支持配置允许的事件类型 (select/manage)
- 支持自定义设备节点前缀按钮文字
- 支持向外暴露设备选择逻辑
- 不再封装跳转设备逻辑,由外部实现
- 在车站模式下也支持选择设备
2025-12-25 16:18:41 +08:00
25 changed files with 1269 additions and 131 deletions

View File

@@ -1,2 +1,3 @@
export * from './diag'; export * from './diag';
export * from './link-description';
export * from './station'; export * from './station';

View File

@@ -0,0 +1,10 @@
import type { NdmCameraLinkDescription } from './ndm-camera-link-description';
import type { NdmSecurityBoxLinkDescription } from './ndm-security-box-link-description';
import type { NdmSwitchLinkDescription } from './ndm-switch-link-description';
export * from './link-description';
export * from './ndm-camera-link-description';
export * from './ndm-security-box-link-description';
export * from './ndm-switch-link-description';
export type NdmDeviceLinkDescription = NdmCameraLinkDescription | NdmSecurityBoxLinkDescription | NdmSwitchLinkDescription;

View File

@@ -0,0 +1,5 @@
import type { DeviceStoreIndex } from '@/apis';
export interface LinkDescription {
upstream?: DeviceStoreIndex[];
}

View File

@@ -0,0 +1,3 @@
import type { LinkDescription } from './link-description';
export interface NdmCameraLinkDescription extends LinkDescription {}

View File

@@ -0,0 +1,8 @@
import type { DeviceStoreIndex } from '@/apis';
import type { LinkDescription } from './link-description';
export interface NdmSecurityBoxLinkDescription extends LinkDescription {
downstream?: {
[circuitIndex: number]: DeviceStoreIndex;
};
}

View File

@@ -0,0 +1,8 @@
import type { DeviceStoreIndex } from '@/apis';
import type { LinkDescription } from './link-description';
export interface NdmSwitchLinkDescription extends LinkDescription {
downstream?: {
[portName: string]: DeviceStoreIndex;
};
}

View File

@@ -10,7 +10,13 @@ import type {
NdmVideoServerResultVO, NdmVideoServerResultVO,
Station, Station,
} from '@/apis'; } from '@/apis';
import { DEVICE_TYPE_LITERALS } from '@/enums'; import { DEVICE_TYPE_LITERALS, type DeviceType } from '@/enums';
export interface DeviceStoreIndex {
stationCode: Station['code'];
deviceType: DeviceType;
deviceDbId: string;
}
export interface StationDevices { export interface StationDevices {
[DEVICE_TYPE_LITERALS.ndmAlarmHost]: NdmAlarmHostResultVO[]; [DEVICE_TYPE_LITERALS.ndmAlarmHost]: NdmAlarmHostResultVO[];

View File

@@ -1,13 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { detailDeviceApi, probeDeviceApi, type NdmDeviceResultVO, type Station } from '@/apis'; import { detailDeviceApi, probeDeviceApi, type LinkDescription, type NdmDeviceResultVO, type Station } from '@/apis';
import { DEVICE_TYPE_NAMES, tryGetDeviceType } from '@/enums'; import { SELECT_DEVICE_FN_INJECTION_KEY } from '@/constants';
import { DEVICE_TYPE_LITERALS, DEVICE_TYPE_NAMES, tryGetDeviceType } from '@/enums';
import { useDeviceStore } from '@/stores'; import { useDeviceStore } from '@/stores';
import { parseErrorFeedback } from '@/utils'; import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query'; import { useMutation } from '@tanstack/vue-query';
import { ApiOutlined, ReloadOutlined } from '@vicons/antd'; import { ApiOutlined, ReloadOutlined } from '@vicons/antd';
import { isCancel } from 'axios'; import { isCancel } from 'axios';
import destr from 'destr';
import { NButton, NCard, NFlex, NIcon, NTag, NTooltip } from 'naive-ui'; import { NButton, NCard, NFlex, NIcon, NTag, NTooltip } from 'naive-ui';
import { computed, onBeforeUnmount, ref, toRefs } from 'vue'; import { storeToRefs } from 'pinia';
import { computed, inject, onBeforeUnmount, ref, toRefs } from 'vue';
const props = defineProps<{ const props = defineProps<{
ndmDevice: NdmDeviceResultVO; ndmDevice: NdmDeviceResultVO;
@@ -18,6 +21,9 @@ defineSlots<{
'append-info': () => any; 'append-info': () => any;
}>(); }>();
const deviceStore = useDeviceStore();
const { lineDevices } = storeToRefs(deviceStore);
const { ndmDevice, station } = toRefs(props); const { ndmDevice, station } = toRefs(props);
const type = computed(() => { const type = computed(() => {
@@ -30,6 +36,44 @@ const status = computed(() => ndmDevice.value.deviceStatus);
const ipAddr = computed(() => ndmDevice.value.ipAddress ?? '-'); const ipAddr = computed(() => ndmDevice.value.ipAddress ?? '-');
const gbCode = computed(() => Reflect.get(ndmDevice.value, 'gbCode') as string | undefined); const gbCode = computed(() => Reflect.get(ndmDevice.value, 'gbCode') as string | undefined);
const linkDescription = computed(() => {
const result = destr<any>(ndmDevice.value.linkDescription);
if (!result) return null;
if (typeof result !== 'object') return null;
return result as LinkDescription;
});
const upperDevices = computed(() => {
const devices: NdmDeviceResultVO[] = [];
if (!linkDescription.value) return devices;
if (!linkDescription.value.upstream) return devices;
linkDescription.value.upstream.forEach((deviceStoreIndex) => {
const { stationCode, deviceType, deviceDbId } = deviceStoreIndex;
const stationDevices = lineDevices.value[stationCode];
if (!stationDevices) return;
const classified = stationDevices[deviceType];
const device = classified.find((device) => device.id === deviceDbId);
if (device) devices.push(device);
});
return devices.sort((aDevice, bDevice) => {
// 按在DEVICE_TYPE_LITERALS中的顺序排序
const aDeviceType = tryGetDeviceType(aDevice.deviceType);
const bDeviceType = tryGetDeviceType(bDevice.deviceType);
if (!aDeviceType || !bDeviceType) return 0;
const deviceTypes = Object.values(DEVICE_TYPE_LITERALS);
return deviceTypes.indexOf(aDeviceType) - deviceTypes.indexOf(bDeviceType);
});
});
// 获取从父组件注入的 `selectDevice` 函数
const selectDeviceFn = inject(SELECT_DEVICE_FN_INJECTION_KEY);
// 跳转到上游设备
const navigateToUpperDevice = (upperDevice: NdmDeviceResultVO) => {
if (!!selectDeviceFn && !!selectDeviceFn.value) {
selectDeviceFn.value(upperDevice, station.value.code);
}
};
const canOpenMgmtPage = computed(() => { const canOpenMgmtPage = computed(() => {
return Object.keys(ndmDevice.value).includes('manageUrl'); return Object.keys(ndmDevice.value).includes('manageUrl');
}); });
@@ -96,40 +140,49 @@ onBeforeUnmount(() => {
<template> <template>
<NCard hoverable size="small"> <NCard hoverable size="small">
<template #header> <template #header>
<NFlex align="center"> <NFlex vertical>
<NTag v-if="status === '10'" size="small" type="success">在线</NTag> <NFlex align="center">
<NTag v-else-if="status === '20'" size="small" type="error">线</NTag> <NTag v-if="status === '10'" size="small" type="success">线</NTag>
<NTag v-else size="small" type="warning">-</NTag> <NTag v-else-if="status === '20'" size="small" type="error">离线</NTag>
<div>{{ name }}</div> <NTag v-else size="small" type="warning">-</NTag>
<NButton v-if="canOpenMgmtPage" ghost size="tiny" type="default" :focusable="false" @click="onClickOpenMgmtPage">管理</NButton> <div>{{ name }}</div>
<NButton v-if="canOpenMgmtPage" ghost size="tiny" type="default" :focusable="false" @click="onClickOpenMgmtPage">管理</NButton>
<div style="margin-left: auto">
<NTooltip v-if="canProbe" trigger="hover">
<template #trigger>
<NButton size="small" quaternary circle :loading="probing" @click="() => probeDevice()">
<template #icon>
<NIcon :component="ApiOutlined" />
</template>
</NButton>
</template>
<template #default>
<span>请求最新诊断</span>
</template>
</NTooltip>
<NTooltip trigger="hover">
<template #trigger>
<NButton size="small" quaternary circle :loading="loading" @click="() => detailDevice()">
<template #icon>
<NIcon :component="ReloadOutlined" />
</template>
</NButton>
</template>
<template #default>
<span>刷新设备</span>
</template>
</NTooltip>
</div>
</NFlex>
<div v-if="upperDevices.length > 0" style="font-size: 0.85rem">
<span>上游设备</span>
<template v-for="(device, index) in upperDevices" :key="index">
<span style="text-decoration: underline; cursor: pointer" @click="() => navigateToUpperDevice(device)">{{ device.name }}</span>
<span v-if="index < upperDevices.length - 1"></span>
</template>
</div>
</NFlex> </NFlex>
</template> </template>
<template #header-extra>
<NTooltip v-if="canProbe" trigger="hover">
<template #trigger>
<NButton size="small" quaternary circle :loading="probing" @click="() => probeDevice()">
<template #icon>
<NIcon :component="ApiOutlined" />
</template>
</NButton>
</template>
<template #default>
<span>请求最新诊断</span>
</template>
</NTooltip>
<NTooltip trigger="hover">
<template #trigger>
<NButton size="small" quaternary circle :loading="loading" @click="() => detailDevice()">
<template #icon>
<NIcon :component="ReloadOutlined" />
</template>
</NButton>
</template>
<template #default>
<span>刷新设备</span>
</template>
</NTooltip>
</template>
<template #default> <template #default>
<div style="font-size: small; color: #666"> <div style="font-size: small; color: #666">
<div> <div>

View File

@@ -4,7 +4,20 @@ import DeviceHeaderCard from './device-header-card.vue';
import NvrDiskCard from './nvr-disk-card.vue'; import NvrDiskCard from './nvr-disk-card.vue';
import NvrRecordCard from './nvr-record-card.vue'; import NvrRecordCard from './nvr-record-card.vue';
import SecurityBoxCircuitCard from './security-box-circuit-card.vue'; import SecurityBoxCircuitCard from './security-box-circuit-card.vue';
import SecurityBoxCircuitLinkModal from './security-box-circuit-link-modal.vue';
import SecurityBoxEnvCard from './security-box-env-card.vue'; import SecurityBoxEnvCard from './security-box-env-card.vue';
import SwitchPortCard from './switch-port-card.vue'; import SwitchPortCard from './switch-port-card.vue';
import SwitchPortLinkModal from './switch-port-link-modal.vue';
export { DeviceCommonCard, DeviceHardwareCard, DeviceHeaderCard, NvrDiskCard, NvrRecordCard, SecurityBoxCircuitCard, SecurityBoxEnvCard, SwitchPortCard }; export {
DeviceCommonCard,
DeviceHardwareCard,
DeviceHeaderCard,
NvrDiskCard,
NvrRecordCard,
SecurityBoxCircuitCard,
SecurityBoxCircuitLinkModal,
SecurityBoxEnvCard,
SwitchPortCard,
SwitchPortLinkModal,
};

View File

@@ -1,28 +1,53 @@
<script setup lang="ts"> <script setup lang="ts">
import { probeDeviceApi, rebootSecurityBoxApi, turnCitcuitStatusApi, type NdmSecurityBoxCircuit, type NdmSecurityBoxResultVO, type Station } from '@/apis'; import {
detailDeviceApi,
probeDeviceApi,
rebootSecurityBoxApi,
turnCitcuitStatusApi,
updateDeviceApi,
type LinkDescription,
type NdmDeviceResultVO,
type NdmSecurityBoxCircuit,
type NdmSecurityBoxLinkDescription,
type NdmSecurityBoxResultVO,
type Station,
} from '@/apis';
import { SecurityBoxCircuitLinkModal } from '@/components';
import { SELECT_DEVICE_FN_INJECTION_KEY } from '@/constants';
import { useDeviceStore, useSettingStore } from '@/stores';
import { parseErrorFeedback } from '@/utils'; import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query'; import { useMutation } from '@tanstack/vue-query';
import { PoweroffOutlined } from '@vicons/antd'; import { PoweroffOutlined } from '@vicons/antd';
import { watchImmediate } from '@vueuse/core'; import { watchImmediate } from '@vueuse/core';
import { NButton, NCard, NDescriptions, NDescriptionsItem, NFlex, NIcon, NPopconfirm, NPopover, NSwitch, NTag, useThemeVars, type TagProps } from 'naive-ui'; import { isCancel } from 'axios';
import { computed, ref, toRefs } from 'vue'; import destr from 'destr';
import { cloneDeep, isFunction } from 'es-toolkit';
import { NButton, NCard, NDescriptions, NDescriptionsItem, NDropdown, NFlex, NIcon, NPopconfirm, NPopover, NSwitch, NTag, useThemeVars, type DropdownOption, type TagProps } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, inject, ref, toRefs } from 'vue';
const props = defineProps<{ const props = defineProps<{
circuits?: NdmSecurityBoxCircuit[];
ndmDevice: NdmSecurityBoxResultVO; ndmDevice: NdmSecurityBoxResultVO;
station: Station; station: Station;
circuits?: NdmSecurityBoxCircuit[];
}>(); }>();
const themeVars = useThemeVars(); const themeVars = useThemeVars();
const { circuits, ndmDevice, station } = toRefs(props); const deviceStore = useDeviceStore();
const { lineDevices } = storeToRefs(deviceStore);
const settingStore = useSettingStore();
const { offlineDev } = storeToRefs(settingStore);
const { ndmDevice, station, circuits } = toRefs(props);
const showCard = computed(() => !!circuits.value && circuits.value.length > 0); const showCard = computed(() => !!circuits.value && circuits.value.length > 0);
const boxCircuits = ref<NdmSecurityBoxCircuit[]>([]); const localCircuits = ref<NdmSecurityBoxCircuit[]>([]);
watchImmediate(circuits, (newCircuits) => { watchImmediate(circuits, (newCircuits) => {
boxCircuits.value = newCircuits?.map((circuit) => ({ ...circuit })) ?? []; localCircuits.value = newCircuits?.map((circuit) => ({ ...circuit })) ?? [];
}); });
const getCircuitStatusTagType = (circuit: NdmSecurityBoxCircuit): TagProps['type'] => { const getCircuitStatusTagType = (circuit: NdmSecurityBoxCircuit): TagProps['type'] => {
@@ -40,22 +65,34 @@ const getCircuitStatusClassName = (circuit: NdmSecurityBoxCircuit) => {
return status === 0 ? 'circuit-off' : status === 1 ? 'circuit-on' : 'circuit-unknown'; return status === 0 ? 'circuit-off' : status === 1 ? 'circuit-on' : 'circuit-unknown';
}; };
const abortController = ref<AbortController>(new AbortController());
const { mutate: turnStatus, isPending: turning } = useMutation({ const { mutate: turnStatus, isPending: turning } = useMutation({
mutationFn: async (params: { circuitIndex: number; newStatus: boolean }) => { mutationFn: async (params: { circuitIndex: number; newStatus: boolean }) => {
abortController.value.abort();
abortController.value = new AbortController();
window.$loadingBar.start();
const { circuitIndex, newStatus } = params; const { circuitIndex, newStatus } = params;
if (!ndmDevice.value.ipAddress) { if (!ndmDevice.value.ipAddress) {
throw new Error('设备IP地址不存在'); throw new Error('设备IP地址不存在');
} }
const status = newStatus ? 1 : 0; const status = newStatus ? 1 : 0;
await turnCitcuitStatusApi(ndmDevice.value.ipAddress, circuitIndex, status, { stationCode: station.value.code }); const stationCode = station.value.code;
await probeDeviceApi(ndmDevice.value, { stationCode: station.value.code }); const signal = abortController.value.signal;
await turnCitcuitStatusApi(ndmDevice.value.ipAddress, circuitIndex, status, { stationCode, signal });
await probeDeviceApi(ndmDevice.value, { stationCode, signal });
return status; return status;
}, },
onSuccess: (status, { circuitIndex }) => { onSuccess: (status, { circuitIndex }) => {
const circuit = boxCircuits.value.at(circuitIndex); window.$loadingBar.finish();
const circuit = localCircuits.value.at(circuitIndex);
if (circuit) circuit.status = status; if (circuit) circuit.status = status;
}, },
onError: (error) => { onError: (error) => {
window.$loadingBar.error();
if (isCancel(error)) return;
console.error(error); console.error(error);
const errorFeedback = parseErrorFeedback(error); const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback); window.$message.error(errorFeedback);
@@ -64,15 +101,188 @@ const { mutate: turnStatus, isPending: turning } = useMutation({
const { mutate: reboot, isPending: rebooting } = useMutation({ const { mutate: reboot, isPending: rebooting } = useMutation({
mutationFn: async () => { mutationFn: async () => {
abortController.value.abort();
abortController.value = new AbortController();
window.$loadingBar.start();
if (!ndmDevice.value.ipAddress) { if (!ndmDevice.value.ipAddress) {
throw new Error('设备IP地址不存在'); throw new Error('设备IP地址不存在');
} }
await rebootSecurityBoxApi(ndmDevice.value.ipAddress, { stationCode: station.value.code });
const stationCode = station.value.code;
const signal = abortController.value.signal;
await rebootSecurityBoxApi(ndmDevice.value.ipAddress, { stationCode, signal });
}, },
onSuccess: () => { onSuccess: () => {
window.$loadingBar.finish();
window.$message.success('设备重启成功'); window.$message.success('设备重启成功');
}, },
onError: (error) => { onError: (error) => {
window.$loadingBar.error();
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
const upperDeviceLinkDescription = computed(() => {
const result = destr<any>(ndmDevice.value.linkDescription);
if (!result) return null;
if (typeof result !== 'object') return null;
return result as NdmSecurityBoxLinkDescription;
});
const getLowerDeviceByCircuitIndex = (circuitIndex: number) => {
if (!upperDeviceLinkDescription.value) return null;
const downstream = upperDeviceLinkDescription.value.downstream;
if (!downstream) return null;
const deviceStoreIndex = downstream[circuitIndex];
if (!deviceStoreIndex) return null;
const { stationCode, deviceType, deviceDbId } = deviceStoreIndex;
const stationDevices = lineDevices.value[stationCode];
if (!stationDevices) return null;
const devices = stationDevices[deviceType];
const lowerDevice = devices.find((device) => device.id === deviceDbId);
if (!lowerDevice) {
// 下游设备不存在时解除关联
const modifiedUpperDevice = cloneDeep(ndmDevice.value);
const modifiedUpperLinkDescription = cloneDeep(upperDeviceLinkDescription.value);
delete modifiedUpperLinkDescription.downstream?.[circuitIndex];
modifiedUpperDevice.linkDescription = JSON.stringify(modifiedUpperLinkDescription);
// 不需要等待异步
const stationCode = station.value.code;
updateDeviceApi(modifiedUpperDevice, { stationCode }).then(() => {
detailDeviceApi(modifiedUpperDevice, { stationCode }).then((upperDevice) => {
if (!upperDevice) return;
deviceStore.patchDevice(stationCode, { ...upperDevice });
});
});
return null;
}
return lowerDevice;
};
// 获取从父组件注入的selectDevice函数
const selectDeviceFn = inject(SELECT_DEVICE_FN_INJECTION_KEY);
// 跳转到下游设备
const navigateToLowerDevice = (circuitIndex: number) => {
const lowerDevice = getLowerDeviceByCircuitIndex(circuitIndex);
if (!lowerDevice) return;
if (!!selectDeviceFn && !!selectDeviceFn.value) {
selectDeviceFn.value(lowerDevice, station.value.code);
}
};
const showModal = ref(false);
const contextmenu = ref<{ x: number; y: number; circuitIndex?: number }>({ x: 0, y: 0 });
const showContextmenu = ref(false);
const contextmenuOptions = computed<DropdownOption[]>(() => [
{
label: '关联设备',
key: 'link-device',
onSelect: () => {
showContextmenu.value = false;
showModal.value = true;
},
},
{
label: '解除关联',
key: 'unlink-device',
onSelect: () => {
showContextmenu.value = false;
const circuitIndex = contextmenu.value.circuitIndex;
if (circuitIndex === undefined) return;
const lowerDevice = getLowerDeviceByCircuitIndex(circuitIndex);
if (!lowerDevice) return;
window.$dialog.warning({
title: '确认解除关联吗?',
content: `将解除【电路${circuitIndex + 1}】与【${lowerDevice.name}】的关联关系。`,
style: { width: '600px' },
contentStyle: { height: '60px' },
negativeText: '取消',
positiveText: '确认',
onNegativeClick: () => {
window.$dialog.destroyAll();
},
onPositiveClick: () => {
window.$dialog.destroyAll();
unlinkDevice({ circuitIndex, lowerDevice });
},
});
},
},
]);
const onSelectDropdownOption = (key: string, option: DropdownOption) => {
const onSelect = option['onSelect'];
if (isFunction(onSelect)) {
onSelect();
}
};
const onContextmenu = (payload: PointerEvent, circuitIndex: number) => {
payload.stopPropagation();
payload.preventDefault();
const { clientX, clientY } = payload;
contextmenu.value = { x: clientX, y: clientY, circuitIndex };
showContextmenu.value = true;
};
const { mutate: unlinkDevice } = useMutation({
mutationFn: async (params: { circuitIndex: number; lowerDevice: NdmDeviceResultVO }) => {
abortController.value.abort();
abortController.value = new AbortController();
window.$loadingBar.start();
const { circuitIndex, lowerDevice } = params;
if (!upperDeviceLinkDescription.value) return;
// 1.从下游设备的linkDescription的upstream字段中删除当前上游设备
const modifiedLowerDevice = cloneDeep(lowerDevice);
// 解除关联时下游设备的linkDescription一定存在
const modifiedLowerDeviceLinkDescription = destr<LinkDescription>(modifiedLowerDevice.linkDescription);
// upstream字段存在时才进行删除操作
if (modifiedLowerDeviceLinkDescription.upstream) {
const index = modifiedLowerDeviceLinkDescription.upstream.findIndex((deviceStoreIndex) => deviceStoreIndex.deviceDbId === ndmDevice.value.id);
if (index !== -1) {
modifiedLowerDeviceLinkDescription.upstream.splice(index, 1);
}
}
modifiedLowerDevice.linkDescription = JSON.stringify(modifiedLowerDeviceLinkDescription);
// 2. 修改上游设备的linkDescription的downstream字段
const modifiedUpperDevice = cloneDeep(ndmDevice.value);
const modifiedUpperLinkDescription = cloneDeep(upperDeviceLinkDescription.value);
delete modifiedUpperLinkDescription.downstream?.[circuitIndex];
modifiedUpperDevice.linkDescription = JSON.stringify(modifiedUpperLinkDescription);
// TODO: 3. 发起update请求并获取最新的设备详情离线模式下直接修改本地数据
if (offlineDev.value) {
return { upperDevice: modifiedUpperDevice, lowerDevice: modifiedLowerDevice };
}
const stationCode = station.value.code;
const signal = abortController.value.signal;
await updateDeviceApi(modifiedUpperDevice, { stationCode, signal });
await updateDeviceApi(modifiedLowerDevice, { stationCode, signal });
const latestUpperDevice = await detailDeviceApi(modifiedUpperDevice, { stationCode, signal });
const latestLowerDevice = await detailDeviceApi(modifiedLowerDevice, { stationCode, signal });
return { upperDevice: latestUpperDevice, lowerDevice: latestLowerDevice };
},
onSuccess: (data) => {
window.$loadingBar.finish();
window.$message.success('解除成功');
if (!data) return;
const { upperDevice, lowerDevice } = data;
if (!!upperDevice && !!lowerDevice) {
deviceStore.patchDevice(station.value.code, { ...upperDevice });
deviceStore.patchDevice(station.value.code, { ...lowerDevice });
}
},
onError: (error) => {
window.$loadingBar.error();
if (isCancel(error)) return;
console.error(error); console.error(error);
const errorFeedback = parseErrorFeedback(error); const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback); window.$message.error(errorFeedback);
@@ -94,11 +304,11 @@ const { mutate: reboot, isPending: rebooting } = useMutation({
</NFlex> </NFlex>
</template> </template>
<template #default> <template #default>
<div style="display: grid" :style="{ 'grid-template-columns': `repeat(${Math.min(boxCircuits.length, 4)}, 1fr)` }"> <div style="display: grid" :style="{ 'grid-template-columns': `repeat(${Math.min(localCircuits.length, 4)}, 1fr)` }">
<template v-for="(circuit, index) in boxCircuits" :key="index"> <template v-for="(circuit, circuitIndex) in localCircuits" :key="circuitIndex">
<NPopover :delay="300"> <NPopover :delay="300">
<template #trigger> <template #trigger>
<NFlex justify="center" align="center" :size="0"> <div style="display: flex; justify-content: center; align-items: center" @contextmenu="(payload) => onContextmenu(payload, circuitIndex)">
<NFlex vertical class="pointer-cursor circuit" style="padding: 12px" :class="getCircuitStatusClassName(circuit)"> <NFlex vertical class="pointer-cursor circuit" style="padding: 12px" :class="getCircuitStatusClassName(circuit)">
<NFlex align="center"> <NFlex align="center">
<NTag class="pointer-cursor" size="small" :type="getCircuitStatusTagType(circuit)"> <NTag class="pointer-cursor" size="small" :type="getCircuitStatusTagType(circuit)">
@@ -109,25 +319,31 @@ const { mutate: reboot, isPending: rebooting } = useMutation({
<span>{{ getCircuitStatusText(circuit) }}</span> <span>{{ getCircuitStatusText(circuit) }}</span>
</template> </template>
</NTag> </NTag>
<span>电路{{ index + 1 }}</span> <span>电路{{ circuitIndex + 1 }}</span>
</NFlex> </NFlex>
<NFlex justify="end" align="center"> <NFlex justify="end" align="center">
<NPopconfirm :positive-text="'确认'" :negative-text="'取消'" @positive-click="() => turnStatus({ circuitIndex: index, newStatus: circuit.status !== 1 })"> <NPopconfirm :positive-text="'确认'" :negative-text="'取消'" @positive-click="() => turnStatus({ circuitIndex: circuitIndex, newStatus: circuit.status !== 1 })">
<template #trigger> <template #trigger>
<NSwitch size="small" :value="circuit.status === 1" :loading="turning" /> <NSwitch size="small" :value="circuit.status === 1" :loading="turning" />
</template> </template>
<template #default> <template #default>
<span>确定要{{ circuit.status === 1 ? '关闭' : '开启' }}电路{{ index + 1 }}吗?</span> <span>确定要{{ circuit.status === 1 ? '关闭' : '开启' }}电路{{ circuitIndex + 1 }}吗?</span>
</template> </template>
</NPopconfirm> </NPopconfirm>
</NFlex> </NFlex>
</NFlex> </NFlex>
</NFlex> </div>
</template> </template>
<template #default> <template #default>
<NDescriptions bordered size="small" label-placement="left" :column="1"> <NDescriptions bordered size="small" label-placement="left" :column="1">
<NDescriptionsItem label="电压">{{ circuit.voltage }}V</NDescriptionsItem> <NDescriptionsItem label="电压">{{ circuit.voltage }}V</NDescriptionsItem>
<NDescriptionsItem label="电流">{{ circuit.current }}A</NDescriptionsItem> <NDescriptionsItem label="电流">{{ circuit.current }}A</NDescriptionsItem>
<NDescriptionsItem label="关联设备">
<span v-if="getLowerDeviceByCircuitIndex(circuitIndex)" style="text-decoration: underline; cursor: pointer" @click="() => navigateToLowerDevice(circuitIndex)">
{{ getLowerDeviceByCircuitIndex(circuitIndex)?.name || '-' }}
</span>
<span v-else>-</span>
</NDescriptionsItem>
</NDescriptions> </NDescriptions>
</template> </template>
</NPopover> </NPopover>
@@ -135,6 +351,19 @@ const { mutate: reboot, isPending: rebooting } = useMutation({
</div> </div>
</template> </template>
</NCard> </NCard>
<SecurityBoxCircuitLinkModal v-model:show="showModal" :ndm-device="ndmDevice" :station="station" :circuit-index="contextmenu.circuitIndex" />
<NDropdown
placement="bottom-start"
trigger="manual"
:show="showContextmenu"
:x="contextmenu.x"
:y="contextmenu.y"
:options="contextmenuOptions"
@select="onSelectDropdownOption"
@clickoutside="() => (showContextmenu = false)"
/>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -0,0 +1,212 @@
<script setup lang="ts">
import { detailDeviceApi, updateDeviceApi, type NdmCameraLinkDescription, type NdmDeviceResultVO, type NdmSecurityBoxLinkDescription, type NdmSecurityBoxResultVO, type Station } from '@/apis';
import { DeviceTree } from '@/components';
import { tryGetDeviceType } from '@/enums';
import { useDeviceStore, useSettingStore } from '@/stores';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import destr from 'destr';
import { cloneDeep } from 'es-toolkit';
import { NButton, NFlex, NModal } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, ref, toRefs } from 'vue';
const props = defineProps<{
ndmDevice: NdmSecurityBoxResultVO;
station: Station;
circuitIndex?: number;
}>();
const show = defineModel<boolean>('show', { default: false });
const deviceStore = useDeviceStore();
const settingStore = useSettingStore();
const { offlineDev } = storeToRefs(settingStore);
const { ndmDevice, station, circuitIndex } = toRefs(props);
const upperDeviceLinkDescription = computed(() => {
const result = destr<any>(ndmDevice.value.linkDescription);
if (!result) return null;
if (typeof result !== 'object') return null;
return result as NdmSecurityBoxLinkDescription;
});
const lowerDevice = ref<NdmDeviceResultVO>();
const lowerDeviceLinkDescription = computed<NdmCameraLinkDescription | null>(() => {
if (!lowerDevice.value) return null;
const result = destr<any>(lowerDevice.value.linkDescription);
if (!result) return null;
if (typeof result !== 'object') return null;
return result;
});
const onAfterSelectDevice = (device: NdmDeviceResultVO) => {
lowerDevice.value = device;
};
const abortController = ref<AbortController>(new AbortController());
const { mutate: linkPortToDevice, isPending: linking } = useMutation({
mutationFn: async () => {
abortController.value.abort();
abortController.value = new AbortController();
window.$loadingBar.start();
const upperDeviceType = tryGetDeviceType(ndmDevice.value.deviceType);
if (!upperDeviceType) throw new Error('本设备类型未知');
const upperDeviceDbId = ndmDevice.value.id;
if (!upperDeviceDbId) throw new Error('本设备没有ID');
if (circuitIndex.value === undefined) throw new Error('该电路不存在');
if (!lowerDevice.value) throw new Error('请选择要关联的设备');
const lowerDeviceType = tryGetDeviceType(lowerDevice.value?.deviceType);
if (!lowerDeviceType) throw new Error('该设备类型未知');
const lowerDeviceDbId = lowerDevice.value?.id;
if (!lowerDeviceDbId) throw new Error('该设备没有ID');
// 0. 检查上游设备的linkDescription的downstream字段是否存在某个端口已经关联下游设备
const duplicated = Object.entries(upperDeviceLinkDescription.value?.downstream ?? {}).find(([, deviceStoreIndex]) => {
return deviceStoreIndex.deviceDbId === lowerDeviceDbId;
});
if (duplicated) {
const [portName] = duplicated;
throw new Error(`该设备已关联到端口${portName}`);
}
// 1. 修改上游设备的linkDescription的downstream字段
const modifiedUpperDevice = cloneDeep(ndmDevice.value);
let modifiedUpperDeviceLinkDescription: NdmSecurityBoxLinkDescription;
if (!upperDeviceLinkDescription.value) {
modifiedUpperDeviceLinkDescription = {
downstream: {
[circuitIndex.value]: {
stationCode: station.value.code,
deviceType: lowerDeviceType,
deviceDbId: lowerDeviceDbId,
},
},
};
} else {
modifiedUpperDeviceLinkDescription = {
...upperDeviceLinkDescription.value,
downstream: {
...upperDeviceLinkDescription.value.downstream,
[circuitIndex.value]: {
stationCode: station.value.code,
deviceType: lowerDeviceType,
deviceDbId: lowerDeviceDbId,
},
},
};
}
modifiedUpperDevice.linkDescription = JSON.stringify(modifiedUpperDeviceLinkDescription);
// 2. 修改下游设备的linkDescription的upstream字段
const modifiedLowerDevice = cloneDeep(lowerDevice.value);
let modifiedLowerDeviceLinkDescription: NdmCameraLinkDescription;
if (!lowerDeviceLinkDescription.value) {
modifiedLowerDeviceLinkDescription = {
upstream: [
{
stationCode: station.value.code,
deviceType: upperDeviceType,
deviceDbId: upperDeviceDbId,
},
],
};
} else {
const upstream = cloneDeep(lowerDeviceLinkDescription.value.upstream);
if (!upstream) {
modifiedLowerDeviceLinkDescription = {
...lowerDeviceLinkDescription.value,
upstream: [
{
stationCode: station.value.code,
deviceType: upperDeviceType,
deviceDbId: upperDeviceDbId,
},
],
};
} else {
const deviceStoreIndex = upstream.find((deviceStoreIndex) => deviceStoreIndex.deviceDbId === upperDeviceDbId);
if (!deviceStoreIndex) {
upstream.push({
stationCode: station.value.code,
deviceType: upperDeviceType,
deviceDbId: upperDeviceDbId,
});
}
modifiedLowerDeviceLinkDescription = {
...lowerDeviceLinkDescription.value,
upstream,
};
}
}
modifiedLowerDevice.linkDescription = JSON.stringify(modifiedLowerDeviceLinkDescription);
// TODO: 3. 发起update请求并获取最新的设备详情离线模式下直接修改本地数据
if (offlineDev.value) {
return { upperDevice: modifiedUpperDevice, lowerDevice: modifiedLowerDevice };
}
const stationCode = station.value.code;
const signal = abortController.value.signal;
await updateDeviceApi(modifiedUpperDevice, { stationCode, signal });
await updateDeviceApi(modifiedLowerDevice, { stationCode, signal });
const latestUpperDevice = await detailDeviceApi(modifiedUpperDevice, { stationCode, signal });
const latestLowerDevice = await detailDeviceApi(modifiedLowerDevice, { stationCode, signal });
return { upperDevice: latestUpperDevice, lowerDevice: latestLowerDevice };
},
onSuccess: ({ upperDevice, lowerDevice }) => {
show.value = false;
window.$loadingBar.finish();
window.$message.success('关联成功');
if (!!upperDevice && !!lowerDevice) {
deviceStore.patchDevice(station.value.code, { ...upperDevice });
deviceStore.patchDevice(station.value.code, { ...lowerDevice });
}
},
onError: (error) => {
window.$loadingBar.error();
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
const onLink = () => {
linkPortToDevice();
};
const onCancel = () => {
abortController.value.abort();
show.value = false;
};
</script>
<template>
<NModal v-model:show="show" preset="card" style="width: 600px; height: 600px" :content-style="{ height: '100%', overflow: 'hidden' }" @close="onCancel" @esc="onCancel">
<template #header>
<span>{{ ndmDevice.name }}</span>
<span> - </span>
<span>电路{{ circuitIndex ? circuitIndex + 1 : '-' }}</span>
<span> - </span>
<span>关联设备</span>
</template>
<template #default>
<DeviceTree :station="station" :events="['select']" :device-prefix-label="'选择'" @after-select-device="onAfterSelectDevice" />
</template>
<template #action>
<NFlex justify="end">
<NButton size="small" quaternary @click="onCancel">取消</NButton>
<NButton size="small" type="primary" :disabled="!lowerDevice" :loading="linking" @click="onLink">关联</NButton>
</NFlex>
</template>
</NModal>
</template>
<style scoped lang="scss"></style>

View File

@@ -1,16 +1,33 @@
<script setup lang="ts"> <script setup lang="ts">
import type { NdmSwitchPortInfo } from '@/apis'; import { detailDeviceApi, updateDeviceApi, type LinkDescription, type NdmDeviceResultVO, type NdmSwitchLinkDescription, type NdmSwitchPortInfo, type NdmSwitchResultVO, type Station } from '@/apis';
import { SwitchPortLinkModal } from '@/components';
import { SELECT_DEVICE_FN_INJECTION_KEY } from '@/constants';
import { getPortStatusValue, transformPortSpeed } from '@/helpers'; import { getPortStatusValue, transformPortSpeed } from '@/helpers';
import { NCard, NDescriptions, NDescriptionsItem, NPopover, useThemeVars } from 'naive-ui'; import { useDeviceStore, useSettingStore } from '@/stores';
import { computed, toRefs } from 'vue'; import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import destr from 'destr';
import { cloneDeep, isFunction } from 'es-toolkit';
import { NCard, NDescriptions, NDescriptionsItem, NDropdown, NPopover, useThemeVars, type DropdownOption } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, inject, onBeforeUnmount, ref, toRefs } from 'vue';
const props = defineProps<{ const props = defineProps<{
ndmDevice: NdmSwitchResultVO;
station: Station;
ports?: NdmSwitchPortInfo[]; ports?: NdmSwitchPortInfo[];
}>(); }>();
const themeVars = useThemeVars(); const themeVars = useThemeVars();
const { ports } = toRefs(props); const deviceStore = useDeviceStore();
const { lineDevices } = storeToRefs(deviceStore);
const settingStore = useSettingStore();
const { offlineDev } = storeToRefs(settingStore);
const { ndmDevice, station, ports } = toRefs(props);
const showCard = computed(() => !!ports.value); const showCard = computed(() => !!ports.value);
@@ -58,6 +75,172 @@ const getPortClassName = (port: NdmSwitchPortInfo) => {
} }
return 'port-unknown'; return 'port-unknown';
}; };
const upperDeviceLinkDescription = computed(() => {
const result = destr<any>(ndmDevice.value.linkDescription);
if (!result) return null;
if (typeof result !== 'object') return null;
return result as NdmSwitchLinkDescription;
});
const getLowerDeviceByPort = (port: NdmSwitchPortInfo) => {
if (!upperDeviceLinkDescription.value) return null;
const downstream = upperDeviceLinkDescription.value.downstream;
if (!downstream) return null;
const deviceStoreIndex = downstream[port.portName];
if (!deviceStoreIndex) return null;
const { stationCode, deviceType, deviceDbId } = deviceStoreIndex;
const stationDevices = lineDevices.value[stationCode];
if (!stationDevices) return null;
const devices = stationDevices[deviceType];
const lowerDevice = devices.find((device) => device.id === deviceDbId);
if (!lowerDevice) {
// 下游设备不存在时解除关联
const modifiedUpperDevice = cloneDeep(ndmDevice.value);
const modifiedUpperLinkDescription = cloneDeep(upperDeviceLinkDescription.value);
delete modifiedUpperLinkDescription.downstream?.[port.portName];
modifiedUpperDevice.linkDescription = JSON.stringify(modifiedUpperLinkDescription);
// 不需要等待异步
const stationCode = station.value.code;
updateDeviceApi(modifiedUpperDevice, { stationCode }).then(() => {
detailDeviceApi(modifiedUpperDevice, { stationCode }).then((upperDevice) => {
if (!upperDevice) return;
deviceStore.patchDevice(stationCode, { ...upperDevice });
});
});
return null;
}
return lowerDevice;
};
// 获取从父组件注入的selectDevice函数
const selectDeviceFn = inject(SELECT_DEVICE_FN_INJECTION_KEY);
// 跳转到下游设备
const navigateToLowerDevice = (port: NdmSwitchPortInfo) => {
const lowerDevice = getLowerDeviceByPort(port);
if (!lowerDevice) return;
if (!!selectDeviceFn && !!selectDeviceFn.value) {
selectDeviceFn.value(lowerDevice, station.value.code);
}
};
const showModal = ref(false);
const contextmenu = ref<{ x: number; y: number; port?: NdmSwitchPortInfo }>({ x: 0, y: 0 });
const showContextmenu = ref(false);
const contextmenuOptions = computed<DropdownOption[]>(() => [
{
label: '关联设备',
key: 'link-device',
onSelect: () => {
showContextmenu.value = false;
showModal.value = true;
},
},
{
label: '解除关联',
key: 'unlink-device',
onSelect: () => {
showContextmenu.value = false;
const port = contextmenu.value.port;
if (!port) return;
const lowerDevice = getLowerDeviceByPort(port);
if (!lowerDevice) return;
window.$dialog.warning({
title: '确认解除关联吗?',
content: `将解除【${port.portName}】与【${lowerDevice.name}】的关联关系。`,
style: { width: '600px' },
contentStyle: { height: '60px' },
negativeText: '取消',
positiveText: '确认',
onNegativeClick: () => {
window.$dialog.destroyAll();
},
onPositiveClick: () => {
window.$dialog.destroyAll();
unlinkDevice({ port, lowerDevice });
},
});
},
},
]);
const onSelectDropdownOption = (key: string, option: DropdownOption) => {
const onSelect = option['onSelect'];
if (isFunction(onSelect)) {
onSelect();
}
};
const onContextmenu = (payload: PointerEvent, port: NdmSwitchPortInfo) => {
payload.stopPropagation();
payload.preventDefault();
const { clientX, clientY } = payload;
contextmenu.value = { x: clientX, y: clientY, port };
showContextmenu.value = true;
};
const abortController = ref<AbortController>(new AbortController());
const { mutate: unlinkDevice } = useMutation({
mutationFn: async (params: { port: NdmSwitchPortInfo; lowerDevice: NdmDeviceResultVO }) => {
abortController.value.abort();
abortController.value = new AbortController();
window.$loadingBar.start();
const { port, lowerDevice } = params;
if (!upperDeviceLinkDescription.value) return;
// 1. 从下游设备的linkDescription的upstream字段中删除当前上游设备
const modifiedLowerDevice = cloneDeep(lowerDevice);
// 解除关联时下游设备的linkDescription一定存在
const modifiedLowerDeviceLinkDescription = destr<LinkDescription>(modifiedLowerDevice.linkDescription);
// upstream字段存在时才进行删除操作
if (modifiedLowerDeviceLinkDescription.upstream) {
const index = modifiedLowerDeviceLinkDescription.upstream.findIndex((deviceStoreIndex) => deviceStoreIndex.deviceDbId === ndmDevice.value.id);
if (index !== -1) {
modifiedLowerDeviceLinkDescription.upstream.splice(index, 1);
}
}
modifiedLowerDevice.linkDescription = JSON.stringify(modifiedLowerDeviceLinkDescription);
// 2. 修改上游设备的linkDescription的downstream字段
const modifiedUpperDevice = cloneDeep(ndmDevice.value);
const modifiedUpperLinkDescription = cloneDeep(upperDeviceLinkDescription.value);
delete modifiedUpperLinkDescription.downstream?.[port.portName];
modifiedUpperDevice.linkDescription = JSON.stringify(modifiedUpperLinkDescription);
// TODO: 3. 发起update请求并获取最新的设备详情离线模式下直接修改本地数据
if (offlineDev.value) {
return { upperDevice: modifiedUpperDevice, lowerDevice: modifiedLowerDevice };
}
const stationCode = station.value.code;
const signal = abortController.value.signal;
await updateDeviceApi(modifiedUpperDevice, { stationCode, signal });
await updateDeviceApi(modifiedLowerDevice, { stationCode, signal });
const latestUpperDevice = await detailDeviceApi(modifiedUpperDevice, { stationCode, signal });
const latestLowerDevice = await detailDeviceApi(modifiedLowerDevice, { stationCode, signal });
return { upperDevice: latestUpperDevice, lowerDevice: latestLowerDevice };
},
onSuccess: (data) => {
window.$loadingBar.finish();
window.$message.success('解除成功');
if (!data) return;
const { upperDevice, lowerDevice } = data;
if (!!upperDevice && !!lowerDevice) {
deviceStore.patchDevice(station.value.code, { ...upperDevice });
deviceStore.patchDevice(station.value.code, { ...lowerDevice });
}
},
onError: (error) => {
window.$loadingBar.error();
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
onBeforeUnmount(() => {
abortController.value.abort();
});
</script> </script>
<template> <template>
@@ -77,7 +260,12 @@ const getPortClassName = (port: NdmSwitchPortInfo) => {
<NPopover :delay="300"> <NPopover :delay="300">
<template #trigger> <template #trigger>
<!-- 最外层div宽度100% --> <!-- 最外层div宽度100% -->
<div class="port" style="height: 40px; box-sizing: border-box; display: flex; cursor: pointer" :class="getPortClassName(port)"> <div
class="port"
style="height: 40px; box-sizing: border-box; display: flex; cursor: pointer"
:class="getPortClassName(port)"
@contextmenu="(payload) => onContextmenu(payload, port)"
>
<!-- 将端口号和状态指示器包裹起来 用于居中布局 --> <!-- 将端口号和状态指示器包裹起来 用于居中布局 -->
<div style="margin: auto; display: flex; flex-direction: column; align-items: center"> <div style="margin: auto; display: flex; flex-direction: column; align-items: center">
<div style="font-size: xx-small">{{ index }}</div> <div style="font-size: xx-small">{{ index }}</div>
@@ -92,6 +280,12 @@ const getPortClassName = (port: NdmSwitchPortInfo) => {
<NDescriptionsItem label="上行速率">{{ transformPortSpeed(port, 'in') }}</NDescriptionsItem> <NDescriptionsItem label="上行速率">{{ transformPortSpeed(port, 'in') }}</NDescriptionsItem>
<NDescriptionsItem label="下行速率">{{ transformPortSpeed(port, 'out') }}</NDescriptionsItem> <NDescriptionsItem label="下行速率">{{ transformPortSpeed(port, 'out') }}</NDescriptionsItem>
<NDescriptionsItem label="总速率">{{ transformPortSpeed(port, 'total') }}</NDescriptionsItem> <NDescriptionsItem label="总速率">{{ transformPortSpeed(port, 'total') }}</NDescriptionsItem>
<NDescriptionsItem label="关联设备">
<span v-if="getLowerDeviceByPort(port)" style="text-decoration: underline; cursor: pointer" @click="() => navigateToLowerDevice(port)">
{{ getLowerDeviceByPort(port)?.name || '-' }}
</span>
<span v-else>-</span>
</NDescriptionsItem>
</NDescriptions> </NDescriptions>
</template> </template>
</NPopover> </NPopover>
@@ -101,6 +295,19 @@ const getPortClassName = (port: NdmSwitchPortInfo) => {
</template> </template>
</template> </template>
</NCard> </NCard>
<SwitchPortLinkModal v-model:show="showModal" :ndm-device="ndmDevice" :station="station" :port="contextmenu.port" />
<NDropdown
placement="bottom-start"
trigger="manual"
:show="showContextmenu"
:x="contextmenu.x"
:y="contextmenu.y"
:options="contextmenuOptions"
@select="onSelectDropdownOption"
@clickoutside="() => (showContextmenu = false)"
/>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -0,0 +1,222 @@
<script setup lang="ts">
import {
detailDeviceApi,
updateDeviceApi,
type NdmCameraLinkDescription,
type NdmDeviceResultVO,
type NdmSwitchLinkDescription,
type NdmSwitchPortInfo,
type NdmSwitchResultVO,
type Station,
} from '@/apis';
import { DeviceTree } from '@/components';
import { tryGetDeviceType } from '@/enums';
import { useDeviceStore, useSettingStore } from '@/stores';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import destr from 'destr';
import { cloneDeep } from 'es-toolkit';
import { NButton, NFlex, NModal } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, ref, toRefs } from 'vue';
const props = defineProps<{
ndmDevice: NdmSwitchResultVO;
station: Station;
port?: NdmSwitchPortInfo;
}>();
const show = defineModel<boolean>('show', { default: false });
const deviceStore = useDeviceStore();
const settingStore = useSettingStore();
const { offlineDev } = storeToRefs(settingStore);
const { ndmDevice, station, port } = toRefs(props);
const upperDeviceLinkDescription = computed(() => {
const result = destr<any>(ndmDevice.value.linkDescription);
if (!result) return null;
if (typeof result !== 'object') return null;
return result as NdmSwitchLinkDescription;
});
const lowerDevice = ref<NdmDeviceResultVO>();
const lowerDeviceLinkDescription = computed<NdmCameraLinkDescription | NdmSwitchLinkDescription | null>(() => {
if (!lowerDevice.value) return null;
const result = destr<any>(lowerDevice.value.linkDescription);
if (!result) return null;
if (typeof result !== 'object') return null;
return result;
});
const onAfterSelectDevice = (device: NdmDeviceResultVO) => {
lowerDevice.value = device;
};
const abortController = ref<AbortController>(new AbortController());
const { mutate: linkPortToDevice, isPending: linking } = useMutation({
mutationFn: async () => {
abortController.value.abort();
abortController.value = new AbortController();
window.$loadingBar.start();
const upperDeviceType = tryGetDeviceType(ndmDevice.value.deviceType);
if (!upperDeviceType) throw new Error('本设备类型未知');
const upperDeviceDbId = ndmDevice.value.id;
if (!upperDeviceDbId) throw new Error('本设备没有ID');
const { portName } = port.value ?? {};
if (!portName) throw new Error('该端口没有名称');
if (!lowerDevice.value) throw new Error('请选择要关联的设备');
const lowerDeviceType = tryGetDeviceType(lowerDevice.value?.deviceType);
if (!lowerDeviceType) throw new Error('该设备类型未知');
const lowerDeviceDbId = lowerDevice.value?.id;
if (!lowerDeviceDbId) throw new Error('该设备没有ID');
// 0. 检查上游设备的linkDescription的downstream字段是否存在某个端口已经关联下游设备
const duplicated = Object.entries(upperDeviceLinkDescription.value?.downstream ?? {}).find(([, deviceStoreIndex]) => {
return deviceStoreIndex.deviceDbId === lowerDeviceDbId;
});
if (duplicated) {
const [portName] = duplicated;
throw new Error(`该设备已关联到端口${portName}`);
}
// 1. 修改上游设备的linkDescription的downstream字段
const modifiedUpperDevice = cloneDeep(ndmDevice.value);
let modifiedUpperDeviceLinkDescription: NdmSwitchLinkDescription;
if (!upperDeviceLinkDescription.value) {
modifiedUpperDeviceLinkDescription = {
downstream: {
[portName]: {
stationCode: station.value.code,
deviceType: lowerDeviceType,
deviceDbId: lowerDeviceDbId,
},
},
};
} else {
modifiedUpperDeviceLinkDescription = {
...upperDeviceLinkDescription.value,
downstream: {
...upperDeviceLinkDescription.value.downstream,
[portName]: {
stationCode: station.value.code,
deviceType: lowerDeviceType,
deviceDbId: lowerDeviceDbId,
},
},
};
}
modifiedUpperDevice.linkDescription = JSON.stringify(modifiedUpperDeviceLinkDescription);
// 2. 修改下游设备的linkDescription的upstream字段
const modifiedLowerDevice = cloneDeep(lowerDevice.value);
let modifiedLowerDeviceLinkDescription: NdmSwitchLinkDescription | NdmCameraLinkDescription;
if (!lowerDeviceLinkDescription.value) {
modifiedLowerDeviceLinkDescription = {
upstream: [
{
stationCode: station.value.code,
deviceType: upperDeviceType,
deviceDbId: upperDeviceDbId,
},
],
};
} else {
const upstream = cloneDeep(lowerDeviceLinkDescription.value.upstream);
if (!upstream) {
modifiedLowerDeviceLinkDescription = {
...lowerDeviceLinkDescription.value,
upstream: [
{
stationCode: station.value.code,
deviceType: upperDeviceType,
deviceDbId: upperDeviceDbId,
},
],
};
} else {
const deviceStoreIndex = upstream.find((deviceStoreIndex) => deviceStoreIndex.deviceDbId === upperDeviceDbId);
if (!deviceStoreIndex) {
upstream.push({
stationCode: station.value.code,
deviceType: upperDeviceType,
deviceDbId: upperDeviceDbId,
});
}
modifiedLowerDeviceLinkDescription = {
...lowerDeviceLinkDescription.value,
upstream,
};
}
}
modifiedLowerDevice.linkDescription = JSON.stringify(modifiedLowerDeviceLinkDescription);
// TODO: 3. 发起update请求并获取最新的设备详情离线模式下直接修改本地数据
if (offlineDev.value) {
return { upperDevice: modifiedUpperDevice, lowerDevice: modifiedLowerDevice };
}
const stationCode = station.value.code;
const signal = abortController.value.signal;
await updateDeviceApi(modifiedUpperDevice, { stationCode, signal });
await updateDeviceApi(modifiedLowerDevice, { stationCode, signal });
const latestUpperDevice = await detailDeviceApi(modifiedUpperDevice, { stationCode, signal });
const latestLowerDevice = await detailDeviceApi(modifiedLowerDevice, { stationCode, signal });
return { upperDevice: latestUpperDevice, lowerDevice: latestLowerDevice };
},
onSuccess: ({ upperDevice, lowerDevice }) => {
show.value = false;
window.$loadingBar.finish();
window.$message.success('关联成功');
if (!!upperDevice && !!lowerDevice) {
deviceStore.patchDevice(station.value.code, { ...upperDevice });
deviceStore.patchDevice(station.value.code, { ...lowerDevice });
}
},
onError: (error) => {
window.$loadingBar.error();
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
const onLink = () => {
linkPortToDevice();
};
const onCancel = () => {
abortController.value.abort();
show.value = false;
};
</script>
<template>
<NModal v-model:show="show" preset="card" style="width: 600px; height: 600px" :content-style="{ height: '100%', overflow: 'hidden' }" @close="onCancel" @esc="onCancel">
<template #header>
<span>{{ ndmDevice.name }}</span>
<span> - </span>
<span>{{ port?.portName }}</span>
<span> - </span>
<span>关联设备</span>
</template>
<template #default>
<DeviceTree :station="station" :events="['select']" :device-prefix-label="'选择'" @after-select-device="onAfterSelectDevice" />
</template>
<template #action>
<NFlex justify="end">
<NButton size="small" quaternary @click="onCancel">取消</NButton>
<NButton size="small" type="primary" :disabled="!lowerDevice" :loading="linking" @click="onLink">关联</NButton>
</NFlex>
</template>
</NModal>
</template>
<style scoped lang="ts"></style>

View File

@@ -49,7 +49,7 @@ const circuits = computed(() => lastDiagInfo.value?.info?.at(0)?.circuits);
<DeviceCommonCard :common-info="commonInfo" /> <DeviceCommonCard :common-info="commonInfo" />
<DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" /> <DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" />
<SecurityBoxEnvCard :fan-speeds="fanSpeeds" :temperature="temperature" :humidity="humidity" :switches="switches" /> <SecurityBoxEnvCard :fan-speeds="fanSpeeds" :temperature="temperature" :humidity="humidity" :switches="switches" />
<SecurityBoxCircuitCard :circuits="circuits" :ndm-device="ndmDevice" :station="station" /> <SecurityBoxCircuitCard :ndm-device="ndmDevice" :station="station" :circuits="circuits" />
</NFlex> </NFlex>
</template> </template>

View File

@@ -29,7 +29,7 @@ const ports = computed(() => lastDiagInfo.value?.info?.portInfoList);
<NFlex vertical> <NFlex vertical>
<DeviceHeaderCard :ndm-device="ndmDevice" :station="station" /> <DeviceHeaderCard :ndm-device="ndmDevice" :station="station" />
<DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" /> <DeviceHardwareCard :cpu-usage="cpuUsage" :mem-usage="memUsage" />
<SwitchPortCard :ports="ports" /> <SwitchPortCard :ndm-device="ndmDevice" :station="station" :ports="ports" />
</NFlex> </NFlex>
</template> </template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { initStationDevices, type NdmDeviceResultVO, type NdmNvrResultVO, type Station } from '@/apis'; import { initStationDevices, type NdmDeviceResultVO, type NdmNvrResultVO, type Station } from '@/apis';
import { useDeviceTree } from '@/composables'; import { useDeviceTree, type UseDeviceTreeReturn } from '@/composables';
import { DEVICE_TYPE_NAMES, DEVICE_TYPE_LITERALS, tryGetDeviceType, type DeviceType } from '@/enums'; import { DEVICE_TYPE_NAMES, DEVICE_TYPE_LITERALS, tryGetDeviceType, type DeviceType } from '@/enums';
import { isNvrCluster } from '@/helpers'; import { isNvrCluster } from '@/helpers';
import { useDeviceStore, useStationStore } from '@/stores'; import { useDeviceStore, useStationStore } from '@/stores';
@@ -30,14 +30,33 @@ import { storeToRefs } from 'pinia';
import { computed, h, nextTick, onBeforeUnmount, ref, toRefs, useTemplateRef, watch, type CSSProperties } from 'vue'; import { computed, h, nextTick, onBeforeUnmount, ref, toRefs, useTemplateRef, watch, type CSSProperties } from 'vue';
const props = defineProps<{ const props = defineProps<{
station?: Station; // 支持渲染指定车站的设备树 /**
* 支持渲染指定车站的设备树
*/
station?: Station;
/**
* 允许的事件类型
*
* - `select`:允许选择设备
* - `manage`:允许右键菜单管理设备
*/
events?: ('select' | 'manage')[];
/**
* 是否同步路由参数
*/
syncRoute?: boolean;
/**
* 设备节点的前缀按钮文字
*/
devicePrefixLabel?: string;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
selectDevice: [device: NdmDeviceResultVO, stationCode: Station['code']]; afterSelectDevice: [device: NdmDeviceResultVO, stationCode: Station['code']];
exposeSelectDeviceFn: [selectDeviceFn: UseDeviceTreeReturn['selectDevice']];
}>(); }>();
const { station } = toRefs(props); const { station, events, syncRoute, devicePrefixLabel } = toRefs(props);
const themeVars = useThemeVars(); const themeVars = useThemeVars();
@@ -47,22 +66,25 @@ const {
selectedDeviceType, selectedDeviceType,
selectedDevice, selectedDevice,
selectDevice, selectDevice,
routeDevice,
// 设备管理 // 设备管理
exportDevice, exportDevice,
exportDeviceTemplate, exportDeviceTemplate,
importDevice, importDevice,
deleteDevice, deleteDevice,
} = useDeviceTree(); } = useDeviceTree({
syncRoute: computed(() => !!syncRoute.value),
});
// 将 `selectDevice` 函数暴露给父组件
emit('exposeSelectDeviceFn', selectDevice);
const onSelectDevice = (device: NdmDeviceResultVO, stationCode: Station['code']) => { const onSelectDevice = (device: NdmDeviceResultVO, stationCode: Station['code']) => {
selectDevice(device, stationCode); // 仅当事件列表包含 `select` 时才触发选择事件
emit('selectDevice', device, stationCode); if (!events.value) return;
}; if (!events.value.includes('select')) return;
const onRouteDevice = (device: NdmDeviceResultVO, stationCode: Station['code']) => { selectDevice(device, stationCode);
routeDevice(device, stationCode, { path: '/device' }); emit('afterSelectDevice', device, stationCode);
emit('selectDevice', device, stationCode);
}; };
const stationStore = useStationStore(); const stationStore = useStationStore();
@@ -187,20 +209,20 @@ const nodeProps: TreeProps['nodeProps'] = ({ option }) => {
onDblclick: (payload) => { onDblclick: (payload) => {
if (option['device']) { if (option['device']) {
payload.stopPropagation(); payload.stopPropagation();
const device = option['device'] as NdmDeviceResultVO; const device = option['device'] as NdmDeviceResultVO;
const stationCode = option['stationCode'] as Station['code']; const stationCode = option['stationCode'] as Station['code'];
// 区分是否需要跳转路由
// 当 props.station 存在时,说明当前是单独渲染车站的设备树,需要跳转路由到设备诊断页面 onSelectDevice(device, stationCode);
if (!station.value) {
onSelectDevice(device, stationCode);
} else {
onRouteDevice(device, stationCode);
}
} }
}, },
onContextmenu: (payload) => { onContextmenu: (payload) => {
payload.stopPropagation(); payload.stopPropagation();
payload.preventDefault(); payload.preventDefault();
// 仅当事件列表包含 `manage` 时才显示右键菜单
if (!events.value?.includes('manage')) return;
const { clientX, clientY } = payload; const { clientX, clientY } = payload;
const stationCode = option['stationCode'] as Station['code']; const stationCode = option['stationCode'] as Station['code'];
const deviceType = option['deviceType'] as DeviceType | undefined; const deviceType = option['deviceType'] as DeviceType | undefined;
@@ -231,6 +253,7 @@ const renderIcmpStatistics = (onlineCount: number, offlineCount: number, count:
}; };
const renderDeviceNodePrefix = (device: NdmDeviceResultVO, stationCode: Station['code']) => { const renderDeviceNodePrefix = (device: NdmDeviceResultVO, stationCode: Station['code']) => {
const renderViewDeviceButton = (device: NdmDeviceResultVO, stationCode: string) => { const renderViewDeviceButton = (device: NdmDeviceResultVO, stationCode: string) => {
if (!devicePrefixLabel.value) return null;
return h( return h(
NButton, NButton,
{ {
@@ -242,17 +265,11 @@ const renderDeviceNodePrefix = (device: NdmDeviceResultVO, stationCode: Station[
} as CSSProperties, } as CSSProperties,
onClick: (e: MouseEvent) => { onClick: (e: MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
// 选择设备
// 区分是否需要跳转路由 onSelectDevice(device, stationCode);
// 当 props.station 存在时,说明当前是单独渲染车站的设备树,需要跳转路由到设备诊断页面
if (!station.value) {
onSelectDevice(device, stationCode);
} else {
onRouteDevice(device, stationCode);
}
}, },
}, },
() => '查看', () => devicePrefixLabel.value,
); );
}; };
const renderDeviceStatusTag = (device: NdmDeviceResultVO) => { const renderDeviceStatusTag = (device: NdmDeviceResultVO) => {
@@ -529,6 +546,7 @@ watch(selectedDevice, async () => {
virtual-scroll virtual-scroll
:data="stationDeviceTreeData" :data="stationDeviceTreeData"
:animated="animated" :animated="animated"
:selected-keys="selectedKeys"
:show-irrelevant-nodes="false" :show-irrelevant-nodes="false"
:pattern="searchPattern" :pattern="searchPattern"
:filter="searchFilter" :filter="searchFilter"

View File

@@ -2,12 +2,13 @@
import type { NdmDeviceAlarmLogResultVO, Station } from '@/apis'; import type { NdmDeviceAlarmLogResultVO, Station } from '@/apis';
import { ALARM_TYPES, DEVICE_TYPE_LITERALS, DEVICE_TYPE_NAMES, FAULT_LEVELS, tryGetDeviceType } from '@/enums'; import { ALARM_TYPES, DEVICE_TYPE_LITERALS, DEVICE_TYPE_NAMES, FAULT_LEVELS, tryGetDeviceType } from '@/enums';
import { renderAlarmDateCell, renderAlarmTypeCell, renderDeviceTypeCell, renderFaultLevelCell } from '@/helpers'; import { renderAlarmDateCell, renderAlarmTypeCell, renderDeviceTypeCell, renderFaultLevelCell } from '@/helpers';
import { useAlarmStore } from '@/stores'; import { useAlarmStore, useDeviceStore } from '@/stores';
import { downloadByData } from '@/utils'; import { downloadByData } from '@/utils';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { NButton, NDataTable, NFlex, NGrid, NGridItem, NModal, NStatistic, NTag, type DataTableBaseColumn, type DataTableRowData, type PaginationProps } from 'naive-ui'; import { NButton, NDataTable, NFlex, NGrid, NGridItem, NModal, NStatistic, NTag, type DataTableBaseColumn, type DataTableRowData, type PaginationProps } from 'naive-ui';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed, h, reactive, ref, toRefs } from 'vue'; import { computed, h, reactive, ref, toRefs, type CSSProperties } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const props = defineProps<{ const props = defineProps<{
station?: Station; station?: Station;
@@ -15,8 +16,13 @@ const props = defineProps<{
const show = defineModel<boolean>('show', { default: false }); const show = defineModel<boolean>('show', { default: false });
const route = useRoute();
const router = useRouter();
const { station } = toRefs(props); const { station } = toRefs(props);
const deviceStore = useDeviceStore();
const { lineDevices } = storeToRefs(deviceStore);
const alarmStore = useAlarmStore(); const alarmStore = useAlarmStore();
const { lineAlarms } = storeToRefs(alarmStore); const { lineAlarms } = storeToRefs(alarmStore);
@@ -37,7 +43,40 @@ const tableColumns = ref<DataTableBaseColumn<NdmDeviceAlarmLogResultVO>[]>([
{ title: '告警流水号', key: 'alarmNo' }, { title: '告警流水号', key: 'alarmNo' },
{ title: '告警时间', key: 'alarmDate', render: renderAlarmDateCell }, { title: '告警时间', key: 'alarmDate', render: renderAlarmDateCell },
{ title: '设备类型', key: 'deviceType', render: renderDeviceTypeCell }, { title: '设备类型', key: 'deviceType', render: renderDeviceTypeCell },
{ title: '设备名称', key: 'deviceName' }, {
title: '设备名称',
key: 'deviceName',
render: (rowData) => {
return h(
'div',
{
style: { textDecoration: 'underline', cursor: 'pointer' } as CSSProperties,
onClick: () => {
const stationCode = rowData.stationCode;
if (!stationCode) return;
const deviceType = tryGetDeviceType(rowData.deviceType);
if (!deviceType) return;
const stationDevices = lineDevices.value[stationCode];
if (!stationDevices) return;
const classified = stationDevices[deviceType];
const device = classified.find((device) => device.deviceId === rowData.deviceId);
if (!device) return;
const deviceDbId = device.id;
router.push({
path: '/device',
query: {
stationCode,
deviceType,
deviceDbId,
fromPage: route.path,
},
});
},
},
`${rowData.deviceName}`,
);
},
},
{ title: '告警类型', key: 'alarmType', align: 'center', render: renderAlarmTypeCell }, { title: '告警类型', key: 'alarmType', align: 'center', render: renderAlarmTypeCell },
{ title: '故障级别', key: 'faultLevel', align: 'center', render: renderFaultLevelCell }, { title: '故障级别', key: 'faultLevel', align: 'center', render: renderFaultLevelCell },
// { title: '故障编码', key: 'faultCode', align: 'center' }, // { title: '故障编码', key: 'faultCode', align: 'center' },

View File

@@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Station } from '@/apis'; import type { Station } from '@/apis';
import { DeviceTree } from '@/components'; import { DeviceTree, type DeviceTreeProps } from '@/components';
import { tryGetDeviceType } from '@/enums';
import { NModal } from 'naive-ui'; import { NModal } from 'naive-ui';
import { toRefs } from 'vue'; import { toRefs } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const props = defineProps<{ const props = defineProps<{
station?: Station; station?: Station;
@@ -10,13 +12,30 @@ const props = defineProps<{
const show = defineModel<boolean>('show', { default: false }); const show = defineModel<boolean>('show', { default: false });
const route = useRoute();
const router = useRouter();
const { station } = toRefs(props); const { station } = toRefs(props);
const onAfterSelectDevice: DeviceTreeProps['onAfterSelectDevice'] = (device, stationCode) => {
const deviceDbId = device.id;
const deviceType = tryGetDeviceType(device.deviceType);
router.push({
path: '/device',
query: {
stationCode,
deviceType,
deviceDbId,
fromPage: route.path,
},
});
};
</script> </script>
<template> <template>
<NModal v-model:show="show" preset="card" style="width: 600px; height: 600px" :title="`${station?.name} - 设备详情`" :content-style="{ height: '100%', overflow: 'hidden' }"> <NModal v-model:show="show" preset="card" style="width: 600px; height: 600px" :title="`${station?.name} - 设备详情`" :content-style="{ height: '100%', overflow: 'hidden' }">
<template #default> <template #default>
<DeviceTree :station="station" /> <DeviceTree :station="station" :events="['select', 'manage']" :device-prefix-label="'查看'" @after-select-device="onAfterSelectDevice" />
</template> </template>
</NModal> </NModal>
</template> </template>

View File

@@ -1,13 +1,14 @@
import { deleteDeviceApi, exportDeviceApi, importDeviceApi, type ImportMsg, type NdmDevicePageQuery, type PageParams, type Station } from '@/apis'; import { deleteDeviceApi, exportDeviceApi, importDeviceApi, type ImportMsg, type NdmDeviceLinkDescription, type NdmDevicePageQuery, type PageParams, type Station } from '@/apis';
import { useStationDevicesMutation } from '@/composables';
import { DEVICE_TYPE_NAMES, type DeviceType } from '@/enums'; import { DEVICE_TYPE_NAMES, type DeviceType } from '@/enums';
import { useDeviceStore, useStationStore } from '@/stores'; import { useDeviceStore, useStationStore } from '@/stores';
import { downloadByData, parseErrorFeedback } from '@/utils'; import { downloadByData, parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query'; import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import destr from 'destr';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { h, onBeforeUnmount } from 'vue'; import { h, onBeforeUnmount } from 'vue';
import { useStationDevicesMutation } from '../query';
import { isCancel } from 'axios';
export const useDeviceManagement = () => { export const useDeviceManagement = () => {
const stationStore = useStationStore(); const stationStore = useStationStore();
@@ -152,6 +153,32 @@ export const useDeviceManagement = () => {
window.$loadingBar.start(); window.$loadingBar.start();
// 检查要删除的设备是否存在关联设备
const stationDevices = lineDevices.value[stationCode];
if (!!stationDevices) {
const classified = stationDevices[deviceType];
if (!!classified) {
const device = classified.find((device) => device.id === id);
if (!!device) {
const maybeLinkDescription = destr<any>(device.linkDescription);
if (!!maybeLinkDescription && typeof maybeLinkDescription === 'object') {
const linkDescription = maybeLinkDescription as NdmDeviceLinkDescription;
// 只要有上游或下游设备,就不能删除
const { upstream } = linkDescription;
if (!!upstream && upstream.length > 0) {
throw new Error('该设备存在关联的上游设备,无法删除');
}
if ('downstream' in linkDescription) {
const { downstream } = linkDescription;
if (!!downstream && Object.keys(downstream).length > 0) {
throw new Error('该设备存在关联的下游设备,无法删除');
}
}
}
}
}
}
return await deleteDeviceApi(deviceType, id, { stationCode, signal }); return await deleteDeviceApi(deviceType, id, { stationCode, signal });
}, },
onSuccess: (_, { stationCode, signal }) => { onSuccess: (_, { stationCode, signal }) => {
@@ -182,3 +209,5 @@ export const useDeviceManagement = () => {
deleteDevice, deleteDevice,
}; };
}; };
export type UseDeviceManagementReturn = ReturnType<typeof useDeviceManagement>;

View File

@@ -1,26 +1,28 @@
import type { LineDevices, NdmDeviceResultVO } from '@/apis'; import type { LineDevices, NdmDeviceResultVO, Station } from '@/apis';
import { tryGetDeviceType, type DeviceType } from '@/enums'; import { tryGetDeviceType, type DeviceType } from '@/enums';
import { useDeviceStore } from '@/stores'; import { useDeviceStore } from '@/stores';
import { watchDebounced } from '@vueuse/core'; import { watchDebounced } from '@vueuse/core';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { onMounted, ref, watch } from 'vue'; import { onMounted, ref, toValue, watch, type MaybeRefOrGetter } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
export const useDeviceSelection = () => { export const useDeviceSelection = (options?: { syncRoute?: MaybeRefOrGetter<boolean> }) => {
const { syncRoute } = options ?? {};
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const deviceStore = useDeviceStore(); const deviceStore = useDeviceStore();
const { lineDevices } = storeToRefs(deviceStore); const { lineDevices } = storeToRefs(deviceStore);
const selectedStationCode = ref<string>(); const selectedStationCode = ref<Station['code']>();
const selectedDeviceType = ref<DeviceType>(); const selectedDeviceType = ref<DeviceType>();
const selectedDevice = ref<NdmDeviceResultVO>(); const selectedDevice = ref<NdmDeviceResultVO>();
const initFromRoute = (lineDevices: LineDevices) => { const initFromRoute = (lineDevices: LineDevices) => {
const { stationCode, deviceType, deviceDbId } = route.query; const { stationCode, deviceType, deviceDbId } = route.query;
if (stationCode) { if (stationCode) {
selectedStationCode.value = stationCode as string; selectedStationCode.value = stationCode as Station['code'];
} }
if (deviceType) { if (deviceType) {
selectedDeviceType.value = deviceType as DeviceType; selectedDeviceType.value = deviceType as DeviceType;
@@ -40,7 +42,7 @@ export const useDeviceSelection = () => {
} }
}; };
const selectDevice = (device: NdmDeviceResultVO, stationCode: string) => { const selectDevice = (device: NdmDeviceResultVO, stationCode: Station['code']) => {
selectedDevice.value = device; selectedDevice.value = device;
selectedStationCode.value = stationCode; selectedStationCode.value = stationCode;
const deviceType = tryGetDeviceType(device.deviceType); const deviceType = tryGetDeviceType(device.deviceType);
@@ -49,20 +51,6 @@ export const useDeviceSelection = () => {
} }
}; };
const routeDevice = (device: NdmDeviceResultVO, stationCode: string, to: { path: string }) => {
const deviceDbId = device.id;
const deviceType = tryGetDeviceType(device.deviceType);
router.push({
path: to.path,
query: {
stationCode,
deviceType,
deviceDbId,
fromPage: route.path,
},
});
};
const syncToRoute = () => { const syncToRoute = () => {
const query = { ...route.query }; const query = { ...route.query };
// 当选中的设备发生变化时删除fromPage参数 // 当选中的设备发生变化时删除fromPage参数
@@ -81,14 +69,20 @@ export const useDeviceSelection = () => {
router.replace({ query }); router.replace({ query });
}; };
watch(selectedDevice, syncToRoute); watch(selectedDevice, () => {
if (toValue(syncRoute)) {
syncToRoute();
}
});
// lineDevices是shallowRef因此需要深度侦听才能获取内部变化 // lineDevices是shallowRef因此需要深度侦听才能获取内部变化
// 而单纯的深度侦听又可能会引发性能问题,因此尝试使用防抖侦听 // 而单纯的深度侦听又可能会引发性能问题,因此尝试使用防抖侦听
watchDebounced( watchDebounced(
lineDevices, lineDevices,
(newLineDevices) => { (newLineDevices) => {
initFromRoute(newLineDevices); if (toValue(syncRoute)) {
initFromRoute(newLineDevices);
}
}, },
{ {
debounce: 500, debounce: 500,
@@ -97,7 +91,9 @@ export const useDeviceSelection = () => {
); );
onMounted(() => { onMounted(() => {
initFromRoute(lineDevices.value); if (toValue(syncRoute)) {
initFromRoute(lineDevices.value);
}
}); });
return { return {
@@ -107,6 +103,7 @@ export const useDeviceSelection = () => {
initFromRoute, initFromRoute,
selectDevice, selectDevice,
routeDevice,
}; };
}; };
export type UseDeviceSelectionReturn = ReturnType<typeof useDeviceSelection>;

View File

@@ -1,8 +1,11 @@
import type { MaybeRefOrGetter } from 'vue';
import { useDeviceManagement } from './use-device-management'; import { useDeviceManagement } from './use-device-management';
import { useDeviceSelection } from './use-device-selection'; import { useDeviceSelection } from './use-device-selection';
export const useDeviceTree = () => { export const useDeviceTree = (options?: { syncRoute?: MaybeRefOrGetter<boolean> }) => {
const deviceSelection = useDeviceSelection(); const { syncRoute } = options ?? {};
const deviceSelection = useDeviceSelection({ syncRoute });
const deviceManagement = useDeviceManagement(); const deviceManagement = useDeviceManagement();
return { return {
@@ -10,3 +13,5 @@ export const useDeviceTree = () => {
...deviceManagement, ...deviceManagement,
}; };
}; };
export type UseDeviceTreeReturn = ReturnType<typeof useDeviceTree>;

View File

@@ -1,3 +1,4 @@
export * from './injection';
export * from './java'; export * from './java';
export * from './mutation'; export * from './mutation';
export * from './query'; export * from './query';

View File

@@ -0,0 +1,5 @@
import type { UseDeviceTreeReturn } from '@/composables';
import { createInjectionKey } from '@/utils';
import { type Ref } from 'vue';
export const SELECT_DEVICE_FN_INJECTION_KEY = createInjectionKey<Ref<UseDeviceTreeReturn['selectDevice'] | undefined>>('select-device-fn');

View File

@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { exportDeviceAlarmLogApi, pageDeviceAlarmLogApi, type NdmDeviceAlarmLog, type NdmDeviceAlarmLogResultVO, type PageQueryExtra, type Station } from '@/apis'; import { exportDeviceAlarmLogApi, pageDeviceAlarmLogApi, type NdmDeviceAlarmLog, type NdmDeviceAlarmLogResultVO, type PageQueryExtra, type Station } from '@/apis';
import { useAlarmActionColumn, useCameraSnapColumn } from '@/composables'; import { useAlarmActionColumn, useCameraSnapColumn } from '@/composables';
import { ALARM_TYPES, DEVICE_TYPE_CODES, DEVICE_TYPE_LITERALS, DEVICE_TYPE_NAMES, FAULT_LEVELS, type DeviceType } from '@/enums'; import { ALARM_TYPES, DEVICE_TYPE_CODES, DEVICE_TYPE_LITERALS, DEVICE_TYPE_NAMES, FAULT_LEVELS, tryGetDeviceType, type DeviceType } from '@/enums';
import { renderAlarmDateCell, renderAlarmTypeCell, renderDeviceTypeCell, renderFaultLevelCell } from '@/helpers'; import { renderAlarmDateCell, renderAlarmTypeCell, renderDeviceTypeCell, renderFaultLevelCell } from '@/helpers';
import { useAlarmStore, useStationStore } from '@/stores'; import { useAlarmStore, useDeviceStore, useStationStore } from '@/stores';
import { downloadByData, parseErrorFeedback } from '@/utils'; import { downloadByData, parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query'; import { useMutation } from '@tanstack/vue-query';
import { watchDebounced } from '@vueuse/core'; import { watchDebounced } from '@vueuse/core';
@@ -26,7 +26,8 @@ import {
type SelectOption, type SelectOption,
} from 'naive-ui'; } from 'naive-ui';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed, h, onBeforeMount, reactive, ref, watch } from 'vue'; import { computed, h, onBeforeMount, reactive, ref, watch, type CSSProperties } from 'vue';
import { useRoute, useRouter } from 'vue-router';
interface SearchFields extends PageQueryExtra<NdmDeviceAlarmLog> { interface SearchFields extends PageQueryExtra<NdmDeviceAlarmLog> {
stationCode_in: Station['code'][]; stationCode_in: Station['code'][];
@@ -37,8 +38,13 @@ interface SearchFields extends PageQueryExtra<NdmDeviceAlarmLog> {
alarmDate: [number, number]; alarmDate: [number, number];
} }
const route = useRoute();
const router = useRouter();
const stationStore = useStationStore(); const stationStore = useStationStore();
const { stations } = storeToRefs(stationStore); const { stations } = storeToRefs(stationStore);
const deviceStore = useDeviceStore();
const { lineDevices } = storeToRefs(deviceStore);
const alarmStore = useAlarmStore(); const alarmStore = useAlarmStore();
const { unreadAlarmCount } = storeToRefs(alarmStore); const { unreadAlarmCount } = storeToRefs(alarmStore);
@@ -140,7 +146,40 @@ const tableColumns: DataTableColumns<NdmDeviceAlarmLogResultVO & { snapUrl?: str
{ title: '告警时间', key: 'alarmDate', render: renderAlarmDateCell }, { title: '告警时间', key: 'alarmDate', render: renderAlarmDateCell },
{ title: '车站', key: 'stationName', render: (rowData) => stations.value.find((station) => station.code === rowData.stationCode)?.name ?? '-' }, { title: '车站', key: 'stationName', render: (rowData) => stations.value.find((station) => station.code === rowData.stationCode)?.name ?? '-' },
{ title: '设备类型', key: 'deviceType', render: renderDeviceTypeCell }, { title: '设备类型', key: 'deviceType', render: renderDeviceTypeCell },
{ title: '设备名称', key: 'deviceName' }, {
title: '设备名称',
key: 'deviceName',
render: (rowData) => {
return h(
'div',
{
style: { textDecoration: 'underline', cursor: 'pointer' } as CSSProperties,
onClick: () => {
const stationCode = rowData.stationCode;
if (!stationCode) return;
const deviceType = tryGetDeviceType(rowData.deviceType);
if (!deviceType) return;
const stationDevices = lineDevices.value[stationCode];
if (!stationDevices) return;
const classified = stationDevices[deviceType];
const device = classified.find((device) => device.deviceId === rowData.deviceId);
if (!device) return;
const deviceDbId = device.id;
router.push({
path: '/device',
query: {
stationCode,
deviceType,
deviceDbId,
fromPage: route.path,
},
});
},
},
`${rowData.deviceName}`,
);
},
},
{ title: '告警类型', key: 'alarmType', align: 'center', render: renderAlarmTypeCell }, { title: '告警类型', key: 'alarmType', align: 'center', render: renderAlarmTypeCell },
{ title: '故障级别', key: 'faultLevel', align: 'center', render: renderFaultLevelCell }, { title: '故障级别', key: 'faultLevel', align: 'center', render: renderFaultLevelCell },
{ title: '故障描述', key: 'faultDescription' }, { title: '故障描述', key: 'faultDescription' },

View File

@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { NdmDeviceResultVO, Station } from '@/apis'; import type { NdmDeviceResultVO, Station } from '@/apis';
import { DeviceRenderer, DeviceTree, type DeviceTreeProps } from '@/components'; import { DeviceRenderer, DeviceTree, type DeviceTreeProps } from '@/components';
import type { UseDeviceSelectionReturn } from '@/composables';
import { SELECT_DEVICE_FN_INJECTION_KEY } from '@/constants';
import { useStationStore } from '@/stores'; import { useStationStore } from '@/stores';
import { NLayout, NLayoutContent, NLayoutSider } from 'naive-ui'; import { NLayout, NLayoutContent, NLayoutSider } from 'naive-ui';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { ref } from 'vue'; import { provide, ref } from 'vue';
const stationStore = useStationStore(); const stationStore = useStationStore();
const { stations } = storeToRefs(stationStore); const { stations } = storeToRefs(stationStore);
@@ -12,7 +14,14 @@ const { stations } = storeToRefs(stationStore);
const selectedStation = ref<Station>(); const selectedStation = ref<Station>();
const selectedDevice = ref<NdmDeviceResultVO>(); const selectedDevice = ref<NdmDeviceResultVO>();
const onSelectDevice: DeviceTreeProps['onSelectDevice'] = (device, stationCode) => { // 获取设备树暴露出来的 `selectDevice` 函数,并将其提供给子组件
const selectDeviceFn = ref<UseDeviceSelectionReturn['selectDevice']>();
const onExposeSelectDeviceFn: DeviceTreeProps['onExposeSelectDeviceFn'] = (fn) => {
selectDeviceFn.value = fn;
};
provide(SELECT_DEVICE_FN_INJECTION_KEY, selectDeviceFn);
const onAfterSelectDevice: DeviceTreeProps['onAfterSelectDevice'] = (device, stationCode) => {
selectedDevice.value = device; selectedDevice.value = device;
selectedStation.value = stations.value.find((station) => station.code === stationCode); selectedStation.value = stations.value.find((station) => station.code === stationCode);
}; };
@@ -21,7 +30,7 @@ const onSelectDevice: DeviceTreeProps['onSelectDevice'] = (device, stationCode)
<template> <template>
<NLayout has-sider style="height: 100%"> <NLayout has-sider style="height: 100%">
<NLayoutSider bordered :width="600" :collapsed-width="0" show-trigger="bar"> <NLayoutSider bordered :width="600" :collapsed-width="0" show-trigger="bar">
<DeviceTree @select-device="onSelectDevice" /> <DeviceTree :events="['select', 'manage']" :sync-route="true" :device-prefix-label="'查看'" @expose-select-device-fn="onExposeSelectDeviceFn" @after-select-device="onAfterSelectDevice" />
</NLayoutSider> </NLayoutSider>
<NLayoutContent :content-style="{ padding: '8px 8px 8px 24px' }"> <NLayoutContent :content-style="{ padding: '8px 8px 8px 24px' }">
<template v-if="selectedStation && selectedDevice"> <template v-if="selectedStation && selectedDevice">