@nocobase/flow-engine 2.0.0-beta.2 → 2.0.0-beta.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/lib/BlockScopedFlowEngine.js +0 -1
  2. package/lib/JSRunner.d.ts +6 -0
  3. package/lib/JSRunner.js +2 -1
  4. package/lib/ViewScopedFlowEngine.js +3 -0
  5. package/lib/acl/Acl.js +13 -3
  6. package/lib/components/dnd/gridDragPlanner.d.ts +1 -0
  7. package/lib/components/dnd/gridDragPlanner.js +53 -1
  8. package/lib/components/settings/wrappers/component/SwitchWithTitle.js +2 -1
  9. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +11 -3
  10. package/lib/components/variables/VariableInput.js +8 -2
  11. package/lib/data-source/index.js +6 -0
  12. package/lib/executor/FlowExecutor.d.ts +2 -1
  13. package/lib/executor/FlowExecutor.js +156 -22
  14. package/lib/flowContext.d.ts +4 -1
  15. package/lib/flowContext.js +176 -107
  16. package/lib/flowEngine.d.ts +21 -0
  17. package/lib/flowEngine.js +38 -0
  18. package/lib/flowSettings.js +12 -10
  19. package/lib/index.d.ts +3 -0
  20. package/lib/index.js +16 -0
  21. package/lib/models/CollectionFieldModel.d.ts +1 -0
  22. package/lib/models/CollectionFieldModel.js +3 -2
  23. package/lib/models/flowModel.d.ts +7 -0
  24. package/lib/models/flowModel.js +66 -1
  25. package/lib/provider.js +7 -6
  26. package/lib/resources/baseRecordResource.d.ts +5 -0
  27. package/lib/resources/baseRecordResource.js +24 -0
  28. package/lib/resources/multiRecordResource.d.ts +1 -0
  29. package/lib/resources/multiRecordResource.js +11 -4
  30. package/lib/resources/singleRecordResource.js +2 -0
  31. package/lib/resources/sqlResource.d.ts +1 -0
  32. package/lib/resources/sqlResource.js +8 -3
  33. package/lib/runjs-context/contexts/base.js +10 -4
  34. package/lib/runjsLibs.d.ts +28 -0
  35. package/lib/runjsLibs.js +532 -0
  36. package/lib/scheduler/ModelOperationScheduler.d.ts +2 -0
  37. package/lib/scheduler/ModelOperationScheduler.js +21 -21
  38. package/lib/types.d.ts +15 -0
  39. package/lib/utils/createCollectionContextMeta.js +1 -0
  40. package/lib/utils/index.d.ts +2 -0
  41. package/lib/utils/index.js +10 -0
  42. package/lib/utils/params-resolvers.js +16 -9
  43. package/lib/utils/resolveModuleUrl.d.ts +58 -0
  44. package/lib/utils/resolveModuleUrl.js +65 -0
  45. package/lib/utils/runjsModuleLoader.d.ts +58 -0
  46. package/lib/utils/runjsModuleLoader.js +422 -0
  47. package/lib/utils/runjsTemplateCompat.d.ts +35 -0
  48. package/lib/utils/runjsTemplateCompat.js +743 -0
  49. package/lib/utils/safeGlobals.d.ts +5 -9
  50. package/lib/utils/safeGlobals.js +129 -17
  51. package/lib/views/createViewMeta.d.ts +0 -7
  52. package/lib/views/createViewMeta.js +19 -70
  53. package/lib/views/index.d.ts +1 -2
  54. package/lib/views/index.js +4 -3
  55. package/lib/views/useDialog.js +8 -3
  56. package/lib/views/useDrawer.js +7 -2
  57. package/lib/views/usePage.d.ts +4 -0
  58. package/lib/views/usePage.js +43 -6
  59. package/lib/views/usePopover.js +4 -1
  60. package/lib/views/viewEvents.d.ts +17 -0
  61. package/lib/views/viewEvents.js +90 -0
  62. package/package.json +4 -4
  63. package/src/BlockScopedFlowEngine.ts +2 -5
  64. package/src/JSRunner.ts +8 -1
  65. package/src/ViewScopedFlowEngine.ts +4 -0
  66. package/src/__tests__/createViewMeta.popup.test.ts +62 -1
  67. package/src/__tests__/flowEngine.dataSourceDirty.test.ts +63 -0
  68. package/src/__tests__/flowSettings.open.test.tsx +69 -15
  69. package/src/__tests__/provider.test.tsx +0 -5
  70. package/src/__tests__/runjsExternalLibs.test.ts +242 -0
  71. package/src/__tests__/runjsLibsLazyLoading.test.ts +44 -0
  72. package/src/__tests__/runjsPreprocessDefault.test.ts +49 -0
  73. package/src/acl/Acl.tsx +3 -3
  74. package/src/components/__tests__/gridDragPlanner.test.ts +141 -1
  75. package/src/components/dnd/gridDragPlanner.ts +60 -0
  76. package/src/components/settings/wrappers/component/SwitchWithTitle.tsx +2 -1
  77. package/src/components/settings/wrappers/component/__tests__/InlineControls.test.tsx +74 -0
  78. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +11 -3
  79. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +63 -4
  80. package/src/components/variables/VariableInput.tsx +8 -2
  81. package/src/data-source/index.ts +6 -0
  82. package/src/executor/FlowExecutor.ts +193 -23
  83. package/src/executor/__tests__/flowExecutor.test.ts +66 -0
  84. package/src/flowContext.ts +234 -118
  85. package/src/flowEngine.ts +41 -0
  86. package/src/flowSettings.ts +12 -11
  87. package/src/index.ts +10 -0
  88. package/src/models/CollectionFieldModel.tsx +3 -1
  89. package/src/models/__tests__/dispatchEvent.when.test.ts +356 -0
  90. package/src/models/__tests__/flowModel.clone.test.ts +416 -0
  91. package/src/models/__tests__/flowModel.test.ts +16 -0
  92. package/src/models/flowModel.tsx +94 -1
  93. package/src/provider.tsx +9 -7
  94. package/src/resources/__tests__/multiRecordResource.test.ts +44 -0
  95. package/src/resources/__tests__/sqlResource.test.ts +60 -0
  96. package/src/resources/baseRecordResource.ts +31 -0
  97. package/src/resources/multiRecordResource.ts +11 -4
  98. package/src/resources/singleRecordResource.ts +3 -0
  99. package/src/resources/sqlResource.ts +8 -3
  100. package/src/runjs-context/contexts/base.ts +9 -2
  101. package/src/runjsLibs.ts +622 -0
  102. package/src/scheduler/ModelOperationScheduler.ts +23 -21
  103. package/src/types.ts +26 -1
  104. package/src/utils/__tests__/params-resolvers.test.ts +40 -0
  105. package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +38 -0
  106. package/src/utils/__tests__/runjsTemplateCompat.test.ts +159 -0
  107. package/src/utils/__tests__/safeGlobals.test.ts +49 -2
  108. package/src/utils/createCollectionContextMeta.ts +1 -0
  109. package/src/utils/index.ts +6 -0
  110. package/src/utils/params-resolvers.ts +23 -9
  111. package/src/utils/resolveModuleUrl.ts +91 -0
  112. package/src/utils/runjsModuleLoader.ts +553 -0
  113. package/src/utils/runjsTemplateCompat.ts +828 -0
  114. package/src/utils/safeGlobals.ts +133 -16
  115. package/src/views/__tests__/FlowView.usePage.test.tsx +54 -1
  116. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +35 -8
  117. package/src/views/__tests__/viewEvents.resolveOpenerEngine.test.ts +28 -0
  118. package/src/views/createViewMeta.ts +22 -75
  119. package/src/views/index.tsx +1 -2
  120. package/src/views/useDialog.tsx +9 -2
  121. package/src/views/useDrawer.tsx +8 -1
  122. package/src/views/usePage.tsx +51 -5
  123. package/src/views/usePopover.tsx +4 -1
  124. package/src/views/viewEvents.ts +55 -0
@@ -0,0 +1,74 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import React from 'react';
11
+ import { describe, it, expect, vi } from 'vitest';
12
+ import { render, screen, fireEvent } from '@nocobase/test/client';
13
+ import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
14
+
15
+ import { SwitchWithTitle } from '../SwitchWithTitle';
16
+ import { SelectWithTitle } from '../SelectWithTitle';
17
+
18
+ vi.mock('antd', async (importOriginal) => {
19
+ const actual = (await importOriginal()) as any;
20
+ return {
21
+ ...actual,
22
+ Select: ({
23
+ popupMatchSelectWidth,
24
+ bordered,
25
+ popupClassName,
26
+ fieldNames,
27
+ labelRender,
28
+ optionRender,
29
+ dropdownRender,
30
+ options,
31
+ ...props
32
+ }: any) => React.createElement('select', props),
33
+ Switch: ({ checkedChildren, unCheckedChildren, size, ...props }: any) =>
34
+ React.createElement('input', { ...props, type: 'checkbox', readOnly: true }),
35
+ };
36
+ });
37
+
38
+ describe('Inline controls - stopPropagation', () => {
39
+ it('SwitchWithTitle click does not bubble to parent', async () => {
40
+ const engine = new FlowEngine();
41
+ const parentClick = vi.fn();
42
+ const onChange = vi.fn();
43
+
44
+ render(
45
+ <FlowEngineProvider engine={engine}>
46
+ <div onClick={parentClick}>
47
+ <SwitchWithTitle title="Enabled" itemKey="enabled" onChange={onChange} />
48
+ </div>
49
+ </FlowEngineProvider>,
50
+ );
51
+
52
+ fireEvent.click(screen.getByText('Enabled'));
53
+
54
+ expect(parentClick).not.toHaveBeenCalled();
55
+ expect(onChange).toHaveBeenCalledWith({ enabled: true });
56
+ });
57
+
58
+ it('SelectWithTitle click does not bubble to parent', async () => {
59
+ const engine = new FlowEngine();
60
+ const parentClick = vi.fn();
61
+
62
+ render(
63
+ <FlowEngineProvider engine={engine}>
64
+ <div onClick={parentClick}>
65
+ <SelectWithTitle title="Mode" itemKey="mode" options={[{ label: 'A', value: 'a' }]} />
66
+ </div>
67
+ </FlowEngineProvider>,
68
+ );
69
+
70
+ fireEvent.click(screen.getByText('Mode'));
71
+
72
+ expect(parentClick).not.toHaveBeenCalled();
73
+ });
74
+ });
@@ -184,6 +184,9 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
184
184
  // 当模型发生子模型替换/增删等变化时,强制刷新菜单数据
185
185
  const [refreshTick, setRefreshTick] = useState(0);
186
186
  const [extraMenuItems, setExtraMenuItems] = useState<FlowModelExtraMenuItem[]>([]);
187
+ const closeDropdown = useCallback(() => {
188
+ setVisible(false);
189
+ }, []);
187
190
  const handleOpenChange: DropdownProps['onOpenChange'] = useCallback((nextOpen: boolean, info) => {
188
191
  if (info.source === 'trigger' || nextOpen) {
189
192
  // 当鼠标快速滑过时,终止菜单的渲染,防止卡顿
@@ -292,6 +295,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
292
295
  );
293
296
 
294
297
  const handleDelete = useCallback(() => {
298
+ closeDropdown();
295
299
  Modal.confirm({
296
300
  title: t('Confirm delete'),
297
301
  icon: <ExclamationCircleOutlined />,
@@ -312,7 +316,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
312
316
  }
313
317
  },
314
318
  });
315
- }, [model]);
319
+ }, [closeDropdown, model, t]);
316
320
 
317
321
  const handleStepConfiguration = useCallback(
318
322
  (key: string) => {
@@ -345,6 +349,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
345
349
  }
346
350
 
347
351
  try {
352
+ closeDropdown();
348
353
  targetModel.openFlowSettings({
349
354
  flowKey,
350
355
  stepKey,
@@ -353,7 +358,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
353
358
  console.log(t('Configuration popup cancelled or error'), ':', error);
354
359
  }
355
360
  },
356
- [model],
361
+ [closeDropdown, model, t],
357
362
  );
358
363
 
359
364
  const handleMenuClick = useCallback(
@@ -363,18 +368,21 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
363
368
  const cleanKey = key.includes('-') && /^(.+)-\d+$/.test(key) ? key.replace(/-\d+$/, '') : key;
364
369
 
365
370
  if (cleanKey.startsWith('copy-pop-uid:')) {
371
+ closeDropdown();
366
372
  handleCopyPopupUid(cleanKey);
367
373
  return;
368
374
  }
369
375
 
370
376
  const extra = extraMenuItems.find((it) => it?.key === originalKey || it?.key === cleanKey);
371
377
  if (extra?.onClick) {
378
+ closeDropdown();
372
379
  extra.onClick();
373
380
  return;
374
381
  }
375
382
 
376
383
  switch (cleanKey) {
377
384
  case 'copy-uid':
385
+ closeDropdown();
378
386
  handleCopyUid();
379
387
  break;
380
388
  case 'delete':
@@ -385,7 +393,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
385
393
  break;
386
394
  }
387
395
  },
388
- [handleCopyUid, handleDelete, handleStepConfiguration, handleCopyPopupUid, extraMenuItems],
396
+ [closeDropdown, handleCopyUid, handleDelete, handleStepConfiguration, handleCopyPopupUid, extraMenuItems],
389
397
  );
390
398
 
391
399
  // 获取单个模型的可配置flows和steps
@@ -35,6 +35,7 @@ vi.mock('antd', async (importOriginal) => {
35
35
  const Dropdown = (props: any) => {
36
36
  (globalThis as any).__lastDropdownMenu = props.menu;
37
37
  (globalThis as any).__lastDropdownOnOpenChange = props.onOpenChange;
38
+ (globalThis as any).__lastDropdownOpen = props.open;
38
39
  dropdownMenus.push(props.menu);
39
40
  return React.createElement('span', { 'data-testid': 'dropdown' }, props.children);
40
41
  };
@@ -98,6 +99,7 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
98
99
  dropdownMenus.length = 0;
99
100
  (globalThis as any).__lastDropdownMenu = undefined;
100
101
  (globalThis as any).__lastDropdownOnOpenChange = undefined;
102
+ (globalThis as any).__lastDropdownOpen = undefined;
101
103
  });
102
104
 
103
105
  afterEach(() => {
@@ -265,10 +267,60 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
265
267
  expect((globalThis as any).__lastDropdownMenu).toBeTruthy();
266
268
  });
267
269
  const menu = (globalThis as any).__lastDropdownMenu;
268
- menu.onClick?.({ key: 'flowC:general' });
270
+ await act(async () => {
271
+ menu.onClick?.({ key: 'flowC:general' });
272
+ });
269
273
  expect(openSpy).toHaveBeenCalledWith({ flowKey: 'flowC', stepKey: 'general' });
270
274
  });
271
275
 
276
+ it('closes dropdown when opening flow settings modal', async () => {
277
+ class TestFlowModel extends FlowModel {}
278
+ const engine = new FlowEngine();
279
+ const model = new TestFlowModel({ uid: 'm-close', flowEngine: engine });
280
+ vi.spyOn(model, 'openFlowSettings').mockResolvedValue(undefined as any);
281
+
282
+ TestFlowModel.registerFlow({
283
+ key: 'flowClose',
284
+ title: 'Flow Close',
285
+ steps: {
286
+ general: { title: 'General', uiSchema: { f: { type: 'string', 'x-component': 'Input' } } },
287
+ },
288
+ });
289
+
290
+ render(
291
+ React.createElement(
292
+ ConfigProvider as any,
293
+ null,
294
+ React.createElement(App as any, null, React.createElement(DefaultSettingsIcon as any, { model })),
295
+ ),
296
+ );
297
+
298
+ await waitFor(() => {
299
+ expect((globalThis as any).__lastDropdownMenu).toBeTruthy();
300
+ expect((globalThis as any).__lastDropdownOnOpenChange).toBeTruthy();
301
+ });
302
+
303
+ // open dropdown
304
+ await act(async () => {
305
+ (globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
306
+ });
307
+
308
+ await waitFor(() => {
309
+ expect((globalThis as any).__lastDropdownOpen).toBe(true);
310
+ });
311
+
312
+ const menu = (globalThis as any).__lastDropdownMenu;
313
+
314
+ // click config item to open modal
315
+ await act(async () => {
316
+ menu.onClick?.({ key: 'flowClose:general' });
317
+ });
318
+
319
+ await waitFor(() => {
320
+ expect((globalThis as any).__lastDropdownOpen).toBe(false);
321
+ });
322
+ });
323
+
272
324
  it('copy UID action writes model uid to clipboard', async () => {
273
325
  class TestFlowModel extends FlowModel {}
274
326
  const engine = new FlowEngine();
@@ -298,7 +350,9 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
298
350
  expect((globalThis as any).__lastDropdownMenu).toBeTruthy();
299
351
  });
300
352
  const menu = (globalThis as any).__lastDropdownMenu;
301
- menu.onClick?.({ key: 'copy-uid' });
353
+ await act(async () => {
354
+ menu.onClick?.({ key: 'copy-uid' });
355
+ });
302
356
  expect((navigator as any).clipboard.writeText).toHaveBeenCalledWith('m-copy');
303
357
  });
304
358
 
@@ -326,7 +380,9 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
326
380
  expect((globalThis as any).__lastDropdownMenu).toBeTruthy();
327
381
  });
328
382
  const menu = (globalThis as any).__lastDropdownMenu;
329
- menu.onClick?.({ key: 'delete' });
383
+ await act(async () => {
384
+ menu.onClick?.({ key: 'delete' });
385
+ });
330
386
  expect(destroySpy).toHaveBeenCalled();
331
387
  });
332
388
 
@@ -556,8 +612,11 @@ describe('DefaultSettingsIcon - extra menu items', () => {
556
612
  });
557
613
 
558
614
  const menu = (globalThis as any).__lastDropdownMenu;
559
- menu.onClick?.({ key: 'extra-action' });
615
+ await act(async () => {
616
+ menu.onClick?.({ key: 'extra-action' });
617
+ });
560
618
  expect(onClick).toHaveBeenCalled();
619
+ expect((globalThis as any).__lastDropdownOpen).toBe(false);
561
620
  } finally {
562
621
  dispose?.();
563
622
  }
@@ -241,12 +241,18 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
241
241
 
242
242
  const handleVariableSelect = useCallback(
243
243
  (variableValue: string, metaTreeNode?: MetaTreeNode) => {
244
+ if (!metaTreeNode && variableValue === '') {
245
+ const cleared = clearValue !== undefined ? clearValue : null;
246
+ setInnerValue(cleared);
247
+ emitChange(cleared as any);
248
+ return;
249
+ }
244
250
  setCurrentMetaTreeNode(metaTreeNode);
245
251
  const finalValue = resolveValueFromPath?.(metaTreeNode) || variableValue;
246
252
  setInnerValue(finalValue);
247
253
  emitChange(finalValue, metaTreeNode);
248
254
  },
249
- [emitChange, resolveValueFromPath],
255
+ [emitChange, resolveValueFromPath, clearValue],
250
256
  );
251
257
 
252
258
  const { disabled } = restProps;
@@ -286,7 +292,7 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
286
292
 
287
293
  const inputProps = useMemo(() => {
288
294
  const baseProps = {
289
- value: innerValue ?? '',
295
+ value: ValueComponent === Input ? innerValue ?? '' : innerValue,
290
296
  onChange: handleInputChange,
291
297
  disabled,
292
298
  };
@@ -486,6 +486,9 @@ export class Collection {
486
486
  if (typeof this.filterTargetKey === 'string') {
487
487
  return record[this.filterTargetKey];
488
488
  }
489
+ if (Array.isArray(this.filterTargetKey) && this.filterTargetKey.length === 1) {
490
+ return record[this.filterTargetKey[0]];
491
+ }
489
492
  return _.pick(record, this.filterTargetKey);
490
493
  }
491
494
 
@@ -815,6 +818,9 @@ export class CollectionField {
815
818
  if (typeof v !== 'object') {
816
819
  return v;
817
820
  }
821
+ if (v.value === null || v.value === undefined) {
822
+ return v;
823
+ }
818
824
  return {
819
825
  ...v,
820
826
  value: Number(v.value),
@@ -17,10 +17,20 @@ import { FlowExitException, resolveDefaultParams } from '../utils';
17
17
  import { FlowExitAllException } from '../utils/exceptions';
18
18
  import { setupRuntimeContextSteps } from '../utils/setupRuntimeContextSteps';
19
19
  import { createEphemeralContext } from '../utils/createEphemeralContext';
20
+ import type { ScheduledCancel } from '../scheduler/ModelOperationScheduler';
20
21
 
21
22
  export class FlowExecutor {
22
23
  constructor(private readonly engine: FlowEngine) {}
23
24
 
25
+ private async emitModelEventIf(
26
+ eventName: string | undefined,
27
+ topic: string,
28
+ payload: Record<string, any>,
29
+ ): Promise<void> {
30
+ if (!eventName) return;
31
+ await this.engine.emitter.emitAsync(`model:event:${eventName}:${topic}`, payload);
32
+ }
33
+
24
34
  /** Cache wrapper for applyFlow cache lifecycle */
25
35
  private async withApplyFlowCache<T>(cacheKey: string | null, executor: () => Promise<T>): Promise<T> {
26
36
  if (!cacheKey || !this.engine) return await executor();
@@ -57,7 +67,13 @@ export class FlowExecutor {
57
67
  /**
58
68
  * Execute a single flow on model.
59
69
  */
60
- async runFlow(model: FlowModel, flowKey: string, inputArgs?: Record<string, any>, runId?: string): Promise<any> {
70
+ async runFlow(
71
+ model: FlowModel,
72
+ flowKey: string,
73
+ inputArgs?: Record<string, any>,
74
+ runId?: string,
75
+ eventName?: string,
76
+ ): Promise<any> {
61
77
  const flow = model.getFlow(flowKey);
62
78
 
63
79
  if (!flow) {
@@ -99,6 +115,16 @@ export class FlowExecutor {
99
115
  setupRuntimeContextSteps(flowContext, stepDefs, model, flowKey);
100
116
  const stepsRuntime = flowContext.steps as Record<string, { params: any; uiSchema?: any; result?: any }>;
101
117
 
118
+ const flowEventBasePayload = {
119
+ uid: model.uid,
120
+ model,
121
+ runId: flowContext.runId,
122
+ inputArgs,
123
+ flowKey,
124
+ };
125
+
126
+ await this.emitModelEventIf(eventName, `flow:${flowKey}:start`, flowEventBasePayload);
127
+
102
128
  for (const [stepKey, step] of Object.entries(stepDefs) as [string, StepDefinition][]) {
103
129
  // Resolve handler and params
104
130
  let handler: ActionDefinition<FlowModel, FlowRuntimeContext>['handler'] | undefined;
@@ -156,6 +182,11 @@ export class FlowExecutor {
156
182
  );
157
183
  continue;
158
184
  }
185
+
186
+ await this.emitModelEventIf(eventName, `flow:${flowKey}:step:${stepKey}:start`, {
187
+ ...flowEventBasePayload,
188
+ stepKey,
189
+ });
159
190
  const currentStepResult = handler(runtimeCtx, combinedParams);
160
191
  const isAwait = step.isAwait !== false;
161
192
  lastResult = isAwait ? await currentStepResult : currentStepResult;
@@ -163,15 +194,47 @@ export class FlowExecutor {
163
194
  // Store step result and update context
164
195
  stepResults[stepKey] = lastResult;
165
196
  stepsRuntime[stepKey].result = stepResults[stepKey];
197
+ await this.emitModelEventIf(eventName, `flow:${flowKey}:step:${stepKey}:end`, {
198
+ ...flowEventBasePayload,
199
+ result: lastResult,
200
+ stepKey,
201
+ });
166
202
  } catch (error) {
203
+ if (!(error instanceof FlowExitException) && !(error instanceof FlowExitAllException)) {
204
+ await this.emitModelEventIf(eventName, `flow:${flowKey}:step:${stepKey}:error`, {
205
+ ...flowEventBasePayload,
206
+ error,
207
+ stepKey,
208
+ });
209
+ }
167
210
  if (error instanceof FlowExitException) {
168
211
  flowContext.logger.info(`[FlowEngine] ${error.message}`);
212
+ await this.emitModelEventIf(eventName, `flow:${flowKey}:step:${stepKey}:end`, {
213
+ ...flowEventBasePayload,
214
+ stepKey,
215
+ });
216
+ await this.emitModelEventIf(eventName, `flow:${flowKey}:end`, {
217
+ ...flowEventBasePayload,
218
+ result: stepResults,
219
+ });
169
220
  return Promise.resolve(stepResults);
170
221
  }
171
222
  if (error instanceof FlowExitAllException) {
172
223
  flowContext.logger.info(`[FlowEngine] ${error.message}`);
224
+ await this.emitModelEventIf(eventName, `flow:${flowKey}:step:${stepKey}:end`, {
225
+ ...flowEventBasePayload,
226
+ stepKey,
227
+ });
228
+ await this.emitModelEventIf(eventName, `flow:${flowKey}:end`, {
229
+ ...flowEventBasePayload,
230
+ result: error,
231
+ });
173
232
  return Promise.resolve(error);
174
233
  }
234
+ await this.emitModelEventIf(eventName, `flow:${flowKey}:error`, {
235
+ ...flowEventBasePayload,
236
+ error,
237
+ });
175
238
  flowContext.logger.error(
176
239
  { err: error },
177
240
  `BaseModel.applyFlow: Error executing step '${stepKey}' in flow '${flowKey}':`,
@@ -179,11 +242,13 @@ export class FlowExecutor {
179
242
  return Promise.reject(error);
180
243
  }
181
244
  }
245
+ await this.emitModelEventIf(eventName, `flow:${flowKey}:end`, {
246
+ ...flowEventBasePayload,
247
+ result: stepResults,
248
+ });
182
249
  return Promise.resolve(stepResults);
183
250
  }
184
251
 
185
- // runAutoFlows 已移除:统一通过 dispatchEvent('beforeRender') + useCache 控制
186
-
187
252
  /**
188
253
  * Dispatch an event to flows bound via flow.on and execute them.
189
254
  */
@@ -202,14 +267,15 @@ export class FlowExecutor {
202
267
 
203
268
  const runId = `${model.uid}-${eventName}-${Date.now()}`;
204
269
  const logger = model.context.logger;
270
+ const eventBasePayload = {
271
+ uid: model.uid,
272
+ model,
273
+ runId,
274
+ inputArgs,
275
+ };
205
276
 
206
277
  try {
207
- await this.engine.emitter.emitAsync(`model:event:${eventName}:start`, {
208
- uid: model.uid,
209
- model,
210
- runId,
211
- inputArgs,
212
- });
278
+ await this.emitModelEventIf(eventName, 'start', eventBasePayload);
213
279
  await model.onDispatchEventStart?.(eventName, options, inputArgs);
214
280
  } catch (err) {
215
281
  if (isBeforeRender && err instanceof FlowExitException) {
@@ -237,11 +303,25 @@ export class FlowExecutor {
237
303
  return false;
238
304
  });
239
305
 
306
+ // 路由系统的“重放打开视图”会再次 dispatchEvent('click'),但这不应重复触发用户配置的动态事件流。
307
+ // 约定:由路由重放触发时,会在 inputArgs 中携带 triggerByRouter: true
308
+ const isRouterReplayClick = eventName === 'click' && inputArgs?.triggerByRouter === true;
309
+ const flowsToRun = isRouterReplayClick
310
+ ? flows.filter((flow) => {
311
+ const reg = flow['flowRegistry'] as any;
312
+ const type = reg?.constructor?._type as 'instance' | 'global' | undefined;
313
+ return type !== 'instance';
314
+ })
315
+ : flows;
316
+
317
+ // 记录本次 dispatchEvent 内注册的调度任务,用于在结束/错误后兜底清理未触发的任务
318
+ const scheduledCancels: ScheduledCancel[] = [];
319
+
240
320
  // 组装执行函数(返回值用于缓存;beforeRender 返回 results:any[],其它返回 true)
241
321
  const execute = async () => {
242
322
  if (sequential) {
243
323
  // 顺序执行:动态流(实例级)优先,其次静态流;各自组内再按 sort 升序,最后保持原始顺序稳定
244
- const flowsWithIndex = flows.map((f, i) => ({ f, i }));
324
+ const flowsWithIndex = flowsToRun.map((f, i) => ({ f, i }));
245
325
  const ordered = flowsWithIndex
246
326
  .slice()
247
327
  .sort((a, b) => {
@@ -259,12 +339,103 @@ export class FlowExecutor {
259
339
  })
260
340
  .map((x) => x.f);
261
341
  const results: any[] = [];
342
+
343
+ // 预处理:当事件流配置了 on.phase 时,将其执行移动到指定节点,并从“立即执行列表”中移除
344
+ const staticFlowsByKey = new Map(
345
+ ordered
346
+ .filter((f) => {
347
+ const reg = f['flowRegistry'] as any;
348
+ const type = reg?.constructor?._type as 'instance' | 'global' | undefined;
349
+ return type !== 'instance';
350
+ })
351
+ .map((f) => [f.key, f] as const),
352
+ );
353
+ const scheduled = new Set<string>();
354
+ const scheduleGroups = new Map<string, Array<{ flow: any; order: number }>>();
355
+ ordered.forEach((flow, indexInOrdered) => {
356
+ const on = flow.on;
357
+ const onObj = typeof on === 'object' ? (on as any) : undefined;
358
+ if (!onObj) return;
359
+
360
+ const phase: any = onObj.phase;
361
+ const flowKey: any = onObj.flowKey;
362
+ const stepKey: any = onObj.stepKey;
363
+
364
+ // 默认:beforeAllFlows(保持现有行为)
365
+ if (!phase || phase === 'beforeAllFlows') return;
366
+
367
+ let whenKey: string | null = null;
368
+ if (phase === 'afterAllFlows') {
369
+ whenKey = `event:${eventName}:end`;
370
+ } else if (phase === 'beforeFlow' || phase === 'afterFlow') {
371
+ if (!flowKey) {
372
+ // 配置不完整:降级到“全部静态流之后”
373
+ whenKey = `event:${eventName}:end`;
374
+ } else {
375
+ const anchorFlow = staticFlowsByKey.get(String(flowKey));
376
+ if (anchorFlow) {
377
+ const anchorPhase = phase === 'beforeFlow' ? 'start' : 'end';
378
+ whenKey = `event:${eventName}:flow:${String(flowKey)}:${anchorPhase}`;
379
+ } else {
380
+ // 锚点不存在(flow 被删除或覆盖等):降级到“全部静态流之后”
381
+ whenKey = `event:${eventName}:end`;
382
+ }
383
+ }
384
+ } else if (phase === 'beforeStep' || phase === 'afterStep') {
385
+ if (!flowKey || !stepKey) {
386
+ // 配置不完整:降级到“全部静态流之后”
387
+ whenKey = `event:${eventName}:end`;
388
+ } else {
389
+ const anchorFlow = staticFlowsByKey.get(String(flowKey));
390
+ const anchorStepExists = !!anchorFlow?.hasStep?.(String(stepKey));
391
+ if (anchorFlow && anchorStepExists) {
392
+ const anchorPhase = phase === 'beforeStep' ? 'start' : 'end';
393
+ whenKey = `event:${eventName}:flow:${String(flowKey)}:step:${String(stepKey)}:${anchorPhase}`;
394
+ } else {
395
+ // 锚点不存在(flow/step 被删除或覆盖等):降级到“全部静态流之后”
396
+ whenKey = `event:${eventName}:end`;
397
+ }
398
+ }
399
+ } else {
400
+ // 未知 phase:忽略
401
+ return;
402
+ }
403
+
404
+ if (!whenKey) return;
405
+ scheduled.add(flow.key);
406
+ const list = scheduleGroups.get(whenKey) || [];
407
+ list.push({ flow, order: indexInOrdered });
408
+ scheduleGroups.set(whenKey, list);
409
+ });
410
+
411
+ // 注册调度(同锚点按 flow.sort 升序;sort 相同保持稳定顺序)
412
+ for (const [whenKey, list] of scheduleGroups.entries()) {
413
+ const sorted = list.slice().sort((a, b) => {
414
+ const sa = a.flow.sort ?? 0;
415
+ const sb = b.flow.sort ?? 0;
416
+ if (sa !== sb) return sa - sb;
417
+ return a.order - b.order;
418
+ });
419
+ for (const it of sorted) {
420
+ const cancel = model.scheduleModelOperation(
421
+ model.uid,
422
+ async (m) => {
423
+ const res = await this.runFlow(m, it.flow.key, inputArgs, runId, eventName);
424
+ results.push(res);
425
+ },
426
+ { when: whenKey as any },
427
+ );
428
+ scheduledCancels.push(cancel);
429
+ }
430
+ }
431
+
262
432
  for (const flow of ordered) {
433
+ if (scheduled.has(flow.key)) continue;
263
434
  try {
264
435
  logger.debug(
265
436
  `BaseModel '${model.uid}' dispatching event '${eventName}' to flow '${flow.key}' (sequential).`,
266
437
  );
267
- const result = await this.runFlow(model, flow.key, inputArgs, runId);
438
+ const result = await this.runFlow(model, flow.key, inputArgs, runId, eventName);
268
439
  if (result instanceof FlowExitAllException) {
269
440
  logger.debug(`[FlowEngine.dispatchEvent] ${result.message}`);
270
441
  break; // 终止后续
@@ -283,10 +454,10 @@ export class FlowExecutor {
283
454
 
284
455
  // 并行
285
456
  const results = await Promise.all(
286
- flows.map(async (flow) => {
457
+ flowsToRun.map(async (flow) => {
287
458
  logger.debug(`BaseModel '${model.uid}' dispatching event '${eventName}' to flow '${flow.key}'.`);
288
459
  try {
289
- return await this.runFlow(model, flow.key, inputArgs, runId);
460
+ return await this.runFlow(model, flow.key, inputArgs, runId, eventName);
290
461
  } catch (error) {
291
462
  logger.error(
292
463
  { err: error },
@@ -318,11 +489,8 @@ export class FlowExecutor {
318
489
  } catch (hookErr) {
319
490
  logger.error({ err: hookErr }, `BaseModel.dispatchEvent: End hook error for event '${eventName}'`);
320
491
  }
321
- await this.engine.emitter.emitAsync(`model:event:${eventName}:end`, {
322
- uid: model.uid,
323
- model,
324
- runId,
325
- inputArgs,
492
+ await this.emitModelEventIf(eventName, 'end', {
493
+ ...eventBasePayload,
326
494
  result,
327
495
  });
328
496
  return result;
@@ -337,14 +505,16 @@ export class FlowExecutor {
337
505
  { err: error },
338
506
  `BaseModel.dispatchEvent: Error executing event '${eventName}' for model '${model.uid}':`,
339
507
  );
340
- await this.engine.emitter.emitAsync(`model:event:${eventName}:error`, {
341
- uid: model.uid,
342
- model,
343
- runId,
344
- inputArgs,
508
+ await this.emitModelEventIf(eventName, 'error', {
509
+ ...eventBasePayload,
345
510
  error,
346
511
  });
347
512
  if (throwOnError) throw error;
513
+ } finally {
514
+ // 清理未触发的调度任务,避免跨事件/跨 runId 残留导致意外执行
515
+ for (const cancel of scheduledCancels) {
516
+ cancel();
517
+ }
348
518
  }
349
519
  }
350
520
  }