@nocobase/client-v2 2.1.0-alpha.30 → 2.1.0-alpha.32

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 (103) hide show
  1. package/es/components/form/JsonTextArea.d.ts +18 -0
  2. package/es/components/index.d.ts +1 -0
  3. package/es/flow/actions/dateRangeLimit.d.ts +9 -0
  4. package/es/flow/actions/index.d.ts +1 -0
  5. package/es/flow/actions/linkageRulesFormValueRefresh.d.ts +10 -0
  6. package/es/flow/index.d.ts +1 -0
  7. package/es/flow/models/actions/AssociateActionModel.d.ts +19 -0
  8. package/es/flow/models/actions/AssociationActionUtils.d.ts +17 -0
  9. package/es/flow/models/actions/DisassociateActionModel.d.ts +16 -0
  10. package/es/flow/models/actions/index.d.ts +3 -0
  11. package/es/flow/models/base/GridModel.d.ts +3 -1
  12. package/es/flow/models/base/PageModel/PageModel.d.ts +4 -0
  13. package/es/flow/models/base/PageModel/RootPageModel.d.ts +9 -0
  14. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +15 -6
  15. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +7 -0
  16. package/es/flow/models/blocks/shared/filterOperators.d.ts +9 -0
  17. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +3 -0
  18. package/es/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.d.ts +9 -0
  19. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +20 -0
  20. package/es/flow/models/fields/JSEditableFieldModel.d.ts +4 -0
  21. package/es/flow-compat/data.d.ts +9 -2
  22. package/es/flow-compat/index.d.ts +1 -1
  23. package/es/index.d.ts +1 -0
  24. package/es/index.mjs +100 -93
  25. package/lib/index.js +101 -94
  26. package/package.json +6 -5
  27. package/src/BaseApplication.tsx +1 -1
  28. package/src/__tests__/app.test.tsx +23 -6
  29. package/src/__tests__/globalDeps.test.ts +5 -0
  30. package/src/components/form/JsonTextArea.tsx +129 -0
  31. package/src/components/index.ts +1 -0
  32. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +478 -0
  33. package/src/flow/actions/__tests__/linkageRules.formValueDrivenRefresh.test.ts +438 -0
  34. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +42 -0
  35. package/src/flow/actions/__tests__/pattern.test.ts +190 -0
  36. package/src/flow/actions/dateRangeLimit.tsx +66 -0
  37. package/src/flow/actions/index.ts +1 -0
  38. package/src/flow/actions/linkageRules.tsx +119 -14
  39. package/src/flow/actions/linkageRulesFormValueRefresh.ts +492 -0
  40. package/src/flow/actions/linkageRulesRefresh.tsx +4 -2
  41. package/src/flow/actions/openView.tsx +2 -1
  42. package/src/flow/actions/pattern.tsx +25 -2
  43. package/src/flow/actions/titleField.tsx +8 -3
  44. package/src/flow/admin-shell/AdminLayoutRouteCoordinator.ts +7 -1
  45. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +117 -0
  46. package/src/flow/components/FieldAssignValueInput.tsx +1 -0
  47. package/src/flow/components/filter/LinkageFilterItem.tsx +6 -5
  48. package/src/flow/components/filter/VariableFilterItem.tsx +14 -13
  49. package/src/flow/components/filter/__tests__/LinkageFilterItem.test.tsx +33 -0
  50. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +48 -5
  51. package/src/flow/index.ts +1 -0
  52. package/src/flow/internal/utils/__tests__/titleFieldQuickSync.test.ts +1 -0
  53. package/src/flow/internal/utils/titleFieldQuickSync.ts +2 -2
  54. package/src/flow/models/actions/AssociateActionModel.tsx +196 -0
  55. package/src/flow/models/actions/AssociationActionUtils.ts +90 -0
  56. package/src/flow/models/actions/DisassociateActionModel.tsx +57 -0
  57. package/src/flow/models/actions/FilterActionModel.tsx +17 -9
  58. package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +250 -0
  59. package/src/flow/models/actions/index.ts +3 -0
  60. package/src/flow/models/base/GridModel.tsx +21 -1
  61. package/src/flow/models/base/PageModel/PageModel.tsx +15 -3
  62. package/src/flow/models/base/PageModel/RootPageModel.tsx +37 -2
  63. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +73 -0
  64. package/src/flow/models/base/PageModel/__tests__/RootPageModel.test.ts +116 -0
  65. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +98 -0
  66. package/src/flow/models/blocks/details/DetailsItemModel.tsx +3 -0
  67. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +200 -36
  68. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +270 -1
  69. package/src/flow/models/blocks/filter-form/__tests__/customFieldOperators.test.tsx +23 -0
  70. package/src/flow/models/blocks/filter-form/customFieldOperators.ts +12 -1
  71. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +22 -8
  72. package/src/flow/models/blocks/filter-form/fields/__tests__/FilterFormCustomFieldModel.recordSelect.test.tsx +18 -0
  73. package/src/flow/models/blocks/filter-manager/FilterManager.ts +51 -1
  74. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +75 -0
  75. package/src/flow/models/blocks/form/FormItemModel.tsx +48 -28
  76. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +167 -1
  77. package/src/flow/models/blocks/form/value-runtime/runtime.ts +103 -11
  78. package/src/flow/models/blocks/shared/filterOperators.ts +14 -0
  79. package/src/flow/models/blocks/table/TableBlockModel.tsx +19 -3
  80. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +5 -1
  81. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +48 -8
  82. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +47 -0
  83. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +42 -0
  84. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.refresh.test.tsx +122 -0
  85. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +2 -0
  86. package/src/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.ts +20 -0
  87. package/src/flow/models/fields/ClickableFieldModel.tsx +21 -9
  88. package/src/flow/models/fields/DateTimeFieldModel/DateOnlyFieldModel.tsx +9 -0
  89. package/src/flow/models/fields/DateTimeFieldModel/DateTimeFieldModel.tsx +4 -0
  90. package/src/flow/models/fields/DateTimeFieldModel/DateTimeNoTzFieldModel.tsx +9 -0
  91. package/src/flow/models/fields/DateTimeFieldModel/DateTimeTzFieldModel.tsx +9 -0
  92. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +242 -0
  93. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +152 -0
  94. package/src/flow/models/fields/DividerItemModel.tsx +30 -15
  95. package/src/flow/models/fields/JSEditableFieldModel.tsx +110 -14
  96. package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +87 -0
  97. package/src/flow/models/fields/__tests__/JSEditableFieldModel.test.tsx +210 -0
  98. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +11 -3
  99. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +235 -0
  100. package/src/flow-compat/data.ts +25 -3
  101. package/src/flow-compat/index.ts +7 -1
  102. package/src/index.ts +1 -0
  103. package/src/utils/globalDeps.ts +6 -0
@@ -0,0 +1,57 @@
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 { tExpr } from '@nocobase/flow-engine';
11
+ import type { ButtonProps } from 'antd';
12
+ import { ActionModel, ActionSceneEnum } from '../base';
13
+ import { applyDisassociateAction, isAssociationBlockContext } from './AssociationActionUtils';
14
+
15
+ export class DisassociateActionModel extends ActionModel {
16
+ static scene = ActionSceneEnum.record;
17
+ static capabilityActionName = 'update';
18
+
19
+ defaultProps: ButtonProps = {
20
+ type: 'link',
21
+ title: tExpr('Disassociate'),
22
+ icon: 'DisconnectOutlined',
23
+ };
24
+
25
+ getAclActionName() {
26
+ return 'update';
27
+ }
28
+ }
29
+
30
+ DisassociateActionModel.define({
31
+ label: tExpr('Disassociate'),
32
+ sort: 65,
33
+ hide(ctx) {
34
+ return !isAssociationBlockContext(ctx);
35
+ },
36
+ });
37
+
38
+ DisassociateActionModel.registerFlow({
39
+ key: 'disassociateSettings',
40
+ title: tExpr('Disassociate settings'),
41
+ on: 'click',
42
+ steps: {
43
+ confirm: {
44
+ use: 'confirm',
45
+ defaultParams: {
46
+ enable: true,
47
+ title: tExpr('Disassociate record'),
48
+ content: tExpr('Are you sure you want to disassociate it?'),
49
+ },
50
+ },
51
+ disassociate: {
52
+ async handler(ctx) {
53
+ await applyDisassociateAction(ctx);
54
+ },
55
+ },
56
+ },
57
+ });
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { tExpr, MobilePopup, MultiRecordResource, useFlowSettingsContext } from '@nocobase/flow-engine';
11
+ import type { CollectionFieldInterfaceDataSourceManager } from '@nocobase/flow-engine';
11
12
  import { isEmptyFilter, transformFilter } from '@nocobase/utils/client';
12
13
  import { ButtonProps, Popover, Transfer } from 'antd';
13
14
  import React from 'react';
@@ -15,6 +16,7 @@ import { FilterGroup, VariableFilterItem } from '../../components/filter';
15
16
  import { ActionModel, CollectionBlockModel } from '../base';
16
17
  import { FilterContainer } from '../../components/filter/FilterContainer';
17
18
  import _ from 'lodash';
19
+ import { getFlowFieldInterfaceOptions } from '../../../flow-compat';
18
20
 
19
21
  export class FilterActionModel extends ActionModel {
20
22
  static scene = 'collection';
@@ -135,9 +137,11 @@ FilterActionModel.registerFlow({
135
137
  'x-component': (props) => {
136
138
  // eslint-disable-next-line react-hooks/rules-of-hooks
137
139
  const { model } = useFlowSettingsContext();
138
- const dm = model?.context?.app?.dataSourceManager;
139
- const fiMgr = dm?.collectionFieldInterfaceManager;
140
- const filterable = getFilterableFields(model.context.blockModel.collection, fiMgr);
140
+ const filterable = getFilterableFields(
141
+ model.context.blockModel.collection,
142
+ model.context.dataSourceManager,
143
+ model.context.app?.dataSourceManager,
144
+ );
141
145
  const dataSource = filterable.map((field: any) => ({ key: field.name, title: field.title }));
142
146
  return (
143
147
  <Transfer
@@ -157,9 +161,11 @@ FilterActionModel.registerFlow({
157
161
  },
158
162
  defaultParams(ctx) {
159
163
  // 默认仅包含“可筛选”的字段(与 1.0 一致),以避免 JSON 等未提供 operators 的字段出现在默认允许集合中
160
- const dm = ctx?.model?.context?.app?.dataSourceManager;
161
- const fiMgr = dm?.collectionFieldInterfaceManager;
162
- const names = getFilterableFields(ctx.blockModel.collection, fiMgr).map((field: any) => field.name);
164
+ const names = getFilterableFields(
165
+ ctx.blockModel.collection,
166
+ ctx.model?.context?.dataSourceManager,
167
+ ctx.model?.context?.app?.dataSourceManager,
168
+ ).map((field: any) => field.name);
163
169
  return {
164
170
  filterableFieldNames: names || [],
165
171
  };
@@ -304,13 +310,15 @@ FilterActionModel.registerFlow({
304
310
  },
305
311
  });
306
312
 
307
- function getFilterableFields(collection: any, fiMgr: any) {
313
+ function getFilterableFields(
314
+ collection: any,
315
+ ...dataSourceManagers: Array<CollectionFieldInterfaceDataSourceManager | null | undefined>
316
+ ) {
308
317
  const fields = collection?.getFields?.() || [];
309
- if (!fiMgr) return [];
310
318
  return fields.filter((field: any) => {
311
319
  if (!field?.interface) return false;
312
320
  if (field?.filterable === false) return false;
313
- const fi = fiMgr.getFieldInterface(field.interface);
321
+ const fi = getFlowFieldInterfaceOptions(field.interface, ...dataSourceManagers);
314
322
  return !!fi?.filterable;
315
323
  });
316
324
  }
@@ -0,0 +1,250 @@
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 { describe, expect, it, vi } from 'vitest';
11
+ import { BaseRecordResource, FlowEngine } from '@nocobase/flow-engine';
12
+ import {
13
+ applyDisassociateAction,
14
+ ActionModel,
15
+ applyAssociateAction,
16
+ AssociateActionModel,
17
+ CollectionActionGroupModel,
18
+ DisassociateActionModel,
19
+ getAssociationTargetResourceSettings,
20
+ PopupActionModel,
21
+ RecordActionGroupModel,
22
+ } from '../../..';
23
+
24
+ class TestAssociationResource<T = any> extends BaseRecordResource<T> {
25
+ async refresh(): Promise<void> {}
26
+ }
27
+
28
+ const createEngine = () => {
29
+ const engine = new FlowEngine();
30
+ engine.registerModels({
31
+ ActionModel,
32
+ AssociateActionModel,
33
+ DisassociateActionModel,
34
+ PopupActionModel,
35
+ CollectionActionGroupModel,
36
+ RecordActionGroupModel,
37
+ });
38
+ return engine;
39
+ };
40
+
41
+ const createContext = (engine: FlowEngine, resourceSettingsInit: any) => {
42
+ const collection = {
43
+ options: {
44
+ availableActions: ['list', 'update'],
45
+ },
46
+ getFilterByTK: vi.fn((record) => record.id),
47
+ };
48
+ return {
49
+ engine,
50
+ collection,
51
+ blockModel: {
52
+ collection,
53
+ getResourceSettingsInitParams: () => resourceSettingsInit,
54
+ },
55
+ } as any;
56
+ };
57
+
58
+ describe('association action models', () => {
59
+ it('uses update permission for association operations', () => {
60
+ expect(AssociateActionModel.prototype.getAclActionName.call({})).toBe('update');
61
+ expect(DisassociateActionModel.prototype.getAclActionName.call({})).toBe('update');
62
+ expect(AssociateActionModel.capabilityActionName).toBe('update');
63
+ expect(DisassociateActionModel.capabilityActionName).toBe('update');
64
+ });
65
+
66
+ it('uses association target collection for selector table blocks', () => {
67
+ const ctx: any = {
68
+ blockModel: {
69
+ association: {
70
+ target: 'transports',
71
+ targetCollection: {
72
+ name: 'transports',
73
+ dataSourceKey: 'main',
74
+ },
75
+ },
76
+ getResourceSettingsInitParams: () => ({
77
+ dataSourceKey: 'main',
78
+ collectionName: 'orders',
79
+ associationName: 'products.transports',
80
+ }),
81
+ },
82
+ };
83
+
84
+ expect(getAssociationTargetResourceSettings(ctx)).toEqual({
85
+ dataSourceKey: 'main',
86
+ collectionName: 'transports',
87
+ });
88
+ });
89
+
90
+ it('shows Associate only in collection actions of association blocks', async () => {
91
+ const engine = createEngine();
92
+
93
+ const relationItems = await CollectionActionGroupModel.defineChildren(
94
+ createContext(engine, {
95
+ dataSourceKey: 'main',
96
+ collectionName: 'orders',
97
+ associationName: 'products.o2m_orders',
98
+ }),
99
+ );
100
+ const normalItems = await CollectionActionGroupModel.defineChildren(
101
+ createContext(engine, {
102
+ dataSourceKey: 'main',
103
+ collectionName: 'orders',
104
+ }),
105
+ );
106
+
107
+ expect(relationItems.map((item: any) => item.useModel)).toContain('AssociateActionModel');
108
+ expect(relationItems.map((item: any) => item.useModel)).not.toContain('DisassociateActionModel');
109
+ expect(normalItems.map((item: any) => item.useModel)).not.toContain('AssociateActionModel');
110
+ });
111
+
112
+ it('shows Disassociate only in record actions of association blocks', async () => {
113
+ const engine = createEngine();
114
+
115
+ const relationItems = await RecordActionGroupModel.defineChildren(
116
+ createContext(engine, {
117
+ dataSourceKey: 'main',
118
+ collectionName: 'orders',
119
+ associationName: 'products.o2m_orders',
120
+ }),
121
+ );
122
+ const normalItems = await RecordActionGroupModel.defineChildren(
123
+ createContext(engine, {
124
+ dataSourceKey: 'main',
125
+ collectionName: 'orders',
126
+ }),
127
+ );
128
+
129
+ expect(relationItems.map((item: any) => item.useModel)).toContain('DisassociateActionModel');
130
+ expect(relationItems.map((item: any) => item.useModel)).not.toContain('AssociateActionModel');
131
+ expect(normalItems.map((item: any) => item.useModel)).not.toContain('DisassociateActionModel');
132
+ });
133
+
134
+ it('disassociates the current record through association resource remove action', async () => {
135
+ const resource = {
136
+ runAction: vi.fn(async () => ({})),
137
+ refresh: vi.fn(async () => {}),
138
+ };
139
+ const collection = {
140
+ getFilterByTK: vi.fn(() => 12),
141
+ };
142
+ const ctx: any = {
143
+ blockModel: {
144
+ collection,
145
+ resource,
146
+ getResourceSettingsInitParams: () => ({
147
+ dataSourceKey: 'main',
148
+ collectionName: 'orders',
149
+ associationName: 'products.o2m_orders',
150
+ }),
151
+ },
152
+ record: {
153
+ id: 12,
154
+ },
155
+ message: {
156
+ success: vi.fn(),
157
+ error: vi.fn(),
158
+ },
159
+ t: (value: string) => value,
160
+ };
161
+
162
+ await applyDisassociateAction(ctx);
163
+
164
+ expect(collection.getFilterByTK).toHaveBeenCalledWith(ctx.record);
165
+ expect(resource.runAction).toHaveBeenCalledWith('remove', {
166
+ data: [12],
167
+ });
168
+ expect(resource.refresh).toHaveBeenCalled();
169
+ expect(ctx.message.success).toHaveBeenCalledWith('Record disassociated successfully');
170
+ });
171
+
172
+ it('associates selected records through association resource add action', async () => {
173
+ const resource = {
174
+ runAction: vi.fn(async () => ({})),
175
+ refresh: vi.fn(async () => {}),
176
+ };
177
+ const collection = {
178
+ getFilterByTK: vi.fn((record) => record.id),
179
+ };
180
+ const ctx: any = {
181
+ blockModel: {
182
+ collection,
183
+ resource,
184
+ getResourceSettingsInitParams: () => ({
185
+ dataSourceKey: 'main',
186
+ collectionName: 'orders',
187
+ associationName: 'products.o2m_orders',
188
+ }),
189
+ },
190
+ message: {
191
+ success: vi.fn(),
192
+ warning: vi.fn(),
193
+ error: vi.fn(),
194
+ },
195
+ t: (value: string) => value,
196
+ };
197
+
198
+ await applyAssociateAction(ctx, [{ id: 11 }, { id: 12 }]);
199
+
200
+ expect(collection.getFilterByTK).toHaveBeenCalledWith({ id: 11 });
201
+ expect(collection.getFilterByTK).toHaveBeenCalledWith({ id: 12 });
202
+ expect(resource.runAction).toHaveBeenCalledWith('add', {
203
+ data: [11, 12],
204
+ });
205
+ expect(resource.refresh).toHaveBeenCalled();
206
+ expect(ctx.message.success).toHaveBeenCalledWith('Record associated successfully');
207
+ });
208
+
209
+ it('uses nested association resource url when adding associated records', async () => {
210
+ const engine = createEngine();
211
+ const resource = engine.createResource(TestAssociationResource);
212
+ const api = {
213
+ request: vi.fn().mockResolvedValue({ data: { data: {} } }),
214
+ };
215
+ resource.setAPIClient(api as any);
216
+ resource.setResourceName('products.o2m_orders');
217
+ resource.setSourceId('362872646860800');
218
+
219
+ const ctx: any = {
220
+ blockModel: {
221
+ collection: {
222
+ getFilterByTK: vi.fn((record) => record.id),
223
+ },
224
+ resource,
225
+ getResourceSettingsInitParams: () => ({
226
+ dataSourceKey: 'main',
227
+ collectionName: 'orders',
228
+ associationName: 'products.o2m_orders',
229
+ sourceId: '362872646860800',
230
+ }),
231
+ },
232
+ message: {
233
+ success: vi.fn(),
234
+ warning: vi.fn(),
235
+ error: vi.fn(),
236
+ },
237
+ t: (value: string) => value,
238
+ };
239
+
240
+ await applyAssociateAction(ctx, [{ id: 11 }]);
241
+
242
+ expect(api.request).toHaveBeenCalledWith(
243
+ expect.objectContaining({
244
+ method: 'post',
245
+ url: 'products/362872646860800/o2m_orders:add',
246
+ data: [11],
247
+ }),
248
+ );
249
+ });
250
+ });
@@ -8,8 +8,11 @@
8
8
  */
9
9
 
10
10
  export * from './AddNewActionModel';
11
+ export * from './AssociateActionModel';
12
+ export * from './AssociationActionUtils';
11
13
  export * from './BulkDeleteActionModel';
12
14
  export * from './DeleteActionModel';
15
+ export * from './DisassociateActionModel';
13
16
  export * from './EditActionModel';
14
17
  export * from './FilterActionModel';
15
18
  export * from './JSActionModel';
@@ -118,6 +118,14 @@ export class GridModel<T extends { subModels: { items: FlowModel[] } } = Default
118
118
  private dragState?: DragState;
119
119
  private _memoItemFlowSettings?: Exclude<FlowModelRendererProps['showFlowSettings'], boolean>;
120
120
 
121
+ onInit(options) {
122
+ super.onInit(options);
123
+ // 历史数据里可能残留拖拽高亮框,初始化时立即清理,避免刷新页面后常驻显示。
124
+ if (this.props.dragOverlayRect) {
125
+ this.setProps('dragOverlayRect', null);
126
+ }
127
+ }
128
+
121
129
  private updateDragPointerPosition = (event: Event) => {
122
130
  if (!this.dragState) {
123
131
  return;
@@ -243,6 +251,12 @@ export class GridModel<T extends { subModels: { items: FlowModel[] } } = Default
243
251
  return this.normalizeLayoutFromSource();
244
252
  }
245
253
 
254
+ serialize(): Record<string, any> {
255
+ const data = super.serialize();
256
+ data.props = _.omit(data.props, ['dragOverlayRect']);
257
+ return data;
258
+ }
259
+
246
260
  syncLayoutProps(layout: GridLayoutV2) {
247
261
  const projection = projectLayoutToLegacyRows(layout);
248
262
  this.setProps('layout', layout);
@@ -801,11 +815,17 @@ export class GridModel<T extends { subModels: { items: FlowModel[] } } = Default
801
815
  this.setProps('dragOverlayRect', null);
802
816
  }
803
817
 
804
- handleDragEnd(_event: DragEndEvent) {
818
+ handleDragEnd(event: DragEndEvent) {
805
819
  if (!this.dragState) {
806
820
  return;
807
821
  }
808
822
 
823
+ const finalPoint = this.computePointerPosition(event);
824
+ if (finalPoint) {
825
+ const finalSlot = this.resolveDragSlot(finalPoint);
826
+ this.applyPreview(finalSlot);
827
+ }
828
+
809
829
  const previewLayout = this.dragState.previewLayout;
810
830
  if (previewLayout) {
811
831
  if (previewLayout.layout) {
@@ -49,6 +49,17 @@ export class PageModel extends FlowModel<PageModelStructure> {
49
49
  private unmounted = false;
50
50
  private documentTitleUpdateVersion = 0;
51
51
 
52
+ /**
53
+ * 根页面标签页开关以路由表为准,避免 flow model 里的旧配置覆盖路由管理设置。
54
+ */
55
+ private getEnableTabs(): boolean {
56
+ const routeEnableTabs = (this.context as any)?.currentRoute?.enableTabs;
57
+ if (this.props.routeId != null && typeof routeEnableTabs === 'boolean') {
58
+ return routeEnableTabs;
59
+ }
60
+ return !!this.props.enableTabs;
61
+ }
62
+
52
63
  private getActiveTabKey(): string | undefined {
53
64
  const viewParams = this.context.view?.navigation?.viewParams;
54
65
  if (viewParams) {
@@ -193,7 +204,7 @@ export class PageModel extends FlowModel<PageModelStructure> {
193
204
  };
194
205
 
195
206
  let nextTitle = '';
196
- if (this.props.enableTabs) {
207
+ if (this.getEnableTabs()) {
197
208
  const activeTabKey = preferredActiveTabKey || this.getActiveTabKey();
198
209
  const activeTabModel = activeTabKey
199
210
  ? (this.flowEngine.getModel(activeTabKey) as BasePageTabModel | undefined)
@@ -356,13 +367,14 @@ export class PageModel extends FlowModel<PageModelStructure> {
356
367
  headerStyle.paddingBlock = token.paddingSM;
357
368
  headerStyle.paddingInline = token.paddingLG;
358
369
  }
359
- if (this.props.enableTabs) {
370
+ const enableTabs = this.getEnableTabs();
371
+ if (enableTabs) {
360
372
  headerStyle.paddingBottom = 0;
361
373
  }
362
374
  return (
363
375
  <>
364
376
  {this.props.displayTitle && <PageHeader title={this.props.title} style={headerStyle} />}
365
- {this.props.enableTabs ? this.renderTabs() : this.renderFirstTab()}
377
+ {enableTabs ? this.renderTabs() : this.renderFirstTab()}
366
378
  </>
367
379
  );
368
380
  }
@@ -17,6 +17,31 @@ import { PageModel } from './PageModel';
17
17
  export class RootPageModel extends PageModel {
18
18
  mounted = false;
19
19
 
20
+ /**
21
+ * 打开页面设置前,把标签页开关表单值同步为路由表中的当前状态。
22
+ */
23
+ private syncPageSettingsEnableTabsFromRoute() {
24
+ const routeEnableTabs = (this.context as any)?.currentRoute?.enableTabs;
25
+ if (typeof routeEnableTabs !== 'boolean') {
26
+ return;
27
+ }
28
+ this.setStepParams('pageSettings', 'general', {
29
+ enableTabs: routeEnableTabs,
30
+ });
31
+ }
32
+
33
+ /**
34
+ * 保存页面设置后立即同步当前页面状态,让标签页显隐无需等路由列表刷新或页面重载。
35
+ */
36
+ private syncEnableTabsToCurrentPage(enableTabs: boolean) {
37
+ const currentRoute = (this.context as any)?.currentRoute;
38
+ const routeId = this.props.routeId;
39
+ if (currentRoute && (routeId == null || currentRoute.id == null || String(currentRoute.id) === String(routeId))) {
40
+ currentRoute.enableTabs = enableTabs;
41
+ }
42
+ this.setProps('enableTabs', enableTabs);
43
+ }
44
+
20
45
  /**
21
46
  * 新建 tab 在首次保存完成前,前端 route 里可能还没有数据库 id。
22
47
  * 拖拽前兜底触发一次保存,确保 move 接口拿到真实主键。
@@ -65,18 +90,28 @@ export class RootPageModel extends PageModel {
65
90
  );
66
91
  }
67
92
 
93
+ async openFlowSettings(options?: Parameters<PageModel['openFlowSettings']>[0]) {
94
+ if (options?.flowKey === 'pageSettings' && options?.stepKey === 'general') {
95
+ this.syncPageSettingsEnableTabsFromRoute();
96
+ }
97
+ return super.openFlowSettings(options);
98
+ }
99
+
68
100
  async saveStepParams() {
69
101
  await super.saveStepParams();
70
102
 
71
103
  if (this.stepParams.pageSettings) {
104
+ const enableTabs = !!this.stepParams.pageSettings.general.enableTabs;
72
105
  // 更新路由
73
- this.context.api.request({
106
+ await this.context.api.request({
74
107
  url: `desktopRoutes:update?filter[id]=${this.props.routeId}`,
75
108
  method: 'post',
76
109
  data: {
77
- enableTabs: !!this.stepParams.pageSettings.general.enableTabs,
110
+ enableTabs,
78
111
  },
79
112
  });
113
+ this.syncEnableTabsToCurrentPage(enableTabs);
114
+ await this.context.refreshDesktopRoutes?.();
80
115
  }
81
116
  }
82
117
 
@@ -412,6 +412,7 @@ describe('PageModel', () => {
412
412
  describe('render header spacing with tabs', () => {
413
413
  it('should compact page header bottom spacing when tabs are enabled', () => {
414
414
  pageModel.props = {
415
+ routeId: 'route-1',
415
416
  displayTitle: true,
416
417
  enableTabs: true,
417
418
  title: 'Title',
@@ -430,6 +431,7 @@ describe('PageModel', () => {
430
431
 
431
432
  it('should keep original header style when tabs are disabled', () => {
432
433
  pageModel.props = {
434
+ routeId: 'route-1',
433
435
  displayTitle: true,
434
436
  enableTabs: false,
435
437
  title: 'Title',
@@ -442,6 +444,57 @@ describe('PageModel', () => {
442
444
 
443
445
  expect(header.props.style).toEqual({ backgroundColor: 'var(--colorBgLayout)' });
444
446
  });
447
+
448
+ it('should use desktop route enableTabs=false before flow model props', () => {
449
+ pageModel.props = {
450
+ routeId: 'route-1',
451
+ displayTitle: true,
452
+ enableTabs: true,
453
+ title: 'Title',
454
+ headerStyle: { backgroundColor: 'var(--colorBgLayout)' },
455
+ } as any;
456
+ (pageModel as any).context = {
457
+ currentRoute: {
458
+ enableTabs: false,
459
+ },
460
+ };
461
+ pageModel.renderTabs = vi.fn(() => null);
462
+ pageModel.renderFirstTab = vi.fn(() => null);
463
+
464
+ const result = pageModel.render() as any;
465
+ const header = result.props.children[0];
466
+
467
+ expect(pageModel.renderTabs).not.toHaveBeenCalled();
468
+ expect(pageModel.renderFirstTab).toHaveBeenCalled();
469
+ expect(header.props.style).toEqual({ backgroundColor: 'var(--colorBgLayout)' });
470
+ });
471
+
472
+ it('should use desktop route enableTabs=true before flow model props', () => {
473
+ pageModel.props = {
474
+ routeId: 'route-1',
475
+ displayTitle: true,
476
+ enableTabs: false,
477
+ title: 'Title',
478
+ headerStyle: { backgroundColor: 'var(--colorBgLayout)' },
479
+ } as any;
480
+ (pageModel as any).context = {
481
+ currentRoute: {
482
+ enableTabs: true,
483
+ },
484
+ };
485
+ pageModel.renderTabs = vi.fn(() => null);
486
+ pageModel.renderFirstTab = vi.fn(() => null);
487
+
488
+ const result = pageModel.render() as any;
489
+ const header = result.props.children[0];
490
+
491
+ expect(pageModel.renderTabs).toHaveBeenCalled();
492
+ expect(pageModel.renderFirstTab).not.toHaveBeenCalled();
493
+ expect(header.props.style).toMatchObject({
494
+ backgroundColor: 'var(--colorBgLayout)',
495
+ paddingBottom: 0,
496
+ });
497
+ });
445
498
  });
446
499
 
447
500
  describe('dirty refresh signal', () => {
@@ -574,6 +627,26 @@ describe('PageModel', () => {
574
627
  expect(document.title).toBe('Resolved tab doc title');
575
628
  });
576
629
 
630
+ it('should use page documentTitle when desktop route disables tabs even if flow model enables tabs', async () => {
631
+ pageModel.props = { routeId: 'route-1', enableTabs: true, title: 'Route page title' } as any;
632
+ (pageModel as any).context.currentRoute = {
633
+ enableTabs: false,
634
+ };
635
+ (pageModel as any).stepParams = {
636
+ pageSettings: {
637
+ general: {
638
+ documentTitle: 'Route page doc title',
639
+ },
640
+ },
641
+ };
642
+ (pageModel as any).context.resolveJsonTemplate = vi.fn(async () => 'Resolved route page doc title');
643
+
644
+ await (pageModel as any).updateDocumentTitle();
645
+
646
+ expect((pageModel as any).context.resolveJsonTemplate).toHaveBeenCalledWith('Route page doc title');
647
+ expect(document.title).toBe('Resolved route page doc title');
648
+ });
649
+
577
650
  it('should fallback to tab title when active tab documentTitle is empty', async () => {
578
651
  pageModel.props = { enableTabs: true } as any;
579
652
  const activeTab = {