@nocobase/plugin-ui-templates 2.0.1 → 2.0.3

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 (53) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +99 -0
  3. package/dist/externalVersion.js +7 -7
  4. package/package.json +3 -3
  5. package/LICENSE.txt +0 -172
  6. package/src/client/__tests__/openViewActionExtensions.test.ts +0 -1208
  7. package/src/client/collections/flowModelTemplates.ts +0 -131
  8. package/src/client/components/FlowModelTemplatesPage.tsx +0 -78
  9. package/src/client/components/TemplateSelectOption.tsx +0 -106
  10. package/src/client/constants.ts +0 -10
  11. package/src/client/hooks/useFlowModelTemplateActions.tsx +0 -137
  12. package/src/client/index.ts +0 -52
  13. package/src/client/locale.ts +0 -40
  14. package/src/client/menuExtensions.tsx +0 -1091
  15. package/src/client/models/ReferenceBlockModel.tsx +0 -841
  16. package/src/client/models/ReferenceFormGridModel.tsx +0 -448
  17. package/src/client/models/SubModelTemplateImporterModel.tsx +0 -725
  18. package/src/client/models/__tests__/ReferenceBlockModel.test.tsx +0 -547
  19. package/src/client/models/__tests__/ReferenceFormGridModel.test.tsx +0 -965
  20. package/src/client/models/__tests__/SubModelTemplateImporterModel.test.ts +0 -529
  21. package/src/client/models/referenceShared.tsx +0 -107
  22. package/src/client/openViewActionExtensions.tsx +0 -986
  23. package/src/client/schemas/flowModelTemplates.ts +0 -264
  24. package/src/client/utils/__tests__/templateCopy.test.ts +0 -67
  25. package/src/client/utils/infiniteSelect.ts +0 -150
  26. package/src/client/utils/templateCompatibility.ts +0 -374
  27. package/src/client/utils/templateCopy.ts +0 -59
  28. package/src/client.ts +0 -10
  29. package/src/index.ts +0 -11
  30. package/src/locale/de-DE.json +0 -14
  31. package/src/locale/en-US.json +0 -72
  32. package/src/locale/es-ES.json +0 -14
  33. package/src/locale/fr-FR.json +0 -14
  34. package/src/locale/hu-HU.json +0 -14
  35. package/src/locale/id-ID.json +0 -14
  36. package/src/locale/it-IT.json +0 -14
  37. package/src/locale/ja-JP.json +0 -14
  38. package/src/locale/ko-KR.json +0 -14
  39. package/src/locale/nl-NL.json +0 -14
  40. package/src/locale/pt-BR.json +0 -14
  41. package/src/locale/ru-RU.json +0 -14
  42. package/src/locale/tr-TR.json +0 -14
  43. package/src/locale/uk-UA.json +0 -14
  44. package/src/locale/vi-VN.json +0 -14
  45. package/src/locale/zh-CN.json +0 -71
  46. package/src/locale/zh-TW.json +0 -14
  47. package/src/server/__tests__/template-usage.test.ts +0 -351
  48. package/src/server/collections/flowModelTemplateUsages.ts +0 -51
  49. package/src/server/collections/flowModelTemplates.ts +0 -76
  50. package/src/server/index.ts +0 -10
  51. package/src/server/plugin.ts +0 -236
  52. package/src/server/resources/flowModelTemplateUsages.ts +0 -61
  53. package/src/server/resources/flowModelTemplates.ts +0 -251
@@ -1,725 +0,0 @@
1
- /**
2
- * This file is part of the NocoBase (R) project.
3
- * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
- * Authors: NocoBase Team.
5
- *
6
- * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
- * For more information, please refer to: https://www.nocobase.com/agreement.
8
- */
9
-
10
- import React from 'react';
11
- import { Button, Space } from 'antd';
12
- import { BlockModel, CommonItemModel, FilterFormBlockModel, FormBlockModel } from '@nocobase/client';
13
- import {
14
- FlowModel,
15
- FlowContext,
16
- type FlowModelContext,
17
- createBlockScopedEngine,
18
- FlowExitException,
19
- isInheritedFrom,
20
- type ModelConstructor,
21
- tExpr,
22
- } from '@nocobase/flow-engine';
23
- import { NAMESPACE, tStr } from '../locale';
24
- import { renderTemplateSelectLabel, renderTemplateSelectOption } from '../components/TemplateSelectOption';
25
- import {
26
- TEMPLATE_LIST_PAGE_SIZE,
27
- calcHasMore,
28
- getTemplateAvailabilityDisabledReason,
29
- parseResourceListResponse,
30
- resolveExpectedResourceInfoByModelChain,
31
- } from '../utils/templateCompatibility';
32
- import { bindInfiniteScrollToFormilySelect, defaultSelectOptionComparator } from '../utils/infiniteSelect';
33
- import { patchGridOptionsFromTemplateRoot } from '../utils/templateCopy';
34
- import { REF_HOST_CTX_KEY } from '../constants';
35
-
36
- type ImporterProps = {
37
- expectedRootUse?: string | string[];
38
- expectedDataSourceKey?: string;
39
- expectedCollectionName?: string;
40
- };
41
-
42
- const FLOW_KEY = 'subModelTemplateImportSettings';
43
- const GRID_REF_FLOW_KEY = 'referenceSettings';
44
- const GRID_REF_STEP_KEY = 'useTemplate';
45
-
46
- /** 最大递归深度,防止循环引用或过深嵌套 */
47
- const MAX_NORMALIZE_DEPTH = 20;
48
-
49
- type NormalizeSubModelTemplateImportNode = (
50
- node: Record<string, unknown>,
51
- ctx: { mountTarget: FlowModel; engine: FlowModel['flowEngine'] },
52
- ) => Record<string, unknown> | undefined;
53
-
54
- interface ModelWithGetModelClassName {
55
- getModelClassName?: (name: string) => string | undefined;
56
- }
57
-
58
- function resolveMappedFormItemUse(mountTarget: FlowModel): string | undefined {
59
- const sources: Array<ModelWithGetModelClassName | undefined> = [
60
- mountTarget as FlowModel & ModelWithGetModelClassName,
61
- mountTarget.context as ModelWithGetModelClassName | undefined,
62
- ];
63
- for (const source of sources) {
64
- if (typeof source?.getModelClassName === 'function') {
65
- const mapped = source.getModelClassName('FormItemModel');
66
- if (mapped) return mapped;
67
- }
68
- }
69
- return undefined;
70
- }
71
-
72
- interface NormalizeOptions {
73
- normalizeMappedItem?: NormalizeSubModelTemplateImportNode;
74
- mountTarget?: FlowModel;
75
- }
76
-
77
- function normalizeGridModelOptionsForMappedFormItemUse(
78
- input: unknown,
79
- mappedFormItemUse: string,
80
- options?: NormalizeOptions,
81
- depth = 0,
82
- ) {
83
- if (depth > MAX_NORMALIZE_DEPTH) {
84
- return;
85
- }
86
-
87
- if (Array.isArray(input)) {
88
- return input.map((n) => normalizeGridModelOptionsForMappedFormItemUse(n, mappedFormItemUse, options, depth + 1));
89
- }
90
- if (!input || typeof input !== 'object') {
91
- return input;
92
- }
93
-
94
- const source = input as Record<string, unknown>;
95
- const next: Record<string, unknown> = { ...source };
96
-
97
- if (next.use === 'FormItemModel') {
98
- next.use = mappedFormItemUse;
99
- }
100
-
101
- if (next.subModels) {
102
- const subModels = next.subModels;
103
- next.subModels = Object.fromEntries(
104
- Object.entries(subModels).map(([k, v]) => [
105
- k,
106
- normalizeGridModelOptionsForMappedFormItemUse(v, mappedFormItemUse, options, depth + 1),
107
- ]),
108
- );
109
- }
110
-
111
- const { mountTarget, normalizeMappedItem } = options || {};
112
- if (mountTarget && typeof normalizeMappedItem === 'function' && next.use === mappedFormItemUse) {
113
- const normalized = normalizeMappedItem(next, { mountTarget, engine: mountTarget.flowEngine });
114
- if (normalized && typeof normalized === 'object') {
115
- return normalized;
116
- }
117
- }
118
-
119
- return next;
120
- }
121
-
122
- function findBlockModel(model: FlowModel): BlockModel | undefined {
123
- if (!model) return;
124
- if (model instanceof BlockModel) {
125
- return model;
126
- }
127
- return findBlockModel(model.parent);
128
- }
129
-
130
- function isModelInsideReferenceBlock(model: FlowModel | undefined): boolean {
131
- if (!model) return false;
132
- return model.parent?.use === 'ReferenceBlockModel';
133
- }
134
-
135
- export function resolveExpectedRootUse(blockModel: FlowModel | undefined): string | string[] {
136
- // Create/Edit:允许互通
137
- if (blockModel?.use === 'CreateFormModel' || blockModel?.use === 'EditFormModel') {
138
- return ['CreateFormModel', 'EditFormModel'];
139
- }
140
- // 审批发起/处理表单:允许互通(最小改动方案)
141
- if (blockModel?.use === 'ApplyFormModel' || blockModel?.use === 'ProcessFormModel') {
142
- return ['ApplyFormModel', 'ProcessFormModel'];
143
- }
144
- return blockModel?.use || '';
145
- }
146
-
147
- export class SubModelTemplateImporterModel extends CommonItemModel {
148
- declare props: ImporterProps;
149
-
150
- public resolveExpectedResourceInfo(
151
- ctx?: FlowContext,
152
- start?: FlowModel,
153
- ): { dataSourceKey?: string; collectionName?: string } {
154
- const fromPropsDataSourceKey = String(this.props?.expectedDataSourceKey || '').trim();
155
- const fromPropsCollectionName = String(this.props?.expectedCollectionName || '').trim();
156
- if (fromPropsDataSourceKey && fromPropsCollectionName) {
157
- return { dataSourceKey: fromPropsDataSourceKey, collectionName: fromPropsCollectionName };
158
- }
159
- const resolved = resolveExpectedResourceInfoByModelChain(ctx, start || this.parent, {
160
- dataSourceManager: this.context.dataSourceManager,
161
- });
162
- return {
163
- dataSourceKey: String(resolved?.dataSourceKey || fromPropsDataSourceKey || '').trim() || undefined,
164
- collectionName: String(resolved?.collectionName || fromPropsCollectionName || '').trim() || undefined,
165
- };
166
- }
167
-
168
- async afterAddAsSubModel() {
169
- // 作为临时"动作模型",添加后执行导入逻辑并自清理
170
- const parentGrid = this.parent as FlowModel | undefined;
171
- const mountTarget = parentGrid?.parent;
172
-
173
- // 先自清理:避免被保存为真实字段
174
- this.remove();
175
-
176
- // 注意:GridModel 会在 onSubModelRemoved 中触发 saveStepParams(异步且不 await),
177
- // 若我们紧接着替换/保存 grid,会与该 save 竞争导致最终落库被覆盖。
178
- // 这里显式等待一次同 uid 的保存完成,确保后续 replaceModel/save 不被"旧 grid saveStepParams"覆盖。
179
- await parentGrid.saveStepParams();
180
- const step = (this.getStepParams(FLOW_KEY, 'selectTemplate') || {}) as Record<string, any>;
181
- const templateUid = String(step.templateUid || '').trim();
182
- const targetUid = String(step.targetUid || '').trim();
183
- const templateName = String(step.templateName || '').trim() || undefined;
184
- const templateDescription = String(step.templateDescription || '').trim() || undefined;
185
- const mode = String(step.mode || 'reference');
186
-
187
- const existingGrid = mountTarget.subModels?.grid as FlowModel;
188
-
189
- if (mode === 'copy') {
190
- const scoped = createBlockScopedEngine(mountTarget.flowEngine);
191
- const root = await scoped.loadModel<FlowModel>({ uid: targetUid });
192
- const gridModel = root.subModels?.grid as FlowModel;
193
-
194
- const duplicated = await mountTarget.flowEngine.duplicateModel(gridModel.uid);
195
- const duplicatedUid = duplicated.uid;
196
-
197
- // 自定义表单区块可能会对 FormItemModel 做映射:这里按 block 提供的映射将模板字段入口改为目标入口
198
- const rawMappedUse = resolveMappedFormItemUse(mountTarget);
199
- const mappedUse =
200
- rawMappedUse && rawMappedUse !== 'FormItemModel' && mountTarget.flowEngine.getModelClass(rawMappedUse)
201
- ? rawMappedUse
202
- : undefined;
203
-
204
- type ModelClassWithNormalize = { normalizeSubModelTemplateImportNode?: NormalizeSubModelTemplateImportNode };
205
- const mappedClass = mappedUse
206
- ? (mountTarget.flowEngine.getModelClass(mappedUse) as ModelClassWithNormalize | undefined)
207
- : undefined;
208
- const normalizeFn =
209
- mappedClass && typeof mappedClass.normalizeSubModelTemplateImportNode === 'function'
210
- ? mappedClass.normalizeSubModelTemplateImportNode
211
- : undefined;
212
-
213
- const normalized = mappedUse
214
- ? normalizeGridModelOptionsForMappedFormItemUse(duplicated, mappedUse, {
215
- mountTarget,
216
- normalizeMappedItem: normalizeFn,
217
- })
218
- : duplicated;
219
-
220
- const merged = patchGridOptionsFromTemplateRoot(root, normalized);
221
-
222
- // 将复制出的 grid(默认脱离父级)移动到当前表单 grid 位置,避免再走 replaceModel/save 重建整棵树
223
- await mountTarget.flowEngine.modelRepository.move(duplicatedUid, existingGrid.uid, 'after');
224
-
225
- const newGrid = mountTarget.flowEngine.createModel<FlowModel>({
226
- ...merged.options,
227
- parentId: mountTarget.uid,
228
- subKey: 'grid',
229
- subType: 'object',
230
- });
231
- mountTarget.setSubModel('grid', newGrid);
232
- await newGrid.afterAddAsSubModel();
233
- await mountTarget.flowEngine.destroyModel(existingGrid.uid);
234
- if (merged.patched) {
235
- await newGrid.saveStepParams();
236
- }
237
-
238
- await mountTarget.rerender();
239
- return;
240
- }
241
-
242
- const nextSettings = { templateUid, templateName, templateDescription, targetUid, mode };
243
- const isReferenceGrid = typeof existingGrid.use === 'string' && existingGrid.use === 'ReferenceFormGridModel';
244
- if (isReferenceGrid) {
245
- existingGrid.setStepParams(GRID_REF_FLOW_KEY, GRID_REF_STEP_KEY, nextSettings);
246
- await existingGrid.saveStepParams();
247
- await mountTarget.rerender();
248
- return;
249
- }
250
-
251
- const uidToReplace = existingGrid.uid;
252
- const oldStepParams: Record<string, any> =
253
- existingGrid.stepParams && typeof existingGrid.stepParams === 'object'
254
- ? (existingGrid.stepParams as Record<string, any>)
255
- : {};
256
- const prevRefSettings =
257
- oldStepParams[GRID_REF_FLOW_KEY] && typeof oldStepParams[GRID_REF_FLOW_KEY] === 'object'
258
- ? (oldStepParams[GRID_REF_FLOW_KEY] as Record<string, any>)
259
- : {};
260
- const nextStepParams = {
261
- [GRID_REF_FLOW_KEY]: {
262
- ...prevRefSettings,
263
- [GRID_REF_STEP_KEY]: nextSettings,
264
- },
265
- };
266
-
267
- // 需要清理旧 grid 子树(否则旧字段会残留并被 serialize 落库)
268
- await mountTarget.flowEngine.destroyModel(uidToReplace);
269
-
270
- const newGrid = mountTarget.flowEngine.createModel<FlowModel>({
271
- uid: uidToReplace,
272
- use: 'ReferenceFormGridModel',
273
- props: existingGrid.props,
274
- sortIndex: existingGrid.sortIndex,
275
- parentId: mountTarget.uid,
276
- subKey: 'grid',
277
- subType: 'object',
278
- stepParams: nextStepParams,
279
- });
280
- mountTarget.setSubModel('grid', newGrid);
281
- await newGrid.afterAddAsSubModel();
282
- await newGrid.save();
283
-
284
- await mountTarget.rerender();
285
- }
286
-
287
- async save() {
288
- // 禁止持久化
289
- return { uid: this.uid };
290
- }
291
-
292
- async saveStepParams() {
293
- // 禁止持久化(FlowSettingsDialog 会调用 saveStepParams)
294
- return { uid: this.uid };
295
- }
296
-
297
- render() {
298
- // 临时模型不渲染任何内容
299
- return null;
300
- }
301
-
302
- public getTemplateDisabledReason(
303
- ctx: FlowContext,
304
- tpl: Record<string, any>,
305
- expected?: { dataSourceKey?: string; collectionName?: string },
306
- ): string | undefined {
307
- const expectedDataSourceKey = String(expected?.dataSourceKey || this.props?.expectedDataSourceKey || '').trim();
308
- const expectedCollectionName = String(expected?.collectionName || this.props?.expectedCollectionName || '').trim();
309
- if (!expectedDataSourceKey || !expectedCollectionName) return undefined;
310
- return getTemplateAvailabilityDisabledReason(
311
- ctx,
312
- tpl,
313
- { dataSourceKey: expectedDataSourceKey, collectionName: expectedCollectionName },
314
- { dataSourceManager: this.context.dataSourceManager },
315
- );
316
- }
317
-
318
- public async fetchTemplateOptions(
319
- ctx: FlowContext,
320
- keyword?: string,
321
- pagination?: { page?: number; pageSize?: number },
322
- ): Promise<{ options: any[]; hasMore: boolean }> {
323
- const api = ctx.api;
324
- if (!api?.resource) return { options: [], hasMore: false };
325
- const page = Math.max(1, Number(pagination?.page || 1));
326
- const pageSize = Math.max(1, Number(pagination?.pageSize || TEMPLATE_LIST_PAGE_SIZE));
327
- try {
328
- const expectedRootUse = this.props?.expectedRootUse;
329
- const expects = expectedRootUse
330
- ? Array.isArray(expectedRootUse)
331
- ? expectedRootUse.map((u) => String(u))
332
- : [String(expectedRootUse)]
333
- : [];
334
- // useModel 不匹配直接不显示
335
- const useModelFilter =
336
- expects.length > 0
337
- ? {
338
- $or: [{ useModel: { $in: expects } }, { useModel: null }, { useModel: '' }],
339
- }
340
- : undefined;
341
- // 排除弹窗模板(popup templates),避免污染区块/字段模板列表
342
- const nonPopupFilter = {
343
- $and: [{ $or: [{ type: { $notIn: ['popup'] } }, { type: null }, { type: '' }] }],
344
- };
345
- const mergedFilter = useModelFilter ? { $and: [useModelFilter, ...nonPopupFilter.$and] } : nonPopupFilter;
346
- const res = await api.resource('flowModelTemplates').list({
347
- page,
348
- pageSize,
349
- search: keyword || undefined,
350
- filter: mergedFilter,
351
- });
352
- const { rows: rawRows, count } = parseResourceListResponse<any>(res);
353
- const rawLength = rawRows.length;
354
-
355
- const expectedResource = this.resolveExpectedResourceInfo(ctx);
356
- const withIndex = rawRows.flatMap((r, idx) => {
357
- const useModel = r?.useModel;
358
- if (expects.length > 0) {
359
- if (!useModel) return [];
360
- if (!expects.includes(String(useModel))) return [];
361
- }
362
- const name = r?.name || r?.uid || '';
363
- const desc = r?.description;
364
- const disabledReason = this.getTemplateDisabledReason(ctx, r, expectedResource);
365
- return [
366
- {
367
- __idx: (page - 1) * pageSize + idx,
368
- label: renderTemplateSelectLabel(name),
369
- value: r?.uid,
370
- description: desc,
371
- rawName: name,
372
- targetUid: r?.targetUid,
373
- disabled: !!disabledReason,
374
- disabledReason,
375
- dataSourceKey: r?.dataSourceKey,
376
- collectionName: r?.collectionName,
377
- useModel,
378
- },
379
- ];
380
- });
381
- // enabled 模板放在最前面,其余保持原有顺序(稳定排序)
382
- withIndex.sort(defaultSelectOptionComparator);
383
- return {
384
- options: withIndex,
385
- hasMore: calcHasMore({ page, pageSize, rowsLength: rawLength, count }),
386
- };
387
- } catch (e) {
388
- console.error('fetch template options failed', e);
389
- return { options: [], hasMore: false };
390
- }
391
- }
392
- }
393
-
394
- SubModelTemplateImporterModel.define({
395
- label: tStr('Field template'),
396
- sort: -999,
397
- hide: (ctx: FlowModelContext) => {
398
- // FilterForm 里暂不支持字段模板入口(避免误创建临时模型)
399
- const blockModel = findBlockModel(ctx.model);
400
- if (!blockModel) {
401
- return true;
402
- }
403
-
404
- // ApplyTaskCardDetailsModel 不支持区块/字段模板相关入口
405
- if (blockModel.use === 'ApplyTaskCardDetailsModel' || blockModel.use === 'ApprovalTaskCardDetailsModel') {
406
- return true;
407
- }
408
- if (blockModel instanceof FilterFormBlockModel) {
409
- return true;
410
- }
411
-
412
- // 2) 若当前区块是 ReferenceBlockModel 渲染的 target,隐藏 "From template"
413
- // 因为在 ReferenceBlockModel 内部编辑字段会直接影响被引用的模板
414
- if (isModelInsideReferenceBlock(blockModel)) {
415
- return true;
416
- }
417
-
418
- return Object.prototype.hasOwnProperty.call(blockModel.context, REF_HOST_CTX_KEY);
419
- },
420
- createModelOptions: (ctx: FlowModelContext) => {
421
- const blockModel = findBlockModel(ctx.model);
422
- const expectedRootUse = resolveExpectedRootUse(blockModel);
423
-
424
- const resourceInit = blockModel?.getStepParams?.('resourceSettings', 'init') || {};
425
- const expectedDataSourceKey =
426
- typeof resourceInit?.dataSourceKey === 'string' ? resourceInit.dataSourceKey : undefined;
427
- const expectedCollectionName =
428
- typeof resourceInit?.collectionName === 'string' ? resourceInit.collectionName : undefined;
429
-
430
- return {
431
- use: 'SubModelTemplateImporterModel',
432
- props: {
433
- expectedRootUse,
434
- expectedDataSourceKey,
435
- expectedCollectionName,
436
- },
437
- };
438
- },
439
- });
440
-
441
- SubModelTemplateImporterModel.registerFlow({
442
- key: FLOW_KEY,
443
- title: tExpr('Field template'),
444
- manual: true,
445
- sort: -999,
446
- steps: {
447
- selectTemplate: {
448
- preset: true,
449
- title: tStr('Field template'),
450
- uiSchema: (ctx) => {
451
- const m = ctx.model as SubModelTemplateImporterModel;
452
- const step = m.getStepParams(FLOW_KEY, 'selectTemplate') || {};
453
- const templateUid = (step?.templateUid || '').trim();
454
- const isNew = !!m.isNew;
455
- const disableSelect = !isNew && !!templateUid;
456
-
457
- // 固定挂载到 parent.parent(即 grid 的父级 block)
458
- const mountTarget = m.parent?.parent;
459
-
460
- // 若当前区块对 FormItemModel 做了映射(自定义入口),默认更安全的 copy 模式
461
- const rawMappedUse = mountTarget ? resolveMappedFormItemUse(mountTarget) : undefined;
462
- const hasMappedUse =
463
- rawMappedUse && rawMappedUse !== 'FormItemModel' && !!mountTarget?.flowEngine?.getModelClass?.(rawMappedUse);
464
-
465
- const fetchOptions = (keyword?: string, pagination?: { page?: number; pageSize?: number }) =>
466
- m.fetchTemplateOptions(ctx as FlowContext, keyword, pagination);
467
- return {
468
- templateUid: {
469
- title: tStr('Template'),
470
- description: tStr('Template field section description'),
471
- 'x-decorator': 'FormItem',
472
- 'x-component': 'Select',
473
- 'x-component-props': {
474
- showSearch: true,
475
- filterOption: false,
476
- allowClear: true,
477
- placeholder: tStr('Search templates'),
478
- disabled: disableSelect,
479
- optionLabelProp: 'label',
480
- dropdownMatchSelectWidth: true,
481
- dropdownStyle: { maxWidth: 560 },
482
- getPopupContainer: () => document.body,
483
- optionRender: renderTemplateSelectOption,
484
- },
485
- default: templateUid || undefined,
486
- 'x-validator': [{ required: true }],
487
- 'x-reactions': [
488
- (field) => {
489
- bindInfiniteScrollToFormilySelect(
490
- field,
491
- async (keyword: string, page: number, pageSize: number) => {
492
- return fetchOptions(keyword, { page, pageSize });
493
- },
494
- { pageSize: TEMPLATE_LIST_PAGE_SIZE, composingKey: '__templateComposing' },
495
- );
496
- },
497
- ],
498
- },
499
- mode: {
500
- title: tStr('Mode'),
501
- 'x-decorator': 'FormItem',
502
- 'x-component': 'Radio.Group',
503
- enum: [
504
- { label: tStr('Reference'), value: 'reference' },
505
- { label: tStr('Duplicate'), value: 'copy' },
506
- ],
507
- // 若当前区块对 FormItemModel 做了映射(自定义入口),默认更安全的 copy 模式
508
- default: step?.mode || (hasMappedUse ? 'copy' : 'reference'),
509
- },
510
- modeDescriptionReference: {
511
- type: 'void',
512
- 'x-decorator': 'FormItem',
513
- 'x-decorator-props': { colon: false },
514
- 'x-component': 'Alert',
515
- 'x-component-props': {
516
- type: 'info',
517
- showIcon: false,
518
- message: tStr('Reference mode description'),
519
- style: { marginTop: -8 },
520
- },
521
- 'x-reactions': {
522
- dependencies: ['mode'],
523
- fulfill: { state: { hidden: '{{$deps[0] === "copy"}}' } },
524
- },
525
- },
526
- modeDescriptionDuplicate: {
527
- type: 'void',
528
- 'x-decorator': 'FormItem',
529
- 'x-decorator-props': { colon: false },
530
- 'x-component': 'Alert',
531
- 'x-component-props': {
532
- type: 'info',
533
- showIcon: false,
534
- message: tStr('Duplicate mode description'),
535
- style: { marginTop: -8 },
536
- },
537
- 'x-reactions': {
538
- dependencies: ['mode'],
539
- fulfill: { state: { hidden: '{{$deps[0] !== "copy"}}' } },
540
- },
541
- },
542
- };
543
- },
544
- async beforeParamsSave(ctx, params) {
545
- const importer = ctx.model as SubModelTemplateImporterModel;
546
- const parent = importer.parent as FlowModel | undefined;
547
- if (!parent) {
548
- throw new Error('[block-reference] Cannot resolve mount target: importer has no parent.');
549
- }
550
-
551
- // 固定挂载到 parent.parent(即 grid 的父级 block)
552
- const mountTarget = parent.parent;
553
- const api = (ctx as FlowContext).api;
554
- if (!mountTarget) {
555
- throw new Error(
556
- `[block-reference] Cannot resolve mount target from importer parent (uid='${parent.uid}', use='${parent.use}').`,
557
- );
558
- }
559
-
560
- const templateUid = String(params?.templateUid || '').trim();
561
- if (!templateUid) {
562
- throw new Error('[block-reference] templateUid is required.');
563
- }
564
-
565
- if (!api?.resource) {
566
- throw new Error('[block-reference] ctx.api.resource is required to fetch templates.');
567
- }
568
- const res = await api.resource('flowModelTemplates').get({ filterByTk: templateUid });
569
- const tpl = res?.data?.data || res?.data || res;
570
-
571
- const targetUid = (tpl?.targetUid || params?.targetUid || '').trim();
572
- if (!targetUid) {
573
- throw new Error(`[block-reference] Template '${templateUid}' has no targetUid.`);
574
- }
575
- const templateName = (tpl?.name || params?.templateName || '').trim();
576
- const templateDescription = (tpl?.description || params?.templateDescription || '').trim();
577
- const mode = String(params?.mode || 'reference');
578
-
579
- // 禁止跨数据源/数据表使用字段模板(在 UI 侧禁用的同时,这里做一次硬校验,避免绕过)
580
- const expectedResource = importer.resolveExpectedResourceInfo(ctx as FlowContext, mountTarget);
581
- const disabledReason = importer.getTemplateDisabledReason(ctx as FlowContext, tpl || {}, expectedResource);
582
- if (disabledReason) {
583
- throw new Error(disabledReason);
584
- }
585
-
586
- // 引用模式下,如果目标挂载点已有字段,需先提示确认
587
- if (mode !== 'copy') {
588
- const existingGrid = mountTarget.subModels?.grid;
589
- if (!(existingGrid instanceof FlowModel)) {
590
- throw new Error('[block-reference] Cannot mount: mountTarget has no grid subModel.');
591
- }
592
-
593
- const isReferenceGrid = typeof existingGrid.use === 'string' && existingGrid.use === 'ReferenceFormGridModel';
594
-
595
- // 已经是引用 grid 且引用未变化:直接退出,避免重复确认
596
- if (isReferenceGrid) {
597
- const existing = existingGrid.getStepParams(GRID_REF_FLOW_KEY, GRID_REF_STEP_KEY) || {};
598
- const norm = (v: any) => String(v || '').trim();
599
- const isSame =
600
- norm(existing.templateUid) === norm(templateUid) && norm(existing.targetUid) === norm(targetUid);
601
- if (isSame) {
602
- // 仍需写入当前 stepParams,避免 afterAddAsSubModel 无法读取 targetUid 等
603
- importer.setStepParams(FLOW_KEY, 'selectTemplate', {
604
- templateUid,
605
- targetUid,
606
- templateName,
607
- templateDescription,
608
- mode,
609
- });
610
- return;
611
- }
612
- }
613
-
614
- // 仅检测“当前区块”已持久化/在当前引擎内的字段,避免 reference grid 透传模板字段导致误提示
615
- const fieldItemBaseClasses = (
616
- ['FormItemModel', 'FormCustomItemModel', 'FormJSFieldItemModel']
617
- .map((name) => mountTarget.flowEngine.getModelClass(name))
618
- .filter(Boolean) as ModelConstructor[]
619
- ).filter(Boolean);
620
- // Details 区块的字段项类型与表单不同,采用"任意 items 都算已有字段"的策略进行确认提示
621
- const isDetailsBlock = typeof mountTarget.use === 'string' && mountTarget.use === 'DetailsBlockModel';
622
- const isDetailsGrid = typeof existingGrid.use === 'string' && existingGrid.use === 'DetailsGridModel';
623
- const shouldFallbackToAnyItem = isDetailsBlock || isDetailsGrid || fieldItemBaseClasses.length === 0;
624
-
625
- const isFieldItem = (m: FlowModel) => {
626
- if (shouldFallbackToAnyItem) return true;
627
- const ctor = m.constructor as ModelConstructor;
628
- return fieldItemBaseClasses.some((Base) => ctor === Base || isInheritedFrom(ctor, Base));
629
- };
630
-
631
- let hasExistingFields = false;
632
-
633
- // 非 reference grid:直接检查 subModels(避免全量扫描引擎)
634
- if (!isReferenceGrid) {
635
- const items = existingGrid.subModels?.items;
636
- const list = Array.isArray(items) ? items : [];
637
- for (const item of list) {
638
- if (!(item instanceof FlowModel)) continue;
639
- if (item.uid === importer.uid) continue;
640
- if (isFieldItem(item)) {
641
- hasExistingFields = true;
642
- break;
643
- }
644
- }
645
- } else {
646
- // reference grid:避免透传模板字段,按当前引擎内的 parent/subKey 关系识别“本地字段”
647
- mountTarget.flowEngine.forEachModel((m) => {
648
- if (hasExistingFields) return;
649
- if (!(m instanceof FlowModel)) return;
650
- if (m.uid === mountTarget.uid || m.uid === importer.uid) return;
651
- if (m.parent?.uid !== existingGrid.uid || m.subKey !== 'items') return;
652
- if (isFieldItem(m)) {
653
- hasExistingFields = true;
654
- }
655
- });
656
- }
657
- if (hasExistingFields) {
658
- const viewer = (ctx as FlowContext).viewer || mountTarget.context.viewer || importer.context.viewer;
659
- const message = ctx.t('Using reference fields will remove existing fields', {
660
- ns: [NAMESPACE, 'client'],
661
- nsMode: 'fallback',
662
- });
663
- ctx.view?.close(undefined, true);
664
- await new Promise<void>((resolve) => setTimeout(resolve, 0));
665
-
666
- const confirmed = await new Promise<boolean>((resolve) => {
667
- let resolved = false;
668
- const resolveOnce = (val: boolean) => {
669
- if (resolved) return;
670
- resolved = true;
671
- resolve(val);
672
- };
673
- viewer.dialog({
674
- title:
675
- (ctx as FlowContext).t?.('Field template', { ns: [NAMESPACE, 'client'], nsMode: 'fallback' }) ||
676
- 'Field template',
677
- width: 520,
678
- destroyOnClose: true,
679
- content: (currentDialog: any) => (
680
- <>
681
- <div style={{ marginBottom: 16 }}>{message}</div>
682
- <currentDialog.Footer>
683
- <Space align="end">
684
- <Button
685
- onClick={() => {
686
- resolveOnce(false);
687
- currentDialog.close(undefined, true);
688
- }}
689
- >
690
- {(ctx as FlowContext).t?.('Cancel') || 'Cancel'}
691
- </Button>
692
- <Button
693
- type="primary"
694
- onClick={() => {
695
- resolveOnce(true);
696
- currentDialog.close(undefined, true);
697
- }}
698
- >
699
- {(ctx as FlowContext).t?.('Confirm') || 'Confirm'}
700
- </Button>
701
- </Space>
702
- </currentDialog.Footer>
703
- </>
704
- ),
705
- onClose: () => resolveOnce(false),
706
- zIndex: typeof viewer.getNextZIndex === 'function' ? viewer.getNextZIndex() + 1000 : undefined,
707
- });
708
- });
709
- if (!confirmed) {
710
- throw new FlowExitException(FLOW_KEY, importer.uid, 'User cancelled template import');
711
- }
712
- }
713
- }
714
-
715
- importer.setStepParams(FLOW_KEY, 'selectTemplate', {
716
- templateUid,
717
- targetUid,
718
- templateName,
719
- templateDescription,
720
- mode,
721
- });
722
- },
723
- },
724
- },
725
- });