变频器功能页面
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user