vue-stack-tabs 1.0.1 → 1.3.0

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 (116) hide show
  1. package/README.en.md +526 -0
  2. package/README.md +337 -520
  3. package/README.ru.md +526 -0
  4. package/dist/iframe-bridge.d.ts +8 -0
  5. package/dist/iframe-bridge.html +77 -0
  6. package/dist/iframe-bridge.mjs +20 -0
  7. package/dist/iframe-child.html +88 -0
  8. package/dist/nuxt/favicon.svg +10 -0
  9. package/dist/nuxt/iframe-bridge.html +77 -0
  10. package/dist/nuxt/iframe-child.html +88 -0
  11. package/dist/nuxt/module.d.ts +6 -0
  12. package/dist/nuxt/module.mjs +28 -0
  13. package/dist/nuxt/plugin.mjs +9 -0
  14. package/dist/nuxt/runtime/plugin.d.ts +2 -0
  15. package/dist/nuxt/runtime/plugin.mjs +9 -0
  16. package/dist/src/lib/StackTabs.vue.d.ts +30 -16
  17. package/dist/src/lib/components/ContextMenu/ContextMenuItem.vue.d.ts +7 -1
  18. package/dist/src/lib/components/ContextMenu/index.vue.d.ts +5 -4
  19. package/dist/src/lib/components/PageLoading.vue.d.ts +2 -1
  20. package/dist/src/lib/components/StackKeepAlive/StackCacheRenderer.vue.d.ts +25 -0
  21. package/dist/src/lib/components/StackKeepAlive/StackKeepAlive.vue.d.ts +12 -0
  22. package/dist/src/lib/components/TabHeader/TabHeaderButton.vue.d.ts +11 -12
  23. package/dist/src/lib/components/TabHeader/TabHeaderItem.vue.d.ts +5 -4
  24. package/dist/src/lib/components/TabHeader/TabHeaderScroll.vue.d.ts +8 -22
  25. package/dist/src/lib/components/TabHeader/index.vue.d.ts +11 -164
  26. package/dist/src/lib/hooks/stackTabsContext.d.ts +49 -0
  27. package/dist/src/lib/hooks/tabPanel/evict.d.ts +21 -23
  28. package/dist/src/lib/hooks/tabPanel/navigationTransaction.d.ts +35 -0
  29. package/dist/src/lib/hooks/tabPanel/refresh.d.ts +17 -0
  30. package/dist/src/lib/hooks/tabPanel/scroll.d.ts +8 -8
  31. package/dist/src/lib/hooks/tabPanel/session.d.ts +9 -8
  32. package/dist/src/lib/hooks/tabPanel/state.d.ts +20 -73
  33. package/dist/src/lib/hooks/useIframeManager.d.ts +35 -0
  34. package/dist/src/lib/hooks/useTabActions.d.ts +5 -46
  35. package/dist/src/lib/hooks/useTabEventBus.d.ts +22 -9
  36. package/dist/src/lib/hooks/useTabPanel.d.ts +17 -74
  37. package/dist/src/lib/hooks/useTabRouter.d.ts +5 -5
  38. package/dist/src/lib/i18n/index.d.ts +6 -4
  39. package/dist/src/lib/i18n/lang/en.d.ts +5 -0
  40. package/dist/src/lib/i18n/lang/zh-CN.d.ts +5 -0
  41. package/dist/src/lib/i18n/stackTabsLocale.d.ts +2 -0
  42. package/dist/src/lib/iframe-bridge.d.ts +8 -0
  43. package/dist/src/lib/iframe.vue.d.ts +2 -1
  44. package/dist/src/lib/index.d.ts +4 -5
  45. package/dist/src/lib/model/TabModel.d.ts +14 -33
  46. package/dist/src/lib/nuxt/module.d.ts +6 -0
  47. package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +2 -0
  48. package/dist/src/lib/nuxt/runtime/plugin.d.ts +2 -0
  49. package/dist/src/lib/utils/iframeBridge.d.ts +11 -3
  50. package/dist/src/lib/utils/stackTabsA11y.d.ts +8 -0
  51. package/dist/src/lib/utils/stackTabsMessage.d.ts +7 -0
  52. package/dist/src/lib/utils/tabInfoEncoder.d.ts +1 -16
  53. package/dist/src/lib/utils/typeGuards.d.ts +2 -0
  54. package/dist/src/lib/utils/urlParser.d.ts +24 -3
  55. package/dist/src/lib/versionLogger.d.ts +1 -0
  56. package/dist/style.css +2 -1
  57. package/dist/utils/iframeBridge.d.ts +39 -0
  58. package/dist/vue-stack-tabs.css +2 -1
  59. package/dist/vue-stack-tabs.es.js +1 -2411
  60. package/package.json +68 -47
  61. package/src/lib/StackTabs.vue +216 -192
  62. package/src/lib/assets/style/stackTab.scss +84 -12
  63. package/src/lib/components/ContextMenu/ContextMenuItem.vue +23 -11
  64. package/src/lib/components/ContextMenu/index.vue +94 -10
  65. package/src/lib/components/PageLoading.vue +9 -7
  66. package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +70 -0
  67. package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +57 -0
  68. package/src/lib/components/TabHeader/TabHeaderButton.vue +19 -8
  69. package/src/lib/components/TabHeader/TabHeaderItem.vue +45 -16
  70. package/src/lib/components/TabHeader/TabHeaderScroll.vue +47 -25
  71. package/src/lib/components/TabHeader/index.vue +109 -12
  72. package/src/lib/hooks/stackTabsContext.ts +135 -0
  73. package/src/lib/hooks/tabPanel/evict.ts +123 -41
  74. package/src/lib/hooks/tabPanel/navigationTransaction.ts +61 -0
  75. package/src/lib/hooks/tabPanel/refresh.ts +109 -0
  76. package/src/lib/hooks/tabPanel/scroll.ts +57 -36
  77. package/src/lib/hooks/tabPanel/session.ts +84 -22
  78. package/src/lib/hooks/tabPanel/state.ts +41 -44
  79. package/src/lib/hooks/useIframeManager.ts +253 -0
  80. package/src/lib/hooks/useTabActions.ts +62 -30
  81. package/src/lib/hooks/useTabEventBus.ts +29 -19
  82. package/src/lib/hooks/useTabLoading.ts +3 -2
  83. package/src/lib/hooks/useTabPanel.tsx +415 -213
  84. package/src/lib/hooks/useTabRouter.ts +172 -43
  85. package/src/lib/i18n/index.ts +53 -45
  86. package/src/lib/i18n/lang/en.ts +6 -1
  87. package/src/lib/i18n/lang/zh-CN.ts +6 -1
  88. package/src/lib/i18n/stackTabsLocale.ts +8 -0
  89. package/src/lib/iframe-bridge.ts +12 -0
  90. package/src/lib/index.ts +14 -19
  91. package/src/lib/model/TabModel.ts +34 -86
  92. package/src/lib/nuxt/module.d.ts +4 -0
  93. package/src/lib/nuxt/module.ts +8 -3
  94. package/src/lib/nuxt/runtime/plugin.client.ts +2 -3
  95. package/src/lib/nuxt/runtime/plugin.ts +11 -0
  96. package/src/lib/utils/iframeBridge.ts +29 -5
  97. package/src/lib/utils/scrollUtils.ts +9 -4
  98. package/src/lib/utils/stackTabsA11y.ts +12 -0
  99. package/src/lib/utils/stackTabsMessage.ts +99 -0
  100. package/src/lib/utils/tabInfoEncoder.ts +69 -42
  101. package/src/lib/utils/typeGuards.ts +3 -0
  102. package/src/lib/utils/urlParser.ts +144 -14
  103. package/src/lib/versionLogger.ts +7 -19
  104. package/dist/src/lib/hooks/tabPanel/evict.spec.d.ts +0 -1
  105. package/dist/src/lib/hooks/useTabPanel.spec.d.ts +0 -1
  106. package/dist/src/lib/model/TabModel.spec.d.ts +0 -1
  107. package/dist/src/lib/utils/iframeBridge.spec.d.ts +0 -1
  108. package/dist/src/lib/utils/tabInfoEncoder.spec.d.ts +0 -1
  109. package/dist/src/lib/utils/urlParser.spec.d.ts +0 -1
  110. package/dist/vue-stack-tabs.umd.js +0 -1
  111. package/src/lib/hooks/tabPanel/evict.spec.ts +0 -85
  112. package/src/lib/hooks/useTabPanel.spec.ts +0 -34
  113. package/src/lib/model/TabModel.spec.ts +0 -40
  114. package/src/lib/utils/iframeBridge.spec.ts +0 -37
  115. package/src/lib/utils/tabInfoEncoder.spec.ts +0 -42
  116. package/src/lib/utils/urlParser.spec.ts +0 -45
@@ -12,51 +12,42 @@
12
12
  * 使用范围:内部 hook,由 StackTabs、useTabActions、useTabRouter、ContextMenu 调用
13
13
  */
14
14
  import type { ITabBase, ITabData, ITabItem, ITabPage } from '../model/TabModel'
15
- import { defineComponent, onActivated, onDeactivated, onMounted, onUnmounted } from 'vue'
15
+ import {
16
+ defineComponent,
17
+ onActivated,
18
+ onDeactivated,
19
+ onMounted,
20
+ onUnmounted,
21
+ computed,
22
+ ref,
23
+ shallowRef,
24
+ cloneVNode
25
+ } from 'vue'
16
26
  import type { DefineComponent, VNode } from 'vue'
17
- import { useRouter } from 'vue-router'
27
+ import { isNavigationFailure, useRouter } from 'vue-router'
18
28
  import type { RouteLocationNormalizedLoaded } from 'vue-router'
19
- import { ulid } from 'ulid'
20
29
  import { encodeTabInfo, createPageId, decodeTabInfo } from '../utils/tabInfoEncoder'
21
30
  import { defu } from 'defu'
22
31
  import { Stack } from '../model/TabModel'
23
- import { parseUrl, isCrossOriginUrl } from '../utils/urlParser'
32
+ import {
33
+ parseUrl,
34
+ isCrossOriginUrl,
35
+ omitStackTabsReservedQuery,
36
+ toSafeTabUrl,
37
+ decodeSafeTabUrl,
38
+ cloneLocationQuery,
39
+ clonePage
40
+ } from '../utils/urlParser'
24
41
  import PageLoading from '../components/PageLoading.vue'
25
42
  import { TabEventType, useTabEmitter } from './useTabEventBus'
43
+ import { runNavigationTransaction } from './tabPanel/navigationTransaction'
26
44
  import { useI18n } from 'vue-i18n-lite'
27
45
 
28
- import {
29
- tabs,
30
- defaultTabs,
31
- caches,
32
- components,
33
- tabIdsToEvict,
34
- refreshKey,
35
- excludedCacheIdsForRefresh,
36
- iframeRefreshKeys,
37
- isInitialized,
38
- maxTabCount,
39
- useGlobalScroll,
40
- cacheIdsToEvict,
41
- setMaxTabCount,
42
- setUseGlobalScroll,
43
- setSessionPrefix
44
- } from './tabPanel/state'
45
- import {
46
- markCacheForEviction,
47
- markTabPagesForEviction,
48
- markTabPagesForEvictionOnly,
49
- addCache,
50
- removeCache,
51
- evictMarkedCaches
52
- } from './tabPanel/evict'
53
- import { restoreScroller, saveScroller, removeScroller, addPageScroller } from './tabPanel/scroll'
54
- import {
55
- saveActiveTabToSession,
56
- clearSession,
57
- restoreTabFromSession,
58
- getSessionKey
59
- } from './tabPanel/session'
46
+ import { resolveStackTabsRuntimeContext } from './stackTabsContext'
47
+ import { createTabPanelEviction } from './tabPanel/evict'
48
+ import { createTabPanelScroll } from './tabPanel/scroll'
49
+ import { createTabPanelSession } from './tabPanel/session'
50
+ import { createTabPanelRefresh } from './tabPanel/refresh'
60
51
 
61
52
  /* eslint-disable vue/one-component-per-file */
62
53
  /** 占位组件:当路由组件为空或标签已被标记删除时返回 */
@@ -67,6 +58,11 @@ const EmptyPlaceholderComponent = defineComponent({
67
58
  }
68
59
  })
69
60
 
61
+ const cloneRouteQuery = (route: RouteLocationNormalizedLoaded, tabInfo: ITabBase) => ({
62
+ ...cloneLocationQuery(route.query),
63
+ __tab: route.query.__tab ?? encodeTabInfo(tabInfo)
64
+ })
65
+
70
66
  /** 规范化路径(nginx 等可能加尾部斜杠) */
71
67
  export const normalizePathForCache = (route: RouteLocationNormalizedLoaded): string => {
72
68
  const lastMatch = route.matched[route.matched.length - 1]
@@ -79,6 +75,37 @@ export default () => {
79
75
  const router = useRouter()
80
76
  const emitter = useTabEmitter()
81
77
  const { t } = useI18n()
78
+ const runtimeContext = resolveStackTabsRuntimeContext()
79
+ const {
80
+ tabs,
81
+ defaultTabs,
82
+ caches,
83
+ components,
84
+ tabIdsToEvict,
85
+ refreshKey,
86
+ iframeRefreshKeys,
87
+ isInitialized,
88
+ cacheIdsToEvict
89
+ } = runtimeContext
90
+ const {
91
+ markCacheForEviction,
92
+ markTabPagesForEviction,
93
+ markTabPagesForEvictionOnly,
94
+ addCache,
95
+ removeCache,
96
+ evictMarkedCaches,
97
+ evictPageCache,
98
+ replacePageCaches
99
+ } = createTabPanelEviction(runtimeContext)
100
+ const { restoreScroller, saveScroller, removeScroller, addPageScroller } =
101
+ createTabPanelScroll(runtimeContext)
102
+ const {
103
+ saveActiveTabToSession,
104
+ clearSession,
105
+ restoreTabFromSession,
106
+ restoreActiveTabSession,
107
+ getSessionKey
108
+ } = createTabPanelSession(runtimeContext)
82
109
 
83
110
  const size = (): number => tabs.value.length
84
111
 
@@ -87,80 +114,114 @@ export default () => {
87
114
  * @param staticTabs - 初始标签配置
88
115
  */
89
116
  const initialize = (staticTabs: ITabData[]) => {
117
+ const newDefaults: ITabItem[] = []
118
+ const newTabs: ITabItem[] = []
119
+
90
120
  for (const item of staticTabs) {
91
- const fullItem = defu(item, { id: ulid(), refreshable: true, closable: true, iframe: false })
92
- const tabId = fullItem.id ?? ulid()
93
- fullItem.id = tabId
94
- const uri = parseUrl(fullItem.path)
95
- const config = defu(fullItem, {
96
- closable: true,
121
+ const fullItem = defu(item, {
122
+ id: crypto.randomUUID(),
97
123
  refreshable: true,
124
+ closable: true,
98
125
  iframe: false
99
126
  })
100
- const cacheName = createPageId(tabId, uri.path, uri.query)
127
+ const tabId = fullItem.id ?? crypto.randomUUID()
128
+ fullItem.id = tabId
129
+ const uri = parseUrl(fullItem.path)
130
+ const cacheName = createPageId()
101
131
  const page: ITabPage = {
102
132
  id: cacheName,
103
133
  tabId,
104
134
  path: uri.path,
105
- query: defu(uri.query, { __tab: encodeTabInfo(fullItem) })
135
+ query: defu(omitStackTabsReservedQuery(uri.query), { __tab: encodeTabInfo(fullItem) })
106
136
  }
107
137
  const pages = new Stack<ITabPage>()
108
138
  pages.push(page)
109
139
  const tab: ITabItem = {
110
140
  id: tabId,
111
- title: config.title ?? '',
112
- closable: config.closable,
113
- refreshable: config.refreshable,
114
- iframe: config.iframe,
115
- iframeRefreshMode: (config as { iframeRefreshMode?: string }).iframeRefreshMode === 'reload' ? 'reload' : 'postMessage',
141
+ title: fullItem.title ?? '',
142
+ closable: fullItem.closable!,
143
+ refreshable: fullItem.refreshable!,
144
+ iframe: fullItem.iframe!,
145
+ iframeRefreshMode:
146
+ (fullItem as { iframeRefreshMode?: string }).iframeRefreshMode === 'reload'
147
+ ? 'reload'
148
+ : 'postMessage',
116
149
  active: false,
117
150
  pages
118
151
  }
119
152
 
120
153
  addCache(cacheName)
121
- defaultTabs.push(tab)
122
- tabs.value.push({ ...tab })
154
+ newDefaults.push(tab)
155
+ newTabs.push({ ...tab })
123
156
  }
124
157
 
158
+ defaultTabs.value = [...defaultTabs.value, ...newDefaults]
159
+ tabs.value = [...tabs.value, ...newTabs]
160
+
125
161
  const storedTabJson = sessionStorage.getItem(getSessionKey())
126
162
  const restoredTab = restoreTabFromSession(storedTabJson)
127
- if (restoredTab && !tabs.value.some((t) => t.id === restoredTab!.id)) {
163
+ if (restoredTab && !tabs.value.some((t) => t.id === restoredTab.id)) {
164
+ if (restoredTab.url) restoredTab.url = toSafeTabUrl(restoredTab.url)
128
165
  if (restoredTab.iframe && restoredTab.url && restoredTab.iframeRefreshMode === undefined) {
129
- restoredTab.iframeRefreshMode = isCrossOriginUrl(restoredTab.url)
130
- ? 'reload'
131
- : 'postMessage'
166
+ restoredTab.iframeRefreshMode = isCrossOriginUrl(restoredTab.url) ? 'reload' : 'postMessage'
132
167
  }
133
- tabs.value.push(restoredTab)
168
+
169
+ if (restoredTab.pages && typeof restoredTab.pages.list === 'function') {
170
+ const pages = restoredTab.pages.list()
171
+ pages.forEach((page) => {
172
+ if (page && page.id) addCache(page.id)
173
+ })
174
+ }
175
+
176
+ tabs.value = [...tabs.value, restoredTab]
134
177
  }
135
178
  isInitialized.value = true
136
179
  }
137
180
 
138
181
  const hasTab = (id: string) => tabs.value.some((t) => t.id === id)
139
182
  const canAddTab = () =>
140
- maxTabCount <= 0 || (maxTabCount > 0 && maxTabCount > tabs.value.length)
183
+ runtimeContext.maxTabCount.value <= 0 ||
184
+ (runtimeContext.maxTabCount.value > 0 && runtimeContext.maxTabCount.value > tabs.value.length)
141
185
 
142
- const addTab = (tab: ITabItem) => {
143
- tabs.value.push(tab)
144
- return Promise.resolve(true)
186
+ const addTab = (tab: ITabItem): void => {
187
+ tabs.value = [...tabs.value, tab]
145
188
  }
146
189
 
147
190
  const getTab = (id: string) => tabs.value.find((t) => t.id === id) ?? null
148
191
 
149
- /** 从路由解析 tabInfo,若无则创建默认并写入 query */
192
+ /** 从路由解析 tabInfo,若无则创建默认信息,调用方负责把编码信息写入内部 page.query */
150
193
  const parseTabInfoFromRoute = (route: RouteLocationNormalizedLoaded): ITabBase => {
151
- if (route.query.__tab) return decodeTabInfo(route.query.__tab as string)
152
- const tabInfo: ITabBase = {
153
- id: ulid(),
194
+ if (typeof route.query.__tab === 'string') {
195
+ const tabInfo = decodeTabInfo(route.query.__tab)
196
+ if (tabInfo.id) return tabInfo
197
+ }
198
+
199
+ const path = normalizePathForCache(route)
200
+ const activeTab = tabs.value.find((tab) => tab.active)
201
+ const activePage = activeTab?.pages.peek()
202
+ if (activeTab && activePage?.path === path) {
203
+ return {
204
+ id: activeTab.id,
205
+ title: activeTab.title,
206
+ closable: activeTab.closable,
207
+ refreshable: activeTab.refreshable,
208
+ iframe: activeTab.iframe,
209
+ iframeRefreshMode: activeTab.iframeRefreshMode
210
+ }
211
+ }
212
+
213
+ return {
214
+ id: crypto.randomUUID(),
154
215
  title: t('VueStackTab.undefined'),
155
216
  closable: true,
156
217
  refreshable: true,
157
218
  iframe: false
158
219
  }
159
- route.query.__tab = encodeTabInfo(tabInfo)
160
- return tabInfo
161
220
  }
162
221
 
163
- /** 查找或创建目标 tab,同步 active 并添加 page */
222
+ /**
223
+ * 查找或创建目标标签,并同步更新 active 状态与 pages 栈。
224
+ */
164
225
  const findOrCreateTargetTab = (
165
226
  tabInfo: ITabBase,
166
227
  route: RouteLocationNormalizedLoaded,
@@ -184,13 +245,12 @@ export default () => {
184
245
  refreshable: tabInfo.refreshable!,
185
246
  iframe: tabInfo.iframe!,
186
247
  iframeRefreshMode: tabInfo.iframeRefreshMode ?? 'postMessage',
187
- url: decodeURIComponent(src as string),
248
+ url: decodeSafeTabUrl(src),
188
249
  active: true,
189
250
  pages
190
- }
191
- addTab(targetTab).then(() => {
192
- emitter.emit(TabEventType.TAB_ACTIVE, { id: tabInfo.id!, isRoute: false })
193
- })
251
+ } as unknown as ITabItem
252
+ addTab(targetTab)
253
+ emitter.emit(TabEventType.TAB_ACTIVE, { id: tabInfo.id!, isRoute: false })
194
254
  } else {
195
255
  targetTab.active = true
196
256
  if (targetTab.pages.isEmpty() || targetTab.pages.peek()!.id !== page.id) {
@@ -200,9 +260,12 @@ export default () => {
200
260
  return targetTab
201
261
  }
202
262
 
263
+ const createCacheName = () => {
264
+ return createPageId()
265
+ }
266
+
203
267
  /**
204
- * 根据当前路由更新/创建标签与页面状态
205
- * @returns 缓存名、标签信息、目标标签,或 empty/recovered
268
+ * 根据当前路由更新/创建标签与页面状态(核心路由适配器)。
206
269
  */
207
270
  const updatePageState = (
208
271
  route: RouteLocationNormalizedLoaded
@@ -214,62 +277,179 @@ export default () => {
214
277
  if (tabIdsToEvict.has(tabInfo.id!)) return { empty: true }
215
278
 
216
279
  const path = normalizePathForCache(route)
217
- const cacheName = createPageId(tabInfo.id!, path, route.query.valueOf())
280
+
281
+ let targetTab: ITabItem | null = tabInfo.id ? (getTab(tabInfo.id) as ITabItem | null) : null
282
+ let cacheName: string
283
+ let page: ITabPage | null = null
284
+
285
+ if (targetTab && !targetTab.pages.isEmpty()) {
286
+ const top = targetTab.pages.peek()
287
+ if (!top) throw new Error('[vue-stack-tabs] Stack is not empty but peek() returned undefined')
288
+
289
+ if (top.path === path) {
290
+ cacheName = top.id
291
+ } else {
292
+ const pagesList = targetTab.pages.readonlyList()
293
+ let foundIndex = -1
294
+ for (let i = pagesList.length - 2; i >= 0; i--) {
295
+ const pastPage = pagesList[i]
296
+ if (pastPage && pastPage.path === path) {
297
+ foundIndex = i
298
+ break
299
+ }
300
+ }
301
+
302
+ if (foundIndex !== -1) {
303
+ if (!import.meta.env.PROD) {
304
+ console.warn(
305
+ '[vue-stack-tabs] Detected native browser back navigation, repairing page stack...'
306
+ )
307
+ }
308
+ const stepsToPop = pagesList.length - 1 - foundIndex
309
+ for (let i = 0; i < stepsToPop; i++) {
310
+ const popped = targetTab.pages.pop()
311
+ if (popped) evictPageCache(popped.id)
312
+ }
313
+ const newTop = targetTab.pages.peek()
314
+ cacheName = newTop ? newTop.id : createPageId()
315
+
316
+ if (!newTop) {
317
+ page = {
318
+ id: cacheName,
319
+ tabId: tabInfo.id!,
320
+ path: route.path,
321
+ query: cloneRouteQuery(route, tabInfo)
322
+ }
323
+ }
324
+ } else {
325
+ cacheName = createPageId()
326
+ page = {
327
+ id: cacheName,
328
+ tabId: tabInfo.id!,
329
+ path: route.path,
330
+ query: cloneRouteQuery(route, tabInfo)
331
+ }
332
+ }
333
+ }
334
+ } else {
335
+ cacheName = createPageId()
336
+ page = {
337
+ id: cacheName,
338
+ tabId: tabInfo.id!,
339
+ path: route.path,
340
+ query: cloneRouteQuery(route, tabInfo)
341
+ }
342
+ }
343
+
218
344
  if (cacheIdsToEvict.has(cacheName)) {
219
345
  cacheIdsToEvict.delete(cacheName)
220
346
  const cached = components.get(cacheName)
221
347
  if (cached) return { recovered: cached, cacheName }
222
348
  }
223
349
 
224
- const page: ITabPage = {
225
- id: cacheName,
226
- tabId: tabInfo.id!,
227
- path: route.path,
228
- query: route.query as Record<string, string>
350
+ if (page) {
351
+ targetTab = findOrCreateTargetTab(tabInfo, route, cacheName, page)
352
+ } else {
353
+ for (const t of tabs.value) t.active = false
354
+ targetTab!.active = true
229
355
  }
230
- const targetTab = findOrCreateTargetTab(tabInfo, route, cacheName, page)
231
- saveActiveTabToSession(targetTab)
232
- return { cacheName, tabInfo, targetTab }
356
+
357
+ saveActiveTabToSession(targetTab!)
358
+ return { cacheName, tabInfo, targetTab: targetTab! }
233
359
  }
234
360
 
235
- const resolvePageComponent = (
236
- ctx: { cacheName: string; tabInfo: ITabBase; targetTab: ITabItem },
237
- component: VNode
238
- ): DefineComponent => {
361
+ /**
362
+ * 获取或创建与 cacheName 对应的 cacheComponent(keep-alive 包装组件)。
363
+ */
364
+ const resolvePageComponent = (ctx: {
365
+ cacheName: string
366
+ tabInfo: ITabBase
367
+ targetTab: ITabItem
368
+ }): DefineComponent => {
239
369
  const { cacheName, tabInfo } = ctx
370
+
240
371
  if (components.has(cacheName)) {
241
372
  addCache(cacheName)
242
373
  return components.get(cacheName)!
243
374
  }
375
+
244
376
  const cacheComponent = defineComponent({
245
377
  name: cacheName,
246
- components: { DynamicComponent: component },
378
+ props: {
379
+ vnode: {
380
+ type: Object as () => VNode,
381
+ required: false,
382
+ default: undefined
383
+ }
384
+ },
247
385
  emits: ['onLoaded'],
248
- setup(_props, context) {
386
+ setup(props, context) {
387
+ const localBackParams = ref<Record<string, unknown> | null>(null)
388
+ const lastVnode = shallowRef<VNode | null>(null)
389
+ const lastCloned = shallowRef<VNode | null>(null)
390
+ const lastBackParams = shallowRef<Record<string, unknown> | null>(null)
391
+
392
+ const checkAndConsumeBackParams = () => {
393
+ try {
394
+ const tab = getTab(tabInfo.id!)
395
+ const top = tab?.pages.peek()
396
+ if (top && top.id === cacheName && top._backParams) {
397
+ localBackParams.value = { ...top._backParams }
398
+ delete top._backParams
399
+ } else {
400
+ localBackParams.value = null
401
+ }
402
+ } catch {
403
+ localBackParams.value = null
404
+ }
405
+ }
406
+
249
407
  onMounted(() => {
250
408
  context.emit('onLoaded')
251
- addPageScroller(cacheName, '.cache-page-wrapper')
409
+ addPageScroller(cacheName, `#W-${cacheName}`)
410
+ checkAndConsumeBackParams()
252
411
  })
412
+
253
413
  onDeactivated(() => saveScroller(cacheName))
414
+
254
415
  onActivated(() => {
255
416
  context.emit('onLoaded')
256
417
  restoreScroller(cacheName)
257
418
  evictMarkedCaches()
258
419
  tabIdsToEvict.clear()
259
- clearExcludedCacheIdForRefresh(cacheName)
260
- component.props = null
420
+ checkAndConsumeBackParams()
261
421
  })
422
+
262
423
  onUnmounted(() => removeScroller(cacheName))
263
- return () => (
264
- <div
265
- class="cache-page-wrapper"
266
- id={'W-' + tabInfo.id}
267
- style={[useGlobalScroll ? 'overflow:auto' : 'overflow:hidden']}
268
- >
269
- <dynamic-component tId={tabInfo.id} pId={cacheName} />
270
- <PageLoading tabId={tabInfo.id!} />
271
- </div>
272
- )
424
+
425
+ return () => {
426
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
427
+ const Node = (props as any).vnode as VNode
428
+ const backParams = localBackParams.value
429
+
430
+ if (Node !== lastVnode.value || backParams !== lastBackParams.value) {
431
+ const dynamicBackProps = backParams ? { _back: backParams } : {}
432
+ lastCloned.value = Node
433
+ ? cloneVNode(Node, { tId: tabInfo.id, pId: cacheName, ...dynamicBackProps })
434
+ : null
435
+ lastVnode.value = Node
436
+ lastBackParams.value = backParams
437
+ }
438
+
439
+ return (
440
+ <div
441
+ class="cache-page-wrapper"
442
+ id={`W-${cacheName}`}
443
+ style={[
444
+ runtimeContext.useGlobalScroll.value ? 'overflow:auto' : 'overflow:hidden',
445
+ 'height: 100%'
446
+ ]}
447
+ >
448
+ {lastCloned.value}
449
+ <PageLoading tabId={tabInfo.id!} />
450
+ </div>
451
+ )
452
+ }
273
453
  }
274
454
  }) as DefineComponent
275
455
  components.set(cacheName, cacheComponent)
@@ -277,32 +457,63 @@ export default () => {
277
457
  return cacheComponent
278
458
  }
279
459
 
280
- const clearExcludedCacheIdForRefresh = (cacheId: string) => {
281
- excludedCacheIdsForRefresh.value = excludedCacheIdsForRefresh.value.filter((c) => c !== cacheId)
282
- }
460
+ /** 当前正在渲染的页面的缓存 Key(ULID),供 StackTabs.vue 的 :key 绑定使用 */
461
+ const activeCacheKey = ref<string>('')
462
+ /** 只随当前活动页面刷新变化,避免一个 Tab 刷新导致其他 Tab 的缓存 key 失效。 */
463
+ const activePageRefreshVersion = computed(() => {
464
+ const activeTab = tabs.value.find((tab) => tab.active)
465
+ return activeTab?.pages.peek()?.refreshVersion ?? 0
466
+ })
283
467
 
284
- /**
285
- * 将路由对应的组件注册为缓存页面,由 tabWrapper 调用
286
- * @returns 包装后的缓存组件
287
- */
288
- const addPage = (route: RouteLocationNormalizedLoaded, component: VNode): DefineComponent => {
468
+ let lastRouteKey = ''
469
+ let lastAddPageResult: DefineComponent | null = null
470
+
471
+ const addPage = (
472
+ route: RouteLocationNormalizedLoaded,
473
+ component?: VNode | null
474
+ ): DefineComponent => {
289
475
  if (!component) return EmptyPlaceholderComponent as DefineComponent
290
476
 
477
+ const routeKey = route.fullPath + (route.query.__tab || '')
478
+ if (routeKey === lastRouteKey && lastAddPageResult) {
479
+ return lastAddPageResult
480
+ }
481
+
291
482
  const state = updatePageState(route)
292
483
  if ('empty' in state) return EmptyPlaceholderComponent as DefineComponent
293
484
  if ('recovered' in state) {
294
485
  addCache(state.cacheName)
486
+ activeCacheKey.value = state.cacheName
487
+ lastRouteKey = routeKey
488
+ lastAddPageResult = state.recovered
295
489
  return state.recovered
296
490
  }
297
- return resolvePageComponent(state, component)
491
+ activeCacheKey.value = state.cacheName
492
+ const result = resolvePageComponent(state)
493
+ lastRouteKey = routeKey
494
+ lastAddPageResult = result
495
+ return result
298
496
  }
299
497
 
300
498
  const renewTab = (tab: ITabData) => {
301
499
  const currentTab = getTab(tab.id!)
302
- if (!currentTab) return
500
+ if (!currentTab) return undefined
501
+
502
+ const pageSnapshot = currentTab.pages.list().map((p) => clonePage(p))
503
+ const cacheSnapshot = [...caches.value]
504
+ const componentSnapshot = new Map(components)
505
+
303
506
  markTabPagesForEvictionOnly(currentTab)
304
507
  evictMarkedCaches()
305
508
  currentTab.pages.clear()
509
+
510
+ return () => {
511
+ currentTab.pages.clear()
512
+ for (const page of pageSnapshot) currentTab.pages.push(clonePage(page))
513
+ caches.value = [...cacheSnapshot]
514
+ components.clear()
515
+ for (const [id, component] of componentSnapshot) components.set(id, component)
516
+ }
306
517
  }
307
518
 
308
519
  const removeTab = (id: string): string => {
@@ -333,16 +544,21 @@ export default () => {
333
544
  return activeTabId
334
545
  }
335
546
 
336
- const removeAllTabs = () => {
547
+ /** 批量关闭满足条件的可关闭标签,并统一标记其页面缓存待驱逐。 */
548
+ const removeTabs = (shouldRemove: (tab: ITabItem, index: number) => boolean) => {
337
549
  const tabList = tabs.value
338
- for (let i = tabList.length - 1; i >= 0; i--) {
339
- const tab = tabList[i]
340
- if (tab?.closable) {
341
- markTabPagesForEviction(tab)
342
- tab.pages.clear()
343
- tabList.splice(i, 1)
344
- }
550
+ for (let index = tabList.length - 1; index >= 0; index--) {
551
+ const tab = tabList[index]
552
+ if (!tab || !tab.closable || !shouldRemove(tab, index)) continue
553
+ markTabPagesForEviction(tab)
554
+ tab.pages.clear()
555
+ tabList.splice(index, 1)
345
556
  }
557
+ }
558
+
559
+ const removeAllTabs = () => {
560
+ const tabList = tabs.value
561
+ removeTabs(() => true)
346
562
  const hasActive = tabList.some((t) => t.active)
347
563
  if (hasActive) {
348
564
  evictMarkedCaches()
@@ -355,18 +571,8 @@ export default () => {
355
571
 
356
572
  const removeOtherTabs = (id: string) => {
357
573
  const tabList = tabs.value
358
- let activeTab: ITabItem | undefined
359
- for (let i = tabList.length - 1; i >= 0; i--) {
360
- const tab = tabList[i]
361
- if (!tab) continue
362
- if (tab.id === id) {
363
- activeTab = tab as ITabItem
364
- } else if (tab.closable) {
365
- markTabPagesForEviction(tab)
366
- tab.pages.clear()
367
- tabList.splice(i, 1)
368
- }
369
- }
574
+ const activeTab = tabList.find((tab) => tab.id === id)
575
+ removeTabs((tab) => tab.id !== id)
370
576
  if (activeTab && !activeTab.active) {
371
577
  emitter.emit(TabEventType.TAB_ACTIVE, { id })
372
578
  } else {
@@ -380,14 +586,7 @@ export default () => {
380
586
  const pivot = tabList.findIndex((t) => t?.id === id)
381
587
  if (pivot <= 0) return
382
588
 
383
- for (let i = pivot - 1; i >= 0; i--) {
384
- const tab = tabList[i]
385
- if (tab?.closable) {
386
- markTabPagesForEviction(tab)
387
- tab.pages.clear()
388
- tabList.splice(i, 1)
389
- }
390
- }
589
+ removeTabs((_, index) => index < pivot)
391
590
  if (tabList.some((t) => t.active)) {
392
591
  evictMarkedCaches()
393
592
  tabIdsToEvict.clear()
@@ -402,14 +601,7 @@ export default () => {
402
601
  const pivot = tabList.findIndex((t) => t?.id === id)
403
602
  if (pivot < 0 || pivot >= tabList.length - 1) return
404
603
 
405
- for (let i = tabList.length - 1; i > pivot; i--) {
406
- const tab = tabList[i]
407
- if (tab?.closable) {
408
- markTabPagesForEviction(tab)
409
- tab.pages.clear()
410
- tabList.splice(i, 1)
411
- }
412
- }
604
+ removeTabs((_, index) => index > pivot)
413
605
  if (tabList.some((t) => t.active)) {
414
606
  evictMarkedCaches()
415
607
  tabIdsToEvict.clear()
@@ -419,54 +611,16 @@ export default () => {
419
611
  if (last) emitter.emit(TabEventType.TAB_ACTIVE, { id: last.id })
420
612
  }
421
613
 
422
- const refreshTab = (id: string) => {
423
- const tab = getTab(id)
424
- if (!tab?.refreshable) return
425
-
426
- if (tab.iframe) {
427
- if (tab.iframeRefreshMode === 'reload') {
428
- iframeRefreshKeys.value = {
429
- ...iframeRefreshKeys.value,
430
- [id]: (iframeRefreshKeys.value[id] ?? 0) + 1
431
- }
432
- } else {
433
- ;(emitter as { emit: (t: string, p: string) => void }).emit('REFRESH_IFRAME_POSTMESSAGE', id)
434
- }
435
- return
436
- }
437
-
438
- const currentPageId = tab.pages.peek()?.id
439
- if (!currentPageId) return
440
-
441
- excludedCacheIdsForRefresh.value = [...excludedCacheIdsForRefresh.value, currentPageId]
442
- if (tab.active) {
443
- refreshKey.value++
444
- }
445
- }
446
-
447
- const refreshAllTabs = () => {
448
- const toRefresh: string[] = []
449
- for (const tab of tabs.value) {
450
- if (!tab?.refreshable) continue
451
- if (tab.iframe) {
452
- if (tab.iframeRefreshMode === 'reload') {
453
- iframeRefreshKeys.value = {
454
- ...iframeRefreshKeys.value,
455
- [tab.id]: (iframeRefreshKeys.value[tab.id] ?? 0) + 1
456
- }
457
- } else {
458
- ;(emitter as { emit: (t: string, p: string) => void }).emit('REFRESH_IFRAME_POSTMESSAGE', tab.id)
459
- }
460
- } else {
461
- const pageId = tab.pages.peek()?.id
462
- if (pageId) toRefresh.push(pageId)
463
- }
464
- }
465
- if (toRefresh.length > 0) {
466
- excludedCacheIdsForRefresh.value = [...excludedCacheIdsForRefresh.value, ...toRefresh]
467
- refreshKey.value++
468
- }
469
- }
614
+ const { refreshTab, refreshAllTabs } = createTabPanelRefresh({
615
+ tabs,
616
+ iframeRefreshKeys,
617
+ getTab,
618
+ createPageId,
619
+ addCache,
620
+ evictPageCache,
621
+ replacePageCaches,
622
+ refreshIframeByPostMessage: (id) => emitter.emit(TabEventType.REFRESH_IFRAME_POSTMESSAGE, id)
623
+ })
470
624
 
471
625
  const getComponent = (id: string) => components.get(id)
472
626
 
@@ -476,32 +630,71 @@ export default () => {
476
630
  * @param route - 是否执行 router.push
477
631
  */
478
632
  const active = (id: string, route = true) => {
479
- emitter.emit('FORWARD')
633
+ emitter.emit(TabEventType.FORWARD)
480
634
  const target = getTab(id)
481
- if (!target) return
482
- if (target.active) return
635
+ if (!target) return Promise.resolve()
636
+ if (target.active) return Promise.resolve()
483
637
 
484
- for (const tab of tabs.value) {
485
- tab.active = tab.id === id
638
+ const top = target.pages.peek()
639
+ const activateTarget = () => {
640
+ for (const tab of tabs.value) {
641
+ tab.active = tab.id === id
642
+ }
643
+ saveActiveTabToSession(target as ITabItem)
644
+ tabs.value = tabs.value.slice()
645
+ }
646
+
647
+ if (!route) {
648
+ activateTarget()
649
+ return Promise.resolve()
486
650
  }
487
- saveActiveTabToSession(target as ITabItem)
488
- // 强制触发 tabs 响应式更新,确保 TabHeader 高亮与 iframe 进入动画 timing 正确
489
- tabs.value = tabs.value.slice()
490
- if (route) {
491
- const top = target.pages.peek()
492
- if (top) router.push({ path: top.path, query: top.query })
651
+ if (!top) {
652
+ activateTarget()
653
+ return Promise.resolve()
493
654
  }
655
+
656
+ const __tab = encodeTabInfo({
657
+ id: target.id,
658
+ title: target.title,
659
+ closable: target.closable,
660
+ refreshable: target.refreshable,
661
+ iframe: target.iframe
662
+ })
663
+ const query = defu({ __tab }, top.query || {})
664
+
665
+ return runNavigationTransaction({
666
+ apply: () => {
667
+ const prevActiveId = tabs.value.find((tab) => tab.active)?.id
668
+ const sessionSnapshot = window.sessionStorage.getItem(getSessionKey())
669
+ activateTarget()
670
+ return { prevActiveId, sessionSnapshot }
671
+ },
672
+ navigate: () => router.push({ path: top.path, query }),
673
+ rollback: (snapshot) => {
674
+ for (const tab of tabs.value) {
675
+ tab.active = tab.id === snapshot.prevActiveId
676
+ }
677
+ tabs.value = tabs.value.slice()
678
+ restoreActiveTabSession(snapshot.sessionSnapshot)
679
+ },
680
+ isFailureResult: isNavigationFailure,
681
+ rejectFailureResult: true
682
+ })
494
683
  }
495
684
 
496
685
  const reset = () => removeAllTabs()
497
686
 
498
687
  const destroy = () => {
499
- tabs.value.length = 0
688
+ tabs.value = []
689
+ defaultTabs.value = []
500
690
  cacheIdsToEvict.clear()
501
691
  tabIdsToEvict.clear()
502
692
  iframeRefreshKeys.value = {}
503
- caches.value.length = 0
693
+ caches.value = []
504
694
  components.clear()
695
+ runtimeContext.scrollPositionsByPageId.clear()
696
+ runtimeContext.isInitialized.value = false
697
+ runtimeContext.refreshKey.value = 0
505
698
  clearSession()
506
699
  }
507
700
 
@@ -509,13 +702,17 @@ export default () => {
509
702
  tabs,
510
703
  caches,
511
704
  refreshKey,
512
- excludedCacheIdsForRefresh,
705
+ activeCacheKey,
706
+ activePageRefreshVersion,
513
707
  isInitialized,
514
- setMaxSize: setMaxTabCount,
708
+ setMaxSize: (value: number) => {
709
+ runtimeContext.maxTabCount.value = value
710
+ },
515
711
  canAddTab,
516
712
  initialize,
517
713
  size,
518
714
  active,
715
+ getCacheName: createCacheName,
519
716
  destroy,
520
717
  reset,
521
718
  addCache,
@@ -535,12 +732,17 @@ export default () => {
535
732
  removeComponent: (id: string) => components.delete(id),
536
733
  markCacheForEviction,
537
734
  evictMarkedCaches,
735
+ evictPageCache,
538
736
  refreshTab,
539
737
  refreshAllTabs,
540
738
  iframeRefreshKeys,
541
739
  addPageScroller,
542
- setGlobalScroll: setUseGlobalScroll,
740
+ setGlobalScroll: (value: boolean) => {
741
+ runtimeContext.useGlobalScroll.value = value
742
+ },
543
743
  clearSession,
544
- setSessionPrefix
744
+ setSessionPrefix: (value: string) => {
745
+ runtimeContext.sessionPrefix.value = value
746
+ }
545
747
  }
546
748
  }