vue-stack-tabs 1.1.0 → 1.3.1

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 (115) hide show
  1. package/README.en.md +526 -0
  2. package/README.md +141 -39
  3. package/README.ru.md +526 -0
  4. package/dist/iframe-bridge.d.ts +8 -0
  5. package/dist/iframe-bridge.html +77 -0
  6. package/dist/iframe-bridge.mjs +20 -0
  7. package/dist/iframe-child.html +19 -6
  8. package/dist/nuxt/favicon.svg +10 -0
  9. package/dist/nuxt/iframe-bridge.html +77 -0
  10. package/dist/nuxt/iframe-child.html +88 -0
  11. package/dist/nuxt/module.d.ts +6 -0
  12. package/dist/nuxt/module.mjs +28 -0
  13. package/dist/nuxt/plugin.mjs +9 -0
  14. package/dist/nuxt/runtime/plugin.d.ts +2 -0
  15. package/dist/nuxt/runtime/plugin.mjs +9 -0
  16. package/dist/src/lib/StackTabs.vue.d.ts +30 -16
  17. package/dist/src/lib/components/ContextMenu/ContextMenuItem.vue.d.ts +7 -1
  18. package/dist/src/lib/components/ContextMenu/index.vue.d.ts +5 -4
  19. package/dist/src/lib/components/PageLoading.vue.d.ts +2 -1
  20. package/dist/src/lib/components/StackKeepAlive/StackCacheRenderer.vue.d.ts +25 -0
  21. package/dist/src/lib/components/StackKeepAlive/StackKeepAlive.vue.d.ts +12 -0
  22. package/dist/src/lib/components/TabHeader/TabHeaderButton.vue.d.ts +11 -12
  23. package/dist/src/lib/components/TabHeader/TabHeaderItem.vue.d.ts +5 -4
  24. package/dist/src/lib/components/TabHeader/TabHeaderScroll.vue.d.ts +8 -22
  25. package/dist/src/lib/components/TabHeader/index.vue.d.ts +11 -164
  26. package/dist/src/lib/hooks/stackTabsContext.d.ts +49 -0
  27. package/dist/src/lib/hooks/tabPanel/evict.d.ts +21 -61
  28. package/dist/src/lib/hooks/tabPanel/navigationTransaction.d.ts +35 -0
  29. package/dist/src/lib/hooks/tabPanel/refresh.d.ts +17 -0
  30. package/dist/src/lib/hooks/tabPanel/scroll.d.ts +8 -8
  31. package/dist/src/lib/hooks/tabPanel/session.d.ts +9 -8
  32. package/dist/src/lib/hooks/tabPanel/state.d.ts +20 -72
  33. package/dist/src/lib/hooks/useIframeManager.d.ts +35 -0
  34. package/dist/src/lib/hooks/useTabActions.d.ts +5 -46
  35. package/dist/src/lib/hooks/useTabEventBus.d.ts +22 -9
  36. package/dist/src/lib/hooks/useTabPanel.d.ts +14 -73
  37. package/dist/src/lib/i18n/index.d.ts +6 -4
  38. package/dist/src/lib/i18n/lang/en.d.ts +5 -0
  39. package/dist/src/lib/i18n/lang/zh-CN.d.ts +5 -0
  40. package/dist/src/lib/i18n/stackTabsLocale.d.ts +2 -0
  41. package/dist/src/lib/iframe-bridge.d.ts +8 -0
  42. package/dist/src/lib/iframe.vue.d.ts +2 -1
  43. package/dist/src/lib/index.d.ts +4 -5
  44. package/dist/src/lib/model/TabModel.d.ts +11 -40
  45. package/dist/src/lib/nuxt/module.d.ts +6 -0
  46. package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +2 -0
  47. package/dist/src/lib/nuxt/runtime/plugin.d.ts +2 -0
  48. package/dist/src/lib/utils/iframeBridge.d.ts +11 -3
  49. package/dist/src/lib/utils/stackTabsA11y.d.ts +8 -0
  50. package/dist/src/lib/utils/stackTabsMessage.d.ts +7 -0
  51. package/dist/src/lib/utils/tabInfoEncoder.d.ts +0 -15
  52. package/dist/src/lib/utils/typeGuards.d.ts +2 -0
  53. package/dist/src/lib/utils/urlParser.d.ts +24 -3
  54. package/dist/src/lib/versionLogger.d.ts +1 -0
  55. package/dist/style.css +2 -1
  56. package/dist/utils/iframeBridge.d.ts +39 -0
  57. package/dist/vue-stack-tabs.css +2 -1
  58. package/dist/vue-stack-tabs.es.js +1 -2220
  59. package/package.json +71 -50
  60. package/src/lib/StackTabs.vue +223 -196
  61. package/src/lib/assets/style/stackTab.scss +110 -13
  62. package/src/lib/components/ContextMenu/ContextMenuItem.vue +23 -11
  63. package/src/lib/components/ContextMenu/index.vue +94 -10
  64. package/src/lib/components/PageLoading.vue +9 -7
  65. package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +70 -0
  66. package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +57 -0
  67. package/src/lib/components/TabHeader/TabHeaderButton.vue +19 -8
  68. package/src/lib/components/TabHeader/TabHeaderItem.vue +74 -17
  69. package/src/lib/components/TabHeader/TabHeaderScroll.vue +47 -25
  70. package/src/lib/components/TabHeader/index.vue +181 -13
  71. package/src/lib/hooks/stackTabsContext.ts +135 -0
  72. package/src/lib/hooks/tabPanel/evict.ts +122 -87
  73. package/src/lib/hooks/tabPanel/navigationTransaction.ts +61 -0
  74. package/src/lib/hooks/tabPanel/refresh.ts +109 -0
  75. package/src/lib/hooks/tabPanel/scroll.ts +57 -40
  76. package/src/lib/hooks/tabPanel/session.ts +84 -22
  77. package/src/lib/hooks/tabPanel/state.ts +41 -44
  78. package/src/lib/hooks/useIframeManager.ts +253 -0
  79. package/src/lib/hooks/useTabActions.ts +64 -30
  80. package/src/lib/hooks/useTabEventBus.ts +29 -19
  81. package/src/lib/hooks/useTabLoading.ts +3 -2
  82. package/src/lib/hooks/useTabPanel.tsx +290 -313
  83. package/src/lib/hooks/useTabRouter.ts +111 -97
  84. package/src/lib/i18n/index.ts +53 -45
  85. package/src/lib/i18n/lang/en.ts +6 -1
  86. package/src/lib/i18n/lang/zh-CN.ts +6 -1
  87. package/src/lib/i18n/stackTabsLocale.ts +8 -0
  88. package/src/lib/iframe-bridge.ts +12 -0
  89. package/src/lib/index.ts +14 -19
  90. package/src/lib/model/TabModel.ts +27 -82
  91. package/src/lib/nuxt/module.d.ts +4 -0
  92. package/src/lib/nuxt/module.ts +8 -3
  93. package/src/lib/nuxt/runtime/plugin.client.ts +1 -0
  94. package/src/lib/nuxt/runtime/plugin.ts +11 -0
  95. package/src/lib/utils/iframeBridge.ts +29 -5
  96. package/src/lib/utils/scrollUtils.ts +9 -4
  97. package/src/lib/utils/stackTabsA11y.ts +12 -0
  98. package/src/lib/utils/stackTabsMessage.ts +99 -0
  99. package/src/lib/utils/tabInfoEncoder.ts +70 -21
  100. package/src/lib/utils/typeGuards.ts +3 -0
  101. package/src/lib/utils/urlParser.ts +144 -14
  102. package/src/lib/versionLogger.ts +7 -19
  103. package/dist/src/lib/hooks/tabPanel/evict.spec.d.ts +0 -1
  104. package/dist/src/lib/hooks/useTabPanel.spec.d.ts +0 -1
  105. package/dist/src/lib/model/TabModel.spec.d.ts +0 -1
  106. package/dist/src/lib/utils/iframeBridge.spec.d.ts +0 -1
  107. package/dist/src/lib/utils/tabInfoEncoder.spec.d.ts +0 -1
  108. package/dist/src/lib/utils/urlParser.spec.d.ts +0 -1
  109. package/dist/vue-stack-tabs.umd.js +0 -1
  110. package/src/lib/hooks/tabPanel/evict.spec.ts +0 -85
  111. package/src/lib/hooks/useTabPanel.spec.ts +0 -34
  112. package/src/lib/model/TabModel.spec.ts +0 -40
  113. package/src/lib/utils/iframeBridge.spec.ts +0 -37
  114. package/src/lib/utils/tabInfoEncoder.spec.ts +0 -32
  115. package/src/lib/utils/urlParser.spec.ts +0 -45
@@ -0,0 +1,135 @@
1
+ /**
2
+ * VueStackTabs 全局运行时上下文管理。
3
+ *
4
+ * 负责创建、注册、解析全局唯一的 StackTabsRuntimeContext,
5
+ * 支持通过 Vue provide/inject 或模块级单例两种方式访问。
6
+ * 每个 Vue app 只允许注册一个 <VueStackTabs> 实例。
7
+ */
8
+ import type { App, DefineComponent, InjectionKey, Ref, ShallowRef } from 'vue'
9
+ import { hasInjectionContext, inject, ref } from 'vue'
10
+ import type { Emitter } from 'mitt'
11
+ import mitt from 'mitt'
12
+ import type { ITabItem } from '../model/TabModel'
13
+ import type { TabEventPayloadMap } from './useTabEventBus'
14
+ import { createTabPanelRuntimeState } from './tabPanel/state'
15
+ import type { ScrollPosition } from './tabPanel/state'
16
+
17
+ export const STACK_TABS_CONTEXT_UNAVAILABLE_MESSAGE =
18
+ 'VueStackTabs runtime context is not available. Render <VueStackTabs> before using vue-stack-tabs composables.'
19
+
20
+ export const STACK_TABS_DUPLICATE_INSTANCE_MESSAGE =
21
+ 'VueStackTabs only supports one <VueStackTabs> instance per Vue app.'
22
+
23
+ export interface StackTabsRuntimeContext {
24
+ tabs: Ref<ITabItem[]>
25
+ defaultTabs: Ref<ITabItem[]>
26
+ caches: ShallowRef<string[]>
27
+ components: Map<string, DefineComponent>
28
+ cacheIdsToEvict: Set<string>
29
+ tabIdsToEvict: Set<string>
30
+ refreshKey: Ref<number>
31
+ scrollPositionsByPageId: Map<string, Map<string, ScrollPosition>>
32
+ isInitialized: Ref<boolean>
33
+ iframeRefreshKeys: Ref<Record<string, number>>
34
+ maxTabCount: Ref<number>
35
+ useGlobalScroll: Ref<boolean>
36
+ sessionPrefix: Ref<string>
37
+ iframePath: Ref<string>
38
+ eventBus: Emitter<TabEventPayloadMap>
39
+ }
40
+
41
+ export interface CreateStackTabsRuntimeContextOptions {
42
+ iframePath?: string
43
+ maxTabCount?: number
44
+ useGlobalScroll?: boolean
45
+ sessionPrefix?: string
46
+ }
47
+
48
+ export interface RegisterStackTabsRuntimeContextOptions {
49
+ app?: App
50
+ isProduction?: boolean
51
+ }
52
+
53
+ export interface UnregisterStackTabsRuntimeContextOptions {
54
+ app?: App
55
+ }
56
+
57
+ export const stackTabsContextKey: InjectionKey<StackTabsRuntimeContext> = Symbol('stackTabsContext')
58
+ export const maximumKey: InjectionKey<Ref<boolean>> = Symbol('stackTabsMaximum')
59
+
60
+ let activeRuntimeContext: StackTabsRuntimeContext | null = null
61
+ let isFallbackRuntimeContextOwnerRegistered = false
62
+ const runtimeContextOwnersByApp = new WeakSet<App>()
63
+
64
+ /** 创建一个新的运行时上下文实例 */
65
+ export const createStackTabsRuntimeContext = (
66
+ options: CreateStackTabsRuntimeContextOptions = {}
67
+ ): StackTabsRuntimeContext => {
68
+ const state = createTabPanelRuntimeState()
69
+
70
+ return {
71
+ ...state,
72
+ maxTabCount: ref(options.maxTabCount ?? state.maxTabCount.value),
73
+ useGlobalScroll: ref(options.useGlobalScroll ?? state.useGlobalScroll.value),
74
+ sessionPrefix: ref(options.sessionPrefix ?? state.sessionPrefix.value),
75
+ iframePath: ref(options.iframePath ?? ''),
76
+ eventBus: mitt<TabEventPayloadMap>()
77
+ }
78
+ }
79
+
80
+ /** 获取当前活跃的运行时上下文(模块级单例) */
81
+ export const getActiveStackTabsRuntimeContext = (): StackTabsRuntimeContext | null =>
82
+ activeRuntimeContext
83
+
84
+ /** 注册运行时上下文,重复注册会在开发环境抛出错误 */
85
+ export const registerStackTabsRuntimeContext = (
86
+ context: StackTabsRuntimeContext,
87
+ options: RegisterStackTabsRuntimeContextOptions = {}
88
+ ): boolean => {
89
+ if (!activeRuntimeContext) {
90
+ activeRuntimeContext = context
91
+ }
92
+
93
+ if (options.app && !runtimeContextOwnersByApp.has(options.app)) {
94
+ runtimeContextOwnersByApp.add(options.app)
95
+ return true
96
+ }
97
+
98
+ if (
99
+ !options.app &&
100
+ activeRuntimeContext === context &&
101
+ !isFallbackRuntimeContextOwnerRegistered
102
+ ) {
103
+ isFallbackRuntimeContextOwnerRegistered = true
104
+ return true
105
+ }
106
+
107
+ const isProduction = options.isProduction ?? import.meta.env.PROD
108
+ if (isProduction) {
109
+ console.warn(STACK_TABS_DUPLICATE_INSTANCE_MESSAGE)
110
+ return false
111
+ }
112
+
113
+ throw new Error(STACK_TABS_DUPLICATE_INSTANCE_MESSAGE)
114
+ }
115
+
116
+ /** 注销运行时上下文,组件卸载时调用 */
117
+ export const unregisterStackTabsRuntimeContext = (
118
+ context: StackTabsRuntimeContext,
119
+ options: UnregisterStackTabsRuntimeContextOptions = {}
120
+ ): void => {
121
+ if (options.app) runtimeContextOwnersByApp.delete(options.app)
122
+ if (!options.app && activeRuntimeContext === context)
123
+ isFallbackRuntimeContextOwnerRegistered = false
124
+ if (activeRuntimeContext === context) activeRuntimeContext = null
125
+ }
126
+
127
+ /** 解析当前可用的运行时上下文,优先使用 inject,回退到模块单例 */
128
+ export const resolveStackTabsRuntimeContext = (): StackTabsRuntimeContext => {
129
+ const injected = hasInjectionContext() ? inject(stackTabsContextKey, null) : null
130
+ const context = injected ?? activeRuntimeContext
131
+
132
+ if (!context) throw new Error(STACK_TABS_CONTEXT_UNAVAILABLE_MESSAGE)
133
+
134
+ return context
135
+ }
@@ -1,104 +1,139 @@
1
1
  /**
2
2
  * tabPanel/evict - 缓存驱逐逻辑
3
3
  *
4
- * 职责:标记待驱逐缓存、执行驱逐、维护 caches / components
5
- *
6
- * 驱逐机制分两种:
7
- *
8
- * 1. 「标记 + 延迟驱逐」—— 用于关闭标签、刷新标签等场景
9
- * 调用 markCacheForEviction 将 cacheName 放入 cacheIdsToEvict 集合,
10
- * 等到目标页面的 cacheComponent 触发 onActivated 时,
11
- * 再批量调用 evictMarkedCaches 真正执行驱逐。
12
- * 该策略确保只有切换到新的活动页后才清理旧缓存,避免渲染空白。
13
- *
14
- * 2. 「立即驱逐」—— 用于 backward 内部退栈场景
15
- * 调用 evictPageCache 直接同步清除单个缓存,
16
- * 但必须在 router.push().then() 回调内调用(即导航完成后),
17
- * 否则会触发以旧路由为基础的响应式重渲染,导致刚驱逐的组件被重建。
18
- * 详见 useTabRouter.ts backward 函数的时序说明。
4
+ * 职责:基于当前 StackTabsRuntimeContext 标记待驱逐缓存、执行驱逐、维护 caches / components。
19
5
  */
20
6
  import type { ITabPage } from '../../model/TabModel'
21
- import { caches, components, cacheIdsToEvict, tabIdsToEvict } from './state'
7
+ import { triggerRef } from 'vue'
8
+ import type { StackTabsRuntimeContext } from '../stackTabsContext'
22
9
 
23
- /**
24
- * 将指定 cacheName 放入待驱逐集合。
25
- * 不立即清除,等叫到 evictMarkedCaches 时统一处理。
26
- */
27
- export const markCacheForEviction = (cacheName: string) => {
28
- cacheIdsToEvict.add(cacheName)
10
+ export interface TabPanelEvictionApi {
11
+ markCacheForEviction: (cacheName: string) => void
12
+ markTabPagesForEviction: (tab: { id: string; pages: { list(): ITabPage[] } }) => void
13
+ markTabPagesForEvictionOnly: (tab: { pages: { list(): ITabPage[] } }) => void
14
+ addCache: (cacheName: string) => void
15
+ removeCache: (cacheName: string) => void
16
+ evictPageCache: (cacheName: string) => void
17
+ replacePageCaches: (
18
+ cacheNamesToEvict: Iterable<string>,
19
+ cacheNamesToAdd: Iterable<string>
20
+ ) => void
21
+ evictMarkedCaches: () => void
29
22
  }
30
23
 
31
- /**
32
- * 标记某个标签下所有页面为待驱逐,并标记该标签本身(用于「关闭标签」场景)。
33
- * tabIdsToEvict 中的标签 ID 会在 updatePageState 中被检测到,
34
- * 阻止被关闭的标签重新路由时意外复活。
35
- */
36
- export const markTabPagesForEviction = (tab: { id: string; pages: { list(): ITabPage[] } }) => {
37
- for (const page of tab.pages.list()) markCacheForEviction(page.id)
38
- tabIdsToEvict.add(tab.id)
39
- }
24
+ export const createTabPanelEviction = (context: StackTabsRuntimeContext): TabPanelEvictionApi => {
25
+ const { caches, components, cacheIdsToEvict, tabIdsToEvict } = context
40
26
 
41
- /**
42
- * 仅标记标签下所有页面为待驱逐,不标记标签本身(用于「续签/换页」场景)。
43
- * 适用于刷新标签:旧页面缓存清零,标签本身仍然存活,
44
- * 导航后由 updatePageState 重新创建页面。
45
- */
46
- export const markTabPagesForEvictionOnly = (tab: { pages: { list(): ITabPage[] } }) => {
47
- for (const page of tab.pages.list()) markCacheForEviction(page.id)
48
- }
27
+ const cacheSet = new Set<string>(caches.value)
28
+ let cacheList = caches.value
49
29
 
50
- /**
51
- * 将 cacheName 加入 <keep-alive> 的 include 列表(caches)。
52
- * 幂等:已存在时不重复添加,避免 include 数组中出现重复项
53
- * 影响 <keep-alive> 缓存范围,决定哪些 cacheComponent 实例会被保留。
54
- */
55
- export const addCache = (cacheName: string) => {
56
- if (!caches.value.includes(cacheName)) caches.value.push(cacheName)
57
- }
30
+ /** 外部重置 caches(例如 destroy)后,同步内部 Set,避免后续 addCache 被错误跳过。 */
31
+ const syncCacheSet = () => {
32
+ if (cacheList === caches.value) return
33
+ cacheList = caches.value
34
+ cacheSet.clear()
35
+ for (const cacheName of cacheList) cacheSet.add(cacheName)
36
+ }
58
37
 
59
- /**
60
- * 将 cacheName 从 include 列表(caches)中移除。
61
- * Vue 的 <keep-alive> 会以 flush:'post' 的 watcher 检测 include 变更,
62
- * 在下次渲染后自动销毁不再被 include 的缓存实例。
63
- */
64
- export const removeCache = (cacheName: string) => {
65
- const i = caches.value.indexOf(cacheName)
66
- if (i >= 0) caches.value.splice(i, 1)
67
- }
38
+ const markCacheForEviction = (cacheName: string): void => {
39
+ cacheIdsToEvict.add(cacheName)
40
+ }
68
41
 
69
- /**
70
- * 立即、同步、彻底驱逐单个页面的缓存。
71
- *
72
- * 操作步骤:
73
- * 1. 从 cacheIdsToEvict 移除(若存在,防止后续 evictMarkedCaches 二次处理)
74
- * 2. removeCache(cacheName) → 从 <keep-alive> include 列表删除,触发 Vue 销毁缓存组件实例
75
- * 3. components.delete(...) → 从 components Map 删除组件定义,
76
- * 使 resolvePageComponent 下次进入时必须重新创建全新的 cacheComponent 实例
77
- *
78
- * ⚠️ 调用时机要求:
79
- * 必须在 router.push() 完成(.then())之后调用,
80
- * 不能在导航前调用,否则驱逐触发的响应式更新会导致 StackTabs 以旧路由重渲染,
81
- * 进而重建刚被删除的组件(竞态)。详见 useTabRouter.ts backward 的注释。
82
- */
83
- export const evictPageCache = (cacheName: string) => {
84
- if (!cacheName) return
85
- cacheIdsToEvict.delete(cacheName)
86
- removeCache(cacheName)
87
- components.delete(cacheName)
88
- }
42
+ const markTabPagesForEviction = (tab: { id: string; pages: { list(): ITabPage[] } }): void => {
43
+ for (const page of tab.pages.list()) markCacheForEviction(page.id)
44
+ tabIdsToEvict.add(tab.id)
45
+ }
89
46
 
90
- /**
91
- * 执行所有已标记缓存的批量驱逐(由 cacheComponent 的 onActivated 调用)。
92
- *
93
- * 调用时机:
94
- * 当 cacheComponent 切换到激活(activated)状态时,说明用户切换到了一个新页面,
95
- * 相关的旧页面已经稳定进入「不活跃/待驱逐」状态,此时批量清理是安全的。
96
- */
97
- export const evictMarkedCaches = () => {
98
- if (cacheIdsToEvict.size > 0) {
99
- for (const cacheName of [...cacheIdsToEvict]) {
100
- evictPageCache(cacheName)
47
+ const markTabPagesForEvictionOnly = (tab: { pages: { list(): ITabPage[] } }): void => {
48
+ for (const page of tab.pages.list()) markCacheForEviction(page.id)
49
+ }
50
+
51
+ const addCache = (cacheName: string): void => {
52
+ syncCacheSet()
53
+ if (!cacheSet.has(cacheName)) {
54
+ cacheSet.add(cacheName)
55
+ cacheList.push(cacheName)
56
+ triggerRef(caches)
57
+ }
58
+ }
59
+
60
+ const removeCache = (cacheName: string): void => {
61
+ syncCacheSet()
62
+ if (cacheSet.has(cacheName)) {
63
+ cacheSet.delete(cacheName)
64
+ const index = cacheList.indexOf(cacheName)
65
+ if (index >= 0) cacheList.splice(index, 1)
66
+ triggerRef(caches)
67
+ }
68
+ }
69
+
70
+ const evictPageCache = (cacheName: string): void => {
71
+ if (!cacheName) return
72
+ cacheIdsToEvict.delete(cacheName)
73
+ removeCache(cacheName)
74
+ components.delete(cacheName)
75
+ }
76
+
77
+ /** 批量替换缓存 id,仅在所有变更完成后通知一次,供刷新全部等操作使用。 */
78
+ const replacePageCaches = (
79
+ cacheNamesToEvict: Iterable<string>,
80
+ cacheNamesToAdd: Iterable<string>
81
+ ): void => {
82
+ syncCacheSet()
83
+ const toEvict = new Set(cacheNamesToEvict)
84
+ let changed = false
85
+
86
+ if (toEvict.size > 0) {
87
+ for (let index = cacheList.length - 1; index >= 0; index--) {
88
+ const cacheName = cacheList[index]
89
+ if (cacheName && toEvict.has(cacheName)) {
90
+ cacheList.splice(index, 1)
91
+ changed = true
92
+ }
93
+ }
94
+ for (const cacheName of toEvict) {
95
+ cacheSet.delete(cacheName)
96
+ cacheIdsToEvict.delete(cacheName)
97
+ components.delete(cacheName)
98
+ }
99
+ }
100
+
101
+ for (const cacheName of cacheNamesToAdd) {
102
+ if (!cacheSet.has(cacheName)) {
103
+ cacheSet.add(cacheName)
104
+ cacheList.push(cacheName)
105
+ changed = true
106
+ }
107
+ }
108
+
109
+ if (changed) triggerRef(caches)
110
+ }
111
+
112
+ const evictMarkedCaches = (): void => {
113
+ if (cacheIdsToEvict.size <= 0) return
114
+
115
+ syncCacheSet()
116
+ const toEvict = new Set(cacheIdsToEvict)
117
+ for (let index = cacheList.length - 1; index >= 0; index--) {
118
+ const cacheName = cacheList[index]
119
+ if (cacheName && toEvict.has(cacheName)) cacheList.splice(index, 1)
101
120
  }
121
+ for (const cacheName of toEvict) {
122
+ cacheSet.delete(cacheName)
123
+ components.delete(cacheName)
124
+ }
125
+ triggerRef(caches)
102
126
  cacheIdsToEvict.clear()
103
127
  }
128
+
129
+ return {
130
+ markCacheForEviction,
131
+ markTabPagesForEviction,
132
+ markTabPagesForEvictionOnly,
133
+ addCache,
134
+ removeCache,
135
+ evictPageCache,
136
+ replacePageCaches,
137
+ evictMarkedCaches
138
+ }
104
139
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 导航事务管理器。
3
+ *
4
+ * 将「乐观 UI 更新 → 路由导航 → 确认/回滚」包装为原子事务:
5
+ * 1. apply() — 先对 UI 状态做乐观修改,返回快照
6
+ * 2. navigate() — 执行 router.push 等异步导航
7
+ * 3. 成功 → commit();失败/异常 → rollback(snapshot) 恢复快照
8
+ *
9
+ * 当连续快速操作导致事务过期时(isCurrent 返回 false),
10
+ * 不回滚而是调用 cleanupStale() 清理残留状态,避免覆盖新事务的结果。
11
+ */
12
+ interface NavigationTransactionOptions<TSnapshot, TResult> {
13
+ /** 乐观应用 UI 变更,返回可用于 rollback 的快照 */
14
+ apply: () => TSnapshot
15
+ /** 执行异步路由导航 */
16
+ navigate: () => Promise<TResult>
17
+ /** 导航失败时回滚到快照状态 */
18
+ rollback: (snapshot: TSnapshot) => void
19
+ /** 导航成功后的确认回调(如驱逐旧缓存) */
20
+ commit?: () => void
21
+ /** 判断导航结果是否为失败(如 vue-router 返回的 NavigationFailure) */
22
+ isFailureResult?: (result: TResult) => boolean
23
+ /** 若为失败结果,是否将其作为异常抛出 */
24
+ rejectFailureResult?: boolean
25
+ /** 检查当前事务是否仍然有效(防止过期事务覆盖新状态) */
26
+ isCurrent?: () => boolean
27
+ /** 事务已过期时的清理回调(替代 rollback) */
28
+ cleanupStale?: () => void
29
+ }
30
+
31
+ /**
32
+ * 执行一次导航事务:apply → navigate → commit/rollback。
33
+ * 保证乐观更新在导航失败时能安全回滚。
34
+ */
35
+ export const runNavigationTransaction = async <TSnapshot, TResult>(
36
+ options: NavigationTransactionOptions<TSnapshot, TResult>
37
+ ): Promise<TResult> => {
38
+ const snapshot = options.apply()
39
+
40
+ try {
41
+ const result = await options.navigate()
42
+ if (options.isFailureResult?.(result)) {
43
+ if (options.isCurrent?.() ?? true) {
44
+ options.rollback(snapshot)
45
+ } else {
46
+ options.cleanupStale?.()
47
+ }
48
+ if (options.rejectFailureResult) throw result
49
+ return result
50
+ }
51
+ if (options.isCurrent?.() ?? true) options.commit?.()
52
+ return result
53
+ } catch (error) {
54
+ if (options.isCurrent?.() ?? true) {
55
+ options.rollback(snapshot)
56
+ } else {
57
+ options.cleanupStale?.()
58
+ }
59
+ throw error
60
+ }
61
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * tabPanel/refresh - 普通页面与 iframe 标签的刷新逻辑。
3
+ *
4
+ * 活动普通页面保持 cache id 稳定,只递增页面级刷新版本;非活动页面可直接替换缓存。
5
+ */
6
+ import type { Ref } from 'vue'
7
+ import type { ITabItem } from '../../model/TabModel'
8
+
9
+ export interface TabPanelRefreshApi {
10
+ refreshTab: (id: string) => void
11
+ refreshAllTabs: () => void
12
+ }
13
+
14
+ export interface CreateTabPanelRefreshOptions {
15
+ tabs: Ref<ITabItem[]>
16
+ iframeRefreshKeys: Ref<Record<string, number>>
17
+ getTab: (id: string) => ITabItem | null
18
+ createPageId: () => string
19
+ addCache: (cacheName: string) => void
20
+ evictPageCache: (cacheName: string) => void
21
+ replacePageCaches: (
22
+ cacheNamesToEvict: Iterable<string>,
23
+ cacheNamesToAdd: Iterable<string>
24
+ ) => void
25
+ refreshIframeByPostMessage: (tabId: string) => void
26
+ }
27
+
28
+ export const createTabPanelRefresh = (
29
+ options: CreateTabPanelRefreshOptions
30
+ ): TabPanelRefreshApi => {
31
+ const {
32
+ tabs,
33
+ iframeRefreshKeys,
34
+ getTab,
35
+ createPageId,
36
+ addCache,
37
+ evictPageCache,
38
+ replacePageCaches,
39
+ refreshIframeByPostMessage
40
+ } = options
41
+
42
+ const refreshIframe = (tab: ITabItem) => {
43
+ if (tab.iframeRefreshMode === 'reload') {
44
+ iframeRefreshKeys.value = {
45
+ ...iframeRefreshKeys.value,
46
+ [tab.id]: (iframeRefreshKeys.value[tab.id] ?? 0) + 1
47
+ }
48
+ return
49
+ }
50
+ refreshIframeByPostMessage(tab.id)
51
+ }
52
+
53
+ const refreshPage = (tab: ITabItem, cacheNamesToEvict?: string[], cacheNamesToAdd?: string[]) => {
54
+ const currentPage = tab.pages.peek()
55
+ if (!currentPage) return
56
+
57
+ if (tab.active) {
58
+ currentPage.refreshVersion = (currentPage.refreshVersion ?? 0) + 1
59
+ return
60
+ }
61
+
62
+ const oldCacheName = currentPage.id
63
+ currentPage.id = createPageId()
64
+ currentPage.refreshVersion = 0
65
+ if (cacheNamesToEvict && cacheNamesToAdd) {
66
+ cacheNamesToEvict.push(oldCacheName)
67
+ cacheNamesToAdd.push(currentPage.id)
68
+ return
69
+ }
70
+ evictPageCache(oldCacheName)
71
+ addCache(currentPage.id)
72
+ }
73
+
74
+ const refreshTab = (id: string) => {
75
+ const tab = getTab(id)
76
+ if (!tab?.refreshable) return
77
+ if (tab.iframe) {
78
+ refreshIframe(tab)
79
+ return
80
+ }
81
+ refreshPage(tab)
82
+ }
83
+
84
+ const refreshAllTabs = () => {
85
+ const cacheNamesToEvict: string[] = []
86
+ const cacheNamesToAdd: string[] = []
87
+ const iframeKeysUpdate = { ...iframeRefreshKeys.value }
88
+ let iframeKeysChanged = false
89
+
90
+ for (const tab of tabs.value) {
91
+ if (!tab.refreshable) continue
92
+ if (tab.iframe) {
93
+ if (tab.iframeRefreshMode === 'reload') {
94
+ iframeKeysUpdate[tab.id] = (iframeKeysUpdate[tab.id] ?? 0) + 1
95
+ iframeKeysChanged = true
96
+ } else {
97
+ refreshIframeByPostMessage(tab.id)
98
+ }
99
+ continue
100
+ }
101
+ refreshPage(tab, cacheNamesToEvict, cacheNamesToAdd)
102
+ }
103
+
104
+ if (iframeKeysChanged) iframeRefreshKeys.value = iframeKeysUpdate
105
+ replacePageCaches(cacheNamesToEvict, cacheNamesToAdd)
106
+ }
107
+
108
+ return { refreshTab, refreshAllTabs }
109
+ }
@@ -1,56 +1,73 @@
1
1
  /**
2
2
  * tabPanel/scroll - 滚动位置保存与恢复
3
3
  *
4
- * 职责:在切出/切入页面时保存和恢复滚动位置
4
+ * 职责:基于当前 StackTabsRuntimeContext 在切出/切入页面时保存和恢复滚动位置。
5
5
  */
6
- import { scrollPositionsByPageId } from './state'
7
-
8
- /** 恢复指定页面的滚动位置 */
9
- export const restoreScroller = (pageCacheId: string) => {
10
- const positions = scrollPositionsByPageId.get(pageCacheId)
11
- if (!positions) return
12
- for (const [sel, pos] of positions) {
13
- const el = sel.startsWith('#')
14
- ? document.getElementById(sel.slice(1))
15
- : (document.querySelector(sel) as HTMLElement | null)
16
- if (el) {
17
- el.scrollTop = pos.top
18
- el.scrollLeft = pos.left
6
+ import type { StackTabsRuntimeContext } from '../stackTabsContext'
7
+
8
+ export interface TabPanelScrollApi {
9
+ restoreScroller: (pageCacheId: string) => void
10
+ saveScroller: (pageCacheId: string) => void
11
+ removeScroller: (pageCacheId: string) => void
12
+ addPageScroller: (pageCacheId: string, ...selectorIds: string[]) => void
13
+ }
14
+
15
+ const resolveScrollElement = (selector: string): HTMLElement | null =>
16
+ selector.startsWith('#')
17
+ ? document.getElementById(selector.slice(1))
18
+ : (document.querySelector(selector) as HTMLElement | null)
19
+
20
+ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanelScrollApi => {
21
+ const { scrollPositionsByPageId } = context
22
+
23
+ const restoreScroller = (pageCacheId: string): void => {
24
+ const positions = scrollPositionsByPageId.get(pageCacheId)
25
+ if (!positions) return
26
+
27
+ for (const [selector, position] of positions) {
28
+ const element = resolveScrollElement(selector)
29
+ if (element) {
30
+ element.scrollTop = position.top
31
+ element.scrollLeft = position.left
32
+ }
19
33
  }
20
34
  }
21
- }
22
35
 
23
- /** 保存指定页面的滚动位置 */
24
- export const saveScroller = (pageCacheId: string) => {
25
- const positions = scrollPositionsByPageId.get(pageCacheId)
26
- if (!positions) return
27
- for (const sel of positions.keys()) {
28
- const el = sel.startsWith('#')
29
- ? document.getElementById(sel.slice(1))
30
- : (document.querySelector(sel) as HTMLElement | null)
31
- positions.set(sel, {
32
- top: el?.scrollTop ?? 0,
33
- left: el?.scrollLeft ?? 0
34
- })
36
+ const saveScroller = (pageCacheId: string): void => {
37
+ const positions = scrollPositionsByPageId.get(pageCacheId)
38
+ if (!positions) return
39
+
40
+ for (const selector of positions.keys()) {
41
+ const element = resolveScrollElement(selector)
42
+ positions.set(selector, {
43
+ top: element?.scrollTop ?? 0,
44
+ left: element?.scrollLeft ?? 0
45
+ })
46
+ }
35
47
  }
36
- }
37
48
 
38
- /** 移除指定页面的滚动记录 */
39
- export const removeScroller = (pageCacheId: string) => {
40
- const positions = scrollPositionsByPageId.get(pageCacheId)
41
- if (positions) {
49
+ const removeScroller = (pageCacheId: string): void => {
50
+ const positions = scrollPositionsByPageId.get(pageCacheId)
51
+ if (!positions) return
52
+
42
53
  positions.clear()
43
54
  scrollPositionsByPageId.delete(pageCacheId)
44
55
  }
45
- }
46
56
 
47
- /** 为页面注册需要记录滚动位置的选择器 */
48
- export const addPageScroller = (pageCacheId: string, ...selectorIds: string[]) => {
49
- if (!scrollPositionsByPageId.has(pageCacheId)) {
50
- scrollPositionsByPageId.set(pageCacheId, new Map<string, { top: number; left: number }>())
57
+ const addPageScroller = (pageCacheId: string, ...selectorIds: string[]): void => {
58
+ if (!scrollPositionsByPageId.has(pageCacheId)) {
59
+ scrollPositionsByPageId.set(pageCacheId, new Map())
60
+ }
61
+ const positions = scrollPositionsByPageId.get(pageCacheId)!
62
+ for (const selector of selectorIds) {
63
+ positions.set(selector, { top: 0, left: 0 })
64
+ }
51
65
  }
52
- const positions = scrollPositionsByPageId.get(pageCacheId)!
53
- for (const sel of selectorIds) {
54
- positions.set(sel, { top: 0, left: 0 })
66
+
67
+ return {
68
+ restoreScroller,
69
+ saveScroller,
70
+ removeScroller,
71
+ addPageScroller
55
72
  }
56
73
  }