@nocobase/flow-engine 2.1.0-alpha.40 → 2.1.0-alpha.46

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 (77) hide show
  1. package/lib/FlowContextProvider.d.ts +5 -1
  2. package/lib/FlowContextProvider.js +9 -2
  3. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +84 -32
  4. package/lib/components/subModel/LazyDropdown.js +208 -16
  5. package/lib/components/subModel/utils.d.ts +1 -0
  6. package/lib/components/subModel/utils.js +6 -2
  7. package/lib/data-source/index.d.ts +9 -0
  8. package/lib/data-source/index.js +12 -0
  9. package/lib/executor/FlowExecutor.js +0 -3
  10. package/lib/flowContext.d.ts +6 -1
  11. package/lib/flowContext.js +38 -6
  12. package/lib/flowEngine.d.ts +4 -3
  13. package/lib/flowEngine.js +72 -40
  14. package/lib/models/flowModel.js +48 -16
  15. package/lib/runjs-context/contexts/FormJSFieldItemRunJSContext.js +4 -3
  16. package/lib/runjs-context/contexts/JSBlockRunJSContext.js +4 -15
  17. package/lib/runjs-context/contexts/JSColumnRunJSContext.js +5 -2
  18. package/lib/runjs-context/contexts/JSEditableFieldRunJSContext.js +5 -8
  19. package/lib/runjs-context/contexts/JSFieldRunJSContext.js +4 -3
  20. package/lib/runjs-context/contexts/JSItemRunJSContext.js +4 -3
  21. package/lib/runjs-context/contexts/base.js +464 -29
  22. package/lib/runjs-context/contexts/elementDoc.d.ts +11 -0
  23. package/lib/runjs-context/contexts/elementDoc.js +152 -0
  24. package/lib/utils/loadedPageCache.d.ts +24 -0
  25. package/lib/utils/loadedPageCache.js +139 -0
  26. package/lib/utils/parsePathnameToViewParams.d.ts +5 -1
  27. package/lib/utils/parsePathnameToViewParams.js +28 -4
  28. package/lib/views/ViewNavigation.d.ts +12 -2
  29. package/lib/views/ViewNavigation.js +22 -7
  30. package/lib/views/createViewMeta.js +114 -50
  31. package/lib/views/inheritLayoutContext.d.ts +10 -0
  32. package/lib/views/inheritLayoutContext.js +50 -0
  33. package/lib/views/useDialog.js +2 -0
  34. package/lib/views/useDrawer.js +2 -0
  35. package/lib/views/usePage.js +2 -0
  36. package/package.json +4 -4
  37. package/src/FlowContextProvider.tsx +9 -1
  38. package/src/__tests__/createViewMeta.popup.test.ts +115 -1
  39. package/src/__tests__/flowContext.test.ts +23 -0
  40. package/src/__tests__/flowEngine.moveModel.test.ts +81 -1
  41. package/src/__tests__/flowEngine.removeModel.test.ts +47 -3
  42. package/src/__tests__/runjsContext.test.ts +18 -0
  43. package/src/__tests__/runjsContextImplementations.test.ts +9 -2
  44. package/src/__tests__/runjsLocales.test.ts +6 -5
  45. package/src/__tests__/viewScopedFlowEngine.test.ts +133 -0
  46. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +90 -38
  47. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +155 -5
  48. package/src/components/subModel/LazyDropdown.tsx +237 -16
  49. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +254 -1
  50. package/src/components/subModel/utils.ts +6 -1
  51. package/src/data-source/index.ts +18 -0
  52. package/src/executor/FlowExecutor.ts +0 -3
  53. package/src/executor/__tests__/flowExecutor.test.ts +26 -0
  54. package/src/flowContext.ts +43 -6
  55. package/src/flowEngine.ts +75 -38
  56. package/src/models/__tests__/flowEngine.resolveUse.test.ts +0 -15
  57. package/src/models/__tests__/flowModel.test.ts +46 -62
  58. package/src/models/flowModel.tsx +65 -32
  59. package/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts +4 -3
  60. package/src/runjs-context/contexts/JSBlockRunJSContext.ts +4 -15
  61. package/src/runjs-context/contexts/JSColumnRunJSContext.ts +4 -2
  62. package/src/runjs-context/contexts/JSEditableFieldRunJSContext.ts +5 -9
  63. package/src/runjs-context/contexts/JSFieldRunJSContext.ts +4 -3
  64. package/src/runjs-context/contexts/JSItemRunJSContext.ts +4 -3
  65. package/src/runjs-context/contexts/base.ts +467 -31
  66. package/src/runjs-context/contexts/elementDoc.ts +130 -0
  67. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +21 -0
  68. package/src/utils/loadedPageCache.ts +147 -0
  69. package/src/utils/parsePathnameToViewParams.ts +45 -5
  70. package/src/views/ViewNavigation.ts +40 -7
  71. package/src/views/__tests__/ViewNavigation.test.ts +52 -0
  72. package/src/views/__tests__/inheritLayoutContext.test.ts +53 -0
  73. package/src/views/createViewMeta.ts +106 -34
  74. package/src/views/inheritLayoutContext.ts +26 -0
  75. package/src/views/useDialog.tsx +2 -0
  76. package/src/views/useDrawer.tsx +2 -0
  77. package/src/views/usePage.tsx +2 -0
package/src/flowEngine.ts CHANGED
@@ -20,6 +20,7 @@ import { APIResource, FlowResource, MultiRecordResource, SingleRecordResource, S
20
20
  import { Emitter } from './emitter';
21
21
  import ModelOperationScheduler from './scheduler/ModelOperationScheduler';
22
22
  import type { ScheduleOptions, ScheduledCancel } from './scheduler/ModelOperationScheduler';
23
+ import { createLoadedPageCache } from './utils/loadedPageCache';
23
24
  import type {
24
25
  ActionDefinition,
25
26
  ApplyFlowCacheEntry,
@@ -39,6 +40,8 @@ import type {
39
40
  } from './types';
40
41
  import { isInheritedFrom } from './utils';
41
42
 
43
+ const getFlowEngineLoggerLevel = () => (process.env.NODE_ENV === 'production' ? 'warn' : 'trace');
44
+
42
45
  /**
43
46
  * FlowEngine is the core class of the flow engine, responsible for managing flow models, actions, model repository, and more.
44
47
  * It provides capabilities for registering, creating, finding, persisting, replacing, and moving models.
@@ -133,6 +136,8 @@ export class FlowEngine {
133
136
  */
134
137
  private _savingModels = new Map<string, Promise<any>>();
135
138
 
139
+ private _loadedPageCache = createLoadedPageCache();
140
+
136
141
  /**
137
142
  * Flow engine context object.
138
143
  * @private
@@ -213,7 +218,7 @@ export class FlowEngine {
213
218
  MultiRecordResource,
214
219
  });
215
220
  this.logger = pino({
216
- level: 'trace',
221
+ level: getFlowEngineLoggerLevel(),
217
222
  browser: {
218
223
  write: {
219
224
  fatal: (o) => console.trace(o),
@@ -1009,7 +1014,6 @@ export class FlowEngine {
1009
1014
 
1010
1015
  while (current) {
1011
1016
  if (visited.has(current)) {
1012
- console.warn(`FlowEngine: resolveUse circular reference detected on '${current.name}'.`);
1013
1017
  break;
1014
1018
  }
1015
1019
  visited.add(current);
@@ -1128,7 +1132,7 @@ export class FlowEngine {
1128
1132
  */
1129
1133
  public removeModel(uid: string): boolean {
1130
1134
  if (!this._modelInstances.has(uid)) {
1131
- console.warn(`FlowEngine: Model with UID '${uid}' does not exist.`);
1135
+ this.logger.debug(`FlowEngine: Model with UID '${uid}' does not exist.`);
1132
1136
  return false;
1133
1137
  }
1134
1138
  const modelInstance = this._modelInstances.get(uid) as FlowModel;
@@ -1338,7 +1342,8 @@ export class FlowEngine {
1338
1342
  async loadModel<T extends FlowModel = FlowModel>(options): Promise<T | null> {
1339
1343
  if (!this.ensureModelRepository()) return;
1340
1344
  const refresh = !!options?.refresh;
1341
- if (!refresh) {
1345
+ const bypassLoadedPageCache = this._loadedPageCache.shouldBypass(options, () => this.context.flowSettingsEnabled);
1346
+ if (!refresh && !bypassLoadedPageCache) {
1342
1347
  const model = this.findModelByParentId(options.parentId, options.subKey);
1343
1348
  if (model) {
1344
1349
  return model as T;
@@ -1349,15 +1354,24 @@ export class FlowEngine {
1349
1354
  }
1350
1355
  }
1351
1356
  const data = await this._modelRepository.findOne(options);
1352
- if (!data?.uid) return null;
1353
- await this.resolveModelTree(data);
1354
- if (refresh) {
1357
+ if (!data?.uid) {
1358
+ if (bypassLoadedPageCache) {
1359
+ this._loadedPageCache.clear(options);
1360
+ }
1361
+ return null;
1362
+ }
1363
+ if (refresh || bypassLoadedPageCache) {
1355
1364
  const existing = this.getModel(data.uid);
1356
1365
  if (existing) {
1357
1366
  this.removeModelWithSubModels(existing.uid);
1358
1367
  }
1359
1368
  }
1360
- return this.createModelAsync<T>(data as any);
1369
+ const model = await this.createModelAsync<T>(data as any);
1370
+ if (bypassLoadedPageCache) {
1371
+ this._loadedPageCache.mountModelToParent(model, true);
1372
+ this._loadedPageCache.clear(options);
1373
+ }
1374
+ return model;
1361
1375
  }
1362
1376
 
1363
1377
  /**
@@ -1397,22 +1411,31 @@ export class FlowEngine {
1397
1411
  ): Promise<T | null> {
1398
1412
  if (!this.ensureModelRepository()) return;
1399
1413
  const { uid, parentId, subKey } = options;
1400
- if (uid && this._modelInstances.has(uid)) {
1414
+ const bypassLoadedPageCache = this._loadedPageCache.shouldBypass(options, () => this.context.flowSettingsEnabled);
1415
+ if (uid && !bypassLoadedPageCache && this._modelInstances.has(uid)) {
1401
1416
  return this._modelInstances.get(uid) as T;
1402
1417
  }
1403
- const m = this.findModelByParentId<T>(parentId, subKey);
1404
- if (m) {
1405
- return m;
1406
- }
1418
+ if (!bypassLoadedPageCache) {
1419
+ const m = this.findModelByParentId<T>(parentId, subKey);
1420
+ if (m) {
1421
+ return m;
1422
+ }
1407
1423
 
1408
- const hydrated = await this.hydrateModelFromPreviousEngines<T>(options, extra);
1409
- if (hydrated) {
1410
- return hydrated;
1424
+ const hydrated = await this.hydrateModelFromPreviousEngines<T>(options, extra);
1425
+ if (hydrated) {
1426
+ return hydrated;
1427
+ }
1411
1428
  }
1412
1429
 
1413
1430
  const data = await this._modelRepository.findOne(options);
1414
1431
  let model: T | null = null;
1415
1432
  if (data?.uid) {
1433
+ if (bypassLoadedPageCache) {
1434
+ const existing = this.getModel(data.uid);
1435
+ if (existing) {
1436
+ this.removeModelWithSubModels(existing.uid);
1437
+ }
1438
+ }
1416
1439
  model = await this.createModelAsync<T>(data as any, extra);
1417
1440
  } else {
1418
1441
  model = await this.createModelAsync<T>(options, extra);
@@ -1420,18 +1443,9 @@ export class FlowEngine {
1420
1443
  await model.save();
1421
1444
  }
1422
1445
  }
1423
- if (model.parent) {
1424
- const subModel = model.parent.findSubModel(model.subKey, (m) => {
1425
- return m.uid === model.uid;
1426
- });
1427
- if (subModel) {
1428
- return model;
1429
- }
1430
- if (model.subType === 'array') {
1431
- model.parent.addSubModel(model.subKey, model);
1432
- } else {
1433
- model.parent.setSubModel(model.subKey, model);
1434
- }
1446
+ this._loadedPageCache.mountModelToParent(model, bypassLoadedPageCache);
1447
+ if (bypassLoadedPageCache) {
1448
+ this._loadedPageCache.clear(options);
1435
1449
  }
1436
1450
  return model;
1437
1451
  }
@@ -1451,6 +1465,9 @@ export class FlowEngine {
1451
1465
  if (!this.ensureModelRepository()) return;
1452
1466
 
1453
1467
  const modelUid = model.uid;
1468
+ const dirtyLoadedPageKey = this._loadedPageCache.getDirtyKeyForModel(model, {
1469
+ force: !!options?.onlyStepParams,
1470
+ });
1454
1471
 
1455
1472
  // 如果这个 model 正在保存中,返回现有的保存 Promise
1456
1473
  if (this._savingModels.has(modelUid)) {
@@ -1464,6 +1481,7 @@ export class FlowEngine {
1464
1481
 
1465
1482
  try {
1466
1483
  const result = await savePromise;
1484
+ this._loadedPageCache.markDirty(dirtyLoadedPageKey);
1467
1485
  return result;
1468
1486
  } finally {
1469
1487
  // 无论成功还是失败,都要清除保存状态
@@ -1500,11 +1518,16 @@ export class FlowEngine {
1500
1518
  * @returns {Promise<boolean>} Whether destroyed successfully
1501
1519
  */
1502
1520
  async destroyModel(uid: string) {
1503
- if (this.ensureModelRepository()) {
1521
+ const modelInstance = this._modelInstances.get(uid) as FlowModel;
1522
+ const dirtyLoadedPageKey = this._loadedPageCache.getDirtyKeyForModel(modelInstance);
1523
+ const hasModelRepository = this.ensureModelRepository();
1524
+ if (hasModelRepository) {
1504
1525
  await this._modelRepository.destroy(uid);
1505
1526
  }
1506
1527
 
1507
- const modelInstance = this._modelInstances.get(uid) as FlowModel;
1528
+ if (hasModelRepository) {
1529
+ this._loadedPageCache.markDirty(dirtyLoadedPageKey);
1530
+ }
1508
1531
  const parent = modelInstance?.parent;
1509
1532
  const result = this.removeModel(uid);
1510
1533
  parent && parent.emitter.emit('onSubModelDestroyed', modelInstance);
@@ -1608,17 +1631,25 @@ export class FlowEngine {
1608
1631
 
1609
1632
  /**
1610
1633
  * Move a model instance within its parent model.
1611
- * @param {any} sourceId Source model UID
1612
- * @param {any} targetId Target model UID
1634
+ * @param {string | number} sourceId Source model UID
1635
+ * @param {string | number} targetId Target model UID
1613
1636
  * @returns {Promise<void>} No return value
1614
1637
  */
1615
- async moveModel(sourceId: any, targetId: any, options?: PersistOptions): Promise<void> {
1616
- const sourceModel = this.getModel(sourceId);
1617
- const targetModel = this.getModel(targetId);
1638
+ async moveModel(sourceId: string | number, targetId: string | number, options?: PersistOptions): Promise<void> {
1639
+ const sourceUid = String(sourceId);
1640
+ const targetUid = String(targetId);
1641
+ if (!sourceUid || !targetUid || sourceUid === targetUid) {
1642
+ return;
1643
+ }
1644
+
1645
+ const sourceModel = this.getModel(sourceUid);
1646
+ const targetModel = this.getModel(targetUid);
1618
1647
  if (!sourceModel || !targetModel) {
1619
1648
  console.warn(`FlowEngine: Cannot move model. Source or target model not found.`);
1620
1649
  return;
1621
1650
  }
1651
+ let position: 'before' | 'after' = 'after';
1652
+ const dirtyLoadedPageKey = this._loadedPageCache.getDirtyKeyForModel(sourceModel);
1622
1653
  const move = (sourceModel: FlowModel, targetModel: FlowModel) => {
1623
1654
  if (!sourceModel.parent || !targetModel.parent || sourceModel.parent !== targetModel.parent) {
1624
1655
  console.error('FlowModel.moveTo: Both models must have the same parent to perform move operation.');
@@ -1648,6 +1679,8 @@ export class FlowEngine {
1648
1679
  return false;
1649
1680
  }
1650
1681
 
1682
+ position = currentIndex < targetIndex ? 'after' : 'before';
1683
+
1651
1684
  // 使用splice直接移动数组元素(O(n)比排序O(n log n)更快)
1652
1685
  const [movedModel] = subModelsCopy.splice(currentIndex, 1);
1653
1686
  subModelsCopy.splice(targetIndex, 0, movedModel);
@@ -1662,10 +1695,14 @@ export class FlowEngine {
1662
1695
 
1663
1696
  return true;
1664
1697
  };
1665
- move(sourceModel, targetModel);
1698
+ const moved = move(sourceModel, targetModel);
1699
+ if (!moved) {
1700
+ return;
1701
+ }
1702
+
1666
1703
  if (options?.persist !== false && this.ensureModelRepository()) {
1667
- const position = sourceModel.sortIndex - targetModel.sortIndex > 0 ? 'after' : 'before';
1668
- await this._modelRepository.move(sourceId, targetId, position);
1704
+ await this._modelRepository.move(sourceUid, targetUid, position);
1705
+ this._loadedPageCache.markDirty(dirtyLoadedPageKey);
1669
1706
  }
1670
1707
  // 触发事件以通知其他部分模型已移动
1671
1708
  sourceModel.parent.emitter.emit('onSubModelMoved', { source: sourceModel, target: targetModel });
@@ -61,21 +61,6 @@ describe('FlowEngine.createModel resolveUse hook', () => {
61
61
  expect(warnSpy).not.toHaveBeenCalled();
62
62
  });
63
63
 
64
- test('should break resolveUse on circular reference and warn', () => {
65
- class LoopModel extends FlowModel {
66
- static resolveUse() {
67
- return 'LoopModel';
68
- }
69
- }
70
-
71
- engine.registerModels({ LoopModel });
72
-
73
- const model = engine.createModel({ use: 'LoopModel', uid: 'loop-model', flowEngine: engine });
74
-
75
- expect(model).toBeInstanceOf(LoopModel);
76
- expect(warnSpy).toHaveBeenCalled();
77
- });
78
-
79
64
  test('should fall back to ErrorFlowModel when resolveUse returns unregistered name', () => {
80
65
  class MissingTargetEntry extends FlowModel {
81
66
  static resolveUse() {
@@ -313,6 +313,19 @@ describe('FlowModel', () => {
313
313
 
314
314
  model.emitter.off('onStepParamsChanged', listener);
315
315
  });
316
+
317
+ test('should not emit onStepParamsChanged when params are unchanged', () => {
318
+ const listener = vi.fn();
319
+ model.emitter.on('onStepParamsChanged', listener);
320
+
321
+ model.setStepParams('testFlow', 'step1', { param1: 'value1' });
322
+ model.setStepParams('testFlow', { step1: { param1: 'value1' } });
323
+ model.setStepParams({ testFlow: { step1: { param1: 'value1' } } });
324
+
325
+ expect(listener).not.toHaveBeenCalled();
326
+
327
+ model.emitter.off('onStepParamsChanged', listener);
328
+ });
316
329
  });
317
330
  });
318
331
 
@@ -370,15 +383,17 @@ describe('FlowModel', () => {
370
383
  };
371
384
 
372
385
  TestFlowModel.registerFlow(exitFlow);
373
- const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
386
+ const loggerSpy = vi.spyOn(model.flowEngine.logger, 'debug').mockImplementation(() => {});
374
387
 
375
- const result = await model.applyFlow('exitFlow');
376
-
377
- expect(result).toBeInstanceOf(FlowExitAllException);
378
- expect(exitFlow.steps.step2.handler).not.toHaveBeenCalled();
379
- expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[FlowModel]'));
388
+ try {
389
+ const result = await model.applyFlow('exitFlow');
380
390
 
381
- consoleSpy.mockRestore();
391
+ expect(result).toBeInstanceOf(FlowExitAllException);
392
+ expect(exitFlow.steps.step2.handler).not.toHaveBeenCalled();
393
+ expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('[FlowModel]'));
394
+ } finally {
395
+ loggerSpy.mockRestore();
396
+ }
382
397
  });
383
398
 
384
399
  test('should handle ctx.exit() as FlowExitAllException in beforeRender dispatch', async () => {
@@ -474,15 +489,17 @@ describe('FlowModel', () => {
474
489
  };
475
490
 
476
491
  TestFlowModel.registerFlow(exitFlow);
477
- const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
492
+ const loggerSpy = vi.spyOn(model.flowEngine.logger, 'debug').mockImplementation(() => {});
478
493
 
479
- const result = await model.applyFlow('exitFlow');
480
-
481
- expect(result).toBeInstanceOf(FlowExitAllException);
482
- expect(exitFlow.steps.step2.handler).not.toHaveBeenCalled();
483
- expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[FlowModel]'));
494
+ try {
495
+ const result = await model.applyFlow('exitFlow');
484
496
 
485
- consoleSpy.mockRestore();
497
+ expect(result).toBeInstanceOf(FlowExitAllException);
498
+ expect(exitFlow.steps.step2.handler).not.toHaveBeenCalled();
499
+ expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('[FlowModel]'));
500
+ } finally {
501
+ loggerSpy.mockRestore();
502
+ }
486
503
  });
487
504
 
488
505
  test('should propagate step execution errors', async () => {
@@ -546,34 +563,6 @@ describe('FlowModel', () => {
546
563
 
547
564
  loggerSpy.mockRestore();
548
565
  });
549
-
550
- test('should warn and skip step when use and handler are both missing', async () => {
551
- const warnSpy = vi.spyOn(model.context.logger, 'warn').mockImplementation(() => {});
552
- const errorSpy = vi.spyOn(model.context.logger, 'error').mockImplementation(() => {});
553
-
554
- TestFlowModel.registerFlow({
555
- key: 'settingsOnlyFlow',
556
- steps: {
557
- edit: {
558
- title: 'Edit',
559
- uiSchema: {},
560
- },
561
- },
562
- });
563
-
564
- const result = await model.applyFlow('settingsOnlyFlow');
565
-
566
- expect(result).toEqual({});
567
- expect(warnSpy).toHaveBeenCalledWith(
568
- expect.stringContaining("Step 'edit' in flow 'settingsOnlyFlow' has neither 'use' nor 'handler'"),
569
- );
570
- expect(errorSpy).not.toHaveBeenCalledWith(
571
- expect.stringContaining("Step 'edit' in flow 'settingsOnlyFlow' has neither 'use' nor 'handler'"),
572
- );
573
-
574
- warnSpy.mockRestore();
575
- errorSpy.mockRestore();
576
- });
577
566
  });
578
567
 
579
568
  describe('beforeRender flows', () => {
@@ -796,7 +785,7 @@ describe('FlowModel', () => {
796
785
  const eventFlow = createEventFlowDefinition('testEvent');
797
786
  TestFlowModel.registerFlow(eventFlow);
798
787
 
799
- const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
788
+ const loggerSpy = vi.spyOn(model.flowEngine.logger, 'debug').mockImplementation(() => {});
800
789
 
801
790
  try {
802
791
  model.dispatchEvent('testEvent', { data: 'payload' });
@@ -804,7 +793,7 @@ describe('FlowModel', () => {
804
793
  // Use a more reliable approach than arbitrary timeout
805
794
  await new Promise((resolve) => setTimeout(resolve, 0));
806
795
 
807
- expect(consoleSpy).toHaveBeenCalledWith(
796
+ expect(loggerSpy).toHaveBeenCalledWith(
808
797
  expect.stringContaining('[FlowModel] dispatchEvent: uid=test-model-uid, event=testEvent'),
809
798
  );
810
799
  expect(eventFlow.steps.eventStep.handler).toHaveBeenCalledWith(
@@ -814,7 +803,7 @@ describe('FlowModel', () => {
814
803
  expect.any(Object),
815
804
  );
816
805
  } finally {
817
- consoleSpy.mockRestore();
806
+ loggerSpy.mockRestore();
818
807
  }
819
808
  });
820
809
 
@@ -1625,7 +1614,7 @@ describe('FlowModel', () => {
1625
1614
  fork1.dispose = vi.fn();
1626
1615
  fork2.dispose = vi.fn();
1627
1616
 
1628
- const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1617
+ const loggerSpy = vi.spyOn(model.flowEngine.logger, 'debug').mockImplementation(() => {});
1629
1618
 
1630
1619
  try {
1631
1620
  model.clearForks();
@@ -1634,19 +1623,19 @@ describe('FlowModel', () => {
1634
1623
  expect(fork2.dispose).toHaveBeenCalled();
1635
1624
  expect(model.forks.size).toBe(0);
1636
1625
  } finally {
1637
- consoleSpy.mockRestore();
1626
+ loggerSpy.mockRestore();
1638
1627
  }
1639
1628
  });
1640
1629
 
1641
1630
  test('should handle empty forks collection when clearing', () => {
1642
- const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1631
+ const loggerSpy = vi.spyOn(model.flowEngine.logger, 'debug').mockImplementation(() => {});
1643
1632
 
1644
1633
  try {
1645
1634
  model.clearForks();
1646
1635
 
1647
1636
  expect(model.forks.size).toBe(0);
1648
1637
  } finally {
1649
- consoleSpy.mockRestore();
1638
+ loggerSpy.mockRestore();
1650
1639
  }
1651
1640
  });
1652
1641
  });
@@ -1774,7 +1763,7 @@ describe('FlowModel', () => {
1774
1763
  test('should clean up resources on remove', () => {
1775
1764
  model.createFork();
1776
1765
  model.createFork();
1777
- const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1766
+ const loggerSpy = vi.spyOn(model.flowEngine.logger, 'debug').mockImplementation(() => {});
1778
1767
 
1779
1768
  // Mock removeModel to simulate proper fork cleanup
1780
1769
  flowEngine.removeModel = vi.fn().mockImplementation(() => {
@@ -1791,7 +1780,7 @@ describe('FlowModel', () => {
1791
1780
  expect(model.forks.size).toBe(0);
1792
1781
  expect(flowEngine.removeModel).toHaveBeenCalledWith(model.uid);
1793
1782
  } finally {
1794
- consoleSpy.mockRestore();
1783
+ loggerSpy.mockRestore();
1795
1784
  }
1796
1785
  });
1797
1786
  });
@@ -1868,17 +1857,12 @@ describe('FlowModel', () => {
1868
1857
  });
1869
1858
 
1870
1859
  test('should rerender triggers beforeRender without cache', async () => {
1871
- const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1872
1860
  model.dispatchEvent = vi.fn().mockResolvedValue(undefined) as any;
1873
1861
 
1874
- try {
1875
- await expect(model.rerender()).resolves.not.toThrow();
1876
- expect(model.dispatchEvent).toHaveBeenCalledWith('beforeRender', undefined, {
1877
- useCache: false,
1878
- });
1879
- } finally {
1880
- consoleSpy.mockRestore();
1881
- }
1862
+ await expect(model.rerender()).resolves.not.toThrow();
1863
+ expect(model.dispatchEvent).toHaveBeenCalledWith('beforeRender', undefined, {
1864
+ useCache: false,
1865
+ });
1882
1866
  });
1883
1867
  });
1884
1868
 
@@ -2918,7 +2902,7 @@ describe('FlowModel', () => {
2918
2902
  describe('Edge Cases & Error Handling', () => {
2919
2903
  test('should handle model destruction gracefully', () => {
2920
2904
  const model = new FlowModel(modelOptions);
2921
- const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
2905
+ const loggerSpy = vi.spyOn(model.flowEngine.logger, 'debug').mockImplementation(() => {});
2922
2906
 
2923
2907
  model.createFork();
2924
2908
  model.setProps({ testProp: 'value' });
@@ -2926,7 +2910,7 @@ describe('FlowModel', () => {
2926
2910
  try {
2927
2911
  expect(() => model.remove()).not.toThrow();
2928
2912
  } finally {
2929
- consoleSpy.mockRestore();
2913
+ loggerSpy.mockRestore();
2930
2914
  }
2931
2915
  });
2932
2916
 
@@ -56,6 +56,25 @@ const classEventRegistries = new WeakMap<typeof FlowModel, ModelEventRegistry>()
56
56
  // 使用WeakMap存储每个类的meta
57
57
  const modelMetas = new WeakMap<typeof FlowModel, FlowModelMeta>();
58
58
 
59
+ type SortableModelLike = {
60
+ sortIndex?: number | null;
61
+ };
62
+
63
+ function getStableSortIndex(item: SortableModelLike, fallbackIndex: number) {
64
+ return typeof item?.sortIndex === 'number' && Number.isFinite(item.sortIndex) ? item.sortIndex : fallbackIndex + 1;
65
+ }
66
+
67
+ function sortByStableSortIndex<T extends SortableModelLike>(items: T[]) {
68
+ return items
69
+ .map((item, index) => ({
70
+ item,
71
+ index,
72
+ sortIndex: getStableSortIndex(item, index),
73
+ }))
74
+ .sort((a, b) => a.sortIndex - b.sortIndex || a.index - b.index)
75
+ .map(({ item }) => item);
76
+ }
77
+
59
78
  // 使用WeakMap存储每个类的 GlobalFlowRegistry
60
79
  const modelGlobalRegistries = new WeakMap<typeof FlowModel, GlobalFlowRegistry>();
61
80
 
@@ -242,7 +261,7 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
242
261
  };
243
262
  this.stepParams = options.stepParams || {};
244
263
  this.subModels = {};
245
- this.sortIndex = options.sortIndex || 0;
264
+ this.sortIndex = getStableSortIndex({ sortIndex: options.sortIndex }, -1);
246
265
  this._options = options;
247
266
  this._title = '';
248
267
  this._extraTitle = '';
@@ -511,11 +530,9 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
511
530
 
512
531
  Object.entries(mergedSubModels || {}).forEach(([key, value]) => {
513
532
  if (Array.isArray(value)) {
514
- value
515
- .sort((a, b) => (a.sortIndex || 0) - (b.sortIndex || 0))
516
- .forEach((item) => {
517
- this.addSubModel(key, item);
518
- });
533
+ sortByStableSortIndex(value).forEach((item) => {
534
+ this.addSubModel(key, item);
535
+ });
519
536
  } else {
520
537
  this.setSubModel(key, value);
521
538
  }
@@ -778,26 +795,43 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
778
795
  stepKeyOrStepsParams?: string | Record<string, ParamObject>,
779
796
  params?: ParamObject,
780
797
  ): void {
798
+ let hasChanged = false;
799
+
781
800
  if (typeof flowKeyOrAllParams === 'string') {
782
801
  const flowKey = flowKeyOrAllParams;
783
802
  if (typeof stepKeyOrStepsParams === 'string' && params !== undefined) {
784
- if (!this.stepParams[flowKey]) {
785
- this.stepParams[flowKey] = {};
803
+ const currentStepParams = this.stepParams[flowKey]?.[stepKeyOrStepsParams] || {};
804
+ const nextStepParams = { ...currentStepParams, ...params };
805
+ if (!_.isEqual(currentStepParams, nextStepParams)) {
806
+ if (!this.stepParams[flowKey]) {
807
+ this.stepParams[flowKey] = {};
808
+ }
809
+ this.stepParams[flowKey][stepKeyOrStepsParams] = nextStepParams;
810
+ hasChanged = true;
786
811
  }
787
- this.stepParams[flowKey][stepKeyOrStepsParams] = {
788
- ...this.stepParams[flowKey][stepKeyOrStepsParams],
789
- ...params,
790
- };
791
812
  } else if (typeof stepKeyOrStepsParams === 'object' && stepKeyOrStepsParams !== null) {
792
- this.stepParams[flowKey] = { ...(this.stepParams[flowKey] || {}), ...stepKeyOrStepsParams };
813
+ const currentFlowParams = this.stepParams[flowKey] || {};
814
+ const nextFlowParams = { ...currentFlowParams, ...stepKeyOrStepsParams };
815
+ if (!_.isEqual(currentFlowParams, nextFlowParams)) {
816
+ this.stepParams[flowKey] = nextFlowParams;
817
+ hasChanged = true;
818
+ }
793
819
  }
794
820
  } else if (typeof flowKeyOrAllParams === 'object' && flowKeyOrAllParams !== null) {
795
821
  for (const fk in flowKeyOrAllParams) {
796
822
  if (Object.prototype.hasOwnProperty.call(flowKeyOrAllParams, fk)) {
797
- this.stepParams[fk] = { ...(this.stepParams[fk] || {}), ...flowKeyOrAllParams[fk] };
823
+ const currentFlowParams = this.stepParams[fk] || {};
824
+ const nextFlowParams = { ...currentFlowParams, ...flowKeyOrAllParams[fk] };
825
+ if (!_.isEqual(currentFlowParams, nextFlowParams)) {
826
+ this.stepParams[fk] = nextFlowParams;
827
+ hasChanged = true;
828
+ }
798
829
  }
799
830
  }
800
831
  }
832
+ if (!hasChanged) {
833
+ return;
834
+ }
801
835
  // 发起配置修改事件
802
836
  this.emitter.emit('onStepParamsChanged');
803
837
  }
@@ -823,7 +857,7 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
823
857
  }
824
858
  const isFork = (this as any).isFork === true;
825
859
  const target = this;
826
- console.log(
860
+ currentFlowEngine.logger.debug(
827
861
  `[FlowModel] applyFlow: uid=${this.uid}, flowKey=${flowKey}, isFork=${isFork}, cleanRun=${
828
862
  this.cleanRun
829
863
  }, targetIsFork=${(target as any)?.isFork === true}`,
@@ -843,7 +877,7 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
843
877
  }
844
878
  const isFork = (this as any).isFork === true;
845
879
  const target = this;
846
- console.log(
880
+ currentFlowEngine.logger.debug(
847
881
  `[FlowModel] dispatchEvent: uid=${this.uid}, event=${eventName}, isFork=${isFork}, cleanRun=${
848
882
  this.cleanRun
849
883
  }, targetIsFork=${(target as any)?.isFork === true}`,
@@ -1206,7 +1240,10 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
1206
1240
  if (!Array.isArray(subModels[subKey])) {
1207
1241
  subModels[subKey] = observable.shallow([]);
1208
1242
  }
1209
- const maxSortIndex = Math.max(...(subModels[subKey] as FlowModel[]).map((item) => item.sortIndex || 0), 0);
1243
+ const maxSortIndex = Math.max(
1244
+ ...(subModels[subKey] as FlowModel[]).map((item, index) => getStableSortIndex(item, index)),
1245
+ 0,
1246
+ );
1210
1247
  model.sortIndex = maxSortIndex + 1;
1211
1248
  subModels[subKey].push(model);
1212
1249
  actualParent.emitter.emit('onSubModelAdded', model);
@@ -1264,14 +1301,12 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
1264
1301
 
1265
1302
  const results: ArrayElementType<NonNullable<Structure['subModels']>[K]>[] = [];
1266
1303
 
1267
- _.castArray(model)
1268
- .sort((a, b) => (a.sortIndex || 0) - (b.sortIndex || 0))
1269
- .forEach((item, index) => {
1270
- const result = (callback as (model: any, index: number) => boolean)(item, index);
1271
- if (result) {
1272
- results.push(item);
1273
- }
1274
- });
1304
+ sortByStableSortIndex(_.castArray(model)).forEach((item, index) => {
1305
+ const result = (callback as (model: any, index: number) => boolean)(item, index);
1306
+ if (result) {
1307
+ results.push(item);
1308
+ }
1309
+ });
1275
1310
 
1276
1311
  return results;
1277
1312
  }
@@ -1288,12 +1323,10 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
1288
1323
 
1289
1324
  const results: R[] = [];
1290
1325
 
1291
- _.castArray(model)
1292
- .sort((a, b) => (a.sortIndex || 0) - (b.sortIndex || 0))
1293
- .forEach((item, index) => {
1294
- const result = (callback as (model: any, index: number) => R)(item, index);
1295
- results.push(result);
1296
- });
1326
+ sortByStableSortIndex(_.castArray(model)).forEach((item, index) => {
1327
+ const result = (callback as (model: any, index: number) => R)(item, index);
1328
+ results.push(result);
1329
+ });
1297
1330
 
1298
1331
  return results;
1299
1332
  }
@@ -1379,7 +1412,7 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
1379
1412
  }
1380
1413
 
1381
1414
  clearForks() {
1382
- console.log(`FlowModel ${this.uid} clearing all forks.`);
1415
+ this.flowEngine.logger.debug(`FlowModel ${this.uid} clearing all forks.`);
1383
1416
  // 主动使所有 fork 失效
1384
1417
  if (this.forks?.size) {
1385
1418
  this.forks.forEach((fork) => fork.dispose());