@zat-design/sisyphus-react 4.5.9-beta.1 → 4.5.10-beta.1

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 (55) hide show
  1. package/dist/index.esm.css +1 -1
  2. package/dist/less.esm.css +1 -1
  3. package/es/ProAction/components/CheckModalContent/index.js +2 -2
  4. package/es/ProConfigProvider/index.js +12 -3
  5. package/es/ProDownload/index.d.ts +2 -1
  6. package/es/ProDownload/index.js +48 -35
  7. package/es/ProDownload/utils.d.ts +10 -5
  8. package/es/ProDownload/utils.js +28 -13
  9. package/es/ProEditTable/utils/index.js +6 -1
  10. package/es/ProEnum/hooks/useEnumRequest.js +98 -77
  11. package/es/ProEnum/index.js +3 -4
  12. package/es/ProForm/components/base/InputNumber/index.js +1 -1
  13. package/es/ProForm/components/combination/FormList/components/ActionButton.js +2 -2
  14. package/es/ProForm/components/combination/FormList/components/BlockFields.js +1 -0
  15. package/es/ProForm/components/combination/FormList/components/Empty.js +1 -1
  16. package/es/ProForm/components/combination/FormList/components/ToolbarButton.js +3 -3
  17. package/es/ProForm/components/combination/FormList/style/index.less +6 -0
  18. package/es/ProForm/components/combination/Group/component/FlexibleGroup.js +5 -11
  19. package/es/ProForm/components/combination/Group/hooks/index.js +1 -1
  20. package/es/ProForm/components/combination/Group/index.js +1 -1
  21. package/es/ProForm/components/combination/ProModalSelect/hooks/useRequestList.js +22 -8
  22. package/es/ProForm/components/combination/ProModalSelect/index.js +32 -12
  23. package/es/ProForm/components/combination/ProRangeLimit/index.js +1 -1
  24. package/es/ProForm/components/render/Render.js +2 -2
  25. package/es/ProForm/hooks/useWatch.js +7 -8
  26. package/es/ProForm/utils/index.js +2 -2
  27. package/es/ProLayout/components/Layout/Menu/OpenMenu/index.js +8 -8
  28. package/es/ProLayout/components/Layout/Menu/index.js +1 -1
  29. package/es/ProLayout/components/ProFooter/index.js +2 -1
  30. package/es/ProLayout/components/ProHeader/components/Describe/index.js +3 -1
  31. package/es/ProLayout/components/TabsManager/components/TabItem.js +6 -5
  32. package/es/ProLayout/components/TabsManager/hooks/useTabsState.js +104 -16
  33. package/es/ProLayout/components/TabsManager/hooks/useTabsUrlSync.d.ts +19 -0
  34. package/es/ProLayout/components/TabsManager/hooks/useTabsUrlSync.js +295 -0
  35. package/es/ProLayout/components/TabsManager/index.js +54 -14
  36. package/es/ProLayout/components/TabsManager/propTypes.d.ts +4 -0
  37. package/es/ProLayout/components/TabsManager/style/index.less +1 -1
  38. package/es/ProLayout/components/TabsManager/utils/historyObserver.d.ts +7 -0
  39. package/es/ProLayout/components/TabsManager/utils/historyObserver.js +62 -0
  40. package/es/ProLayout/components/TabsManager/utils/index.d.ts +15 -2
  41. package/es/ProLayout/components/TabsManager/utils/index.js +63 -9
  42. package/es/ProLayout/images/logoImg.png +0 -0
  43. package/es/ProLayout/index.js +6 -3
  44. package/es/ProLayout/propTypes.d.ts +28 -1
  45. package/es/ProSelect/index.js +10 -1
  46. package/es/ProSelect/style/index.less +13 -0
  47. package/es/ProStep/style/index.less +1 -1
  48. package/es/ProTable/hooks/useAntdTable.js +11 -10
  49. package/es/ProTabs/index.js +3 -3
  50. package/es/ProTooltip/index.js +2 -5
  51. package/es/ProTree/components/ProTreeSelect/index.js +2 -2
  52. package/es/ProTreeModal/index.js +1 -4
  53. package/es/ProUpload/components/DragRender.js +0 -14
  54. package/package.json +5 -1
  55. package/es/ProConfigProvider/demo/locale.js +0 -58
@@ -3,7 +3,7 @@ import { message } from 'antd';
3
3
  import { DEFAULT_TABS_CONFIG } from "../propTypes";
4
4
  import { useTabsCache } from "./useTabsCache";
5
5
  import locale, { formatMessage } from "../../../../locale";
6
- import { createTabFromMenu, generateTabId, shouldOpenExternal, handleExternalOpen, checkTabLimit, getRightTabs, flattenMenuData, canOpenAsTab, getMenuRoute, findExistingTab, resolveTabMenuItem, derivePinned, getPinnedBoundary, pinTab as pinTabInList, unpinTab as unpinTabInList, reorderWithPinConstraint } from "../utils";
6
+ import { createTabFromMenu, generateTabId, shouldOpenExternal, handleExternalOpen, checkTabLimit, getRightTabs, flattenMenuData, canOpenAsTab, getMenuRoute, findExistingTab, normalizeTabUrl, isSafeExternalUrl, isSafeHttpUrl, resolveTabMenuItem, derivePinned, getPinnedBoundary, pinTab as pinTabInList, unpinTab as unpinTabInList, reorderWithPinConstraint } from "../utils";
7
7
 
8
8
  /**
9
9
  * 标签页状态管理Hook
@@ -21,6 +21,7 @@ export const useTabsState = options => {
21
21
  ...DEFAULT_TABS_CONFIG,
22
22
  ...config
23
23
  };
24
+ const urlSyncEnabled = finalConfig.urlSync === true || typeof finalConfig.urlSync === 'object' && finalConfig.urlSync !== null;
24
25
 
25
26
  // 初始化状态
26
27
  const [state, setState] = useState(() => ({
@@ -47,7 +48,10 @@ export const useTabsState = options => {
47
48
  const [isInitialized, setIsInitialized] = useState(false);
48
49
 
49
50
  // 从缓存恢复状态
51
+ const hasRestoredCacheRef = useRef(false);
50
52
  useEffect(() => {
53
+ if (hasRestoredCacheRef.current) return;
54
+ hasRestoredCacheRef.current = true;
51
55
  const cachedState = restoreFromCache();
52
56
  if (cachedState && cachedState.tabsList && cachedState.tabsList.length > 0) {
53
57
  // 兼容旧缓存:如果缓存中有 activeTab 但没有 activeTabInfo,进行迁移
@@ -67,11 +71,33 @@ export const useTabsState = options => {
67
71
  pinned: tab.pinned === undefined ? derivePinned(tab) : tab.pinned
68
72
  }));
69
73
  restoredState.activeKey = restoredState.activeKey === undefined || restoredState.activeKey === null ? '' : String(restoredState.activeKey);
74
+ if (urlSyncEnabled) {
75
+ const retainedTabByUrl = new Map();
76
+ const retainedIdByOriginalId = new Map();
77
+ restoredState.tabsList.forEach(tab => {
78
+ const normalizedUrl = normalizeTabUrl(tab.url);
79
+ if (!normalizedUrl) return;
80
+ const retainedTab = retainedTabByUrl.get(normalizedUrl);
81
+ if (retainedTab) {
82
+ retainedIdByOriginalId.set(tab.id, retainedTab.id);
83
+ return;
84
+ }
85
+ const normalizedTab = {
86
+ ...tab,
87
+ url: normalizedUrl
88
+ };
89
+ retainedTabByUrl.set(normalizedUrl, normalizedTab);
90
+ retainedIdByOriginalId.set(tab.id, normalizedTab.id);
91
+ });
92
+ restoredState.tabsList = Array.from(retainedTabByUrl.values());
93
+ restoredState.activeKey = retainedIdByOriginalId.get(restoredState.activeKey) || restoredState.tabsList[0]?.id || '';
94
+ }
70
95
  restoredState.activeTabInfo = restoredState.tabsList.find(tab => tab.id === restoredState.activeKey);
96
+ restoredState.activeComponent = restoredState.activeTabInfo ? restoredState.activeTabInfo.menuItem?.code || restoredState.activeTabInfo.id : '';
71
97
  setState(restoredState);
72
98
  }
73
99
  setIsInitialized(true);
74
- }, [restoreFromCache]);
100
+ }, [restoreFromCache, urlSyncEnabled]);
75
101
  const lastActiveKeyRef = useRef('');
76
102
  const shouldSkipSaveRef = useRef(false);
77
103
  const initialTabsAppliedRef = useRef(false);
@@ -128,9 +154,11 @@ export const useTabsState = options => {
128
154
  return false;
129
155
  }
130
156
 
131
- // 如果 forceNew = false(默认),检查是否已存在相同的标签页
132
- if (!forceNew) {
133
- const existingTab = findExistingTab(state.tabsList, menuItem);
157
+ // URL 同步模式下完整 URL 始终唯一,forceNew 不能绕过身份匹配。
158
+ if (urlSyncEnabled || !forceNew) {
159
+ const existingTab = findExistingTab(state.tabsList, menuItem, {
160
+ urlSync: urlSyncEnabled
161
+ });
134
162
  if (existingTab) {
135
163
  // 如果已存在,可以添加(会切换到已存在的标签页)
136
164
  return true;
@@ -143,7 +171,7 @@ export const useTabsState = options => {
143
171
  return false;
144
172
  }
145
173
  return true;
146
- }, [state.tabsList, finalConfig]);
174
+ }, [state.tabsList, finalConfig, urlSyncEnabled]);
147
175
 
148
176
  /**
149
177
  * 添加标签页
@@ -156,15 +184,33 @@ export const useTabsState = options => {
156
184
  setState(prevState => {
157
185
  if (!canOpenAsTab(menuItem)) return prevState;
158
186
  if (shouldOpenExternal(menuItem)) {
159
- handleExternalOpen(menuItem);
187
+ const externalUrl = getMenuRoute(menuItem);
188
+ if (!urlSyncEnabled || isSafeHttpUrl(externalUrl)) {
189
+ handleExternalOpen(menuItem, urlSyncEnabled);
190
+ }
191
+ return prevState;
192
+ }
193
+ const rawUrl = getMenuRoute(menuItem);
194
+ const normalizedUrl = urlSyncEnabled ? normalizeTabUrl(rawUrl) : undefined;
195
+ if (urlSyncEnabled && rawUrl && !normalizedUrl) {
196
+ if (isSafeExternalUrl(rawUrl)) {
197
+ handleExternalOpen(menuItem, true);
198
+ }
160
199
  return prevState;
161
200
  }
201
+ const resolvedMenuItem = normalizedUrl ? {
202
+ ...menuItem,
203
+ url: normalizedUrl,
204
+ redirectUrl: undefined
205
+ } : menuItem;
162
206
 
163
207
  // 非强制新建时,查找已存在的标签页并切换
164
- const existingTab = !forceNew ? findExistingTab(prevState.tabsList, menuItem) : undefined;
208
+ const existingTab = urlSyncEnabled || !forceNew ? findExistingTab(prevState.tabsList, resolvedMenuItem, {
209
+ urlSync: urlSyncEnabled
210
+ }) : undefined;
165
211
  if (existingTab) {
166
212
  // 若来源菜单明确标记 closable: false 而已有 tab 未继承,则修正
167
- const correctedTab = menuItem.closable === false && existingTab.closable !== false ? {
213
+ const correctedTab = resolvedMenuItem.closable === false && existingTab.closable !== false ? {
168
214
  ...existingTab,
169
215
  closable: false
170
216
  } : existingTab;
@@ -184,8 +230,8 @@ export const useTabsState = options => {
184
230
  return prevState;
185
231
  }
186
232
  const existingIds = prevState.tabsList.map(tab => tab.id);
187
- const tabId = generateTabId(menuItem, existingIds);
188
- const newTab = createTabFromMenu(menuItem, prevState.newTabIndex);
233
+ const tabId = generateTabId(resolvedMenuItem, existingIds);
234
+ const newTab = createTabFromMenu(resolvedMenuItem, prevState.newTabIndex);
189
235
  newTab.id = tabId;
190
236
  const pinnedBoundary = getPinnedBoundary(prevState.tabsList);
191
237
  const nextTabsList = derivePinned(newTab) ? [...prevState.tabsList.slice(0, pinnedBoundary), newTab, ...prevState.tabsList.slice(pinnedBoundary)] : [...prevState.tabsList, newTab];
@@ -198,10 +244,10 @@ export const useTabsState = options => {
198
244
  activeKey: shouldActivate ? tabId : prevState.activeKey,
199
245
  activeTabInfo: shouldActivate ? newTab : prevState.activeTabInfo,
200
246
  newTabIndex: prevState.newTabIndex + 1,
201
- activeComponent: shouldActivate ? menuItem.code || tabId : prevState.activeComponent
247
+ activeComponent: shouldActivate ? resolvedMenuItem.code || tabId : prevState.activeComponent
202
248
  };
203
249
  });
204
- }, [finalConfig]);
250
+ }, [finalConfig, urlSyncEnabled]);
205
251
 
206
252
  // 普通初始标签只在当前 TabsManager 挂载周期应用一次。
207
253
  // 缓存恢复已有标签时只消费默认值;closeAll 后 ref 保持 true,避免再次补签。
@@ -344,6 +390,46 @@ export const useTabsState = options => {
344
390
  });
345
391
  }, []);
346
392
 
393
+ /**
394
+ * Router blocker 拒绝 URL 导航时,将激活状态还原到上一个已提交页签。
395
+ * 首次打开页签就被拦截时,同时移除未提交的新页签。
396
+ */
397
+ const rollbackTabActivation = useCallback((previousTabId, attemptedTabId, removeAttemptedTab) => {
398
+ setState(prevState => {
399
+ if (prevState.activeKey !== attemptedTabId) return prevState;
400
+ const nextTabsList = removeAttemptedTab ? prevState.tabsList.filter(tab => tab.id !== attemptedTabId) : prevState.tabsList;
401
+ const previousTab = nextTabsList.find(tab => tab.id === previousTabId);
402
+ return {
403
+ ...prevState,
404
+ tabsList: nextTabsList,
405
+ activeKey: previousTab?.id || '',
406
+ activeTabInfo: previousTab,
407
+ activeComponent: previousTab ? previousTab.menuItem?.code || previousTab.id : ''
408
+ };
409
+ });
410
+ }, []);
411
+
412
+ /**
413
+ * 更新页签当前 URL,保持 tab.id 和 menuItem 的菜单基础路由不变。
414
+ */
415
+ const updateTabUrl = useCallback((tabId, url) => {
416
+ setState(prevState => {
417
+ const targetIndex = prevState.tabsList.findIndex(tab => tab.id === tabId);
418
+ if (targetIndex === -1 || prevState.tabsList[targetIndex].url === url) return prevState;
419
+ const nextTab = {
420
+ ...prevState.tabsList[targetIndex],
421
+ url
422
+ };
423
+ const nextTabsList = [...prevState.tabsList];
424
+ nextTabsList[targetIndex] = nextTab;
425
+ return {
426
+ ...prevState,
427
+ tabsList: nextTabsList,
428
+ activeTabInfo: prevState.activeKey === tabId ? nextTab : prevState.activeTabInfo
429
+ };
430
+ });
431
+ }, []);
432
+
347
433
  /**
348
434
  * 关闭其他标签页
349
435
  */
@@ -474,9 +560,9 @@ export const useTabsState = options => {
474
560
  });
475
561
  }, []);
476
562
 
477
- // 监听 URL 变化并同步 Tabs (仅在初始化和 location 变化时)
478
- // 放在最后以确保可以使用 addTab 和 switchTab
563
+ // URL 不同步模式保留旧有的 pathname -> Tabs 初始匹配,但不写回 URL。
479
564
  useEffect(() => {
565
+ if (urlSyncEnabled) return;
480
566
  if (!dataSource || !('menus' in dataSource)) return;
481
567
  const currentPath = window.location.pathname;
482
568
  const activeTab = state.tabsList.find(tab => tab.id === state.activeKey);
@@ -490,7 +576,7 @@ export const useTabsState = options => {
490
576
  return;
491
577
  }
492
578
  addTab(targetMenu);
493
- }, [dataSource, state.tabsList, state.activeKey, addTab, switchTab]);
579
+ }, [urlSyncEnabled, dataSource, state.tabsList, state.activeKey, addTab, switchTab]);
494
580
  return {
495
581
  state,
496
582
  isInitialized,
@@ -498,7 +584,9 @@ export const useTabsState = options => {
498
584
  canAddTab,
499
585
  removeTab,
500
586
  updateTab,
587
+ updateTabUrl,
501
588
  switchTab,
589
+ rollbackTabActivation,
502
590
  closeOtherTabs,
503
591
  closeRightTabs,
504
592
  closeAllTabs,
@@ -0,0 +1,19 @@
1
+ import type { TabsConfig, TabsState } from '../../../propTypes';
2
+ import type { TabsManagerProps, UseTabsStateReturn } from '../propTypes';
3
+ interface UseTabsUrlSyncOptions {
4
+ config: TabsConfig;
5
+ dataSource: TabsManagerProps['dataSource'];
6
+ state: TabsState;
7
+ isInitialized: boolean;
8
+ addTab: UseTabsStateReturn['addTab'];
9
+ switchTab: UseTabsStateReturn['switchTab'];
10
+ updateTabUrl: UseTabsStateReturn['updateTabUrl'];
11
+ rollbackTabActivation: UseTabsStateReturn['rollbackTabActivation'];
12
+ interactionSequence: number;
13
+ }
14
+ /**
15
+ * 维护浏览器 location 与活动页签 URL 的双向同步。
16
+ * 未开启 config.urlSync 时不安装任何 History 监听或写入逻辑。
17
+ */
18
+ export declare const useTabsUrlSync: ({ config, dataSource, state, isInitialized, addTab, switchTab, updateTabUrl, rollbackTabActivation, interactionSequence, }: UseTabsUrlSyncOptions) => void;
19
+ export {};
@@ -0,0 +1,295 @@
1
+ import { createContext, useContext, useEffect, useRef, useState } from 'react';
2
+ import * as ReactRouterDom from 'react-router-dom';
3
+ import { canOpenAsTab, flattenMenuData, getCurrentTabUrl, getMenuRoute, getTabUrlPathname, normalizeTabUrl } from "../utils";
4
+ import { subscribeHistoryLocation } from "../utils/historyObserver";
5
+ const getOptionalRouterContext = exportName => {
6
+ const descriptor = Object.getOwnPropertyDescriptor(ReactRouterDom, exportName);
7
+ return descriptor ? Reflect.get(ReactRouterDom, exportName) : undefined;
8
+ };
9
+ const OptionalNavigationContext = getOptionalRouterContext('UNSAFE_NavigationContext') || /*#__PURE__*/createContext(null);
10
+
11
+ // Data Router 从 react-router-dom 6.4 起提供;保留 6.0-6.3 peer 兼容。
12
+ const OptionalDataRouterContext = getOptionalRouterContext('UNSAFE_DataRouterContext') || /*#__PURE__*/createContext(null);
13
+ const getDataRouterUrl = (url, basename) => {
14
+ if (!basename || basename === '/') return url;
15
+ const pathname = getTabUrlPathname(url);
16
+ if (!pathname) return url;
17
+ const normalizedBasename = `/${basename.replace(/^\/+|\/+$/g, '')}`;
18
+ const lowerPathname = pathname.toLowerCase();
19
+ const lowerBasename = normalizedBasename.toLowerCase();
20
+ const suffix = url.slice(pathname.length);
21
+ if (lowerPathname === lowerBasename) return `/${suffix}`;
22
+ if (lowerPathname.startsWith(`${lowerBasename}/`)) {
23
+ return `${pathname.slice(normalizedBasename.length)}${suffix}`;
24
+ }
25
+ return url;
26
+ };
27
+ const isCurrentDataRouterNavigation = (currentUrl, dataRouterContext) => {
28
+ const navigationLocation = dataRouterContext?.router.state?.navigation?.location;
29
+ if (!navigationLocation) return false;
30
+ const navigationUrl = `${navigationLocation.pathname}${navigationLocation.search ?? ''}${navigationLocation.hash ?? ''}`;
31
+ return navigationUrl === currentUrl || navigationUrl === getDataRouterUrl(currentUrl, dataRouterContext?.basename);
32
+ };
33
+ const isSameOrNestedPath = (basePathname, currentPathname) => {
34
+ if (!basePathname || !currentPathname) return false;
35
+ const normalizedBase = basePathname === '/' ? '/' : basePathname.replace(/\/$/, '');
36
+ return currentPathname === normalizedBase || normalizedBase !== '/' && currentPathname.startsWith(`${normalizedBase}/`);
37
+ };
38
+ const getSourceMenus = dataSource => {
39
+ if (!dataSource || !('menus' in dataSource) || !Array.isArray(dataSource.menus)) return [];
40
+ return flattenMenuData(dataSource.menus);
41
+ };
42
+ const createPushHistoryState = currentState => {
43
+ if (!currentState || typeof currentState !== 'object' || Array.isArray(currentState)) {
44
+ return currentState;
45
+ }
46
+ const nextState = {
47
+ ...currentState
48
+ };
49
+ const currentIndex = nextState.idx;
50
+ const currentKey = nextState.key;
51
+ const isReactRouterState = 'usr' in nextState && typeof currentIndex === 'number' && typeof currentKey === 'string';
52
+ if (isReactRouterState) {
53
+ nextState.idx = currentIndex + 1;
54
+ nextState.key = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
55
+ }
56
+ return nextState;
57
+ };
58
+ const getRouterLocationState = currentState => {
59
+ if (!currentState || typeof currentState !== 'object' || Array.isArray(currentState)) {
60
+ return currentState;
61
+ }
62
+ const state = currentState;
63
+ if (typeof state.idx !== 'number') return currentState;
64
+ return 'usr' in state ? state.usr : null;
65
+ };
66
+ /**
67
+ * 维护浏览器 location 与活动页签 URL 的双向同步。
68
+ * 未开启 config.urlSync 时不安装任何 History 监听或写入逻辑。
69
+ */
70
+ export const useTabsUrlSync = ({
71
+ config,
72
+ dataSource,
73
+ state,
74
+ isInitialized,
75
+ addTab,
76
+ switchTab,
77
+ updateTabUrl,
78
+ rollbackTabActivation,
79
+ interactionSequence
80
+ }) => {
81
+ const navigationContext = useContext(OptionalNavigationContext);
82
+ const dataRouterContext = useContext(OptionalDataRouterContext);
83
+ const navigator = navigationContext?.navigator;
84
+ const enabled = config.urlSync === true || typeof config.urlSync === 'object' && config.urlSync !== null;
85
+ const historyMode = typeof config.urlSync === 'object' ? config.urlSync.history || 'replace' : 'replace';
86
+ const bootstrappedRef = useRef(false);
87
+ const writingUrlRef = useRef(false);
88
+ const preserveUrlInteractionRef = useRef(null);
89
+ const locationSequenceRef = useRef(0);
90
+ const handledLocationSequenceRef = useRef(undefined);
91
+ const committedActiveKeyRef = useRef(state.activeKey);
92
+ const committedTabIdsRef = useRef(new Set(state.tabsList.map(tab => tab.id)));
93
+ const navigationAttemptRef = useRef(0);
94
+ const pendingDataNavigationRef = useRef(false);
95
+ const pendingDataNavigationUrlRef = useRef(null);
96
+ const ignoredHistoryUrlRef = useRef(null);
97
+ const [locationChange, setLocationChange] = useState(() => ({
98
+ url: typeof window === 'undefined' ? '' : getCurrentTabUrl(),
99
+ source: 'initial',
100
+ sequence: 0
101
+ }));
102
+
103
+ // History API 不会为 pushState/replaceState 触发 popstate,在挂载期间补齐通知。
104
+ useEffect(() => {
105
+ if (!enabled || typeof window === 'undefined') return undefined;
106
+ return subscribeHistoryLocation(source => {
107
+ if (writingUrlRef.current) return;
108
+ const currentUrl = getCurrentTabUrl();
109
+ if (ignoredHistoryUrlRef.current === currentUrl) {
110
+ ignoredHistoryUrlRef.current = null;
111
+ return;
112
+ }
113
+ const pendingDataNavigation = pendingDataNavigationRef.current;
114
+ const pendingDataNavigationUrl = pendingDataNavigationUrlRef.current;
115
+ const isPendingDataNavigationCommit = pendingDataNavigation && (pendingDataNavigationUrl === currentUrl || isCurrentDataRouterNavigation(currentUrl, dataRouterContext));
116
+ if (!isPendingDataNavigationCommit) {
117
+ navigationAttemptRef.current += 1;
118
+ }
119
+ pendingDataNavigationRef.current = false;
120
+ pendingDataNavigationUrlRef.current = isPendingDataNavigationCommit ? currentUrl : null;
121
+ if (pendingDataNavigation && !isPendingDataNavigationCommit && dataRouterContext) {
122
+ ignoredHistoryUrlRef.current = currentUrl;
123
+ const cancellation = dataRouterContext.router.navigate(getDataRouterUrl(currentUrl, dataRouterContext.basename), {
124
+ replace: true,
125
+ state: getRouterLocationState(window.history.state)
126
+ });
127
+ void cancellation.finally(() => {
128
+ if (ignoredHistoryUrlRef.current === currentUrl) {
129
+ ignoredHistoryUrlRef.current = null;
130
+ }
131
+ });
132
+ }
133
+ preserveUrlInteractionRef.current = null;
134
+ locationSequenceRef.current += 1;
135
+ setLocationChange({
136
+ url: currentUrl,
137
+ source,
138
+ sequence: locationSequenceRef.current
139
+ });
140
+ });
141
+ }, [enabled, dataRouterContext]);
142
+
143
+ // URL -> Tabs:精确 URL 优先;同 pathname 视为当前页签内导航;
144
+ // 其次才按菜单 pathname 创建新页签。
145
+ useEffect(() => {
146
+ if (!enabled || !isInitialized || typeof window === 'undefined') return;
147
+ const currentUrl = normalizeTabUrl(locationChange.url);
148
+ if (!currentUrl) return;
149
+ if (handledLocationSequenceRef.current === locationChange.sequence) return;
150
+ const exactTab = state.tabsList.find(tab => normalizeTabUrl(tab.url) === currentUrl);
151
+ if (exactTab) {
152
+ handledLocationSequenceRef.current = locationChange.sequence;
153
+ bootstrappedRef.current = true;
154
+ preserveUrlInteractionRef.current = null;
155
+ if (state.activeKey !== exactTab.id) {
156
+ switchTab(exactTab.id);
157
+ }
158
+ return;
159
+ }
160
+ const currentPathname = getTabUrlPathname(currentUrl);
161
+ const sourceMenus = getSourceMenus(dataSource);
162
+ const activeTab = state.tabsList.find(tab => tab.id === state.activeKey);
163
+ const activeCode = activeTab?.menuItem?.code || activeTab?.code;
164
+ const activeSourceMenu = activeCode ? sourceMenus.find(item => item.code === activeCode) : undefined;
165
+ const activeMenuItem = activeSourceMenu || activeTab?.menuItem;
166
+ const activeBasePathname = activeMenuItem ? getTabUrlPathname(getMenuRoute(activeMenuItem)) : getTabUrlPathname(activeTab?.url);
167
+ const targetMenu = sourceMenus.map(item => ({
168
+ item,
169
+ pathname: getTabUrlPathname(getMenuRoute(item))
170
+ })).filter(entry => !!entry.pathname && isSameOrNestedPath(entry.pathname, currentPathname)).sort((left, right) => right.pathname.length - left.pathname.length)[0]?.item;
171
+ const targetBelongsToActiveMenu = !targetMenu || !!activeCode && targetMenu.code === activeCode;
172
+ if (activeTab && locationChange.source !== 'initial' && targetBelongsToActiveMenu && isSameOrNestedPath(activeBasePathname, currentPathname)) {
173
+ handledLocationSequenceRef.current = locationChange.sequence;
174
+ bootstrappedRef.current = true;
175
+ preserveUrlInteractionRef.current = null;
176
+ updateTabUrl(activeTab.id, currentUrl);
177
+ return;
178
+ }
179
+ if (targetMenu && canOpenAsTab(targetMenu)) {
180
+ handledLocationSequenceRef.current = locationChange.sequence;
181
+ bootstrappedRef.current = true;
182
+ preserveUrlInteractionRef.current = interactionSequence;
183
+ addTab({
184
+ ...targetMenu,
185
+ url: currentUrl,
186
+ redirectUrl: undefined
187
+ });
188
+ return;
189
+ }
190
+ if (!bootstrappedRef.current) {
191
+ bootstrappedRef.current = true;
192
+ preserveUrlInteractionRef.current = interactionSequence;
193
+ return;
194
+ }
195
+ if (activeTab && locationChange.source !== 'initial') {
196
+ handledLocationSequenceRef.current = locationChange.sequence;
197
+ preserveUrlInteractionRef.current = null;
198
+ navigationAttemptRef.current += 1;
199
+ updateTabUrl(activeTab.id, currentUrl);
200
+ return;
201
+ }
202
+ if (locationChange.source !== 'initial') {
203
+ handledLocationSequenceRef.current = locationChange.sequence;
204
+ }
205
+ }, [enabled, isInitialized, locationChange, state.tabsList, state.activeKey, dataSource, addTab, switchTab, updateTabUrl, interactionSequence]);
206
+
207
+ // Tabs -> URL:默认用 replace 避免切签堆积浏览器历史。
208
+ useEffect(() => {
209
+ if (!enabled || !isInitialized || !bootstrappedRef.current || typeof window === 'undefined') {
210
+ return;
211
+ }
212
+ const activeTab = state.tabsList.find(tab => tab.id === state.activeKey);
213
+ const targetUrl = normalizeTabUrl(activeTab?.url);
214
+ const commitActiveTab = () => {
215
+ committedActiveKeyRef.current = state.activeKey;
216
+ committedTabIdsRef.current = new Set(state.tabsList.map(tab => tab.id));
217
+ };
218
+ if (preserveUrlInteractionRef.current !== null) {
219
+ if (preserveUrlInteractionRef.current === interactionSequence) {
220
+ if (targetUrl === getCurrentTabUrl()) {
221
+ preserveUrlInteractionRef.current = null;
222
+ }
223
+ commitActiveTab();
224
+ return;
225
+ }
226
+ preserveUrlInteractionRef.current = null;
227
+ }
228
+ if (!targetUrl || targetUrl === getCurrentTabUrl()) {
229
+ const pendingNavigation = pendingDataNavigationRef.current;
230
+ navigationAttemptRef.current += 1;
231
+ pendingDataNavigationRef.current = false;
232
+ pendingDataNavigationUrlRef.current = null;
233
+ if (pendingNavigation && dataRouterContext) {
234
+ const cancellationUrl = getCurrentTabUrl();
235
+ ignoredHistoryUrlRef.current = cancellationUrl;
236
+ const cancellation = dataRouterContext.router.navigate(getDataRouterUrl(cancellationUrl, dataRouterContext.basename), {
237
+ replace: true,
238
+ state: getRouterLocationState(window.history.state)
239
+ });
240
+ void cancellation.finally(() => {
241
+ if (ignoredHistoryUrlRef.current === cancellationUrl) {
242
+ ignoredHistoryUrlRef.current = null;
243
+ }
244
+ });
245
+ }
246
+ commitActiveTab();
247
+ return;
248
+ }
249
+ handledLocationSequenceRef.current = locationChange.sequence;
250
+ const previousActiveKey = committedActiveKeyRef.current;
251
+ const attemptedActiveKey = state.activeKey;
252
+ const removeAttemptedTab = !committedTabIdsRef.current.has(attemptedActiveKey);
253
+ const navigationAttempt = navigationAttemptRef.current + 1;
254
+ navigationAttemptRef.current = navigationAttempt;
255
+ writingUrlRef.current = true;
256
+ try {
257
+ const routerState = getRouterLocationState(window.history.state);
258
+ if (dataRouterContext) {
259
+ const settleNavigation = () => {
260
+ if (navigationAttemptRef.current !== navigationAttempt) return;
261
+ const committedDataNavigationUrl = pendingDataNavigationUrlRef.current;
262
+ pendingDataNavigationRef.current = false;
263
+ pendingDataNavigationUrlRef.current = null;
264
+ const currentUrl = getCurrentTabUrl();
265
+ if (targetUrl === currentUrl || committedDataNavigationUrl === currentUrl) {
266
+ commitActiveTab();
267
+ return;
268
+ }
269
+ rollbackTabActivation(previousActiveKey, attemptedActiveKey, removeAttemptedTab);
270
+ };
271
+ pendingDataNavigationRef.current = true;
272
+ pendingDataNavigationUrlRef.current = targetUrl;
273
+ void dataRouterContext.router.navigate(getDataRouterUrl(targetUrl, dataRouterContext.basename), {
274
+ replace: historyMode === 'replace',
275
+ state: routerState
276
+ }).then(settleNavigation);
277
+ } else if (navigator) {
278
+ if (historyMode === 'push') {
279
+ navigator.push(targetUrl, routerState);
280
+ } else {
281
+ navigator.replace(targetUrl, routerState);
282
+ }
283
+ commitActiveTab();
284
+ } else if (historyMode === 'push') {
285
+ window.history.pushState(createPushHistoryState(window.history.state), '', targetUrl);
286
+ commitActiveTab();
287
+ } else {
288
+ window.history.replaceState(window.history.state, '', targetUrl);
289
+ commitActiveTab();
290
+ }
291
+ } finally {
292
+ writingUrlRef.current = false;
293
+ }
294
+ }, [enabled, historyMode, isInitialized, state.tabsList, state.activeKey, interactionSequence, navigator, dataRouterContext, rollbackTabActivation]);
295
+ };
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useMemo, forwardRef, useImperativeHandle, useState, useEffect, useLayoutEffect, useRef } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import { useTabsState } from "./hooks/useTabsState";
4
+ import { useTabsUrlSync } from "./hooks/useTabsUrlSync";
4
5
  import { useIframeRoute } from "./hooks/useIframeRoute";
5
6
  import { flattenMenuData, resolveTabMenuItem, derivePinned } from "./utils";
6
7
  import { TabItemComponent } from "./components/TabItem";
@@ -22,12 +23,12 @@ const TabsManager = /*#__PURE__*/forwardRef(({
22
23
  }, ref) => {
23
24
  // iframe 模式检测(在 useTabsState 之前,因为 cacheEnabled 依赖 pure)
24
25
  // addTab 由后续 useTabsState 提供,此处先用占位,后面用 ref 透传
25
- const iframeAddTabRef = useRef(() => {});
26
+ const iframeAddTabRef = useRef(undefined);
26
27
  const {
27
28
  pure: isIframePure
28
29
  } = useIframeRoute({
29
30
  config: config?.iframe,
30
- addTab: params => iframeAddTabRef.current(params)
31
+ addTab: params => iframeAddTabRef.current?.(params)
31
32
  });
32
33
 
33
34
  // 通知父级(ProLayout)pure 状态变化
@@ -46,7 +47,9 @@ const TabsManager = /*#__PURE__*/forwardRef(({
46
47
  canAddTab,
47
48
  removeTab,
48
49
  updateTab,
50
+ updateTabUrl,
49
51
  switchTab,
52
+ rollbackTabActivation,
50
53
  closeOtherTabs,
51
54
  closeRightTabs,
52
55
  closeAllTabs,
@@ -58,6 +61,21 @@ const TabsManager = /*#__PURE__*/forwardRef(({
58
61
  dataSource,
59
62
  cacheEnabled: !isIframePure
60
63
  });
64
+ const tabInteractionSequenceRef = useRef(0);
65
+ const markTabInteraction = useCallback(() => {
66
+ tabInteractionSequenceRef.current += 1;
67
+ }, []);
68
+ useTabsUrlSync({
69
+ config,
70
+ dataSource,
71
+ state,
72
+ isInitialized,
73
+ addTab,
74
+ switchTab,
75
+ updateTabUrl,
76
+ rollbackTabActivation,
77
+ interactionSequence: tabInteractionSequenceRef.current
78
+ });
61
79
 
62
80
  // 桥接 useIframeRoute 的 { code, name, extra } → addTab(menuItem)
63
81
  iframeAddTabRef.current = params => {
@@ -212,10 +230,11 @@ const TabsManager = /*#__PURE__*/forwardRef(({
212
230
  // 处理菜单点击 - 拦截原有的菜单点击逻辑
213
231
  const handleMenuClick = useCallback(params => {
214
232
  if (params.item) {
233
+ markTabInteraction();
215
234
  addTab(params.item);
216
235
  }
217
236
  originalOnMenuClick?.(params);
218
- }, [addTab, originalOnMenuClick]);
237
+ }, [addTab, markTabInteraction, originalOnMenuClick]);
219
238
 
220
239
  // 切换固定状态:已固定→取消固定,未固定→固定
221
240
  const handleTogglePin = useCallback(tabId => {
@@ -231,11 +250,15 @@ const TabsManager = /*#__PURE__*/forwardRef(({
231
250
  // 创建标签页实例 API
232
251
  const tabsInstance = useMemo(() => ({
233
252
  addTab: (params, options) => {
253
+ markTabInteraction();
234
254
  const sourceMenus = Array.isArray(dataSource?.menus) ? dataSource.menus : [];
235
255
  const menuItem = resolveTabMenuItem(params, sourceMenus);
236
256
  addTab(menuItem, options);
237
257
  },
238
- removeTab,
258
+ removeTab: tabId => {
259
+ markTabInteraction();
260
+ removeTab(tabId);
261
+ },
239
262
  updateTab,
240
263
  getTabInfo: () => ({
241
264
  tabsList: state.tabsList,
@@ -245,15 +268,32 @@ const TabsManager = /*#__PURE__*/forwardRef(({
245
268
  pushView,
246
269
  back,
247
270
  getViewStack
248
- }), [addTab, removeTab, updateTab, state.tabsList, state.activeKey, state.activeComponent, dataSource, pushView, back, getViewStack]);
271
+ }), [addTab, markTabInteraction, removeTab, updateTab, state.tabsList, state.activeKey, state.activeComponent, dataSource, pushView, back, getViewStack]);
249
272
  const handleTabChange = useCallback(activeKey => {
273
+ markTabInteraction();
250
274
  switchTab(activeKey);
251
- }, [switchTab]);
275
+ }, [markTabInteraction, switchTab]);
276
+ const handleRemoveTab = useCallback(tabId => {
277
+ markTabInteraction();
278
+ removeTab(tabId);
279
+ }, [markTabInteraction, removeTab]);
280
+ const handleCloseOtherTabs = useCallback(tabId => {
281
+ markTabInteraction();
282
+ closeOtherTabs(tabId);
283
+ }, [closeOtherTabs, markTabInteraction]);
284
+ const handleCloseRightTabs = useCallback(tabId => {
285
+ markTabInteraction();
286
+ closeRightTabs(tabId);
287
+ }, [closeRightTabs, markTabInteraction]);
288
+ const handleCloseAllTabs = useCallback(() => {
289
+ markTabInteraction();
290
+ closeAllTabs();
291
+ }, [closeAllTabs, markTabInteraction]);
252
292
  const handleTabEdit = useCallback((targetKey, action) => {
253
293
  if (action === 'remove') {
254
- removeTab(targetKey);
294
+ handleRemoveTab(targetKey);
255
295
  }
256
- }, [removeTab]);
296
+ }, [handleRemoveTab]);
257
297
 
258
298
  // 构建 Tabs items
259
299
  const tabsItems = useMemo(() => {
@@ -269,11 +309,11 @@ const TabsManager = /*#__PURE__*/forwardRef(({
269
309
  label: /*#__PURE__*/_jsx(TabItemComponent, {
270
310
  tab: tab,
271
311
  active: tab.id === state.activeKey,
272
- onClick: () => switchTab(tab.id),
273
- onClose: () => removeTab(tab.id),
274
- onCloseOthers: () => closeOtherTabs(tab.id),
275
- onCloseRight: () => closeRightTabs(tab.id),
276
- onCloseAll: closeAllTabs,
312
+ onClick: () => handleTabChange(tab.id),
313
+ onClose: () => handleRemoveTab(tab.id),
314
+ onCloseOthers: () => handleCloseOtherTabs(tab.id),
315
+ onCloseRight: () => handleCloseRightTabs(tab.id),
316
+ onCloseAll: handleCloseAllTabs,
277
317
  pinned: pinned,
278
318
  pinDisabled: pinDisabled,
279
319
  onTogglePin: () => handleTogglePin(tab.id),
@@ -286,7 +326,7 @@ const TabsManager = /*#__PURE__*/forwardRef(({
286
326
  children: null
287
327
  };
288
328
  });
289
- }, [state.tabsList, state.activeKey, config?.menuItems, config?.tabMenuClick, config?.fixed, switchTab, removeTab, closeOtherTabs, closeRightTabs, closeAllTabs, handleTogglePin]);
329
+ }, [state.tabsList, state.activeKey, config?.menuItems, config?.tabMenuClick, config?.fixed, handleTabChange, handleRemoveTab, handleCloseOtherTabs, handleCloseRightTabs, handleCloseAllTabs, handleTogglePin]);
290
330
  const activeComponent = config?.activeComponent;
291
331
  const emptyComponent = config?.empty;
292
332