vue-stack-tabs 1.4.1 → 2.2.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 (50) hide show
  1. package/README.en.md +1 -0
  2. package/README.md +1 -0
  3. package/README.ru.md +1 -0
  4. package/dist/StackTabs.vue.d.ts +3 -0
  5. package/dist/components/ContextMenu/ContextMenuItem.vue.d.ts +1 -1
  6. package/dist/hooks/tabPanel/pageComponentFactory.d.ts +5 -2
  7. package/dist/hooks/tabPanel/refresh.d.ts +8 -3
  8. package/dist/hooks/tabPanel/tabStore.d.ts +80 -0
  9. package/dist/hooks/useTabActions.d.ts +1 -0
  10. package/dist/hooks/useTabPanel.d.ts +27 -5
  11. package/dist/model/TabModel.d.ts +5 -0
  12. package/dist/style.css +1 -1
  13. package/dist/utils/tabInfoEncoder.d.ts +10 -0
  14. package/dist/utils/urlParser.d.ts +14 -3
  15. package/dist/vue-stack-tabs.es.js +1 -1
  16. package/package.json +4 -7
  17. package/src/lib/StackTabs.vue +24 -2
  18. package/src/lib/assets/image/close_all.svg +1 -9
  19. package/src/lib/assets/image/close_lefts.svg +1 -1
  20. package/src/lib/assets/image/close_other.svg +1 -1
  21. package/src/lib/assets/image/close_rights.svg +1 -1
  22. package/src/lib/assets/image/reload.svg +1 -3
  23. package/src/lib/assets/image/reload_all.svg +1 -6
  24. package/src/lib/assets/style/common/mixins.scss +58 -19
  25. package/src/lib/assets/style/common/var.scss +100 -60
  26. package/src/lib/assets/style/icon.scss +31 -98
  27. package/src/lib/assets/style/index.scss +2 -2
  28. package/src/lib/assets/style/loading.scss +45 -0
  29. package/src/lib/assets/style/{stackTab.scss → stack-tab.scss} +105 -177
  30. package/src/lib/assets/style/transition.scss +12 -17
  31. package/src/lib/components/ContextMenu/ContextMenuItem.vue +5 -5
  32. package/src/lib/components/ContextMenu/index.vue +22 -19
  33. package/src/lib/components/PageLoading.vue +5 -5
  34. package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +5 -3
  35. package/src/lib/components/TabHeader/TabHeaderItem.vue +13 -7
  36. package/src/lib/components/TabHeader/TabHeaderScroll.vue +8 -6
  37. package/src/lib/components/TabHeader/index.vue +16 -2
  38. package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +96 -26
  39. package/src/lib/hooks/tabPanel/refresh.ts +40 -18
  40. package/src/lib/hooks/tabPanel/tabStore.ts +665 -0
  41. package/src/lib/hooks/useContextMenu.ts +1 -1
  42. package/src/lib/hooks/useIframeManager.ts +10 -3
  43. package/src/lib/hooks/useTabActions.ts +10 -6
  44. package/src/lib/hooks/useTabPanel.tsx +325 -195
  45. package/src/lib/hooks/useTabRouter.ts +50 -48
  46. package/src/lib/model/TabModel.ts +5 -0
  47. package/src/lib/utils/tabInfoEncoder.ts +101 -17
  48. package/src/lib/utils/urlParser.ts +35 -11
  49. package/dist/vue-stack-tabs.css +0 -2
  50. package/src/lib/assets/style/overlayer.scss +0 -46
@@ -16,7 +16,7 @@ import { computed, ref } from 'vue'
16
16
  import type { DefineComponent, VNode } from 'vue'
17
17
  import { isNavigationFailure, useRouter } from 'vue-router'
18
18
  import type { RouteLocationNormalizedLoaded } from 'vue-router'
19
- import { encodeTabInfo, createPageId, decodeTabInfo } from '../utils/tabInfoEncoder'
19
+ import { encodeTabInfo, createId, createPageId, decodeTabInfo } from '../utils/tabInfoEncoder'
20
20
  import { defu } from 'defu'
21
21
  import { Stack } from '../model/TabModel'
22
22
  import {
@@ -26,14 +26,15 @@ import {
26
26
  toSafeTabUrl,
27
27
  decodeSafeTabUrl,
28
28
  cloneLocationQuery,
29
- clonePage
29
+ isSameQueryIgnoringReserved,
30
+ normalizePathname
30
31
  } from '../utils/urlParser'
31
32
  import { TabEventType, useTabEmitter } from './useTabEventBus'
32
33
  import { runNavigationTransaction } from './tabPanel/navigationTransaction'
33
34
  import { useI18n } from 'vue-i18n-lite'
34
35
 
35
36
  import { resolveStackTabsRuntimeContext, type StackTabsRuntimeContext } from './stackTabsContext'
36
- import { createTabPanelEviction } from './tabPanel/evict'
37
+ import { getTabStore } from './tabPanel/tabStore'
37
38
  import { createTabPanelScroll } from './tabPanel/scroll'
38
39
  import { createTabPanelSession } from './tabPanel/session'
39
40
  import { createTabPanelRefresh } from './tabPanel/refresh'
@@ -45,6 +46,9 @@ const cloneRouteQuery = (route: RouteLocationNormalizedLoaded, tabInfo: ITabBase
45
46
  __tab: route.query.__tab ?? encodeTabInfo(tabInfo)
46
47
  })
47
48
 
49
+ /** homeTab 未显式指定 id 时使用的固定 id,保证刷新前后 __tab 与标签身份一致。 */
50
+ const DEFAULT_HOME_TAB_ID = '__stack_tabs_home__'
51
+
48
52
  /** 规范化路径(nginx 等可能加尾部斜杠) */
49
53
  export const normalizePathForCache = (route: RouteLocationNormalizedLoaded): string => {
50
54
  const lastMatch = route.matched[route.matched.length - 1]
@@ -53,32 +57,57 @@ export const normalizePathForCache = (route: RouteLocationNormalizedLoaded): str
53
57
  return matchPath === path ? path : route.path
54
58
  }
55
59
 
56
- export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
60
+ const createTabPanelApi = (runtimeContext: StackTabsRuntimeContext) => {
57
61
  const router = useRouter()
58
- const runtimeContext = providedRuntimeContext ?? resolveStackTabsRuntimeContext()
59
62
  const emitter = useTabEmitter(runtimeContext)
60
63
  const { t } = useI18n()
64
+ const { tabs, defaultTabs, caches, refreshKey, iframeRefreshKeys, isInitialized } = runtimeContext
65
+ const store = getTabStore(runtimeContext)
61
66
  const {
62
- tabs,
63
- defaultTabs,
64
- caches,
65
- components,
66
- tabIdsToEvict,
67
- refreshKey,
68
- iframeRefreshKeys,
69
- isInitialized,
70
- cacheIdsToEvict
71
- } = runtimeContext
72
- const {
67
+ getTabs,
68
+ getTab,
69
+ getActiveTab,
70
+ findTabByTopPagePath,
71
+ hasTab,
72
+ canAddTab,
73
+ getPages,
74
+ getTopPage,
75
+ getPageRefreshVersion,
76
+ getPageSnapshot,
77
+ addTab,
78
+ addTabs,
79
+ activateTab,
80
+ updateTabTitle: updateTabTitleInStore,
81
+ moveTab,
82
+ removeTab: removeTabFromStore,
83
+ removeTabs: removeTabsFromStore,
84
+ appendPage,
85
+ popPages,
86
+ replacePages,
87
+ clearPages,
88
+ updatePage,
89
+ updatePages,
90
+ consumeBackParams,
73
91
  markCacheForEviction,
74
- markTabPagesForEviction,
75
92
  markTabPagesForEvictionOnly,
76
93
  addCache,
77
94
  removeCache,
78
95
  evictMarkedCaches,
79
96
  evictPageCache,
80
- replacePageCaches
81
- } = createTabPanelEviction(runtimeContext)
97
+ replacePageCaches,
98
+ isTabMarkedForEviction,
99
+ isCacheMarkedForEviction,
100
+ unmarkCacheForEviction,
101
+ clearTabEvictionMarks,
102
+ getComponent,
103
+ setComponent,
104
+ deleteComponent,
105
+ snapshotComponents,
106
+ restoreComponents,
107
+ snapshotCaches,
108
+ restoreCaches,
109
+ reset: resetStore
110
+ } = store
82
111
  const { restoreScroller, saveScroller, removeScroller, addPageScroller } =
83
112
  createTabPanelScroll(runtimeContext)
84
113
  const {
@@ -89,32 +118,46 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
89
118
  getSessionKey
90
119
  } = createTabPanelSession(runtimeContext)
91
120
 
92
- const size = (): number => tabs.value.length
121
+ const size = (): number => getTabs().length
93
122
 
94
123
  /**
95
- * 初始化:根据 defaultTabs 创建标签与初始页面,并尝试从 sessionStorage 恢复上次激活的标签
124
+ * 初始化:先创建固定首页(homeTab),再创建 defaultTabs,并尝试从 sessionStorage 恢复上次激活的标签
96
125
  * @param staticTabs - 初始标签配置
126
+ * @param homeTab - 固定首页;始终位于第一位、不可关闭、不可移动
97
127
  */
98
- const initialize = (staticTabs: ITabData[]) => {
128
+ const initialize = (staticTabs: ITabData[], homeTab?: ITabData) => {
129
+ // 重复调用会累加 defaultTabs 并重复注册标签;初始化后直接忽略。
130
+ if (isInitialized.value) {
131
+ if (!import.meta.env.PROD) {
132
+ console.warn('[vue-stack-tabs] initialize() called more than once; ignored.')
133
+ }
134
+ return
135
+ }
99
136
  const newDefaults: ITabItem[] = []
100
137
  const newTabs: ITabItem[] = []
138
+ let homeTabId = ''
101
139
 
102
- for (const item of staticTabs) {
140
+ /** 由 ITabData 构建运行时标签;homeTab 通过 options 强制固定属性 */
141
+ const buildTab = (
142
+ item: ITabData,
143
+ options: { pinned?: boolean; closable?: boolean } = {}
144
+ ): ITabItem => {
103
145
  // 先用默认值填充非空缺字段,避免在循环里提前求值生成用不到的 UUID。
104
146
  const fullItem = defu(item, {
105
147
  refreshable: true,
106
148
  closable: true,
107
149
  iframe: false
108
150
  })
109
- // id 单独兜底,避免每次循环都无意义地调用 crypto.randomUUID。
110
- fullItem.id = item.id ?? crypto.randomUUID()
151
+ // homeTab 未指定 id 时使用固定 id,避免每次初始化生成新 id 后与 session 恢复标签重复。
152
+ fullItem.id = item.id ?? (options.pinned ? DEFAULT_HOME_TAB_ID : createId())
153
+ if (options.closable !== undefined) fullItem.closable = options.closable
111
154
  const tabId = fullItem.id
112
155
  const uri = parseUrl(fullItem.path)
113
156
  const cacheName = createPageId()
114
157
  const page: ITabPage = {
115
158
  id: cacheName,
116
159
  tabId,
117
- path: uri.path,
160
+ path: normalizePathname(uri.path),
118
161
  query: defu(omitStackTabsReservedQuery(uri.query), { __tab: encodeTabInfo(fullItem) })
119
162
  }
120
163
  const pages = new Stack<ITabPage>()
@@ -130,98 +173,76 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
130
173
  ? 'reload'
131
174
  : 'postMessage',
132
175
  active: false,
133
- pages
176
+ pages,
177
+ pinned: options.pinned
134
178
  }
135
179
 
136
180
  addCache(cacheName)
181
+ return tab
182
+ }
183
+
184
+ if (homeTab) {
185
+ const home = buildTab(homeTab, { pinned: true, closable: false })
186
+ homeTabId = home.id
187
+ newDefaults.push(home)
188
+ newTabs.push({ ...home })
189
+ }
190
+
191
+ for (const item of staticTabs) {
192
+ // home 已单独创建;相同 id 的配置跳过,避免出现两个固定首页。
193
+ if (homeTabId && item.id === homeTabId) {
194
+ if (!import.meta.env.PROD) {
195
+ console.warn(
196
+ '[vue-stack-tabs] defaultTabs contains the same id as homeTab; skipped:',
197
+ item.id
198
+ )
199
+ }
200
+ continue
201
+ }
202
+ const tab = buildTab(item)
137
203
  newDefaults.push(tab)
138
204
  newTabs.push({ ...tab })
139
205
  }
140
206
 
141
207
  defaultTabs.value = [...defaultTabs.value, ...newDefaults]
142
- tabs.value = [...tabs.value, ...newTabs]
208
+ addTabs(newTabs)
143
209
 
144
210
  // SSR 环境无 sessionStorage,直接跳过恢复。
145
211
  const storedTabJson =
146
212
  typeof window !== 'undefined' ? sessionStorage.getItem(getSessionKey()) : null
147
213
  const restoredTab = restoreTabFromSession(storedTabJson)
148
- if (restoredTab && !tabs.value.some((t) => t.id === restoredTab.id)) {
214
+ if (restoredTab && !hasTab(restoredTab.id)) {
149
215
  if (restoredTab.url) restoredTab.url = toSafeTabUrl(restoredTab.url)
150
216
  if (restoredTab.iframe && restoredTab.url && restoredTab.iframeRefreshMode === undefined) {
151
217
  restoredTab.iframeRefreshMode = isCrossOriginUrl(restoredTab.url) ? 'reload' : 'postMessage'
152
218
  }
153
219
 
154
220
  if (restoredTab.pages && typeof restoredTab.pages.list === 'function') {
155
- const pages = restoredTab.pages.list()
156
- pages.forEach((page) => {
221
+ addTab(restoredTab)
222
+ for (const page of getPages(restoredTab.id)) {
157
223
  if (page && page.id) addCache(page.id)
158
- })
224
+ }
225
+ } else {
226
+ addTab(restoredTab)
159
227
  }
160
-
161
- tabs.value = [...tabs.value, restoredTab]
162
228
  }
163
229
  isInitialized.value = true
164
230
  }
165
231
 
166
- const hasTab = (id: string) => tabs.value.some((t) => t.id === id)
167
- const canAddTab = () =>
168
- runtimeContext.maxTabCount.value <= 0 ||
169
- (runtimeContext.maxTabCount.value > 0 && runtimeContext.maxTabCount.value > tabs.value.length)
170
-
171
- const addTab = (tab: ITabItem): void => {
172
- tabs.value = [...tabs.value, tab]
173
- }
174
-
175
- const getTab = (id: string) => tabs.value.find((t) => t.id === id) ?? null
176
-
177
- /**
178
- * 唯一的标签激活状态写入口。即使标签对象保持不变,也替换列表引用,
179
- * 让 TabHeader 与 iframe 视图在同一个响应式提交中观察到一致状态。
180
- */
181
- const setActiveTab = (id: string): ITabItem | null => {
182
- let activeTab: ITabItem | null = null
183
- tabs.value = tabs.value.map((tab) => {
184
- tab.active = tab.id === id
185
- if (tab.active) activeTab = tab
186
- return tab
187
- })
188
- return activeTab
189
- }
190
-
191
232
  const { resolvePageComponent } = createPageComponentFactory({
192
233
  runtimeContext,
193
- getTab,
234
+ consumeBackParams,
235
+ getComponent,
236
+ setComponent,
194
237
  addCache,
195
238
  evictMarkedCaches,
239
+ clearTabEvictionMarks,
196
240
  addPageScroller,
197
241
  saveScroller,
198
242
  restoreScroller,
199
243
  removeScroller
200
244
  })
201
245
 
202
- /**
203
- * 将指定标签移动到目标索引。标签对象本身不变,因此其页面栈和缓存关联保持不受影响。
204
- * @returns 是否实际调整了顺序
205
- */
206
- const moveTab = (id: string, targetIndex: number): boolean => {
207
- const sourceIndex = tabs.value.findIndex((tab) => tab.id === id)
208
- if (
209
- sourceIndex < 0 ||
210
- targetIndex < 0 ||
211
- targetIndex >= tabs.value.length ||
212
- sourceIndex === targetIndex
213
- ) {
214
- return false
215
- }
216
-
217
- const reorderedTabs = tabs.value.slice()
218
- const [tab] = reorderedTabs.splice(sourceIndex, 1)
219
- if (!tab) return false
220
- reorderedTabs.splice(targetIndex, 0, tab)
221
- tabs.value = reorderedTabs
222
- return true
223
- }
224
-
225
246
  /** 把已有标签的对外字段提取为 ITabBase(用于让路由归属回该标签) */
226
247
  const toTabBase = (tab: ITabItem): ITabBase => ({
227
248
  id: tab.id,
@@ -232,21 +253,101 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
232
253
  iframeRefreshMode: tab.iframeRefreshMode
233
254
  })
234
255
 
256
+ /**
257
+ * 动态更新标签标题。
258
+ *
259
+ * 同步 TabRecord、对外视图与 session;激活标签还会把新的 __tab 写回当前 URL,
260
+ * 避免刷新或分享链接后标题回退。
261
+ */
262
+ const updateTabTitle = (tabId: string, title: string): boolean => {
263
+ if (!updateTabTitleInStore(tabId, title)) return false
264
+ const tab = getTab(tabId)
265
+ if (!tab) return false
266
+
267
+ if (tab.active) {
268
+ saveActiveTabToSession(tab)
269
+ const top = getTopPage(tabId)
270
+ if (top) {
271
+ void Promise.resolve(
272
+ router.replace({
273
+ path: top.path,
274
+ query: { ...cloneLocationQuery(top.query), __tab: encodeTabInfo(toTabBase(tab)) },
275
+ hash: router.currentRoute.value.hash
276
+ })
277
+ ).catch((error: unknown) => {
278
+ if (!import.meta.env.PROD) {
279
+ console.warn('[vue-stack-tabs] Failed to sync tab title to URL:', error)
280
+ }
281
+ })
282
+ }
283
+ }
284
+ return true
285
+ }
286
+
287
+ /** 当前固定首页(homeTab);未配置时返回 null。 */
288
+ const getHomeTab = (): ITabItem | null => getTabs().find((tab) => tab.pinned) ?? null
289
+
290
+ /**
291
+ * 无 __tab 且当前路径没有归属标签时,导航到 homeTab 的栈顶页面。
292
+ * 使用 replace,避免在浏览器历史里留下中间状态。
293
+ */
294
+ const redirectToHome = (): boolean => {
295
+ const home = getHomeTab()
296
+ const top = home ? getTopPage(home.id) : null
297
+ if (!home || !top) return false
298
+
299
+ const query = {
300
+ ...cloneLocationQuery(top.query),
301
+ __tab: encodeTabInfo(toTabBase(home))
302
+ }
303
+ void Promise.resolve(
304
+ router.replace({
305
+ path: top.path,
306
+ query,
307
+ hash: ''
308
+ })
309
+ ).catch((error: unknown) => {
310
+ if (!import.meta.env.PROD) {
311
+ console.warn('[vue-stack-tabs] Failed to redirect to home tab:', error)
312
+ }
313
+ })
314
+ return true
315
+ }
316
+
317
+ /**
318
+ * 是否应回落到 homeTab:仅限 URL 没有 __tab,且当前路径没有被任何标签的栈顶承载。
319
+ * 带 __tab 的地址即使页面不存在也不回落,交给应用自己的 404 路由处理。
320
+ */
321
+ const shouldRedirectToHome = (route: RouteLocationNormalizedLoaded): boolean => {
322
+ if (typeof route.query.__tab === 'string') return false
323
+
324
+ const path = normalizePathForCache(route)
325
+ const activeTab = getActiveTab()
326
+ if (activeTab && getTopPage(activeTab.id)?.path === path) return false
327
+ if (findTabByTopPagePath(path)) return false
328
+
329
+ const home = getHomeTab()
330
+ return Boolean(home && getTopPage(home.id))
331
+ }
332
+
235
333
  /**
236
334
  * 从路由解析 tabInfo,若无则创建默认信息,调用方负责把编码信息写入内部 page.query。
237
335
  *
238
336
  * 首次进入应用时 URL 上通常还没有 __tab:此时优先接管 path 匹配的默认标签(defaultTabs),
239
- * 由调用方把 __tab 写回地址,保证「URL 指向默认标签」;确实找不到归属时才新建兜底标签。
337
+ * 由调用方把 __tab 写回地址,保证「URL 指向默认标签」;确实找不到归属且配置了 homeTab 时
338
+ * 返回 null,由调用方重定向到首页;未配置 homeTab 时保持原有兜底标签行为。
240
339
  */
241
- const parseTabInfoFromRoute = (route: RouteLocationNormalizedLoaded): ITabBase => {
242
- if (typeof route.query.__tab === 'string') {
243
- const tabInfo = decodeTabInfo(route.query.__tab)
340
+ const parseTabInfoFromRoute = (route: RouteLocationNormalizedLoaded): ITabBase | null => {
341
+ const encodedTab = route.query.__tab
342
+ const hasEncodedTab = typeof encodedTab === 'string'
343
+ if (hasEncodedTab) {
344
+ const tabInfo = decodeTabInfo(encodedTab)
244
345
  if (tabInfo.id) return tabInfo
245
346
  }
246
347
 
247
348
  const path = normalizePathForCache(route)
248
- const activeTab = tabs.value.find((tab) => tab.active)
249
- const activePage = activeTab?.pages.peek()
349
+ const activeTab = getActiveTab()
350
+ const activePage = activeTab ? getTopPage(activeTab.id) : null
250
351
  if (activeTab && activePage?.path === path) {
251
352
  return toTabBase(activeTab)
252
353
  }
@@ -254,11 +355,15 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
254
355
  // 当前激活标签与路由不一致:首屏 URL 无 __tab,或 session 恢复了上次激活的其它标签
255
356
  //(演示应用在 iframe 中重载后 hash 为空即属此类)。此时接管栈顶 path 与当前路由一致的
256
357
  // 标签(默认标签或已有标签),避免同一个页面既留下已有标签、又生成一个「未命名」的兜底标签。
257
- const pathMatchedTab = tabs.value.find((tab) => tab.pages.peek()?.path === path)
358
+ const pathMatchedTab = findTabByTopPagePath(path)
258
359
  if (pathMatchedTab) return toTabBase(pathMatchedTab)
259
360
 
361
+ // 无 __tab 且无任何标签承载当前路径:配置了 homeTab 时交给调用方重定向。
362
+ // 注意:__tab 存在但无效时仍保持原行为(新建兜底标签),不把错误地址变成首页。
363
+ if (!hasEncodedTab && getHomeTab()) return null
364
+
260
365
  return {
261
- id: crypto.randomUUID(),
366
+ id: createId(),
262
367
  title: t('VueStackTab.undefined'),
263
368
  closable: true,
264
369
  refreshable: true,
@@ -291,14 +396,15 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
291
396
  url: decodeSafeTabUrl(src),
292
397
  active: true,
293
398
  pages
294
- } as unknown as ITabItem
295
- setActiveTab('')
399
+ }
400
+ activateTab('')
296
401
  addTab(targetTab)
297
402
  emitter.emit(TabEventType.TAB_ACTIVE, { id: tabInfo.id!, isRoute: false })
298
403
  } else {
299
- setActiveTab(targetTab.id)
300
- if (targetTab.pages.isEmpty() || targetTab.pages.peek()!.id !== page.id) {
301
- targetTab.pages.push(page)
404
+ activateTab(targetTab.id)
405
+ const topPage = getTopPage(targetTab.id)
406
+ if (!topPage || topPage.id !== page.id) {
407
+ appendPage(targetTab.id, page)
302
408
  }
303
409
  }
304
410
  return targetTab
@@ -317,27 +423,46 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
317
423
  | { cacheName: string; tabInfo: ITabBase; targetTab: ITabItem }
318
424
  | { empty: true }
319
425
  | { recovered: DefineComponent; cacheName: string } => {
320
- const tabInfo = parseTabInfoFromRoute(route)
321
- if (tabIdsToEvict.has(tabInfo.id!)) return { empty: true }
426
+ const resolvedTabInfo = parseTabInfoFromRoute(route)
427
+ if (resolvedTabInfo === null) {
428
+ // 无 __tab 且没有标签承载当前路径:回落到 homeTab。
429
+ redirectToHome()
430
+ return { empty: true }
431
+ }
432
+ const tabInfo = resolvedTabInfo
433
+ if (isTabMarkedForEviction(tabInfo.id!)) return { empty: true }
322
434
 
323
435
  const path = normalizePathForCache(route)
436
+ const routeQuery = cloneRouteQuery(route, tabInfo)
437
+ /** path 与业务 query 都一致才算同一个页面(忽略 __tab 等保留参数) */
438
+ const isSameLocation = (candidate: ITabPage): boolean =>
439
+ candidate.path === path && isSameQueryIgnoringReserved(routeQuery, candidate.query || {})
324
440
 
325
441
  let targetTab: ITabItem | null = tabInfo.id ? (getTab(tabInfo.id) as ITabItem | null) : null
326
442
  let cacheName: string
327
443
  let page: ITabPage | null = null
328
444
 
329
- if (targetTab && !targetTab.pages.isEmpty()) {
330
- const top = targetTab.pages.peek()
445
+ if (targetTab && getTopPage(targetTab.id)) {
446
+ const top = getTopPage(targetTab.id)!
331
447
  if (!top) throw new Error('[vue-stack-tabs] Stack is not empty but peek() returned undefined')
332
448
 
333
- if (top.path === path) {
449
+ if (isSameLocation(top)) {
334
450
  cacheName = top.id
451
+ } else if (top.path === path) {
452
+ // 同 path、不同 query:按产品语义作为新页面入栈,backward 才能按 query 精确回退。
453
+ cacheName = createPageId()
454
+ page = {
455
+ id: cacheName,
456
+ tabId: tabInfo.id!,
457
+ path,
458
+ query: cloneLocationQuery(routeQuery)
459
+ }
335
460
  } else {
336
- const pagesList = targetTab.pages.readonlyList()
461
+ const pagesList = getPages(targetTab.id)
337
462
  let foundIndex = -1
338
463
  for (let i = pagesList.length - 2; i >= 0; i--) {
339
464
  const pastPage = pagesList[i]
340
- if (pastPage && pastPage.path === path) {
465
+ if (pastPage && isSameLocation(pastPage)) {
341
466
  foundIndex = i
342
467
  break
343
468
  }
@@ -350,21 +475,19 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
350
475
  )
351
476
  }
352
477
  const stepsToPop = pagesList.length - 1 - foundIndex
353
- for (let i = 0; i < stepsToPop; i++) {
354
- const popped = targetTab.pages.pop()
355
- // 仅标记待驱逐,由 onActivated 或下一拍 evictMarkedCaches 统一清理,
356
- // 避免 watcher 同步执行与守卫拒绝路由之间产生不一致。
357
- if (popped) markCacheForEviction(popped.id)
358
- }
359
- const newTop = targetTab.pages.peek()
478
+ const poppedPages = popPages(targetTab.id, stepsToPop)
479
+ // 仅标记待驱逐,由 onActivated 或下一拍 evictMarkedCaches 统一清理,
480
+ // 避免 watcher 同步执行与守卫拒绝路由之间产生不一致。
481
+ for (const popped of poppedPages) markCacheForEviction(popped.id)
482
+ const newTop = getTopPage(targetTab.id)
360
483
  cacheName = newTop ? newTop.id : createPageId()
361
484
 
362
485
  if (!newTop) {
363
486
  page = {
364
487
  id: cacheName,
365
488
  tabId: tabInfo.id!,
366
- path: route.path,
367
- query: cloneRouteQuery(route, tabInfo)
489
+ path,
490
+ query: cloneLocationQuery(routeQuery)
368
491
  }
369
492
  }
370
493
  } else {
@@ -372,8 +495,8 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
372
495
  page = {
373
496
  id: cacheName,
374
497
  tabId: tabInfo.id!,
375
- path: route.path,
376
- query: cloneRouteQuery(route, tabInfo)
498
+ path,
499
+ query: cloneLocationQuery(routeQuery)
377
500
  }
378
501
  }
379
502
  }
@@ -382,21 +505,21 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
382
505
  page = {
383
506
  id: cacheName,
384
507
  tabId: tabInfo.id!,
385
- path: route.path,
386
- query: cloneRouteQuery(route, tabInfo)
508
+ path,
509
+ query: cloneLocationQuery(routeQuery)
387
510
  }
388
511
  }
389
512
 
390
- if (cacheIdsToEvict.has(cacheName)) {
391
- cacheIdsToEvict.delete(cacheName)
392
- const cached = components.get(cacheName)
513
+ if (isCacheMarkedForEviction(cacheName)) {
514
+ unmarkCacheForEviction(cacheName)
515
+ const cached = getComponent(cacheName)
393
516
  if (cached) return { recovered: cached, cacheName }
394
517
  }
395
518
 
396
519
  if (page) {
397
520
  targetTab = findOrCreateTargetTab(tabInfo, route, cacheName, page)
398
521
  } else {
399
- setActiveTab(targetTab!.id)
522
+ activateTab(targetTab!.id)
400
523
  }
401
524
 
402
525
  // 首屏 URL 往往没有 __tab(用户直接打开默认路由):把解析出的标签编码写回地址,
@@ -417,8 +540,8 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
417
540
  const activeCacheKey = ref<string>('')
418
541
  /** 只随当前活动页面刷新变化,避免一个 Tab 刷新导致其他 Tab 的缓存 key 失效。 */
419
542
  const activePageRefreshVersion = computed(() => {
420
- const activeTab = tabs.value.find((tab) => tab.active)
421
- return activeTab?.pages.peek()?.refreshVersion ?? 0
543
+ const activeTab = getActiveTab()
544
+ return activeTab ? (getTopPage(activeTab.id)?.refreshVersion ?? 0) : 0
422
545
  })
423
546
 
424
547
  let lastRouteKey = ''
@@ -428,7 +551,12 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
428
551
  route: RouteLocationNormalizedLoaded,
429
552
  component?: VNode | null
430
553
  ): DefineComponent => {
431
- if (!component) return getEmptyPlaceholderComponent()
554
+ if (!component) {
555
+ // 路由没有匹配到组件时不会进入 updatePageState;仍需处理无 __tab 的未知地址回落到首页。
556
+ // 带 __tab 的未知页面不做重定向,交给应用自己的 404 路由(或空占位)处理。
557
+ if (shouldRedirectToHome(route)) redirectToHome()
558
+ return getEmptyPlaceholderComponent()
559
+ }
432
560
 
433
561
  const routeKey = route.fullPath + (route.query.__tab || '')
434
562
  if (routeKey === lastRouteKey && lastAddPageResult) {
@@ -455,74 +583,45 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
455
583
  const currentTab = getTab(tab.id!)
456
584
  if (!currentTab) return undefined
457
585
 
458
- const pageSnapshot = currentTab.pages.list().map((p) => clonePage(p))
459
- const cacheSnapshot = [...caches.value]
460
- const componentSnapshot = new Map(components)
586
+ const pageSnapshot = getPageSnapshot(currentTab.id)
587
+ const cacheSnapshot = snapshotCaches()
588
+ const componentSnapshot = snapshotComponents()
461
589
 
462
590
  markTabPagesForEvictionOnly(currentTab)
463
591
  evictMarkedCaches()
464
- currentTab.pages.clear()
592
+ clearPages(currentTab.id)
465
593
 
466
594
  return () => {
467
- currentTab.pages.clear()
468
- for (const page of pageSnapshot) currentTab.pages.push(clonePage(page))
469
- caches.value = [...cacheSnapshot]
470
- components.clear()
471
- for (const [id, component] of componentSnapshot) components.set(id, component)
595
+ replacePages(currentTab.id, pageSnapshot)
596
+ restoreCaches(cacheSnapshot)
597
+ restoreComponents(componentSnapshot)
472
598
  }
473
599
  }
474
600
 
475
601
  const removeTab = (id: string): string => {
476
- let activeTabId = ''
477
- const currentTab = getTab(id)
478
- const tabList = tabs.value
479
- const i = tabList.findIndex((t) => t?.id === id)
480
- if (i < 0) return ''
481
-
482
- const tab = tabList[i]!
483
- if (!tab.closable) return id
484
-
485
- markTabPagesForEviction(tab)
486
- tab.pages.clear()
487
- if (tabList.length > 1 && tab.active) {
488
- activeTabId = (i === 0 ? tabList[1] : tabList[i - 1])?.id ?? ''
489
- }
490
- tabs.value = [...tabList.slice(0, i), ...tabList.slice(i + 1)]
602
+ const result = removeTabFromStore(id)
603
+ if (result.status === 'not-found') return ''
604
+ if (result.status === 'not-closable') return id
491
605
 
492
- if (activeTabId) {
493
- emitter.emit(TabEventType.TAB_ACTIVE, { id: activeTabId })
494
- }
495
- if (!currentTab?.active) {
496
- evictMarkedCaches()
497
- tabIdsToEvict.clear()
606
+ if (result.nextActiveId) {
607
+ emitter.emit(TabEventType.TAB_ACTIVE, { id: result.nextActiveId })
498
608
  }
499
609
 
500
- return activeTabId
610
+ return result.nextActiveId
501
611
  }
502
612
 
503
613
  /** 批量关闭满足条件的可关闭标签,并统一标记其页面缓存待驱逐。 */
504
- const removeTabs = (shouldRemove: (tab: ITabItem, index: number) => boolean) => {
505
- const removed: ITabItem[] = []
506
- const remaining = tabs.value.filter((tab, index) => {
507
- if (!tab.closable || !shouldRemove(tab, index)) return true
508
- markTabPagesForEviction(tab)
509
- tab.pages.clear()
510
- removed.push(tab)
511
- return false
512
- })
513
- tabs.value = remaining
514
- return removed
515
- }
614
+ const removeTabs = (shouldRemove: (tab: ITabItem, index: number) => boolean) =>
615
+ removeTabsFromStore(shouldRemove)
516
616
 
517
617
  const removeAllTabs = () => {
518
618
  removeTabs(() => true)
519
- const hasActive = tabs.value.some((t) => t.active)
520
- if (hasActive) {
619
+ if (getActiveTab()) {
521
620
  evictMarkedCaches()
522
- tabIdsToEvict.clear()
621
+ clearTabEvictionMarks()
523
622
  return
524
623
  }
525
- const last = tabs.value.at(-1)
624
+ const last = getTabs().at(-1)
526
625
  if (last) emitter.emit(TabEventType.TAB_ACTIVE, { id: last.id })
527
626
  }
528
627
 
@@ -533,42 +632,45 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
533
632
  emitter.emit(TabEventType.TAB_ACTIVE, { id })
534
633
  } else {
535
634
  evictMarkedCaches()
536
- tabIdsToEvict.clear()
635
+ clearTabEvictionMarks()
537
636
  }
538
637
  }
539
638
 
540
639
  const removeLeftTabs = (id: string) => {
541
- const pivot = tabs.value.findIndex((t) => t?.id === id)
640
+ const pivot = getTabs().findIndex((t) => t?.id === id)
542
641
  if (pivot <= 0) return
543
642
 
544
643
  removeTabs((_, index) => index < pivot)
545
- if (tabs.value.some((t) => t.active)) {
644
+ if (getActiveTab()) {
546
645
  evictMarkedCaches()
547
- tabIdsToEvict.clear()
646
+ clearTabEvictionMarks()
548
647
  return
549
648
  }
550
- const first = tabs.value[0]
649
+ const first = getTabs()[0]
551
650
  if (first) emitter.emit(TabEventType.TAB_ACTIVE, { id: first.id })
552
651
  }
553
652
 
554
653
  const removeRightTabs = (id: string) => {
555
- const pivot = tabs.value.findIndex((t) => t?.id === id)
556
- if (pivot < 0 || pivot >= tabs.value.length - 1) return
654
+ const tabList = getTabs()
655
+ const pivot = tabList.findIndex((t) => t?.id === id)
656
+ if (pivot < 0 || pivot >= tabList.length - 1) return
557
657
 
558
658
  removeTabs((_, index) => index > pivot)
559
- if (tabs.value.some((t) => t.active)) {
659
+ if (getActiveTab()) {
560
660
  evictMarkedCaches()
561
- tabIdsToEvict.clear()
661
+ clearTabEvictionMarks()
562
662
  return
563
663
  }
564
- const last = tabs.value.at(-1)
664
+ const last = getTabs().at(-1)
565
665
  if (last) emitter.emit(TabEventType.TAB_ACTIVE, { id: last.id })
566
666
  }
567
667
 
568
668
  const { refreshTab, refreshAllTabs } = createTabPanelRefresh({
569
- tabs,
669
+ getTabs,
670
+ getTopPage,
671
+ updatePage,
672
+ updatePages,
570
673
  iframeRefreshKeys,
571
- getTab,
572
674
  createPageId,
573
675
  addCache,
574
676
  evictPageCache,
@@ -576,8 +678,6 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
576
678
  refreshIframeByPostMessage: (id) => emitter.emit(TabEventType.REFRESH_IFRAME_POSTMESSAGE, id)
577
679
  })
578
680
 
579
- const getComponent = (id: string) => components.get(id)
580
-
581
681
  /**
582
682
  * 激活指定标签,更新 active 状态并可选跳转路由
583
683
  * @param id - 标签 id
@@ -589,9 +689,9 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
589
689
  if (!target) return Promise.resolve()
590
690
  if (target.active) return Promise.resolve()
591
691
 
592
- const top = target.pages.peek()
692
+ const top = getTopPage(id)
593
693
  const activateTarget = () => {
594
- const activeTab = setActiveTab(id)
694
+ const activeTab = activateTab(id)
595
695
  if (activeTab) saveActiveTabToSession(activeTab)
596
696
  }
597
697
 
@@ -616,7 +716,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
616
716
 
617
717
  return runNavigationTransaction({
618
718
  apply: () => {
619
- const prevActiveId = tabs.value.find((tab) => tab.active)?.id
719
+ const prevActiveId = getActiveTab()?.id
620
720
  // SSR 环境无 sessionStorage;快照为 null 时 rollback 视为「无变化」。
621
721
  const sessionSnapshot =
622
722
  typeof window !== 'undefined' ? window.sessionStorage.getItem(getSessionKey()) : null
@@ -625,7 +725,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
625
725
  },
626
726
  navigate: () => router.push({ path: top.path, query }),
627
727
  rollback: (snapshot) => {
628
- setActiveTab(snapshot.prevActiveId ?? '')
728
+ activateTab(snapshot.prevActiveId ?? '')
629
729
  restoreActiveTabSession(snapshot.sessionSnapshot)
630
730
  },
631
731
  isFailureResult: isNavigationFailure,
@@ -636,13 +736,9 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
636
736
  const reset = () => removeAllTabs()
637
737
 
638
738
  const destroy = () => {
639
- tabs.value = []
739
+ resetStore()
640
740
  defaultTabs.value = []
641
- cacheIdsToEvict.clear()
642
- tabIdsToEvict.clear()
643
741
  iframeRefreshKeys.value = {}
644
- caches.value = []
645
- components.clear()
646
742
  runtimeContext.scrollPositionsByPageId.clear()
647
743
  runtimeContext.isInitialized.value = false
648
744
  runtimeContext.refreshKey.value = 0
@@ -663,14 +759,26 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
663
759
  initialize,
664
760
  size,
665
761
  active,
762
+ updateTabTitle,
666
763
  getCacheName: createCacheName,
667
764
  destroy,
668
765
  reset,
669
766
  addCache,
670
767
  removeCache,
671
768
  getTab,
769
+ getActiveTab,
770
+ getTopPage,
771
+ getPages,
772
+ getPageRefreshVersion,
773
+ getPageSnapshot,
672
774
  moveTab,
673
775
  addTab,
776
+ appendPage,
777
+ popPages,
778
+ replacePages,
779
+ clearPages,
780
+ updatePage,
781
+ updatePages,
674
782
  removeTab,
675
783
  removeAllTabs,
676
784
  removeLeftTabs,
@@ -679,12 +787,13 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
679
787
  hasTab,
680
788
  renewTab,
681
789
  addPage,
682
- addComponent: (id: string, comp: DefineComponent) => components.set(id, comp),
790
+ addComponent: setComponent,
683
791
  getComponent,
684
- removeComponent: (id: string) => components.delete(id),
792
+ removeComponent: deleteComponent,
685
793
  markCacheForEviction,
686
794
  evictMarkedCaches,
687
795
  evictPageCache,
796
+ clearTabEvictionMarks,
688
797
  refreshTab,
689
798
  refreshAllTabs,
690
799
  iframeRefreshKeys,
@@ -698,3 +807,24 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
698
807
  }
699
808
  }
700
809
  }
810
+
811
+ type TabPanelApi = ReturnType<typeof createTabPanelApi>
812
+
813
+ const panelApiByContext = new WeakMap<StackTabsRuntimeContext, TabPanelApi>()
814
+
815
+ /**
816
+ * 同一个运行时上下文复用同一套 panel API。
817
+ *
818
+ * StackTabs、StackKeepAlive、StackCacheRenderer、ContextMenu 与页面内的 useTabRouter
819
+ * 都会调用本 hook;首次调用在 setup 上下文中完成 useRouter/useI18n 注入,
820
+ * 后续调用直接返回缓存,避免重复创建 scroll/session/refresh 闭包与局部状态。
821
+ */
822
+ export default (providedRuntimeContext?: StackTabsRuntimeContext): TabPanelApi => {
823
+ const runtimeContext = providedRuntimeContext ?? resolveStackTabsRuntimeContext()
824
+ const existing = panelApiByContext.get(runtimeContext)
825
+ if (existing) return existing
826
+
827
+ const panelApi = createTabPanelApi(runtimeContext)
828
+ panelApiByContext.set(runtimeContext, panelApi)
829
+ return panelApi
830
+ }