@nocobase/flow-engine 2.1.0-beta.2 → 2.1.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 (126) hide show
  1. package/LICENSE +201 -661
  2. package/README.md +79 -10
  3. package/lib/JSRunner.d.ts +10 -1
  4. package/lib/JSRunner.js +50 -5
  5. package/lib/ViewScopedFlowEngine.js +5 -1
  6. package/lib/components/FlowModelRenderer.d.ts +1 -1
  7. package/lib/components/FlowModelRenderer.js +10 -6
  8. package/lib/components/MobilePopup.js +6 -5
  9. package/lib/components/dnd/gridDragPlanner.js +6 -2
  10. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  11. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +48 -9
  12. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +19 -43
  13. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +339 -295
  14. package/lib/components/settings/wrappers/contextual/StepSettingsDialog.js +16 -2
  15. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  16. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +272 -0
  17. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  18. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +247 -0
  19. package/lib/components/subModel/AddSubModelButton.js +27 -1
  20. package/lib/components/subModel/utils.js +2 -2
  21. package/lib/data-source/index.js +6 -0
  22. package/lib/executor/FlowExecutor.js +31 -8
  23. package/lib/flowContext.js +31 -1
  24. package/lib/flowEngine.d.ts +151 -1
  25. package/lib/flowEngine.js +389 -15
  26. package/lib/flowSettings.d.ts +14 -6
  27. package/lib/flowSettings.js +34 -6
  28. package/lib/lazy-helper.d.ts +14 -0
  29. package/lib/lazy-helper.js +71 -0
  30. package/lib/locale/en-US.json +1 -0
  31. package/lib/locale/index.d.ts +2 -0
  32. package/lib/locale/zh-CN.json +1 -0
  33. package/lib/models/flowModel.d.ts +2 -1
  34. package/lib/models/flowModel.js +28 -9
  35. package/lib/reactive/observer.js +46 -16
  36. package/lib/runjs-context/registry.d.ts +1 -1
  37. package/lib/runjs-context/setup.js +20 -12
  38. package/lib/runjs-context/snippets/index.js +13 -2
  39. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
  40. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
  41. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
  42. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
  43. package/lib/scheduler/ModelOperationScheduler.d.ts +5 -1
  44. package/lib/scheduler/ModelOperationScheduler.js +3 -2
  45. package/lib/types.d.ts +47 -1
  46. package/lib/utils/index.d.ts +2 -2
  47. package/lib/utils/index.js +4 -0
  48. package/lib/utils/parsePathnameToViewParams.js +1 -1
  49. package/lib/utils/runjsTemplateCompat.js +1 -1
  50. package/lib/utils/runjsValue.js +41 -11
  51. package/lib/utils/schema-utils.d.ts +7 -1
  52. package/lib/utils/schema-utils.js +19 -0
  53. package/lib/views/FlowView.d.ts +7 -1
  54. package/lib/views/runViewBeforeClose.d.ts +10 -0
  55. package/lib/views/runViewBeforeClose.js +45 -0
  56. package/lib/views/useDialog.d.ts +2 -1
  57. package/lib/views/useDialog.js +20 -3
  58. package/lib/views/useDrawer.d.ts +2 -1
  59. package/lib/views/useDrawer.js +20 -3
  60. package/lib/views/usePage.d.ts +2 -1
  61. package/lib/views/usePage.js +10 -3
  62. package/package.json +6 -5
  63. package/src/JSRunner.ts +68 -4
  64. package/src/ViewScopedFlowEngine.ts +4 -0
  65. package/src/__tests__/JSRunner.test.ts +27 -1
  66. package/src/__tests__/flow-engine.test.ts +166 -0
  67. package/src/__tests__/flowContext.test.ts +65 -1
  68. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  69. package/src/__tests__/flowSettings.test.ts +94 -15
  70. package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
  71. package/src/__tests__/runjsContext.test.ts +16 -0
  72. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  73. package/src/__tests__/runjsPreprocessDefault.test.ts +23 -0
  74. package/src/__tests__/runjsSnippets.test.ts +21 -0
  75. package/src/__tests__/viewScopedFlowEngine.test.ts +3 -3
  76. package/src/components/FlowModelRenderer.tsx +12 -6
  77. package/src/components/MobilePopup.tsx +4 -2
  78. package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
  79. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
  80. package/src/components/__tests__/gridDragPlanner.test.ts +88 -0
  81. package/src/components/dnd/gridDragPlanner.ts +8 -2
  82. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +63 -9
  83. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +468 -440
  84. package/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx +18 -2
  85. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +95 -0
  86. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +609 -0
  87. package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +358 -0
  88. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +281 -0
  89. package/src/components/subModel/AddSubModelButton.tsx +32 -2
  90. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +142 -32
  91. package/src/components/subModel/utils.ts +1 -1
  92. package/src/data-source/index.ts +6 -0
  93. package/src/executor/FlowExecutor.ts +34 -9
  94. package/src/executor/__tests__/flowExecutor.test.ts +57 -0
  95. package/src/flowContext.ts +35 -3
  96. package/src/flowEngine.ts +445 -11
  97. package/src/flowSettings.ts +40 -6
  98. package/src/lazy-helper.tsx +57 -0
  99. package/src/locale/en-US.json +1 -0
  100. package/src/locale/zh-CN.json +1 -0
  101. package/src/models/__tests__/dispatchEvent.when.test.ts +214 -0
  102. package/src/models/flowModel.tsx +31 -10
  103. package/src/reactive/__tests__/observer.test.tsx +82 -0
  104. package/src/reactive/observer.tsx +87 -25
  105. package/src/runjs-context/registry.ts +1 -1
  106. package/src/runjs-context/setup.ts +22 -12
  107. package/src/runjs-context/snippets/index.ts +12 -1
  108. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
  109. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
  110. package/src/scheduler/ModelOperationScheduler.ts +14 -3
  111. package/src/types.ts +60 -0
  112. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +7 -0
  113. package/src/utils/__tests__/runjsValue.test.ts +11 -0
  114. package/src/utils/__tests__/utils.test.ts +62 -0
  115. package/src/utils/index.ts +2 -1
  116. package/src/utils/parsePathnameToViewParams.ts +2 -2
  117. package/src/utils/runjsTemplateCompat.ts +1 -1
  118. package/src/utils/runjsValue.ts +50 -11
  119. package/src/utils/schema-utils.ts +30 -1
  120. package/src/views/FlowView.tsx +11 -1
  121. package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
  122. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +13 -12
  123. package/src/views/runViewBeforeClose.ts +19 -0
  124. package/src/views/useDialog.tsx +25 -3
  125. package/src/views/useDrawer.tsx +25 -3
  126. package/src/views/usePage.tsx +12 -3
package/src/flowEngine.ts CHANGED
@@ -24,7 +24,11 @@ import type {
24
24
  ActionDefinition,
25
25
  ApplyFlowCacheEntry,
26
26
  CreateModelOptions,
27
+ EnsureBatchResult,
27
28
  EventDefinition,
29
+ FlowModelLoaderEntry,
30
+ FlowModelLoaderInputMap,
31
+ FlowModelLoaderResult,
28
32
  FlowModelOptions,
29
33
  IFlowModelRepository,
30
34
  ModelConstructor,
@@ -75,6 +79,32 @@ export class FlowEngine {
75
79
  */
76
80
  private _modelClasses: Map<string, ModelConstructor> = observable.shallow(new Map());
77
81
 
82
+ /**
83
+ * Registered model entries.
84
+ * Key is the model class name, value is the model loader entry.
85
+ * @private
86
+ */
87
+ private _modelLoaders: Map<string, FlowModelLoaderEntry> = new Map();
88
+
89
+ /**
90
+ * In-flight model loading promises.
91
+ * Key is the model class name, value is the loading promise.
92
+ * @private
93
+ */
94
+ private _loadingModelPromises: Map<string, Promise<ModelConstructor | null>> = new Map();
95
+
96
+ /**
97
+ * Whether model-loader preload has completed in this session.
98
+ * @private
99
+ */
100
+ private _modelLoadersPreloaded = false;
101
+
102
+ /**
103
+ * In-flight model-loader preload promise.
104
+ * @private
105
+ */
106
+ private _modelLoadersPreloadPromise?: Promise<EnsureBatchResult>;
107
+
78
108
  /**
79
109
  * Created model instances.
80
110
  * Key is the model instance UID, value is the model instance object.
@@ -117,6 +147,13 @@ export class FlowEngine {
117
147
  private _previousEngine?: FlowEngine;
118
148
  private _nextEngine?: FlowEngine;
119
149
 
150
+ /**
151
+ * 视图销毁回调。由 useDrawer / useDialog 在创建弹窗视图时注册,
152
+ * 供外部(如 afterSuccess)通过引擎栈遍历来关闭多层弹窗。
153
+ * embed 视图(usePage)不注册此回调,因此 destroyView() 会自然跳过。
154
+ */
155
+ private _destroyView?: () => void;
156
+
120
157
  private _resources = new Map<string, typeof FlowResource>();
121
158
 
122
159
  /**
@@ -282,6 +319,28 @@ export class FlowEngine {
282
319
  }
283
320
  }
284
321
 
322
+ /**
323
+ * 注册视图销毁回调(由 useDrawer / useDialog 调用)。
324
+ */
325
+ public setDestroyView(fn: () => void): void {
326
+ this._destroyView = fn;
327
+ }
328
+
329
+ /**
330
+ * 关闭当前引擎关联的弹窗视图。
331
+ * 路由触发的弹窗会先 navigation.back() 清理 URL,再 destroy() 移除元素;
332
+ * 非路由弹窗直接 destroy()。
333
+ * embed 视图不注册回调,调用时返回 false 自动跳过。
334
+ * @returns 是否成功执行
335
+ */
336
+ public destroyView(): boolean {
337
+ if (this._destroyView) {
338
+ this._destroyView();
339
+ return true;
340
+ }
341
+ return false;
342
+ }
343
+
285
344
  // (已移除)getModelGlobal/forEachModelGlobal/getAllModelsGlobal:不再维护冗余全局遍历 API
286
345
 
287
346
  /**
@@ -395,6 +454,13 @@ export class FlowEngine {
395
454
  * @private
396
455
  */
397
456
  #registerModel(name: string, modelClass: ModelConstructor): void {
457
+ return this._registerModel(name, modelClass);
458
+ }
459
+
460
+ /**
461
+ * for proxy instance, the #registerModel can't be called.
462
+ */
463
+ private _registerModel(name: string, modelClass: ModelConstructor): void {
398
464
  if (this._modelClasses.has(name)) {
399
465
  console.warn(`FlowEngine: Model class with name '${name}' is already registered and will be overwritten.`);
400
466
  }
@@ -415,6 +481,306 @@ export class FlowEngine {
415
481
  }
416
482
  }
417
483
 
484
+ /**
485
+ * Register multiple model loader entries.
486
+ * The `extends` field declares parent class(es) for async subclass discovery via `getSubclassesOfAsync`.
487
+ * It accepts `string | ModelConstructor | (string | ModelConstructor)[]` and is normalized to `string[]` internally.
488
+ * @param {FlowModelLoaderInputMap} loaders Model loader input map
489
+ * @returns {void}
490
+ * @example
491
+ * flowEngine.registerModelLoaders({
492
+ * DemoModel: {
493
+ * extends: 'BaseModel',
494
+ * loader: () => import('./models/DemoModel'),
495
+ * },
496
+ * });
497
+ */
498
+ public registerModelLoaders(loaders: FlowModelLoaderInputMap): void {
499
+ let changed = false;
500
+ for (const [name, input] of Object.entries(loaders)) {
501
+ if (this._modelLoaders.has(name)) {
502
+ console.warn(`FlowEngine: Model loader with name '${name}' is already registered and will be overwritten.`);
503
+ }
504
+ const entry: FlowModelLoaderEntry = {
505
+ loader: input.loader,
506
+ };
507
+ if (input.extends != null) {
508
+ const raw = Array.isArray(input.extends) ? input.extends : [input.extends];
509
+ entry.extends = raw.map((item) => (typeof item === 'string' ? item : item.name));
510
+ }
511
+ this._modelLoaders.set(name, entry);
512
+ changed = true;
513
+ }
514
+ if (changed) {
515
+ this._modelLoadersPreloaded = false;
516
+ this._modelLoadersPreloadPromise = undefined;
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Get a registered model class (constructor) asynchronously.
522
+ * This will first ensure the model loader entry is resolved.
523
+ * @param {string} name Model class name
524
+ * @returns {Promise<ModelConstructor | undefined>} Model constructor, or undefined if not found
525
+ */
526
+ public async getModelClassAsync(name: string): Promise<ModelConstructor | undefined> {
527
+ await this.ensureModel(name);
528
+ return this.getModelClass(name);
529
+ }
530
+
531
+ /**
532
+ * Get all registered model classes asynchronously.
533
+ * This will first ensure all registered model loader entries are resolved.
534
+ * @returns {Promise<Map<string, ModelConstructor>>} Model class map
535
+ */
536
+ public async getModelClassesAsync(): Promise<Map<string, ModelConstructor>> {
537
+ await this.ensureModels(Array.from(this._modelLoaders.keys()));
538
+ return this.getModelClasses();
539
+ }
540
+
541
+ /**
542
+ * Create and register a model instance asynchronously.
543
+ * This will first ensure all string-based model references in the model tree are resolved.
544
+ * @template T FlowModel subclass type, defaults to FlowModel.
545
+ * @param {CreateModelOptions} options Model creation options
546
+ * @returns {Promise<T>} Created model instance
547
+ */
548
+ public async createModelAsync<T extends FlowModel = FlowModel>(
549
+ options: CreateModelOptions,
550
+ extra?: { delegateToParent?: boolean; delegate?: FlowContext },
551
+ ): Promise<T> {
552
+ await this.resolveModelTree(options);
553
+ return this.createModel<T>(options, extra);
554
+ }
555
+
556
+ /**
557
+ * Normalize a loader result into a model constructor.
558
+ * @param {string} name Model class name
559
+ * @param {FlowModelLoaderResult} loaded Loader result
560
+ * @returns {ModelConstructor | null} Normalized model constructor
561
+ * @private
562
+ */
563
+ private normalizeModelLoaderResult(name: string, loaded: FlowModelLoaderResult): ModelConstructor | null {
564
+ if (typeof loaded === 'function') {
565
+ return loaded as ModelConstructor;
566
+ }
567
+ if (loaded && typeof loaded === 'object') {
568
+ const defaultExport = loaded.default;
569
+ if (typeof defaultExport === 'function') {
570
+ return defaultExport as ModelConstructor;
571
+ }
572
+ const namedExport = loaded[name];
573
+ if (typeof namedExport === 'function') {
574
+ return namedExport as ModelConstructor;
575
+ }
576
+ }
577
+ console.warn(`FlowEngine: model loader for '${name}' did not resolve to a valid model constructor.`);
578
+ return null;
579
+ }
580
+
581
+ /**
582
+ * Collect string-based model names from a model tree.
583
+ * @param {unknown} data Model tree data
584
+ * @param {Set<string>} names Model name set
585
+ * @private
586
+ */
587
+ private collectModelNamesFromTree(data: unknown, names: Set<string>): void {
588
+ if (!data || typeof data !== 'object') {
589
+ return;
590
+ }
591
+ if (Array.isArray(data)) {
592
+ data.forEach((item) => this.collectModelNamesFromTree(item, names));
593
+ return;
594
+ }
595
+
596
+ const tree = data as Record<string, any>;
597
+ if (typeof tree.use === 'string') {
598
+ names.add(tree.use);
599
+ }
600
+
601
+ const subModels = tree.subModels;
602
+ if (!subModels || typeof subModels !== 'object') {
603
+ return;
604
+ }
605
+
606
+ Object.values(subModels).forEach((value) => {
607
+ this.collectModelNamesFromTree(value, names);
608
+ });
609
+ }
610
+
611
+ /**
612
+ * Collect additional model names from object-form meta.createModelOptions defaults.
613
+ * @param {ModelConstructor} modelClass Model class constructor
614
+ * @param {Set<string>} names Model name set
615
+ * @private
616
+ */
617
+ private collectModelNamesFromMetaDefaults(modelClass: ModelConstructor, names: Set<string>): void {
618
+ const metaCreate = (modelClass as typeof FlowModel).meta?.createModelOptions;
619
+ if (metaCreate && typeof metaCreate === 'object') {
620
+ this.collectModelNamesFromTree(metaCreate, names);
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Ensure a single model class is available.
626
+ * @param {string} name Model class name
627
+ * @returns {Promise<ModelConstructor | null>} Model constructor or null when resolution fails
628
+ * @private
629
+ */
630
+ private async ensureModel(name: string): Promise<ModelConstructor | null> {
631
+ const existing = this._modelClasses.get(name);
632
+ if (existing) {
633
+ return existing;
634
+ }
635
+
636
+ const inflight = this._loadingModelPromises.get(name);
637
+ if (inflight) {
638
+ return inflight;
639
+ }
640
+
641
+ const entry = this._modelLoaders.get(name);
642
+ if (!entry) {
643
+ console.warn(`FlowEngine: Model entry '${name}' not found. Falling back to ErrorFlowModel when needed.`);
644
+ return null;
645
+ }
646
+
647
+ const promise = (async () => {
648
+ try {
649
+ const loaded = await entry.loader();
650
+ const modelClass = this.normalizeModelLoaderResult(name, loaded);
651
+ if (!modelClass) {
652
+ return null;
653
+ }
654
+ // 这里拿到的 this 是 Proxy(FlowEngine) 而不是原始的 FlowEngine,无法直接调用 #registerModel
655
+ this._registerModel(name, modelClass);
656
+ return modelClass;
657
+ } catch (error) {
658
+ console.warn(`FlowEngine: Failed to load model '${name}'. Falling back to ErrorFlowModel when needed.`, error);
659
+ return null;
660
+ } finally {
661
+ this._loadingModelPromises.delete(name);
662
+ }
663
+ })();
664
+
665
+ this._loadingModelPromises.set(name, promise);
666
+ return promise;
667
+ }
668
+
669
+ /**
670
+ * Ensure multiple model classes are available.
671
+ * @param {string[]} names Model class names
672
+ * @returns {Promise<EnsureBatchResult>} Batch ensure result
673
+ * @private
674
+ */
675
+ private async ensureModels(names: string[]): Promise<EnsureBatchResult> {
676
+ const requested = Array.from(new Set(names.filter((name): name is string => !!name)));
677
+ const loaded: string[] = [];
678
+ const failed: EnsureBatchResult['failed'] = [];
679
+
680
+ const results = await Promise.all(
681
+ requested.map(async (name) => {
682
+ const modelClass = await this.ensureModel(name);
683
+ return { name, modelClass };
684
+ }),
685
+ );
686
+
687
+ results.forEach(({ name, modelClass }) => {
688
+ if (modelClass) {
689
+ loaded.push(name);
690
+ } else {
691
+ failed.push({ name });
692
+ }
693
+ });
694
+
695
+ return { requested, loaded, failed };
696
+ }
697
+
698
+ /**
699
+ * Resolve all unresolved string-based model references in a model tree before synchronous creation begins.
700
+ *
701
+ * Use this when you already have a model tree object, such as repository-returned data or resolved
702
+ * `createModelOptions`, and you need to ensure every string `use` in that tree has been loaded and
703
+ * registered into `_modelClasses` before calling `createModel()`.
704
+ *
705
+ * @param {unknown} data Model tree data
706
+ * @returns {Promise<EnsureBatchResult>} Batch ensure result
707
+ */
708
+ public async resolveModelTree(data: unknown): Promise<EnsureBatchResult> {
709
+ const requested = new Set<string>();
710
+ const loaded = new Set<string>();
711
+ const failed = new Map<string, { name: string; error?: unknown }>();
712
+ const processed = new Set<string>();
713
+ const pending = new Set<string>();
714
+
715
+ this.collectModelNamesFromTree(data, pending);
716
+
717
+ while (pending.size > 0) {
718
+ const batch = Array.from(pending).filter((name) => !processed.has(name));
719
+ pending.clear();
720
+ if (batch.length === 0) {
721
+ break;
722
+ }
723
+
724
+ batch.forEach((name) => requested.add(name));
725
+ const result = await this.ensureModels(batch);
726
+
727
+ result.loaded.forEach((name) => {
728
+ processed.add(name);
729
+ loaded.add(name);
730
+ const modelClass = this.getModelClass(name);
731
+ if (modelClass) {
732
+ const discovered = new Set<string>();
733
+ this.collectModelNamesFromMetaDefaults(modelClass, discovered);
734
+ discovered.forEach((discoveredName) => {
735
+ if (!processed.has(discoveredName)) {
736
+ pending.add(discoveredName);
737
+ }
738
+ });
739
+ }
740
+ });
741
+
742
+ result.failed.forEach((item) => {
743
+ processed.add(item.name);
744
+ failed.set(item.name, item);
745
+ });
746
+ }
747
+
748
+ return {
749
+ requested: Array.from(requested),
750
+ loaded: Array.from(loaded),
751
+ failed: Array.from(failed.values()),
752
+ };
753
+ }
754
+
755
+ /**
756
+ * Preload all currently registered unresolved model loaders.
757
+ *
758
+ * This method is intended for flow-settings/discovery style entry points that need registered model
759
+ * classes to exist before UI is rendered, without requiring callers to know which specific models
760
+ * will be touched next.
761
+ *
762
+ * @returns {Promise<EnsureBatchResult>} Batch ensure result
763
+ */
764
+ public async preloadModelLoaders(): Promise<EnsureBatchResult> {
765
+ const unresolved = Array.from(this._modelLoaders.keys()).filter((name) => !this._modelClasses.has(name));
766
+ if (unresolved.length === 0) {
767
+ this._modelLoadersPreloaded = true;
768
+ return { requested: [], loaded: [], failed: [] };
769
+ }
770
+ if (this._modelLoadersPreloadPromise) {
771
+ return this._modelLoadersPreloadPromise;
772
+ }
773
+
774
+ this._modelLoadersPreloadPromise = (async () => {
775
+ const result = await this.ensureModels(unresolved);
776
+ this._modelLoadersPreloaded = result.failed.length === 0;
777
+ this._modelLoadersPreloadPromise = undefined;
778
+ return result;
779
+ })();
780
+
781
+ return this._modelLoadersPreloadPromise;
782
+ }
783
+
418
784
  registerResources(resources: Record<string, any>) {
419
785
  for (const [name, resourceClass] of Object.entries(resources)) {
420
786
  this._resources.set(name, resourceClass);
@@ -489,6 +855,70 @@ export class FlowEngine {
489
855
  return result;
490
856
  }
491
857
 
858
+ /**
859
+ * Asynchronously get all subclasses of a base class, including those registered via model loaders.
860
+ * Merges results from already-loaded classes (_modelClasses) and async loader entries with matching `extends` declarations.
861
+ * Loader-resolved classes are validated with `isInheritedFrom`; mismatches are warned and excluded.
862
+ * @param {string | ModelConstructor} baseClass Base class name or constructor
863
+ * @param {(ModelClass: ModelConstructor, className: string) => boolean} [filter] Optional filter function
864
+ * @returns {Promise<Map<string, ModelConstructor>>} Model classes that are subclasses of the base class
865
+ */
866
+ public async getSubclassesOfAsync(
867
+ baseClass: string | ModelConstructor,
868
+ filter?: (ModelClass: ModelConstructor, className: string) => boolean,
869
+ ): Promise<Map<string, ModelConstructor>> {
870
+ const baseClassName = typeof baseClass === 'string' ? baseClass : baseClass.name;
871
+
872
+ // If baseClass is a string and not yet loaded, try to resolve it first
873
+ let parentModelClass: ModelConstructor | undefined;
874
+ if (typeof baseClass === 'string') {
875
+ if (!this.getModelClass(baseClass)) {
876
+ await this.ensureModel(baseClass);
877
+ }
878
+ parentModelClass = this.getModelClass(baseClass);
879
+ } else {
880
+ parentModelClass = baseClass;
881
+ }
882
+
883
+ if (!parentModelClass) {
884
+ return new Map();
885
+ }
886
+
887
+ // Step 1: Collect already-loaded subclasses from _modelClasses
888
+ const result = this.getSubclassesOf(parentModelClass, filter);
889
+
890
+ // Step 2: Find unloaded loaders whose extends includes baseClassName
891
+ const loaderCandidates: string[] = [];
892
+ for (const [name, entry] of this._modelLoaders) {
893
+ if (result.has(name) || this._modelClasses.has(name)) continue;
894
+ if (entry.extends?.includes(baseClassName)) {
895
+ loaderCandidates.push(name);
896
+ }
897
+ }
898
+
899
+ // Step 3: Resolve all matching loaders
900
+ if (loaderCandidates.length > 0) {
901
+ await this.ensureModels(loaderCandidates);
902
+ }
903
+
904
+ // Step 4: Validate resolved classes and add to result
905
+ for (const name of loaderCandidates) {
906
+ const ModelClass = this._modelClasses.get(name);
907
+ if (!ModelClass) continue;
908
+ if (!isInheritedFrom(ModelClass, parentModelClass)) {
909
+ console.warn(
910
+ `FlowEngine: Model '${name}' declares extends '${baseClassName}' but does not actually inherit from it. Skipping.`,
911
+ );
912
+ continue;
913
+ }
914
+ if (!filter || filter(ModelClass, name)) {
915
+ result.set(name, ModelClass);
916
+ }
917
+ }
918
+
919
+ return result;
920
+ }
921
+
492
922
  /**
493
923
  * Create and register a model instance.
494
924
  * If an instance with the same UID exists, returns the existing instance.
@@ -836,10 +1266,10 @@ export class FlowEngine {
836
1266
  * Hydrate a model into current engine from an already-existing model instance in previous engines.
837
1267
  * - Avoids repository requests when the model tree is already present in memory.
838
1268
  */
839
- private hydrateModelFromPreviousEngines<T extends FlowModel = FlowModel>(
1269
+ private async hydrateModelFromPreviousEngines<T extends FlowModel = FlowModel>(
840
1270
  options: any,
841
1271
  extra?: { delegateToParent?: boolean; delegate?: FlowContext },
842
- ): T | null {
1272
+ ): Promise<T | null> {
843
1273
  const uid = options?.uid;
844
1274
  const parentId = options?.parentId;
845
1275
  const subKey = options?.subKey;
@@ -853,7 +1283,7 @@ export class FlowEngine {
853
1283
  }
854
1284
  if (existing) {
855
1285
  const data = existing.serialize();
856
- return this.createModel<T>(data as any, extra);
1286
+ return this.createModelAsync<T>(data as any, extra);
857
1287
  }
858
1288
  }
859
1289
 
@@ -868,11 +1298,11 @@ export class FlowEngine {
868
1298
  if (!localParent) {
869
1299
  const parentData = parentFromPrev.serialize();
870
1300
  delete (parentData as any).subModels;
871
- localParent = this.createModel<FlowModel>(parentData as any, extra);
1301
+ localParent = await this.createModelAsync<FlowModel>(parentData as any, extra);
872
1302
  }
873
1303
  // Create (or reuse) the sub-model instance in current engine.
874
1304
  const modelData = modelFromPrev.serialize();
875
- const localModel = this.createModel<T>(modelData as any, extra);
1305
+ const localModel = await this.createModelAsync<T>(modelData as any, extra);
876
1306
 
877
1307
  // Mount under local parent if not mounted yet (so later lookups by parentId/subKey won't hit repo).
878
1308
  const mounted = (localParent.subModels as any)?.[subKey];
@@ -913,20 +1343,21 @@ export class FlowEngine {
913
1343
  if (model) {
914
1344
  return model as T;
915
1345
  }
916
- const hydrated = this.hydrateModelFromPreviousEngines<T>(options);
1346
+ const hydrated = await this.hydrateModelFromPreviousEngines<T>(options);
917
1347
  if (hydrated) {
918
1348
  return hydrated as T;
919
1349
  }
920
1350
  }
921
1351
  const data = await this._modelRepository.findOne(options);
922
1352
  if (!data?.uid) return null;
1353
+ await this.resolveModelTree(data);
923
1354
  if (refresh) {
924
1355
  const existing = this.getModel(data.uid);
925
1356
  if (existing) {
926
1357
  this.removeModelWithSubModels(existing.uid);
927
1358
  }
928
1359
  }
929
- return this.createModel<T>(data as any);
1360
+ return this.createModelAsync<T>(data as any);
930
1361
  }
931
1362
 
932
1363
  /**
@@ -959,6 +1390,7 @@ export class FlowEngine {
959
1390
  async loadOrCreateModel<T extends FlowModel = FlowModel>(
960
1391
  options,
961
1392
  extra?: {
1393
+ skipSave?: boolean;
962
1394
  delegateToParent?: boolean;
963
1395
  delegate?: FlowContext;
964
1396
  },
@@ -973,7 +1405,7 @@ export class FlowEngine {
973
1405
  return m;
974
1406
  }
975
1407
 
976
- const hydrated = this.hydrateModelFromPreviousEngines<T>(options, extra);
1408
+ const hydrated = await this.hydrateModelFromPreviousEngines<T>(options, extra);
977
1409
  if (hydrated) {
978
1410
  return hydrated;
979
1411
  }
@@ -981,10 +1413,12 @@ export class FlowEngine {
981
1413
  const data = await this._modelRepository.findOne(options);
982
1414
  let model: T | null = null;
983
1415
  if (data?.uid) {
984
- model = this.createModel<T>(data as any, extra);
1416
+ model = await this.createModelAsync<T>(data as any, extra);
985
1417
  } else {
986
- model = this.createModel<T>(options, extra);
987
- await model.save();
1418
+ model = await this.createModelAsync<T>(options, extra);
1419
+ if (!extra?.skipSave) {
1420
+ await model.save();
1421
+ }
988
1422
  }
989
1423
  if (model.parent) {
990
1424
  const subModel = model.parent.findSubModel(model.subKey, (m) => {
@@ -35,6 +35,7 @@ import {
35
35
  import { FlowExitAllException } from './utils/exceptions';
36
36
  import { FlowStepContext } from './hooks/useFlowStep';
37
37
  import { GLOBAL_EMBED_CONTAINER_ID, EMBED_REPLACING_DATA_KEY } from './views';
38
+ import { lazy } from './lazy-helper';
38
39
 
39
40
  const Panel = Collapse.Panel;
40
41
 
@@ -114,16 +115,23 @@ export interface FlowSettingsOpenOptions {
114
115
  onSaved?: () => void | Promise<void>;
115
116
  }
116
117
 
118
+ export type FlowSettingsComponent = React.ComponentType<any>;
119
+ export type FlowSettingsComponentModule = { default?: FlowSettingsComponent } | Record<string, FlowSettingsComponent>;
120
+ export type FlowSettingsComponentLoader = () => Promise<FlowSettingsComponentModule | FlowSettingsComponent>;
121
+ export type FlowSettingsComponentLoaderMap = Record<string, FlowSettingsComponentLoader>;
122
+
117
123
  export class FlowSettings {
118
124
  public components: Record<string, any> = {};
119
125
  public scopes: Record<string, any> = {};
120
126
  private antdComponentsLoaded = false;
121
127
  public enabled: boolean;
128
+ private engine: FlowEngine;
122
129
  #forceEnabled = false; // 强制启用状态,主要用于设计模式下的强制启用
123
130
  public toolbarItems: ToolbarItemConfig[] = [];
124
131
  #emitter: Emitter = new Emitter();
125
132
 
126
133
  constructor(engine: FlowEngine) {
134
+ this.engine = engine;
127
135
  // 初始默认为 false,由 SchemaComponentProvider 根据实际设计模式状态同步设置
128
136
  this.enabled = false;
129
137
  engine.context.defineProperty('flowSettingsEnabled', {
@@ -291,6 +299,30 @@ export class FlowSettings {
291
299
  });
292
300
  }
293
301
 
302
+ public registerComponentLoaders(loaders: FlowSettingsComponentLoaderMap): void {
303
+ Object.entries(loaders).forEach(([name, loader]) => {
304
+ if (this.components[name]) {
305
+ console.warn(`FlowSettings: Component with name '${name}' is already registered and will be overwritten.`);
306
+ }
307
+ this.components[name] = lazy(async () => {
308
+ const loaded = await loader();
309
+ if (typeof loaded === 'function') {
310
+ return { default: loaded };
311
+ }
312
+ if (loaded?.default && typeof loaded.default === 'function') {
313
+ return { default: loaded.default };
314
+ }
315
+ const namedComponent = loaded?.[name];
316
+ if (typeof namedComponent === 'function') {
317
+ return { default: namedComponent };
318
+ }
319
+ throw new Error(
320
+ `FlowSettings: component loader for '${name}' must resolve to a React component or a module exporting it.`,
321
+ );
322
+ });
323
+ });
324
+ }
325
+
294
326
  /**
295
327
  * 添加作用域到 FlowSettings 的作用域注册表中。
296
328
  * 这些作用域可以在 flow step 的 uiSchema 中使用。
@@ -311,13 +343,15 @@ export class FlowSettings {
311
343
  /**
312
344
  * 启用流程设置组件的显示
313
345
  * @example
314
- * flowSettings.enable();
346
+ * await flowSettings.enable();
315
347
  */
316
- public enable(): void {
348
+ public async enable(): Promise<void> {
349
+ await this.engine.preloadModelLoaders();
317
350
  this.enabled = true;
318
351
  }
319
352
 
320
- public forceEnable() {
353
+ public async forceEnable(): Promise<void> {
354
+ await this.engine.preloadModelLoaders();
321
355
  this.#forceEnabled = true;
322
356
  this.enabled = true;
323
357
  }
@@ -325,16 +359,16 @@ export class FlowSettings {
325
359
  /**
326
360
  * 禁用流程设置组件的显示
327
361
  * @example
328
- * flowSettings.disable();
362
+ * await flowSettings.disable();
329
363
  */
330
- public disable(): void {
364
+ public async disable(): Promise<void> {
331
365
  if (this.#forceEnabled) {
332
366
  return;
333
367
  }
334
368
  this.enabled = false;
335
369
  }
336
370
 
337
- public forceDisable() {
371
+ public async forceDisable(): Promise<void> {
338
372
  this.#forceEnabled = false;
339
373
  this.enabled = false;
340
374
  }