354 lines
13 KiB
Vue
354 lines
13 KiB
Vue
<script setup lang="ts">
|
||
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 { useDeviceStore, useSettingStore } from '@/stores';
|
||
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<{
|
||
ndmDevice: NdmSwitchResultVO;
|
||
station: Station;
|
||
ports?: NdmSwitchPortInfo[];
|
||
}>();
|
||
|
||
const themeVars = useThemeVars();
|
||
|
||
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 switchSlots = computed(() => {
|
||
// 解析端口名称,将端口按槽位进行分组
|
||
const groupMap = new Map<string, NdmSwitchPortInfo[]>();
|
||
ports.value?.forEach((port) => {
|
||
const parts = port.portName.split('/');
|
||
if (parts.length >= 3) {
|
||
const slotName = `${parts[0]}/${parts[1]}`;
|
||
if (!groupMap.has(slotName)) {
|
||
groupMap.set(slotName, []);
|
||
}
|
||
groupMap.get(slotName)!.push(port);
|
||
}
|
||
});
|
||
|
||
// 将Map转换为entries
|
||
const entries = Array.from(groupMap.entries());
|
||
|
||
// 按槽位进行排序
|
||
const sorted = entries.sort(([aSlotName], [bSlotName]) => {
|
||
const [mainA = 0, subA = 0] = aSlotName.split('/').map((value) => parseInt(value));
|
||
const [mainB = 0, subB = 0] = bSlotName.split('/').map((value) => parseInt(value));
|
||
return mainA - mainB || subA - subB;
|
||
});
|
||
|
||
// 按端口号进行排序
|
||
return sorted.map(([slotName, ports]) => ({
|
||
slotName,
|
||
ports: ports.sort((portA, portB) => {
|
||
const portNumA = parseInt(portA.portName.split('/').at(2) ?? '0');
|
||
const portNumB = parseInt(portB.portName.split('/').at(2) ?? '0');
|
||
return portNumA - portNumB;
|
||
}),
|
||
}));
|
||
});
|
||
|
||
const getPortClassName = (port: NdmSwitchPortInfo) => {
|
||
if (port.upDown === 1) {
|
||
return 'port-up';
|
||
}
|
||
if (port.upDown === 2) {
|
||
return 'port-down';
|
||
}
|
||
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>
|
||
|
||
<template>
|
||
<NCard v-if="showCard" hoverable size="small">
|
||
<template #header>
|
||
<span>端口数据</span>
|
||
</template>
|
||
<template #default>
|
||
<!-- 遍历所有槽位 -->
|
||
<template v-for="{ slotName, ports } in switchSlots" :key="slotName">
|
||
<div style="padding: 8px 0">
|
||
<span>{{ slotName }}({{ ports.length }}个端口)</span>
|
||
<!-- 端口布局 2行 至少12列 纵向排序 -->
|
||
<div style="display: grid; grid-template-rows: repeat(2, auto); grid-auto-flow: column" :style="{ 'grid-template-columns': `repeat(${Math.max(ports.length / 2, 12)}, 1fr)` }">
|
||
<template v-for="(port, index) in ports" :key="port.portName">
|
||
<!-- 端口 -->
|
||
<NPopover :delay="300">
|
||
<template #trigger>
|
||
<!-- 最外层div宽度100% -->
|
||
<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="font-size: xx-small">{{ index }}</div>
|
||
<div class="indicator" style="width: 8px; height: 8px; border-radius: 50%" :class="getPortClassName(port)"></div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template #default>
|
||
<NDescriptions bordered size="small" label-placement="left" :column="1">
|
||
<NDescriptionsItem label="端口名称">{{ port.portName }}</NDescriptionsItem>
|
||
<NDescriptionsItem label="状态">{{ getPortStatusValue(port) }}</NDescriptionsItem>
|
||
<NDescriptionsItem label="上行速率">{{ transformPortSpeed(port, 'in') }}</NDescriptionsItem>
|
||
<NDescriptionsItem label="下行速率">{{ transformPortSpeed(port, 'out') }}</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>
|
||
</template>
|
||
</NPopover>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
</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>
|
||
|
||
<style scoped lang="scss">
|
||
.port {
|
||
&.port-up {
|
||
&:hover {
|
||
background-color: #18a05816;
|
||
}
|
||
}
|
||
|
||
&.port-down {
|
||
&:hover {
|
||
background-color: #d0305016;
|
||
}
|
||
}
|
||
|
||
&.port-unknown {
|
||
&:hover {
|
||
background-color: #f0a02016;
|
||
}
|
||
}
|
||
|
||
&:hover {
|
||
transform: translateY(-2px);
|
||
transition: transform 0.2s ease-in-out;
|
||
box-shadow: v-bind('themeVars.boxShadow1');
|
||
}
|
||
|
||
.indicator {
|
||
&.port-up {
|
||
background-color: #18a058;
|
||
}
|
||
|
||
&.port-down {
|
||
background-color: #d03050;
|
||
}
|
||
|
||
&.port-unknown {
|
||
background-color: #f0a020;
|
||
}
|
||
}
|
||
}
|
||
</style>
|