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
@@ -32,19 +32,23 @@ export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPane
32
32
  let pendingWrite: { tab: ITabItem; version: number } | null = null
33
33
  let writeVersion = 0
34
34
  let writeScheduled = false
35
+ /** 最近一次成功写入的内容;与待写入内容相同时跳过 setItem。 */
36
+ let lastPersistedJson: string | null = null
35
37
 
36
38
  /**
37
39
  * 写入 sessionStorage,捕获隐私模式(QuotaExceededError)等异常,
38
40
  * 避免在受限浏览器环境下抛出未处理错误。SSR 环境无 window 时直接跳过。
39
41
  */
40
- const safeSetItem = (key: string, value: string): void => {
41
- if (typeof window === 'undefined') return
42
+ const safeSetItem = (key: string, value: string): boolean => {
43
+ if (typeof window === 'undefined') return false
42
44
  try {
43
45
  window.sessionStorage.setItem(key, value)
46
+ return true
44
47
  } catch (error) {
45
48
  if (!import.meta.env.PROD) {
46
49
  console.warn('[vue-stack-tabs] Failed to write sessionStorage:', error)
47
50
  }
51
+ return false
48
52
  }
49
53
  }
50
54
 
@@ -64,7 +68,9 @@ export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPane
64
68
  const write = pendingWrite
65
69
  pendingWrite = null
66
70
  if (!write || write.version !== writeVersion) return
67
- safeSetItem(getSessionKey(), JSON.stringify(write.tab))
71
+ const serialized = JSON.stringify(write.tab)
72
+ if (serialized === lastPersistedJson) return
73
+ if (safeSetItem(getSessionKey(), serialized)) lastPersistedJson = serialized
68
74
  }
69
75
 
70
76
  const invalidatePendingWrite = () => {
@@ -83,6 +89,7 @@ export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPane
83
89
  const clearSession = (): void => {
84
90
  invalidatePendingWrite()
85
91
  safeRemoveItem(getSessionKey())
92
+ lastPersistedJson = null
86
93
  }
87
94
 
88
95
  const restoreActiveTabSession = (storedJson: string | null): void => {
@@ -91,7 +98,7 @@ export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPane
91
98
  return
92
99
  }
93
100
  invalidatePendingWrite()
94
- safeSetItem(getSessionKey(), storedJson)
101
+ if (safeSetItem(getSessionKey(), storedJson)) lastPersistedJson = storedJson
95
102
  }
96
103
 
97
104
  const restoreTabFromSession = (storedJson: string | null): ITabItem | null => {
@@ -15,7 +15,6 @@ export interface ScrollPosition {
15
15
 
16
16
  export interface TabPanelRuntimeState {
17
17
  tabs: Ref<ITabItem[]>
18
- defaultTabs: Ref<ITabItem[]>
19
18
  caches: ShallowRef<string[]>
20
19
  components: Map<string, DefineComponent>
21
20
  cacheIdsToEvict: Set<string>
@@ -34,7 +33,6 @@ export const SESSION_TAB_NAME = 'stacktab-active-tab'
34
33
 
35
34
  export const createTabPanelRuntimeState = (): TabPanelRuntimeState => ({
36
35
  tabs: ref<ITabItem[]>([]) as Ref<ITabItem[]>,
37
- defaultTabs: ref<ITabItem[]>([]) as Ref<ITabItem[]>,
38
36
  caches: shallowRef<string[]>([]),
39
37
  components: new Map<string, DefineComponent>(),
40
38
  cacheIdsToEvict: new Set<string>(),
@@ -12,7 +12,7 @@
12
12
  * 注意:本模块不依赖 vue-router,也不触发业务事件;路由事务与事件由 useTabPanel /
13
13
  * useTabRouter 等上层编排。
14
14
  */
15
- import { reactive, ref } from 'vue'
15
+ import { markRaw, reactive, ref } from 'vue'
16
16
  import type { DefineComponent } from 'vue'
17
17
  import type { ITabItem, ITabPage } from '../../model/TabModel'
18
18
  import { Stack } from '../../model/TabModel'
@@ -27,6 +27,12 @@ export interface RemoveTabResult {
27
27
  nextActiveId: string
28
28
  }
29
29
 
30
+ export interface PageUpdate {
31
+ tabId: string
32
+ pageId: string
33
+ patch: PagePatch
34
+ }
35
+
30
36
  /** updatePage 允许修改的字段;页面身份与刷新版本必须通过 store 写入 */
31
37
  export interface PagePatch {
32
38
  id?: string
@@ -46,6 +52,8 @@ export interface TabStoreApi extends TabPanelEvictionApi {
46
52
  canAddTab: () => boolean
47
53
  getPages: (tabId: string) => readonly ITabPage[]
48
54
  getTopPage: (tabId: string) => ITabPage | null
55
+ /** 读取任意页面的刷新版本;渲染层据此只重建对应页面 */
56
+ getPageRefreshVersion: (pageId: string) => number
49
57
  /** 返回深拷贝的页面栈快照,可直接用于 rollback */
50
58
  getPageSnapshot: (tabId: string) => ITabPage[]
51
59
 
@@ -54,6 +62,8 @@ export interface TabStoreApi extends TabPanelEvictionApi {
54
62
  addTabs: (tabs: readonly ITabItem[]) => void
55
63
  /** 唯一 active 写入口 */
56
64
  activateTab: (id: string) => ITabItem | null
65
+ /** 动态更新标签标题;返回 false 表示标签不存在 */
66
+ updateTabTitle: (id: string, title: string) => boolean
57
67
  moveTab: (id: string, targetIndex: number) => boolean
58
68
  removeTab: (id: string) => RemoveTabResult
59
69
  removeTabs: (shouldRemove: (tab: ITabItem, index: number) => boolean) => ITabItem[]
@@ -65,8 +75,10 @@ export interface TabStoreApi extends TabPanelEvictionApi {
65
75
  /** 用给定页面替换整个栈(内部深拷贝页面) */
66
76
  replacePages: (tabId: string, pages: readonly ITabPage[]) => void
67
77
  clearPages: (tabId: string) => void
68
- /** 修改页面字段(id / refreshVersion / _backParams),并触发 tabs 更新 */
78
+ /** 修改页面字段(id / refreshVersion / _backParams);仅 id 变化时重建 tabs 数组 */
69
79
  updatePage: (tabId: string, pageId: string, patch: PagePatch) => boolean
80
+ /** 批量修改页面字段,最后最多重建一次 tabs 数组(供 refreshAllTabs 使用) */
81
+ updatePages: (updates: readonly PageUpdate[]) => void
70
82
  /** 取出并清除栈顶页面的 _backParams;pageId 不是当前栈顶时返回 null */
71
83
  consumeBackParams: (tabId: string, pageId: string) => Record<string, unknown> | null
72
84
 
@@ -103,11 +115,18 @@ interface TabRecord {
103
115
  iframe: boolean
104
116
  iframeRefreshMode?: 'postMessage' | 'reload'
105
117
  url?: string
118
+ /** 固定在标签栏最前方,不可拖动,其他标签也不能排到它前面 */
119
+ pinned?: boolean
106
120
  }
107
121
 
108
122
  interface PageStackAccess {
109
123
  getPages: (tabId: string) => readonly ITabPage[]
124
+ getPageIds: (tabId: string) => readonly string[]
125
+ getPageAt: (tabId: string, index: number) => ITabPage | undefined
126
+ getPageCount: (tabId: string) => number
110
127
  getTopPage: (tabId: string) => ITabPage | null
128
+ /** 读取任意页面的刷新版本;渲染层据此只重建对应页面 */
129
+ getPageRefreshVersion: (pageId: string) => number
111
130
  appendPage: (tabId: string, page: ITabPage) => void
112
131
  popPages: (tabId: string, steps: number) => ITabPage[]
113
132
  clearPages: (tabId: string) => void
@@ -140,15 +159,16 @@ class PageStackView extends Stack<ITabPage> {
140
159
  }
141
160
 
142
161
  override at(index: number): ITabPage | undefined {
143
- return this.access.getPages(this.tabId)[index]
162
+ // 只取单个元素时不必构造整条页面数组。
163
+ return this.access.getPageAt(this.tabId, index)
144
164
  }
145
165
 
146
166
  override isEmpty(): boolean {
147
- return this.access.getPages(this.tabId).length === 0
167
+ return this.access.getPageCount(this.tabId) === 0
148
168
  }
149
169
 
150
170
  override size(): number {
151
- return this.access.getPages(this.tabId).length
171
+ return this.access.getPageCount(this.tabId)
152
172
  }
153
173
 
154
174
  override clear(): void {
@@ -211,6 +231,7 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
211
231
  // ---- 查询 ----
212
232
  const getTabs = (): readonly ITabItem[] => tabs.value
213
233
 
234
+ // 返回 tabs.value 中的对象(深度代理后的视图),保持调用方的对象身份比较语义。
214
235
  const getTab = (id: string): ITabItem | null => tabs.value.find((tab) => tab.id === id) ?? null
215
236
 
216
237
  const getActiveTab = (): ITabItem | null => tabs.value.find((tab) => tab.active) ?? null
@@ -234,9 +255,22 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
234
255
  return pagesById.get(ids[ids.length - 1] as string) ?? null
235
256
  }
236
257
 
258
+ const getPageCount = (tabId: string): number => pageIdsByTabId.get(tabId)?.length ?? 0
259
+
260
+ /** 按下标读取页面;越界(含负数)返回 undefined,与数组下标语义一致 */
261
+ const getPageAt = (tabId: string, index: number): ITabPage | undefined => {
262
+ const ids = pageIdsByTabId.get(tabId)
263
+ if (!ids || index < 0 || index >= ids.length) return undefined
264
+ return pagesById.get(ids[index] as string)
265
+ }
266
+
267
+ const getPageRefreshVersion = (pageId: string): number =>
268
+ pagesById.get(pageId)?.refreshVersion ?? 0
269
+
237
270
  const findTabByTopPagePath = (path: string): ITabItem | null => {
271
+ // 直接遍历 tabViews,避免原先「getTopPage + getTab(find)」的 O(n²) 组合。
238
272
  for (const id of tabOrder.value) {
239
- if (getTopPage(id)?.path === path) return getTab(id)
273
+ if (getTopPage(id)?.path === path) return tabViews.get(id) ?? null
240
274
  }
241
275
  return null
242
276
  }
@@ -258,7 +292,8 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
258
292
  refreshable: tab.refreshable,
259
293
  iframe: tab.iframe,
260
294
  iframeRefreshMode: tab.iframeRefreshMode,
261
- url: tab.url
295
+ url: tab.url,
296
+ pinned: tab.pinned
262
297
  })
263
298
 
264
299
  const registerTab = (tab: ITabItem): void => {
@@ -288,27 +323,46 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
288
323
  }
289
324
 
290
325
  const activateTab = (id: string): ITabItem | null => {
291
- activeTabId.value = tabsById.has(id) ? id : ''
292
- syncTabsView()
326
+ const nextId = tabsById.has(id) ? id : ''
327
+ // 重复激活同一标签(页面内导航经常走到这里)无需重建 tabs 数组。
328
+ if (nextId !== activeTabId.value) {
329
+ activeTabId.value = nextId
330
+ syncTabsView()
331
+ }
293
332
  return getTab(id)
294
333
  }
295
334
 
335
+ const updateTabTitle = (id: string, title: string): boolean => {
336
+ const record = tabsById.get(id)
337
+ if (!record) return false
338
+ if (record.title === title) return true
339
+ record.title = title
340
+ syncTabsView()
341
+ return true
342
+ }
343
+
296
344
  const moveTab = (id: string, targetIndex: number): boolean => {
297
345
  const order = tabOrder.value
298
346
  const sourceIndex = order.indexOf(id)
299
- if (
300
- sourceIndex < 0 ||
301
- targetIndex < 0 ||
302
- targetIndex >= order.length ||
303
- sourceIndex === targetIndex
304
- ) {
347
+ if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) {
305
348
  return false
306
349
  }
307
350
 
351
+ // pinned 标签(home)固定在最前方:自身不可移动,其他标签也不得插到它前面。
352
+ const sourceRecord = tabsById.get(id)
353
+ if (!sourceRecord || sourceRecord.pinned) return false
354
+
355
+ let firstUnpinnedIndex = 0
356
+ for (let index = 0; index < order.length; index++) {
357
+ if (tabsById.get(order[index] as string)?.pinned) firstUnpinnedIndex = index + 1
358
+ }
359
+ const safeTargetIndex = Math.max(targetIndex, firstUnpinnedIndex)
360
+ if (safeTargetIndex >= order.length || safeTargetIndex === sourceIndex) return false
361
+
308
362
  const reordered = [...order]
309
363
  const [tabId] = reordered.splice(sourceIndex, 1)
310
364
  if (!tabId) return false
311
- reordered.splice(targetIndex, 0, tabId)
365
+ reordered.splice(safeTargetIndex, 0, tabId)
312
366
  tabOrder.value = reordered
313
367
  syncTabsView()
314
368
  return true
@@ -379,12 +433,13 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
379
433
  }
380
434
 
381
435
  // ---- 页面栈写入 ----
436
+ // 页面栈只修改 pageIdsByTabId / pagesById(两者都是响应式 Map),
437
+ // 不重建 tabs 数组:标签栏、iframe 与刷新版本计算都无需因入栈/出栈重新求值。
382
438
  const appendPage = (tabId: string, page: ITabPage): void => {
383
439
  if (!tabsById.has(tabId)) return
384
440
  const ids = pageIdsByTabId.get(tabId) ?? []
385
441
  pageIdsByTabId.set(tabId, [...ids, page.id])
386
442
  pagesById.set(page.id, page)
387
- syncTabsView()
388
443
  }
389
444
 
390
445
  const popPages = (tabId: string, steps: number): ITabPage[] => {
@@ -400,7 +455,6 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
400
455
  pagesById.delete(pageId)
401
456
  }
402
457
  pageIdsByTabId.set(tabId, ids)
403
- syncTabsView()
404
458
  return popped
405
459
  }
406
460
 
@@ -415,22 +469,25 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
415
469
  nextPageIds.push(cloned.id)
416
470
  }
417
471
  pageIdsByTabId.set(tabId, nextPageIds)
418
- syncTabsView()
419
472
  }
420
473
 
421
474
  const clearPages = (tabId: string): void => {
422
475
  if (!tabsById.has(tabId)) return
423
476
  for (const pageId of getPageIds(tabId)) pagesById.delete(pageId)
424
477
  pageIdsByTabId.set(tabId, [])
425
- syncTabsView()
426
478
  }
427
479
 
428
- const updatePage = (tabId: string, pageId: string, patch: PagePatch): boolean => {
480
+ const applyPagePatch = (
481
+ tabId: string,
482
+ pageId: string,
483
+ patch: PagePatch
484
+ ): 'missing' | 'conflict' | 'same-id' | 'new-id' => {
429
485
  const ids = pageIdsByTabId.get(tabId)
430
- if (!ids || !ids.includes(pageId)) return false
486
+ if (!ids || !ids.includes(pageId)) return 'missing'
431
487
 
432
488
  const page = pagesById.get(pageId)
433
- if (!page) return false
489
+ if (!page) return 'missing'
490
+ if (patch.id && patch.id !== pageId && pagesById.has(patch.id)) return 'conflict'
434
491
 
435
492
  Object.assign(page, patch)
436
493
  if (patch.id && patch.id !== pageId) {
@@ -440,17 +497,38 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
440
497
  tabId,
441
498
  ids.map((id) => (id === pageId ? (patch.id as string) : id))
442
499
  )
500
+ return 'new-id'
443
501
  }
444
- syncTabsView()
502
+ return 'same-id'
503
+ }
504
+
505
+ /**
506
+ * 刷新版本、_backParams 都是 page 上的响应式字段,读取方会自行更新;
507
+ * 只有页面 id 变化才需要重建 tabs 视图,避免刷新一个标签就 diff 整个标签栏。
508
+ */
509
+ const updatePage = (tabId: string, pageId: string, patch: PagePatch): boolean => {
510
+ const result = applyPagePatch(tabId, pageId, patch)
511
+ if (result === 'missing' || result === 'conflict') return false
512
+ if (result === 'new-id') syncTabsView()
445
513
  return true
446
514
  }
447
515
 
516
+ const updatePages = (updates: readonly PageUpdate[]): void => {
517
+ let needsSync = false
518
+ for (const update of updates) {
519
+ if (applyPagePatch(update.tabId, update.pageId, update.patch) === 'new-id') {
520
+ needsSync = true
521
+ }
522
+ }
523
+ if (needsSync) syncTabsView()
524
+ }
525
+
448
526
  const consumeBackParams = (tabId: string, pageId: string): Record<string, unknown> | null => {
449
527
  const top = getTopPage(tabId)
450
528
  if (!top || top.id !== pageId || !top._backParams) return null
451
529
  const backParams = { ...top._backParams }
530
+ // _backParams 是页面对象上的响应式字段,删除后读取方会自行更新。
452
531
  delete top._backParams
453
- syncTabsView()
454
532
  return backParams
455
533
  }
456
534
 
@@ -485,7 +563,11 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
485
563
  // ---- 兼容视图 ----
486
564
  const pageAccess: PageStackAccess = {
487
565
  getPages,
566
+ getPageIds,
567
+ getPageAt,
568
+ getPageCount,
488
569
  getTopPage,
570
+ getPageRefreshVersion,
489
571
  appendPage,
490
572
  popPages,
491
573
  clearPages
@@ -506,7 +588,9 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
506
588
  iframeRefreshMode: record.iframeRefreshMode,
507
589
  url: record.url,
508
590
  active: activeTabId.value === id,
509
- pages: new PageStackView(id, pageAccess)
591
+ // pages 的读写都转发给 store,本身不需要被 ref 深度代理。
592
+ pages: markRaw(new PageStackView(id, pageAccess)),
593
+ pinned: record.pinned
510
594
  }
511
595
  tabViews.set(id, view)
512
596
  } else {
@@ -515,6 +599,7 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
515
599
  view.refreshable = record.refreshable
516
600
  view.iframe = record.iframe
517
601
  view.active = activeTabId.value === id
602
+ view.pinned = record.pinned
518
603
  if (record.iframeRefreshMode === undefined) {
519
604
  delete view.iframeRefreshMode
520
605
  } else {
@@ -548,10 +633,12 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
548
633
  canAddTab,
549
634
  getPages,
550
635
  getTopPage,
636
+ getPageRefreshVersion,
551
637
  getPageSnapshot,
552
638
  addTab,
553
639
  addTabs,
554
640
  activateTab,
641
+ updateTabTitle,
555
642
  moveTab,
556
643
  removeTab,
557
644
  removeTabs,
@@ -560,6 +647,7 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
560
647
  replacePages,
561
648
  clearPages,
562
649
  updatePage,
650
+ updatePages,
563
651
  consumeBackParams,
564
652
  isTabMarkedForEviction: (id) => tabIdsToEvict.has(id),
565
653
  isCacheMarkedForEviction: (id) => cacheIdsToEvict.has(id),
@@ -41,7 +41,7 @@ export default () => {
41
41
  /** 点击菜单外部时关闭菜单。 */
42
42
  const handleClickOutside = (ev: MouseEvent) => {
43
43
  const target = ev.target as Element
44
- if (target.closest?.('.stack-tab__contextmenu')) return
44
+ if (target.closest?.('.stack-tab__context-menu')) return
45
45
  // 同一手势的 click 仍在守卫窗口内,跳过关闭判断。
46
46
  if (Date.now() - lastContextMenuAt < BUBBLE_GUARD_MS) return
47
47
  hideContextMenu()
@@ -19,7 +19,7 @@ interface IframeLoadState {
19
19
  export interface UseIframeManagerOptions {
20
20
  tabs: Ref<ITabItem[]>
21
21
  iframeRefreshKeys: Ref<Record<string, number>>
22
- iframeLoadTimeout: number
22
+ iframeLoadTimeout: Ref<number>
23
23
  t: (key: string) => string
24
24
  }
25
25
 
@@ -106,7 +106,7 @@ export function useIframeManager(options: UseIframeManagerOptions) {
106
106
  message: t('VueStackTab.iframeLoadTimeout')
107
107
  }
108
108
  }
109
- }, iframeLoadTimeout)
109
+ }, iframeLoadTimeout.value)
110
110
  )
111
111
  }
112
112
 
@@ -137,10 +137,17 @@ export function useIframeManager(options: UseIframeManagerOptions) {
137
137
  }
138
138
  }
139
139
 
140
- /** 同步 iframe 激活和移除状态,避免用多个 watcher 重复遍历 iframe 列表。 */
140
+ /**
141
+ * 同步 iframe 激活和移除状态。
142
+ * 只监听 id:active 签名,普通标签的标题/页面栈变化不会触发遍历。
143
+ */
144
+ const iframeActivationSignature = computed(() =>
145
+ iframeTabs.value.map((frame) => `${frame.id}:${frame.active ? 1 : 0}`).join('|')
146
+ )
141
147
  watch(
142
- () => iframeTabs.value.map((frame) => ({ id: frame.id, active: frame.active })),
143
- (frames) => {
148
+ iframeActivationSignature,
149
+ () => {
150
+ const frames = iframeTabs.value
144
151
  for (const f of frames) {
145
152
  if (!f.active) continue
146
153
  iframeEverActivated[f.id] = true
@@ -169,20 +176,16 @@ export function useIframeManager(options: UseIframeManagerOptions) {
169
176
  )
170
177
 
171
178
  // refreshKey 变化时重新触发对应 iframe 的加载状态
172
- watch(
173
- iframeRefreshKeys,
174
- (keys, previousKeys = {}) => {
175
- for (const frame of iframeTabs.value) {
176
- if (!frame.active) continue
177
- const nextKey = keys[frame.id] ?? 0
178
- const previousKey = previousKeys[frame.id] ?? 0
179
- if (nextKey !== previousKey) {
180
- setIframeLoading(frame.id, { force: true })
181
- }
179
+ watch(iframeRefreshKeys, (keys, previousKeys = {}) => {
180
+ for (const frame of iframeTabs.value) {
181
+ if (!frame.active) continue
182
+ const nextKey = keys[frame.id] ?? 0
183
+ const previousKey = previousKeys[frame.id] ?? 0
184
+ if (nextKey !== previousKey) {
185
+ setIframeLoading(frame.id, { force: true })
182
186
  }
183
- },
184
- { deep: true }
185
- )
187
+ }
188
+ })
186
189
 
187
190
  const handleIframeLoad = (frame: { id: string; url?: string }, event: Event) => {
188
191
  const iframe = event.currentTarget as HTMLIFrameElement | null
@@ -13,6 +13,8 @@
13
13
  * - 阈值(默认 5px)内视为已贴边(≤ 阈值)→ 隐藏阴影;超过阈值视为可继续滚动 → 显示阴影
14
14
  * - SSR 安全:在 document 不存在时直接返回
15
15
  * - 自动清理:onScopeDispose 移除监听并取消 rAF/disconnect observer
16
+ * - 阴影类可以挂到另一个不参与 transform 的元素上:滚动容器的 ::after 一旦
17
+ * 跟随 transform(例如页面刷新动画),绝对定位的包含块就会变化,阴影会错位/消失
16
18
  */
17
19
  import { onScopeDispose, type Ref } from 'vue'
18
20
 
@@ -22,8 +24,15 @@ const SHADOW_VERTICAL_CLASS = 'stack-tab__shadow-inset-vertical'
22
24
  const DEFAULT_EDGE_THRESHOLD = 5
23
25
 
24
26
  export interface UseScrollShadowOptions {
25
- /** 容器元素的 ref,可以是 Ref<HTMLElement | null> 或 getter 函数 */
27
+ /** 滚动容器:用于监听 scroll / resize 并读取滚动尺寸 */
26
28
  target: Ref<HTMLElement | null> | (() => HTMLElement | null)
29
+ /**
30
+ * 阴影类挂载目标,默认与 target 相同。
31
+ * 当 target 参与 transform 动画(如刷新转场)时,应传入不参与动画的祖先
32
+ * (例如 .stack-tab__keep-alive-panel),否则 ::after 的绝对定位包含块会变化,
33
+ * 阴影会随动画错位或消失。
34
+ */
35
+ classTarget?: Ref<HTMLElement | null> | (() => HTMLElement | null)
27
36
  /**
28
37
  * 是否监听内容缩放/尺寸变化(默认 true)。
29
38
  * 启用后会用 ResizeObserver 监视目标尺寸,容器或子树尺寸变化都会重新计算阴影边界。
@@ -37,8 +46,10 @@ export interface UseScrollShadowOptions {
37
46
  }
38
47
 
39
48
  export interface UseScrollShadowReturn {
40
- /** 手动同步一次(用于内容变化/resize 后) */
49
+ /** 重新解析滚动/阴影目标并同步一次(用于 keep-alive 激活、DOM 重新挂载后) */
41
50
  sync: () => void
51
+ /** 移除阴影类并取消待执行的 rAF(用于 keep-alive 失活) */
52
+ clear: () => void
42
53
  }
43
54
 
44
55
  const resolveTarget = (
@@ -47,18 +58,36 @@ const resolveTarget = (
47
58
 
48
59
  export const useScrollShadow = (options: UseScrollShadowOptions): UseScrollShadowReturn => {
49
60
  if (typeof document === 'undefined') {
50
- return { sync: () => undefined }
61
+ return { sync: () => undefined, clear: () => undefined }
51
62
  }
52
63
 
53
64
  const watchResize = options.watchResize !== false
54
65
  const edgeThreshold = Math.max(0, options.edgeThreshold ?? DEFAULT_EDGE_THRESHOLD)
55
66
  let rafId: number | null = null
56
67
  let attached: HTMLElement | null = null
68
+ let classElement: HTMLElement | null = null
57
69
  let resizeObserver: ResizeObserver | null = null
58
70
 
71
+ const clearClasses = (element: HTMLElement | null): void => {
72
+ if (!element) return
73
+ element.classList.remove(SHADOW_TOP_CLASS, SHADOW_BOTTOM_CLASS, SHADOW_VERTICAL_CLASS)
74
+ }
75
+
76
+ /** 阴影类目标:配置了 classTarget 时必须真实解析到,否则宁可不显示(激活后会 sync 重试) */
77
+ const resolveClassTarget = (): HTMLElement | null =>
78
+ options.classTarget ? resolveTarget(options.classTarget) : attached
79
+
59
80
  const recompute = (): void => {
60
81
  if (!attached) return
61
82
  const target = attached
83
+ const nextClassTarget = resolveClassTarget()
84
+ if (classElement && classElement !== nextClassTarget) {
85
+ clearClasses(classElement)
86
+ classElement = null
87
+ }
88
+ classElement = nextClassTarget
89
+ if (!classElement) return
90
+
62
91
  const { scrollTop, scrollHeight, clientHeight } = target
63
92
  const distanceToTop = scrollTop
64
93
  const distanceToBottom = scrollHeight - clientHeight - scrollTop
@@ -69,9 +98,9 @@ export const useScrollShadow = (options: UseScrollShadowOptions): UseScrollShado
69
98
  // 注意:-top / -bottom 是两个独立 CSS 规则、作用在同一个 ::after 上,
70
99
  // 同时加会互相覆盖(后定义的 -bottom 胜出)。因此这里互斥地只加一个类:
71
100
  // 两侧都有内容用 -vertical,只有一侧用 -top / -bottom,贴边时都不加。
72
- target.classList.toggle(SHADOW_VERTICAL_CLASS, showTopShadow && showBottomShadow)
73
- target.classList.toggle(SHADOW_TOP_CLASS, showTopShadow && !showBottomShadow)
74
- target.classList.toggle(SHADOW_BOTTOM_CLASS, !showTopShadow && showBottomShadow)
101
+ classElement.classList.toggle(SHADOW_VERTICAL_CLASS, showTopShadow && showBottomShadow)
102
+ classElement.classList.toggle(SHADOW_TOP_CLASS, showTopShadow && !showBottomShadow)
103
+ classElement.classList.toggle(SHADOW_BOTTOM_CLASS, !showTopShadow && showBottomShadow)
75
104
  }
76
105
 
77
106
  const schedule = (): void => {
@@ -84,11 +113,17 @@ export const useScrollShadow = (options: UseScrollShadowOptions): UseScrollShado
84
113
 
85
114
  const attach = (): void => {
86
115
  const el = resolveTarget(options.target)
87
- if (!el || el === attached) return
116
+ if (!el) return
117
+ if (el === attached) {
118
+ schedule()
119
+ return
120
+ }
88
121
  if (attached) {
89
122
  attached.removeEventListener('scroll', schedule)
90
123
  resizeObserver?.disconnect()
91
124
  resizeObserver = null
125
+ clearClasses(classElement)
126
+ classElement = null
92
127
  }
93
128
  attached = el
94
129
  el.addEventListener('scroll', schedule, { passive: true })
@@ -99,18 +134,40 @@ export const useScrollShadow = (options: UseScrollShadowOptions): UseScrollShado
99
134
  schedule()
100
135
  }
101
136
 
102
- const detach = (): void => {
103
- if (attached) {
104
- attached.removeEventListener('scroll', schedule)
105
- attached.classList.remove(SHADOW_TOP_CLASS, SHADOW_BOTTOM_CLASS, SHADOW_VERTICAL_CLASS)
106
- attached = null
137
+ const cancelScheduled = (): void => {
138
+ if (rafId === null) return
139
+ cancelAnimationFrame(rafId)
140
+ rafId = null
141
+ }
142
+
143
+ const clear = (): void => {
144
+ clearClasses(classElement)
145
+ classElement = null
146
+ cancelScheduled()
147
+ }
148
+
149
+ /** 重新解析目标:keep-alive 激活或 DOM 重新挂载后由调用方触发 */
150
+ const sync = (): void => {
151
+ const el = resolveTarget(options.target)
152
+ if (!el) {
153
+ clear()
154
+ return
155
+ }
156
+ if (el !== attached) {
157
+ attach()
158
+ return
107
159
  }
160
+ schedule()
161
+ }
162
+
163
+ const detach = (): void => {
164
+ if (attached) attached.removeEventListener('scroll', schedule)
165
+ attached = null
108
166
  resizeObserver?.disconnect()
109
167
  resizeObserver = null
110
- if (rafId !== null) {
111
- cancelAnimationFrame(rafId)
112
- rafId = null
113
- }
168
+ clearClasses(classElement)
169
+ classElement = null
170
+ cancelScheduled()
114
171
  }
115
172
 
116
173
  // 初始挂载(target 可能是异步插入的 DOM,使用 microtask 等一帧)
@@ -118,7 +175,5 @@ export const useScrollShadow = (options: UseScrollShadowOptions): UseScrollShado
118
175
 
119
176
  onScopeDispose(detach)
120
177
 
121
- return {
122
- sync: () => schedule()
123
- }
178
+ return { sync, clear }
124
179
  }