vue-stack-tabs 1.3.1 → 1.4.2

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 (97) hide show
  1. package/README.en.md +119 -52
  2. package/README.md +103 -42
  3. package/README.ru.md +125 -54
  4. package/dist/{src/lib/StackTabs.vue.d.ts → StackTabs.vue.d.ts} +5 -5
  5. package/dist/{src/lib/components → components}/ContextMenu/index.vue.d.ts +1 -1
  6. package/dist/components/StackKeepAlive/emptyPlaceholder.d.ts +3 -0
  7. package/dist/{src/lib/components → components}/TabHeader/TabHeaderItem.vue.d.ts +9 -0
  8. package/dist/{src/lib/components → components}/TabHeader/index.vue.d.ts +3 -3
  9. package/dist/{src/lib/hooks → hooks}/stackTabsContext.d.ts +5 -2
  10. package/dist/hooks/tabPanel/pageComponentFactory.d.ts +23 -0
  11. package/dist/{src/lib/hooks → hooks}/tabPanel/refresh.d.ts +6 -3
  12. package/dist/{src/lib/hooks → hooks}/tabPanel/scroll.d.ts +2 -0
  13. package/dist/hooks/tabPanel/tabStore.d.ts +69 -0
  14. package/dist/hooks/useScrollShadow.d.ts +20 -0
  15. package/dist/{src/lib/hooks → hooks}/useTabActions.d.ts +7 -2
  16. package/dist/{src/lib/hooks → hooks}/useTabEventBus.d.ts +2 -1
  17. package/dist/{src/lib/hooks → hooks}/useTabPanel.d.ts +16 -4
  18. package/dist/iframe-bridge.mjs +1 -1
  19. package/dist/index.d.ts +22 -6
  20. package/dist/{src/lib/model → model}/TabModel.d.ts +21 -0
  21. package/dist/nuxt/module.mjs +10 -6
  22. package/dist/nuxt/runtime/plugin.mjs +1600 -4
  23. package/dist/style.css +1 -1
  24. package/dist/utils/iframeBridge.d.ts +7 -1
  25. package/dist/{src/lib/utils → utils}/scrollUtils.d.ts +7 -1
  26. package/dist/{src/lib/versionLogger.d.ts → versionLogger.d.ts} +1 -1
  27. package/dist/vue-stack-tabs.css +1 -1
  28. package/dist/vue-stack-tabs.es.js +1 -1
  29. package/package.json +64 -40
  30. package/src/lib/StackTabs.vue +16 -3
  31. package/src/lib/assets/style/common/var.scss +2 -0
  32. package/src/lib/assets/style/stackTab.scss +17 -3
  33. package/src/lib/components/ContextMenu/index.vue +12 -3
  34. package/src/lib/components/PageLoading.vue +10 -2
  35. package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +3 -9
  36. package/src/lib/components/StackKeepAlive/emptyPlaceholder.tsx +18 -0
  37. package/src/lib/components/TabHeader/index.vue +4 -3
  38. package/src/lib/hooks/stackTabsContext.ts +23 -18
  39. package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +136 -0
  40. package/src/lib/hooks/tabPanel/refresh.ts +20 -13
  41. package/src/lib/hooks/tabPanel/scroll.ts +110 -7
  42. package/src/lib/hooks/tabPanel/session.ts +29 -3
  43. package/src/lib/hooks/tabPanel/tabStore.ts +595 -0
  44. package/src/lib/hooks/useContextMenu.ts +57 -11
  45. package/src/lib/hooks/useIframeManager.ts +18 -2
  46. package/src/lib/hooks/useScrollShadow.ts +124 -0
  47. package/src/lib/hooks/useTabActions.ts +18 -8
  48. package/src/lib/hooks/useTabEventBus.ts +5 -3
  49. package/src/lib/hooks/useTabPanel.tsx +183 -302
  50. package/src/lib/hooks/useTabRouter.ts +42 -38
  51. package/src/lib/i18n/index.ts +3 -3
  52. package/src/lib/model/TabModel.ts +40 -1
  53. package/src/lib/nuxt/module.ts +10 -3
  54. package/src/lib/nuxt/runtime/plugin.ts +13 -2
  55. package/src/lib/utils/iframeBridge.ts +9 -1
  56. package/src/lib/utils/scrollUtils.ts +35 -2
  57. package/src/lib/utils/tabInfoEncoder.ts +21 -8
  58. package/src/lib/utils/urlParser.ts +3 -1
  59. package/src/lib/versionLogger.ts +6 -2
  60. package/dist/favicon.svg +0 -10
  61. package/dist/iframe-bridge.html +0 -77
  62. package/dist/iframe-child.html +0 -88
  63. package/dist/nuxt/favicon.svg +0 -10
  64. package/dist/nuxt/iframe-bridge.html +0 -77
  65. package/dist/nuxt/iframe-child.html +0 -88
  66. package/dist/nuxt/plugin.mjs +0 -9
  67. package/dist/src/lib/iframe-bridge.d.ts +0 -8
  68. package/dist/src/lib/index.d.ts +0 -22
  69. package/dist/src/lib/nuxt/module.d.ts +0 -6
  70. package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +0 -2
  71. package/dist/src/lib/nuxt/runtime/plugin.d.ts +0 -2
  72. package/dist/src/lib/utils/iframeBridge.d.ts +0 -39
  73. package/src/lib/nuxt/runtime/plugin.client.ts +0 -10
  74. /package/dist/{src/lib/components → components}/ContextMenu/ContextMenuItem.vue.d.ts +0 -0
  75. /package/dist/{src/lib/components → components}/PageLoading.vue.d.ts +0 -0
  76. /package/dist/{src/lib/components → components}/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -0
  77. /package/dist/{src/lib/components → components}/StackKeepAlive/StackKeepAlive.vue.d.ts +0 -0
  78. /package/dist/{src/lib/components → components}/TabHeader/TabHeaderButton.vue.d.ts +0 -0
  79. /package/dist/{src/lib/components → components}/TabHeader/TabHeaderScroll.vue.d.ts +0 -0
  80. /package/dist/{src/lib/hooks → hooks}/tabPanel/evict.d.ts +0 -0
  81. /package/dist/{src/lib/hooks → hooks}/tabPanel/navigationTransaction.d.ts +0 -0
  82. /package/dist/{src/lib/hooks → hooks}/tabPanel/session.d.ts +0 -0
  83. /package/dist/{src/lib/hooks → hooks}/tabPanel/state.d.ts +0 -0
  84. /package/dist/{src/lib/hooks → hooks}/useContextMenu.d.ts +0 -0
  85. /package/dist/{src/lib/hooks → hooks}/useIframeManager.d.ts +0 -0
  86. /package/dist/{src/lib/hooks → hooks}/useTabLoading.d.ts +0 -0
  87. /package/dist/{src/lib/hooks → hooks}/useTabRouter.d.ts +0 -0
  88. /package/dist/{src/lib/i18n → i18n}/index.d.ts +0 -0
  89. /package/dist/{src/lib/i18n → i18n}/lang/en.d.ts +0 -0
  90. /package/dist/{src/lib/i18n → i18n}/lang/zh-CN.d.ts +0 -0
  91. /package/dist/{src/lib/i18n → i18n}/stackTabsLocale.d.ts +0 -0
  92. /package/dist/{src/lib/iframe.vue.d.ts → iframe.vue.d.ts} +0 -0
  93. /package/dist/{src/lib/utils → utils}/stackTabsA11y.d.ts +0 -0
  94. /package/dist/{src/lib/utils → utils}/stackTabsMessage.d.ts +0 -0
  95. /package/dist/{src/lib/utils → utils}/tabInfoEncoder.d.ts +0 -0
  96. /package/dist/{src/lib/utils → utils}/typeGuards.d.ts +0 -0
  97. /package/dist/{src/lib/utils → utils}/urlParser.d.ts +0 -0
@@ -12,17 +12,7 @@
12
12
  * 使用范围:内部 hook,由 StackTabs、useTabActions、useTabRouter、ContextMenu 调用
13
13
  */
14
14
  import type { ITabBase, ITabData, ITabItem, ITabPage } from '../model/TabModel'
15
- import {
16
- defineComponent,
17
- onActivated,
18
- onDeactivated,
19
- onMounted,
20
- onUnmounted,
21
- computed,
22
- ref,
23
- shallowRef,
24
- cloneVNode
25
- } from 'vue'
15
+ import { computed, ref } from 'vue'
26
16
  import type { DefineComponent, VNode } from 'vue'
27
17
  import { isNavigationFailure, useRouter } from 'vue-router'
28
18
  import type { RouteLocationNormalizedLoaded } from 'vue-router'
@@ -35,28 +25,19 @@ import {
35
25
  omitStackTabsReservedQuery,
36
26
  toSafeTabUrl,
37
27
  decodeSafeTabUrl,
38
- cloneLocationQuery,
39
- clonePage
28
+ cloneLocationQuery
40
29
  } from '../utils/urlParser'
41
- import PageLoading from '../components/PageLoading.vue'
42
30
  import { TabEventType, useTabEmitter } from './useTabEventBus'
43
31
  import { runNavigationTransaction } from './tabPanel/navigationTransaction'
44
32
  import { useI18n } from 'vue-i18n-lite'
45
33
 
46
- import { resolveStackTabsRuntimeContext } from './stackTabsContext'
47
- import { createTabPanelEviction } from './tabPanel/evict'
34
+ import { resolveStackTabsRuntimeContext, type StackTabsRuntimeContext } from './stackTabsContext'
35
+ import { getTabStore } from './tabPanel/tabStore'
48
36
  import { createTabPanelScroll } from './tabPanel/scroll'
49
37
  import { createTabPanelSession } from './tabPanel/session'
50
38
  import { createTabPanelRefresh } from './tabPanel/refresh'
51
-
52
- /* eslint-disable vue/one-component-per-file */
53
- /** 占位组件:当路由组件为空或标签已被标记删除时返回 */
54
- const EmptyPlaceholderComponent = defineComponent({
55
- name: 'StackTabEmptyPlaceholder',
56
- setup() {
57
- return () => null
58
- }
59
- })
39
+ import { createPageComponentFactory } from './tabPanel/pageComponentFactory'
40
+ import { getEmptyPlaceholderComponent } from '../components/StackKeepAlive/emptyPlaceholder'
60
41
 
61
42
  const cloneRouteQuery = (route: RouteLocationNormalizedLoaded, tabInfo: ITabBase) => ({
62
43
  ...cloneLocationQuery(route.query),
@@ -71,32 +52,55 @@ export const normalizePathForCache = (route: RouteLocationNormalizedLoaded): str
71
52
  return matchPath === path ? path : route.path
72
53
  }
73
54
 
74
- export default () => {
55
+ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
75
56
  const router = useRouter()
76
- const emitter = useTabEmitter()
57
+ const runtimeContext = providedRuntimeContext ?? resolveStackTabsRuntimeContext()
58
+ const emitter = useTabEmitter(runtimeContext)
77
59
  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
60
+ const { tabs, defaultTabs, caches, refreshKey, iframeRefreshKeys, isInitialized } = runtimeContext
61
+ const store = getTabStore(runtimeContext)
90
62
  const {
63
+ getTabs,
64
+ getTab,
65
+ getActiveTab,
66
+ findTabByTopPagePath,
67
+ hasTab,
68
+ canAddTab,
69
+ getPages,
70
+ getTopPage,
71
+ getPageSnapshot,
72
+ addTab,
73
+ addTabs,
74
+ activateTab,
75
+ moveTab,
76
+ removeTab: removeTabFromStore,
77
+ removeTabs: removeTabsFromStore,
78
+ appendPage,
79
+ popPages,
80
+ replacePages,
81
+ clearPages,
82
+ updatePage,
83
+ consumeBackParams,
91
84
  markCacheForEviction,
92
- markTabPagesForEviction,
93
85
  markTabPagesForEvictionOnly,
94
86
  addCache,
95
87
  removeCache,
96
88
  evictMarkedCaches,
97
89
  evictPageCache,
98
- replacePageCaches
99
- } = createTabPanelEviction(runtimeContext)
90
+ replacePageCaches,
91
+ isTabMarkedForEviction,
92
+ isCacheMarkedForEviction,
93
+ unmarkCacheForEviction,
94
+ clearTabEvictionMarks,
95
+ getComponent,
96
+ setComponent,
97
+ deleteComponent,
98
+ snapshotComponents,
99
+ restoreComponents,
100
+ snapshotCaches,
101
+ restoreCaches,
102
+ reset: resetStore
103
+ } = store
100
104
  const { restoreScroller, saveScroller, removeScroller, addPageScroller } =
101
105
  createTabPanelScroll(runtimeContext)
102
106
  const {
@@ -107,7 +111,7 @@ export default () => {
107
111
  getSessionKey
108
112
  } = createTabPanelSession(runtimeContext)
109
113
 
110
- const size = (): number => tabs.value.length
114
+ const size = (): number => getTabs().length
111
115
 
112
116
  /**
113
117
  * 初始化:根据 defaultTabs 创建标签与初始页面,并尝试从 sessionStorage 恢复上次激活的标签
@@ -118,14 +122,15 @@ export default () => {
118
122
  const newTabs: ITabItem[] = []
119
123
 
120
124
  for (const item of staticTabs) {
125
+ // 先用默认值填充非空缺字段,避免在循环里提前求值生成用不到的 UUID。
121
126
  const fullItem = defu(item, {
122
- id: crypto.randomUUID(),
123
127
  refreshable: true,
124
128
  closable: true,
125
129
  iframe: false
126
130
  })
127
- const tabId = fullItem.id ?? crypto.randomUUID()
128
- fullItem.id = tabId
131
+ // id 单独兜底,避免每次循环都无意义地调用 crypto.randomUUID。
132
+ fullItem.id = item.id ?? crypto.randomUUID()
133
+ const tabId = fullItem.id
129
134
  const uri = parseUrl(fullItem.path)
130
135
  const cacheName = createPageId()
131
136
  const page: ITabPage = {
@@ -156,63 +161,60 @@ export default () => {
156
161
  }
157
162
 
158
163
  defaultTabs.value = [...defaultTabs.value, ...newDefaults]
159
- tabs.value = [...tabs.value, ...newTabs]
164
+ addTabs(newTabs)
160
165
 
161
- const storedTabJson = sessionStorage.getItem(getSessionKey())
166
+ // SSR 环境无 sessionStorage,直接跳过恢复。
167
+ const storedTabJson =
168
+ typeof window !== 'undefined' ? sessionStorage.getItem(getSessionKey()) : null
162
169
  const restoredTab = restoreTabFromSession(storedTabJson)
163
- if (restoredTab && !tabs.value.some((t) => t.id === restoredTab.id)) {
170
+ if (restoredTab && !hasTab(restoredTab.id)) {
164
171
  if (restoredTab.url) restoredTab.url = toSafeTabUrl(restoredTab.url)
165
172
  if (restoredTab.iframe && restoredTab.url && restoredTab.iframeRefreshMode === undefined) {
166
173
  restoredTab.iframeRefreshMode = isCrossOriginUrl(restoredTab.url) ? 'reload' : 'postMessage'
167
174
  }
168
175
 
169
176
  if (restoredTab.pages && typeof restoredTab.pages.list === 'function') {
170
- const pages = restoredTab.pages.list()
171
- pages.forEach((page) => {
177
+ addTab(restoredTab)
178
+ for (const page of getPages(restoredTab.id)) {
172
179
  if (page && page.id) addCache(page.id)
173
- })
180
+ }
181
+ } else {
182
+ addTab(restoredTab)
174
183
  }
175
-
176
- tabs.value = [...tabs.value, restoredTab]
177
184
  }
178
185
  isInitialized.value = true
179
186
  }
180
187
 
181
- const hasTab = (id: string) => tabs.value.some((t) => t.id === id)
182
- const canAddTab = () =>
183
- runtimeContext.maxTabCount.value <= 0 ||
184
- (runtimeContext.maxTabCount.value > 0 && runtimeContext.maxTabCount.value > tabs.value.length)
185
-
186
- const addTab = (tab: ITabItem): void => {
187
- tabs.value = [...tabs.value, tab]
188
- }
188
+ const { resolvePageComponent } = createPageComponentFactory({
189
+ runtimeContext,
190
+ consumeBackParams,
191
+ getComponent,
192
+ setComponent,
193
+ addCache,
194
+ evictMarkedCaches,
195
+ clearTabEvictionMarks,
196
+ addPageScroller,
197
+ saveScroller,
198
+ restoreScroller,
199
+ removeScroller
200
+ })
189
201
 
190
- const getTab = (id: string) => tabs.value.find((t) => t.id === id) ?? null
202
+ /** 把已有标签的对外字段提取为 ITabBase(用于让路由归属回该标签) */
203
+ const toTabBase = (tab: ITabItem): ITabBase => ({
204
+ id: tab.id,
205
+ title: tab.title,
206
+ closable: tab.closable,
207
+ refreshable: tab.refreshable,
208
+ iframe: tab.iframe,
209
+ iframeRefreshMode: tab.iframeRefreshMode
210
+ })
191
211
 
192
212
  /**
193
- * 将指定标签移动到目标索引。标签对象本身不变,因此其页面栈和缓存关联保持不受影响。
194
- * @returns 是否实际调整了顺序
213
+ * 从路由解析 tabInfo,若无则创建默认信息,调用方负责把编码信息写入内部 page.query。
214
+ *
215
+ * 首次进入应用时 URL 上通常还没有 __tab:此时优先接管 path 匹配的默认标签(defaultTabs),
216
+ * 由调用方把 __tab 写回地址,保证「URL 指向默认标签」;确实找不到归属时才新建兜底标签。
195
217
  */
196
- const moveTab = (id: string, targetIndex: number): boolean => {
197
- const sourceIndex = tabs.value.findIndex((tab) => tab.id === id)
198
- if (
199
- sourceIndex < 0 ||
200
- targetIndex < 0 ||
201
- targetIndex >= tabs.value.length ||
202
- sourceIndex === targetIndex
203
- ) {
204
- return false
205
- }
206
-
207
- const reorderedTabs = tabs.value.slice()
208
- const [tab] = reorderedTabs.splice(sourceIndex, 1)
209
- if (!tab) return false
210
- reorderedTabs.splice(targetIndex, 0, tab)
211
- tabs.value = reorderedTabs
212
- return true
213
- }
214
-
215
- /** 从路由解析 tabInfo,若无则创建默认信息,调用方负责把编码信息写入内部 page.query */
216
218
  const parseTabInfoFromRoute = (route: RouteLocationNormalizedLoaded): ITabBase => {
217
219
  if (typeof route.query.__tab === 'string') {
218
220
  const tabInfo = decodeTabInfo(route.query.__tab)
@@ -220,19 +222,18 @@ export default () => {
220
222
  }
221
223
 
222
224
  const path = normalizePathForCache(route)
223
- const activeTab = tabs.value.find((tab) => tab.active)
224
- const activePage = activeTab?.pages.peek()
225
+ const activeTab = getActiveTab()
226
+ const activePage = activeTab ? getTopPage(activeTab.id) : null
225
227
  if (activeTab && activePage?.path === path) {
226
- return {
227
- id: activeTab.id,
228
- title: activeTab.title,
229
- closable: activeTab.closable,
230
- refreshable: activeTab.refreshable,
231
- iframe: activeTab.iframe,
232
- iframeRefreshMode: activeTab.iframeRefreshMode
233
- }
228
+ return toTabBase(activeTab)
234
229
  }
235
230
 
231
+ // 当前激活标签与路由不一致:首屏 URL 无 __tab,或 session 恢复了上次激活的其它标签
232
+ //(演示应用在 iframe 中重载后 hash 为空即属此类)。此时接管栈顶 path 与当前路由一致的
233
+ // 标签(默认标签或已有标签),避免同一个页面既留下已有标签、又生成一个「未命名」的兜底标签。
234
+ const pathMatchedTab = findTabByTopPagePath(path)
235
+ if (pathMatchedTab) return toTabBase(pathMatchedTab)
236
+
236
237
  return {
237
238
  id: crypto.randomUUID(),
238
239
  title: t('VueStackTab.undefined'),
@@ -251,11 +252,7 @@ export default () => {
251
252
  cacheName: string,
252
253
  page: ITabPage
253
254
  ): ITabItem => {
254
- let targetTab: ITabItem | null = null
255
- for (const tab of tabs.value) {
256
- tab.active = false
257
- if (tab.id === tabInfo.id) targetTab = tab as ITabItem
258
- }
255
+ let targetTab = tabInfo.id ? getTab(tabInfo.id) : null
259
256
 
260
257
  if (targetTab === null) {
261
258
  const pages = new Stack<ITabPage>()
@@ -272,12 +269,14 @@ export default () => {
272
269
  active: true,
273
270
  pages
274
271
  } as unknown as ITabItem
272
+ activateTab('')
275
273
  addTab(targetTab)
276
274
  emitter.emit(TabEventType.TAB_ACTIVE, { id: tabInfo.id!, isRoute: false })
277
275
  } else {
278
- targetTab.active = true
279
- if (targetTab.pages.isEmpty() || targetTab.pages.peek()!.id !== page.id) {
280
- targetTab.pages.push(page)
276
+ activateTab(targetTab.id)
277
+ const topPage = getTopPage(targetTab.id)
278
+ if (!topPage || topPage.id !== page.id) {
279
+ appendPage(targetTab.id, page)
281
280
  }
282
281
  }
283
282
  return targetTab
@@ -297,7 +296,7 @@ export default () => {
297
296
  | { empty: true }
298
297
  | { recovered: DefineComponent; cacheName: string } => {
299
298
  const tabInfo = parseTabInfoFromRoute(route)
300
- if (tabIdsToEvict.has(tabInfo.id!)) return { empty: true }
299
+ if (isTabMarkedForEviction(tabInfo.id!)) return { empty: true }
301
300
 
302
301
  const path = normalizePathForCache(route)
303
302
 
@@ -305,14 +304,14 @@ export default () => {
305
304
  let cacheName: string
306
305
  let page: ITabPage | null = null
307
306
 
308
- if (targetTab && !targetTab.pages.isEmpty()) {
309
- const top = targetTab.pages.peek()
307
+ if (targetTab && getTopPage(targetTab.id)) {
308
+ const top = getTopPage(targetTab.id)!
310
309
  if (!top) throw new Error('[vue-stack-tabs] Stack is not empty but peek() returned undefined')
311
310
 
312
311
  if (top.path === path) {
313
312
  cacheName = top.id
314
313
  } else {
315
- const pagesList = targetTab.pages.readonlyList()
314
+ const pagesList = getPages(targetTab.id)
316
315
  let foundIndex = -1
317
316
  for (let i = pagesList.length - 2; i >= 0; i--) {
318
317
  const pastPage = pagesList[i]
@@ -329,11 +328,11 @@ export default () => {
329
328
  )
330
329
  }
331
330
  const stepsToPop = pagesList.length - 1 - foundIndex
332
- for (let i = 0; i < stepsToPop; i++) {
333
- const popped = targetTab.pages.pop()
334
- if (popped) evictPageCache(popped.id)
335
- }
336
- const newTop = targetTab.pages.peek()
331
+ const poppedPages = popPages(targetTab.id, stepsToPop)
332
+ // 仅标记待驱逐,由 onActivated 或下一拍 evictMarkedCaches 统一清理,
333
+ // 避免 watcher 同步执行与守卫拒绝路由之间产生不一致。
334
+ for (const popped of poppedPages) markCacheForEviction(popped.id)
335
+ const newTop = getTopPage(targetTab.id)
337
336
  cacheName = newTop ? newTop.id : createPageId()
338
337
 
339
338
  if (!newTop) {
@@ -364,128 +363,38 @@ export default () => {
364
363
  }
365
364
  }
366
365
 
367
- if (cacheIdsToEvict.has(cacheName)) {
368
- cacheIdsToEvict.delete(cacheName)
369
- const cached = components.get(cacheName)
366
+ if (isCacheMarkedForEviction(cacheName)) {
367
+ unmarkCacheForEviction(cacheName)
368
+ const cached = getComponent(cacheName)
370
369
  if (cached) return { recovered: cached, cacheName }
371
370
  }
372
371
 
373
372
  if (page) {
374
373
  targetTab = findOrCreateTargetTab(tabInfo, route, cacheName, page)
375
374
  } else {
376
- for (const t of tabs.value) t.active = false
377
- targetTab!.active = true
375
+ activateTab(targetTab!.id)
378
376
  }
379
377
 
380
- saveActiveTabToSession(targetTab!)
381
- return { cacheName, tabInfo, targetTab: targetTab! }
382
- }
383
-
384
- /**
385
- * 获取或创建与 cacheName 对应的 cacheComponent(keep-alive 包装组件)。
386
- */
387
- const resolvePageComponent = (ctx: {
388
- cacheName: string
389
- tabInfo: ITabBase
390
- targetTab: ITabItem
391
- }): DefineComponent => {
392
- const { cacheName, tabInfo } = ctx
393
-
394
- if (components.has(cacheName)) {
395
- addCache(cacheName)
396
- return components.get(cacheName)!
378
+ // 首屏 URL 往往没有 __tab(用户直接打开默认路由):把解析出的标签编码写回地址,
379
+ // 保证 URL 始终指向当前标签;之后的导航各自会带上自己的 __tab。
380
+ if (typeof route.query.__tab !== 'string') {
381
+ void router.replace({
382
+ path: route.path,
383
+ query: { ...cloneLocationQuery(route.query), __tab: encodeTabInfo(tabInfo) },
384
+ hash: route.hash
385
+ })
397
386
  }
398
387
 
399
- const cacheComponent = defineComponent({
400
- name: cacheName,
401
- props: {
402
- vnode: {
403
- type: Object as () => VNode,
404
- required: false,
405
- default: undefined
406
- }
407
- },
408
- emits: ['onLoaded'],
409
- setup(props, context) {
410
- const localBackParams = ref<Record<string, unknown> | null>(null)
411
- const lastVnode = shallowRef<VNode | null>(null)
412
- const lastCloned = shallowRef<VNode | null>(null)
413
- const lastBackParams = shallowRef<Record<string, unknown> | null>(null)
414
-
415
- const checkAndConsumeBackParams = () => {
416
- try {
417
- const tab = getTab(tabInfo.id!)
418
- const top = tab?.pages.peek()
419
- if (top && top.id === cacheName && top._backParams) {
420
- localBackParams.value = { ...top._backParams }
421
- delete top._backParams
422
- } else {
423
- localBackParams.value = null
424
- }
425
- } catch {
426
- localBackParams.value = null
427
- }
428
- }
429
-
430
- onMounted(() => {
431
- context.emit('onLoaded')
432
- addPageScroller(cacheName, `#W-${cacheName}`)
433
- checkAndConsumeBackParams()
434
- })
435
-
436
- onDeactivated(() => saveScroller(cacheName))
437
-
438
- onActivated(() => {
439
- context.emit('onLoaded')
440
- restoreScroller(cacheName)
441
- evictMarkedCaches()
442
- tabIdsToEvict.clear()
443
- checkAndConsumeBackParams()
444
- })
445
-
446
- onUnmounted(() => removeScroller(cacheName))
447
-
448
- return () => {
449
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
450
- const Node = (props as any).vnode as VNode
451
- const backParams = localBackParams.value
452
-
453
- if (Node !== lastVnode.value || backParams !== lastBackParams.value) {
454
- const dynamicBackProps = backParams ? { _back: backParams } : {}
455
- lastCloned.value = Node
456
- ? cloneVNode(Node, { tId: tabInfo.id, pId: cacheName, ...dynamicBackProps })
457
- : null
458
- lastVnode.value = Node
459
- lastBackParams.value = backParams
460
- }
461
-
462
- return (
463
- <div
464
- class="cache-page-wrapper"
465
- id={`W-${cacheName}`}
466
- style={[
467
- runtimeContext.useGlobalScroll.value ? 'overflow:auto' : 'overflow:hidden',
468
- 'height: 100%'
469
- ]}
470
- >
471
- {lastCloned.value}
472
- <PageLoading tabId={tabInfo.id!} />
473
- </div>
474
- )
475
- }
476
- }
477
- }) as DefineComponent
478
- components.set(cacheName, cacheComponent)
479
- addCache(cacheName)
480
- return cacheComponent
388
+ saveActiveTabToSession(targetTab!)
389
+ return { cacheName, tabInfo, targetTab: targetTab! }
481
390
  }
482
391
 
483
392
  /** 当前正在渲染的页面的缓存 Key(ULID),供 StackTabs.vue 的 :key 绑定使用 */
484
393
  const activeCacheKey = ref<string>('')
485
394
  /** 只随当前活动页面刷新变化,避免一个 Tab 刷新导致其他 Tab 的缓存 key 失效。 */
486
395
  const activePageRefreshVersion = computed(() => {
487
- const activeTab = tabs.value.find((tab) => tab.active)
488
- return activeTab?.pages.peek()?.refreshVersion ?? 0
396
+ const activeTab = getActiveTab()
397
+ return activeTab ? (getTopPage(activeTab.id)?.refreshVersion ?? 0) : 0
489
398
  })
490
399
 
491
400
  let lastRouteKey = ''
@@ -495,7 +404,7 @@ export default () => {
495
404
  route: RouteLocationNormalizedLoaded,
496
405
  component?: VNode | null
497
406
  ): DefineComponent => {
498
- if (!component) return EmptyPlaceholderComponent as DefineComponent
407
+ if (!component) return getEmptyPlaceholderComponent()
499
408
 
500
409
  const routeKey = route.fullPath + (route.query.__tab || '')
501
410
  if (routeKey === lastRouteKey && lastAddPageResult) {
@@ -503,7 +412,7 @@ export default () => {
503
412
  }
504
413
 
505
414
  const state = updatePageState(route)
506
- if ('empty' in state) return EmptyPlaceholderComponent as DefineComponent
415
+ if ('empty' in state) return getEmptyPlaceholderComponent()
507
416
  if ('recovered' in state) {
508
417
  addCache(state.cacheName)
509
418
  activeCacheKey.value = state.cacheName
@@ -522,122 +431,93 @@ export default () => {
522
431
  const currentTab = getTab(tab.id!)
523
432
  if (!currentTab) return undefined
524
433
 
525
- const pageSnapshot = currentTab.pages.list().map((p) => clonePage(p))
526
- const cacheSnapshot = [...caches.value]
527
- const componentSnapshot = new Map(components)
434
+ const pageSnapshot = getPageSnapshot(currentTab.id)
435
+ const cacheSnapshot = snapshotCaches()
436
+ const componentSnapshot = snapshotComponents()
528
437
 
529
438
  markTabPagesForEvictionOnly(currentTab)
530
439
  evictMarkedCaches()
531
- currentTab.pages.clear()
440
+ clearPages(currentTab.id)
532
441
 
533
442
  return () => {
534
- currentTab.pages.clear()
535
- for (const page of pageSnapshot) currentTab.pages.push(clonePage(page))
536
- caches.value = [...cacheSnapshot]
537
- components.clear()
538
- for (const [id, component] of componentSnapshot) components.set(id, component)
443
+ replacePages(currentTab.id, pageSnapshot)
444
+ restoreCaches(cacheSnapshot)
445
+ restoreComponents(componentSnapshot)
539
446
  }
540
447
  }
541
448
 
542
449
  const removeTab = (id: string): string => {
543
- let activeTabId = ''
544
- const currentTab = getTab(id)
545
- const tabList = tabs.value
546
- const i = tabList.findIndex((t) => t?.id === id)
547
- if (i < 0) return ''
548
-
549
- const tab = tabList[i]!
550
- if (!tab.closable) return id
551
-
552
- markTabPagesForEviction(tab)
553
- tab.pages.clear()
554
- if (tabList.length > 1 && tab.active) {
555
- activeTabId = (i === 0 ? tabList[1] : tabList[i - 1])?.id ?? ''
556
- }
557
- tabList.splice(i, 1)
450
+ const result = removeTabFromStore(id)
451
+ if (result.status === 'not-found') return ''
452
+ if (result.status === 'not-closable') return id
558
453
 
559
- if (activeTabId) {
560
- emitter.emit(TabEventType.TAB_ACTIVE, { id: activeTabId })
561
- }
562
- if (!currentTab?.active) {
563
- evictMarkedCaches()
564
- tabIdsToEvict.clear()
454
+ if (result.nextActiveId) {
455
+ emitter.emit(TabEventType.TAB_ACTIVE, { id: result.nextActiveId })
565
456
  }
566
457
 
567
- return activeTabId
458
+ return result.nextActiveId
568
459
  }
569
460
 
570
461
  /** 批量关闭满足条件的可关闭标签,并统一标记其页面缓存待驱逐。 */
571
- const removeTabs = (shouldRemove: (tab: ITabItem, index: number) => boolean) => {
572
- const tabList = tabs.value
573
- for (let index = tabList.length - 1; index >= 0; index--) {
574
- const tab = tabList[index]
575
- if (!tab || !tab.closable || !shouldRemove(tab, index)) continue
576
- markTabPagesForEviction(tab)
577
- tab.pages.clear()
578
- tabList.splice(index, 1)
579
- }
580
- }
462
+ const removeTabs = (shouldRemove: (tab: ITabItem, index: number) => boolean) =>
463
+ removeTabsFromStore(shouldRemove)
581
464
 
582
465
  const removeAllTabs = () => {
583
- const tabList = tabs.value
584
466
  removeTabs(() => true)
585
- const hasActive = tabList.some((t) => t.active)
586
- if (hasActive) {
467
+ if (getActiveTab()) {
587
468
  evictMarkedCaches()
588
- tabIdsToEvict.clear()
469
+ clearTabEvictionMarks()
589
470
  return
590
471
  }
591
- const last = tabList[tabList.length - 1]
472
+ const last = getTabs().at(-1)
592
473
  if (last) emitter.emit(TabEventType.TAB_ACTIVE, { id: last.id })
593
474
  }
594
475
 
595
476
  const removeOtherTabs = (id: string) => {
596
- const tabList = tabs.value
597
- const activeTab = tabList.find((tab) => tab.id === id)
477
+ const activeTab = getTab(id)
598
478
  removeTabs((tab) => tab.id !== id)
599
479
  if (activeTab && !activeTab.active) {
600
480
  emitter.emit(TabEventType.TAB_ACTIVE, { id })
601
481
  } else {
602
482
  evictMarkedCaches()
603
- tabIdsToEvict.clear()
483
+ clearTabEvictionMarks()
604
484
  }
605
485
  }
606
486
 
607
487
  const removeLeftTabs = (id: string) => {
608
- const tabList = tabs.value
609
- const pivot = tabList.findIndex((t) => t?.id === id)
488
+ const pivot = getTabs().findIndex((t) => t?.id === id)
610
489
  if (pivot <= 0) return
611
490
 
612
491
  removeTabs((_, index) => index < pivot)
613
- if (tabList.some((t) => t.active)) {
492
+ if (getActiveTab()) {
614
493
  evictMarkedCaches()
615
- tabIdsToEvict.clear()
494
+ clearTabEvictionMarks()
616
495
  return
617
496
  }
618
- const first = tabList[0]
497
+ const first = getTabs()[0]
619
498
  if (first) emitter.emit(TabEventType.TAB_ACTIVE, { id: first.id })
620
499
  }
621
500
 
622
501
  const removeRightTabs = (id: string) => {
623
- const tabList = tabs.value
502
+ const tabList = getTabs()
624
503
  const pivot = tabList.findIndex((t) => t?.id === id)
625
504
  if (pivot < 0 || pivot >= tabList.length - 1) return
626
505
 
627
506
  removeTabs((_, index) => index > pivot)
628
- if (tabList.some((t) => t.active)) {
507
+ if (getActiveTab()) {
629
508
  evictMarkedCaches()
630
- tabIdsToEvict.clear()
509
+ clearTabEvictionMarks()
631
510
  return
632
511
  }
633
- const last = tabList[tabList.length - 1]
512
+ const last = getTabs().at(-1)
634
513
  if (last) emitter.emit(TabEventType.TAB_ACTIVE, { id: last.id })
635
514
  }
636
515
 
637
516
  const { refreshTab, refreshAllTabs } = createTabPanelRefresh({
638
- tabs,
517
+ getTabs,
518
+ getTopPage,
519
+ updatePage,
639
520
  iframeRefreshKeys,
640
- getTab,
641
521
  createPageId,
642
522
  addCache,
643
523
  evictPageCache,
@@ -645,8 +525,6 @@ export default () => {
645
525
  refreshIframeByPostMessage: (id) => emitter.emit(TabEventType.REFRESH_IFRAME_POSTMESSAGE, id)
646
526
  })
647
527
 
648
- const getComponent = (id: string) => components.get(id)
649
-
650
528
  /**
651
529
  * 激活指定标签,更新 active 状态并可选跳转路由
652
530
  * @param id - 标签 id
@@ -658,13 +536,10 @@ export default () => {
658
536
  if (!target) return Promise.resolve()
659
537
  if (target.active) return Promise.resolve()
660
538
 
661
- const top = target.pages.peek()
539
+ const top = getTopPage(id)
662
540
  const activateTarget = () => {
663
- for (const tab of tabs.value) {
664
- tab.active = tab.id === id
665
- }
666
- saveActiveTabToSession(target as ITabItem)
667
- tabs.value = tabs.value.slice()
541
+ const activeTab = activateTab(id)
542
+ if (activeTab) saveActiveTabToSession(activeTab)
668
543
  }
669
544
 
670
545
  if (!route) {
@@ -683,21 +558,21 @@ export default () => {
683
558
  refreshable: target.refreshable,
684
559
  iframe: target.iframe
685
560
  })
686
- const query = defu({ __tab }, top.query || {})
561
+ // __tab 是兜底标识,目标栈顶已带 __tab 时优先沿用,否则用我们刚编码的版本。
562
+ const query = defu(top.query || {}, { __tab })
687
563
 
688
564
  return runNavigationTransaction({
689
565
  apply: () => {
690
- const prevActiveId = tabs.value.find((tab) => tab.active)?.id
691
- const sessionSnapshot = window.sessionStorage.getItem(getSessionKey())
566
+ const prevActiveId = getActiveTab()?.id
567
+ // SSR 环境无 sessionStorage;快照为 null 时 rollback 视为「无变化」。
568
+ const sessionSnapshot =
569
+ typeof window !== 'undefined' ? window.sessionStorage.getItem(getSessionKey()) : null
692
570
  activateTarget()
693
571
  return { prevActiveId, sessionSnapshot }
694
572
  },
695
573
  navigate: () => router.push({ path: top.path, query }),
696
574
  rollback: (snapshot) => {
697
- for (const tab of tabs.value) {
698
- tab.active = tab.id === snapshot.prevActiveId
699
- }
700
- tabs.value = tabs.value.slice()
575
+ activateTab(snapshot.prevActiveId ?? '')
701
576
  restoreActiveTabSession(snapshot.sessionSnapshot)
702
577
  },
703
578
  isFailureResult: isNavigationFailure,
@@ -708,13 +583,9 @@ export default () => {
708
583
  const reset = () => removeAllTabs()
709
584
 
710
585
  const destroy = () => {
711
- tabs.value = []
586
+ resetStore()
712
587
  defaultTabs.value = []
713
- cacheIdsToEvict.clear()
714
- tabIdsToEvict.clear()
715
588
  iframeRefreshKeys.value = {}
716
- caches.value = []
717
- components.clear()
718
589
  runtimeContext.scrollPositionsByPageId.clear()
719
590
  runtimeContext.isInitialized.value = false
720
591
  runtimeContext.refreshKey.value = 0
@@ -741,8 +612,17 @@ export default () => {
741
612
  addCache,
742
613
  removeCache,
743
614
  getTab,
615
+ getActiveTab,
616
+ getTopPage,
617
+ getPages,
618
+ getPageSnapshot,
744
619
  moveTab,
745
620
  addTab,
621
+ appendPage,
622
+ popPages,
623
+ replacePages,
624
+ clearPages,
625
+ updatePage,
746
626
  removeTab,
747
627
  removeAllTabs,
748
628
  removeLeftTabs,
@@ -751,12 +631,13 @@ export default () => {
751
631
  hasTab,
752
632
  renewTab,
753
633
  addPage,
754
- addComponent: (id: string, comp: DefineComponent) => components.set(id, comp),
634
+ addComponent: setComponent,
755
635
  getComponent,
756
- removeComponent: (id: string) => components.delete(id),
636
+ removeComponent: deleteComponent,
757
637
  markCacheForEviction,
758
638
  evictMarkedCaches,
759
639
  evictPageCache,
640
+ clearTabEvictionMarks,
760
641
  refreshTab,
761
642
  refreshAllTabs,
762
643
  iframeRefreshKeys,