vue-stack-tabs 1.4.2 → 2.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 (71) 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 +5 -2
  5. package/dist/components/ContextMenu/ContextMenuItem.vue.d.ts +1 -1
  6. package/dist/components/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -2
  7. package/dist/hooks/stackTabsContext.d.ts +0 -1
  8. package/dist/hooks/tabPanel/evict.d.ts +2 -2
  9. package/dist/hooks/tabPanel/refresh.d.ts +4 -2
  10. package/dist/hooks/tabPanel/scroll.d.ts +0 -2
  11. package/dist/hooks/tabPanel/state.d.ts +0 -1
  12. package/dist/hooks/tabPanel/tabStore.d.ts +12 -1
  13. package/dist/hooks/useIframeManager.d.ts +1 -1
  14. package/dist/hooks/useScrollShadow.d.ts +11 -2
  15. package/dist/hooks/useTabActions.d.ts +1 -0
  16. package/dist/hooks/useTabEventBus.d.ts +5 -0
  17. package/dist/hooks/useTabPanel.d.ts +14 -2
  18. package/dist/model/TabModel.d.ts +17 -5
  19. package/dist/nuxt/module.mjs +1 -1
  20. package/dist/nuxt/runtime/plugin.mjs +8 -1598
  21. package/dist/style.css +1 -1
  22. package/dist/utils/scrollUtils.d.ts +0 -2
  23. package/dist/utils/tabInfoEncoder.d.ts +8 -0
  24. package/dist/utils/urlParser.d.ts +20 -10
  25. package/dist/vue-stack-tabs.es.js +1 -1
  26. package/package.json +4 -7
  27. package/src/lib/StackTabs.vue +40 -3
  28. package/src/lib/assets/image/close_all.svg +1 -9
  29. package/src/lib/assets/image/close_lefts.svg +1 -1
  30. package/src/lib/assets/image/close_other.svg +1 -1
  31. package/src/lib/assets/image/close_rights.svg +1 -1
  32. package/src/lib/assets/image/reload.svg +1 -3
  33. package/src/lib/assets/image/reload_all.svg +1 -6
  34. package/src/lib/assets/style/common/mixins.scss +58 -19
  35. package/src/lib/assets/style/common/var.scss +100 -60
  36. package/src/lib/assets/style/icon.scss +31 -98
  37. package/src/lib/assets/style/index.scss +2 -2
  38. package/src/lib/assets/style/loading.scss +45 -0
  39. package/src/lib/assets/style/{stackTab.scss → stack-tab.scss} +116 -182
  40. package/src/lib/assets/style/transition.scss +12 -17
  41. package/src/lib/components/ContextMenu/ContextMenuItem.vue +5 -5
  42. package/src/lib/components/ContextMenu/index.vue +22 -19
  43. package/src/lib/components/PageLoading.vue +12 -11
  44. package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +1 -5
  45. package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +5 -3
  46. package/src/lib/components/TabHeader/TabHeaderItem.vue +13 -7
  47. package/src/lib/components/TabHeader/TabHeaderScroll.vue +170 -94
  48. package/src/lib/components/TabHeader/index.vue +67 -12
  49. package/src/lib/hooks/stackTabsContext.ts +0 -1
  50. package/src/lib/hooks/tabPanel/evict.ts +14 -6
  51. package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +252 -7
  52. package/src/lib/hooks/tabPanel/refresh.ts +27 -9
  53. package/src/lib/hooks/tabPanel/scroll.ts +4 -15
  54. package/src/lib/hooks/tabPanel/session.ts +11 -4
  55. package/src/lib/hooks/tabPanel/state.ts +0 -2
  56. package/src/lib/hooks/tabPanel/tabStore.ts +114 -26
  57. package/src/lib/hooks/useContextMenu.ts +1 -1
  58. package/src/lib/hooks/useIframeManager.ts +21 -18
  59. package/src/lib/hooks/useScrollShadow.ts +74 -19
  60. package/src/lib/hooks/useTabActions.ts +18 -8
  61. package/src/lib/hooks/useTabEventBus.ts +3 -0
  62. package/src/lib/hooks/useTabPanel.tsx +247 -58
  63. package/src/lib/hooks/useTabRouter.ts +18 -11
  64. package/src/lib/model/TabModel.ts +19 -5
  65. package/src/lib/nuxt/module.ts +3 -1
  66. package/src/lib/nuxt/runtime/plugin.ts +7 -3
  67. package/src/lib/utils/scrollUtils.ts +0 -18
  68. package/src/lib/utils/tabInfoEncoder.ts +121 -18
  69. package/src/lib/utils/urlParser.ts +75 -48
  70. package/dist/vue-stack-tabs.css +0 -2
  71. package/src/lib/assets/style/overlayer.scss +0 -46
@@ -1,8 +1,8 @@
1
- import { isNavigationFailure, useRouter } from 'vue-router'
1
+ import { isNavigationFailure, NavigationFailureType, useRouter } from 'vue-router'
2
2
  import type { LocationQueryRaw } from 'vue-router'
3
3
  import { defu } from 'defu'
4
4
  import useTabPanel from './useTabPanel'
5
- import { encodeTabInfo } from '../utils/tabInfoEncoder'
5
+ import { createId, encodeTabInfo } from '../utils/tabInfoEncoder'
6
6
  import {
7
7
  parseUrl,
8
8
  isCrossOriginUrl,
@@ -31,6 +31,7 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
31
31
  const runtimeContext = providedRuntimeContext ?? resolveStackTabsRuntimeContext()
32
32
  const {
33
33
  active,
34
+ updateTabTitle,
34
35
  hasTab,
35
36
  refreshKey,
36
37
  reset,
@@ -61,7 +62,7 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
61
62
  defaults.iframeRefreshMode = (tab.iframeRefreshMode ?? 'postMessage') as IframeRefreshMode
62
63
  }
63
64
  const info = defu(tab, defaults)
64
- if (!info.id) info.id = crypto.randomUUID()
65
+ if (!info.id) info.id = createId()
65
66
  return info
66
67
  }
67
68
 
@@ -94,7 +95,7 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
94
95
  return
95
96
  }
96
97
 
97
- navigateToTab(tab, tabInfo).then(
98
+ navigateToTab(tab, tabInfo, isExistingTab && renew).then(
98
99
  () => resolve(tabInfo.id),
99
100
  (error) => {
100
101
  rollbackRenew?.()
@@ -106,15 +107,17 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
106
107
  }
107
108
 
108
109
  /** 执行路由跳转到标签对应页面(普通路由或 iframe 占位路由) */
109
- const navigateToTab = (tab: ITabData, tabInfo: ITabData) => {
110
+ const navigateToTab = (tab: ITabData, tabInfo: ITabData, allowDuplicatedNavigation = false) => {
110
111
  const __tab = encodeTabInfo(tabInfo)
111
112
  const tabQuery = omitStackTabsReservedQuery(tab.query)
112
113
  let query = defu(tabQuery, { __tab }) as LocationQueryRaw
113
114
  let path: string
115
+ let hash: string | undefined
114
116
 
115
117
  if (!tab.iframe) {
116
118
  const url = parseUrl(tab.path)
117
119
  path = url.path
120
+ hash = url.hash || undefined
118
121
  query = defu(omitStackTabsReservedQuery(url.query), query, { __tab }) as LocationQueryRaw
119
122
  query = {
120
123
  ...query,
@@ -132,9 +135,15 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
132
135
  if (!isAllowedTabUrl(tab.path)) return Promise.reject(new Error('Invalid tab URL'))
133
136
  return runNavigationTransaction({
134
137
  apply: () => undefined,
135
- navigate: () => router.push({ path, query }),
138
+ navigate: () => router.push({ path, query, hash }),
136
139
  rollback: () => undefined,
137
- isFailureResult: isNavigationFailure,
140
+ // renew 当前标签时地址可能不变:vue-router 会返回 duplicated NavigationFailure,
141
+ // 这属于预期结果(refreshKey 会触发页面重新解析),不应让 openTab reject。
142
+ isFailureResult: (result) =>
143
+ isNavigationFailure(result) &&
144
+ !(
145
+ allowDuplicatedNavigation && isNavigationFailure(result, NavigationFailureType.duplicated)
146
+ ),
138
147
  rejectFailureResult: true
139
148
  })
140
149
  }
@@ -155,7 +164,7 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
155
164
  if (typeof document === 'undefined') return undefined
156
165
  const pageId = target.pageId ?? (target.tabId ? getTopPage(target.tabId)?.id : undefined)
157
166
  if (pageId) return document.getElementById(`W-${pageId}`) ?? undefined
158
- return document.querySelector<HTMLElement>('.cache-page-wrapper') ?? undefined
167
+ return document.querySelector<HTMLElement>('.stack-tab__cache-page-wrapper') ?? undefined
159
168
  }
160
169
 
161
170
  /** iframe 标签在新窗口打开(无法嵌入时降级) */
@@ -181,6 +190,7 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
181
190
  // 激活与重置
182
191
  activeTab: active,
183
192
  moveTab,
193
+ updateTabTitle,
184
194
  reset,
185
195
 
186
196
  // 数据与配置
@@ -19,6 +19,8 @@ declare module '@vue/runtime-core' {
19
19
  export enum TabEventType {
20
20
  PAGE_LOADING = 'PAGE_LOADING',
21
21
  TAB_ACTIVE = 'TAB_ACTIVE',
22
+ /** 请求标签栏将指定标签滚动到可视区域(不改变激活状态) */
23
+ TAB_SCROLL_TO = 'TAB_SCROLL_TO',
22
24
  FORWARD = 'FORWARD',
23
25
  BACKWARD = 'BACKWARD',
24
26
  REFRESH_IFRAME_POSTMESSAGE = 'REFRESH_IFRAME_POSTMESSAGE'
@@ -27,6 +29,7 @@ export enum TabEventType {
27
29
  export interface TabEventPayloadMap extends Record<EventType, unknown> {
28
30
  [TabEventType.PAGE_LOADING]: { tId: string; value: boolean }
29
31
  [TabEventType.TAB_ACTIVE]: { id: string; isRoute?: boolean }
32
+ [TabEventType.TAB_SCROLL_TO]: { id: string }
30
33
  [TabEventType.FORWARD]: void
31
34
  [TabEventType.BACKWARD]: void
32
35
  [TabEventType.REFRESH_IFRAME_POSTMESSAGE]: string
@@ -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 {
@@ -25,7 +25,9 @@ import {
25
25
  omitStackTabsReservedQuery,
26
26
  toSafeTabUrl,
27
27
  decodeSafeTabUrl,
28
- cloneLocationQuery
28
+ cloneLocationQuery,
29
+ isSameQueryIgnoringReserved,
30
+ normalizePathname
29
31
  } from '../utils/urlParser'
30
32
  import { TabEventType, useTabEmitter } from './useTabEventBus'
31
33
  import { runNavigationTransaction } from './tabPanel/navigationTransaction'
@@ -44,6 +46,9 @@ const cloneRouteQuery = (route: RouteLocationNormalizedLoaded, tabInfo: ITabBase
44
46
  __tab: route.query.__tab ?? encodeTabInfo(tabInfo)
45
47
  })
46
48
 
49
+ /** homeTab 未显式指定 id 时使用的固定 id,保证刷新前后 __tab 与标签身份一致。 */
50
+ const DEFAULT_HOME_TAB_ID = '__stack_tabs_home__'
51
+
47
52
  /** 规范化路径(nginx 等可能加尾部斜杠) */
48
53
  export const normalizePathForCache = (route: RouteLocationNormalizedLoaded): string => {
49
54
  const lastMatch = route.matched[route.matched.length - 1]
@@ -52,12 +57,11 @@ export const normalizePathForCache = (route: RouteLocationNormalizedLoaded): str
52
57
  return matchPath === path ? path : route.path
53
58
  }
54
59
 
55
- export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
60
+ const createTabPanelApi = (runtimeContext: StackTabsRuntimeContext) => {
56
61
  const router = useRouter()
57
- const runtimeContext = providedRuntimeContext ?? resolveStackTabsRuntimeContext()
58
62
  const emitter = useTabEmitter(runtimeContext)
59
63
  const { t } = useI18n()
60
- const { tabs, defaultTabs, caches, refreshKey, iframeRefreshKeys, isInitialized } = runtimeContext
64
+ const { tabs, caches, refreshKey, iframeRefreshKeys, isInitialized } = runtimeContext
61
65
  const store = getTabStore(runtimeContext)
62
66
  const {
63
67
  getTabs,
@@ -68,10 +72,12 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
68
72
  canAddTab,
69
73
  getPages,
70
74
  getTopPage,
75
+ getPageRefreshVersion,
71
76
  getPageSnapshot,
72
77
  addTab,
73
78
  addTabs,
74
79
  activateTab,
80
+ updateTabTitle: updateTabTitleInStore,
75
81
  moveTab,
76
82
  removeTab: removeTabFromStore,
77
83
  removeTabs: removeTabsFromStore,
@@ -80,6 +86,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
80
86
  replacePages,
81
87
  clearPages,
82
88
  updatePage,
89
+ updatePages,
83
90
  consumeBackParams,
84
91
  markCacheForEviction,
85
92
  markTabPagesForEvictionOnly,
@@ -114,30 +121,57 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
114
121
  const size = (): number => getTabs().length
115
122
 
116
123
  /**
117
- * 初始化:根据 defaultTabs 创建标签与初始页面,并尝试从 sessionStorage 恢复上次激活的标签
124
+ * 初始化:先创建固定首页(homeTab),再创建 defaultTabs,并尝试从 sessionStorage 恢复上次激活的标签
118
125
  * @param staticTabs - 初始标签配置
126
+ * @param homeTab - 固定首页;始终位于第一位、不可关闭、不可移动
119
127
  */
120
- const initialize = (staticTabs: ITabData[]) => {
121
- const newDefaults: ITabItem[] = []
128
+ const initialize = (staticTabs: ITabData[], homeTab?: ITabData) => {
129
+ // 重复调用会重复注册标签;初始化后直接忽略。
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
+ }
122
136
  const newTabs: ITabItem[] = []
137
+ let homeTabId = ''
123
138
 
124
- for (const item of staticTabs) {
139
+ /** 由 ITabData 构建运行时标签;homeTab 通过 options 强制固定属性 */
140
+ const buildTab = (
141
+ item: ITabData,
142
+ options: { pinned?: boolean; closable?: boolean } = {}
143
+ ): ITabItem => {
125
144
  // 先用默认值填充非空缺字段,避免在循环里提前求值生成用不到的 UUID。
126
145
  const fullItem = defu(item, {
127
146
  refreshable: true,
128
147
  closable: true,
129
148
  iframe: false
130
149
  })
131
- // id 单独兜底,避免每次循环都无意义地调用 crypto.randomUUID。
132
- fullItem.id = item.id ?? crypto.randomUUID()
150
+ // homeTab 未指定 id 时使用固定 id,避免每次初始化生成新 id 后与 session 恢复标签重复。
151
+ fullItem.id = item.id ?? (options.pinned ? DEFAULT_HOME_TAB_ID : createId())
152
+ if (options.closable !== undefined) fullItem.closable = options.closable
133
153
  const tabId = fullItem.id
154
+ const isIframe = Boolean(fullItem.iframe)
134
155
  const uri = parseUrl(fullItem.path)
135
156
  const cacheName = createPageId()
157
+ // iframe 标签的 path 是外部 URL:路由侧用 iframePath + __src 承载,
158
+ // 与 openTab 的语义保持一致(否则默认 iframe 标签会渲染 about:blank)。
159
+ const pagePath = isIframe
160
+ ? normalizePathname(runtimeContext.iframePath.value)
161
+ : normalizePathname(uri.path)
162
+ const iframeRefreshMode = isIframe
163
+ ? (fullItem.iframeRefreshMode ??
164
+ (isCrossOriginUrl(fullItem.path) ? 'reload' : 'postMessage'))
165
+ : 'postMessage'
166
+ // 把生效的刷新模式写回 fullItem,保证 __tab 与会话恢复得到一致的 iframe 刷新语义。
167
+ fullItem.iframeRefreshMode = iframeRefreshMode
168
+ const pageQuery = omitStackTabsReservedQuery(isIframe ? fullItem.query : uri.query)
169
+ if (isIframe) pageQuery.__src = encodeURIComponent(fullItem.path)
136
170
  const page: ITabPage = {
137
171
  id: cacheName,
138
172
  tabId,
139
- path: uri.path,
140
- query: defu(omitStackTabsReservedQuery(uri.query), { __tab: encodeTabInfo(fullItem) })
173
+ path: pagePath,
174
+ query: defu(pageQuery, { __tab: encodeTabInfo(fullItem) })
141
175
  }
142
176
  const pages = new Stack<ITabPage>()
143
177
  pages.push(page)
@@ -146,21 +180,39 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
146
180
  title: fullItem.title ?? '',
147
181
  closable: fullItem.closable!,
148
182
  refreshable: fullItem.refreshable!,
149
- iframe: fullItem.iframe!,
150
- iframeRefreshMode:
151
- (fullItem as { iframeRefreshMode?: string }).iframeRefreshMode === 'reload'
152
- ? 'reload'
153
- : 'postMessage',
183
+ iframe: isIframe,
184
+ iframeRefreshMode,
185
+ url: isIframe ? toSafeTabUrl(fullItem.path) : undefined,
154
186
  active: false,
155
- pages
187
+ pages,
188
+ pinned: options.pinned
156
189
  }
157
190
 
158
191
  addCache(cacheName)
159
- newDefaults.push(tab)
192
+ return tab
193
+ }
194
+
195
+ if (homeTab) {
196
+ const home = buildTab(homeTab, { pinned: true, closable: false })
197
+ homeTabId = home.id
198
+ newTabs.push({ ...home })
199
+ }
200
+
201
+ for (const item of staticTabs) {
202
+ // home 已单独创建;相同 id 的配置跳过,避免出现两个固定首页。
203
+ if (homeTabId && item.id === homeTabId) {
204
+ if (!import.meta.env.PROD) {
205
+ console.warn(
206
+ '[vue-stack-tabs] defaultTabs contains the same id as homeTab; skipped:',
207
+ item.id
208
+ )
209
+ }
210
+ continue
211
+ }
212
+ const tab = buildTab(item)
160
213
  newTabs.push({ ...tab })
161
214
  }
162
215
 
163
- defaultTabs.value = [...defaultTabs.value, ...newDefaults]
164
216
  addTabs(newTabs)
165
217
 
166
218
  // SSR 环境无 sessionStorage,直接跳过恢复。
@@ -209,15 +261,95 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
209
261
  iframeRefreshMode: tab.iframeRefreshMode
210
262
  })
211
263
 
264
+ /**
265
+ * 动态更新标签标题。
266
+ *
267
+ * 同步 TabRecord、对外视图与 session;激活标签还会把新的 __tab 写回当前 URL,
268
+ * 避免刷新或分享链接后标题回退。
269
+ */
270
+ const updateTabTitle = (tabId: string, title: string): boolean => {
271
+ if (!updateTabTitleInStore(tabId, title)) return false
272
+ const tab = getTab(tabId)
273
+ if (!tab) return false
274
+
275
+ if (tab.active) {
276
+ saveActiveTabToSession(tab)
277
+ const top = getTopPage(tabId)
278
+ if (top) {
279
+ void Promise.resolve(
280
+ router.replace({
281
+ path: top.path,
282
+ query: { ...cloneLocationQuery(top.query), __tab: encodeTabInfo(toTabBase(tab)) },
283
+ hash: router.currentRoute.value.hash
284
+ })
285
+ ).catch((error: unknown) => {
286
+ if (!import.meta.env.PROD) {
287
+ console.warn('[vue-stack-tabs] Failed to sync tab title to URL:', error)
288
+ }
289
+ })
290
+ }
291
+ }
292
+ return true
293
+ }
294
+
295
+ /** 当前固定首页(homeTab);未配置时返回 null。 */
296
+ const getHomeTab = (): ITabItem | null => getTabs().find((tab) => tab.pinned) ?? null
297
+
298
+ /**
299
+ * 无 __tab 且当前路径没有归属标签时,导航到 homeTab 的栈顶页面。
300
+ * 使用 replace,避免在浏览器历史里留下中间状态。
301
+ */
302
+ const redirectToHome = (): boolean => {
303
+ const home = getHomeTab()
304
+ const top = home ? getTopPage(home.id) : null
305
+ if (!home || !top) return false
306
+
307
+ const query = {
308
+ ...cloneLocationQuery(top.query),
309
+ __tab: encodeTabInfo(toTabBase(home))
310
+ }
311
+ void Promise.resolve(
312
+ router.replace({
313
+ path: top.path,
314
+ query,
315
+ hash: ''
316
+ })
317
+ ).catch((error: unknown) => {
318
+ if (!import.meta.env.PROD) {
319
+ console.warn('[vue-stack-tabs] Failed to redirect to home tab:', error)
320
+ }
321
+ })
322
+ return true
323
+ }
324
+
325
+ /**
326
+ * 是否应回落到 homeTab:仅限 URL 没有 __tab,且当前路径没有被任何标签的栈顶承载。
327
+ * 带 __tab 的地址即使页面不存在也不回落,交给应用自己的 404 路由处理。
328
+ */
329
+ const shouldRedirectToHome = (route: RouteLocationNormalizedLoaded): boolean => {
330
+ if (typeof route.query.__tab === 'string') return false
331
+
332
+ const path = normalizePathForCache(route)
333
+ const activeTab = getActiveTab()
334
+ if (activeTab && getTopPage(activeTab.id)?.path === path) return false
335
+ if (findTabByTopPagePath(path)) return false
336
+
337
+ const home = getHomeTab()
338
+ return Boolean(home && getTopPage(home.id))
339
+ }
340
+
212
341
  /**
213
342
  * 从路由解析 tabInfo,若无则创建默认信息,调用方负责把编码信息写入内部 page.query。
214
343
  *
215
344
  * 首次进入应用时 URL 上通常还没有 __tab:此时优先接管 path 匹配的默认标签(defaultTabs),
216
- * 由调用方把 __tab 写回地址,保证「URL 指向默认标签」;确实找不到归属时才新建兜底标签。
345
+ * 由调用方把 __tab 写回地址,保证「URL 指向默认标签」;确实找不到归属且配置了 homeTab 时
346
+ * 返回 null,由调用方重定向到首页;未配置 homeTab 时保持原有兜底标签行为。
217
347
  */
218
- const parseTabInfoFromRoute = (route: RouteLocationNormalizedLoaded): ITabBase => {
219
- if (typeof route.query.__tab === 'string') {
220
- const tabInfo = decodeTabInfo(route.query.__tab)
348
+ const parseTabInfoFromRoute = (route: RouteLocationNormalizedLoaded): ITabBase | null => {
349
+ const encodedTab = route.query.__tab
350
+ const hasEncodedTab = typeof encodedTab === 'string'
351
+ if (hasEncodedTab) {
352
+ const tabInfo = decodeTabInfo(encodedTab)
221
353
  if (tabInfo.id) return tabInfo
222
354
  }
223
355
 
@@ -234,8 +366,12 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
234
366
  const pathMatchedTab = findTabByTopPagePath(path)
235
367
  if (pathMatchedTab) return toTabBase(pathMatchedTab)
236
368
 
369
+ // 无 __tab 且无任何标签承载当前路径:配置了 homeTab 时交给调用方重定向。
370
+ // 注意:__tab 存在但无效时仍保持原行为(新建兜底标签),不把错误地址变成首页。
371
+ if (!hasEncodedTab && getHomeTab()) return null
372
+
237
373
  return {
238
- id: crypto.randomUUID(),
374
+ id: createId(),
239
375
  title: t('VueStackTab.undefined'),
240
376
  closable: true,
241
377
  refreshable: true,
@@ -268,7 +404,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
268
404
  url: decodeSafeTabUrl(src),
269
405
  active: true,
270
406
  pages
271
- } as unknown as ITabItem
407
+ }
272
408
  activateTab('')
273
409
  addTab(targetTab)
274
410
  emitter.emit(TabEventType.TAB_ACTIVE, { id: tabInfo.id!, isRoute: false })
@@ -295,27 +431,44 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
295
431
  | { cacheName: string; tabInfo: ITabBase; targetTab: ITabItem }
296
432
  | { empty: true }
297
433
  | { recovered: DefineComponent; cacheName: string } => {
298
- const tabInfo = parseTabInfoFromRoute(route)
434
+ const resolvedTabInfo = parseTabInfoFromRoute(route)
435
+ if (resolvedTabInfo === null) {
436
+ // 无 __tab 且没有标签承载当前路径:回落到 homeTab。
437
+ redirectToHome()
438
+ return { empty: true }
439
+ }
440
+ const tabInfo = resolvedTabInfo
299
441
  if (isTabMarkedForEviction(tabInfo.id!)) return { empty: true }
300
442
 
301
443
  const path = normalizePathForCache(route)
444
+ const routeQuery = cloneRouteQuery(route, tabInfo)
445
+ /** path 与业务 query 都一致才算同一个页面(忽略 __tab 等保留参数) */
446
+ const isSameLocation = (candidate: ITabPage): boolean =>
447
+ candidate.path === path && isSameQueryIgnoringReserved(routeQuery, candidate.query || {})
302
448
 
303
449
  let targetTab: ITabItem | null = tabInfo.id ? (getTab(tabInfo.id) as ITabItem | null) : null
304
450
  let cacheName: string
305
451
  let page: ITabPage | null = null
306
452
 
307
- if (targetTab && getTopPage(targetTab.id)) {
308
- const top = getTopPage(targetTab.id)!
309
- if (!top) throw new Error('[vue-stack-tabs] Stack is not empty but peek() returned undefined')
310
-
311
- if (top.path === path) {
453
+ const top = targetTab ? getTopPage(targetTab.id) : null
454
+ if (targetTab && top) {
455
+ if (isSameLocation(top)) {
312
456
  cacheName = top.id
457
+ } else if (top.path === path) {
458
+ // 同 path、不同 query:按产品语义作为新页面入栈,backward 才能按 query 精确回退。
459
+ cacheName = createPageId()
460
+ page = {
461
+ id: cacheName,
462
+ tabId: tabInfo.id!,
463
+ path,
464
+ query: cloneLocationQuery(routeQuery)
465
+ }
313
466
  } else {
314
467
  const pagesList = getPages(targetTab.id)
315
468
  let foundIndex = -1
316
469
  for (let i = pagesList.length - 2; i >= 0; i--) {
317
470
  const pastPage = pagesList[i]
318
- if (pastPage && pastPage.path === path) {
471
+ if (pastPage && isSameLocation(pastPage)) {
319
472
  foundIndex = i
320
473
  break
321
474
  }
@@ -339,8 +492,8 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
339
492
  page = {
340
493
  id: cacheName,
341
494
  tabId: tabInfo.id!,
342
- path: route.path,
343
- query: cloneRouteQuery(route, tabInfo)
495
+ path,
496
+ query: cloneLocationQuery(routeQuery)
344
497
  }
345
498
  }
346
499
  } else {
@@ -348,8 +501,8 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
348
501
  page = {
349
502
  id: cacheName,
350
503
  tabId: tabInfo.id!,
351
- path: route.path,
352
- query: cloneRouteQuery(route, tabInfo)
504
+ path,
505
+ query: cloneLocationQuery(routeQuery)
353
506
  }
354
507
  }
355
508
  }
@@ -358,8 +511,8 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
358
511
  page = {
359
512
  id: cacheName,
360
513
  tabId: tabInfo.id!,
361
- path: route.path,
362
- query: cloneRouteQuery(route, tabInfo)
514
+ path,
515
+ query: cloneLocationQuery(routeQuery)
363
516
  }
364
517
  }
365
518
 
@@ -378,11 +531,17 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
378
531
  // 首屏 URL 往往没有 __tab(用户直接打开默认路由):把解析出的标签编码写回地址,
379
532
  // 保证 URL 始终指向当前标签;之后的导航各自会带上自己的 __tab。
380
533
  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
- })
534
+ void router
535
+ .replace({
536
+ path: route.path,
537
+ query: { ...cloneLocationQuery(route.query), __tab: encodeTabInfo(tabInfo) },
538
+ hash: route.hash
539
+ })
540
+ .catch((error: unknown) => {
541
+ if (!import.meta.env.PROD) {
542
+ console.warn('[vue-stack-tabs] Failed to attach __tab to the current URL:', error)
543
+ }
544
+ })
386
545
  }
387
546
 
388
547
  saveActiveTabToSession(targetTab!)
@@ -404,9 +563,15 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
404
563
  route: RouteLocationNormalizedLoaded,
405
564
  component?: VNode | null
406
565
  ): DefineComponent => {
407
- if (!component) return getEmptyPlaceholderComponent()
566
+ if (!component) {
567
+ // 路由没有匹配到组件时不会进入 updatePageState;仍需处理无 __tab 的未知地址回落到首页。
568
+ // 带 __tab 的未知页面不做重定向,交给应用自己的 404 路由(或空占位)处理。
569
+ if (shouldRedirectToHome(route)) redirectToHome()
570
+ return getEmptyPlaceholderComponent()
571
+ }
408
572
 
409
- const routeKey = route.fullPath + (route.query.__tab || '')
573
+ // fullPath 已包含完整 query(含 __tab)与 hash;refreshKey 让 renew 能强制重新解析同一地址。
574
+ const routeKey = `${route.fullPath}::${refreshKey.value}`
410
575
  if (routeKey === lastRouteKey && lastAddPageResult) {
411
576
  return lastAddPageResult
412
577
  }
@@ -517,6 +682,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
517
682
  getTabs,
518
683
  getTopPage,
519
684
  updatePage,
685
+ updatePages,
520
686
  iframeRefreshKeys,
521
687
  createPageId,
522
688
  addCache,
@@ -534,7 +700,11 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
534
700
  emitter.emit(TabEventType.FORWARD)
535
701
  const target = getTab(id)
536
702
  if (!target) return Promise.resolve()
537
- if (target.active) return Promise.resolve()
703
+ if (target.active) {
704
+ // 已是激活标签时仍需通知标签栏确保其可见(用户可能已把标签栏滚开)。
705
+ emitter.emit(TabEventType.TAB_SCROLL_TO, { id })
706
+ return Promise.resolve()
707
+ }
538
708
 
539
709
  const top = getTopPage(id)
540
710
  const activateTarget = () => {
@@ -551,15 +721,11 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
551
721
  return Promise.resolve()
552
722
  }
553
723
 
554
- const __tab = encodeTabInfo({
555
- id: target.id,
556
- title: target.title,
557
- closable: target.closable,
558
- refreshable: target.refreshable,
559
- iframe: target.iframe
560
- })
561
- // __tab 是兜底标识,目标栈顶已带 __tab 时优先沿用,否则用我们刚编码的版本。
562
- const query = defu(top.query || {}, { __tab })
724
+ // __tab 是兜底标识:目标栈顶已带 __tab 时直接沿用,避免每次激活都重新编码。
725
+ const query = cloneLocationQuery(top.query)
726
+ if (typeof query.__tab !== 'string' || !query.__tab) {
727
+ query.__tab = encodeTabInfo(toTabBase(target))
728
+ }
563
729
 
564
730
  return runNavigationTransaction({
565
731
  apply: () => {
@@ -584,7 +750,6 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
584
750
 
585
751
  const destroy = () => {
586
752
  resetStore()
587
- defaultTabs.value = []
588
753
  iframeRefreshKeys.value = {}
589
754
  runtimeContext.scrollPositionsByPageId.clear()
590
755
  runtimeContext.isInitialized.value = false
@@ -606,6 +771,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
606
771
  initialize,
607
772
  size,
608
773
  active,
774
+ updateTabTitle,
609
775
  getCacheName: createCacheName,
610
776
  destroy,
611
777
  reset,
@@ -615,6 +781,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
615
781
  getActiveTab,
616
782
  getTopPage,
617
783
  getPages,
784
+ getPageRefreshVersion,
618
785
  getPageSnapshot,
619
786
  moveTab,
620
787
  addTab,
@@ -623,6 +790,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
623
790
  replacePages,
624
791
  clearPages,
625
792
  updatePage,
793
+ updatePages,
626
794
  removeTab,
627
795
  removeAllTabs,
628
796
  removeLeftTabs,
@@ -651,3 +819,24 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
651
819
  }
652
820
  }
653
821
  }
822
+
823
+ type TabPanelApi = ReturnType<typeof createTabPanelApi>
824
+
825
+ const panelApiByContext = new WeakMap<StackTabsRuntimeContext, TabPanelApi>()
826
+
827
+ /**
828
+ * 同一个运行时上下文复用同一套 panel API。
829
+ *
830
+ * StackTabs、StackKeepAlive、StackCacheRenderer、ContextMenu 与页面内的 useTabRouter
831
+ * 都会调用本 hook;首次调用在 setup 上下文中完成 useRouter/useI18n 注入,
832
+ * 后续调用直接返回缓存,避免重复创建 scroll/session/refresh 闭包与局部状态。
833
+ */
834
+ export default (providedRuntimeContext?: StackTabsRuntimeContext): TabPanelApi => {
835
+ const runtimeContext = providedRuntimeContext ?? resolveStackTabsRuntimeContext()
836
+ const existing = panelApiByContext.get(runtimeContext)
837
+ if (existing) return existing
838
+
839
+ const panelApi = createTabPanelApi(runtimeContext)
840
+ panelApiByContext.set(runtimeContext, panelApi)
841
+ return panelApi
842
+ }