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
@@ -1,33 +1,95 @@
1
1
  /**
2
2
  * tabPanel/session - Session 持久化
3
3
  *
4
- * 职责:将当前激活标签保存到 sessionStorage,刷新后恢复
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 type { ITabPage } from '../../model/TabModel'
9
- import { SESSION_TAB_NAME, sessionPrefix } from './state'
8
+ import { isRecord } from '../../utils/typeGuards'
9
+ import type { StackTabsRuntimeContext } from '../stackTabsContext'
10
+ import { SESSION_TAB_NAME } from './state'
10
11
 
11
- /** 获取 sessionStorage 的 key */
12
- export const getSessionKey = () => sessionPrefix + SESSION_TAB_NAME
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
- /** 将当前激活的标签保存到 sessionStorage */
15
- export const saveActiveTabToSession = (tab: ITabItem) => {
16
- if (!tab.id) return
17
- window.sessionStorage.setItem(getSessionKey(), JSON.stringify(tab))
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
- /** 清除 sessionStorage 中的激活标签 */
21
- export const clearSession = () => {
22
- window.sessionStorage.removeItem(getSessionKey())
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
- /** 从 sessionStorage 解析恢复的标签(含 Stack 反序列化) */
26
- export const restoreTabFromSession = (
27
- storedJson: string | null
28
- ): ITabItem | null => {
29
- if (storedJson == null) return null
30
- return JSON.parse(storedJson, (k, v) =>
31
- k === 'pages' ? new Stack<ITabPage>(v) : v
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
- * 职责:集中管理 tabs、caches、components、驱逐集合、滚动位置等
5
- * 被 useTabPanel、evict、scroll、session 导入使用
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
- export const tabs = ref<ITabItem[]>([])
12
- /** 初始化时传入的默认标签(用于对比 session 恢复) */
13
- export const defaultTabs: ITabItem[] = []
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
- /** iframe 标签 tabId -> 刷新序号,变更时触发 iframe 重载 */
35
- export const iframeRefreshKeys = ref<Record<string, number>>({})
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
- /** 最大标签数量,0 表示不限制 */
38
- export let maxTabCount = 0
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 setMaxTabCount = (n: number) => {
45
- maxTabCount = n
46
- }
47
- export const setUseGlobalScroll = (v: boolean) => {
48
- useGlobalScroll = v
49
- }
50
- export const setSessionPrefix = (p: string) => {
51
- sessionPrefix = p
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 { throttle } from 'throttle-debounce'
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 { parseUrl, isCrossOriginUrl } from '../utils/urlParser'
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 { TabEventType, useTabEmitter } from './useTabEventBus'
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 emitter = useTabEmitter()
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 = ulid()
63
+ if (!info.id) info.id = crypto.randomUUID()
62
64
  return info
63
65
  }
64
66
 
65
- /** 打开新标签;renew=true 时若已存在则清空该标签的页面栈后重新打开;500ms 节流 */
66
- const openTab = throttle(500, (tab: ITabData, renew = false) => {
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
- if (hasTab(tabInfo.id!) && !renew) {
71
- emitter.emit(TabEventType.TAB_ACTIVE, { id: tabInfo.id! })
72
- resolve(tabInfo.id)
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
- if (tabInfo.id && renew && hasTab(tabInfo.id)) {
77
- renewTab(tab)
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
- if (currentTab?.active) refreshKey.value++
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
- resolve(tabInfo.id)
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
- let query = defu({ __tab }, tab.query) as Record<string, string>
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['__src'] = encodeURIComponent(tab.path as string)
103
- path = iframePath
123
+ query = {
124
+ ...query,
125
+ __src: encodeURIComponent(tab.path as string),
126
+ __tab
127
+ }
128
+ path = runtimeContext.iframePath.value
104
129
  }
105
- router.push({ path, query })
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
  // 数据与配置