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.
- package/README.md +717 -3
- package/dist/index.d.ts +3 -0
- package/dist/src/lib/StackTabs.vue.d.ts +33 -161
- package/dist/src/lib/components/ContextMenu/ContextMenuItem.vue.d.ts +6 -6
- package/dist/src/lib/components/ContextMenu/index.vue.d.ts +14 -14
- package/dist/src/lib/components/PageLoading.vue.d.ts +5 -5
- package/dist/src/lib/components/TabHeader/TabHeaderButton.vue.d.ts +24 -15
- package/dist/src/lib/components/TabHeader/TabHeaderItem.vue.d.ts +10 -8
- package/dist/src/lib/components/TabHeader/TabHeaderScroll.vue.d.ts +31 -13
- package/dist/src/lib/components/TabHeader/index.vue.d.ts +172 -20
- package/dist/src/lib/hooks/tabPanel/evict.d.ts +24 -0
- package/dist/src/lib/hooks/tabPanel/evict.spec.d.ts +1 -0
- package/dist/src/lib/hooks/tabPanel/scroll.d.ts +8 -0
- package/dist/src/lib/hooks/tabPanel/session.d.ts +9 -0
- package/dist/src/lib/hooks/tabPanel/state.d.ts +77 -0
- package/dist/src/lib/hooks/useContextMenu.d.ts +6 -1
- package/dist/src/lib/hooks/useTabActions.d.ts +68 -0
- package/dist/src/lib/hooks/{useTabMitt.d.ts → useTabEventBus.d.ts} +4 -3
- package/dist/src/lib/hooks/useTabLoading.d.ts +6 -0
- package/dist/src/lib/hooks/{useTabpanel.d.ts → useTabPanel.d.ts} +20 -14
- package/dist/src/lib/hooks/useTabPanel.spec.d.ts +1 -0
- package/dist/src/lib/hooks/useTabRouter.d.ts +14 -5
- package/dist/src/lib/i18n/index.d.ts +1 -1
- package/dist/src/lib/i18n/lang/en.d.ts +1 -0
- package/dist/src/lib/i18n/lang/zh-CN.d.ts +1 -0
- package/dist/src/lib/index.d.ts +5 -4
- package/dist/src/lib/model/TabModel.d.ts +20 -3
- package/dist/src/lib/model/TabModel.spec.d.ts +1 -0
- package/dist/src/lib/utils/iframeBridge.d.ts +31 -0
- package/dist/src/lib/utils/iframeBridge.spec.d.ts +1 -0
- package/dist/src/lib/utils/scrollUtils.d.ts +28 -0
- package/dist/src/lib/utils/tabInfoEncoder.d.ts +19 -0
- package/dist/src/lib/utils/tabInfoEncoder.spec.d.ts +1 -0
- package/dist/src/lib/utils/urlParser.d.ts +19 -0
- package/dist/src/lib/utils/urlParser.spec.d.ts +1 -0
- package/dist/src/lib/versionLogger.d.ts +2 -0
- package/dist/style.css +1 -1
- package/dist/vue-stack-tabs.css +1 -0
- package/dist/vue-stack-tabs.es.js +1795 -1679
- package/dist/vue-stack-tabs.umd.js +1 -5
- package/package.json +76 -39
- package/src/lib/StackTabs.vue +222 -47
- package/src/lib/assets/image/open.svg +1 -0
- package/src/lib/assets/style/common/var.scss +1 -1
- package/src/lib/assets/style/icon.scss +6 -0
- package/src/lib/assets/style/stackTab.scss +46 -3
- package/src/lib/assets/style/transition.scss +63 -62
- package/src/lib/components/ContextMenu/ContextMenuItem.vue +7 -0
- package/src/lib/components/ContextMenu/index.vue +50 -38
- package/src/lib/components/PageLoading.vue +30 -10
- package/src/lib/components/TabHeader/TabHeaderButton.vue +12 -3
- package/src/lib/components/TabHeader/TabHeaderItem.vue +31 -27
- package/src/lib/components/TabHeader/TabHeaderScroll.vue +83 -77
- package/src/lib/components/TabHeader/index.vue +45 -40
- package/src/lib/hooks/tabPanel/evict.spec.ts +85 -0
- package/src/lib/hooks/tabPanel/evict.ts +57 -0
- package/src/lib/hooks/tabPanel/scroll.ts +52 -0
- package/src/lib/hooks/tabPanel/session.ts +33 -0
- package/src/lib/hooks/tabPanel/state.ts +52 -0
- package/src/lib/hooks/useContextMenu.ts +22 -20
- package/src/lib/hooks/useTabActions.ts +148 -0
- package/src/lib/hooks/useTabEventBus.ts +31 -0
- package/src/lib/hooks/useTabLoading.ts +12 -4
- package/src/lib/hooks/useTabPanel.spec.ts +34 -0
- package/src/lib/hooks/useTabPanel.tsx +546 -0
- package/src/lib/hooks/useTabRouter.ts +114 -103
- package/src/lib/i18n/index.ts +14 -18
- package/src/lib/i18n/lang/en.ts +2 -1
- package/src/lib/i18n/lang/zh-CN.ts +2 -1
- package/src/lib/iframe.vue +6 -0
- package/src/lib/index.ts +27 -8
- package/src/lib/model/TabModel.spec.ts +40 -0
- package/src/lib/model/TabModel.ts +22 -9
- package/src/lib/nuxt/module.d.ts +19 -0
- package/src/lib/nuxt/module.ts +34 -0
- package/src/lib/nuxt/runtime/plugin.client.ts +11 -0
- package/src/lib/utils/iframeBridge.spec.ts +37 -0
- package/src/lib/utils/iframeBridge.ts +52 -0
- package/src/lib/utils/{TabScrollHelper.ts → scrollUtils.ts} +23 -29
- package/src/lib/utils/tabInfoEncoder.spec.ts +42 -0
- package/src/lib/utils/tabInfoEncoder.ts +65 -0
- package/src/lib/utils/urlParser.spec.ts +45 -0
- package/src/lib/utils/urlParser.ts +42 -0
- package/src/lib/{banner.ts → versionLogger.ts} +4 -5
- package/dist/src/lib/banner.d.ts +0 -2
- package/dist/src/lib/hooks/useStackTab.d.ts +0 -11
- package/dist/src/lib/utils/TabIdHelper.d.ts +0 -11
- package/dist/src/lib/utils/TabScrollHelper.d.ts +0 -33
- package/dist/src/lib/utils/UriHelper.d.ts +0 -4
- package/src/lib/hooks/useStackTab.ts +0 -119
- package/src/lib/hooks/useTabMitt.ts +0 -30
- package/src/lib/hooks/useTabpanel.tsx +0 -708
- package/src/lib/utils/TabIdHelper.ts +0 -70
- 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 {
|
|
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 =
|
|
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(
|
|
18
|
+
emitter.emit(TabEventType.PAGE_LOADING, { tId: attrs.tId, value: true })
|
|
12
19
|
}
|
|
13
20
|
|
|
21
|
+
/** 隐藏加载遮罩 */
|
|
14
22
|
const closeTabLoading = () => {
|
|
15
|
-
emitter.emit(
|
|
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
|
+
}
|