自动布局

This commit is contained in:
rjb
2026-01-20 18:05:31 +08:00
parent fab1767792
commit b8f340401a
8 changed files with 3812 additions and 18 deletions

View File

@@ -20,6 +20,55 @@
<el-icon><DocumentCopy /></el-icon>
粘贴节点 (Ctrl+V)
</el-button>
<el-divider direction="vertical" />
<!-- 节点对齐功能 -->
<el-dropdown @command="handleAlignNodes" trigger="click">
<el-button>
<el-icon><Rank /></el-icon>
对齐
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="left">
<el-icon><Sort style="transform: rotate(90deg)" /></el-icon>
左对齐
</el-dropdown-item>
<el-dropdown-item command="right">
<el-icon><Sort style="transform: rotate(-90deg)" /></el-icon>
右对齐
</el-dropdown-item>
<el-dropdown-item command="top">
<el-icon><Sort /></el-icon>
上对齐
</el-dropdown-item>
<el-dropdown-item command="bottom">
<el-icon><Sort style="transform: rotate(180deg)" /></el-icon>
下对齐
</el-dropdown-item>
<el-dropdown-item command="center-h">
<el-icon><Grid /></el-icon>
水平居中
</el-dropdown-item>
<el-dropdown-item command="center-v">
<el-icon><Grid style="transform: rotate(90deg)" /></el-icon>
垂直居中
</el-dropdown-item>
<el-dropdown-item command="distribute-h" divided>
<el-icon><Operation /></el-icon>
水平分布
</el-dropdown-item>
<el-dropdown-item command="distribute-v">
<el-icon><Operation style="transform: rotate(90deg)" /></el-icon>
垂直分布
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button @click="handleAutoLayout" title="自动布局 (Ctrl+L)">
<el-icon><Operation /></el-icon>
自动布局
</el-button>
<div class="toolbar-spacer"></div>
<div class="zoom-controls">
<el-button size="small" @click="zoomIn" title="放大 (Ctrl +)">
@@ -1255,7 +1304,7 @@ import { Controls } from '@vue-flow/controls'
import { MiniMap } from '@vue-flow/minimap'
import type { Node, Edge, NodeClickEvent, EdgeClickEvent, Connection, Viewport } from '@vue-flow/core'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, Warning, ZoomIn, ZoomOut, FullScreen, DocumentCopy, User, VideoPlay, InfoFilled, WarningFilled } from '@element-plus/icons-vue'
import { Check, Warning, ZoomIn, ZoomOut, FullScreen, DocumentCopy, User, VideoPlay, InfoFilled, WarningFilled, Rank, ArrowDown, Sort, Grid, Operation } from '@element-plus/icons-vue'
import { useWorkflowStore } from '@/stores/workflow'
import api from '@/api'
import type { WorkflowNode, WorkflowEdge } from '@/types'
@@ -2253,6 +2302,11 @@ const handleKeyDown = (event: KeyboardEvent) => {
event.preventDefault()
zoomOut()
}
// Ctrl+L 自动布局
if (event.ctrlKey && event.key === 'l') {
event.preventDefault()
handleAutoLayout()
}
}
// 删除节点(优化版)
@@ -2454,6 +2508,292 @@ const handleClear = () => {
ElMessage.success('画布已清空')
}
// 节点对齐功能
const handleAlignNodes = (command: string) => {
// 获取选中的节点(支持多选)
const selectedNodes = nodes.value.filter(node => node.selected)
if (selectedNodes.length < 2) {
ElMessage.warning('请至少选择2个节点进行对齐')
return
}
// 计算对齐基准值
let baseValue: number
// 默认节点尺寸(根据节点类型可能有不同)
const defaultNodeWidth = 150
const defaultNodeHeight = 50
const positions = selectedNodes.map(node => {
// 尝试从DOM获取实际尺寸如果获取不到则使用默认值
let width = defaultNodeWidth
let height = defaultNodeHeight
// 可以通过vueFlowInstance获取节点尺寸
try {
const nodeElement = document.querySelector(`[data-id="${node.id}"]`)
if (nodeElement) {
const rect = nodeElement.getBoundingClientRect()
width = rect.width || defaultNodeWidth
height = rect.height || defaultNodeHeight
}
} catch (e) {
// 如果获取失败,使用默认值
}
return {
id: node.id,
x: node.position.x,
y: node.position.y,
width,
height
}
})
switch (command) {
case 'left':
// 左对齐:以最左边的节点为基准
baseValue = Math.min(...positions.map(p => p.x))
positions.forEach(pos => {
updateNode(pos.id, { position: { x: baseValue, y: pos.y } })
})
ElMessage.success('节点已左对齐')
break
case 'right':
// 右对齐:以最右边的节点为基准
baseValue = Math.max(...positions.map(p => p.x + p.width))
positions.forEach(pos => {
updateNode(pos.id, { position: { x: baseValue - pos.width, y: pos.y } })
})
ElMessage.success('节点已右对齐')
break
case 'top':
// 上对齐:以最上边的节点为基准
baseValue = Math.min(...positions.map(p => p.y))
positions.forEach(pos => {
updateNode(pos.id, { position: { x: pos.x, y: baseValue } })
})
ElMessage.success('节点已上对齐')
break
case 'bottom':
// 下对齐:以最下边的节点为基准
baseValue = Math.max(...positions.map(p => p.y + p.height))
positions.forEach(pos => {
updateNode(pos.id, { position: { x: pos.x, y: baseValue - pos.height } })
})
ElMessage.success('节点已下对齐')
break
case 'center-h':
// 水平居中:以所有节点的中心点为基准
const minX = Math.min(...positions.map(p => p.x))
const maxX = Math.max(...positions.map(p => p.x + p.width))
const centerX = (minX + maxX) / 2
positions.forEach(pos => {
updateNode(pos.id, { position: { x: centerX - pos.width / 2, y: pos.y } })
})
ElMessage.success('节点已水平居中')
break
case 'center-v':
// 垂直居中:以所有节点的中心点为基准
const minY = Math.min(...positions.map(p => p.y))
const maxY = Math.max(...positions.map(p => p.y + p.height))
const centerY = (minY + maxY) / 2
positions.forEach(pos => {
updateNode(pos.id, { position: { x: pos.x, y: centerY - pos.height / 2 } })
})
ElMessage.success('节点已垂直居中')
break
case 'distribute-h':
// 水平分布:均匀分布节点
const sortedByX = [...positions].sort((a, b) => a.x - b.x)
const totalWidth = sortedByX[sortedByX.length - 1].x + sortedByX[sortedByX.length - 1].width - sortedByX[0].x
const spacing = totalWidth / (sortedByX.length - 1)
let currentX = sortedByX[0].x
sortedByX.forEach((pos, index) => {
if (index > 0) {
currentX = sortedByX[index - 1].x + sortedByX[index - 1].width + spacing - pos.width
}
updateNode(pos.id, { position: { x: currentX, y: pos.y } })
})
ElMessage.success('节点已水平分布')
break
case 'distribute-v':
// 垂直分布:均匀分布节点
const sortedByY = [...positions].sort((a, b) => a.y - b.y)
const totalHeight = sortedByY[sortedByY.length - 1].y + sortedByY[sortedByY.length - 1].height - sortedByY[0].y
const vSpacing = totalHeight / (sortedByY.length - 1)
let currentY = sortedByY[0].y
sortedByY.forEach((pos, index) => {
if (index > 0) {
currentY = sortedByY[index - 1].y + sortedByY[index - 1].height + vSpacing - pos.height
}
updateNode(pos.id, { position: { x: pos.x, y: currentY } })
})
ElMessage.success('节点已垂直分布')
break
}
// 标记有变更
hasChanges.value = true
}
// 自动布局功能基于DAG的层次布局算法
const handleAutoLayout = async () => {
if (nodes.value.length === 0) {
ElMessage.warning('画布中没有节点')
return
}
// 找到开始节点
const startNode = nodes.value.find(n => n.type === 'start')
if (!startNode) {
ElMessage.warning('未找到开始节点,无法进行自动布局')
return
}
// 构建邻接表(有向图)
const graph: Record<string, string[]> = {}
const inDegree: Record<string, number> = {}
// 初始化
nodes.value.forEach(node => {
graph[node.id] = []
inDegree[node.id] = 0
})
// 构建图
edges.value.forEach(edge => {
if (graph[edge.source] && !graph[edge.source].includes(edge.target)) {
graph[edge.source].push(edge.target)
inDegree[edge.target] = (inDegree[edge.target] || 0) + 1
}
})
// 拓扑排序,将节点分层
const layers: string[][] = []
const visited = new Set<string>()
const queue: string[] = []
// 找到所有入度为0的节点开始节点
Object.keys(inDegree).forEach(nodeId => {
if (inDegree[nodeId] === 0) {
queue.push(nodeId)
}
})
// 如果没有入度为0的节点使用开始节点
if (queue.length === 0 && startNode) {
queue.push(startNode.id)
}
// 分层遍历
while (queue.length > 0) {
const layer: string[] = []
const layerSize = queue.length
for (let i = 0; i < layerSize; i++) {
const nodeId = queue.shift()!
if (visited.has(nodeId)) continue
visited.add(nodeId)
layer.push(nodeId)
// 处理该节点的所有出边
const neighbors = graph[nodeId] || []
neighbors.forEach(neighborId => {
inDegree[neighborId] = (inDegree[neighborId] || 0) - 1
if (inDegree[neighborId] === 0 && !visited.has(neighborId)) {
queue.push(neighborId)
}
})
}
if (layer.length > 0) {
layers.push(layer)
}
}
// 处理未访问的节点(可能是孤立节点)
nodes.value.forEach(node => {
if (!visited.has(node.id)) {
if (layers.length === 0) {
layers.push([node.id])
} else {
layers[layers.length - 1].push(node.id)
}
}
})
// 布局参数
const nodeWidth = 200
const nodeHeight = 80
const horizontalSpacing = 280 // 水平间距(增大间距,避免节点重叠)
const verticalSpacing = 180 // 垂直间距(增大间距,使布局更清晰)
const startX = 100
const startY = 100
// 计算每层的布局(水平居中)
layers.forEach((layer, layerIndex) => {
const layerY = startY + layerIndex * verticalSpacing
const layerWidth = (layer.length - 1) * horizontalSpacing
const layerStartX = startX - layerWidth / 2
layer.forEach((nodeId, nodeIndex) => {
const nodeX = layerStartX + nodeIndex * horizontalSpacing
updateNode(nodeId, {
position: { x: nodeX, y: layerY }
})
})
})
// 自动调整视口,使所有节点可见
await nextTick()
setTimeout(() => {
try {
const allNodes = nodes.value
if (allNodes.length > 0) {
const minX = Math.min(...allNodes.map(n => n.position.x))
const maxX = Math.max(...allNodes.map(n => n.position.x + nodeWidth))
const minY = Math.min(...allNodes.map(n => n.position.y))
const maxY = Math.max(...allNodes.map(n => n.position.y + nodeHeight))
const centerX = (minX + maxX) / 2
const centerY = (minY + maxY) / 2
const width = maxX - minX
const height = maxY - minY
// 计算合适的缩放比例
const viewport = getViewport()
if (viewport) {
const scale = Math.min(
(viewport.zoom * 800) / width,
(viewport.zoom * 600) / height,
1.2 // 最大缩放不超过1.2
)
setViewport({
x: -centerX * scale + 400,
y: -centerY * scale + 300,
zoom: scale
}, { duration: 300 })
}
}
} catch (e) {
console.warn('自动调整视口失败:', e)
}
}, 100)
ElMessage.success(`自动布局完成,共 ${layers.length} 层,${nodes.value.length} 个节点`)
hasChanges.value = true
}
// 测试动画 - 依次执行所有节点,展示完整的工作流动画效果
const handleTestAnimation = () => {
// 清除之前的测试状态