531 lines
13 KiB
Vue
531 lines
13 KiB
Vue
<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>
|