vue-stack-tabs 1.3.1 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.en.md +119 -52
  2. package/README.md +103 -42
  3. package/README.ru.md +125 -54
  4. package/dist/{src/lib/StackTabs.vue.d.ts → StackTabs.vue.d.ts} +5 -5
  5. package/dist/{src/lib/components → components}/ContextMenu/index.vue.d.ts +1 -1
  6. package/dist/components/StackKeepAlive/emptyPlaceholder.d.ts +3 -0
  7. package/dist/{src/lib/components → components}/TabHeader/TabHeaderItem.vue.d.ts +9 -0
  8. package/dist/{src/lib/components → components}/TabHeader/index.vue.d.ts +3 -3
  9. package/dist/{src/lib/hooks → hooks}/stackTabsContext.d.ts +5 -2
  10. package/dist/hooks/tabPanel/pageComponentFactory.d.ts +23 -0
  11. package/dist/{src/lib/hooks → hooks}/tabPanel/refresh.d.ts +6 -3
  12. package/dist/{src/lib/hooks → hooks}/tabPanel/scroll.d.ts +2 -0
  13. package/dist/hooks/tabPanel/tabStore.d.ts +69 -0
  14. package/dist/hooks/useScrollShadow.d.ts +20 -0
  15. package/dist/{src/lib/hooks → hooks}/useTabActions.d.ts +7 -2
  16. package/dist/{src/lib/hooks → hooks}/useTabEventBus.d.ts +2 -1
  17. package/dist/{src/lib/hooks → hooks}/useTabPanel.d.ts +16 -4
  18. package/dist/iframe-bridge.mjs +1 -1
  19. package/dist/index.d.ts +22 -6
  20. package/dist/{src/lib/model → model}/TabModel.d.ts +21 -0
  21. package/dist/nuxt/module.mjs +10 -6
  22. package/dist/nuxt/runtime/plugin.mjs +1600 -4
  23. package/dist/style.css +1 -1
  24. package/dist/utils/iframeBridge.d.ts +7 -1
  25. package/dist/{src/lib/utils → utils}/scrollUtils.d.ts +7 -1
  26. package/dist/{src/lib/versionLogger.d.ts → versionLogger.d.ts} +1 -1
  27. package/dist/vue-stack-tabs.css +1 -1
  28. package/dist/vue-stack-tabs.es.js +1 -1
  29. package/package.json +64 -40
  30. package/src/lib/StackTabs.vue +16 -3
  31. package/src/lib/assets/style/common/var.scss +2 -0
  32. package/src/lib/assets/style/stackTab.scss +17 -3
  33. package/src/lib/components/ContextMenu/index.vue +12 -3
  34. package/src/lib/components/PageLoading.vue +10 -2
  35. package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +3 -9
  36. package/src/lib/components/StackKeepAlive/emptyPlaceholder.tsx +18 -0
  37. package/src/lib/components/TabHeader/index.vue +4 -3
  38. package/src/lib/hooks/stackTabsContext.ts +23 -18
  39. package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +136 -0
  40. package/src/lib/hooks/tabPanel/refresh.ts +20 -13
  41. package/src/lib/hooks/tabPanel/scroll.ts +110 -7
  42. package/src/lib/hooks/tabPanel/session.ts +29 -3
  43. package/src/lib/hooks/tabPanel/tabStore.ts +595 -0
  44. package/src/lib/hooks/useContextMenu.ts +57 -11
  45. package/src/lib/hooks/useIframeManager.ts +18 -2
  46. package/src/lib/hooks/useScrollShadow.ts +124 -0
  47. package/src/lib/hooks/useTabActions.ts +18 -8
  48. package/src/lib/hooks/useTabEventBus.ts +5 -3
  49. package/src/lib/hooks/useTabPanel.tsx +183 -302
  50. package/src/lib/hooks/useTabRouter.ts +42 -38
  51. package/src/lib/i18n/index.ts +3 -3
  52. package/src/lib/model/TabModel.ts +40 -1
  53. package/src/lib/nuxt/module.ts +10 -3
  54. package/src/lib/nuxt/runtime/plugin.ts +13 -2
  55. package/src/lib/utils/iframeBridge.ts +9 -1
  56. package/src/lib/utils/scrollUtils.ts +35 -2
  57. package/src/lib/utils/tabInfoEncoder.ts +21 -8
  58. package/src/lib/utils/urlParser.ts +3 -1
  59. package/src/lib/versionLogger.ts +6 -2
  60. package/dist/favicon.svg +0 -10
  61. package/dist/iframe-bridge.html +0 -77
  62. package/dist/iframe-child.html +0 -88
  63. package/dist/nuxt/favicon.svg +0 -10
  64. package/dist/nuxt/iframe-bridge.html +0 -77
  65. package/dist/nuxt/iframe-child.html +0 -88
  66. package/dist/nuxt/plugin.mjs +0 -9
  67. package/dist/src/lib/iframe-bridge.d.ts +0 -8
  68. package/dist/src/lib/index.d.ts +0 -22
  69. package/dist/src/lib/nuxt/module.d.ts +0 -6
  70. package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +0 -2
  71. package/dist/src/lib/nuxt/runtime/plugin.d.ts +0 -2
  72. package/dist/src/lib/utils/iframeBridge.d.ts +0 -39
  73. package/src/lib/nuxt/runtime/plugin.client.ts +0 -10
  74. /package/dist/{src/lib/components → components}/ContextMenu/ContextMenuItem.vue.d.ts +0 -0
  75. /package/dist/{src/lib/components → components}/PageLoading.vue.d.ts +0 -0
  76. /package/dist/{src/lib/components → components}/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -0
  77. /package/dist/{src/lib/components → components}/StackKeepAlive/StackKeepAlive.vue.d.ts +0 -0
  78. /package/dist/{src/lib/components → components}/TabHeader/TabHeaderButton.vue.d.ts +0 -0
  79. /package/dist/{src/lib/components → components}/TabHeader/TabHeaderScroll.vue.d.ts +0 -0
  80. /package/dist/{src/lib/hooks → hooks}/tabPanel/evict.d.ts +0 -0
  81. /package/dist/{src/lib/hooks → hooks}/tabPanel/navigationTransaction.d.ts +0 -0
  82. /package/dist/{src/lib/hooks → hooks}/tabPanel/session.d.ts +0 -0
  83. /package/dist/{src/lib/hooks → hooks}/tabPanel/state.d.ts +0 -0
  84. /package/dist/{src/lib/hooks → hooks}/useContextMenu.d.ts +0 -0
  85. /package/dist/{src/lib/hooks → hooks}/useIframeManager.d.ts +0 -0
  86. /package/dist/{src/lib/hooks → hooks}/useTabLoading.d.ts +0 -0
  87. /package/dist/{src/lib/hooks → hooks}/useTabRouter.d.ts +0 -0
  88. /package/dist/{src/lib/i18n → i18n}/index.d.ts +0 -0
  89. /package/dist/{src/lib/i18n → i18n}/lang/en.d.ts +0 -0
  90. /package/dist/{src/lib/i18n → i18n}/lang/zh-CN.d.ts +0 -0
  91. /package/dist/{src/lib/i18n → i18n}/stackTabsLocale.d.ts +0 -0
  92. /package/dist/{src/lib/iframe.vue.d.ts → iframe.vue.d.ts} +0 -0
  93. /package/dist/{src/lib/utils → utils}/stackTabsA11y.d.ts +0 -0
  94. /package/dist/{src/lib/utils → utils}/stackTabsMessage.d.ts +0 -0
  95. /package/dist/{src/lib/utils → utils}/tabInfoEncoder.d.ts +0 -0
  96. /package/dist/{src/lib/utils → utils}/typeGuards.d.ts +0 -0
  97. /package/dist/{src/lib/utils → utils}/urlParser.d.ts +0 -0
@@ -13,7 +13,7 @@
13
13
  :style="{
14
14
  left: `${menuPosition.left}px`,
15
15
  top: `${menuPosition.top}px`,
16
- 'z-index': getMaxZIndex('.stack-tab,.stack-tab *')
16
+ 'z-index': menuZIndex()
17
17
  }"
18
18
  @keydown="handleMenuKeydown"
19
19
  >
@@ -84,7 +84,7 @@
84
84
  import { ref, onMounted, reactive, nextTick } from 'vue'
85
85
  import type { ITabItem, IContextMenu } from '../../model/TabModel'
86
86
  import { useI18n } from 'vue-i18n-lite'
87
- import { getMaxZIndex } from '../../utils/scrollUtils'
87
+ import { getMaxZIndex, invalidateZIndexCache } from '../../utils/scrollUtils'
88
88
  import useTabActions from '../../hooks/useTabActions'
89
89
  import useTabPanel from '../../hooks/useTabPanel'
90
90
  import ContextMenuItem from './ContextMenuItem.vue'
@@ -115,6 +115,14 @@ const menuPosition = reactive({ left: props.left, top: props.top })
115
115
  const { closeTab, closeAllTabs, refreshTab, refreshAllTabs, openInNewWindow } = useTabActions()
116
116
  const { removeLeftTabs, removeRightTabs, removeOtherTabs } = useTabPanel()
117
117
 
118
+ /**
119
+ * 菜单挂载时 z-index 必须重新计算(DOM 层叠已变化),先用 invalidateZIndexCache 失效再读取。
120
+ */
121
+ const menuZIndex = () => {
122
+ invalidateZIndexCache('.stack-tab,.stack-tab *')
123
+ return getMaxZIndex('.stack-tab,.stack-tab *')
124
+ }
125
+
118
126
  const focusActiveTabFallback = () => {
119
127
  const activeTab = document.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')
120
128
  activeTab?.focus()
@@ -193,7 +201,8 @@ const handleMenuKeydown = (event: KeyboardEvent) => {
193
201
  }
194
202
  }
195
203
 
196
- const getCustomMenuKey = (item: IContextMenu): string => item.key ?? `${item.title}-${item.icon ?? ''}`
204
+ const getCustomMenuKey = (item: IContextMenu): string =>
205
+ item.key ?? `${item.title}-${item.icon ?? ''}`
197
206
 
198
207
  /** 挂载后修正菜单位置,避免超出右边界 */
199
208
  onMounted(() => {
@@ -11,7 +11,7 @@
11
11
  role="status"
12
12
  aria-live="polite"
13
13
  :aria-label="t('VueStackTab.loading')"
14
- :style="{ zIndex: getMaxZIndex('.cache-page-wrapper *') }"
14
+ :style="{ zIndex: loadingZIndex() }"
15
15
  >
16
16
  <div class="stack-tab-loading--spin turn" aria-hidden="true" />
17
17
  </div>
@@ -20,7 +20,15 @@
20
20
  import { ref, onUnmounted } from 'vue'
21
21
  import { useI18n } from 'vue-i18n-lite'
22
22
  import { TabEventType, useTabEmitter } from '../hooks/useTabEventBus'
23
- import { getMaxZIndex } from '../utils/scrollUtils'
23
+ import { getMaxZIndex, invalidateZIndexCache } from '../utils/scrollUtils'
24
+
25
+ /**
26
+ * loading 遮罩每次显隐都会改变 .cache-page-wrapper 子树层叠,先失效缓存再读取避免旧值。
27
+ */
28
+ const loadingZIndex = () => {
29
+ invalidateZIndexCache('.cache-page-wrapper *')
30
+ return getMaxZIndex('.cache-page-wrapper *')
31
+ }
24
32
  const props = defineProps<{
25
33
  /** 当前标签 id,用于过滤 PAGE_LOADING 事件 */
26
34
  tabId: string
@@ -11,10 +11,11 @@
11
11
  - 不负责 transition、keep-alive、TabHeader、iframe 或 session 初始化
12
12
  -->
13
13
  <script lang="ts" setup>
14
- import { defineComponent, shallowRef, watch } from 'vue'
14
+ import { shallowRef, watch } from 'vue'
15
15
  import type { DefineComponent, VNode } from 'vue'
16
16
  import type { RouteLocationNormalizedLoaded } from 'vue-router'
17
17
  import useTabPanel from '../../hooks/useTabPanel'
18
+ import { getEmptyPlaceholderComponent } from './emptyPlaceholder'
18
19
 
19
20
  interface StackCacheRendererProps {
20
21
  route: RouteLocationNormalizedLoaded
@@ -37,14 +38,7 @@ defineSlots<{
37
38
 
38
39
  const { refreshKey, activeCacheKey, activePageRefreshVersion, addPage } = useTabPanel()
39
40
 
40
- const EmptyRendererComponent = defineComponent({
41
- name: 'StackCacheRendererEmpty',
42
- setup() {
43
- return () => null
44
- }
45
- }) as DefineComponent
46
-
47
- const wrappedComponent = shallowRef<DefineComponent>(EmptyRendererComponent)
41
+ const wrappedComponent = shallowRef<DefineComponent>(getEmptyPlaceholderComponent())
48
42
 
49
43
  const getComponentIdentity = (component?: VNode | null): unknown => {
50
44
  return component?.type ?? component
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 共享的 Empty 占位组件。
3
+ *
4
+ * useTabPanel 在「empty」/「recovered」分支会返回一个不渲染任何内容的占位组件。
5
+ * 原先在 pageComponentFactory.tsx 和 StackCacheRenderer.vue 各自定义了一份,
6
+ * 这里抽出唯一来源,避免两份略有差异的副本带来维护负担。
7
+ */
8
+ import { defineComponent } from 'vue'
9
+ import type { DefineComponent } from 'vue'
10
+
11
+ export const EmptyPlaceholderComponent = defineComponent({
12
+ name: 'StackTabEmptyPlaceholder',
13
+ setup() {
14
+ return () => null
15
+ }
16
+ }) as DefineComponent
17
+
18
+ export const getEmptyPlaceholderComponent = (): DefineComponent => EmptyPlaceholderComponent
@@ -243,17 +243,18 @@ const handleTabListKeydown = (event: KeyboardEvent) => {
243
243
  if (count <= 0) return
244
244
 
245
245
  const currentIndex = getActiveTabIndex()
246
- const safeIndex = currentIndex >= 0 ? currentIndex : 0
247
246
 
248
247
  if (event.key === 'ArrowRight') {
249
248
  event.preventDefault()
250
- activateTabByIndex((safeIndex + 1) % count)
249
+ // 无 active 标签时从 -1 起步,按右方向键跳到第 0 个;正常情况环绕到下一个。
250
+ activateTabByIndex(currentIndex === -1 ? 0 : (currentIndex + 1) % count)
251
251
  return
252
252
  }
253
253
 
254
254
  if (event.key === 'ArrowLeft') {
255
255
  event.preventDefault()
256
- activateTabByIndex((safeIndex - 1 + count) % count)
256
+ // 无 active 标签时按左方向键跳到最后一个;正常情况环绕到上一个。
257
+ activateTabByIndex(currentIndex === -1 ? count - 1 : (currentIndex - 1 + count) % count)
257
258
  return
258
259
  }
259
260
 
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * VueStackTabs 全局运行时上下文管理。
3
3
  *
4
- * 负责创建、注册、解析全局唯一的 StackTabsRuntimeContext,
5
- * 支持通过 Vue provide/inject 或模块级单例两种方式访问。
4
+ * 负责创建、注册、解析 StackTabsRuntimeContext。
5
+ * 业务运行时通过 Vue provide/inject 隔离;模块级回退仅保留给没有 App
6
+ * 容器的兼容调用(例如旧的单元测试),绝不能承载某个 Vue App 的状态。
6
7
  * 每个 Vue app 只允许注册一个 <VueStackTabs> 实例。
7
8
  */
8
9
  import type { App, DefineComponent, InjectionKey, Ref, ShallowRef } from 'vue'
@@ -77,7 +78,10 @@ export const createStackTabsRuntimeContext = (
77
78
  }
78
79
  }
79
80
 
80
- /** 获取当前活跃的运行时上下文(模块级单例) */
81
+ /**
82
+ * 获取非 App 容器下注册的兼容上下文。
83
+ * @deprecated App 内的 composable 必须依赖 provide/inject,不应使用这个回退。
84
+ */
81
85
  export const getActiveStackTabsRuntimeContext = (): StackTabsRuntimeContext | null =>
82
86
  activeRuntimeContext
83
87
 
@@ -86,22 +90,21 @@ export const registerStackTabsRuntimeContext = (
86
90
  context: StackTabsRuntimeContext,
87
91
  options: RegisterStackTabsRuntimeContextOptions = {}
88
92
  ): boolean => {
89
- if (!activeRuntimeContext) {
90
- activeRuntimeContext = context
91
- }
92
-
93
93
  if (options.app && !runtimeContextOwnersByApp.has(options.app)) {
94
94
  runtimeContextOwnersByApp.add(options.app)
95
95
  return true
96
96
  }
97
97
 
98
- if (
99
- !options.app &&
100
- activeRuntimeContext === context &&
101
- !isFallbackRuntimeContextOwnerRegistered
102
- ) {
103
- isFallbackRuntimeContextOwnerRegistered = true
104
- return true
98
+ if (!options.app) {
99
+ if (!activeRuntimeContext) {
100
+ activeRuntimeContext = context
101
+ isFallbackRuntimeContextOwnerRegistered = true
102
+ return true
103
+ }
104
+ if (activeRuntimeContext === context && !isFallbackRuntimeContextOwnerRegistered) {
105
+ isFallbackRuntimeContextOwnerRegistered = true
106
+ return true
107
+ }
105
108
  }
106
109
 
107
110
  const isProduction = options.isProduction ?? import.meta.env.PROD
@@ -118,13 +121,15 @@ export const unregisterStackTabsRuntimeContext = (
118
121
  context: StackTabsRuntimeContext,
119
122
  options: UnregisterStackTabsRuntimeContextOptions = {}
120
123
  ): void => {
121
- if (options.app) runtimeContextOwnersByApp.delete(options.app)
122
- if (!options.app && activeRuntimeContext === context)
123
- isFallbackRuntimeContextOwnerRegistered = false
124
+ if (options.app) {
125
+ runtimeContextOwnersByApp.delete(options.app)
126
+ return
127
+ }
128
+ if (activeRuntimeContext === context) isFallbackRuntimeContextOwnerRegistered = false
124
129
  if (activeRuntimeContext === context) activeRuntimeContext = null
125
130
  }
126
131
 
127
- /** 解析当前可用的运行时上下文,优先使用 inject,回退到模块单例 */
132
+ /** 解析当前可用的运行时上下文,优先使用 inject,兼容回退仅限非 App 容器。 */
128
133
  export const resolveStackTabsRuntimeContext = (): StackTabsRuntimeContext => {
129
134
  const injected = hasInjectionContext() ? inject(stackTabsContextKey, null) : null
130
135
  const context = injected ?? activeRuntimeContext
@@ -0,0 +1,136 @@
1
+ /**
2
+ * 页面缓存组件工厂。
3
+ *
4
+ * 将 keep-alive 包装、VNode 注入和页面激活生命周期从 tab 状态编排中隔离,
5
+ * 使 useTabPanel 只负责领域状态与路由协调。
6
+ */
7
+ import {
8
+ cloneVNode,
9
+ defineComponent,
10
+ onActivated,
11
+ onDeactivated,
12
+ onMounted,
13
+ onUnmounted,
14
+ ref,
15
+ shallowRef
16
+ } from 'vue'
17
+ import type { DefineComponent, PropType, VNode } from 'vue'
18
+ import type { ITabBase } from '../../model/TabModel'
19
+ import PageLoading from '../../components/PageLoading.vue'
20
+ import type { StackTabsRuntimeContext } from '../stackTabsContext'
21
+ import { useScrollShadow } from '../useScrollShadow'
22
+
23
+ interface PageComponentFactoryOptions {
24
+ runtimeContext: StackTabsRuntimeContext
25
+ consumeBackParams: (tabId: string, pageId: string) => Record<string, unknown> | null
26
+ getComponent: (id: string) => DefineComponent | undefined
27
+ setComponent: (id: string, component: DefineComponent) => void
28
+ addCache: (cacheName: string) => void
29
+ evictMarkedCaches: () => void
30
+ clearTabEvictionMarks: () => void
31
+ addPageScroller: (pageId: string, ...selectors: string[]) => void
32
+ saveScroller: (pageId: string) => void
33
+ restoreScroller: (pageId: string) => void
34
+ removeScroller: (pageId: string) => void
35
+ }
36
+
37
+ export const createPageComponentFactory = (options: PageComponentFactoryOptions) => {
38
+ const {
39
+ runtimeContext,
40
+ consumeBackParams,
41
+ getComponent,
42
+ setComponent,
43
+ addCache,
44
+ evictMarkedCaches,
45
+ clearTabEvictionMarks,
46
+ addPageScroller,
47
+ saveScroller,
48
+ restoreScroller,
49
+ removeScroller
50
+ } = options
51
+
52
+ const resolvePageComponent = (ctx: { cacheName: string; tabInfo: ITabBase }): DefineComponent => {
53
+ const { cacheName, tabInfo } = ctx
54
+ const existing = getComponent(cacheName)
55
+ if (existing) {
56
+ addCache(cacheName)
57
+ return existing
58
+ }
59
+
60
+ const cacheComponent = defineComponent<{ vnode?: VNode }>({
61
+ name: cacheName,
62
+ props: {
63
+ vnode: {
64
+ type: Object as PropType<VNode>,
65
+ required: false,
66
+ default: undefined
67
+ }
68
+ },
69
+ emits: ['onLoaded'],
70
+ setup(props, context) {
71
+ const localBackParams = ref<Record<string, unknown> | null>(null)
72
+ const lastVnode = shallowRef<VNode | null | undefined>(null)
73
+ const lastCloned = shallowRef<VNode | null>(null)
74
+ const lastBackParams = shallowRef<Record<string, unknown> | null>(null)
75
+ const pageRoot = ref<HTMLElement | null>(null)
76
+
77
+ const checkAndConsumeBackParams = () => {
78
+ localBackParams.value = consumeBackParams(tabInfo.id!, cacheName)
79
+ }
80
+
81
+ onMounted(() => {
82
+ context.emit('onLoaded')
83
+ addPageScroller(cacheName, `#W-${cacheName}`)
84
+ checkAndConsumeBackParams()
85
+ })
86
+ onDeactivated(() => saveScroller(cacheName))
87
+ onActivated(() => {
88
+ context.emit('onLoaded')
89
+ restoreScroller(cacheName)
90
+ evictMarkedCaches()
91
+ clearTabEvictionMarks()
92
+ checkAndConsumeBackParams()
93
+ })
94
+ onUnmounted(() => removeScroller(cacheName))
95
+
96
+ // 给 .cache-page-wrapper 挂滚动阴影,确保 keep-alive 的 suspended/deactivated 生命周期下表现一致
97
+ useScrollShadow({ target: pageRoot })
98
+
99
+ return () => {
100
+ const vnode = props.vnode
101
+ const backParams = localBackParams.value
102
+ if (vnode !== lastVnode.value || backParams !== lastBackParams.value) {
103
+ lastCloned.value = vnode
104
+ ? cloneVNode(vnode, {
105
+ tId: tabInfo.id,
106
+ pId: cacheName,
107
+ ...(backParams ? { _back: backParams } : {})
108
+ })
109
+ : null
110
+ lastVnode.value = vnode
111
+ lastBackParams.value = backParams
112
+ }
113
+ return (
114
+ <div
115
+ ref={pageRoot}
116
+ class="cache-page-wrapper"
117
+ id={`W-${cacheName}`}
118
+ style={[
119
+ runtimeContext.useGlobalScroll.value ? 'overflow:auto' : 'overflow:hidden',
120
+ 'height: 100%'
121
+ ]}
122
+ >
123
+ {lastCloned.value}
124
+ <PageLoading tabId={tabInfo.id!} />
125
+ </div>
126
+ )
127
+ }
128
+ }
129
+ }) as DefineComponent
130
+ setComponent(cacheName, cacheComponent)
131
+ addCache(cacheName)
132
+ return cacheComponent
133
+ }
134
+
135
+ return { resolvePageComponent }
136
+ }
@@ -4,7 +4,8 @@
4
4
  * 活动普通页面保持 cache id 稳定,只递增页面级刷新版本;非活动页面可直接替换缓存。
5
5
  */
6
6
  import type { Ref } from 'vue'
7
- import type { ITabItem } from '../../model/TabModel'
7
+ import type { ITabItem, ITabPage } from '../../model/TabModel'
8
+ import type { PagePatch } from './tabStore'
8
9
 
9
10
  export interface TabPanelRefreshApi {
10
11
  refreshTab: (id: string) => void
@@ -12,9 +13,11 @@ export interface TabPanelRefreshApi {
12
13
  }
13
14
 
14
15
  export interface CreateTabPanelRefreshOptions {
15
- tabs: Ref<ITabItem[]>
16
+ getTabs: () => readonly ITabItem[]
17
+ getTopPage: (tabId: string) => ITabPage | null
18
+ /** 修改页面字段(刷新版本 / 页面 id)并触发 tabs 更新 */
19
+ updatePage: (tabId: string, pageId: string, patch: PagePatch) => boolean
16
20
  iframeRefreshKeys: Ref<Record<string, number>>
17
- getTab: (id: string) => ITabItem | null
18
21
  createPageId: () => string
19
22
  addCache: (cacheName: string) => void
20
23
  evictPageCache: (cacheName: string) => void
@@ -29,9 +32,10 @@ export const createTabPanelRefresh = (
29
32
  options: CreateTabPanelRefreshOptions
30
33
  ): TabPanelRefreshApi => {
31
34
  const {
32
- tabs,
35
+ getTabs,
36
+ getTopPage,
37
+ updatePage,
33
38
  iframeRefreshKeys,
34
- getTab,
35
39
  createPageId,
36
40
  addCache,
37
41
  evictPageCache,
@@ -51,28 +55,31 @@ export const createTabPanelRefresh = (
51
55
  }
52
56
 
53
57
  const refreshPage = (tab: ITabItem, cacheNamesToEvict?: string[], cacheNamesToAdd?: string[]) => {
54
- const currentPage = tab.pages.peek()
58
+ const currentPage = getTopPage(tab.id)
55
59
  if (!currentPage) return
56
60
 
57
61
  if (tab.active) {
58
- currentPage.refreshVersion = (currentPage.refreshVersion ?? 0) + 1
62
+ // updatePage 内部会触发 tabs 更新,activePageRefreshVersion 随之重新求值。
63
+ updatePage(tab.id, currentPage.id, {
64
+ refreshVersion: (currentPage.refreshVersion ?? 0) + 1
65
+ })
59
66
  return
60
67
  }
61
68
 
62
69
  const oldCacheName = currentPage.id
63
- currentPage.id = createPageId()
64
- currentPage.refreshVersion = 0
70
+ const nextPageId = createPageId()
71
+ updatePage(tab.id, oldCacheName, { id: nextPageId, refreshVersion: 0 })
65
72
  if (cacheNamesToEvict && cacheNamesToAdd) {
66
73
  cacheNamesToEvict.push(oldCacheName)
67
- cacheNamesToAdd.push(currentPage.id)
74
+ cacheNamesToAdd.push(nextPageId)
68
75
  return
69
76
  }
70
77
  evictPageCache(oldCacheName)
71
- addCache(currentPage.id)
78
+ addCache(nextPageId)
72
79
  }
73
80
 
74
81
  const refreshTab = (id: string) => {
75
- const tab = getTab(id)
82
+ const tab = getTabs().find((item) => item.id === id)
76
83
  if (!tab?.refreshable) return
77
84
  if (tab.iframe) {
78
85
  refreshIframe(tab)
@@ -87,7 +94,7 @@ export const createTabPanelRefresh = (
87
94
  const iframeKeysUpdate = { ...iframeRefreshKeys.value }
88
95
  let iframeKeysChanged = false
89
96
 
90
- for (const tab of tabs.value) {
97
+ for (const tab of getTabs()) {
91
98
  if (!tab.refreshable) continue
92
99
  if (tab.iframe) {
93
100
  if (tab.iframeRefreshMode === 'reload') {
@@ -2,6 +2,8 @@
2
2
  * tabPanel/scroll - 滚动位置保存与恢复
3
3
  *
4
4
  * 职责:基于当前 StackTabsRuntimeContext 在切出/切入页面时保存和恢复滚动位置。
5
+ * 优化:用 MutationObserver 缓存 [data-stack-tab-scroll] 元素列表,
6
+ * 避免每次 deactivate 都全量 querySelectorAll。
5
7
  */
6
8
  import type { StackTabsRuntimeContext } from '../stackTabsContext'
7
9
 
@@ -10,6 +12,8 @@ export interface TabPanelScrollApi {
10
12
  saveScroller: (pageCacheId: string) => void
11
13
  removeScroller: (pageCacheId: string) => void
12
14
  addPageScroller: (pageCacheId: string, ...selectorIds: string[]) => void
15
+ /** 主动失效某页面的滚动元素缓存,例如外部强制刷新缓存页时 */
16
+ invalidateAutoScrollCache: (pageCacheId: string) => void
13
17
  }
14
18
 
15
19
  const resolveScrollElement = (selector: string): HTMLElement | null =>
@@ -17,6 +21,73 @@ const resolveScrollElement = (selector: string): HTMLElement | null =>
17
21
  ? document.getElementById(selector.slice(1))
18
22
  : (document.querySelector(selector) as HTMLElement | null)
19
23
 
24
+ const AUTO_SCROLLER_PREFIX = '__stack-tab-auto-scroll__:'
25
+
26
+ interface AutoScrollCache {
27
+ elements: HTMLElement[]
28
+ observer: MutationObserver | null
29
+ }
30
+
31
+ /**
32
+ * 在 pageRoot 上挂一个 MutationObserver,自动维护 [data-stack-tab-scroll] 元素列表;
33
+ * 列表变化时无需上层主动失效,下次读到的就是最新值。
34
+ */
35
+ const createAutoScrollCache = (pageRoot: HTMLElement): AutoScrollCache => {
36
+ const collect = (): HTMLElement[] =>
37
+ Array.from(pageRoot.querySelectorAll<HTMLElement>('[data-stack-tab-scroll]'))
38
+
39
+ const cache: AutoScrollCache = {
40
+ elements: collect(),
41
+ observer: null
42
+ }
43
+
44
+ if (typeof MutationObserver !== 'undefined') {
45
+ cache.observer = new MutationObserver(() => {
46
+ cache.elements = collect()
47
+ })
48
+ // 仅监听子树增删与属性变化,不监听字符数据,减少开销。
49
+ cache.observer.observe(pageRoot, {
50
+ childList: true,
51
+ subtree: true,
52
+ attributes: true,
53
+ attributeFilter: ['data-stack-tab-scroll']
54
+ })
55
+ }
56
+
57
+ return cache
58
+ }
59
+
60
+ const autoScrollCaches = new Map<string, AutoScrollCache>()
61
+
62
+ const getAutoScrollCache = (pageCacheId: string): AutoScrollCache | null => {
63
+ const pageRoot = document.getElementById(`W-${pageCacheId}`)
64
+ if (!pageRoot) {
65
+ autoScrollCaches.delete(pageCacheId)
66
+ return null
67
+ }
68
+
69
+ let cache = autoScrollCaches.get(pageCacheId)
70
+ if (!cache) {
71
+ cache = createAutoScrollCache(pageRoot)
72
+ autoScrollCaches.set(pageCacheId, cache)
73
+ } else {
74
+ // 防御:检查 pageRoot 是否已被替换(极端情况:同一 pageCacheId 重新挂载)
75
+ if (cache.elements.length === 0 && cache.observer) {
76
+ cache.elements = Array.from(pageRoot.querySelectorAll<HTMLElement>('[data-stack-tab-scroll]'))
77
+ }
78
+ }
79
+ return cache
80
+ }
81
+
82
+ const getAutoScrollerKey = (index: number): string => `${AUTO_SCROLLER_PREFIX}${index}`
83
+
84
+ const resolveAutoScrollElement = (pageCacheId: string, key: string): HTMLElement | null => {
85
+ const index = Number(key.slice(AUTO_SCROLLER_PREFIX.length))
86
+ if (!Number.isInteger(index)) return null
87
+ const cache = getAutoScrollCache(pageCacheId)
88
+ return cache?.elements[index] ?? null
89
+ }
90
+
20
91
  export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanelScrollApi => {
21
92
  const { scrollPositionsByPageId } = context
22
93
 
@@ -25,7 +96,9 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
25
96
  if (!positions) return
26
97
 
27
98
  for (const [selector, position] of positions) {
28
- const element = resolveScrollElement(selector)
99
+ const element = selector.startsWith(AUTO_SCROLLER_PREFIX)
100
+ ? resolveAutoScrollElement(pageCacheId, selector)
101
+ : resolveScrollElement(selector)
29
102
  if (element) {
30
103
  element.scrollTop = position.top
31
104
  element.scrollLeft = position.left
@@ -37,8 +110,23 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
37
110
  const positions = scrollPositionsByPageId.get(pageCacheId)
38
111
  if (!positions) return
39
112
 
113
+ // 与当前页面 DOM 同步自动标记的容器;移除的元素不保留过期位置。
114
+ for (const key of positions.keys()) {
115
+ if (key.startsWith(AUTO_SCROLLER_PREFIX)) positions.delete(key)
116
+ }
117
+ const cache = getAutoScrollCache(pageCacheId)
118
+ const elements = cache?.elements ?? []
119
+ elements.forEach((element, index) => {
120
+ positions.set(getAutoScrollerKey(index), {
121
+ top: element.scrollTop,
122
+ left: element.scrollLeft
123
+ })
124
+ })
125
+
40
126
  for (const selector of positions.keys()) {
41
- const element = resolveScrollElement(selector)
127
+ const element = selector.startsWith(AUTO_SCROLLER_PREFIX)
128
+ ? resolveAutoScrollElement(pageCacheId, selector)
129
+ : resolveScrollElement(selector)
42
130
  positions.set(selector, {
43
131
  top: element?.scrollTop ?? 0,
44
132
  left: element?.scrollLeft ?? 0
@@ -48,10 +136,16 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
48
136
 
49
137
  const removeScroller = (pageCacheId: string): void => {
50
138
  const positions = scrollPositionsByPageId.get(pageCacheId)
51
- if (!positions) return
52
-
53
- positions.clear()
54
- scrollPositionsByPageId.delete(pageCacheId)
139
+ if (positions) {
140
+ positions.clear()
141
+ scrollPositionsByPageId.delete(pageCacheId)
142
+ }
143
+ // 同步卸载 MutationObserver,避免页面被驱逐后 observer 仍持有 DOM 引用。
144
+ const cache = autoScrollCaches.get(pageCacheId)
145
+ if (cache) {
146
+ cache.observer?.disconnect()
147
+ autoScrollCaches.delete(pageCacheId)
148
+ }
55
149
  }
56
150
 
57
151
  const addPageScroller = (pageCacheId: string, ...selectorIds: string[]): void => {
@@ -64,10 +158,19 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
64
158
  }
65
159
  }
66
160
 
161
+ const invalidateAutoScrollCache = (pageCacheId: string): void => {
162
+ const cache = autoScrollCaches.get(pageCacheId)
163
+ if (!cache) return
164
+ cache.elements = []
165
+ cache.observer?.disconnect()
166
+ autoScrollCaches.delete(pageCacheId)
167
+ }
168
+
67
169
  return {
68
170
  restoreScroller,
69
171
  saveScroller,
70
172
  removeScroller,
71
- addPageScroller
173
+ addPageScroller,
174
+ invalidateAutoScrollCache
72
175
  }
73
176
  }
@@ -33,12 +33,38 @@ export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPane
33
33
  let writeVersion = 0
34
34
  let writeScheduled = false
35
35
 
36
+ /**
37
+ * 写入 sessionStorage,捕获隐私模式(QuotaExceededError)等异常,
38
+ * 避免在受限浏览器环境下抛出未处理错误。SSR 环境无 window 时直接跳过。
39
+ */
40
+ const safeSetItem = (key: string, value: string): void => {
41
+ if (typeof window === 'undefined') return
42
+ try {
43
+ window.sessionStorage.setItem(key, value)
44
+ } catch (error) {
45
+ if (!import.meta.env.PROD) {
46
+ console.warn('[vue-stack-tabs] Failed to write sessionStorage:', error)
47
+ }
48
+ }
49
+ }
50
+
51
+ const safeRemoveItem = (key: string): void => {
52
+ if (typeof window === 'undefined') return
53
+ try {
54
+ window.sessionStorage.removeItem(key)
55
+ } catch (error) {
56
+ if (!import.meta.env.PROD) {
57
+ console.warn('[vue-stack-tabs] Failed to clear sessionStorage:', error)
58
+ }
59
+ }
60
+ }
61
+
36
62
  const flushPendingWrite = () => {
37
63
  writeScheduled = false
38
64
  const write = pendingWrite
39
65
  pendingWrite = null
40
66
  if (!write || write.version !== writeVersion) return
41
- window.sessionStorage.setItem(getSessionKey(), JSON.stringify(write.tab))
67
+ safeSetItem(getSessionKey(), JSON.stringify(write.tab))
42
68
  }
43
69
 
44
70
  const invalidatePendingWrite = () => {
@@ -56,7 +82,7 @@ export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPane
56
82
 
57
83
  const clearSession = (): void => {
58
84
  invalidatePendingWrite()
59
- window.sessionStorage.removeItem(getSessionKey())
85
+ safeRemoveItem(getSessionKey())
60
86
  }
61
87
 
62
88
  const restoreActiveTabSession = (storedJson: string | null): void => {
@@ -65,7 +91,7 @@ export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPane
65
91
  return
66
92
  }
67
93
  invalidatePendingWrite()
68
- window.sessionStorage.setItem(getSessionKey(), storedJson)
94
+ safeSetItem(getSessionKey(), storedJson)
69
95
  }
70
96
 
71
97
  const restoreTabFromSession = (storedJson: string | null): ITabItem | null => {