@nocobase/flow-engine 2.1.0-beta.37 → 2.1.0-beta.40

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 (41) hide show
  1. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +8 -1
  2. package/lib/components/subModel/LazyDropdown.js +200 -16
  3. package/lib/data-source/index.d.ts +9 -0
  4. package/lib/data-source/index.js +12 -0
  5. package/lib/flowContext.js +3 -0
  6. package/lib/flowEngine.js +3 -3
  7. package/lib/models/flowModel.js +3 -3
  8. package/lib/utils/parsePathnameToViewParams.d.ts +5 -1
  9. package/lib/utils/parsePathnameToViewParams.js +28 -4
  10. package/lib/views/ViewNavigation.d.ts +12 -2
  11. package/lib/views/ViewNavigation.js +22 -7
  12. package/lib/views/createViewMeta.js +114 -50
  13. package/lib/views/inheritLayoutContext.d.ts +10 -0
  14. package/lib/views/inheritLayoutContext.js +50 -0
  15. package/lib/views/useDialog.js +2 -0
  16. package/lib/views/useDrawer.js +2 -0
  17. package/lib/views/usePage.js +2 -0
  18. package/package.json +4 -4
  19. package/src/__tests__/createViewMeta.popup.test.ts +115 -1
  20. package/src/__tests__/flowEngine.removeModel.test.ts +47 -3
  21. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +11 -1
  22. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +5 -2
  23. package/src/components/subModel/LazyDropdown.tsx +228 -16
  24. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +203 -1
  25. package/src/data-source/index.ts +18 -0
  26. package/src/executor/__tests__/flowExecutor.test.ts +28 -0
  27. package/src/flowContext.ts +3 -0
  28. package/src/flowEngine.ts +4 -3
  29. package/src/models/__tests__/flowEngine.resolveUse.test.ts +0 -15
  30. package/src/models/__tests__/flowModel.test.ts +33 -34
  31. package/src/models/flowModel.tsx +3 -3
  32. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +21 -0
  33. package/src/utils/parsePathnameToViewParams.ts +45 -5
  34. package/src/views/ViewNavigation.ts +40 -7
  35. package/src/views/__tests__/ViewNavigation.test.ts +52 -0
  36. package/src/views/__tests__/inheritLayoutContext.test.ts +53 -0
  37. package/src/views/createViewMeta.ts +106 -34
  38. package/src/views/inheritLayoutContext.ts +26 -0
  39. package/src/views/useDialog.tsx +2 -0
  40. package/src/views/useDrawer.tsx +2 -0
  41. package/src/views/usePage.tsx +2 -0
@@ -81,6 +81,34 @@ describe('FlowExecutor', () => {
81
81
  expect(result.step2).toBe('step2-ok');
82
82
  });
83
83
 
84
+ it('runFlow warns and skips steps without use or handler', async () => {
85
+ const flows = {
86
+ referenceSettings: {
87
+ steps: {
88
+ target: {},
89
+ },
90
+ },
91
+ } satisfies Record<string, Omit<FlowDefinitionOptions, 'key'>>;
92
+ const model = createModelWithFlows('m-empty-step', flows);
93
+ const loggerChildSpy = vi.spyOn(engine.logger, 'child').mockReturnValue(engine.logger);
94
+ const loggerWarnSpy = vi.spyOn(engine.logger, 'warn').mockImplementation(() => {});
95
+ const loggerErrorSpy = vi.spyOn(engine.logger, 'error').mockImplementation(() => {});
96
+
97
+ try {
98
+ const result = await engine.executor.runFlow(model, 'referenceSettings');
99
+
100
+ expect(result).toEqual({});
101
+ expect(loggerWarnSpy).toHaveBeenCalledWith(
102
+ "BaseModel.applyFlow: Step 'target' in flow 'referenceSettings' has neither 'use' nor 'handler'. Skipping.",
103
+ );
104
+ expect(loggerErrorSpy).not.toHaveBeenCalled();
105
+ } finally {
106
+ loggerChildSpy.mockRestore();
107
+ loggerWarnSpy.mockRestore();
108
+ loggerErrorSpy.mockRestore();
109
+ }
110
+ });
111
+
84
112
  it("dispatchEvent('beforeRender') executes flows in sort order and caches result (when options specify)", async () => {
85
113
  const calls: string[] = [];
86
114
  const mkFlow = (key: string, sort: number) => ({
@@ -3581,6 +3581,9 @@ export class FlowEngineContext extends BaseFlowEngineContext {
3581
3581
  },
3582
3582
  });
3583
3583
  this.defineMethod('aclCheck', function (params) {
3584
+ if (this.skipAclCheck) {
3585
+ return true;
3586
+ }
3584
3587
  return this.acl.aclCheck(params);
3585
3588
  });
3586
3589
  this.defineMethod('createResource', function (this: BaseFlowEngineContext, resourceType) {
package/src/flowEngine.ts CHANGED
@@ -39,6 +39,8 @@ import type {
39
39
  } from './types';
40
40
  import { isInheritedFrom } from './utils';
41
41
 
42
+ const getFlowEngineLoggerLevel = () => (process.env.NODE_ENV === 'production' ? 'warn' : 'trace');
43
+
42
44
  /**
43
45
  * FlowEngine is the core class of the flow engine, responsible for managing flow models, actions, model repository, and more.
44
46
  * It provides capabilities for registering, creating, finding, persisting, replacing, and moving models.
@@ -213,7 +215,7 @@ export class FlowEngine {
213
215
  MultiRecordResource,
214
216
  });
215
217
  this.logger = pino({
216
- level: 'trace',
218
+ level: getFlowEngineLoggerLevel(),
217
219
  browser: {
218
220
  write: {
219
221
  fatal: (o) => console.trace(o),
@@ -1009,7 +1011,6 @@ export class FlowEngine {
1009
1011
 
1010
1012
  while (current) {
1011
1013
  if (visited.has(current)) {
1012
- console.warn(`FlowEngine: resolveUse circular reference detected on '${current.name}'.`);
1013
1014
  break;
1014
1015
  }
1015
1016
  visited.add(current);
@@ -1128,7 +1129,7 @@ export class FlowEngine {
1128
1129
  */
1129
1130
  public removeModel(uid: string): boolean {
1130
1131
  if (!this._modelInstances.has(uid)) {
1131
- console.warn(`FlowEngine: Model with UID '${uid}' does not exist.`);
1132
+ this.logger.debug(`FlowEngine: Model with UID '${uid}' does not exist.`);
1132
1133
  return false;
1133
1134
  }
1134
1135
  const modelInstance = this._modelInstances.get(uid) as FlowModel;
@@ -61,21 +61,6 @@ describe('FlowEngine.createModel resolveUse hook', () => {
61
61
  expect(warnSpy).not.toHaveBeenCalled();
62
62
  });
63
63
 
64
- test('should break resolveUse on circular reference and warn', () => {
65
- class LoopModel extends FlowModel {
66
- static resolveUse() {
67
- return 'LoopModel';
68
- }
69
- }
70
-
71
- engine.registerModels({ LoopModel });
72
-
73
- const model = engine.createModel({ use: 'LoopModel', uid: 'loop-model', flowEngine: engine });
74
-
75
- expect(model).toBeInstanceOf(LoopModel);
76
- expect(warnSpy).toHaveBeenCalled();
77
- });
78
-
79
64
  test('should fall back to ErrorFlowModel when resolveUse returns unregistered name', () => {
80
65
  class MissingTargetEntry extends FlowModel {
81
66
  static resolveUse() {
@@ -370,15 +370,17 @@ describe('FlowModel', () => {
370
370
  };
371
371
 
372
372
  TestFlowModel.registerFlow(exitFlow);
373
- const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
373
+ const loggerSpy = vi.spyOn(model.flowEngine.logger, 'debug').mockImplementation(() => {});
374
374
 
375
- const result = await model.applyFlow('exitFlow');
376
-
377
- expect(result).toBeInstanceOf(FlowExitAllException);
378
- expect(exitFlow.steps.step2.handler).not.toHaveBeenCalled();
379
- expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[FlowModel]'));
375
+ try {
376
+ const result = await model.applyFlow('exitFlow');
380
377
 
381
- consoleSpy.mockRestore();
378
+ expect(result).toBeInstanceOf(FlowExitAllException);
379
+ expect(exitFlow.steps.step2.handler).not.toHaveBeenCalled();
380
+ expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('[FlowModel]'));
381
+ } finally {
382
+ loggerSpy.mockRestore();
383
+ }
382
384
  });
383
385
 
384
386
  test('should handle ctx.exit() as FlowExitAllException in beforeRender dispatch', async () => {
@@ -474,15 +476,17 @@ describe('FlowModel', () => {
474
476
  };
475
477
 
476
478
  TestFlowModel.registerFlow(exitFlow);
477
- const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
478
-
479
- const result = await model.applyFlow('exitFlow');
479
+ const loggerSpy = vi.spyOn(model.flowEngine.logger, 'debug').mockImplementation(() => {});
480
480
 
481
- expect(result).toBeInstanceOf(FlowExitAllException);
482
- expect(exitFlow.steps.step2.handler).not.toHaveBeenCalled();
483
- expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[FlowModel]'));
481
+ try {
482
+ const result = await model.applyFlow('exitFlow');
484
483
 
485
- consoleSpy.mockRestore();
484
+ expect(result).toBeInstanceOf(FlowExitAllException);
485
+ expect(exitFlow.steps.step2.handler).not.toHaveBeenCalled();
486
+ expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('[FlowModel]'));
487
+ } finally {
488
+ loggerSpy.mockRestore();
489
+ }
486
490
  });
487
491
 
488
492
  test('should propagate step execution errors', async () => {
@@ -796,7 +800,7 @@ describe('FlowModel', () => {
796
800
  const eventFlow = createEventFlowDefinition('testEvent');
797
801
  TestFlowModel.registerFlow(eventFlow);
798
802
 
799
- const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
803
+ const loggerSpy = vi.spyOn(model.flowEngine.logger, 'debug').mockImplementation(() => {});
800
804
 
801
805
  try {
802
806
  model.dispatchEvent('testEvent', { data: 'payload' });
@@ -804,7 +808,7 @@ describe('FlowModel', () => {
804
808
  // Use a more reliable approach than arbitrary timeout
805
809
  await new Promise((resolve) => setTimeout(resolve, 0));
806
810
 
807
- expect(consoleSpy).toHaveBeenCalledWith(
811
+ expect(loggerSpy).toHaveBeenCalledWith(
808
812
  expect.stringContaining('[FlowModel] dispatchEvent: uid=test-model-uid, event=testEvent'),
809
813
  );
810
814
  expect(eventFlow.steps.eventStep.handler).toHaveBeenCalledWith(
@@ -814,7 +818,7 @@ describe('FlowModel', () => {
814
818
  expect.any(Object),
815
819
  );
816
820
  } finally {
817
- consoleSpy.mockRestore();
821
+ loggerSpy.mockRestore();
818
822
  }
819
823
  });
820
824
 
@@ -1625,7 +1629,7 @@ describe('FlowModel', () => {
1625
1629
  fork1.dispose = vi.fn();
1626
1630
  fork2.dispose = vi.fn();
1627
1631
 
1628
- const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1632
+ const loggerSpy = vi.spyOn(model.flowEngine.logger, 'debug').mockImplementation(() => {});
1629
1633
 
1630
1634
  try {
1631
1635
  model.clearForks();
@@ -1634,19 +1638,19 @@ describe('FlowModel', () => {
1634
1638
  expect(fork2.dispose).toHaveBeenCalled();
1635
1639
  expect(model.forks.size).toBe(0);
1636
1640
  } finally {
1637
- consoleSpy.mockRestore();
1641
+ loggerSpy.mockRestore();
1638
1642
  }
1639
1643
  });
1640
1644
 
1641
1645
  test('should handle empty forks collection when clearing', () => {
1642
- const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1646
+ const loggerSpy = vi.spyOn(model.flowEngine.logger, 'debug').mockImplementation(() => {});
1643
1647
 
1644
1648
  try {
1645
1649
  model.clearForks();
1646
1650
 
1647
1651
  expect(model.forks.size).toBe(0);
1648
1652
  } finally {
1649
- consoleSpy.mockRestore();
1653
+ loggerSpy.mockRestore();
1650
1654
  }
1651
1655
  });
1652
1656
  });
@@ -1774,7 +1778,7 @@ describe('FlowModel', () => {
1774
1778
  test('should clean up resources on remove', () => {
1775
1779
  model.createFork();
1776
1780
  model.createFork();
1777
- const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1781
+ const loggerSpy = vi.spyOn(model.flowEngine.logger, 'debug').mockImplementation(() => {});
1778
1782
 
1779
1783
  // Mock removeModel to simulate proper fork cleanup
1780
1784
  flowEngine.removeModel = vi.fn().mockImplementation(() => {
@@ -1791,7 +1795,7 @@ describe('FlowModel', () => {
1791
1795
  expect(model.forks.size).toBe(0);
1792
1796
  expect(flowEngine.removeModel).toHaveBeenCalledWith(model.uid);
1793
1797
  } finally {
1794
- consoleSpy.mockRestore();
1798
+ loggerSpy.mockRestore();
1795
1799
  }
1796
1800
  });
1797
1801
  });
@@ -1868,17 +1872,12 @@ describe('FlowModel', () => {
1868
1872
  });
1869
1873
 
1870
1874
  test('should rerender triggers beforeRender without cache', async () => {
1871
- const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1872
1875
  model.dispatchEvent = vi.fn().mockResolvedValue(undefined) as any;
1873
1876
 
1874
- try {
1875
- await expect(model.rerender()).resolves.not.toThrow();
1876
- expect(model.dispatchEvent).toHaveBeenCalledWith('beforeRender', undefined, {
1877
- useCache: false,
1878
- });
1879
- } finally {
1880
- consoleSpy.mockRestore();
1881
- }
1877
+ await expect(model.rerender()).resolves.not.toThrow();
1878
+ expect(model.dispatchEvent).toHaveBeenCalledWith('beforeRender', undefined, {
1879
+ useCache: false,
1880
+ });
1882
1881
  });
1883
1882
  });
1884
1883
 
@@ -2918,7 +2917,7 @@ describe('FlowModel', () => {
2918
2917
  describe('Edge Cases & Error Handling', () => {
2919
2918
  test('should handle model destruction gracefully', () => {
2920
2919
  const model = new FlowModel(modelOptions);
2921
- const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
2920
+ const loggerSpy = vi.spyOn(model.flowEngine.logger, 'debug').mockImplementation(() => {});
2922
2921
 
2923
2922
  model.createFork();
2924
2923
  model.setProps({ testProp: 'value' });
@@ -2926,7 +2925,7 @@ describe('FlowModel', () => {
2926
2925
  try {
2927
2926
  expect(() => model.remove()).not.toThrow();
2928
2927
  } finally {
2929
- consoleSpy.mockRestore();
2928
+ loggerSpy.mockRestore();
2930
2929
  }
2931
2930
  });
2932
2931
 
@@ -823,7 +823,7 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
823
823
  }
824
824
  const isFork = (this as any).isFork === true;
825
825
  const target = this;
826
- console.log(
826
+ currentFlowEngine.logger.debug(
827
827
  `[FlowModel] applyFlow: uid=${this.uid}, flowKey=${flowKey}, isFork=${isFork}, cleanRun=${
828
828
  this.cleanRun
829
829
  }, targetIsFork=${(target as any)?.isFork === true}`,
@@ -843,7 +843,7 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
843
843
  }
844
844
  const isFork = (this as any).isFork === true;
845
845
  const target = this;
846
- console.log(
846
+ currentFlowEngine.logger.debug(
847
847
  `[FlowModel] dispatchEvent: uid=${this.uid}, event=${eventName}, isFork=${isFork}, cleanRun=${
848
848
  this.cleanRun
849
849
  }, targetIsFork=${(target as any)?.isFork === true}`,
@@ -1379,7 +1379,7 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
1379
1379
  }
1380
1380
 
1381
1381
  clearForks() {
1382
- console.log(`FlowModel ${this.uid} clearing all forks.`);
1382
+ this.flowEngine.logger.debug(`FlowModel ${this.uid} clearing all forks.`);
1383
1383
  // 主动使所有 fork 失效
1384
1384
  if (this.forks?.size) {
1385
1385
  this.forks.forEach((fork) => fork.dispose());
@@ -102,6 +102,27 @@ describe('parsePathnameToViewParams', () => {
102
102
  expect(result).toEqual([{ viewUid: 'xxx', tabUid: 'yyy' }]);
103
103
  });
104
104
 
105
+ test('should parse custom root prefix', () => {
106
+ const result = parsePathnameToViewParams('/embed/xxx/tab/yyy/view/zzz', { rootPrefix: 'embed' });
107
+ expect(result).toEqual([{ viewUid: 'xxx', tabUid: 'yyy' }, { viewUid: 'zzz' }]);
108
+ });
109
+
110
+ test('should parse pathname by basePath', () => {
111
+ const result = parsePathnameToViewParams('/embed/xxx/tab/yyy/view/zzz', { basePath: '/embed' });
112
+ expect(result).toEqual([{ viewUid: 'xxx', tabUid: 'yyy' }, { viewUid: 'zzz' }]);
113
+ });
114
+
115
+ test('should parse pathname by nested basePath', () => {
116
+ const result = parsePathnameToViewParams('/admin/settings/public-forms/xxx/view/zzz', {
117
+ basePath: '/admin/settings/public-forms',
118
+ });
119
+ expect(result).toEqual([{ viewUid: 'xxx' }, { viewUid: 'zzz' }]);
120
+ });
121
+
122
+ test('should keep admin as default root prefix', () => {
123
+ expect(parsePathnameToViewParams('/embed/xxx')).toEqual([]);
124
+ });
125
+
105
126
  test('should parse filterByTk from key-value encoded segment into object', () => {
106
127
  const kv = encodeURIComponent('id=1&tenant=ac');
107
128
  const path = `/admin/xxx/filterbytk/${kv}`;
@@ -18,6 +18,35 @@ export interface ViewParam {
18
18
  sourceId?: string;
19
19
  }
20
20
 
21
+ export interface ParsePathnameToViewParamsOptions {
22
+ rootPrefix?: string;
23
+ basePath?: string;
24
+ }
25
+
26
+ const normalizePathname = (pathname: string) => {
27
+ if (!pathname || pathname === '/') {
28
+ return '/';
29
+ }
30
+ return `/${pathname.replace(/^\/+/, '').replace(/\/+$/, '')}`;
31
+ };
32
+
33
+ const normalizeBasePath = (basePath: string) => `/${basePath.replace(/^\/+/, '').replace(/\/+$/, '')}`;
34
+
35
+ const stripBasePath = (pathname: string, basePath: string) => {
36
+ const normalizedPathname = normalizePathname(pathname);
37
+ const normalizedBasePath = normalizeBasePath(basePath);
38
+
39
+ if (normalizedPathname === normalizedBasePath) {
40
+ return '';
41
+ }
42
+
43
+ if (normalizedPathname.startsWith(`${normalizedBasePath}/`)) {
44
+ return normalizedPathname.slice(normalizedBasePath.length + 1);
45
+ }
46
+
47
+ return '';
48
+ };
49
+
21
50
  /**
22
51
  * 解析路径名为视图参数数组
23
52
  *
@@ -33,15 +62,21 @@ export interface ViewParam {
33
62
  * parsePathnameToViewParams('/admin/xxx/view/yyy') // [{ viewUid: 'xxx' }, { viewUid: 'yyy' }]
34
63
  * ```
35
64
  */
36
- export const parsePathnameToViewParams = (pathname: string): ViewParam[] => {
65
+ export const parsePathnameToViewParams = (
66
+ pathname: string,
67
+ options: ParsePathnameToViewParamsOptions = {},
68
+ ): ViewParam[] => {
37
69
  if (!pathname || pathname === '/') {
38
70
  return [];
39
71
  }
40
72
 
73
+ const rootPrefix = options.rootPrefix || 'admin';
74
+ const relativePath = options.basePath ? stripBasePath(pathname, options.basePath) : '';
75
+
41
76
  // 移除开头的斜杠并分割路径
42
- const segments = pathname.replace(/^\/+/, '').split('/').filter(Boolean);
77
+ const segments = (options.basePath ? relativePath : pathname).replace(/^\/+/, '').split('/').filter(Boolean);
43
78
 
44
- if (segments.length < 2) {
79
+ if (segments.length < (options.basePath ? 1 : 2)) {
45
80
  return [];
46
81
  }
47
82
 
@@ -49,11 +84,16 @@ export const parsePathnameToViewParams = (pathname: string): ViewParam[] => {
49
84
  let currentView: ViewParam | null = null;
50
85
  let i = 0;
51
86
 
87
+ if (options.basePath) {
88
+ currentView = { viewUid: segments[0] };
89
+ i = 1;
90
+ }
91
+
52
92
  while (i < segments.length) {
53
93
  const segment = segments[i];
54
94
 
55
- // 处理 admin 或 view 关键字
56
- if (segment === 'admin' || segment === 'view') {
95
+ // 处理布局根前缀或 view 关键字
96
+ if (segment === rootPrefix || segment === 'view') {
57
97
  // 如果有当前视图,先保存到结果中
58
98
  if (currentView) {
59
99
  result.push(currentView);
@@ -13,6 +13,16 @@ import { ViewParam as SharedViewParam } from '../utils';
13
13
 
14
14
  type ViewParams = Omit<SharedViewParam, 'viewUid'> & { viewUid?: string };
15
15
 
16
+ export interface GeneratePathnameFromViewParamsOptions {
17
+ prefix?: string;
18
+ basePath?: string;
19
+ }
20
+
21
+ export interface ViewNavigationOptions {
22
+ basePath?: string;
23
+ layoutBasePath?: string;
24
+ }
25
+
16
26
  function encodeFilterByTk(val: SharedViewParam['filterByTk']): string {
17
27
  if (val === undefined || val === null) return '';
18
28
  // 1.x 兼容:对象按 key1=v1&key2=v2 拼接后整体 encodeURIComponent
@@ -30,6 +40,11 @@ function hasUsableSourceId(sourceId: unknown): sourceId is string | number {
30
40
  return sourceId !== undefined && sourceId !== null && String(sourceId) !== '';
31
41
  }
32
42
 
43
+ function normalizeBasePath(basePath?: string) {
44
+ const value = basePath || '/admin';
45
+ return `/${value.replace(/^\/+/, '').replace(/\/+$/, '')}`;
46
+ }
47
+
33
48
  /**
34
49
  * 将 ViewParam 数组转换为 pathname
35
50
  *
@@ -43,12 +58,17 @@ function hasUsableSourceId(sourceId: unknown): sourceId is string | number {
43
58
  * generatePathnameFromViewParams([{ viewUid: 'xxx' }, { viewUid: 'yyy' }]) // '/admin/xxx/view/yyy'
44
59
  * ```
45
60
  */
46
- export function generatePathnameFromViewParams(viewParams: ViewParams[]): string {
61
+ export function generatePathnameFromViewParams(
62
+ viewParams: ViewParams[],
63
+ options: GeneratePathnameFromViewParamsOptions = {},
64
+ ): string {
65
+ const basePath = normalizeBasePath(options.basePath || options.prefix);
66
+
47
67
  if (!viewParams || viewParams.length === 0) {
48
- return '/admin';
68
+ return basePath;
49
69
  }
50
70
 
51
- const segments = ['admin'];
71
+ const segments = basePath.replace(/^\/+/, '').split('/').filter(Boolean);
52
72
 
53
73
  viewParams.forEach((viewParam, index) => {
54
74
  // 如果不是第一个视图,添加 'view' 关键字
@@ -81,10 +101,12 @@ export class ViewNavigation {
81
101
  viewStack: ReadonlyArray<ViewParams>; // 只能通过 setViewStack 修改
82
102
  ctx: FlowEngineContext;
83
103
  viewParams: ViewParams;
104
+ private readonly basePath?: string;
84
105
 
85
- constructor(ctx: FlowEngineContext, viewParams: ViewParams[]) {
106
+ constructor(ctx: FlowEngineContext, viewParams: ViewParams[], options: ViewNavigationOptions = {}) {
86
107
  this.setViewStack(viewParams);
87
108
  this.ctx = ctx;
109
+ this.basePath = options.basePath || options.layoutBasePath;
88
110
 
89
111
  define(this, {
90
112
  viewParams: observable,
@@ -106,7 +128,7 @@ export class ViewNavigation {
106
128
  });
107
129
 
108
130
  // 2. 根据 viewStack 生成新的 pathname
109
- const newPathname = generatePathnameFromViewParams(newViewStack);
131
+ const newPathname = generatePathnameFromViewParams(newViewStack, { basePath: this.getLayoutBasePath() });
110
132
 
111
133
  // 3. 触发一次跳转。使用 replace 的方式
112
134
  this.ctx.router.navigate(newPathname, { replace: true });
@@ -115,7 +137,9 @@ export class ViewNavigation {
115
137
  navigateTo(viewParam: ViewParams, opts?: { replace?: boolean; state?: any }) {
116
138
  // 1. 基于当前 viewStack 生成一个 pathname
117
139
  // 2. 将当前传入的参数转为 path string
118
- const newViewPathname = generatePathnameFromViewParams([...this.viewStack, viewParam]);
140
+ const newViewPathname = generatePathnameFromViewParams([...this.viewStack, viewParam], {
141
+ basePath: this.getLayoutBasePath(),
142
+ });
119
143
 
120
144
  // 3. 与 pathname 拼接成新的 pathname(这里直接使用新生成的 pathname)
121
145
  const newPathname = newViewPathname;
@@ -126,7 +150,16 @@ export class ViewNavigation {
126
150
 
127
151
  back() {
128
152
  const prevStack = this.viewStack.slice(0, -1);
129
- const prevPath = generatePathnameFromViewParams(prevStack);
153
+ const prevPath = generatePathnameFromViewParams(prevStack, { basePath: this.getLayoutBasePath() });
130
154
  this.ctx.router.navigate(prevPath, { replace: true });
131
155
  }
156
+
157
+ private getLayoutBasePath() {
158
+ const routePath = (this.ctx as any).layout?.routePath;
159
+ return (
160
+ this.basePath ||
161
+ (this.ctx as any).layoutRoute?.basePathname ||
162
+ (routePath?.startsWith('/') ? routePath : '/admin')
163
+ );
164
+ }
132
165
  }
@@ -146,6 +146,47 @@ describe('ViewNavigation', () => {
146
146
 
147
147
  expect(mockCtx.router.navigate).toHaveBeenCalledWith('/admin', { replace: true });
148
148
  });
149
+
150
+ it('should use explicit basePath when navigating back', () => {
151
+ viewNavigation = new ViewNavigation(mockCtx, [{ viewUid: 'view1' }], { basePath: '/embed' });
152
+
153
+ viewNavigation.back();
154
+
155
+ expect(mockCtx.router.navigate).toHaveBeenCalledWith('/embed', { replace: true });
156
+ });
157
+
158
+ it('should use layout route basePathname when explicit basePath is absent', () => {
159
+ mockCtx.layoutRoute = {
160
+ basePathname: '/mobile',
161
+ };
162
+ viewNavigation = new ViewNavigation(mockCtx, [{ viewUid: 'view1' }]);
163
+
164
+ viewNavigation.back();
165
+
166
+ expect(mockCtx.router.navigate).toHaveBeenCalledWith('/mobile', { replace: true });
167
+ });
168
+
169
+ it('should fall back to layout routePath when explicit basePath is absent', () => {
170
+ mockCtx.layout = {
171
+ routePath: '/mobile',
172
+ };
173
+ viewNavigation = new ViewNavigation(mockCtx, [{ viewUid: 'view1' }]);
174
+
175
+ viewNavigation.back();
176
+
177
+ expect(mockCtx.router.navigate).toHaveBeenCalledWith('/mobile', { replace: true });
178
+ });
179
+
180
+ it('should ignore relative layout routePath when runtime basePathname is absent', () => {
181
+ mockCtx.layout = {
182
+ routePath: 'public-forms',
183
+ };
184
+ viewNavigation = new ViewNavigation(mockCtx, [{ viewUid: 'view1' }]);
185
+
186
+ viewNavigation.back();
187
+
188
+ expect(mockCtx.router.navigate).toHaveBeenCalledWith('/admin', { replace: true });
189
+ });
149
190
  });
150
191
  });
151
192
 
@@ -160,6 +201,17 @@ describe('generatePathnameFromViewParams', () => {
160
201
  expect(generatePathnameFromViewParams([{ viewUid: 'xxx' }])).toBe('/admin/xxx');
161
202
  });
162
203
 
204
+ it('should generate path with custom prefix', () => {
205
+ expect(generatePathnameFromViewParams([{ viewUid: 'xxx' }], { basePath: '/embed' })).toBe('/embed/xxx');
206
+ expect(generatePathnameFromViewParams([], { basePath: '/embed' })).toBe('/embed');
207
+ });
208
+
209
+ it('should generate path with nested basePath', () => {
210
+ expect(generatePathnameFromViewParams([{ viewUid: 'xxx' }], { basePath: '/admin/settings/public-forms' })).toBe(
211
+ '/admin/settings/public-forms/xxx',
212
+ );
213
+ });
214
+
163
215
  it('should generate view with tab', () => {
164
216
  expect(generatePathnameFromViewParams([{ viewUid: 'xxx', tabUid: 'yyy' }])).toBe('/admin/xxx/tab/yyy');
165
217
  });
@@ -0,0 +1,53 @@
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 } from 'vitest';
11
+ import { FlowContext } from '../../flowContext';
12
+ import { inheritLayoutContextForDetachedView } from '../inheritLayoutContext';
13
+
14
+ describe('inheritLayoutContextForDetachedView', () => {
15
+ it('inherits layout context for detached view contexts', () => {
16
+ const sourceContext = new FlowContext();
17
+ const engineContext = new FlowContext();
18
+ const layoutContext = new FlowContext();
19
+ const viewContext = new FlowContext();
20
+
21
+ engineContext.defineProperty('skipAclCheck', { value: false });
22
+ layoutContext.defineProperty('skipAclCheck', { value: true });
23
+ sourceContext.defineProperty('engine', {
24
+ value: {
25
+ context: engineContext,
26
+ },
27
+ });
28
+ sourceContext.defineProperty('layoutContext', { value: layoutContext });
29
+
30
+ viewContext.addDelegate(engineContext);
31
+ inheritLayoutContextForDetachedView(viewContext, sourceContext);
32
+
33
+ expect(viewContext.skipAclCheck).toBe(true);
34
+ });
35
+
36
+ it('does nothing when source context has no layout context', () => {
37
+ const sourceContext = new FlowContext();
38
+ const engineContext = new FlowContext();
39
+ const viewContext = new FlowContext();
40
+
41
+ engineContext.defineProperty('skipAclCheck', { value: false });
42
+ sourceContext.defineProperty('engine', {
43
+ value: {
44
+ context: engineContext,
45
+ },
46
+ });
47
+
48
+ viewContext.addDelegate(engineContext);
49
+ inheritLayoutContextForDetachedView(viewContext, sourceContext);
50
+
51
+ expect(viewContext.skipAclCheck).toBe(false);
52
+ });
53
+ });