Files
pqs-9100_client/frontend/src/views/machine/freqConverter/components/freqConverterDetectChannelPairing.vue
2026-04-17 09:15:58 +08:00

531 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<el-card class="section-card" shadow="never">
<template #header>
<div class="section-header">
<span>通道配对</span>
<span class="section-tip">选择设备后默认连接到第一个通道如需调整可删除后重新配对</span>
</div>
</template>
<div class="toolbar">
<div class="toolbar-item">
<span class="toolbar-label">设备</span>
<el-select
v-model="selectedDeviceId"
class="device-select"
filterable
:loading="loading"
placeholder="请选择设备"
>
<el-option
v-for="item in deviceOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</div>
</div>
<div v-if="selectedDevice" ref="flowContainerRef" class="flow-container" :style="{height: `${flowHeight}px`}">
<VueFlow
:nodes="nodes"
:edges="edges"
:edge-types="edgeTypes"
:nodes-draggable="false"
:elements-selectable="true"
:zoom-on-scroll="false"
:pan-on-drag="false"
:zoom-on-double-click="false"
:prevent-scrolling="true"
:min-zoom="0.35"
:max-zoom="1"
fit-view-on-init
@connect="handleConnect"
/>
</div>
<el-empty v-else description="请选择设备后进行通道配对" />
</el-card>
</template>
<script lang="ts" setup>
import {ElMessage} from 'element-plus'
import {computed, h, nextTick, onBeforeUnmount, onMounted, ref, watch} from 'vue'
import {Position, VueFlow, useVueFlow, type Connection, type Edge, type Node} from '@vue-flow/core'
import {getPqDevListAll} from '@/api/device/device'
import {type Device} from '@/api/device/interface/device'
import {type FreqConverter} from '@/api/device/interface/freqConverter'
import FreqConverterDetectEdge from '@/views/machine/freqConverter/components/freqConverterDetectEdge.vue'
const props = defineProps<{
freqConverter: FreqConverter.ResFreqConverter | null;
}>()
const loading = ref(false)
const deviceOptions = ref<Device.ResPqDev[]>([])
const selectedDeviceId = ref('')
const nodes = ref<Node[]>([])
const edges = ref<Edge[]>([])
const flowContainerRef = ref<HTMLElement | null>(null)
const selectedTargetChannelId = ref('')
const {fitView} = useVueFlow()
const flowHeight = 340
let flowContainerResizeObserver: ResizeObserver | null = null
const edgeTypes = {
deletable: FreqConverterDetectEdge
}
const selectedDevice = computed(() => {
return deviceOptions.value.find(item => item.id === selectedDeviceId.value) || null
})
const createDeviceLabel = (title: string, lines: string[]) => {
return h(
'div',
{class: 'device-node-label'},
[
h('div', {class: 'device-node-title'}, title),
...lines.map(line => h('div', {class: 'device-node-line'}, line))
]
) as any
}
const createChannelLabel = (title: string) => {
return h(
'div',
{class: 'channel-node-label'},
h('span', {class: 'channel-node-text'}, title)
) as any
}
const createConnectionEdge = (source: string, target: string): Edge => ({
id: `${source}-${target}`,
source,
target,
type: 'deletable',
selectable: true,
focusable: true,
data: {
onDelete: removeConnectionById
},
style: {
stroke: 'var(--el-color-primary)'
}
})
const getDeviceChannels = (device: Device.ResPqDev | null) => {
if (!device) {
return [] as string[]
}
const channelCount = Math.max(Number(device.devChns) || 0, 0)
return Array.from({length: channelCount}, (_, index) => `${index + 1}`)
}
const buildNodes = (device: Device.ResPqDev) => {
const channelList = getDeviceChannels(device)
const flowNodes: Node[] = []
const channelCount = Math.max(channelList.length, 1)
const flowCanvasHeight = flowHeight
const gapY = 42
const channelHeight = 20
const cardHeight = 120
const cardWidth = 210
const layoutCenterY = flowCanvasHeight / 2
const channelGroupHeight = channelHeight + (channelCount - 1) * gapY
const startY = layoutCenterY - channelGroupHeight / 2
const cardY = layoutCenterY - cardHeight / 2
flowNodes.push({
id: `freq-converter-${props.freqConverter?.id || 'default'}`,
type: 'output',
data: {
label: createDeviceLabel('变频器', [
`名称:${props.freqConverter?.name || '-'}`,
`串口:${props.freqConverter?.portName || '-'}`,
`从机地址:${props.freqConverter?.slaveAddress ?? '-'}`,
`波特率:${props.freqConverter?.baudRate ?? '-'}`
])
},
position: {x: 84, y: cardY},
sourcePosition: Position.Right,
draggable: false,
selectable: false,
class: 'freq-converter-node',
style: {width: `${cardWidth}px`, height: `${cardHeight}px`, border: 'none', boxShadow: 'none', background: 'transparent'}
})
flowNodes.push({
id: `device-card-${device.id}`,
data: {
label: createDeviceLabel('设备', [
`名称:${device.name || '-'}`,
`类型:${device.devType || '-'}`,
`IP${device.ip || '-'}`,
`通道数:${channelList.length}`
])
},
position: {x: 654, y: cardY},
draggable: false,
selectable: false,
class: 'no-handle-node',
style: {width: `${cardWidth}px`, height: `${cardHeight}px`, border: 'none', boxShadow: 'none', background: 'transparent'}
})
channelList.forEach((channel, index) => {
const y = startY + index * gapY
flowNodes.push({
id: `device-channel-${device.id}-${channel}`,
type: 'input',
data: {label: createChannelLabel(`通道${channel}`)},
position: {x: 584, y},
targetPosition: Position.Left,
draggable: false,
selectable: false,
class: 'channel-node',
style: {width: '68px', height: `${channelHeight}px`, border: 'none', boxShadow: 'none', background: 'transparent'}
})
})
return flowNodes
}
const fitFlowView = async () => {
if (!nodes.value.length) {
return
}
await nextTick()
await fitView({
padding: 0.18,
duration: 0
})
}
const observeFlowContainer = () => {
flowContainerResizeObserver?.disconnect()
flowContainerResizeObserver = null
if (!flowContainerRef.value || typeof ResizeObserver === 'undefined') {
return
}
flowContainerResizeObserver = new ResizeObserver(() => {
void fitFlowView()
})
flowContainerResizeObserver.observe(flowContainerRef.value)
}
const getExpectedSourceId = () => `freq-converter-${props.freqConverter?.id || 'default'}`
const getFirstChannelId = (device: Device.ResPqDev | null) => {
if (!device) {
return ''
}
const [firstChannel] = getDeviceChannels(device)
return firstChannel ? `device-channel-${device.id}-${firstChannel}` : ''
}
const syncEdges = () => {
const source = getExpectedSourceId()
edges.value = selectedTargetChannelId.value ? [createConnectionEdge(source, selectedTargetChannelId.value)] : []
}
const rebuildNodes = async () => {
const currentDevice = selectedDevice.value
nodes.value = currentDevice ? buildNodes(currentDevice) : []
if (currentDevice && selectedTargetChannelId.value && !selectedTargetChannelId.value.startsWith(`device-channel-${currentDevice.id}-`)) {
selectedTargetChannelId.value = getFirstChannelId(currentDevice)
}
syncEdges()
await nextTick()
observeFlowContainer()
await fitFlowView()
}
const clearConnections = () => {
selectedTargetChannelId.value = ''
edges.value = []
}
const removeConnectionById = (edgeId: string) => {
if (edges.value.some(item => item.id === edgeId)) {
selectedTargetChannelId.value = ''
}
edges.value = edges.value.filter(item => item.id !== edgeId)
}
const handleConnect = (params: Connection) => {
if (!params.source || !params.target) {
return
}
const expectedSource = getExpectedSourceId()
const connectionNodeIds = [params.source, params.target]
const hasFreqConverterNode = connectionNodeIds.includes(expectedSource)
const deviceChannelId = connectionNodeIds.find(item => item.startsWith('device-channel-'))
const sourceNode = nodes.value.find(item => item.id === expectedSource)
const targetNode = nodes.value.find(item => item.id === deviceChannelId)
if (!hasFreqConverterNode || !deviceChannelId || sourceNode?.type !== 'output' || targetNode?.type !== 'input') {
ElMessage.warning('只能从左侧变频器连线到右侧设备通道')
return
}
if (edges.value.length > 0) {
ElMessage.warning('变频器只能选择一个设备通道,如需重选请先删除配对')
return
}
selectedTargetChannelId.value = deviceChannelId
syncEdges()
}
const loadDeviceOptions = async () => {
loading.value = true
try {
const result = await getPqDevListAll()
const records = Array.isArray(result?.data) ? result.data : []
deviceOptions.value = records
if (records.length) {
selectedDeviceId.value = records[0].id
return
}
selectedDeviceId.value = ''
} finally {
loading.value = false
}
}
const getChannelMapping = () => {
const edge = edges.value[0]
if (!edge) {
return null
}
const targetParts = edge.target.split('-')
return {
deviceId: selectedDevice.value?.id || '',
deviceName: selectedDevice.value?.name || '',
deviceChannel: targetParts[targetParts.length - 1],
freqConverterId: props.freqConverter?.id || '',
freqConverterName: props.freqConverter?.name || ''
}
}
watch(selectedDeviceId, async () => {
selectedTargetChannelId.value = getFirstChannelId(selectedDevice.value)
await rebuildNodes()
})
watch(
() => props.freqConverter?.id,
async () => {
selectedTargetChannelId.value = getFirstChannelId(selectedDevice.value)
await rebuildNodes()
}
)
onMounted(async () => {
await loadDeviceOptions()
selectedTargetChannelId.value = getFirstChannelId(selectedDevice.value)
await rebuildNodes()
})
onBeforeUnmount(() => {
flowContainerResizeObserver?.disconnect()
})
defineExpose({
getSelectedDevice: () => selectedDevice.value,
hasValidConnection: () => edges.value.length === 1,
getChannelMapping,
clearConnections,
resetSelection: () => {
const firstDevice = deviceOptions.value[0] || null
selectedDeviceId.value = firstDevice?.id || ''
selectedTargetChannelId.value = getFirstChannelId(firstDevice)
void rebuildNodes()
}
})
</script>
<style scoped>
.section-card {
border: 1px solid var(--el-border-color-light);
}
:deep(.section-card .el-card__header) {
padding: 10px 14px;
}
:deep(.section-card .el-card__body) {
padding: 10px 14px 14px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.section-tip {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.toolbar-item {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.toolbar-label {
color: var(--el-text-color-regular);
white-space: nowrap;
}
.device-select {
width: 280px;
max-width: 100%;
}
.flow-container {
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
overflow: hidden;
}
:deep(.vue-flow__node) {
border-radius: 8px;
border: 1px solid var(--el-border-color);
background: var(--el-bg-color-page);
box-shadow: none;
}
:deep(.vue-flow__node.no-handle-node .vue-flow__handle) {
display: none;
}
:deep(.vue-flow__handle) {
width: 0;
height: 0;
min-width: 0;
min-height: 0;
background: transparent;
border: none;
overflow: visible;
}
:deep(.vue-flow__handle::before) {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--el-color-primary);
transform: translate(-50%, -50%);
}
:deep(.vue-flow__node.channel-node) {
border: none;
background: transparent;
box-shadow: none;
}
:deep(.vue-flow__node.channel-node .vue-flow__handle) {
top: 50%;
left: 0;
transform: translate(-50%, -50%);
}
:deep(.vue-flow__node.freq-converter-node .vue-flow__handle) {
top: 50%;
right: 0;
left: auto;
bottom: auto;
transform: translate(50%, -50%);
}
:deep(.device-node-label) {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
height: 100%;
box-sizing: border-box;
border: 1px solid var(--el-border-color);
border-radius: 10px;
background: var(--el-fill-color-light);
padding: 10px 12px;
color: var(--el-text-color-primary);
}
:deep(.device-node-title) {
font-weight: 600;
}
:deep(.device-node-line) {
font-size: 12px;
line-height: 1.4;
}
:deep(.channel-node-label) {
display: flex;
align-items: center;
justify-content: flex-start;
height: 100%;
padding: 0 0 0 10px;
font-size: 12px;
color: var(--el-text-color-primary);
text-align: left;
}
:deep(.channel-node-text) {
display: inline-flex;
align-items: center;
min-height: 20px;
line-height: 1;
}
@media (max-width: 1200px) {
.section-header {
align-items: flex-start;
flex-direction: column;
}
.toolbar {
flex-direction: column;
align-items: stretch;
}
.toolbar-item {
flex-wrap: wrap;
}
.device-select {
width: 100%;
}
}
</style>