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.
- package/README.en.md +526 -0
- package/README.md +141 -39
- package/README.ru.md +526 -0
- package/dist/iframe-bridge.d.ts +8 -0
- package/dist/iframe-bridge.html +77 -0
- package/dist/iframe-bridge.mjs +20 -0
- package/dist/iframe-child.html +19 -6
- package/dist/nuxt/favicon.svg +10 -0
- package/dist/nuxt/iframe-bridge.html +77 -0
- package/dist/nuxt/iframe-child.html +88 -0
- package/dist/nuxt/module.d.ts +6 -0
- package/dist/nuxt/module.mjs +28 -0
- package/dist/nuxt/plugin.mjs +9 -0
- package/dist/nuxt/runtime/plugin.d.ts +2 -0
- package/dist/nuxt/runtime/plugin.mjs +9 -0
- package/dist/src/lib/StackTabs.vue.d.ts +30 -16
- package/dist/src/lib/components/ContextMenu/ContextMenuItem.vue.d.ts +7 -1
- package/dist/src/lib/components/ContextMenu/index.vue.d.ts +5 -4
- package/dist/src/lib/components/PageLoading.vue.d.ts +2 -1
- package/dist/src/lib/components/StackKeepAlive/StackCacheRenderer.vue.d.ts +25 -0
- package/dist/src/lib/components/StackKeepAlive/StackKeepAlive.vue.d.ts +12 -0
- package/dist/src/lib/components/TabHeader/TabHeaderButton.vue.d.ts +11 -12
- package/dist/src/lib/components/TabHeader/TabHeaderItem.vue.d.ts +5 -4
- package/dist/src/lib/components/TabHeader/TabHeaderScroll.vue.d.ts +8 -22
- package/dist/src/lib/components/TabHeader/index.vue.d.ts +11 -164
- package/dist/src/lib/hooks/stackTabsContext.d.ts +49 -0
- package/dist/src/lib/hooks/tabPanel/evict.d.ts +21 -61
- package/dist/src/lib/hooks/tabPanel/navigationTransaction.d.ts +35 -0
- package/dist/src/lib/hooks/tabPanel/refresh.d.ts +17 -0
- package/dist/src/lib/hooks/tabPanel/scroll.d.ts +8 -8
- package/dist/src/lib/hooks/tabPanel/session.d.ts +9 -8
- package/dist/src/lib/hooks/tabPanel/state.d.ts +20 -72
- package/dist/src/lib/hooks/useIframeManager.d.ts +35 -0
- package/dist/src/lib/hooks/useTabActions.d.ts +5 -46
- package/dist/src/lib/hooks/useTabEventBus.d.ts +22 -9
- package/dist/src/lib/hooks/useTabPanel.d.ts +14 -73
- package/dist/src/lib/i18n/index.d.ts +6 -4
- package/dist/src/lib/i18n/lang/en.d.ts +5 -0
- package/dist/src/lib/i18n/lang/zh-CN.d.ts +5 -0
- package/dist/src/lib/i18n/stackTabsLocale.d.ts +2 -0
- package/dist/src/lib/iframe-bridge.d.ts +8 -0
- package/dist/src/lib/iframe.vue.d.ts +2 -1
- package/dist/src/lib/index.d.ts +4 -5
- package/dist/src/lib/model/TabModel.d.ts +11 -40
- package/dist/src/lib/nuxt/module.d.ts +6 -0
- package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +2 -0
- package/dist/src/lib/nuxt/runtime/plugin.d.ts +2 -0
- package/dist/src/lib/utils/iframeBridge.d.ts +11 -3
- package/dist/src/lib/utils/stackTabsA11y.d.ts +8 -0
- package/dist/src/lib/utils/stackTabsMessage.d.ts +7 -0
- package/dist/src/lib/utils/tabInfoEncoder.d.ts +0 -15
- package/dist/src/lib/utils/typeGuards.d.ts +2 -0
- package/dist/src/lib/utils/urlParser.d.ts +24 -3
- package/dist/src/lib/versionLogger.d.ts +1 -0
- package/dist/style.css +2 -1
- package/dist/utils/iframeBridge.d.ts +39 -0
- package/dist/vue-stack-tabs.css +2 -1
- package/dist/vue-stack-tabs.es.js +1 -2220
- package/package.json +71 -50
- package/src/lib/StackTabs.vue +223 -196
- package/src/lib/assets/style/stackTab.scss +110 -13
- package/src/lib/components/ContextMenu/ContextMenuItem.vue +23 -11
- package/src/lib/components/ContextMenu/index.vue +94 -10
- package/src/lib/components/PageLoading.vue +9 -7
- package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +70 -0
- package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +57 -0
- package/src/lib/components/TabHeader/TabHeaderButton.vue +19 -8
- package/src/lib/components/TabHeader/TabHeaderItem.vue +74 -17
- package/src/lib/components/TabHeader/TabHeaderScroll.vue +47 -25
- package/src/lib/components/TabHeader/index.vue +181 -13
- package/src/lib/hooks/stackTabsContext.ts +135 -0
- package/src/lib/hooks/tabPanel/evict.ts +122 -87
- package/src/lib/hooks/tabPanel/navigationTransaction.ts +61 -0
- package/src/lib/hooks/tabPanel/refresh.ts +109 -0
- package/src/lib/hooks/tabPanel/scroll.ts +57 -40
- package/src/lib/hooks/tabPanel/session.ts +84 -22
- package/src/lib/hooks/tabPanel/state.ts +41 -44
- package/src/lib/hooks/useIframeManager.ts +253 -0
- package/src/lib/hooks/useTabActions.ts +64 -30
- package/src/lib/hooks/useTabEventBus.ts +29 -19
- package/src/lib/hooks/useTabLoading.ts +3 -2
- package/src/lib/hooks/useTabPanel.tsx +290 -313
- package/src/lib/hooks/useTabRouter.ts +111 -97
- package/src/lib/i18n/index.ts +53 -45
- package/src/lib/i18n/lang/en.ts +6 -1
- package/src/lib/i18n/lang/zh-CN.ts +6 -1
- package/src/lib/i18n/stackTabsLocale.ts +8 -0
- package/src/lib/iframe-bridge.ts +12 -0
- package/src/lib/index.ts +14 -19
- package/src/lib/model/TabModel.ts +27 -82
- package/src/lib/nuxt/module.d.ts +4 -0
- package/src/lib/nuxt/module.ts +8 -3
- package/src/lib/nuxt/runtime/plugin.client.ts +1 -0
- package/src/lib/nuxt/runtime/plugin.ts +11 -0
- package/src/lib/utils/iframeBridge.ts +29 -5
- package/src/lib/utils/scrollUtils.ts +9 -4
- package/src/lib/utils/stackTabsA11y.ts +12 -0
- package/src/lib/utils/stackTabsMessage.ts +99 -0
- package/src/lib/utils/tabInfoEncoder.ts +70 -21
- package/src/lib/utils/typeGuards.ts +3 -0
- package/src/lib/utils/urlParser.ts +144 -14
- package/src/lib/versionLogger.ts +7 -19
- package/dist/src/lib/hooks/tabPanel/evict.spec.d.ts +0 -1
- package/dist/src/lib/hooks/useTabPanel.spec.d.ts +0 -1
- package/dist/src/lib/model/TabModel.spec.d.ts +0 -1
- package/dist/src/lib/utils/iframeBridge.spec.d.ts +0 -1
- package/dist/src/lib/utils/tabInfoEncoder.spec.d.ts +0 -1
- package/dist/src/lib/utils/urlParser.spec.d.ts +0 -1
- package/dist/vue-stack-tabs.umd.js +0 -1
- package/src/lib/hooks/tabPanel/evict.spec.ts +0 -85
- package/src/lib/hooks/useTabPanel.spec.ts +0 -34
- package/src/lib/model/TabModel.spec.ts +0 -40
- package/src/lib/utils/iframeBridge.spec.ts +0 -37
- package/src/lib/utils/tabInfoEncoder.spec.ts +0 -32
- package/src/lib/utils/urlParser.spec.ts +0 -45
|
@@ -1,33 +1,95 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* tabPanel/session - Session 持久化
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 职责:基于当前 StackTabsRuntimeContext 将当前激活标签保存到 sessionStorage,刷新后恢复。
|
|
5
5
|
*/
|
|
6
|
-
import type { ITabItem } from '../../model/TabModel'
|
|
6
|
+
import type { ITabItem, ITabPage } from '../../model/TabModel'
|
|
7
7
|
import { Stack } from '../../model/TabModel'
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
8
|
+
import { isRecord } from '../../utils/typeGuards'
|
|
9
|
+
import type { StackTabsRuntimeContext } from '../stackTabsContext'
|
|
10
|
+
import { SESSION_TAB_NAME } from './state'
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
const isRestoredTab = (value: unknown): value is ITabItem =>
|
|
13
|
+
isRecord(value) &&
|
|
14
|
+
typeof value.id === 'string' &&
|
|
15
|
+
typeof value.title === 'string' &&
|
|
16
|
+
typeof value.closable === 'boolean' &&
|
|
17
|
+
typeof value.refreshable === 'boolean' &&
|
|
18
|
+
typeof value.iframe === 'boolean' &&
|
|
19
|
+
typeof value.active === 'boolean' &&
|
|
20
|
+
value.pages instanceof Stack
|
|
13
21
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
22
|
+
export interface TabPanelSessionApi {
|
|
23
|
+
getSessionKey: () => string
|
|
24
|
+
saveActiveTabToSession: (tab: ITabItem) => void
|
|
25
|
+
clearSession: () => void
|
|
26
|
+
restoreActiveTabSession: (storedJson: string | null) => void
|
|
27
|
+
restoreTabFromSession: (storedJson: string | null) => ITabItem | null
|
|
18
28
|
}
|
|
19
29
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
30
|
+
export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPanelSessionApi => {
|
|
31
|
+
const getSessionKey = (): string => context.sessionPrefix.value + SESSION_TAB_NAME
|
|
32
|
+
let pendingWrite: { tab: ITabItem; version: number } | null = null
|
|
33
|
+
let writeVersion = 0
|
|
34
|
+
let writeScheduled = false
|
|
35
|
+
|
|
36
|
+
const flushPendingWrite = () => {
|
|
37
|
+
writeScheduled = false
|
|
38
|
+
const write = pendingWrite
|
|
39
|
+
pendingWrite = null
|
|
40
|
+
if (!write || write.version !== writeVersion) return
|
|
41
|
+
window.sessionStorage.setItem(getSessionKey(), JSON.stringify(write.tab))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const invalidatePendingWrite = () => {
|
|
45
|
+
writeVersion++
|
|
46
|
+
pendingWrite = null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const saveActiveTabToSession = (tab: ITabItem): void => {
|
|
50
|
+
if (!tab.id) return
|
|
51
|
+
pendingWrite = { tab, version: ++writeVersion }
|
|
52
|
+
if (writeScheduled) return
|
|
53
|
+
writeScheduled = true
|
|
54
|
+
queueMicrotask(flushPendingWrite)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const clearSession = (): void => {
|
|
58
|
+
invalidatePendingWrite()
|
|
59
|
+
window.sessionStorage.removeItem(getSessionKey())
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const restoreActiveTabSession = (storedJson: string | null): void => {
|
|
63
|
+
if (storedJson === null) {
|
|
64
|
+
clearSession()
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
invalidatePendingWrite()
|
|
68
|
+
window.sessionStorage.setItem(getSessionKey(), storedJson)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const restoreTabFromSession = (storedJson: string | null): ITabItem | null => {
|
|
72
|
+
if (storedJson == null) return null
|
|
73
|
+
try {
|
|
74
|
+
const restored = JSON.parse(storedJson, (key, value) =>
|
|
75
|
+
key === 'pages' && Array.isArray(value) ? new Stack<ITabPage>(value) : value
|
|
76
|
+
)
|
|
77
|
+
if (!isRestoredTab(restored)) {
|
|
78
|
+
clearSession()
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
return restored as ITabItem
|
|
82
|
+
} catch {
|
|
83
|
+
clearSession()
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
}
|
|
24
87
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
) as ITabItem
|
|
88
|
+
return {
|
|
89
|
+
getSessionKey,
|
|
90
|
+
saveActiveTabToSession,
|
|
91
|
+
clearSession,
|
|
92
|
+
restoreActiveTabSession,
|
|
93
|
+
restoreTabFromSession
|
|
94
|
+
}
|
|
33
95
|
}
|
|
@@ -1,52 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* tabPanel/state -
|
|
2
|
+
* tabPanel/state - 标签页运行时状态 factory
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 职责:创建 tabs、caches、components、驱逐集合、滚动位置等运行时状态。
|
|
5
|
+
* 注意:本文件不导出模块级可变状态;每个 Vue app 只有一个 StackTabsRuntimeContext 持有这些状态。
|
|
6
6
|
*/
|
|
7
|
+
import type { DefineComponent, Ref, ShallowRef } from 'vue'
|
|
8
|
+
import { ref, shallowRef } from 'vue'
|
|
7
9
|
import type { ITabItem } from '../../model/TabModel'
|
|
8
|
-
import { ref } from 'vue'
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
/** keep-alive include 数组,与 Vue 的 keep-alive :include 绑定 */
|
|
15
|
-
export const caches = ref<string[]>([])
|
|
16
|
-
/** 页面缓存 ID -> 包装后的 DefineComponent */
|
|
17
|
-
export const components = new Map<string, import('vue').DefineComponent>()
|
|
18
|
-
/** 待驱逐的页面缓存 ID 集合 */
|
|
19
|
-
export const cacheIdsToEvict = new Set<string>()
|
|
20
|
-
/** 待驱逐的标签 ID 集合 */
|
|
21
|
-
export const tabIdsToEvict = new Set<string>()
|
|
22
|
-
/** 刷新计数器,变更时触发 component :key 变化 */
|
|
23
|
-
export const refreshKey = ref<number>(0)
|
|
24
|
-
/** 刷新时临时加入 keep-alive exclude 的缓存 ID */
|
|
25
|
-
// 改用 ULID 的新架构,无需黑名单隔离机制
|
|
26
|
-
/** pageCacheId -> Map<selector, { top, left }> 滚动位置存储 */
|
|
27
|
-
export const scrollPositionsByPageId = new Map<string, Map<string, { top: number; left: number }>>()
|
|
28
|
-
/** 是否已完成初始化 */
|
|
29
|
-
export const isInitialized = ref<boolean>(false)
|
|
30
|
-
|
|
31
|
-
/** sessionStorage 中存储当前激活标签的 key 前缀 */
|
|
32
|
-
export const SESSION_TAB_NAME = 'stacktab-active-tab'
|
|
11
|
+
export interface ScrollPosition {
|
|
12
|
+
top: number
|
|
13
|
+
left: number
|
|
14
|
+
}
|
|
33
15
|
|
|
34
|
-
|
|
35
|
-
|
|
16
|
+
export interface TabPanelRuntimeState {
|
|
17
|
+
tabs: Ref<ITabItem[]>
|
|
18
|
+
defaultTabs: Ref<ITabItem[]>
|
|
19
|
+
caches: ShallowRef<string[]>
|
|
20
|
+
components: Map<string, DefineComponent>
|
|
21
|
+
cacheIdsToEvict: Set<string>
|
|
22
|
+
tabIdsToEvict: Set<string>
|
|
23
|
+
refreshKey: Ref<number>
|
|
24
|
+
scrollPositionsByPageId: Map<string, Map<string, ScrollPosition>>
|
|
25
|
+
isInitialized: Ref<boolean>
|
|
26
|
+
iframeRefreshKeys: Ref<Record<string, number>>
|
|
27
|
+
maxTabCount: Ref<number>
|
|
28
|
+
useGlobalScroll: Ref<boolean>
|
|
29
|
+
sessionPrefix: Ref<string>
|
|
30
|
+
}
|
|
36
31
|
|
|
37
|
-
/**
|
|
38
|
-
export
|
|
39
|
-
/** 是否使用页面级滚动 */
|
|
40
|
-
export let useGlobalScroll = false
|
|
41
|
-
/** sessionStorage key 前缀 */
|
|
42
|
-
export let sessionPrefix = ''
|
|
32
|
+
/** sessionStorage 中存储当前激活标签的 key 后缀 */
|
|
33
|
+
export const SESSION_TAB_NAME = 'stacktab-active-tab'
|
|
43
34
|
|
|
44
|
-
export const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
35
|
+
export const createTabPanelRuntimeState = (): TabPanelRuntimeState => ({
|
|
36
|
+
tabs: ref<ITabItem[]>([]) as Ref<ITabItem[]>,
|
|
37
|
+
defaultTabs: ref<ITabItem[]>([]) as Ref<ITabItem[]>,
|
|
38
|
+
caches: shallowRef<string[]>([]),
|
|
39
|
+
components: new Map<string, DefineComponent>(),
|
|
40
|
+
cacheIdsToEvict: new Set<string>(),
|
|
41
|
+
tabIdsToEvict: new Set<string>(),
|
|
42
|
+
refreshKey: ref<number>(0),
|
|
43
|
+
scrollPositionsByPageId: new Map<string, Map<string, ScrollPosition>>(),
|
|
44
|
+
isInitialized: ref<boolean>(false),
|
|
45
|
+
iframeRefreshKeys: ref<Record<string, number>>({}),
|
|
46
|
+
maxTabCount: ref<number>(0),
|
|
47
|
+
useGlobalScroll: ref<boolean>(false),
|
|
48
|
+
sessionPrefix: ref<string>('')
|
|
49
|
+
})
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iframe 标签页生命周期管理。
|
|
3
|
+
*
|
|
4
|
+
* 统一管理所有 iframe 标签的 src 计算、加载状态追踪、超时检测、
|
|
5
|
+
* 刷新控制、DOM ref 收集,以及 postMessage 通信。
|
|
6
|
+
*/
|
|
7
|
+
import { computed, onBeforeUnmount, reactive, watch, type Ref } from 'vue'
|
|
8
|
+
import type { ITabData, ITabItem } from '../model/TabModel'
|
|
9
|
+
import { toSafeTabUrl } from '../utils/urlParser'
|
|
10
|
+
import { getPostMessageTargetOrigin, isStackTabsOpenTabMessage } from '../utils/stackTabsMessage'
|
|
11
|
+
|
|
12
|
+
type IframeLoadStatus = 'idle' | 'loading' | 'loaded' | 'timeout'
|
|
13
|
+
|
|
14
|
+
interface IframeLoadState {
|
|
15
|
+
status: IframeLoadStatus
|
|
16
|
+
message?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UseIframeManagerOptions {
|
|
20
|
+
tabs: Ref<ITabItem[]>
|
|
21
|
+
iframeRefreshKeys: Ref<Record<string, number>>
|
|
22
|
+
iframeLoadTimeout: number
|
|
23
|
+
t: (key: string) => string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useIframeManager(options: UseIframeManagerOptions) {
|
|
27
|
+
const { tabs, iframeRefreshKeys, iframeLoadTimeout, t } = options
|
|
28
|
+
|
|
29
|
+
const iframeTabs = computed(() => tabs.value.filter((item) => item.iframe))
|
|
30
|
+
const activeNonIframeTab = computed(() => tabs.value.find((item) => item.active && !item.iframe))
|
|
31
|
+
const hasActiveNonIframeTab = computed(() => Boolean(activeNonIframeTab.value))
|
|
32
|
+
|
|
33
|
+
// iframe 首次激活时才加载真实 URL,未激活前使用 about:blank 避免不必要的请求
|
|
34
|
+
const iframeEverActivated = reactive<Record<string, boolean>>({})
|
|
35
|
+
|
|
36
|
+
const appendIframeRefreshKey = (src: string, refreshKey: number): string => {
|
|
37
|
+
if (refreshKey <= 0 || src === 'about:blank') return src
|
|
38
|
+
|
|
39
|
+
const hashIndex = src.indexOf('#')
|
|
40
|
+
const baseWithSearch = hashIndex >= 0 ? src.slice(0, hashIndex) : src
|
|
41
|
+
const hash = hashIndex >= 0 ? src.slice(hashIndex) : ''
|
|
42
|
+
const searchIndex = baseWithSearch.indexOf('?')
|
|
43
|
+
const path = searchIndex >= 0 ? baseWithSearch.slice(0, searchIndex) : baseWithSearch
|
|
44
|
+
const search = searchIndex >= 0 ? baseWithSearch.slice(searchIndex + 1) : ''
|
|
45
|
+
const searchParams = new URLSearchParams(search)
|
|
46
|
+
searchParams.set('__stack_tabs_refresh', String(refreshKey))
|
|
47
|
+
|
|
48
|
+
return `${path}?${searchParams.toString()}${hash}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const getIframeRefreshKey = (frameId: string): number => iframeRefreshKeys.value[frameId] ?? 0
|
|
52
|
+
const getIframeSrc = (
|
|
53
|
+
frame: { id: string; url?: string },
|
|
54
|
+
refreshKey = getIframeRefreshKey(frame.id)
|
|
55
|
+
) => {
|
|
56
|
+
const safeUrl = toSafeTabUrl(frame.url ?? '')
|
|
57
|
+
const src = iframeEverActivated[frame.id] ? safeUrl : 'about:blank'
|
|
58
|
+
return appendIframeRefreshKey(src, refreshKey)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const getIframeKey = (frame: { id: string }) => `${frame.id}-${getIframeRefreshKey(frame.id)}`
|
|
62
|
+
|
|
63
|
+
const iframeSrcMap = computed<Record<string, string>>(() => {
|
|
64
|
+
const map: Record<string, string> = {}
|
|
65
|
+
for (const frame of iframeTabs.value) {
|
|
66
|
+
const refreshKey = getIframeRefreshKey(frame.id)
|
|
67
|
+
map[frame.id] = getIframeSrc(frame, refreshKey)
|
|
68
|
+
}
|
|
69
|
+
return map
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const iframeLoadStates = reactive<Record<string, IframeLoadState>>({})
|
|
73
|
+
const getIframeLoadState = (frameId: string): IframeLoadState =>
|
|
74
|
+
iframeLoadStates[frameId] ?? { status: 'idle' }
|
|
75
|
+
|
|
76
|
+
const loadTimeoutIds = new Map<string, ReturnType<typeof setTimeout>>()
|
|
77
|
+
|
|
78
|
+
const clearIframeLoadTimeout = (frameId: string) => {
|
|
79
|
+
const timeoutId = loadTimeoutIds.get(frameId)
|
|
80
|
+
if (!timeoutId) return
|
|
81
|
+
clearTimeout(timeoutId)
|
|
82
|
+
loadTimeoutIds.delete(frameId)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const setIframeLoaded = (frameId: string) => {
|
|
86
|
+
clearIframeLoadTimeout(frameId)
|
|
87
|
+
iframeLoadStates[frameId] = { status: 'loaded' }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const setIframeLoading = (frameId: string, opts: { force?: boolean } = {}) => {
|
|
91
|
+
const current = getIframeLoadState(frameId)
|
|
92
|
+
if (current.status === 'loaded' && !opts.force) return
|
|
93
|
+
|
|
94
|
+
clearIframeLoadTimeout(frameId)
|
|
95
|
+
iframeLoadStates[frameId] = { status: 'loading' }
|
|
96
|
+
loadTimeoutIds.set(
|
|
97
|
+
frameId,
|
|
98
|
+
setTimeout(() => {
|
|
99
|
+
loadTimeoutIds.delete(frameId)
|
|
100
|
+
if (getIframeLoadState(frameId).status === 'loading') {
|
|
101
|
+
iframeLoadStates[frameId] = {
|
|
102
|
+
status: 'timeout',
|
|
103
|
+
message: t('VueStackTab.iframeLoadTimeout')
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}, iframeLoadTimeout)
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const retryIframe = (frameId: string) => {
|
|
111
|
+
iframeLoadStates[frameId] = { status: 'idle' }
|
|
112
|
+
iframeRefreshKeys.value = {
|
|
113
|
+
...iframeRefreshKeys.value,
|
|
114
|
+
[frameId]: (iframeRefreshKeys.value[frameId] ?? 0) + 1
|
|
115
|
+
}
|
|
116
|
+
setIframeLoading(frameId, { force: true })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const shouldShowIframeLoading = (frameId: string) =>
|
|
120
|
+
getIframeLoadState(frameId).status === 'loading'
|
|
121
|
+
const shouldShowIframeError = (frameId: string) =>
|
|
122
|
+
getIframeLoadState(frameId).status === 'timeout'
|
|
123
|
+
|
|
124
|
+
const iframeElRefs = reactive<Record<string, HTMLIFrameElement | null>>({})
|
|
125
|
+
const setIframeRef = (id: string, el: HTMLIFrameElement | null) => {
|
|
126
|
+
if (el) {
|
|
127
|
+
iframeElRefs[id] = el
|
|
128
|
+
} else {
|
|
129
|
+
delete iframeElRefs[id]
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 同步 iframe 激活和移除状态,避免用多个 watcher 重复遍历 iframe 列表。 */
|
|
134
|
+
watch(
|
|
135
|
+
() => iframeTabs.value.map((frame) => ({ id: frame.id, active: frame.active })),
|
|
136
|
+
(frames) => {
|
|
137
|
+
for (const f of frames) {
|
|
138
|
+
if (!f.active) continue
|
|
139
|
+
iframeEverActivated[f.id] = true
|
|
140
|
+
setIframeLoading(f.id)
|
|
141
|
+
}
|
|
142
|
+
const activeIds = new Set(frames.map((frame) => frame.id))
|
|
143
|
+
for (const id of Object.keys(iframeLoadStates)) {
|
|
144
|
+
if (!activeIds.has(id)) {
|
|
145
|
+
clearIframeLoadTimeout(id)
|
|
146
|
+
delete iframeLoadStates[id]
|
|
147
|
+
delete iframeEverActivated[id]
|
|
148
|
+
delete iframeElRefs[id]
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
{ immediate: true }
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
// refreshKey 变化时重新触发对应 iframe 的加载状态
|
|
156
|
+
watch(
|
|
157
|
+
iframeRefreshKeys,
|
|
158
|
+
(keys, previousKeys = {}) => {
|
|
159
|
+
for (const frame of iframeTabs.value) {
|
|
160
|
+
if (!frame.active) continue
|
|
161
|
+
const nextKey = keys[frame.id] ?? 0
|
|
162
|
+
const previousKey = previousKeys[frame.id] ?? 0
|
|
163
|
+
if (nextKey !== previousKey) {
|
|
164
|
+
setIframeLoading(frame.id, { force: true })
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
{ deep: true }
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const handleIframeLoad = (frame: { id: string; url?: string }, event: Event) => {
|
|
172
|
+
const iframe = event.currentTarget as HTMLIFrameElement | null
|
|
173
|
+
const refreshKey = Number(iframe?.dataset.refreshKey ?? 0)
|
|
174
|
+
if (!iframeEverActivated[frame.id]) return
|
|
175
|
+
if (getIframeSrc(frame, refreshKey) === 'about:blank') return
|
|
176
|
+
if (refreshKey !== getIframeRefreshKey(frame.id)) return
|
|
177
|
+
if (iframe !== iframeElRefs[frame.id]) return
|
|
178
|
+
setIframeLoaded(frame.id)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const handleRefreshIframePostMessage = (tabId: string) => {
|
|
182
|
+
const iframe = iframeElRefs[tabId]
|
|
183
|
+
const tab = tabs.value.find((item) => item.id === tabId)
|
|
184
|
+
if (iframe?.contentWindow) {
|
|
185
|
+
try {
|
|
186
|
+
const targetOrigin = getPostMessageTargetOrigin(tab?.url ?? '')
|
|
187
|
+
if (!targetOrigin) {
|
|
188
|
+
if (!import.meta.env.PROD) {
|
|
189
|
+
console.warn(
|
|
190
|
+
`[vue-stack-tabs] Skip refresh postMessage because iframe URL is invalid: ${tabId}`
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
iframe.contentWindow.postMessage({ type: 'vue-stack-tabs:refresh' }, targetOrigin)
|
|
196
|
+
} catch (e) {
|
|
197
|
+
if (!import.meta.env.PROD) {
|
|
198
|
+
console.warn(`[vue-stack-tabs] Failed to send refresh postMessage to iframe: ${tabId}`, e)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
if (!import.meta.env.PROD) {
|
|
203
|
+
console.warn(
|
|
204
|
+
`[vue-stack-tabs] IFrame element not found for tabId: ${tabId} when trying to refresh.`
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const createMessageHandler = (
|
|
211
|
+
allowedOrigins: Ref<string[]>,
|
|
212
|
+
openTab: (tab: ITabData) => Promise<string | undefined>
|
|
213
|
+
) => {
|
|
214
|
+
const isManagedIframeSource = (
|
|
215
|
+
source: Parameters<typeof isStackTabsOpenTabMessage>[0]['source']
|
|
216
|
+
) => Object.values(iframeElRefs).some((el) => el?.contentWindow === source)
|
|
217
|
+
|
|
218
|
+
return (ev: MessageEvent) => {
|
|
219
|
+
if (!allowedOrigins.value.includes(ev.origin)) return
|
|
220
|
+
|
|
221
|
+
const payload = isStackTabsOpenTabMessage(ev, isManagedIframeSource)
|
|
222
|
+
if (!payload) return
|
|
223
|
+
|
|
224
|
+
openTab(payload).catch((error: unknown) => {
|
|
225
|
+
if (!import.meta.env.PROD) {
|
|
226
|
+
console.warn('[vue-stack-tabs] Failed to open tab from iframe message:', error)
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
onBeforeUnmount(() => {
|
|
233
|
+
for (const timeoutId of loadTimeoutIds.values()) clearTimeout(timeoutId)
|
|
234
|
+
loadTimeoutIds.clear()
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
iframeTabs,
|
|
239
|
+
activeNonIframeTab,
|
|
240
|
+
hasActiveNonIframeTab,
|
|
241
|
+
iframeSrcMap,
|
|
242
|
+
getIframeRefreshKey,
|
|
243
|
+
getIframeKey,
|
|
244
|
+
getIframeLoadState,
|
|
245
|
+
shouldShowIframeLoading,
|
|
246
|
+
shouldShowIframeError,
|
|
247
|
+
retryIframe,
|
|
248
|
+
setIframeRef,
|
|
249
|
+
handleIframeLoad,
|
|
250
|
+
handleRefreshIframePostMessage,
|
|
251
|
+
createMessageHandler
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
import { useRouter } from 'vue-router'
|
|
2
|
-
import {
|
|
1
|
+
import { isNavigationFailure, useRouter } from 'vue-router'
|
|
2
|
+
import type { LocationQueryRaw } from 'vue-router'
|
|
3
3
|
import { defu } from 'defu'
|
|
4
|
-
import { ulid } from 'ulid'
|
|
5
|
-
|
|
6
4
|
import useTabPanel from './useTabPanel'
|
|
7
5
|
import { encodeTabInfo } from '../utils/tabInfoEncoder'
|
|
8
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
parseUrl,
|
|
8
|
+
isCrossOriginUrl,
|
|
9
|
+
isAllowedTabUrl,
|
|
10
|
+
omitStackTabsReservedQuery
|
|
11
|
+
} from '../utils/urlParser'
|
|
9
12
|
import type { ITabData, IframeRefreshMode } from '../model/TabModel'
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
/** iframe 占位路由的 path,由 StackTabs 通过 setIFramePath 注入 */
|
|
13
|
-
let iframePath: string
|
|
13
|
+
import { runNavigationTransaction } from './tabPanel/navigationTransaction'
|
|
14
|
+
import { resolveStackTabsRuntimeContext } from './stackTabsContext'
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* useTabActions - 标签操作对外 Hook
|
|
@@ -27,7 +28,7 @@ let iframePath: string
|
|
|
27
28
|
*/
|
|
28
29
|
export default function useTabActions() {
|
|
29
30
|
const router = useRouter()
|
|
30
|
-
const
|
|
31
|
+
const runtimeContext = resolveStackTabsRuntimeContext()
|
|
31
32
|
const {
|
|
32
33
|
active,
|
|
33
34
|
hasTab,
|
|
@@ -37,6 +38,7 @@ export default function useTabActions() {
|
|
|
37
38
|
renewTab,
|
|
38
39
|
getTab,
|
|
39
40
|
tabs,
|
|
41
|
+
moveTab,
|
|
40
42
|
removeTab,
|
|
41
43
|
removeAllTabs,
|
|
42
44
|
refreshTab,
|
|
@@ -58,56 +60,87 @@ export default function useTabActions() {
|
|
|
58
60
|
defaults.iframeRefreshMode = (tab.iframeRefreshMode ?? 'postMessage') as IframeRefreshMode
|
|
59
61
|
}
|
|
60
62
|
const info = defu(tab, defaults)
|
|
61
|
-
if (!info.id) info.id =
|
|
63
|
+
if (!info.id) info.id = crypto.randomUUID()
|
|
62
64
|
return info
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
/** 打开新标签;renew=true
|
|
66
|
-
const openTab =
|
|
67
|
+
/** 打开新标签;renew=true 时若已存在则清空该标签的页面栈后重新打开 */
|
|
68
|
+
const openTab = (tab: ITabData, renew = false): Promise<string | undefined> => {
|
|
67
69
|
return new Promise<string | undefined>((resolve, reject) => {
|
|
68
70
|
const tabInfo = prepareTabInfo(tab)
|
|
69
71
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
const isExistingTab = Boolean(tabInfo.id && hasTab(tabInfo.id))
|
|
73
|
+
|
|
74
|
+
if (isExistingTab && !renew) {
|
|
75
|
+
Promise.resolve(active(tabInfo.id!)).then(() => resolve(tabInfo.id), reject)
|
|
73
76
|
return
|
|
74
77
|
}
|
|
75
78
|
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
let rollbackRenew: (() => void) | undefined
|
|
80
|
+
let refreshKeySnapshot: number | undefined
|
|
81
|
+
|
|
82
|
+
if (tabInfo.id && renew && isExistingTab) {
|
|
78
83
|
const currentTab = getTab(tab.id!)
|
|
79
|
-
|
|
84
|
+
rollbackRenew = renewTab(tab)
|
|
85
|
+
if (currentTab?.active) {
|
|
86
|
+
refreshKeySnapshot = refreshKey.value
|
|
87
|
+
refreshKey.value++
|
|
88
|
+
}
|
|
80
89
|
}
|
|
81
90
|
|
|
82
|
-
if (!canAddTab()) {
|
|
91
|
+
if (!isExistingTab && !canAddTab()) {
|
|
83
92
|
reject(new Error('Max Size'))
|
|
84
93
|
return
|
|
85
94
|
}
|
|
86
95
|
|
|
87
|
-
navigateToTab(tab, tabInfo)
|
|
88
|
-
|
|
96
|
+
navigateToTab(tab, tabInfo).then(
|
|
97
|
+
() => resolve(tabInfo.id),
|
|
98
|
+
(error) => {
|
|
99
|
+
rollbackRenew?.()
|
|
100
|
+
if (refreshKeySnapshot !== undefined) refreshKey.value = refreshKeySnapshot
|
|
101
|
+
reject(error)
|
|
102
|
+
}
|
|
103
|
+
)
|
|
89
104
|
})
|
|
90
|
-
}
|
|
105
|
+
}
|
|
91
106
|
|
|
92
107
|
/** 执行路由跳转到标签对应页面(普通路由或 iframe 占位路由) */
|
|
93
108
|
const navigateToTab = (tab: ITabData, tabInfo: ITabData) => {
|
|
94
109
|
const __tab = encodeTabInfo(tabInfo)
|
|
95
|
-
|
|
110
|
+
const tabQuery = omitStackTabsReservedQuery(tab.query)
|
|
111
|
+
let query = defu(tabQuery, { __tab }) as LocationQueryRaw
|
|
96
112
|
let path: string
|
|
113
|
+
|
|
97
114
|
if (!tab.iframe) {
|
|
98
115
|
const url = parseUrl(tab.path)
|
|
99
116
|
path = url.path
|
|
100
|
-
query = defu(url.query, query)
|
|
117
|
+
query = defu(omitStackTabsReservedQuery(url.query), query, { __tab }) as LocationQueryRaw
|
|
118
|
+
query = {
|
|
119
|
+
...query,
|
|
120
|
+
__tab
|
|
121
|
+
}
|
|
101
122
|
} else {
|
|
102
|
-
query
|
|
103
|
-
|
|
123
|
+
query = {
|
|
124
|
+
...query,
|
|
125
|
+
__src: encodeURIComponent(tab.path as string),
|
|
126
|
+
__tab
|
|
127
|
+
}
|
|
128
|
+
path = runtimeContext.iframePath.value
|
|
104
129
|
}
|
|
105
|
-
|
|
130
|
+
|
|
131
|
+
if (!isAllowedTabUrl(tab.path)) return Promise.reject(new Error('Invalid tab URL'))
|
|
132
|
+
return runNavigationTransaction({
|
|
133
|
+
apply: () => undefined,
|
|
134
|
+
navigate: () => router.push({ path, query }),
|
|
135
|
+
rollback: () => undefined,
|
|
136
|
+
isFailureResult: isNavigationFailure,
|
|
137
|
+
rejectFailureResult: true
|
|
138
|
+
})
|
|
106
139
|
}
|
|
107
140
|
|
|
108
141
|
/** 设置 iframe 占位路由的 path,由 StackTabs 在挂载时调用 */
|
|
109
142
|
const setIFramePath = (path: string) => {
|
|
110
|
-
iframePath = path
|
|
143
|
+
runtimeContext.iframePath.value = path
|
|
111
144
|
}
|
|
112
145
|
|
|
113
146
|
/** 获取当前页面的缓存容器 DOM(用于某些需要直接操作 DOM 的场景) */
|
|
@@ -118,7 +151,7 @@ export default function useTabActions() {
|
|
|
118
151
|
/** iframe 标签在新窗口打开(无法嵌入时降级) */
|
|
119
152
|
const openInNewWindow = (id: string) => {
|
|
120
153
|
const tab = getTab(id)
|
|
121
|
-
if (tab?.iframe && tab.url) {
|
|
154
|
+
if (tab?.iframe && tab.url && isAllowedTabUrl(tab.url)) {
|
|
122
155
|
window.open(tab.url, '_blank', 'noopener,noreferrer')
|
|
123
156
|
}
|
|
124
157
|
}
|
|
@@ -137,6 +170,7 @@ export default function useTabActions() {
|
|
|
137
170
|
|
|
138
171
|
// 激活与重置
|
|
139
172
|
activeTab: active,
|
|
173
|
+
moveTab,
|
|
140
174
|
reset,
|
|
141
175
|
|
|
142
176
|
// 数据与配置
|