From b5a355247b774737d560b49d1d878cf7105e40c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Tue, 8 Sep 2026 14:25:19 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E8=AF=95=E8=BF=90=E8=A1=8C=E8=8A=82=E7=82=B9=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=9C=AA=E9=9A=8F=E7=BC=96=E8=BE=91=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每轮执行刷新轮询节点并复用运行快照,隔离旧请求响应,缓存步骤排序。 补充新增与删除节点、配置更新、暂停恢复及轮询复用回归测试。 --- .../src/views/ai/workflow/WorkflowDesign.vue | 14 +- .../workflow/__tests__/WorkflowDesign.test.ts | 194 ++++++++++++++++++ .../ai/workflow/components/WorkflowForm.vue | 85 +++++--- .../components/__tests__/WorkflowForm.test.ts | 192 +++++++++++++++++ 4 files changed, 448 insertions(+), 37 deletions(-) create mode 100644 easyflow-ui-admin/app/src/views/ai/workflow/__tests__/WorkflowDesign.test.ts create mode 100644 easyflow-ui-admin/app/src/views/ai/workflow/components/__tests__/WorkflowForm.test.ts diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue index ba4f48f8..f6c2a9b5 100644 --- a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue +++ b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue @@ -94,6 +94,10 @@ const workflowInfo = ref({}); const initializationError = ref(false); const runParams = ref(null); const tinyFlowData = shallowRef(null); +const runFlowData = shallowRef(null); +const runNodes = computed(() => + runFlowData.value ? sortNodes(runFlowData.value) : [], +); const onlyRenderVisibleWorkflowElements = computed( () => (tinyFlowData.value?.nodes?.length || 0) >= @@ -592,6 +596,9 @@ function getRunningParams() { .get(`/api/v1/workflow/getRunningParameters?id=${workflowId.value}`) .then((res) => { if (res.errorCode === 0) { + workflowForm.value?.reset(); + runFlowData.value = tinyFlowData.value; + onSubmit(); runParams.value = res.data; drawerVisible.value = true; } @@ -752,6 +759,7 @@ async function handlePublishAction() { } } function onSubmit() { + chainInfo.value = null; initState.value = !initState.value; } async function runIndependently(node: any) { @@ -872,12 +880,12 @@ function onAsyncExecute(info: any) { :workflow-params="runParams" :on-submit="onSubmit" :on-async-execute="onAsyncExecute" - :tiny-flow-data="tinyFlowData" + :tiny-flow-data="runFlowData" />
{{ $t('aiWorkflow.steps') }}:
diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/__tests__/WorkflowDesign.test.ts b/easyflow-ui-admin/app/src/views/ai/workflow/__tests__/WorkflowDesign.test.ts new file mode 100644 index 00000000..f0cb12cc --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/workflow/__tests__/WorkflowDesign.test.ts @@ -0,0 +1,194 @@ +import { flushPromises, mount } from '@vue/test-utils'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import WorkflowForm from '../components/WorkflowForm.vue'; +import WorkflowSteps from '../components/WorkflowSteps.vue'; +import WorkflowDesign from '../WorkflowDesign.vue'; + +const { get, post, getData, sortNodes } = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), + getData: vi.fn(), + sortNodes: vi.fn((flow) => + flow.nodes.map((node: any) => ({ + key: node.id, + label: node.data.title, + original: node, + })), + ), +})); +vi.mock('#/api/request', () => ({ api: { get, post } })); +vi.mock('#/router', () => ({ router: { replace: vi.fn() } })); +vi.mock('vue-router', () => ({ + useRoute: () => ({ query: { id: 'workflow-1', navTitle: '测试' } }), +})); +vi.mock('@easyflow/preferences', () => ({ + usePreferences: () => ({ isDark: false }), +})); +vi.mock('@easyflow/utils', () => ({ sortNodes })); +vi.mock('../customNode/index', () => ({ getCustomNode: async () => ({}) })); +vi.mock('#/views/ai/model/modelUtils/defaultIcon', () => ({ + getIconByValue: () => '', +})); +vi.mock('#/components/commonSelectModal/CommonSelectDataModal.vue', () => ({ + default: { template: '
' }, +})); +vi.mock('../components/SingleRun.vue', () => ({ + default: { template: '
' }, +})); +vi.mock('../components/ExecResult.vue', () => ({ + default: { props: ['nodeJson'], template: '
' }, +})); +vi.mock('@tinyflow-ai/vue', async () => { + const { defineComponent, h } = await import('vue'); + return { + Tinyflow: defineComponent({ + props: { + data: { type: Object, required: true }, + onRunTest: { type: Function, required: true }, + }, + setup(props, { expose }) { + expose({ getData }); + return () => + h( + 'button', + { 'data-test': 'try-run', onClick: props.onRunTest }, + '试运行', + ); + }, + }), + }; +}); + +afterEach(() => { + localStorage.clear(); + vi.clearAllMocks(); +}); + +describe('workflow designer run snapshot', () => { + it('saves new nodes and edited configuration, shares cached display nodes and clears old status', async () => { + const graph: any = { + nodes: [{ id: 'start', type: 'startNode', data: { title: '开始' } }], + edges: [], + }; + let saved = structuredClone(graph); + getData.mockImplementation(() => structuredClone(graph)); + get.mockImplementation(async (url) => { + let data = {}; + if (url.includes('/detail')) { + data = { + id: 'workflow-1', + title: '测试', + content: JSON.stringify(saved), + }; + } else if (url.includes('/getRunningParameters')) { + data = { parameters: [], startFormMeta: { submitText: '开始' } }; + } + return { errorCode: 0, data }; + }); + post.mockImplementation(async (url, body) => { + if (url.endsWith('/update')) saved = structuredClone(body.content); + if (url.endsWith('/check')) + return { errorCode: 0, data: { passed: true } }; + if (url.endsWith('/runAsync')) return { errorCode: 0, data: 'run-1' }; + if (url.endsWith('/getChainStatus')) { + return { + errorCode: 0, + data: { + status: 5, + nodes: Object.fromEntries( + body.nodes.map((node: any) => [ + node.nodeId, + { + status: node.nodeId === 'confirm' ? 5 : 20, + suspendForParameters: + node.nodeId === 'confirm' + ? [ + { + name: 'selection__confirm', + formType: 'radio', + required: true, + options: saved.nodes + .find((item: any) => item.id === 'confirm') + .data.options.map((value: string) => ({ + label: value, + value, + })), + }, + ] + : [], + }, + ]), + ), + }, + }; + } + return { errorCode: 0, data: {} }; + }); + const wrapper = mount(WorkflowDesign, { + global: { + directives: { loading: () => {} }, + stubs: { ShowJson: true, WorkflowFormItem: true }, + }, + }); + try { + await flushPromises(); + const openRun = async () => { + await wrapper.get('[data-test="try-run"]').trigger('click'); + await flushPromises(); + }; + const startRun = async () => { + await wrapper.getComponent(WorkflowForm).get('button').trigger('click'); + await flushPromises(); + }; + await openRun(); + await startRun(); + const originalForm = wrapper.getComponent(WorkflowForm).vm.$.uid; + graph.nodes.push({ + id: 'confirm', + type: 'confirmNode', + data: { + title: '用户确认', + message: '请选择旧模板', + multiple: false, + options: ['旧选项'], + outputDefs: [{ name: 'selection', dataType: 'String' }], + }, + }); + await openRun(); + expect(wrapper.getComponent(WorkflowForm).vm.$.uid).toBe(originalForm); + expect( + wrapper.getComponent(WorkflowSteps).props('pollingData'), + ).toBeNull(); + const latestNodes = wrapper.getComponent(WorkflowSteps).props('nodeJson'); + const sortCount = sortNodes.mock.calls.length; + await startRun(); + expect(wrapper.getComponent(WorkflowSteps).text()).toContain( + '请选择旧模板', + ); + expect(sortNodes).toHaveBeenCalledTimes(sortCount); + expect(wrapper.getComponent(WorkflowSteps).props('nodeJson')).toBe( + latestNodes, + ); + + graph.nodes[1].data.message = '请选择新模板'; + graph.nodes[1].data.options = ['新选项']; + await openRun(); + await startRun(); + expect(saved.nodes[1].data.options).toEqual(['新选项']); + expect(wrapper.getComponent(WorkflowSteps).text()).toContain( + '请选择新模板', + ); + expect( + wrapper.getComponent(WorkflowSteps).props('pollingData').nodes.confirm + .suspendForParameters[0].options, + ).toEqual([{ label: '新选项', value: '新选项' }]); + expect(post.mock.calls.some(([url]) => url.includes('Publish'))).toBe( + false, + ); + } finally { + wrapper.unmount(); + } + }); +}); diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowForm.vue b/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowForm.vue index 9040f518..fcb5d3e7 100644 --- a/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowForm.vue +++ b/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowForm.vue @@ -31,6 +31,7 @@ const props = withDefaults(defineProps(), { }, }); defineExpose({ + reset, resume, }); const runForm = ref(); @@ -88,53 +89,66 @@ watch( ); const executeId = ref(''); async function resume(data: any) { - data.executeId = executeId.value; + if (submitLoading.value || !executeId.value) return false; + const generation = pollingGeneration; submitLoading.value = true; - let accepted = false; try { - const res = await api.post('/api/v1/workflow/resume', data); + const res = await api.post('/api/v1/workflow/resume', { + ...data, + executeId: executeId.value, + }); + if (generation !== pollingGeneration) return false; if (res.errorCode === 0) { - accepted = true; startPolling(executeId.value); + return true; } - return accepted; + return false; } finally { - if (!accepted) { + if (generation === pollingGeneration) { submitLoading.value = false; } } } -function submitV2() { - runForm.value?.validate((valid) => { - if (valid) { - const data = { - id: props.workflowId, - variables: { - ...runParams.value, - }, - }; - props.onSubmit?.(runParams.value); - submitLoading.value = true; - api.post('/api/v1/workflow/runAsync', data).then((res) => { - if (res.errorCode === 0 && res.data) { - // executeId - executeId.value = res.data; - startPolling(res.data); - } - }); +async function submitV2() { + if (submitLoading.value || !runForm.value) return; + stopPolling(); + const generation = pollingGeneration; + submitLoading.value = true; + try { + const valid = await runForm.value.validate().catch(() => false); + if (!valid || generation !== pollingGeneration) return; + + executeId.value = ''; + // 每轮执行只生成一次轻量列表,后续轮询和暂停恢复复用同一份节点。 + nodes = (props.tinyFlowData?.nodes || []).map((node: any) => ({ + nodeId: node.id, + nodeName: node.data?.title || node.id, + })); + props.onSubmit?.(runParams.value); + const res = await api.post('/api/v1/workflow/runAsync', { + id: props.workflowId, + variables: { ...runParams.value }, + }); + if (generation !== pollingGeneration) return; + if (res.errorCode === 0 && res.data) { + executeId.value = res.data; + startPolling(res.data); } - }); + } catch (error) { + if (generation === pollingGeneration) { + console.error('工作流启动失败', error); + } + } finally { + if (generation === pollingGeneration) { + submitLoading.value = false; + } + } } const POLLING_INTERVAL_MS = 1000; const timer = ref>(null); let pollingActive = false; let pollingGeneration = 0; -const nodes = ref( - props.tinyFlowData.nodes.map((node: any) => ({ - nodeId: node.id, - nodeName: node.data.title, - })), -); +let nodes: { nodeId: string; nodeName: string }[] = []; // 轮询执行结果 function startPolling(executeId: any) { if (pollingActive) return; @@ -152,7 +166,7 @@ async function executePolling(executeId: any, generation: number) { try { const res = await api.post('/api/v1/workflow/getChainStatus', { executeId, - nodes: nodes.value, + nodes, }); if (!pollingActive || generation !== pollingGeneration) return; @@ -180,9 +194,12 @@ function stopPolling() { timer.value = null; } } -onUnmounted(() => { +function reset() { stopPolling(); -}); + executeId.value = ''; + nodes = []; +} +onUnmounted(reset);