vue-stack-tabs 1.0.1 → 1.3.0

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 (116) hide show
  1. package/README.en.md +526 -0
  2. package/README.md +337 -520
  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 +88 -0
  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 -23
  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 -73
  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 +17 -74
  37. package/dist/src/lib/hooks/useTabRouter.d.ts +5 -5
  38. package/dist/src/lib/i18n/index.d.ts +6 -4
  39. package/dist/src/lib/i18n/lang/en.d.ts +5 -0
  40. package/dist/src/lib/i18n/lang/zh-CN.d.ts +5 -0
  41. package/dist/src/lib/i18n/stackTabsLocale.d.ts +2 -0
  42. package/dist/src/lib/iframe-bridge.d.ts +8 -0
  43. package/dist/src/lib/iframe.vue.d.ts +2 -1
  44. package/dist/src/lib/index.d.ts +4 -5
  45. package/dist/src/lib/model/TabModel.d.ts +14 -33
  46. package/dist/src/lib/nuxt/module.d.ts +6 -0
  47. package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +2 -0
  48. package/dist/src/lib/nuxt/runtime/plugin.d.ts +2 -0
  49. package/dist/src/lib/utils/iframeBridge.d.ts +11 -3
  50. package/dist/src/lib/utils/stackTabsA11y.d.ts +8 -0
  51. package/dist/src/lib/utils/stackTabsMessage.d.ts +7 -0
  52. package/dist/src/lib/utils/tabInfoEncoder.d.ts +1 -16
  53. package/dist/src/lib/utils/typeGuards.d.ts +2 -0
  54. package/dist/src/lib/utils/urlParser.d.ts +24 -3
  55. package/dist/src/lib/versionLogger.d.ts +1 -0
  56. package/dist/style.css +2 -1
  57. package/dist/utils/iframeBridge.d.ts +39 -0
  58. package/dist/vue-stack-tabs.css +2 -1
  59. package/dist/vue-stack-tabs.es.js +1 -2411
  60. package/package.json +68 -47
  61. package/src/lib/StackTabs.vue +216 -192
  62. package/src/lib/assets/style/stackTab.scss +84 -12
  63. package/src/lib/components/ContextMenu/ContextMenuItem.vue +23 -11
  64. package/src/lib/components/ContextMenu/index.vue +94 -10
  65. package/src/lib/components/PageLoading.vue +9 -7
  66. package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +70 -0
  67. package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +57 -0
  68. package/src/lib/components/TabHeader/TabHeaderButton.vue +19 -8
  69. package/src/lib/components/TabHeader/TabHeaderItem.vue +45 -16
  70. package/src/lib/components/TabHeader/TabHeaderScroll.vue +47 -25
  71. package/src/lib/components/TabHeader/index.vue +109 -12
  72. package/src/lib/hooks/stackTabsContext.ts +135 -0
  73. package/src/lib/hooks/tabPanel/evict.ts +123 -41
  74. package/src/lib/hooks/tabPanel/navigationTransaction.ts +61 -0
  75. package/src/lib/hooks/tabPanel/refresh.ts +109 -0
  76. package/src/lib/hooks/tabPanel/scroll.ts +57 -36
  77. package/src/lib/hooks/tabPanel/session.ts +84 -22
  78. package/src/lib/hooks/tabPanel/state.ts +41 -44
  79. package/src/lib/hooks/useIframeManager.ts +253 -0
  80. package/src/lib/hooks/useTabActions.ts +62 -30
  81. package/src/lib/hooks/useTabEventBus.ts +29 -19
  82. package/src/lib/hooks/useTabLoading.ts +3 -2
  83. package/src/lib/hooks/useTabPanel.tsx +415 -213
  84. package/src/lib/hooks/useTabRouter.ts +172 -43
  85. package/src/lib/i18n/index.ts +53 -45
  86. package/src/lib/i18n/lang/en.ts +6 -1
  87. package/src/lib/i18n/lang/zh-CN.ts +6 -1
  88. package/src/lib/i18n/stackTabsLocale.ts +8 -0
  89. package/src/lib/iframe-bridge.ts +12 -0
  90. package/src/lib/index.ts +14 -19
  91. package/src/lib/model/TabModel.ts +34 -86
  92. package/src/lib/nuxt/module.d.ts +4 -0
  93. package/src/lib/nuxt/module.ts +8 -3
  94. package/src/lib/nuxt/runtime/plugin.client.ts +2 -3
  95. package/src/lib/nuxt/runtime/plugin.ts +11 -0
  96. package/src/lib/utils/iframeBridge.ts +29 -5
  97. package/src/lib/utils/scrollUtils.ts +9 -4
  98. package/src/lib/utils/stackTabsA11y.ts +12 -0
  99. package/src/lib/utils/stackTabsMessage.ts +99 -0
  100. package/src/lib/utils/tabInfoEncoder.ts +69 -42
  101. package/src/lib/utils/typeGuards.ts +3 -0
  102. package/src/lib/utils/urlParser.ts +144 -14
  103. package/src/lib/versionLogger.ts +7 -19
  104. package/dist/src/lib/hooks/tabPanel/evict.spec.d.ts +0 -1
  105. package/dist/src/lib/hooks/useTabPanel.spec.d.ts +0 -1
  106. package/dist/src/lib/model/TabModel.spec.d.ts +0 -1
  107. package/dist/src/lib/utils/iframeBridge.spec.d.ts +0 -1
  108. package/dist/src/lib/utils/tabInfoEncoder.spec.d.ts +0 -1
  109. package/dist/src/lib/utils/urlParser.spec.d.ts +0 -1
  110. package/dist/vue-stack-tabs.umd.js +0 -1
  111. package/src/lib/hooks/tabPanel/evict.spec.ts +0 -85
  112. package/src/lib/hooks/useTabPanel.spec.ts +0 -34
  113. package/src/lib/model/TabModel.spec.ts +0 -40
  114. package/src/lib/utils/iframeBridge.spec.ts +0 -37
  115. package/src/lib/utils/tabInfoEncoder.spec.ts +0 -42
  116. package/src/lib/utils/urlParser.spec.ts +0 -45
@@ -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,
@@ -58,56 +59,87 @@ export default function useTabActions() {
58
59
  defaults.iframeRefreshMode = (tab.iframeRefreshMode ?? 'postMessage') as IframeRefreshMode
59
60
  }
60
61
  const info = defu(tab, defaults)
61
- if (!info.id) info.id = ulid()
62
+ if (!info.id) info.id = crypto.randomUUID()
62
63
  return info
63
64
  }
64
65
 
65
- /** 打开新标签;renew=true 时若已存在则清空该标签的页面栈后重新打开;500ms 节流 */
66
- const openTab = throttle(500, (tab: ITabData, renew = false) => {
66
+ /** 打开新标签;renew=true 时若已存在则清空该标签的页面栈后重新打开 */
67
+ const openTab = (tab: ITabData, renew = false): Promise<string | undefined> => {
67
68
  return new Promise<string | undefined>((resolve, reject) => {
68
69
  const tabInfo = prepareTabInfo(tab)
69
70
 
70
- if (hasTab(tabInfo.id!) && !renew) {
71
- emitter.emit(TabEventType.TAB_ACTIVE, { id: tabInfo.id! })
72
- resolve(tabInfo.id)
71
+ const isExistingTab = Boolean(tabInfo.id && hasTab(tabInfo.id))
72
+
73
+ if (isExistingTab && !renew) {
74
+ Promise.resolve(active(tabInfo.id!)).then(() => resolve(tabInfo.id), reject)
73
75
  return
74
76
  }
75
77
 
76
- if (tabInfo.id && renew && hasTab(tabInfo.id)) {
77
- renewTab(tab)
78
+ let rollbackRenew: (() => void) | undefined
79
+ let refreshKeySnapshot: number | undefined
80
+
81
+ if (tabInfo.id && renew && isExistingTab) {
78
82
  const currentTab = getTab(tab.id!)
79
- if (currentTab?.active) refreshKey.value++
83
+ rollbackRenew = renewTab(tab)
84
+ if (currentTab?.active) {
85
+ refreshKeySnapshot = refreshKey.value
86
+ refreshKey.value++
87
+ }
80
88
  }
81
89
 
82
- if (!canAddTab()) {
90
+ if (!isExistingTab && !canAddTab()) {
83
91
  reject(new Error('Max Size'))
84
92
  return
85
93
  }
86
94
 
87
- navigateToTab(tab, tabInfo)
88
- resolve(tabInfo.id)
95
+ navigateToTab(tab, tabInfo).then(
96
+ () => resolve(tabInfo.id),
97
+ (error) => {
98
+ rollbackRenew?.()
99
+ if (refreshKeySnapshot !== undefined) refreshKey.value = refreshKeySnapshot
100
+ reject(error)
101
+ }
102
+ )
89
103
  })
90
- })
104
+ }
91
105
 
92
106
  /** 执行路由跳转到标签对应页面(普通路由或 iframe 占位路由) */
93
107
  const navigateToTab = (tab: ITabData, tabInfo: ITabData) => {
94
108
  const __tab = encodeTabInfo(tabInfo)
95
- let query = defu({ __tab }, tab.query) as Record<string, string>
109
+ const tabQuery = omitStackTabsReservedQuery(tab.query)
110
+ let query = defu(tabQuery, { __tab }) as LocationQueryRaw
96
111
  let path: string
112
+
97
113
  if (!tab.iframe) {
98
114
  const url = parseUrl(tab.path)
99
115
  path = url.path
100
- query = defu(url.query, query)
116
+ query = defu(omitStackTabsReservedQuery(url.query), query, { __tab }) as LocationQueryRaw
117
+ query = {
118
+ ...query,
119
+ __tab
120
+ }
101
121
  } else {
102
- query['__src'] = encodeURIComponent(tab.path as string)
103
- path = iframePath
122
+ query = {
123
+ ...query,
124
+ __src: encodeURIComponent(tab.path as string),
125
+ __tab
126
+ }
127
+ path = runtimeContext.iframePath.value
104
128
  }
105
- router.push({ path, query })
129
+
130
+ if (!isAllowedTabUrl(tab.path)) return Promise.reject(new Error('Invalid tab URL'))
131
+ return runNavigationTransaction({
132
+ apply: () => undefined,
133
+ navigate: () => router.push({ path, query }),
134
+ rollback: () => undefined,
135
+ isFailureResult: isNavigationFailure,
136
+ rejectFailureResult: true
137
+ })
106
138
  }
107
139
 
108
140
  /** 设置 iframe 占位路由的 path,由 StackTabs 在挂载时调用 */
109
141
  const setIFramePath = (path: string) => {
110
- iframePath = path
142
+ runtimeContext.iframePath.value = path
111
143
  }
112
144
 
113
145
  /** 获取当前页面的缓存容器 DOM(用于某些需要直接操作 DOM 的场景) */
@@ -118,7 +150,7 @@ export default function useTabActions() {
118
150
  /** iframe 标签在新窗口打开(无法嵌入时降级) */
119
151
  const openInNewWindow = (id: string) => {
120
152
  const tab = getTab(id)
121
- if (tab?.iframe && tab.url) {
153
+ if (tab?.iframe && tab.url && isAllowedTabUrl(tab.url)) {
122
154
  window.open(tab.url, '_blank', 'noopener,noreferrer')
123
155
  }
124
156
  }
@@ -1,31 +1,41 @@
1
+ /**
2
+ * 标签事件总线。
3
+ *
4
+ * 基于 mitt 实现组件间事件通信,用于 tab 激活、页面 loading、
5
+ * 栈内导航(forward/backward)、iframe 刷新等事件的发布/订阅。
6
+ */
7
+ import type { InjectionKey } from 'vue'
8
+ import { hasInjectionContext, inject } from 'vue'
1
9
  import type { Emitter, EventType } from 'mitt'
2
- import mitt from 'mitt'
3
- import { type App, inject } from 'vue'
10
+ import { resolveStackTabsRuntimeContext } from './stackTabsContext'
4
11
 
5
12
  declare module '@vue/runtime-core' {
6
13
  export interface ComponentCustomProperties {
7
- $layerEmitter: Emitter<Record<EventType, unknown>>
14
+ $layerEmitter: Emitter<TabEventPayloadMap>
8
15
  }
9
16
  }
10
17
 
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
18
  /** 标签相关事件类型 */
26
19
  export enum TabEventType {
27
20
  PAGE_LOADING = 'PAGE_LOADING',
28
- TAB_ACTIVE = 'TAB_ACTIVE'
21
+ TAB_ACTIVE = 'TAB_ACTIVE',
22
+ FORWARD = 'FORWARD',
23
+ BACKWARD = 'BACKWARD',
24
+ REFRESH_IFRAME_POSTMESSAGE = 'REFRESH_IFRAME_POSTMESSAGE'
25
+ }
26
+
27
+ export interface TabEventPayloadMap extends Record<EventType, unknown> {
28
+ [TabEventType.PAGE_LOADING]: { tId: string; value: boolean }
29
+ [TabEventType.TAB_ACTIVE]: { id: string; isRoute?: boolean }
30
+ [TabEventType.FORWARD]: void
31
+ [TabEventType.BACKWARD]: void
32
+ [TabEventType.REFRESH_IFRAME_POSTMESSAGE]: string
29
33
  }
30
34
 
31
- export default plugin
35
+ export const tabEmitterKey: InjectionKey<Emitter<TabEventPayloadMap>> = Symbol('tabEmitter')
36
+
37
+ /** 获取标签事件总线,用于组件间通信 */
38
+ export const useTabEmitter = (): Emitter<TabEventPayloadMap> => {
39
+ const injected = hasInjectionContext() ? inject(tabEmitterKey, null) : null
40
+ return injected ?? resolveStackTabsRuntimeContext().eventBus
41
+ }
@@ -10,17 +10,18 @@ import { TabEventType, useTabEmitter } from '../hooks/useTabEventBus'
10
10
  export default () => {
11
11
  const emitter = useTabEmitter()
12
12
  const { attrs } = getCurrentInstance() as ComponentInternalInstance
13
+ const getTabId = () => String(attrs.tId ?? '')
13
14
  onUnmounted(() => {
14
15
  closeTabLoading()
15
16
  })
16
17
  /** 显示加载遮罩 */
17
18
  const openTabLoading = () => {
18
- emitter.emit(TabEventType.PAGE_LOADING, { tId: attrs.tId, value: true })
19
+ emitter.emit(TabEventType.PAGE_LOADING, { tId: getTabId(), value: true })
19
20
  }
20
21
 
21
22
  /** 隐藏加载遮罩 */
22
23
  const closeTabLoading = () => {
23
- emitter.emit(TabEventType.PAGE_LOADING, { tId: attrs.tId, value: false })
24
+ emitter.emit(TabEventType.PAGE_LOADING, { tId: getTabId(), value: false })
24
25
  }
25
26
  return { openTabLoading, closeTabLoading }
26
27
  }