mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 01:36:45 +08:00
fix(workflow): restore editor connections
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user