mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 09:46:44 +08:00
fix(workflow): restore editor connections
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue'
|
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 { MiniMap } from '@vue-flow/minimap'
|
||||||
import useDragAndDrop from '@core/utils/workflow'
|
import useDragAndDrop from '@core/utils/workflow'
|
||||||
import { Workflow } from '@/api/types'
|
import { Workflow } from '@/api/types'
|
||||||
@@ -15,7 +15,8 @@ import { useI18n } from 'vue-i18n'
|
|||||||
// 多语言支持
|
// 多语言支持
|
||||||
const { t } = useI18n()
|
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()
|
const { onDragOver, onDrop, onDragLeave, isDragOver } = useDragAndDrop()
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ onConnect((connection: Connection) => {
|
|||||||
normalizeWorkflowEdge({
|
normalizeWorkflowEdge({
|
||||||
...connection,
|
...connection,
|
||||||
id: `edge_${connection.source}_${connection.target}_${Date.now()}`,
|
id: `edge_${connection.source}_${connection.target}_${Date.now()}`,
|
||||||
type: 'animation',
|
type: 'default',
|
||||||
animated: true,
|
animated: true,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -59,7 +60,7 @@ const actionContractMap = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 获取指定节点端口的类型(输入/输出)
|
// 获取指定节点端口的类型(输入/输出)
|
||||||
const getPortType = (node: GraphNode, handleId: string) => {
|
const getPortType = (node: GraphNode, handleId?: string | null) => {
|
||||||
// 检查是否是输入端口(对应 handleBounds.target)
|
// 检查是否是输入端口(对应 handleBounds.target)
|
||||||
const isInput = node.handleBounds?.target?.some(h => h.id === handleId)
|
const isInput = node.handleBounds?.target?.some(h => h.id === handleId)
|
||||||
if (isInput) return 'input'
|
if (isInput) return 'input'
|
||||||
@@ -78,8 +79,10 @@ const isValidConnection = (connection: Connection) => {
|
|||||||
if (!sourceNode || !targetNode) return false
|
if (!sourceNode || !targetNode) return false
|
||||||
|
|
||||||
// 获取端口类型
|
// 获取端口类型
|
||||||
const sourcePortType = getPortType(sourceNode, connection.sourceHandle!)
|
const sourceHandleId = connection.sourceHandle ?? sourceNode.handleBounds?.source?.[0]?.id
|
||||||
const targetPortType = getPortType(targetNode, connection.targetHandle!)
|
const targetHandleId = connection.targetHandle ?? targetNode.handleBounds?.target?.[0]?.id
|
||||||
|
const sourcePortType = getPortType(sourceNode, sourceHandleId)
|
||||||
|
const targetPortType = getPortType(targetNode, targetHandleId)
|
||||||
|
|
||||||
/* 同时满足三个条件,才允许连接:
|
/* 同时满足三个条件,才允许连接:
|
||||||
* 1. 源端口是输出类型(output)
|
* 1. 源端口是输出类型(output)
|
||||||
@@ -104,6 +107,7 @@ const omitConfigKeys = (value: any, keys: string[]) => {
|
|||||||
// 统一流程边的条件和画布展示字段,同时保留后端兼容的流程策略字段。
|
// 统一流程边的条件和画布展示字段,同时保留后端兼容的流程策略字段。
|
||||||
const normalizeWorkflowEdge = (edge: any) => {
|
const normalizeWorkflowEdge = (edge: any) => {
|
||||||
const condition = String(getEdgeConfigValue(edge, 'condition') || '').trim()
|
const condition = String(getEdgeConfigValue(edge, 'condition') || '').trim()
|
||||||
|
const edgeType = !edge?.type || edge.type === 'animation' ? 'default' : edge.type
|
||||||
const edgeClass = String(edge?.class || '')
|
const edgeClass = String(edge?.class || '')
|
||||||
.replace(/\bworkflow-conditional-edge\b/g, '')
|
.replace(/\bworkflow-conditional-edge\b/g, '')
|
||||||
.trim()
|
.trim()
|
||||||
@@ -113,7 +117,7 @@ const normalizeWorkflowEdge = (edge: any) => {
|
|||||||
return {
|
return {
|
||||||
...edge,
|
...edge,
|
||||||
animated: edge?.animated ?? true,
|
animated: edge?.animated ?? true,
|
||||||
type: edge?.type || 'animation',
|
type: edgeType,
|
||||||
label: condition ? t('dialog.workflowActions.edgeConditionalLabel') : undefined,
|
label: condition ? t('dialog.workflowActions.edgeConditionalLabel') : undefined,
|
||||||
class: [edgeClass, condition ? 'workflow-conditional-edge' : ''].filter(Boolean).join(' ') || undefined,
|
class: [edgeClass, condition ? 'workflow-conditional-edge' : ''].filter(Boolean).join(' ') || undefined,
|
||||||
condition: condition || undefined,
|
condition: condition || undefined,
|
||||||
@@ -142,10 +146,41 @@ const normalizeWorkflowNodes = () => {
|
|||||||
setNodes((nodes.value || []).map(node => normalizeWorkflowNode(node)))
|
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']> = []) {
|
function setWorkflowGraph(actions: NonNullable<Workflow['actions']> = [], flows: NonNullable<Workflow['flows']> = []) {
|
||||||
setNodes(actions.map(action => normalizeWorkflowNode(action)))
|
const workflowNodes = actions.map(action => normalizeWorkflowNode(action))
|
||||||
setEdges(flows.map(flow => normalizeWorkflowEdge(flow)))
|
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"
|
:edges="edges"
|
||||||
:nodeTypes="nodeTypes"
|
:nodeTypes="nodeTypes"
|
||||||
:is-valid-connection="isValidConnection"
|
:is-valid-connection="isValidConnection"
|
||||||
:default-edge-options="{ type: 'animation', animated: true }"
|
:default-edge-options="{ type: 'default', animated: true }"
|
||||||
:edge-updater-radius="10"
|
:edge-updater-radius="10"
|
||||||
@dragover="onDragOver"
|
@dragover="onDragOver"
|
||||||
@dragleave="onDragLeave"
|
@dragleave="onDragLeave"
|
||||||
@@ -697,7 +732,7 @@ const isMacOS = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 自定义动作连线样式
|
// 自定义动作连线样式
|
||||||
.vue-flow__edge.animation {
|
.vue-flow__edge.animated {
|
||||||
.vue-flow__edge-path {
|
.vue-flow__edge-path {
|
||||||
stroke: rgb(var(--v-theme-primary));
|
stroke: rgb(var(--v-theme-primary));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import WorkflowActionsDialog from '@/components/dialog/WorkflowActionsDialog.vue'
|
import WorkflowActionsDialog from '@/components/dialog/WorkflowActionsDialog.vue'
|
||||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
import { renderWithProviders } from '@tests/support/render'
|
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'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
type FlowNode = Record<string, unknown>
|
type FlowNode = Record<string, unknown>
|
||||||
@@ -24,10 +24,12 @@ const mocks = vi.hoisted(() => ({
|
|||||||
toastWarning: vi.fn(),
|
toastWarning: vi.fn(),
|
||||||
flowNodes: undefined as Ref<FlowNode[]> | undefined,
|
flowNodes: undefined as Ref<FlowNode[]> | undefined,
|
||||||
flowEdges: undefined as Ref<FlowEdge[]> | undefined,
|
flowEdges: undefined as Ref<FlowEdge[]> | undefined,
|
||||||
|
nodeHandleBounds: {} as Record<string, unknown>,
|
||||||
nodeTypes: undefined as Record<string, unknown> | undefined,
|
nodeTypes: undefined as Record<string, unknown> | undefined,
|
||||||
setNodes: vi.fn(),
|
setNodes: vi.fn(),
|
||||||
setEdges: vi.fn(),
|
setEdges: vi.fn(),
|
||||||
onConnect: undefined as ((connection: Connection) => void) | undefined,
|
onConnect: undefined as ((connection: Connection) => void) | undefined,
|
||||||
|
nodesInitializedHandlers: [] as Array<() => void>,
|
||||||
isValidConnection: undefined as ((connection: Connection) => boolean) | undefined,
|
isValidConnection: undefined as ((connection: Connection) => boolean) | undefined,
|
||||||
conditionItems: [] as ConditionItem[],
|
conditionItems: [] as ConditionItem[],
|
||||||
importCode: '',
|
importCode: '',
|
||||||
@@ -92,7 +94,16 @@ vi.mock('@vue-flow/core', async () => {
|
|||||||
mocks.flowNodes?.value.push(...(Array.isArray(newNodes) ? newNodes : [newNodes])),
|
mocks.flowNodes?.value.push(...(Array.isArray(newNodes) ? newNodes : [newNodes])),
|
||||||
setNodes: (newNodes: FlowNode[]) => {
|
setNodes: (newNodes: FlowNode[]) => {
|
||||||
mocks.setNodes(newNodes)
|
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[]) => {
|
setEdges: (newEdges: FlowEdge[]) => {
|
||||||
mocks.setEdges(newEdges)
|
mocks.setEdges(newEdges)
|
||||||
@@ -103,7 +114,14 @@ vi.mock('@vue-flow/core', async () => {
|
|||||||
onConnect: (handler: (connection: Connection) => void) => {
|
onConnect: (handler: (connection: Connection) => void) => {
|
||||||
mocks.onConnect = handler
|
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,
|
screenToFlowCoordinate: (point: { x: number; y: number }) => point,
|
||||||
updateNode: vi.fn(),
|
updateNode: vi.fn(),
|
||||||
}),
|
}),
|
||||||
@@ -263,8 +281,18 @@ function createWorkflow(overrides: Record<string, unknown> = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderDialog(workflow = createWorkflow(), onSave = vi.fn()) {
|
async function initializeFlowNodes() {
|
||||||
return renderWithProviders(WorkflowActionsDialog, {
|
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 },
|
props: { workflow, onSave },
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
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) {
|
async function clickToolbarButton(container: ParentNode, index: number) {
|
||||||
@@ -292,10 +326,12 @@ describe('WorkflowActionsDialog data contract', () => {
|
|||||||
mocks.toastWarning.mockReset()
|
mocks.toastWarning.mockReset()
|
||||||
mocks.flowNodes!.value = []
|
mocks.flowNodes!.value = []
|
||||||
mocks.flowEdges!.value = []
|
mocks.flowEdges!.value = []
|
||||||
|
mocks.nodeHandleBounds = {}
|
||||||
mocks.nodeTypes = undefined
|
mocks.nodeTypes = undefined
|
||||||
mocks.setNodes.mockReset()
|
mocks.setNodes.mockReset()
|
||||||
mocks.setEdges.mockReset()
|
mocks.setEdges.mockReset()
|
||||||
mocks.onConnect = undefined
|
mocks.onConnect = undefined
|
||||||
|
mocks.nodesInitializedHandlers = []
|
||||||
mocks.isValidConnection = undefined
|
mocks.isValidConnection = undefined
|
||||||
mocks.conditionItems = []
|
mocks.conditionItems = []
|
||||||
mocks.importCode = ''
|
mocks.importCode = ''
|
||||||
@@ -323,14 +359,21 @@ describe('WorkflowActionsDialog data contract', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads persisted workflow nodes and edges through Vue Flow setters', async () => {
|
it('restores persisted edges only after Vue Flow initializes node handles', async () => {
|
||||||
await renderDialog()
|
await renderDialog(createWorkflow(), vi.fn(), false)
|
||||||
|
|
||||||
expect(mocks.setNodes).toHaveBeenCalledWith(
|
expect(mocks.setNodes).toHaveBeenCalledWith(
|
||||||
expect.arrayContaining([expect.objectContaining({ id: 'source', type: 'SourceAction' })]),
|
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(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',
|
sourceHandle: 'out',
|
||||||
target: 'target',
|
target: 'target',
|
||||||
targetHandle: 'in',
|
targetHandle: 'in',
|
||||||
type: 'animation',
|
type: 'default',
|
||||||
animated: true,
|
animated: true,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user