vue-stack-tabs 1.4.2 → 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 (49) 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/refresh.d.ts +4 -2
  7. package/dist/hooks/tabPanel/tabStore.d.ts +12 -1
  8. package/dist/hooks/useTabActions.d.ts +1 -0
  9. package/dist/hooks/useTabPanel.d.ts +14 -2
  10. package/dist/model/TabModel.d.ts +5 -0
  11. package/dist/style.css +1 -1
  12. package/dist/utils/tabInfoEncoder.d.ts +10 -0
  13. package/dist/utils/urlParser.d.ts +14 -3
  14. package/dist/vue-stack-tabs.es.js +1 -1
  15. package/package.json +4 -7
  16. package/src/lib/StackTabs.vue +24 -2
  17. package/src/lib/assets/image/close_all.svg +1 -9
  18. package/src/lib/assets/image/close_lefts.svg +1 -1
  19. package/src/lib/assets/image/close_other.svg +1 -1
  20. package/src/lib/assets/image/close_rights.svg +1 -1
  21. package/src/lib/assets/image/reload.svg +1 -3
  22. package/src/lib/assets/image/reload_all.svg +1 -6
  23. package/src/lib/assets/style/common/mixins.scss +58 -19
  24. package/src/lib/assets/style/common/var.scss +100 -60
  25. package/src/lib/assets/style/icon.scss +31 -98
  26. package/src/lib/assets/style/index.scss +2 -2
  27. package/src/lib/assets/style/loading.scss +45 -0
  28. package/src/lib/assets/style/{stackTab.scss → stack-tab.scss} +105 -177
  29. package/src/lib/assets/style/transition.scss +12 -17
  30. package/src/lib/components/ContextMenu/ContextMenuItem.vue +5 -5
  31. package/src/lib/components/ContextMenu/index.vue +22 -19
  32. package/src/lib/components/PageLoading.vue +5 -5
  33. package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +5 -3
  34. package/src/lib/components/TabHeader/TabHeaderItem.vue +13 -7
  35. package/src/lib/components/TabHeader/TabHeaderScroll.vue +8 -6
  36. package/src/lib/components/TabHeader/index.vue +16 -2
  37. package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +83 -7
  38. package/src/lib/hooks/tabPanel/refresh.ts +27 -9
  39. package/src/lib/hooks/tabPanel/tabStore.ts +86 -16
  40. package/src/lib/hooks/useContextMenu.ts +1 -1
  41. package/src/lib/hooks/useIframeManager.ts +10 -3
  42. package/src/lib/hooks/useTabActions.ts +8 -4
  43. package/src/lib/hooks/useTabPanel.tsx +204 -27
  44. package/src/lib/hooks/useTabRouter.ts +18 -11
  45. package/src/lib/model/TabModel.ts +5 -0
  46. package/src/lib/utils/tabInfoEncoder.ts +101 -17
  47. package/src/lib/utils/urlParser.ts +35 -11
  48. package/dist/vue-stack-tabs.css +0 -2
  49. 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 {
@@ -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,9 +57,8 @@ 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
64
  const { tabs, defaultTabs, caches, refreshKey, iframeRefreshKeys, isInitialized } = runtimeContext
@@ -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,29 +121,43 @@ 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[]) => {
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
+ }
121
136
  const newDefaults: ITabItem[] = []
122
137
  const newTabs: ITabItem[] = []
138
+ let homeTabId = ''
123
139
 
124
- for (const item of staticTabs) {
140
+ /** 由 ITabData 构建运行时标签;homeTab 通过 options 强制固定属性 */
141
+ const buildTab = (
142
+ item: ITabData,
143
+ options: { pinned?: boolean; closable?: boolean } = {}
144
+ ): ITabItem => {
125
145
  // 先用默认值填充非空缺字段,避免在循环里提前求值生成用不到的 UUID。
126
146
  const fullItem = defu(item, {
127
147
  refreshable: true,
128
148
  closable: true,
129
149
  iframe: false
130
150
  })
131
- // id 单独兜底,避免每次循环都无意义地调用 crypto.randomUUID。
132
- 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
133
154
  const tabId = fullItem.id
134
155
  const uri = parseUrl(fullItem.path)
135
156
  const cacheName = createPageId()
136
157
  const page: ITabPage = {
137
158
  id: cacheName,
138
159
  tabId,
139
- path: uri.path,
160
+ path: normalizePathname(uri.path),
140
161
  query: defu(omitStackTabsReservedQuery(uri.query), { __tab: encodeTabInfo(fullItem) })
141
162
  }
142
163
  const pages = new Stack<ITabPage>()
@@ -152,10 +173,33 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
152
173
  ? 'reload'
153
174
  : 'postMessage',
154
175
  active: false,
155
- pages
176
+ pages,
177
+ pinned: options.pinned
156
178
  }
157
179
 
158
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)
159
203
  newDefaults.push(tab)
160
204
  newTabs.push({ ...tab })
161
205
  }
@@ -209,15 +253,95 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
209
253
  iframeRefreshMode: tab.iframeRefreshMode
210
254
  })
211
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
+
212
333
  /**
213
334
  * 从路由解析 tabInfo,若无则创建默认信息,调用方负责把编码信息写入内部 page.query。
214
335
  *
215
336
  * 首次进入应用时 URL 上通常还没有 __tab:此时优先接管 path 匹配的默认标签(defaultTabs),
216
- * 由调用方把 __tab 写回地址,保证「URL 指向默认标签」;确实找不到归属时才新建兜底标签。
337
+ * 由调用方把 __tab 写回地址,保证「URL 指向默认标签」;确实找不到归属且配置了 homeTab 时
338
+ * 返回 null,由调用方重定向到首页;未配置 homeTab 时保持原有兜底标签行为。
217
339
  */
218
- const parseTabInfoFromRoute = (route: RouteLocationNormalizedLoaded): ITabBase => {
219
- if (typeof route.query.__tab === 'string') {
220
- 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)
221
345
  if (tabInfo.id) return tabInfo
222
346
  }
223
347
 
@@ -234,8 +358,12 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
234
358
  const pathMatchedTab = findTabByTopPagePath(path)
235
359
  if (pathMatchedTab) return toTabBase(pathMatchedTab)
236
360
 
361
+ // 无 __tab 且无任何标签承载当前路径:配置了 homeTab 时交给调用方重定向。
362
+ // 注意:__tab 存在但无效时仍保持原行为(新建兜底标签),不把错误地址变成首页。
363
+ if (!hasEncodedTab && getHomeTab()) return null
364
+
237
365
  return {
238
- id: crypto.randomUUID(),
366
+ id: createId(),
239
367
  title: t('VueStackTab.undefined'),
240
368
  closable: true,
241
369
  refreshable: true,
@@ -268,7 +396,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
268
396
  url: decodeSafeTabUrl(src),
269
397
  active: true,
270
398
  pages
271
- } as unknown as ITabItem
399
+ }
272
400
  activateTab('')
273
401
  addTab(targetTab)
274
402
  emitter.emit(TabEventType.TAB_ACTIVE, { id: tabInfo.id!, isRoute: false })
@@ -295,10 +423,20 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
295
423
  | { cacheName: string; tabInfo: ITabBase; targetTab: ITabItem }
296
424
  | { empty: true }
297
425
  | { recovered: DefineComponent; cacheName: string } => {
298
- const tabInfo = parseTabInfoFromRoute(route)
426
+ const resolvedTabInfo = parseTabInfoFromRoute(route)
427
+ if (resolvedTabInfo === null) {
428
+ // 无 __tab 且没有标签承载当前路径:回落到 homeTab。
429
+ redirectToHome()
430
+ return { empty: true }
431
+ }
432
+ const tabInfo = resolvedTabInfo
299
433
  if (isTabMarkedForEviction(tabInfo.id!)) return { empty: true }
300
434
 
301
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 || {})
302
440
 
303
441
  let targetTab: ITabItem | null = tabInfo.id ? (getTab(tabInfo.id) as ITabItem | null) : null
304
442
  let cacheName: string
@@ -308,14 +446,23 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
308
446
  const top = getTopPage(targetTab.id)!
309
447
  if (!top) throw new Error('[vue-stack-tabs] Stack is not empty but peek() returned undefined')
310
448
 
311
- if (top.path === path) {
449
+ if (isSameLocation(top)) {
312
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
+ }
313
460
  } else {
314
461
  const pagesList = getPages(targetTab.id)
315
462
  let foundIndex = -1
316
463
  for (let i = pagesList.length - 2; i >= 0; i--) {
317
464
  const pastPage = pagesList[i]
318
- if (pastPage && pastPage.path === path) {
465
+ if (pastPage && isSameLocation(pastPage)) {
319
466
  foundIndex = i
320
467
  break
321
468
  }
@@ -339,8 +486,8 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
339
486
  page = {
340
487
  id: cacheName,
341
488
  tabId: tabInfo.id!,
342
- path: route.path,
343
- query: cloneRouteQuery(route, tabInfo)
489
+ path,
490
+ query: cloneLocationQuery(routeQuery)
344
491
  }
345
492
  }
346
493
  } else {
@@ -348,8 +495,8 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
348
495
  page = {
349
496
  id: cacheName,
350
497
  tabId: tabInfo.id!,
351
- path: route.path,
352
- query: cloneRouteQuery(route, tabInfo)
498
+ path,
499
+ query: cloneLocationQuery(routeQuery)
353
500
  }
354
501
  }
355
502
  }
@@ -358,8 +505,8 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
358
505
  page = {
359
506
  id: cacheName,
360
507
  tabId: tabInfo.id!,
361
- path: route.path,
362
- query: cloneRouteQuery(route, tabInfo)
508
+ path,
509
+ query: cloneLocationQuery(routeQuery)
363
510
  }
364
511
  }
365
512
 
@@ -404,7 +551,12 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
404
551
  route: RouteLocationNormalizedLoaded,
405
552
  component?: VNode | null
406
553
  ): DefineComponent => {
407
- if (!component) return getEmptyPlaceholderComponent()
554
+ if (!component) {
555
+ // 路由没有匹配到组件时不会进入 updatePageState;仍需处理无 __tab 的未知地址回落到首页。
556
+ // 带 __tab 的未知页面不做重定向,交给应用自己的 404 路由(或空占位)处理。
557
+ if (shouldRedirectToHome(route)) redirectToHome()
558
+ return getEmptyPlaceholderComponent()
559
+ }
408
560
 
409
561
  const routeKey = route.fullPath + (route.query.__tab || '')
410
562
  if (routeKey === lastRouteKey && lastAddPageResult) {
@@ -517,6 +669,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
517
669
  getTabs,
518
670
  getTopPage,
519
671
  updatePage,
672
+ updatePages,
520
673
  iframeRefreshKeys,
521
674
  createPageId,
522
675
  addCache,
@@ -606,6 +759,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
606
759
  initialize,
607
760
  size,
608
761
  active,
762
+ updateTabTitle,
609
763
  getCacheName: createCacheName,
610
764
  destroy,
611
765
  reset,
@@ -615,6 +769,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
615
769
  getActiveTab,
616
770
  getTopPage,
617
771
  getPages,
772
+ getPageRefreshVersion,
618
773
  getPageSnapshot,
619
774
  moveTab,
620
775
  addTab,
@@ -623,6 +778,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
623
778
  replacePages,
624
779
  clearPages,
625
780
  updatePage,
781
+ updatePages,
626
782
  removeTab,
627
783
  removeAllTabs,
628
784
  removeLeftTabs,
@@ -651,3 +807,24 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
651
807
  }
652
808
  }
653
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
+ }
@@ -1,4 +1,3 @@
1
- import { defu } from 'defu'
2
1
  import { getCurrentInstance } from 'vue'
3
2
  import type { ComponentInternalInstance } from 'vue'
4
3
  import type { LocationQueryRaw, RouteLocationPathRaw } from 'vue-router'
@@ -9,7 +8,9 @@ import {
9
8
  parseUrl,
10
9
  cloneLocationQuery,
11
10
  clonePage,
12
- isSameQueryIgnoringReserved
11
+ isSameQueryIgnoringReserved,
12
+ normalizePathname,
13
+ omitStackTabsReservedQuery
13
14
  } from '../utils/urlParser'
14
15
  import { TabEventType, useTabEmitter } from './useTabEventBus'
15
16
 
@@ -84,10 +85,11 @@ export default function useTabRouter() {
84
85
  const forward = (to: RouteLocationPathRaw) => {
85
86
  const tab = getTab(tabId)
86
87
  const targetQuery = (to.query || {}) as LocationQueryRaw
88
+ const targetPath = normalizePathname(to.path)
87
89
 
88
90
  if (tab) {
89
91
  const top = getTopPage(tab.id)
90
- if (top && top.path === to.path) {
92
+ if (top && top.path === targetPath) {
91
93
  if (isSameQueryIgnoringReserved(targetQuery, top.query || {})) {
92
94
  if (!tab.active) {
93
95
  activateTab(tab.id)
@@ -97,9 +99,10 @@ export default function useTabRouter() {
97
99
  }
98
100
  }
99
101
 
100
- // __tab 是 StackTabs 解析路由归属标签的关键,优先沿用目标 query 已有的值,
101
- // 避免被 to.query 中非字符串的同名 key 或缺失值覆盖。
102
- const query = defu(to.query, { __tab: tabInfo })
102
+ // __tab 是 StackTabs 解析路由归属标签的关键:强制使用当前标签的编码,
103
+ // 业务 query 传入同名 key 时也不能把导航带到其它标签。
104
+ const query: LocationQueryRaw = { ...omitStackTabsReservedQuery(targetQuery) }
105
+ if (typeof tabInfo === 'string' && tabInfo) query.__tab = tabInfo
103
106
  const transactionVersion = ++navigationVersion
104
107
  emitter.emit(TabEventType.FORWARD)
105
108
 
@@ -112,8 +115,8 @@ export default function useTabRouter() {
112
115
  appendPage(tab.id, {
113
116
  id: optimisticPageId,
114
117
  tabId: tab.id,
115
- path: to.path,
116
- query: cloneLocationQuery(targetQuery)
118
+ path: targetPath,
119
+ query: cloneLocationQuery(query)
117
120
  })
118
121
  }
119
122
  return snapshot
@@ -156,13 +159,14 @@ export default function useTabRouter() {
156
159
  } else {
157
160
  const parsed = parseUrl(to)
158
161
  const targetQuery = parsed.query || {}
162
+ const targetPath = normalizePathname(parsed.path)
159
163
  let found = false
160
164
 
161
165
  for (let i = pages.length - 2; i >= 0; i--) {
162
166
  steps++
163
167
  const page = pages[i]
164
168
  if (
165
- page?.path === parsed.path &&
169
+ page?.path === targetPath &&
166
170
  isSameQueryIgnoringReserved(targetQuery, page?.query || {})
167
171
  ) {
168
172
  found = true
@@ -210,8 +214,11 @@ export default function useTabRouter() {
210
214
 
211
215
  emitter.emit(TabEventType.BACKWARD)
212
216
 
213
- // 栈顶 query 已带 __tab 时优先沿用,避免被默认 tabInfo 覆盖导致解析时识别错乱。
214
- const targetQueryWithTab = defu(target.query || {}, { __tab: tabInfo })
217
+ // 与 forward 一致:目标页固定归属当前标签,保留其余业务 query。
218
+ const targetQueryWithTab: LocationQueryRaw = {
219
+ ...omitStackTabsReservedQuery(target.query || {})
220
+ }
221
+ if (typeof tabInfo === 'string' && tabInfo) targetQueryWithTab.__tab = tabInfo
215
222
  const transactionVersion = ++navigationVersion
216
223
 
217
224
  runNavigationTransaction({
@@ -64,6 +64,11 @@ export interface ITabItem extends ITabBase {
64
64
  url?: string
65
65
  active: boolean
66
66
  pages: Stack<ITabPage>
67
+ /**
68
+ * 固定在标签栏最前方的内部标记(目前仅 homeTab 使用)。
69
+ * pinned 标签不可被拖动,其他标签也不能排到它前面;仅存在于运行时,不写入 __tab。
70
+ */
71
+ pinned?: boolean
67
72
  }
68
73
 
69
74
  /** 标签内的单个页面(对应 keep-alive 的一个缓存实例) */
@@ -1,4 +1,5 @@
1
1
  import type { ITabBase } from '../model/TabModel'
2
+ import { isRecord } from './typeGuards'
2
3
 
3
4
  interface EncodedTabInfoV1 {
4
5
  version: 1
@@ -14,20 +15,42 @@ interface EncodedTabInfoV1 {
14
15
  * 标签信息编解码器
15
16
  *
16
17
  * 职责:将标签元数据编码为 URL query 可用的短字符串,用于 __tab 参数。
17
- * 当前格式为 JSON + Base64,兼容旧版 id|title|iframe|closable|refreshable|mode 编码。
18
+ * 当前格式为 JSON + Base64URL;解码同时兼容标准 Base64(历史链接)与
19
+ * 旧版 id|title|iframe|closable|refreshable|mode 编码。
18
20
  */
19
21
 
20
22
  const MAX_DECODED_TAB_INFO_LENGTH = 2048
21
23
  const MAX_ENCODED_TAB_INFO_LENGTH = 4096
24
+ /** URL 中 __tab 的 title 上限,避免超长标题把 payload 顶到解码上限导致 id 丢失。 */
25
+ export const MAX_TAB_TITLE_LENGTH = 256
26
+ /** legacy 格式中 id 的合理上限,用于拒绝任意字符串伪装成标签 id。 */
27
+ const MAX_LEGACY_TAB_ID_LENGTH = 128
22
28
 
23
29
  /**
24
- * 把任意 UTF-8 字符串安全地编码为 base64(避免中文/特殊字符直接 btoa 抛错)。
25
- * 使用 encodeURIComponent + unescape 是经典跨浏览器方案,比手动管理 TextEncoder 更简洁。
30
+ * 把 UTF-8 字符串编码为 Base64URL(无填充、`+`→`-`、`/`→`_`)。
31
+ * 使用 TextEncoder 取代已废弃的 escape/unescape,同时让结果可直接放进 URL。
26
32
  */
27
- const encodeBase64 = (value: string): string => btoa(unescape(encodeURIComponent(value)))
33
+ const encodeBase64Url = (value: string): string => {
34
+ const bytes = new TextEncoder().encode(value)
35
+ let binary = ''
36
+ for (const byte of bytes) binary += String.fromCharCode(byte)
37
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
38
+ }
28
39
 
29
- /** encodeBase64 的逆操作,非合法 base64 时抛错,由调用方 try/catch。 */
30
- const decodeBase64 = (encoded: string): string => decodeURIComponent(escape(atob(encoded)))
40
+ /**
41
+ * 解码 Base64URL / 标准 Base64。
42
+ * 兼容旧链接:先还原 URL-safe 字符与填充,再按 UTF-8 解码。
43
+ */
44
+ const decodeBase64 = (encoded: string): string => {
45
+ const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
46
+ const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
47
+ const binary = atob(padded)
48
+ const bytes = new Uint8Array(binary.length)
49
+ for (let index = 0; index < binary.length; index++) {
50
+ bytes[index] = binary.charCodeAt(index)
51
+ }
52
+ return new TextDecoder().decode(bytes)
53
+ }
31
54
 
32
55
  const DEFAULT_TAB_INFO: ITabBase = {
33
56
  id: '',
@@ -38,7 +61,14 @@ const DEFAULT_TAB_INFO: ITabBase = {
38
61
  iframeRefreshMode: 'postMessage'
39
62
  }
40
63
 
41
- import { isRecord } from './typeGuards'
64
+ /** 检测控制字符,避免 legacy id 携带不可见字符。 */
65
+ const hasControlCharacter = (value: string): boolean => {
66
+ for (const char of value) {
67
+ const code = char.charCodeAt(0)
68
+ if (code <= 31 || code === 127) return true
69
+ }
70
+ return false
71
+ }
42
72
 
43
73
  const toTabInfo = (payload: unknown): ITabBase => {
44
74
  if (!isRecord(payload)) return { ...DEFAULT_TAB_INFO }
@@ -54,16 +84,36 @@ const toTabInfo = (payload: unknown): ITabBase => {
54
84
  }
55
85
  }
56
86
 
87
+ /**
88
+ * 旧版分隔符格式:id|title|iframe|closable|refreshable[|mode]。
89
+ *
90
+ * 严格校验字段数量与 Y/N 标记:非法字符串(例如 `?__tab=invalid`)不能再被解析成
91
+ * 任意 id 的标签。格式不合法时返回默认值,由路由层走兜底标签逻辑。
92
+ */
57
93
  const decodeLegacyTabInfo = (tabString: string): ITabBase => {
58
94
  const tabValues: string[] = tabString.split('|')
59
- const refreshMode = tabValues[5] === 'R' ? ('reload' as const) : ('postMessage' as const)
95
+ if (tabValues.length < 5 || tabValues.length > 6) return { ...DEFAULT_TAB_INFO }
96
+
97
+ const [id = '', title = '', iframe, closable, refreshable, mode] = tabValues
98
+ const isFlag = (value: string | undefined): value is 'Y' | 'N' => value === 'Y' || value === 'N'
99
+ const hasValidId =
100
+ id.length > 0 &&
101
+ id.length <= MAX_LEGACY_TAB_ID_LENGTH &&
102
+ id.trim() === id &&
103
+ !hasControlCharacter(id)
104
+
105
+ if (!hasValidId || !isFlag(iframe) || !isFlag(closable) || !isFlag(refreshable)) {
106
+ return { ...DEFAULT_TAB_INFO }
107
+ }
108
+ if (mode !== undefined && mode !== '' && mode !== 'R') return { ...DEFAULT_TAB_INFO }
109
+
60
110
  return {
61
- id: tabValues[0] ?? '',
62
- title: tabValues[1] ?? '',
63
- iframe: tabValues[2] === 'Y',
64
- closable: tabValues[3] === 'Y',
65
- refreshable: tabValues[4] === 'Y',
66
- iframeRefreshMode: refreshMode
111
+ id,
112
+ title,
113
+ iframe: iframe === 'Y',
114
+ closable: closable === 'Y',
115
+ refreshable: refreshable === 'Y',
116
+ iframeRefreshMode: mode === 'R' ? 'reload' : 'postMessage'
67
117
  }
68
118
  }
69
119
 
@@ -71,13 +121,13 @@ export const encodeTabInfo = (tabData: ITabBase): string => {
71
121
  const payload: EncodedTabInfoV1 = {
72
122
  version: 1,
73
123
  id: tabData.id,
74
- title: tabData.title,
124
+ title: tabData.title.slice(0, MAX_TAB_TITLE_LENGTH),
75
125
  iframe: tabData.iframe,
76
126
  closable: tabData.closable,
77
127
  refreshable: tabData.refreshable,
78
128
  iframeRefreshMode: tabData.iframeRefreshMode === 'reload' ? 'reload' : 'postMessage'
79
129
  }
80
- return encodeBase64(JSON.stringify(payload))
130
+ return encodeBase64Url(JSON.stringify(payload))
81
131
  }
82
132
 
83
133
  export const decodeTabInfo = (encoded: string): ITabBase => {
@@ -102,4 +152,38 @@ export const decodeTabInfo = (encoded: string): ITabBase => {
102
152
  }
103
153
  }
104
154
 
105
- export const createPageId = (): string => crypto.randomUUID()
155
+ /**
156
+ * 生成运行时 id。
157
+ *
158
+ * `crypto.randomUUID` 仅在安全上下文(https / localhost)可用;内网 http 部署时
159
+ * 回退到 `crypto.getRandomValues`(非安全上下文同样可用),最后才退回 Math.random,
160
+ * 避免在受限环境下直接抛 TypeError 导致整个组件不可用。
161
+ */
162
+ export const createId = (): string => {
163
+ const cryptoObject = globalThis.crypto
164
+
165
+ if (typeof cryptoObject?.randomUUID === 'function') {
166
+ return cryptoObject.randomUUID()
167
+ }
168
+
169
+ const bytes = new Uint8Array(16)
170
+ if (typeof cryptoObject?.getRandomValues === 'function') {
171
+ cryptoObject.getRandomValues(bytes)
172
+ } else {
173
+ for (let index = 0; index < bytes.length; index++) {
174
+ bytes[index] = Math.floor(Math.random() * 256)
175
+ }
176
+ }
177
+
178
+ // RFC 4122 version 4 + variant bits
179
+ bytes[6] = (bytes[6]! & 0x0f) | 0x40
180
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80
181
+
182
+ const hex: string[] = []
183
+ for (const byte of bytes) hex.push(byte.toString(16).padStart(2, '0'))
184
+ return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex
185
+ .slice(6, 8)
186
+ .join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`
187
+ }
188
+
189
+ export const createPageId = (): string => createId()