fix(workflow): restore persisted action nodes

This commit is contained in:
jxxghp
2026-09-01 15:15:58 +08:00
parent e9e63b2941
commit 1aaff8f8cc
2 changed files with 70 additions and 15 deletions
+33 -13
View File
@@ -15,7 +15,7 @@ import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
const { onConnect, addEdges, nodes, edges, addNodes, screenToFlowCoordinate } = useVueFlow()
const { onConnect, addEdges, nodes, edges, addNodes, setNodes, setEdges, screenToFlowCoordinate } = useVueFlow()
const { onDragOver, onDrop, onDragLeave, isDragOver } = useDragAndDrop()
@@ -123,7 +123,7 @@ const normalizeWorkflowEdge = (edge: any) => {
// 标准化所有流程边,导入和保存前都会调用
const normalizeWorkflowEdges = () => {
edges.value = (edges.value || []).map(edge => normalizeWorkflowEdge(edge))
setEdges((edges.value || []).map(edge => normalizeWorkflowEdge(edge)))
}
// 统一动作节点数据结构,保留后端可识别的运行配置,仅移除编辑器契约元数据。
@@ -139,7 +139,13 @@ const normalizeWorkflowNode = (node: any) => {
// 标准化所有动作节点,导入和保存前都会调用
const normalizeWorkflowNodes = () => {
nodes.value = (nodes.value || []).map(node => normalizeWorkflowNode(node))
setNodes((nodes.value || []).map(node => normalizeWorkflowNode(node)))
}
// 通过 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)))
}
// 获取节点名称,便于在边设置面板展示流转关系
@@ -267,6 +273,11 @@ const nodeTypes: Record<string, any> = ref({})
// 自动扫描目录下所有的 .vue 文件
const components = import.meta.glob('../workflow/*Action.vue')
// 从 import.meta.glob 返回的相对路径中提取动作组件名,兼容 ../workflow/ 前缀。
function getActionComponentName(path: string) {
return path.match(/(?:^|\/)workflow\/([^/]+)\.vue$/)?.[1]
}
// 动态加载某个组件
const loadComponent = async (componentName: string) => {
const component = components[`../workflow/${componentName}.vue`]
@@ -277,14 +288,29 @@ const loadComponent = async (componentName: string) => {
}
// 将所有components中的组件加载到nodeTypes中
let actionComponentLoaderActive = true
// 对话框销毁后忽略仍在途的懒加载结果,避免已卸载实例处理异步异常。
onUnmounted(() => {
actionComponentLoaderActive = false
})
for (const path in components) {
const componentName = path.match(/\.\/workflow\/(.*).vue$/)?.[1]
const componentName = getActionComponentName(path)
if (!componentName) {
continue
}
loadComponent(componentName).then(component => {
loadComponent(componentName)
.then(component => {
if (!actionComponentLoaderActive) return
nodeTypes.value[componentName] = markRaw(component)
})
.catch(error => {
// 单个动作组件加载失败时保留其余节点,并记录原因供排查。
if (actionComponentLoaderActive) {
console.error(`Failed to load workflow action component: ${componentName}`, error)
}
})
}
// 加载动作契约,供边条件构造器使用
@@ -376,13 +402,10 @@ function saveCodeString(type: string, code: any) {
if (code) {
const codeObject = JSON.parse(code.value)
if (type === 'workflow') {
nodes.value = codeObject.actions || []
edges.value = codeObject.flows || []
setWorkflowGraph(codeObject.actions || [], codeObject.flows || [])
if (codeObject.execution_config) {
workflowForm.value.execution_config = codeObject.execution_config
}
normalizeWorkflowNodes()
normalizeWorkflowEdges()
}
importCodeDialog.value = false
$toast.success(t('dialog.workflowActions.importSuccess'))
@@ -409,10 +432,7 @@ function shareWorkflow() {
onMounted(() => {
loadActionDefinitions()
if (props.workflow) {
nodes.value = cloneDeep(workflowForm.value.actions ?? [])
edges.value = cloneDeep(workflowForm.value.flows ?? [])
normalizeWorkflowNodes()
normalizeWorkflowEdges()
setWorkflowGraph(cloneDeep(workflowForm.value.actions ?? []), cloneDeep(workflowForm.value.flows ?? []))
}
})
@@ -24,6 +24,9 @@ const mocks = vi.hoisted(() => ({
toastWarning: vi.fn(),
flowNodes: undefined as Ref<FlowNode[]> | undefined,
flowEdges: undefined as Ref<FlowEdge[]> | undefined,
nodeTypes: undefined as Record<string, unknown> | undefined,
setNodes: vi.fn(),
setEdges: vi.fn(),
onConnect: undefined as ((connection: Connection) => void) | undefined,
isValidConnection: undefined as ((connection: Connection) => boolean) | undefined,
conditionItems: [] as ConditionItem[],
@@ -58,11 +61,13 @@ vi.mock('@vue-flow/core', async () => {
props: {
nodes: { type: Array as PropType<FlowNode[]>, default: () => [] },
edges: { type: Array as PropType<FlowEdge[]>, default: () => [] },
nodeTypes: { type: Object as PropType<Record<string, unknown>>, default: () => ({}) },
isValidConnection: { type: Function as PropType<(connection: Connection) => boolean> },
},
emits: ['edge-click'],
setup(props, { emit }) {
mocks.isValidConnection = props.isValidConnection
mocks.nodeTypes = props.nodeTypes
return () =>
createElement('div', { 'data-testid': 'vue-flow' }, [
createElement(
@@ -85,6 +90,14 @@ vi.mock('@vue-flow/core', async () => {
mocks.flowEdges?.value.push(...(Array.isArray(newEdges) ? newEdges : [newEdges])),
addNodes: (newNodes: FlowNode[] | FlowNode) =>
mocks.flowNodes?.value.push(...(Array.isArray(newNodes) ? newNodes : [newNodes])),
setNodes: (newNodes: FlowNode[]) => {
mocks.setNodes(newNodes)
if (mocks.flowNodes) mocks.flowNodes.value = newNodes
},
setEdges: (newEdges: FlowEdge[]) => {
mocks.setEdges(newEdges)
if (mocks.flowEdges) mocks.flowEdges.value = newEdges
},
edges: mocks.flowEdges,
nodes: mocks.flowNodes,
onConnect: (handler: (connection: Connection) => void) => {
@@ -279,6 +292,9 @@ describe('WorkflowActionsDialog data contract', () => {
mocks.toastWarning.mockReset()
mocks.flowNodes!.value = []
mocks.flowEdges!.value = []
mocks.nodeTypes = undefined
mocks.setNodes.mockReset()
mocks.setEdges.mockReset()
mocks.onConnect = undefined
mocks.isValidConnection = undefined
mocks.conditionItems = []
@@ -299,6 +315,25 @@ describe('WorkflowActionsDialog data contract', () => {
mocks.apiPut.mockResolvedValue(null)
})
it('registers workflow action components for Vue Flow nodes', async () => {
await renderDialog()
await waitFor(() =>
expect(mocks.nodeTypes).toEqual(expect.objectContaining({ AddDownloadAction: expect.anything() })),
)
})
it('loads persisted workflow nodes and edges through Vue Flow setters', async () => {
await renderDialog()
expect(mocks.setNodes).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'source', type: 'SourceAction' })]),
)
expect(mocks.setEdges).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'flow-1', source: 'source', target: 'target' })]),
)
})
it('accepts only known output-to-input connections and rejects invalid endpoints', async () => {
const { container } = await renderDialog()
await waitFor(() => expect(mocks.isValidConnection).toBeTypeOf('function'))