vue-stack-tabs 0.1.51 → 1.0.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 (94) hide show
  1. package/README.md +717 -3
  2. package/dist/index.d.ts +3 -0
  3. package/dist/src/lib/StackTabs.vue.d.ts +33 -161
  4. package/dist/src/lib/components/ContextMenu/ContextMenuItem.vue.d.ts +6 -6
  5. package/dist/src/lib/components/ContextMenu/index.vue.d.ts +14 -14
  6. package/dist/src/lib/components/PageLoading.vue.d.ts +5 -5
  7. package/dist/src/lib/components/TabHeader/TabHeaderButton.vue.d.ts +24 -15
  8. package/dist/src/lib/components/TabHeader/TabHeaderItem.vue.d.ts +10 -8
  9. package/dist/src/lib/components/TabHeader/TabHeaderScroll.vue.d.ts +31 -13
  10. package/dist/src/lib/components/TabHeader/index.vue.d.ts +172 -20
  11. package/dist/src/lib/hooks/tabPanel/evict.d.ts +24 -0
  12. package/dist/src/lib/hooks/tabPanel/evict.spec.d.ts +1 -0
  13. package/dist/src/lib/hooks/tabPanel/scroll.d.ts +8 -0
  14. package/dist/src/lib/hooks/tabPanel/session.d.ts +9 -0
  15. package/dist/src/lib/hooks/tabPanel/state.d.ts +77 -0
  16. package/dist/src/lib/hooks/useContextMenu.d.ts +6 -1
  17. package/dist/src/lib/hooks/useTabActions.d.ts +68 -0
  18. package/dist/src/lib/hooks/{useTabMitt.d.ts → useTabEventBus.d.ts} +4 -3
  19. package/dist/src/lib/hooks/useTabLoading.d.ts +6 -0
  20. package/dist/src/lib/hooks/{useTabpanel.d.ts → useTabPanel.d.ts} +20 -14
  21. package/dist/src/lib/hooks/useTabPanel.spec.d.ts +1 -0
  22. package/dist/src/lib/hooks/useTabRouter.d.ts +14 -5
  23. package/dist/src/lib/i18n/index.d.ts +1 -1
  24. package/dist/src/lib/i18n/lang/en.d.ts +1 -0
  25. package/dist/src/lib/i18n/lang/zh-CN.d.ts +1 -0
  26. package/dist/src/lib/index.d.ts +5 -4
  27. package/dist/src/lib/model/TabModel.d.ts +20 -3
  28. package/dist/src/lib/model/TabModel.spec.d.ts +1 -0
  29. package/dist/src/lib/utils/iframeBridge.d.ts +31 -0
  30. package/dist/src/lib/utils/iframeBridge.spec.d.ts +1 -0
  31. package/dist/src/lib/utils/scrollUtils.d.ts +28 -0
  32. package/dist/src/lib/utils/tabInfoEncoder.d.ts +19 -0
  33. package/dist/src/lib/utils/tabInfoEncoder.spec.d.ts +1 -0
  34. package/dist/src/lib/utils/urlParser.d.ts +19 -0
  35. package/dist/src/lib/utils/urlParser.spec.d.ts +1 -0
  36. package/dist/src/lib/versionLogger.d.ts +2 -0
  37. package/dist/style.css +1 -1
  38. package/dist/vue-stack-tabs.css +1 -0
  39. package/dist/vue-stack-tabs.es.js +1795 -1679
  40. package/dist/vue-stack-tabs.umd.js +1 -5
  41. package/package.json +76 -39
  42. package/src/lib/StackTabs.vue +222 -47
  43. package/src/lib/assets/image/open.svg +1 -0
  44. package/src/lib/assets/style/common/var.scss +1 -1
  45. package/src/lib/assets/style/icon.scss +6 -0
  46. package/src/lib/assets/style/stackTab.scss +46 -3
  47. package/src/lib/assets/style/transition.scss +63 -62
  48. package/src/lib/components/ContextMenu/ContextMenuItem.vue +7 -0
  49. package/src/lib/components/ContextMenu/index.vue +50 -38
  50. package/src/lib/components/PageLoading.vue +30 -10
  51. package/src/lib/components/TabHeader/TabHeaderButton.vue +12 -3
  52. package/src/lib/components/TabHeader/TabHeaderItem.vue +31 -27
  53. package/src/lib/components/TabHeader/TabHeaderScroll.vue +83 -77
  54. package/src/lib/components/TabHeader/index.vue +45 -40
  55. package/src/lib/hooks/tabPanel/evict.spec.ts +85 -0
  56. package/src/lib/hooks/tabPanel/evict.ts +57 -0
  57. package/src/lib/hooks/tabPanel/scroll.ts +52 -0
  58. package/src/lib/hooks/tabPanel/session.ts +33 -0
  59. package/src/lib/hooks/tabPanel/state.ts +52 -0
  60. package/src/lib/hooks/useContextMenu.ts +22 -20
  61. package/src/lib/hooks/useTabActions.ts +148 -0
  62. package/src/lib/hooks/useTabEventBus.ts +31 -0
  63. package/src/lib/hooks/useTabLoading.ts +12 -4
  64. package/src/lib/hooks/useTabPanel.spec.ts +34 -0
  65. package/src/lib/hooks/useTabPanel.tsx +546 -0
  66. package/src/lib/hooks/useTabRouter.ts +114 -103
  67. package/src/lib/i18n/index.ts +14 -18
  68. package/src/lib/i18n/lang/en.ts +2 -1
  69. package/src/lib/i18n/lang/zh-CN.ts +2 -1
  70. package/src/lib/iframe.vue +6 -0
  71. package/src/lib/index.ts +27 -8
  72. package/src/lib/model/TabModel.spec.ts +40 -0
  73. package/src/lib/model/TabModel.ts +22 -9
  74. package/src/lib/nuxt/module.d.ts +19 -0
  75. package/src/lib/nuxt/module.ts +34 -0
  76. package/src/lib/nuxt/runtime/plugin.client.ts +11 -0
  77. package/src/lib/utils/iframeBridge.spec.ts +37 -0
  78. package/src/lib/utils/iframeBridge.ts +52 -0
  79. package/src/lib/utils/{TabScrollHelper.ts → scrollUtils.ts} +23 -29
  80. package/src/lib/utils/tabInfoEncoder.spec.ts +42 -0
  81. package/src/lib/utils/tabInfoEncoder.ts +65 -0
  82. package/src/lib/utils/urlParser.spec.ts +45 -0
  83. package/src/lib/utils/urlParser.ts +42 -0
  84. package/src/lib/{banner.ts → versionLogger.ts} +4 -5
  85. package/dist/src/lib/banner.d.ts +0 -2
  86. package/dist/src/lib/hooks/useStackTab.d.ts +0 -11
  87. package/dist/src/lib/utils/TabIdHelper.d.ts +0 -11
  88. package/dist/src/lib/utils/TabScrollHelper.d.ts +0 -33
  89. package/dist/src/lib/utils/UriHelper.d.ts +0 -4
  90. package/src/lib/hooks/useStackTab.ts +0 -119
  91. package/src/lib/hooks/useTabMitt.ts +0 -30
  92. package/src/lib/hooks/useTabpanel.tsx +0 -708
  93. package/src/lib/utils/TabIdHelper.ts +0 -70
  94. package/src/lib/utils/UriHelper.ts +0 -22
@@ -0,0 +1,31 @@
1
+ import type { Emitter, EventType } from 'mitt'
2
+ import mitt from 'mitt'
3
+ import { type App, inject } from 'vue'
4
+
5
+ declare module '@vue/runtime-core' {
6
+ export interface ComponentCustomProperties {
7
+ $layerEmitter: Emitter<Record<EventType, unknown>>
8
+ }
9
+ }
10
+
11
+ /** 全局标签事件总线,用于跨组件通信(激活、加载等) */
12
+ const eventBus = mitt<Record<EventType, unknown>>()
13
+
14
+ const plugin = {
15
+ install(app: App) {
16
+ app.provide('tabEmitter', eventBus)
17
+ }
18
+ }
19
+
20
+ /** 获取标签事件总线,用于组件间通信 */
21
+ export const useTabEmitter = <T extends Record<EventType, unknown>>(): Emitter<T> => {
22
+ return inject('tabEmitter')!
23
+ }
24
+
25
+ /** 标签相关事件类型 */
26
+ export enum TabEventType {
27
+ PAGE_LOADING = 'PAGE_LOADING',
28
+ TAB_ACTIVE = 'TAB_ACTIVE'
29
+ }
30
+
31
+ export default plugin
@@ -1,18 +1,26 @@
1
1
  import { type ComponentInternalInstance, getCurrentInstance, onUnmounted } from 'vue'
2
- import { MittType, useEmitter } from '../hooks/useTabMitt'
2
+ import { TabEventType, useTabEmitter } from '../hooks/useTabEventBus'
3
3
 
4
+ /**
5
+ * useTabLoading - 标签内页面加载状态 Hook
6
+ *
7
+ * 职责:通过 PAGE_LOADING 事件控制 PageLoading 组件的显示/隐藏
8
+ * 使用范围:标签内的页面组件(需有 tId 通过 attrs 传入)
9
+ */
4
10
  export default () => {
5
- const emitter = useEmitter()
11
+ const emitter = useTabEmitter()
6
12
  const { attrs } = getCurrentInstance() as ComponentInternalInstance
7
13
  onUnmounted(() => {
8
14
  closeTabLoading()
9
15
  })
16
+ /** 显示加载遮罩 */
10
17
  const openTabLoading = () => {
11
- emitter.emit(MittType.PAGE_LOADING, { tId: attrs.tId, value: true })
18
+ emitter.emit(TabEventType.PAGE_LOADING, { tId: attrs.tId, value: true })
12
19
  }
13
20
 
21
+ /** 隐藏加载遮罩 */
14
22
  const closeTabLoading = () => {
15
- emitter.emit(MittType.PAGE_LOADING, { tId: attrs.tId, value: false })
23
+ emitter.emit(TabEventType.PAGE_LOADING, { tId: attrs.tId, value: false })
16
24
  }
17
25
  return { openTabLoading, closeTabLoading }
18
26
  }
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { normalizePathForCache } from './useTabPanel'
3
+ import type { RouteLocationNormalizedLoaded } from 'vue-router'
4
+
5
+ function mockRoute(path: string, matchedPath?: string): RouteLocationNormalizedLoaded {
6
+ return {
7
+ path,
8
+ matched: [{ path: matchedPath ?? path }] as RouteLocationNormalizedLoaded['matched'],
9
+ query: {},
10
+ params: {},
11
+ hash: '',
12
+ fullPath: path,
13
+ name: undefined,
14
+ meta: {},
15
+ redirectedFrom: undefined
16
+ }
17
+ }
18
+
19
+ describe('normalizePathForCache', () => {
20
+ it('当 matched.path 无尾斜杠且 route.path 有尾斜杠时,返回规范化 path', () => {
21
+ const r = mockRoute('/dashboard/', '/dashboard')
22
+ expect(normalizePathForCache(r)).toBe('/dashboard')
23
+ })
24
+
25
+ it('路径与 matched 一致时返回规范化 path', () => {
26
+ const r = mockRoute('/home', '/home')
27
+ expect(normalizePathForCache(r)).toBe('/home')
28
+ })
29
+
30
+ it('路径与 matched 不一致时返回原 path', () => {
31
+ const r = mockRoute('/detail/1', '/detail/:id')
32
+ expect(normalizePathForCache(r)).toBe('/detail/1')
33
+ })
34
+ })
@@ -0,0 +1,546 @@
1
+ /**
2
+ * useTabPanel - 标签页核心 Hook
3
+ *
4
+ * 职责:
5
+ * 1. 管理标签列表 (tabs)、每个标签内的页面栈 (pages)
6
+ * 2. 管理 keep-alive 的缓存列表 (caches)、组件映射 (components)
7
+ * 3. 处理关闭/出栈时的缓存驱逐(标记 + 延迟执行)
8
+ * 4. 处理刷新(通过 exclude 触发重挂载,不销毁组件定义)
9
+ * 5. 滚动位置保存与恢复
10
+ * 6. Session 持久化(刷新后恢复上次激活的标签)
11
+ *
12
+ * 使用范围:内部 hook,由 StackTabs、useTabActions、useTabRouter、ContextMenu 调用
13
+ */
14
+ import type { ITabBase, ITabData, ITabItem, ITabPage } from '../model/TabModel'
15
+ import { defineComponent, onActivated, onDeactivated, onMounted, onUnmounted } from 'vue'
16
+ import type { DefineComponent, VNode } from 'vue'
17
+ import { useRouter } from 'vue-router'
18
+ import type { RouteLocationNormalizedLoaded } from 'vue-router'
19
+ import { ulid } from 'ulid'
20
+ import { encodeTabInfo, createPageId, decodeTabInfo } from '../utils/tabInfoEncoder'
21
+ import { defu } from 'defu'
22
+ import { Stack } from '../model/TabModel'
23
+ import { parseUrl, isCrossOriginUrl } from '../utils/urlParser'
24
+ import PageLoading from '../components/PageLoading.vue'
25
+ import { TabEventType, useTabEmitter } from './useTabEventBus'
26
+ import { useI18n } from 'vue-i18n-lite'
27
+
28
+ import {
29
+ tabs,
30
+ defaultTabs,
31
+ caches,
32
+ components,
33
+ tabIdsToEvict,
34
+ refreshKey,
35
+ excludedCacheIdsForRefresh,
36
+ iframeRefreshKeys,
37
+ isInitialized,
38
+ maxTabCount,
39
+ useGlobalScroll,
40
+ cacheIdsToEvict,
41
+ setMaxTabCount,
42
+ setUseGlobalScroll,
43
+ setSessionPrefix
44
+ } from './tabPanel/state'
45
+ import {
46
+ markCacheForEviction,
47
+ markTabPagesForEviction,
48
+ markTabPagesForEvictionOnly,
49
+ addCache,
50
+ removeCache,
51
+ evictMarkedCaches
52
+ } from './tabPanel/evict'
53
+ import { restoreScroller, saveScroller, removeScroller, addPageScroller } from './tabPanel/scroll'
54
+ import {
55
+ saveActiveTabToSession,
56
+ clearSession,
57
+ restoreTabFromSession,
58
+ getSessionKey
59
+ } from './tabPanel/session'
60
+
61
+ /* eslint-disable vue/one-component-per-file */
62
+ /** 占位组件:当路由组件为空或标签已被标记删除时返回 */
63
+ const EmptyPlaceholderComponent = defineComponent({
64
+ name: 'StackTabEmptyPlaceholder',
65
+ setup() {
66
+ return () => null
67
+ }
68
+ })
69
+
70
+ /** 规范化路径(nginx 等可能加尾部斜杠) */
71
+ export const normalizePathForCache = (route: RouteLocationNormalizedLoaded): string => {
72
+ const lastMatch = route.matched[route.matched.length - 1]
73
+ const matchPath = lastMatch?.path ?? route.path
74
+ const path = route.path.endsWith('/') ? route.path.slice(0, -1) : route.path
75
+ return matchPath === path ? path : route.path
76
+ }
77
+
78
+ export default () => {
79
+ const router = useRouter()
80
+ const emitter = useTabEmitter()
81
+ const { t } = useI18n()
82
+
83
+ const size = (): number => tabs.value.length
84
+
85
+ /**
86
+ * 初始化:根据 defaultTabs 创建标签与初始页面,并尝试从 sessionStorage 恢复上次激活的标签
87
+ * @param staticTabs - 初始标签配置
88
+ */
89
+ const initialize = (staticTabs: ITabData[]) => {
90
+ for (const item of staticTabs) {
91
+ const fullItem = defu(item, { id: ulid(), refreshable: true, closable: true, iframe: false })
92
+ const tabId = fullItem.id ?? ulid()
93
+ fullItem.id = tabId
94
+ const uri = parseUrl(fullItem.path)
95
+ const config = defu(fullItem, {
96
+ closable: true,
97
+ refreshable: true,
98
+ iframe: false
99
+ })
100
+ const cacheName = createPageId(tabId, uri.path, uri.query)
101
+ const page: ITabPage = {
102
+ id: cacheName,
103
+ tabId,
104
+ path: uri.path,
105
+ query: defu(uri.query, { __tab: encodeTabInfo(fullItem) })
106
+ }
107
+ const pages = new Stack<ITabPage>()
108
+ pages.push(page)
109
+ const tab: ITabItem = {
110
+ id: tabId,
111
+ title: config.title ?? '',
112
+ closable: config.closable,
113
+ refreshable: config.refreshable,
114
+ iframe: config.iframe,
115
+ iframeRefreshMode: (config as { iframeRefreshMode?: string }).iframeRefreshMode === 'reload' ? 'reload' : 'postMessage',
116
+ active: false,
117
+ pages
118
+ }
119
+
120
+ addCache(cacheName)
121
+ defaultTabs.push(tab)
122
+ tabs.value.push({ ...tab })
123
+ }
124
+
125
+ const storedTabJson = sessionStorage.getItem(getSessionKey())
126
+ const restoredTab = restoreTabFromSession(storedTabJson)
127
+ if (restoredTab && !tabs.value.some((t) => t.id === restoredTab!.id)) {
128
+ if (restoredTab.iframe && restoredTab.url && restoredTab.iframeRefreshMode === undefined) {
129
+ restoredTab.iframeRefreshMode = isCrossOriginUrl(restoredTab.url)
130
+ ? 'reload'
131
+ : 'postMessage'
132
+ }
133
+ tabs.value.push(restoredTab)
134
+ }
135
+ isInitialized.value = true
136
+ }
137
+
138
+ const hasTab = (id: string) => tabs.value.some((t) => t.id === id)
139
+ const canAddTab = () =>
140
+ maxTabCount <= 0 || (maxTabCount > 0 && maxTabCount > tabs.value.length)
141
+
142
+ const addTab = (tab: ITabItem) => {
143
+ tabs.value.push(tab)
144
+ return Promise.resolve(true)
145
+ }
146
+
147
+ const getTab = (id: string) => tabs.value.find((t) => t.id === id) ?? null
148
+
149
+ /** 从路由解析 tabInfo,若无则创建默认并写入 query */
150
+ const parseTabInfoFromRoute = (route: RouteLocationNormalizedLoaded): ITabBase => {
151
+ if (route.query.__tab) return decodeTabInfo(route.query.__tab as string)
152
+ const tabInfo: ITabBase = {
153
+ id: ulid(),
154
+ title: t('VueStackTab.undefined'),
155
+ closable: true,
156
+ refreshable: true,
157
+ iframe: false
158
+ }
159
+ route.query.__tab = encodeTabInfo(tabInfo)
160
+ return tabInfo
161
+ }
162
+
163
+ /** 查找或创建目标 tab,同步 active 并添加 page */
164
+ const findOrCreateTargetTab = (
165
+ tabInfo: ITabBase,
166
+ route: RouteLocationNormalizedLoaded,
167
+ cacheName: string,
168
+ page: ITabPage
169
+ ): ITabItem => {
170
+ let targetTab: ITabItem | null = null
171
+ for (const tab of tabs.value) {
172
+ tab.active = false
173
+ if (tab.id === tabInfo.id) targetTab = tab as ITabItem
174
+ }
175
+
176
+ if (targetTab === null) {
177
+ const pages = new Stack<ITabPage>()
178
+ pages.push(page)
179
+ const src = route.query.__src
180
+ targetTab = {
181
+ id: tabInfo.id!,
182
+ title: tabInfo.title,
183
+ closable: tabInfo.closable!,
184
+ refreshable: tabInfo.refreshable!,
185
+ iframe: tabInfo.iframe!,
186
+ iframeRefreshMode: tabInfo.iframeRefreshMode ?? 'postMessage',
187
+ url: decodeURIComponent(src as string),
188
+ active: true,
189
+ pages
190
+ }
191
+ addTab(targetTab).then(() => {
192
+ emitter.emit(TabEventType.TAB_ACTIVE, { id: tabInfo.id!, isRoute: false })
193
+ })
194
+ } else {
195
+ targetTab.active = true
196
+ if (targetTab.pages.isEmpty() || targetTab.pages.peek()!.id !== page.id) {
197
+ targetTab.pages.push(page)
198
+ }
199
+ }
200
+ return targetTab
201
+ }
202
+
203
+ /**
204
+ * 根据当前路由更新/创建标签与页面状态
205
+ * @returns 缓存名、标签信息、目标标签,或 empty/recovered
206
+ */
207
+ const updatePageState = (
208
+ route: RouteLocationNormalizedLoaded
209
+ ):
210
+ | { cacheName: string; tabInfo: ITabBase; targetTab: ITabItem }
211
+ | { empty: true }
212
+ | { recovered: DefineComponent; cacheName: string } => {
213
+ const tabInfo = parseTabInfoFromRoute(route)
214
+ if (tabIdsToEvict.has(tabInfo.id!)) return { empty: true }
215
+
216
+ const path = normalizePathForCache(route)
217
+ const cacheName = createPageId(tabInfo.id!, path, route.query.valueOf())
218
+ if (cacheIdsToEvict.has(cacheName)) {
219
+ cacheIdsToEvict.delete(cacheName)
220
+ const cached = components.get(cacheName)
221
+ if (cached) return { recovered: cached, cacheName }
222
+ }
223
+
224
+ const page: ITabPage = {
225
+ id: cacheName,
226
+ tabId: tabInfo.id!,
227
+ path: route.path,
228
+ query: route.query as Record<string, string>
229
+ }
230
+ const targetTab = findOrCreateTargetTab(tabInfo, route, cacheName, page)
231
+ saveActiveTabToSession(targetTab)
232
+ return { cacheName, tabInfo, targetTab }
233
+ }
234
+
235
+ const resolvePageComponent = (
236
+ ctx: { cacheName: string; tabInfo: ITabBase; targetTab: ITabItem },
237
+ component: VNode
238
+ ): DefineComponent => {
239
+ const { cacheName, tabInfo } = ctx
240
+ if (components.has(cacheName)) {
241
+ addCache(cacheName)
242
+ return components.get(cacheName)!
243
+ }
244
+ const cacheComponent = defineComponent({
245
+ name: cacheName,
246
+ components: { DynamicComponent: component },
247
+ emits: ['onLoaded'],
248
+ setup(_props, context) {
249
+ onMounted(() => {
250
+ context.emit('onLoaded')
251
+ addPageScroller(cacheName, '.cache-page-wrapper')
252
+ })
253
+ onDeactivated(() => saveScroller(cacheName))
254
+ onActivated(() => {
255
+ context.emit('onLoaded')
256
+ restoreScroller(cacheName)
257
+ evictMarkedCaches()
258
+ tabIdsToEvict.clear()
259
+ clearExcludedCacheIdForRefresh(cacheName)
260
+ component.props = null
261
+ })
262
+ onUnmounted(() => removeScroller(cacheName))
263
+ return () => (
264
+ <div
265
+ class="cache-page-wrapper"
266
+ id={'W-' + tabInfo.id}
267
+ style={[useGlobalScroll ? 'overflow:auto' : 'overflow:hidden']}
268
+ >
269
+ <dynamic-component tId={tabInfo.id} pId={cacheName} />
270
+ <PageLoading tabId={tabInfo.id!} />
271
+ </div>
272
+ )
273
+ }
274
+ }) as DefineComponent
275
+ components.set(cacheName, cacheComponent)
276
+ addCache(cacheName)
277
+ return cacheComponent
278
+ }
279
+
280
+ const clearExcludedCacheIdForRefresh = (cacheId: string) => {
281
+ excludedCacheIdsForRefresh.value = excludedCacheIdsForRefresh.value.filter((c) => c !== cacheId)
282
+ }
283
+
284
+ /**
285
+ * 将路由对应的组件注册为缓存页面,由 tabWrapper 调用
286
+ * @returns 包装后的缓存组件
287
+ */
288
+ const addPage = (route: RouteLocationNormalizedLoaded, component: VNode): DefineComponent => {
289
+ if (!component) return EmptyPlaceholderComponent as DefineComponent
290
+
291
+ const state = updatePageState(route)
292
+ if ('empty' in state) return EmptyPlaceholderComponent as DefineComponent
293
+ if ('recovered' in state) {
294
+ addCache(state.cacheName)
295
+ return state.recovered
296
+ }
297
+ return resolvePageComponent(state, component)
298
+ }
299
+
300
+ const renewTab = (tab: ITabData) => {
301
+ const currentTab = getTab(tab.id!)
302
+ if (!currentTab) return
303
+ markTabPagesForEvictionOnly(currentTab)
304
+ evictMarkedCaches()
305
+ currentTab.pages.clear()
306
+ }
307
+
308
+ const removeTab = (id: string): string => {
309
+ let activeTabId = ''
310
+ const currentTab = getTab(id)
311
+ const tabList = tabs.value
312
+ const i = tabList.findIndex((t) => t?.id === id)
313
+ if (i < 0) return ''
314
+
315
+ const tab = tabList[i]!
316
+ if (!tab.closable) return id
317
+
318
+ markTabPagesForEviction(tab)
319
+ tab.pages.clear()
320
+ if (tabList.length > 1 && tab.active) {
321
+ activeTabId = (i === 0 ? tabList[1] : tabList[i - 1])?.id ?? ''
322
+ }
323
+ tabList.splice(i, 1)
324
+
325
+ if (activeTabId) {
326
+ emitter.emit(TabEventType.TAB_ACTIVE, { id: activeTabId })
327
+ }
328
+ if (!currentTab?.active) {
329
+ evictMarkedCaches()
330
+ tabIdsToEvict.clear()
331
+ }
332
+
333
+ return activeTabId
334
+ }
335
+
336
+ const removeAllTabs = () => {
337
+ const tabList = tabs.value
338
+ for (let i = tabList.length - 1; i >= 0; i--) {
339
+ const tab = tabList[i]
340
+ if (tab?.closable) {
341
+ markTabPagesForEviction(tab)
342
+ tab.pages.clear()
343
+ tabList.splice(i, 1)
344
+ }
345
+ }
346
+ const hasActive = tabList.some((t) => t.active)
347
+ if (hasActive) {
348
+ evictMarkedCaches()
349
+ tabIdsToEvict.clear()
350
+ return
351
+ }
352
+ const last = tabList[tabList.length - 1]
353
+ if (last) emitter.emit(TabEventType.TAB_ACTIVE, { id: last.id })
354
+ }
355
+
356
+ const removeOtherTabs = (id: string) => {
357
+ const tabList = tabs.value
358
+ let activeTab: ITabItem | undefined
359
+ for (let i = tabList.length - 1; i >= 0; i--) {
360
+ const tab = tabList[i]
361
+ if (!tab) continue
362
+ if (tab.id === id) {
363
+ activeTab = tab as ITabItem
364
+ } else if (tab.closable) {
365
+ markTabPagesForEviction(tab)
366
+ tab.pages.clear()
367
+ tabList.splice(i, 1)
368
+ }
369
+ }
370
+ if (activeTab && !activeTab.active) {
371
+ emitter.emit(TabEventType.TAB_ACTIVE, { id })
372
+ } else {
373
+ evictMarkedCaches()
374
+ tabIdsToEvict.clear()
375
+ }
376
+ }
377
+
378
+ const removeLeftTabs = (id: string) => {
379
+ const tabList = tabs.value
380
+ const pivot = tabList.findIndex((t) => t?.id === id)
381
+ if (pivot <= 0) return
382
+
383
+ for (let i = pivot - 1; i >= 0; i--) {
384
+ const tab = tabList[i]
385
+ if (tab?.closable) {
386
+ markTabPagesForEviction(tab)
387
+ tab.pages.clear()
388
+ tabList.splice(i, 1)
389
+ }
390
+ }
391
+ if (tabList.some((t) => t.active)) {
392
+ evictMarkedCaches()
393
+ tabIdsToEvict.clear()
394
+ return
395
+ }
396
+ const first = tabList[0]
397
+ if (first) emitter.emit(TabEventType.TAB_ACTIVE, { id: first.id })
398
+ }
399
+
400
+ const removeRightTabs = (id: string) => {
401
+ const tabList = tabs.value
402
+ const pivot = tabList.findIndex((t) => t?.id === id)
403
+ if (pivot < 0 || pivot >= tabList.length - 1) return
404
+
405
+ for (let i = tabList.length - 1; i > pivot; i--) {
406
+ const tab = tabList[i]
407
+ if (tab?.closable) {
408
+ markTabPagesForEviction(tab)
409
+ tab.pages.clear()
410
+ tabList.splice(i, 1)
411
+ }
412
+ }
413
+ if (tabList.some((t) => t.active)) {
414
+ evictMarkedCaches()
415
+ tabIdsToEvict.clear()
416
+ return
417
+ }
418
+ const last = tabList[tabList.length - 1]
419
+ if (last) emitter.emit(TabEventType.TAB_ACTIVE, { id: last.id })
420
+ }
421
+
422
+ const refreshTab = (id: string) => {
423
+ const tab = getTab(id)
424
+ if (!tab?.refreshable) return
425
+
426
+ if (tab.iframe) {
427
+ if (tab.iframeRefreshMode === 'reload') {
428
+ iframeRefreshKeys.value = {
429
+ ...iframeRefreshKeys.value,
430
+ [id]: (iframeRefreshKeys.value[id] ?? 0) + 1
431
+ }
432
+ } else {
433
+ ;(emitter as { emit: (t: string, p: string) => void }).emit('REFRESH_IFRAME_POSTMESSAGE', id)
434
+ }
435
+ return
436
+ }
437
+
438
+ const currentPageId = tab.pages.peek()?.id
439
+ if (!currentPageId) return
440
+
441
+ excludedCacheIdsForRefresh.value = [...excludedCacheIdsForRefresh.value, currentPageId]
442
+ if (tab.active) {
443
+ refreshKey.value++
444
+ }
445
+ }
446
+
447
+ const refreshAllTabs = () => {
448
+ const toRefresh: string[] = []
449
+ for (const tab of tabs.value) {
450
+ if (!tab?.refreshable) continue
451
+ if (tab.iframe) {
452
+ if (tab.iframeRefreshMode === 'reload') {
453
+ iframeRefreshKeys.value = {
454
+ ...iframeRefreshKeys.value,
455
+ [tab.id]: (iframeRefreshKeys.value[tab.id] ?? 0) + 1
456
+ }
457
+ } else {
458
+ ;(emitter as { emit: (t: string, p: string) => void }).emit('REFRESH_IFRAME_POSTMESSAGE', tab.id)
459
+ }
460
+ } else {
461
+ const pageId = tab.pages.peek()?.id
462
+ if (pageId) toRefresh.push(pageId)
463
+ }
464
+ }
465
+ if (toRefresh.length > 0) {
466
+ excludedCacheIdsForRefresh.value = [...excludedCacheIdsForRefresh.value, ...toRefresh]
467
+ refreshKey.value++
468
+ }
469
+ }
470
+
471
+ const getComponent = (id: string) => components.get(id)
472
+
473
+ /**
474
+ * 激活指定标签,更新 active 状态并可选跳转路由
475
+ * @param id - 标签 id
476
+ * @param route - 是否执行 router.push
477
+ */
478
+ const active = (id: string, route = true) => {
479
+ emitter.emit('FORWARD')
480
+ const target = getTab(id)
481
+ if (!target) return
482
+ if (target.active) return
483
+
484
+ for (const tab of tabs.value) {
485
+ tab.active = tab.id === id
486
+ }
487
+ saveActiveTabToSession(target as ITabItem)
488
+ // 强制触发 tabs 响应式更新,确保 TabHeader 高亮与 iframe 进入动画 timing 正确
489
+ tabs.value = tabs.value.slice()
490
+ if (route) {
491
+ const top = target.pages.peek()
492
+ if (top) router.push({ path: top.path, query: top.query })
493
+ }
494
+ }
495
+
496
+ const reset = () => removeAllTabs()
497
+
498
+ const destroy = () => {
499
+ tabs.value.length = 0
500
+ cacheIdsToEvict.clear()
501
+ tabIdsToEvict.clear()
502
+ iframeRefreshKeys.value = {}
503
+ caches.value.length = 0
504
+ components.clear()
505
+ clearSession()
506
+ }
507
+
508
+ return {
509
+ tabs,
510
+ caches,
511
+ refreshKey,
512
+ excludedCacheIdsForRefresh,
513
+ isInitialized,
514
+ setMaxSize: setMaxTabCount,
515
+ canAddTab,
516
+ initialize,
517
+ size,
518
+ active,
519
+ destroy,
520
+ reset,
521
+ addCache,
522
+ removeCache,
523
+ getTab,
524
+ addTab,
525
+ removeTab,
526
+ removeAllTabs,
527
+ removeLeftTabs,
528
+ removeRightTabs,
529
+ removeOtherTabs,
530
+ hasTab,
531
+ renewTab,
532
+ addPage,
533
+ addComponent: (id: string, comp: DefineComponent) => components.set(id, comp),
534
+ getComponent,
535
+ removeComponent: (id: string) => components.delete(id),
536
+ markCacheForEviction,
537
+ evictMarkedCaches,
538
+ refreshTab,
539
+ refreshAllTabs,
540
+ iframeRefreshKeys,
541
+ addPageScroller,
542
+ setGlobalScroll: setUseGlobalScroll,
543
+ clearSession,
544
+ setSessionPrefix
545
+ }
546
+ }