fix(workflow): restore editor connections

This commit is contained in:
jxxghp
2026-09-02 16:19:26 +08:00
parent 92464b754a
commit ae0a423d4e
2 changed files with 99 additions and 21 deletions
+47 -12
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { VueFlow, useVueFlow, type Connection, type GraphNode } from '@vue-flow/core'
import { VueFlow, useVueFlow, type Connection, type Edge, type GraphNode } from '@vue-flow/core'
import { MiniMap } from '@vue-flow/minimap'
import useDragAndDrop from '@core/utils/workflow'
import { Workflow } from '@/api/types'
@@ -15,7 +15,8 @@ import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
const { onConnect, addEdges, nodes, edges, addNodes, setNodes, setEdges, screenToFlowCoordinate } = useVueFlow()
const { onConnect, onNodesInitialized, addEdges, nodes, edges, addNodes, setNodes, setEdges, screenToFlowCoordinate } =
useVueFlow()
const { onDragOver, onDrop, onDragLeave, isDragOver } = useDragAndDrop()
@@ -30,7 +31,7 @@ onConnect((connection: Connection) => {
normalizeWorkflowEdge({
...connection,
id: `edge_${connection.source}_${connection.target}_${Date.now()}`,
type: 'animation',
type: 'default',
animated: true,
}),
)
@@ -59,7 +60,7 @@ const actionContractMap = computed(() => {
})
// 获取指定节点端口的类型(输入/输出)
const getPortType = (node: GraphNode, handleId: string) => {
const getPortType = (node: GraphNode, handleId?: string | null) => {
// 检查是否是输入端口(对应 handleBounds.target
const isInput = node.handleBounds?.target?.some(h => h.id === handleId)
if (isInput) return 'input'
@@ -78,8 +79,10 @@ const isValidConnection = (connection: Connection) => {
if (!sourceNode || !targetNode) return false
// 获取端口类型
const sourcePortType = getPortType(sourceNode, connection.sourceHandle!)
const targetPortType = getPortType(targetNode, connection.targetHandle!)
const sourceHandleId = connection.sourceHandle ?? sourceNode.handleBounds?.source?.[0]?.id
const targetHandleId = connection.targetHandle ?? targetNode.handleBounds?.target?.[0]?.id
const sourcePortType = getPortType(sourceNode, sourceHandleId)
const targetPortType = getPortType(targetNode, targetHandleId)
/* 同时满足三个条件,才允许连接:
* 1. 源端口是输出类型(output)
@@ -104,6 +107,7 @@ const omitConfigKeys = (value: any, keys: string[]) => {
// 统一流程边的条件和画布展示字段,同时保留后端兼容的流程策略字段。
const normalizeWorkflowEdge = (edge: any) => {
const condition = String(getEdgeConfigValue(edge, 'condition') || '').trim()
const edgeType = !edge?.type || edge.type === 'animation' ? 'default' : edge.type
const edgeClass = String(edge?.class || '')
.replace(/\bworkflow-conditional-edge\b/g, '')
.trim()
@@ -113,7 +117,7 @@ const normalizeWorkflowEdge = (edge: any) => {
return {
...edge,
animated: edge?.animated ?? true,
type: edge?.type || 'animation',
type: edgeType,
label: condition ? t('dialog.workflowActions.edgeConditionalLabel') : undefined,
class: [edgeClass, condition ? 'workflow-conditional-edge' : ''].filter(Boolean).join(' ') || undefined,
condition: condition || undefined,
@@ -142,10 +146,41 @@ const normalizeWorkflowNodes = () => {
setNodes((nodes.value || []).map(node => normalizeWorkflowNode(node)))
}
// 通过 Vue Flow 的 setter 导入节点和连线,确保初始图形具备尺寸、位置等内部运行状态
// 等待节点端口初始化后恢复的流程边,避免 Vue Flow 在端口为空时将其判定为非法连接
let pendingWorkflowEdges: Edge[] | null = null
// 判断当前边的两端是否已经具备可供连接校验使用的 Handle 信息。
function areWorkflowEdgeEndpointsReady(workflowEdges: Edge[]) {
return workflowEdges.every(edge => {
const sourceNode = nodes.value.find(node => node.id === String(edge.source))
const targetNode = nodes.value.find(node => node.id === String(edge.target))
return Boolean(sourceNode?.handleBounds?.source?.length && targetNode?.handleBounds?.target?.length)
})
}
// 将缓存的流程边写入 Vue Flow;写入时统一经过现有连接合法性校验。
function restorePendingWorkflowEdges() {
if (!pendingWorkflowEdges) return
const workflowEdges = pendingWorkflowEdges
pendingWorkflowEdges = null
setEdges(workflowEdges)
}
onNodesInitialized(() => {
restorePendingWorkflowEdges()
})
// 通过 Vue Flow 的 setter 导入节点,并在节点端口就绪后恢复连线。
function setWorkflowGraph(actions: NonNullable<Workflow['actions']> = [], flows: NonNullable<Workflow['flows']> = []) {
setNodes(actions.map(action => normalizeWorkflowNode(action)))
setEdges(flows.map(flow => normalizeWorkflowEdge(flow)))
const workflowNodes = actions.map(action => normalizeWorkflowNode(action))
pendingWorkflowEdges = flows.map(flow => normalizeWorkflowEdge(flow))
setNodes(workflowNodes)
if (!workflowNodes.length || !pendingWorkflowEdges.length || areWorkflowEdgeEndpointsReady(pendingWorkflowEdges)) {
restorePendingWorkflowEdges()
}
}
// 获取节点名称,便于在边设置面板展示流转关系
@@ -493,7 +528,7 @@ const isMacOS = computed(() => {
:edges="edges"
:nodeTypes="nodeTypes"
:is-valid-connection="isValidConnection"
:default-edge-options="{ type: 'animation', animated: true }"
:default-edge-options="{ type: 'default', animated: true }"
:edge-updater-radius="10"
@dragover="onDragOver"
@dragleave="onDragLeave"
@@ -697,7 +732,7 @@ const isMacOS = computed(() => {
}
// 自定义动作连线样式
.vue-flow__edge.animation {
.vue-flow__edge.animated {
.vue-flow__edge-path {
stroke: rgb(var(--v-theme-primary));
}
@@ -1,7 +1,7 @@
import WorkflowActionsDialog from '@/components/dialog/WorkflowActionsDialog.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h, type PropType, type Ref } from 'vue'
import { defineComponent, h, nextTick, type PropType, type Ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type FlowNode = Record<string, unknown>
@@ -24,10 +24,12 @@ const mocks = vi.hoisted(() => ({
toastWarning: vi.fn(),
flowNodes: undefined as Ref<FlowNode[]> | undefined,
flowEdges: undefined as Ref<FlowEdge[]> | undefined,
nodeHandleBounds: {} as Record<string, unknown>,
nodeTypes: undefined as Record<string, unknown> | undefined,
setNodes: vi.fn(),
setEdges: vi.fn(),
onConnect: undefined as ((connection: Connection) => void) | undefined,
nodesInitializedHandlers: [] as Array<() => void>,
isValidConnection: undefined as ((connection: Connection) => boolean) | undefined,
conditionItems: [] as ConditionItem[],
importCode: '',
@@ -92,7 +94,16 @@ vi.mock('@vue-flow/core', async () => {
mocks.flowNodes?.value.push(...(Array.isArray(newNodes) ? newNodes : [newNodes])),
setNodes: (newNodes: FlowNode[]) => {
mocks.setNodes(newNodes)
if (mocks.flowNodes) mocks.flowNodes.value = newNodes
newNodes.forEach(node => {
mocks.nodeHandleBounds[String(node.id)] = node.handleBounds
})
if (mocks.flowNodes) {
mocks.flowNodes.value = newNodes.map(node => ({
...node,
dimensions: { width: 0, height: 0 },
handleBounds: { source: [], target: [] },
}))
}
},
setEdges: (newEdges: FlowEdge[]) => {
mocks.setEdges(newEdges)
@@ -103,7 +114,14 @@ vi.mock('@vue-flow/core', async () => {
onConnect: (handler: (connection: Connection) => void) => {
mocks.onConnect = handler
},
onNodesInitialized: (handler: () => void) => ({ off: () => undefined, handler }),
onNodesInitialized: (handler: () => void) => {
mocks.nodesInitializedHandlers.push(handler)
return {
off: () => {
mocks.nodesInitializedHandlers = mocks.nodesInitializedHandlers.filter(item => item !== handler)
},
}
},
screenToFlowCoordinate: (point: { x: number; y: number }) => point,
updateNode: vi.fn(),
}),
@@ -263,8 +281,18 @@ function createWorkflow(overrides: Record<string, unknown> = {}) {
}
}
async function renderDialog(workflow = createWorkflow(), onSave = vi.fn()) {
return renderWithProviders(WorkflowActionsDialog, {
async function initializeFlowNodes() {
mocks.flowNodes!.value = mocks.flowNodes!.value.map(node => ({
...node,
dimensions: { width: 240, height: 120 },
handleBounds: mocks.nodeHandleBounds[String(node.id)] ?? { source: [], target: [] },
}))
mocks.nodesInitializedHandlers.forEach(handler => handler())
await nextTick()
}
async function renderDialog(workflow = createWorkflow(), onSave = vi.fn(), initializeNodes = true) {
const rendered = renderWithProviders(WorkflowActionsDialog, {
props: { workflow, onSave },
global: {
stubs: {
@@ -273,6 +301,12 @@ async function renderDialog(workflow = createWorkflow(), onSave = vi.fn()) {
},
},
})
await nextTick()
if (initializeNodes && Array.isArray(workflow.actions) && workflow.actions.length) {
await waitFor(() => expect(mocks.setNodes).toHaveBeenCalled())
await initializeFlowNodes()
}
return rendered
}
async function clickToolbarButton(container: ParentNode, index: number) {
@@ -292,10 +326,12 @@ describe('WorkflowActionsDialog data contract', () => {
mocks.toastWarning.mockReset()
mocks.flowNodes!.value = []
mocks.flowEdges!.value = []
mocks.nodeHandleBounds = {}
mocks.nodeTypes = undefined
mocks.setNodes.mockReset()
mocks.setEdges.mockReset()
mocks.onConnect = undefined
mocks.nodesInitializedHandlers = []
mocks.isValidConnection = undefined
mocks.conditionItems = []
mocks.importCode = ''
@@ -323,14 +359,21 @@ describe('WorkflowActionsDialog data contract', () => {
)
})
it('loads persisted workflow nodes and edges through Vue Flow setters', async () => {
await renderDialog()
it('restores persisted edges only after Vue Flow initializes node handles', async () => {
await renderDialog(createWorkflow(), vi.fn(), false)
expect(mocks.setNodes).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'source', type: 'SourceAction' })]),
)
expect(mocks.setEdges).not.toHaveBeenCalled()
expect(mocks.flowEdges!.value).toEqual([])
await initializeFlowNodes()
expect(mocks.setEdges).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'flow-1', source: 'source', target: 'target' })]),
expect.arrayContaining([
expect.objectContaining({ id: 'flow-1', source: 'source', target: 'target', type: 'default' }),
]),
)
})
@@ -353,7 +396,7 @@ describe('WorkflowActionsDialog data contract', () => {
sourceHandle: 'out',
target: 'target',
targetHandle: 'in',
type: 'animation',
type: 'default',
animated: true,
}),
)