@nocobase/flow-engine 2.1.0-alpha.4 → 2.1.0-alpha.45

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 (209) hide show
  1. package/LICENSE +201 -661
  2. package/README.md +79 -10
  3. package/lib/FlowContextProvider.d.ts +5 -1
  4. package/lib/FlowContextProvider.js +9 -2
  5. package/lib/JSRunner.d.ts +10 -1
  6. package/lib/JSRunner.js +50 -5
  7. package/lib/ViewScopedFlowEngine.js +5 -1
  8. package/lib/components/FieldModelRenderer.js +2 -2
  9. package/lib/components/FlowModelRenderer.d.ts +3 -1
  10. package/lib/components/FlowModelRenderer.js +12 -6
  11. package/lib/components/FormItem.d.ts +6 -0
  12. package/lib/components/FormItem.js +11 -3
  13. package/lib/components/MobilePopup.js +6 -5
  14. package/lib/components/dnd/gridDragPlanner.d.ts +59 -2
  15. package/lib/components/dnd/gridDragPlanner.js +613 -21
  16. package/lib/components/dnd/index.d.ts +31 -2
  17. package/lib/components/dnd/index.js +244 -23
  18. package/lib/components/settings/wrappers/component/SelectWithTitle.d.ts +2 -1
  19. package/lib/components/settings/wrappers/component/SelectWithTitle.js +14 -12
  20. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  21. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +76 -11
  22. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +23 -43
  23. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +352 -295
  24. package/lib/components/settings/wrappers/contextual/StepSettingsDialog.js +16 -2
  25. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  26. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +274 -0
  27. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  28. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +315 -0
  29. package/lib/components/subModel/AddSubModelButton.js +27 -1
  30. package/lib/components/subModel/LazyDropdown.js +293 -52
  31. package/lib/components/subModel/index.d.ts +1 -0
  32. package/lib/components/subModel/index.js +19 -0
  33. package/lib/components/subModel/utils.d.ts +1 -1
  34. package/lib/components/subModel/utils.js +9 -3
  35. package/lib/components/variables/VariableHybridInput.d.ts +27 -0
  36. package/lib/components/variables/VariableHybridInput.js +499 -0
  37. package/lib/components/variables/index.d.ts +2 -0
  38. package/lib/components/variables/index.js +3 -0
  39. package/lib/data-source/index.d.ts +84 -0
  40. package/lib/data-source/index.js +259 -5
  41. package/lib/executor/FlowExecutor.js +32 -9
  42. package/lib/flow-registry/DetachedFlowRegistry.d.ts +21 -0
  43. package/lib/flow-registry/DetachedFlowRegistry.js +80 -0
  44. package/lib/flow-registry/index.d.ts +1 -0
  45. package/lib/flow-registry/index.js +3 -1
  46. package/lib/flowContext.d.ts +3 -0
  47. package/lib/flowContext.js +46 -1
  48. package/lib/flowEngine.d.ts +151 -1
  49. package/lib/flowEngine.js +392 -18
  50. package/lib/flowI18n.js +2 -1
  51. package/lib/flowSettings.d.ts +14 -6
  52. package/lib/flowSettings.js +34 -6
  53. package/lib/index.d.ts +2 -0
  54. package/lib/index.js +7 -0
  55. package/lib/lazy-helper.d.ts +14 -0
  56. package/lib/lazy-helper.js +71 -0
  57. package/lib/locale/en-US.json +1 -0
  58. package/lib/locale/index.d.ts +2 -0
  59. package/lib/locale/zh-CN.json +1 -0
  60. package/lib/models/DisplayItemModel.d.ts +1 -1
  61. package/lib/models/EditableItemModel.d.ts +1 -1
  62. package/lib/models/FilterableItemModel.d.ts +1 -1
  63. package/lib/models/flowModel.d.ts +13 -10
  64. package/lib/models/flowModel.js +81 -21
  65. package/lib/provider.js +38 -23
  66. package/lib/reactive/observer.js +46 -16
  67. package/lib/runjs-context/registry.d.ts +1 -1
  68. package/lib/runjs-context/setup.js +20 -12
  69. package/lib/runjs-context/snippets/index.js +13 -2
  70. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
  71. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
  72. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
  73. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
  74. package/lib/scheduler/ModelOperationScheduler.d.ts +5 -1
  75. package/lib/scheduler/ModelOperationScheduler.js +3 -2
  76. package/lib/types.d.ts +50 -2
  77. package/lib/types.js +1 -0
  78. package/lib/utils/createCollectionContextMeta.js +6 -2
  79. package/lib/utils/index.d.ts +3 -2
  80. package/lib/utils/index.js +7 -0
  81. package/lib/utils/parsePathnameToViewParams.d.ts +5 -1
  82. package/lib/utils/parsePathnameToViewParams.js +29 -5
  83. package/lib/utils/randomId.d.ts +39 -0
  84. package/lib/utils/randomId.js +45 -0
  85. package/lib/utils/runjsTemplateCompat.js +1 -1
  86. package/lib/utils/runjsValue.js +41 -11
  87. package/lib/utils/schema-utils.d.ts +7 -1
  88. package/lib/utils/schema-utils.js +19 -0
  89. package/lib/views/FlowView.d.ts +7 -1
  90. package/lib/views/FlowView.js +11 -1
  91. package/lib/views/PageComponent.js +8 -6
  92. package/lib/views/ViewNavigation.d.ts +12 -2
  93. package/lib/views/ViewNavigation.js +28 -9
  94. package/lib/views/createViewMeta.js +114 -50
  95. package/lib/views/inheritLayoutContext.d.ts +10 -0
  96. package/lib/views/inheritLayoutContext.js +50 -0
  97. package/lib/views/runViewBeforeClose.d.ts +10 -0
  98. package/lib/views/runViewBeforeClose.js +45 -0
  99. package/lib/views/useDialog.d.ts +2 -1
  100. package/lib/views/useDialog.js +22 -3
  101. package/lib/views/useDrawer.d.ts +2 -1
  102. package/lib/views/useDrawer.js +22 -3
  103. package/lib/views/usePage.d.ts +5 -11
  104. package/lib/views/usePage.js +304 -144
  105. package/package.json +6 -5
  106. package/src/FlowContextProvider.tsx +9 -1
  107. package/src/JSRunner.ts +68 -4
  108. package/src/ViewScopedFlowEngine.ts +4 -0
  109. package/src/__tests__/JSRunner.test.ts +27 -1
  110. package/src/__tests__/createViewMeta.popup.test.ts +115 -1
  111. package/src/__tests__/flow-engine.test.ts +166 -0
  112. package/src/__tests__/flowContext.test.ts +82 -1
  113. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  114. package/src/__tests__/flowEngine.removeModel.test.ts +47 -3
  115. package/src/__tests__/flowSettings.test.ts +94 -15
  116. package/src/__tests__/objectVariable.test.ts +24 -0
  117. package/src/__tests__/provider.test.tsx +24 -2
  118. package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
  119. package/src/__tests__/runjsContext.test.ts +16 -0
  120. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  121. package/src/__tests__/runjsPreprocessDefault.test.ts +23 -0
  122. package/src/__tests__/runjsSnippets.test.ts +21 -0
  123. package/src/__tests__/viewScopedFlowEngine.test.ts +3 -3
  124. package/src/components/FieldModelRenderer.tsx +2 -1
  125. package/src/components/FlowModelRenderer.tsx +18 -6
  126. package/src/components/FormItem.tsx +7 -1
  127. package/src/components/MobilePopup.tsx +4 -2
  128. package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
  129. package/src/components/__tests__/FormItem.test.tsx +25 -0
  130. package/src/components/__tests__/dnd.test.ts +44 -0
  131. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
  132. package/src/components/__tests__/gridDragPlanner.test.ts +558 -3
  133. package/src/components/dnd/__tests__/DndProvider.test.tsx +98 -0
  134. package/src/components/dnd/gridDragPlanner.ts +758 -19
  135. package/src/components/dnd/index.tsx +305 -28
  136. package/src/components/settings/wrappers/component/SelectWithTitle.tsx +21 -9
  137. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +99 -11
  138. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +487 -440
  139. package/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx +18 -2
  140. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +194 -5
  141. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +778 -0
  142. package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +360 -0
  143. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +361 -0
  144. package/src/components/subModel/AddSubModelButton.tsx +32 -2
  145. package/src/components/subModel/LazyDropdown.tsx +332 -56
  146. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +522 -37
  147. package/src/components/subModel/__tests__/utils.test.ts +24 -0
  148. package/src/components/subModel/index.ts +1 -0
  149. package/src/components/subModel/utils.ts +7 -1
  150. package/src/components/variables/VariableHybridInput.tsx +531 -0
  151. package/src/components/variables/index.ts +2 -0
  152. package/src/data-source/__tests__/collection.test.ts +41 -2
  153. package/src/data-source/__tests__/index.test.ts +68 -1
  154. package/src/data-source/index.ts +322 -6
  155. package/src/executor/FlowExecutor.ts +35 -10
  156. package/src/executor/__tests__/flowExecutor.test.ts +85 -0
  157. package/src/flow-registry/DetachedFlowRegistry.ts +46 -0
  158. package/src/flow-registry/__tests__/detachedFlowRegistry.test.ts +47 -0
  159. package/src/flow-registry/index.ts +1 -0
  160. package/src/flowContext.ts +50 -3
  161. package/src/flowEngine.ts +449 -14
  162. package/src/flowI18n.ts +2 -1
  163. package/src/flowSettings.ts +40 -6
  164. package/src/index.ts +2 -0
  165. package/src/lazy-helper.tsx +57 -0
  166. package/src/locale/en-US.json +1 -0
  167. package/src/locale/zh-CN.json +1 -0
  168. package/src/models/DisplayItemModel.tsx +1 -1
  169. package/src/models/EditableItemModel.tsx +1 -1
  170. package/src/models/FilterableItemModel.tsx +1 -1
  171. package/src/models/__tests__/dispatchEvent.when.test.ts +214 -0
  172. package/src/models/__tests__/flowEngine.resolveUse.test.ts +0 -15
  173. package/src/models/__tests__/flowModel.test.ts +80 -37
  174. package/src/models/flowModel.tsx +122 -36
  175. package/src/provider.tsx +41 -25
  176. package/src/reactive/__tests__/observer.test.tsx +82 -0
  177. package/src/reactive/observer.tsx +87 -25
  178. package/src/runjs-context/registry.ts +1 -1
  179. package/src/runjs-context/setup.ts +22 -12
  180. package/src/runjs-context/snippets/index.ts +12 -1
  181. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
  182. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
  183. package/src/scheduler/ModelOperationScheduler.ts +14 -3
  184. package/src/types.ts +62 -0
  185. package/src/utils/__tests__/createCollectionContextMeta.test.ts +48 -0
  186. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +28 -0
  187. package/src/utils/__tests__/runjsValue.test.ts +11 -0
  188. package/src/utils/__tests__/utils.test.ts +62 -0
  189. package/src/utils/createCollectionContextMeta.ts +6 -2
  190. package/src/utils/index.ts +5 -1
  191. package/src/utils/parsePathnameToViewParams.ts +47 -7
  192. package/src/utils/randomId.ts +48 -0
  193. package/src/utils/runjsTemplateCompat.ts +1 -1
  194. package/src/utils/runjsValue.ts +50 -11
  195. package/src/utils/schema-utils.ts +30 -1
  196. package/src/views/FlowView.tsx +22 -2
  197. package/src/views/PageComponent.tsx +7 -4
  198. package/src/views/ViewNavigation.ts +46 -9
  199. package/src/views/__tests__/FlowView.usePage.test.tsx +243 -3
  200. package/src/views/__tests__/ViewNavigation.test.ts +52 -0
  201. package/src/views/__tests__/inheritLayoutContext.test.ts +53 -0
  202. package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
  203. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +13 -12
  204. package/src/views/createViewMeta.ts +106 -34
  205. package/src/views/inheritLayoutContext.ts +26 -0
  206. package/src/views/runViewBeforeClose.ts +19 -0
  207. package/src/views/useDialog.tsx +27 -3
  208. package/src/views/useDrawer.tsx +27 -3
  209. package/src/views/usePage.tsx +367 -179
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,
@@ -35,6 +39,8 @@ import type {
35
39
  } from './types';
36
40
  import { isInheritedFrom } from './utils';
37
41
 
42
+ const getFlowEngineLoggerLevel = () => (process.env.NODE_ENV === 'production' ? 'warn' : 'trace');
43
+
38
44
  /**
39
45
  * FlowEngine is the core class of the flow engine, responsible for managing flow models, actions, model repository, and more.
40
46
  * It provides capabilities for registering, creating, finding, persisting, replacing, and moving models.
@@ -75,6 +81,32 @@ export class FlowEngine {
75
81
  */
76
82
  private _modelClasses: Map<string, ModelConstructor> = observable.shallow(new Map());
77
83
 
84
+ /**
85
+ * Registered model entries.
86
+ * Key is the model class name, value is the model loader entry.
87
+ * @private
88
+ */
89
+ private _modelLoaders: Map<string, FlowModelLoaderEntry> = new Map();
90
+
91
+ /**
92
+ * In-flight model loading promises.
93
+ * Key is the model class name, value is the loading promise.
94
+ * @private
95
+ */
96
+ private _loadingModelPromises: Map<string, Promise<ModelConstructor | null>> = new Map();
97
+
98
+ /**
99
+ * Whether model-loader preload has completed in this session.
100
+ * @private
101
+ */
102
+ private _modelLoadersPreloaded = false;
103
+
104
+ /**
105
+ * In-flight model-loader preload promise.
106
+ * @private
107
+ */
108
+ private _modelLoadersPreloadPromise?: Promise<EnsureBatchResult>;
109
+
78
110
  /**
79
111
  * Created model instances.
80
112
  * Key is the model instance UID, value is the model instance object.
@@ -117,6 +149,13 @@ export class FlowEngine {
117
149
  private _previousEngine?: FlowEngine;
118
150
  private _nextEngine?: FlowEngine;
119
151
 
152
+ /**
153
+ * 视图销毁回调。由 useDrawer / useDialog 在创建弹窗视图时注册,
154
+ * 供外部(如 afterSuccess)通过引擎栈遍历来关闭多层弹窗。
155
+ * embed 视图(usePage)不注册此回调,因此 destroyView() 会自然跳过。
156
+ */
157
+ private _destroyView?: () => void;
158
+
120
159
  private _resources = new Map<string, typeof FlowResource>();
121
160
 
122
161
  /**
@@ -176,7 +215,7 @@ export class FlowEngine {
176
215
  MultiRecordResource,
177
216
  });
178
217
  this.logger = pino({
179
- level: 'trace',
218
+ level: getFlowEngineLoggerLevel(),
180
219
  browser: {
181
220
  write: {
182
221
  fatal: (o) => console.trace(o),
@@ -282,6 +321,28 @@ export class FlowEngine {
282
321
  }
283
322
  }
284
323
 
324
+ /**
325
+ * 注册视图销毁回调(由 useDrawer / useDialog 调用)。
326
+ */
327
+ public setDestroyView(fn: () => void): void {
328
+ this._destroyView = fn;
329
+ }
330
+
331
+ /**
332
+ * 关闭当前引擎关联的弹窗视图。
333
+ * 路由触发的弹窗会先 navigation.back() 清理 URL,再 destroy() 移除元素;
334
+ * 非路由弹窗直接 destroy()。
335
+ * embed 视图不注册回调,调用时返回 false 自动跳过。
336
+ * @returns 是否成功执行
337
+ */
338
+ public destroyView(): boolean {
339
+ if (this._destroyView) {
340
+ this._destroyView();
341
+ return true;
342
+ }
343
+ return false;
344
+ }
345
+
285
346
  // (已移除)getModelGlobal/forEachModelGlobal/getAllModelsGlobal:不再维护冗余全局遍历 API
286
347
 
287
348
  /**
@@ -395,6 +456,13 @@ export class FlowEngine {
395
456
  * @private
396
457
  */
397
458
  #registerModel(name: string, modelClass: ModelConstructor): void {
459
+ return this._registerModel(name, modelClass);
460
+ }
461
+
462
+ /**
463
+ * for proxy instance, the #registerModel can't be called.
464
+ */
465
+ private _registerModel(name: string, modelClass: ModelConstructor): void {
398
466
  if (this._modelClasses.has(name)) {
399
467
  console.warn(`FlowEngine: Model class with name '${name}' is already registered and will be overwritten.`);
400
468
  }
@@ -415,6 +483,306 @@ export class FlowEngine {
415
483
  }
416
484
  }
417
485
 
486
+ /**
487
+ * Register multiple model loader entries.
488
+ * The `extends` field declares parent class(es) for async subclass discovery via `getSubclassesOfAsync`.
489
+ * It accepts `string | ModelConstructor | (string | ModelConstructor)[]` and is normalized to `string[]` internally.
490
+ * @param {FlowModelLoaderInputMap} loaders Model loader input map
491
+ * @returns {void}
492
+ * @example
493
+ * flowEngine.registerModelLoaders({
494
+ * DemoModel: {
495
+ * extends: 'BaseModel',
496
+ * loader: () => import('./models/DemoModel'),
497
+ * },
498
+ * });
499
+ */
500
+ public registerModelLoaders(loaders: FlowModelLoaderInputMap): void {
501
+ let changed = false;
502
+ for (const [name, input] of Object.entries(loaders)) {
503
+ if (this._modelLoaders.has(name)) {
504
+ console.warn(`FlowEngine: Model loader with name '${name}' is already registered and will be overwritten.`);
505
+ }
506
+ const entry: FlowModelLoaderEntry = {
507
+ loader: input.loader,
508
+ };
509
+ if (input.extends != null) {
510
+ const raw = Array.isArray(input.extends) ? input.extends : [input.extends];
511
+ entry.extends = raw.map((item) => (typeof item === 'string' ? item : item.name));
512
+ }
513
+ this._modelLoaders.set(name, entry);
514
+ changed = true;
515
+ }
516
+ if (changed) {
517
+ this._modelLoadersPreloaded = false;
518
+ this._modelLoadersPreloadPromise = undefined;
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Get a registered model class (constructor) asynchronously.
524
+ * This will first ensure the model loader entry is resolved.
525
+ * @param {string} name Model class name
526
+ * @returns {Promise<ModelConstructor | undefined>} Model constructor, or undefined if not found
527
+ */
528
+ public async getModelClassAsync(name: string): Promise<ModelConstructor | undefined> {
529
+ await this.ensureModel(name);
530
+ return this.getModelClass(name);
531
+ }
532
+
533
+ /**
534
+ * Get all registered model classes asynchronously.
535
+ * This will first ensure all registered model loader entries are resolved.
536
+ * @returns {Promise<Map<string, ModelConstructor>>} Model class map
537
+ */
538
+ public async getModelClassesAsync(): Promise<Map<string, ModelConstructor>> {
539
+ await this.ensureModels(Array.from(this._modelLoaders.keys()));
540
+ return this.getModelClasses();
541
+ }
542
+
543
+ /**
544
+ * Create and register a model instance asynchronously.
545
+ * This will first ensure all string-based model references in the model tree are resolved.
546
+ * @template T FlowModel subclass type, defaults to FlowModel.
547
+ * @param {CreateModelOptions} options Model creation options
548
+ * @returns {Promise<T>} Created model instance
549
+ */
550
+ public async createModelAsync<T extends FlowModel = FlowModel>(
551
+ options: CreateModelOptions,
552
+ extra?: { delegateToParent?: boolean; delegate?: FlowContext },
553
+ ): Promise<T> {
554
+ await this.resolveModelTree(options);
555
+ return this.createModel<T>(options, extra);
556
+ }
557
+
558
+ /**
559
+ * Normalize a loader result into a model constructor.
560
+ * @param {string} name Model class name
561
+ * @param {FlowModelLoaderResult} loaded Loader result
562
+ * @returns {ModelConstructor | null} Normalized model constructor
563
+ * @private
564
+ */
565
+ private normalizeModelLoaderResult(name: string, loaded: FlowModelLoaderResult): ModelConstructor | null {
566
+ if (typeof loaded === 'function') {
567
+ return loaded as ModelConstructor;
568
+ }
569
+ if (loaded && typeof loaded === 'object') {
570
+ const defaultExport = loaded.default;
571
+ if (typeof defaultExport === 'function') {
572
+ return defaultExport as ModelConstructor;
573
+ }
574
+ const namedExport = loaded[name];
575
+ if (typeof namedExport === 'function') {
576
+ return namedExport as ModelConstructor;
577
+ }
578
+ }
579
+ console.warn(`FlowEngine: model loader for '${name}' did not resolve to a valid model constructor.`);
580
+ return null;
581
+ }
582
+
583
+ /**
584
+ * Collect string-based model names from a model tree.
585
+ * @param {unknown} data Model tree data
586
+ * @param {Set<string>} names Model name set
587
+ * @private
588
+ */
589
+ private collectModelNamesFromTree(data: unknown, names: Set<string>): void {
590
+ if (!data || typeof data !== 'object') {
591
+ return;
592
+ }
593
+ if (Array.isArray(data)) {
594
+ data.forEach((item) => this.collectModelNamesFromTree(item, names));
595
+ return;
596
+ }
597
+
598
+ const tree = data as Record<string, any>;
599
+ if (typeof tree.use === 'string') {
600
+ names.add(tree.use);
601
+ }
602
+
603
+ const subModels = tree.subModels;
604
+ if (!subModels || typeof subModels !== 'object') {
605
+ return;
606
+ }
607
+
608
+ Object.values(subModels).forEach((value) => {
609
+ this.collectModelNamesFromTree(value, names);
610
+ });
611
+ }
612
+
613
+ /**
614
+ * Collect additional model names from object-form meta.createModelOptions defaults.
615
+ * @param {ModelConstructor} modelClass Model class constructor
616
+ * @param {Set<string>} names Model name set
617
+ * @private
618
+ */
619
+ private collectModelNamesFromMetaDefaults(modelClass: ModelConstructor, names: Set<string>): void {
620
+ const metaCreate = (modelClass as typeof FlowModel).meta?.createModelOptions;
621
+ if (metaCreate && typeof metaCreate === 'object') {
622
+ this.collectModelNamesFromTree(metaCreate, names);
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Ensure a single model class is available.
628
+ * @param {string} name Model class name
629
+ * @returns {Promise<ModelConstructor | null>} Model constructor or null when resolution fails
630
+ * @private
631
+ */
632
+ private async ensureModel(name: string): Promise<ModelConstructor | null> {
633
+ const existing = this._modelClasses.get(name);
634
+ if (existing) {
635
+ return existing;
636
+ }
637
+
638
+ const inflight = this._loadingModelPromises.get(name);
639
+ if (inflight) {
640
+ return inflight;
641
+ }
642
+
643
+ const entry = this._modelLoaders.get(name);
644
+ if (!entry) {
645
+ console.warn(`FlowEngine: Model entry '${name}' not found. Falling back to ErrorFlowModel when needed.`);
646
+ return null;
647
+ }
648
+
649
+ const promise = (async () => {
650
+ try {
651
+ const loaded = await entry.loader();
652
+ const modelClass = this.normalizeModelLoaderResult(name, loaded);
653
+ if (!modelClass) {
654
+ return null;
655
+ }
656
+ // 这里拿到的 this 是 Proxy(FlowEngine) 而不是原始的 FlowEngine,无法直接调用 #registerModel
657
+ this._registerModel(name, modelClass);
658
+ return modelClass;
659
+ } catch (error) {
660
+ console.warn(`FlowEngine: Failed to load model '${name}'. Falling back to ErrorFlowModel when needed.`, error);
661
+ return null;
662
+ } finally {
663
+ this._loadingModelPromises.delete(name);
664
+ }
665
+ })();
666
+
667
+ this._loadingModelPromises.set(name, promise);
668
+ return promise;
669
+ }
670
+
671
+ /**
672
+ * Ensure multiple model classes are available.
673
+ * @param {string[]} names Model class names
674
+ * @returns {Promise<EnsureBatchResult>} Batch ensure result
675
+ * @private
676
+ */
677
+ private async ensureModels(names: string[]): Promise<EnsureBatchResult> {
678
+ const requested = Array.from(new Set(names.filter((name): name is string => !!name)));
679
+ const loaded: string[] = [];
680
+ const failed: EnsureBatchResult['failed'] = [];
681
+
682
+ const results = await Promise.all(
683
+ requested.map(async (name) => {
684
+ const modelClass = await this.ensureModel(name);
685
+ return { name, modelClass };
686
+ }),
687
+ );
688
+
689
+ results.forEach(({ name, modelClass }) => {
690
+ if (modelClass) {
691
+ loaded.push(name);
692
+ } else {
693
+ failed.push({ name });
694
+ }
695
+ });
696
+
697
+ return { requested, loaded, failed };
698
+ }
699
+
700
+ /**
701
+ * Resolve all unresolved string-based model references in a model tree before synchronous creation begins.
702
+ *
703
+ * Use this when you already have a model tree object, such as repository-returned data or resolved
704
+ * `createModelOptions`, and you need to ensure every string `use` in that tree has been loaded and
705
+ * registered into `_modelClasses` before calling `createModel()`.
706
+ *
707
+ * @param {unknown} data Model tree data
708
+ * @returns {Promise<EnsureBatchResult>} Batch ensure result
709
+ */
710
+ public async resolveModelTree(data: unknown): Promise<EnsureBatchResult> {
711
+ const requested = new Set<string>();
712
+ const loaded = new Set<string>();
713
+ const failed = new Map<string, { name: string; error?: unknown }>();
714
+ const processed = new Set<string>();
715
+ const pending = new Set<string>();
716
+
717
+ this.collectModelNamesFromTree(data, pending);
718
+
719
+ while (pending.size > 0) {
720
+ const batch = Array.from(pending).filter((name) => !processed.has(name));
721
+ pending.clear();
722
+ if (batch.length === 0) {
723
+ break;
724
+ }
725
+
726
+ batch.forEach((name) => requested.add(name));
727
+ const result = await this.ensureModels(batch);
728
+
729
+ result.loaded.forEach((name) => {
730
+ processed.add(name);
731
+ loaded.add(name);
732
+ const modelClass = this.getModelClass(name);
733
+ if (modelClass) {
734
+ const discovered = new Set<string>();
735
+ this.collectModelNamesFromMetaDefaults(modelClass, discovered);
736
+ discovered.forEach((discoveredName) => {
737
+ if (!processed.has(discoveredName)) {
738
+ pending.add(discoveredName);
739
+ }
740
+ });
741
+ }
742
+ });
743
+
744
+ result.failed.forEach((item) => {
745
+ processed.add(item.name);
746
+ failed.set(item.name, item);
747
+ });
748
+ }
749
+
750
+ return {
751
+ requested: Array.from(requested),
752
+ loaded: Array.from(loaded),
753
+ failed: Array.from(failed.values()),
754
+ };
755
+ }
756
+
757
+ /**
758
+ * Preload all currently registered unresolved model loaders.
759
+ *
760
+ * This method is intended for flow-settings/discovery style entry points that need registered model
761
+ * classes to exist before UI is rendered, without requiring callers to know which specific models
762
+ * will be touched next.
763
+ *
764
+ * @returns {Promise<EnsureBatchResult>} Batch ensure result
765
+ */
766
+ public async preloadModelLoaders(): Promise<EnsureBatchResult> {
767
+ const unresolved = Array.from(this._modelLoaders.keys()).filter((name) => !this._modelClasses.has(name));
768
+ if (unresolved.length === 0) {
769
+ this._modelLoadersPreloaded = true;
770
+ return { requested: [], loaded: [], failed: [] };
771
+ }
772
+ if (this._modelLoadersPreloadPromise) {
773
+ return this._modelLoadersPreloadPromise;
774
+ }
775
+
776
+ this._modelLoadersPreloadPromise = (async () => {
777
+ const result = await this.ensureModels(unresolved);
778
+ this._modelLoadersPreloaded = result.failed.length === 0;
779
+ this._modelLoadersPreloadPromise = undefined;
780
+ return result;
781
+ })();
782
+
783
+ return this._modelLoadersPreloadPromise;
784
+ }
785
+
418
786
  registerResources(resources: Record<string, any>) {
419
787
  for (const [name, resourceClass] of Object.entries(resources)) {
420
788
  this._resources.set(name, resourceClass);
@@ -489,6 +857,70 @@ export class FlowEngine {
489
857
  return result;
490
858
  }
491
859
 
860
+ /**
861
+ * Asynchronously get all subclasses of a base class, including those registered via model loaders.
862
+ * Merges results from already-loaded classes (_modelClasses) and async loader entries with matching `extends` declarations.
863
+ * Loader-resolved classes are validated with `isInheritedFrom`; mismatches are warned and excluded.
864
+ * @param {string | ModelConstructor} baseClass Base class name or constructor
865
+ * @param {(ModelClass: ModelConstructor, className: string) => boolean} [filter] Optional filter function
866
+ * @returns {Promise<Map<string, ModelConstructor>>} Model classes that are subclasses of the base class
867
+ */
868
+ public async getSubclassesOfAsync(
869
+ baseClass: string | ModelConstructor,
870
+ filter?: (ModelClass: ModelConstructor, className: string) => boolean,
871
+ ): Promise<Map<string, ModelConstructor>> {
872
+ const baseClassName = typeof baseClass === 'string' ? baseClass : baseClass.name;
873
+
874
+ // If baseClass is a string and not yet loaded, try to resolve it first
875
+ let parentModelClass: ModelConstructor | undefined;
876
+ if (typeof baseClass === 'string') {
877
+ if (!this.getModelClass(baseClass)) {
878
+ await this.ensureModel(baseClass);
879
+ }
880
+ parentModelClass = this.getModelClass(baseClass);
881
+ } else {
882
+ parentModelClass = baseClass;
883
+ }
884
+
885
+ if (!parentModelClass) {
886
+ return new Map();
887
+ }
888
+
889
+ // Step 1: Collect already-loaded subclasses from _modelClasses
890
+ const result = this.getSubclassesOf(parentModelClass, filter);
891
+
892
+ // Step 2: Find unloaded loaders whose extends includes baseClassName
893
+ const loaderCandidates: string[] = [];
894
+ for (const [name, entry] of this._modelLoaders) {
895
+ if (result.has(name) || this._modelClasses.has(name)) continue;
896
+ if (entry.extends?.includes(baseClassName)) {
897
+ loaderCandidates.push(name);
898
+ }
899
+ }
900
+
901
+ // Step 3: Resolve all matching loaders
902
+ if (loaderCandidates.length > 0) {
903
+ await this.ensureModels(loaderCandidates);
904
+ }
905
+
906
+ // Step 4: Validate resolved classes and add to result
907
+ for (const name of loaderCandidates) {
908
+ const ModelClass = this._modelClasses.get(name);
909
+ if (!ModelClass) continue;
910
+ if (!isInheritedFrom(ModelClass, parentModelClass)) {
911
+ console.warn(
912
+ `FlowEngine: Model '${name}' declares extends '${baseClassName}' but does not actually inherit from it. Skipping.`,
913
+ );
914
+ continue;
915
+ }
916
+ if (!filter || filter(ModelClass, name)) {
917
+ result.set(name, ModelClass);
918
+ }
919
+ }
920
+
921
+ return result;
922
+ }
923
+
492
924
  /**
493
925
  * Create and register a model instance.
494
926
  * If an instance with the same UID exists, returns the existing instance.
@@ -579,7 +1011,6 @@ export class FlowEngine {
579
1011
 
580
1012
  while (current) {
581
1013
  if (visited.has(current)) {
582
- console.warn(`FlowEngine: resolveUse circular reference detected on '${current.name}'.`);
583
1014
  break;
584
1015
  }
585
1016
  visited.add(current);
@@ -698,7 +1129,7 @@ export class FlowEngine {
698
1129
  */
699
1130
  public removeModel(uid: string): boolean {
700
1131
  if (!this._modelInstances.has(uid)) {
701
- console.warn(`FlowEngine: Model with UID '${uid}' does not exist.`);
1132
+ this.logger.debug(`FlowEngine: Model with UID '${uid}' does not exist.`);
702
1133
  return false;
703
1134
  }
704
1135
  const modelInstance = this._modelInstances.get(uid) as FlowModel;
@@ -836,10 +1267,10 @@ export class FlowEngine {
836
1267
  * Hydrate a model into current engine from an already-existing model instance in previous engines.
837
1268
  * - Avoids repository requests when the model tree is already present in memory.
838
1269
  */
839
- private hydrateModelFromPreviousEngines<T extends FlowModel = FlowModel>(
1270
+ private async hydrateModelFromPreviousEngines<T extends FlowModel = FlowModel>(
840
1271
  options: any,
841
1272
  extra?: { delegateToParent?: boolean; delegate?: FlowContext },
842
- ): T | null {
1273
+ ): Promise<T | null> {
843
1274
  const uid = options?.uid;
844
1275
  const parentId = options?.parentId;
845
1276
  const subKey = options?.subKey;
@@ -853,7 +1284,7 @@ export class FlowEngine {
853
1284
  }
854
1285
  if (existing) {
855
1286
  const data = existing.serialize();
856
- return this.createModel<T>(data as any, extra);
1287
+ return this.createModelAsync<T>(data as any, extra);
857
1288
  }
858
1289
  }
859
1290
 
@@ -868,11 +1299,11 @@ export class FlowEngine {
868
1299
  if (!localParent) {
869
1300
  const parentData = parentFromPrev.serialize();
870
1301
  delete (parentData as any).subModels;
871
- localParent = this.createModel<FlowModel>(parentData as any, extra);
1302
+ localParent = await this.createModelAsync<FlowModel>(parentData as any, extra);
872
1303
  }
873
1304
  // Create (or reuse) the sub-model instance in current engine.
874
1305
  const modelData = modelFromPrev.serialize();
875
- const localModel = this.createModel<T>(modelData as any, extra);
1306
+ const localModel = await this.createModelAsync<T>(modelData as any, extra);
876
1307
 
877
1308
  // Mount under local parent if not mounted yet (so later lookups by parentId/subKey won't hit repo).
878
1309
  const mounted = (localParent.subModels as any)?.[subKey];
@@ -913,20 +1344,21 @@ export class FlowEngine {
913
1344
  if (model) {
914
1345
  return model as T;
915
1346
  }
916
- const hydrated = this.hydrateModelFromPreviousEngines<T>(options);
1347
+ const hydrated = await this.hydrateModelFromPreviousEngines<T>(options);
917
1348
  if (hydrated) {
918
1349
  return hydrated as T;
919
1350
  }
920
1351
  }
921
1352
  const data = await this._modelRepository.findOne(options);
922
1353
  if (!data?.uid) return null;
1354
+ await this.resolveModelTree(data);
923
1355
  if (refresh) {
924
1356
  const existing = this.getModel(data.uid);
925
1357
  if (existing) {
926
1358
  this.removeModelWithSubModels(existing.uid);
927
1359
  }
928
1360
  }
929
- return this.createModel<T>(data as any);
1361
+ return this.createModelAsync<T>(data as any);
930
1362
  }
931
1363
 
932
1364
  /**
@@ -959,6 +1391,7 @@ export class FlowEngine {
959
1391
  async loadOrCreateModel<T extends FlowModel = FlowModel>(
960
1392
  options,
961
1393
  extra?: {
1394
+ skipSave?: boolean;
962
1395
  delegateToParent?: boolean;
963
1396
  delegate?: FlowContext;
964
1397
  },
@@ -973,7 +1406,7 @@ export class FlowEngine {
973
1406
  return m;
974
1407
  }
975
1408
 
976
- const hydrated = this.hydrateModelFromPreviousEngines<T>(options, extra);
1409
+ const hydrated = await this.hydrateModelFromPreviousEngines<T>(options, extra);
977
1410
  if (hydrated) {
978
1411
  return hydrated;
979
1412
  }
@@ -981,10 +1414,12 @@ export class FlowEngine {
981
1414
  const data = await this._modelRepository.findOne(options);
982
1415
  let model: T | null = null;
983
1416
  if (data?.uid) {
984
- model = this.createModel<T>(data as any, extra);
1417
+ model = await this.createModelAsync<T>(data as any, extra);
985
1418
  } else {
986
- model = this.createModel<T>(options, extra);
987
- await model.save();
1419
+ model = await this.createModelAsync<T>(options, extra);
1420
+ if (!extra?.skipSave) {
1421
+ await model.save();
1422
+ }
988
1423
  }
989
1424
  if (model.parent) {
990
1425
  const subModel = model.parent.findSubModel(model.subKey, (m) => {
package/src/flowI18n.ts CHANGED
@@ -52,7 +52,8 @@ export class FlowI18n {
52
52
  */
53
53
  private translateKey(key: string, options?: any): string {
54
54
  if (this.context?.i18n?.t) {
55
- return this.context.i18n.t(key, options);
55
+ const translated = this.context.i18n.t(key, options);
56
+ return translated == null || translated === '' ? key : translated;
56
57
  }
57
58
  // 如果没有翻译函数,返回原始键值
58
59
  return key;