自动布局
This commit is contained in:
@@ -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 = () => {
|
||||
// 清除之前的测试状态
|
||||
|
||||
Reference in New Issue
Block a user