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
@@ -6,51 +6,61 @@
6
6
  - 包裹在 router-view 外层,将路由渲染到对应标签的缓存页面中
7
7
 
8
8
  数据流:
9
- - useTabPanel:tabs/caches/excludedCacheIdsForRefresh/refreshKey
9
+ - useTabPanel:tabs、缓存列表与页面级刷新版本
10
10
  - useTabActions:openTab/closeTab 等,需 setIFramePath
11
11
  - keep-alive include=caches,exclude=excludedCacheIdsForRefresh(刷新时排除)
12
12
  -->
13
13
  <script lang="ts" setup>
14
- import { computed, onBeforeMount, onBeforeUnmount, provide, reactive, ref, watch } from 'vue'
15
- import type { TransitionProps, DefineComponent, VNode } from 'vue'
16
- import { type RouteLocationNormalizedLoaded } from 'vue-router'
14
+ import {
15
+ computed,
16
+ getCurrentInstance,
17
+ inject,
18
+ onBeforeMount,
19
+ onBeforeUnmount,
20
+ provide,
21
+ ref,
22
+ watch
23
+ } from 'vue'
24
+ import type { TransitionProps } from 'vue'
17
25
  import { getMaxZIndex } from './utils/scrollUtils'
18
- import { isInvalidIframeUrl } from './utils/urlParser'
26
+ import { isAllowedTabUrl } from './utils/urlParser'
27
+ import { applyStackTabsLocale } from './i18n/stackTabsLocale'
19
28
  import { type ITabData, TabScrollMode } from './model/TabModel'
20
29
  import TabHeader from './components/TabHeader/index.vue'
30
+ import StackKeepAlive from './components/StackKeepAlive/StackKeepAlive.vue'
21
31
  import useTabPanel from './hooks/useTabPanel'
22
32
  import useTabActions from './hooks/useTabActions'
23
33
  import { useI18n } from 'vue-i18n-lite'
24
- import { useTabEmitter } from '@/lib/hooks/useTabEventBus'
25
- const {
26
- tabs,
27
- caches,
28
- refreshKey,
29
- excludedCacheIdsForRefresh,
30
- iframeRefreshKeys,
31
- destroy,
32
- addPage,
33
- initialize,
34
- setMaxSize,
35
- setGlobalScroll,
36
- setSessionPrefix
37
- } = useTabPanel()
38
- /** keep-alive 的 include 列表;excludedCacheIdsForRefresh 为刷新时临时 exclude,触发重挂载 */
34
+ import { TabEventType, tabEmitterKey } from './hooks/useTabEventBus'
35
+ import {
36
+ createStackTabsRuntimeContext,
37
+ maximumKey,
38
+ registerStackTabsRuntimeContext,
39
+ stackTabsContextKey,
40
+ unregisterStackTabsRuntimeContext
41
+ } from './hooks/stackTabsContext'
42
+ import { getStackTabPanelId, getStackTabTabId } from './utils/stackTabsA11y'
43
+ import { useIframeManager } from './hooks/useIframeManager'
44
+ type StackTabsIframeReferrerPolicy =
45
+ | ''
46
+ | 'no-referrer'
47
+ | 'no-referrer-when-downgrade'
48
+ | 'origin'
49
+ | 'origin-when-cross-origin'
50
+ | 'same-origin'
51
+ | 'strict-origin'
52
+ | 'strict-origin-when-cross-origin'
53
+ | 'unsafe-url'
54
+
39
55
  const emit = defineEmits(['onActive', 'onPageLoaded'])
40
56
  const props = withDefaults(
41
57
  defineProps<{
42
- // 初始页签数据
43
58
  defaultTabs?: ITabData[]
44
- // 最大打开数量
45
59
  max?: number
46
- // 右键菜单
47
60
  contextmenu?: boolean | object
48
- // 页面转场效果
49
61
  pageTransition?: string
50
62
  pageTransitionBack?: string
51
- // 标签转场效果
52
63
  tabTransition?: string | TransitionProps
53
- // tab 滚动效果
54
64
  tabScrollMode?: TabScrollMode
55
65
  width?: string
56
66
  height?: string
@@ -59,8 +69,11 @@ const props = withDefaults(
59
69
  globalScroll?: boolean
60
70
  iframePath: string
61
71
  sessionPrefix?: string
62
- /** 允许通过 postMessage 调用 openTab 的 iframe 来源,默认仅同源 */
63
72
  iframeAllowedOrigins?: string[]
73
+ iframeSandbox?: string
74
+ iframeReferrerPolicy?: StackTabsIframeReferrerPolicy
75
+ iframeAllow?: string
76
+ iframeLoadTimeout?: number
64
77
  }>(),
65
78
  {
66
79
  defaultTabs: () => [],
@@ -72,198 +85,169 @@ const props = withDefaults(
72
85
  tabScrollMode: TabScrollMode.BOTH,
73
86
  width: '100%',
74
87
  height: '100%',
75
- i18n: 'zh-CN',
88
+ i18n: undefined,
76
89
  space: 300,
77
90
  globalScroll: false,
78
91
  sessionPrefix: '',
79
- iframeAllowedOrigins: () => []
92
+ iframeAllowedOrigins: () => [],
93
+ iframeSandbox: 'allow-scripts allow-forms allow-popups allow-downloads allow-same-origin',
94
+ iframeReferrerPolicy: 'strict-origin-when-cross-origin' as StackTabsIframeReferrerPolicy,
95
+ iframeAllow: '',
96
+ iframeLoadTimeout: 15000
80
97
  }
81
98
  )
82
99
  const { changeLocale, t } = useI18n()
83
- /** 是否最大化显示,通过 provide 向下传递 */
100
+ const runtimeContext =
101
+ inject(stackTabsContextKey, null) ??
102
+ createStackTabsRuntimeContext({
103
+ iframePath: props.iframePath,
104
+ maxTabCount: props.max,
105
+ useGlobalScroll: props.globalScroll,
106
+ sessionPrefix: props.sessionPrefix
107
+ })
108
+ const stackTabsApp = getCurrentInstance()?.appContext.app
109
+ const isRuntimeContextOwner = registerStackTabsRuntimeContext(
110
+ runtimeContext,
111
+ stackTabsApp ? { app: stackTabsApp } : undefined
112
+ )
113
+ if (isRuntimeContextOwner) {
114
+ provide(stackTabsContextKey, runtimeContext)
115
+ provide(tabEmitterKey, runtimeContext.eventBus)
116
+ }
84
117
  const maximum = ref<boolean>(false)
85
- provide('maximum', maximum)
86
- const { setIFramePath, openTab } = useTabActions()
87
- setIFramePath(props.iframePath)
88
- setGlobalScroll(props.globalScroll)
89
- changeLocale(props.i18n)
90
- /** 将 router-view 的 Component 包装为带缓存 id 的页面组件,交给 addPage 注册到对应标签 */
91
- const tabWrapper = (route: RouteLocationNormalizedLoaded, component: VNode): DefineComponent => {
92
- return addPage(route, component)
118
+ const maximumZIndex = ref<number>()
119
+ if (isRuntimeContextOwner) {
120
+ provide(maximumKey, maximum)
121
+ }
122
+ watch(maximum, (isMaximum) => {
123
+ maximumZIndex.value = isMaximum ? getMaxZIndex('body *:not(.stack-tab,.stack-tab *)') : undefined
124
+ })
125
+ const panelApi = isRuntimeContextOwner
126
+ ? useTabPanel()
127
+ : {
128
+ tabs: runtimeContext.tabs,
129
+ iframeRefreshKeys: runtimeContext.iframeRefreshKeys,
130
+ destroy: () => undefined,
131
+ initialize: () => undefined,
132
+ setMaxSize: () => undefined,
133
+ setGlobalScroll: () => undefined,
134
+ setSessionPrefix: () => undefined
135
+ }
136
+ const {
137
+ tabs,
138
+ iframeRefreshKeys,
139
+ destroy,
140
+ initialize,
141
+ setMaxSize,
142
+ setGlobalScroll,
143
+ setSessionPrefix
144
+ } = panelApi
145
+ const tabActions = isRuntimeContextOwner
146
+ ? useTabActions()
147
+ : {
148
+ setIFramePath: () => undefined,
149
+ openTab: () => Promise.resolve(undefined),
150
+ openInNewWindow: () => undefined
151
+ }
152
+ const { setIFramePath, openTab, openInNewWindow } = tabActions
153
+ if (isRuntimeContextOwner) {
154
+ setIFramePath(props.iframePath)
155
+ setGlobalScroll(props.globalScroll)
156
+ setMaxSize(props.max)
157
+ applyStackTabsLocale(changeLocale, props.i18n)
93
158
  }
94
- /** 标签激活时向外转发 */
159
+
95
160
  const onTabActive = (id: string) => {
96
161
  emit('onActive', id)
97
162
  }
98
- setMaxSize(props.max)
99
- /** 页面组件加载完成时向外转发 */
100
163
  const onComponentLoaded = () => {
101
164
  emit('onPageLoaded')
102
165
  }
103
- /** 当前页面转场动画名,前进/后退时由 useTabRouter 发出 FORWARD/BACKWARD 事件切换 */
166
+
104
167
  const pageSwitch = ref<string>(props.pageTransition)
105
- const emitter = useTabEmitter()
106
- /** 前进时使用 pageTransition */
168
+ const emitter = runtimeContext.eventBus
107
169
  const forwardHandler = () => {
108
170
  pageSwitch.value = props.pageTransition
109
171
  }
110
- /** 后退时使用 pageTransitionBack */
111
172
  const backwardHandler = () => {
112
173
  pageSwitch.value = props.pageTransitionBack
113
174
  }
114
- emitter.on('FORWARD', forwardHandler)
115
- emitter.on('BACKWARD', backwardHandler)
116
-
117
- /** iframe 标签列表,computed 避免重复 filter */
118
- const iframeTabs = computed(() => tabs.value.filter((item) => item.iframe))
119
-
120
- /** 已激活过的 iframe 使用真实 URL(保留内容),未激活过的用 about:blank(按需加载,切换不重载) */
121
- const iframeEverActivated = reactive<Record<string, boolean>>({})
122
- /** 当前激活且有有效 URL 的 iframe 列表(供 watch 复用,减少重复 filter) */
123
- const activeIframesWithUrl = computed(() =>
124
- iframeTabs.value.filter((f) => f.active && (f.url ?? '') && !isInvalidIframeUrl(f.url ?? ''))
125
- )
126
- watch(
127
- activeIframesWithUrl,
128
- (frames) => {
129
- for (const f of frames) iframeEverActivated[f.id] = true
130
- },
131
- { immediate: true, deep: true }
132
- )
133
- /**
134
- * 获取 iframe 的 src:未激活过的用 about:blank 延迟加载,已激活的保留真实 URL
135
- * @param frame - iframe 标签项
136
- * @returns 实际加载的 URL
137
- */
138
- const getIframeSrc = (frame: { id: string; url?: string }) => {
139
- const url = frame.url ?? ''
140
- if (isInvalidIframeUrl(url)) return 'about:blank'
141
- return iframeEverActivated[frame.id] ? url : 'about:blank'
142
- }
143
-
144
- /** iframe 的 key:postMessage 模式仅用 id(不重建,动画正常);reload 模式含 refreshKeys */
145
- const getIframeKey = (frame: { id: string; iframeRefreshMode?: string }) =>
146
- frame.iframeRefreshMode === 'reload'
147
- ? `${frame.id}-${iframeRefreshKeys.value[frame.id] ?? 0}`
148
- : frame.id
149
-
150
- /** 各 iframe 是否已加载过(切回时不再显示 loading,因 load 不会再次触发) */
151
- const iframeHasLoaded = reactive<Record<string, boolean>>({})
152
- /** 各 iframe 加载状态,仅在首次加载时显示 */
153
- const iframeLoadingStates = reactive<Record<string, boolean>>({})
154
- /** iframe 加载超时定时器 Map,用于兜底清理 loading 状态 */
155
- const loadTimeoutIds = new Map<string, ReturnType<typeof setTimeout>>()
156
- /** 加载超时时间(ms),超时后强制隐藏 loading */
157
- const LOAD_TIMEOUT_MS = 5000
158
-
159
- /** 标记 iframe 已加载完成,清除超时并隐藏 loading */
160
- const setIframeLoaded = (frameId: string) => {
161
- const tid = loadTimeoutIds.get(frameId)
162
- if (tid) {
163
- clearTimeout(tid)
164
- loadTimeoutIds.delete(frameId)
165
- }
166
- iframeHasLoaded[frameId] = true
167
- iframeLoadingStates[frameId] = false
175
+ if (isRuntimeContextOwner) {
176
+ emitter.on(TabEventType.FORWARD, forwardHandler)
177
+ emitter.on(TabEventType.BACKWARD, backwardHandler)
168
178
  }
169
179
 
170
- /** 为 iframe 显示 loading 状态,已加载过的切回时不重复显示;设置超时兜底 */
171
- const setIframeLoading = (frameId: string) => {
172
- if (iframeHasLoaded[frameId]) return // 已加载过,切回时不显示 loading
173
- iframeLoadingStates[frameId] = true
174
- const tid = loadTimeoutIds.get(frameId)
175
- if (tid) clearTimeout(tid)
176
- loadTimeoutIds.set(
177
- frameId,
178
- setTimeout(() => {
179
- loadTimeoutIds.delete(frameId)
180
- iframeLoadingStates[frameId] = false
181
- }, LOAD_TIMEOUT_MS)
182
- )
183
- }
184
-
185
- // 当 iframe 标签变为激活且有真实 URL 时,显示加载状态(仅首次)
186
- watch(
187
- activeIframesWithUrl,
188
- (frames) => {
189
- for (const f of frames) setIframeLoading(f.id)
190
- },
191
- { immediate: true, deep: true }
192
- )
193
-
194
- /** iframe 元素 refs,key 为 tabId,用于 postMessage 刷新时获取 contentWindow */
195
- const iframeElRefs = reactive<Record<string, HTMLIFrameElement | null>>({})
196
- /** 注册/注销 iframe 的 DOM 引用 */
197
- const setIframeRef = (id: string, el: HTMLIFrameElement | null) => {
198
- if (el) {
199
- iframeElRefs[id] = el
200
- } else {
201
- delete iframeElRefs[id]
202
- }
203
- }
204
-
205
- /** postMessage 刷新:向 iframe 发送消息,由其自行刷新 */
206
- const handleRefreshIframePostMessage = (payload: unknown) => {
207
- const tabId = payload as string
208
- const iframe = iframeElRefs[tabId]
209
- if (iframe?.contentWindow) {
210
- try {
211
- iframe.contentWindow.postMessage({ type: 'vue-stack-tabs:refresh' }, '*')
212
- } catch {
213
- /* 跨域时可能失败,忽略 */
214
- }
215
- }
216
- }
180
+ const {
181
+ iframeTabs,
182
+ activeNonIframeTab,
183
+ hasActiveNonIframeTab,
184
+ iframeSrcMap,
185
+ getIframeRefreshKey,
186
+ getIframeKey,
187
+ getIframeLoadState,
188
+ shouldShowIframeLoading,
189
+ shouldShowIframeError,
190
+ retryIframe,
191
+ setIframeRef,
192
+ handleIframeLoad,
193
+ handleRefreshIframePostMessage,
194
+ createMessageHandler
195
+ } = useIframeManager({
196
+ tabs,
197
+ iframeRefreshKeys,
198
+ iframeLoadTimeout: props.iframeLoadTimeout,
199
+ t
200
+ })
217
201
 
218
- /** 允许通过 postMessage 调用 openTab 的来源白名单 */
219
202
  const allowedOrigins = computed(() => {
220
203
  const origin = typeof window !== 'undefined' ? window.location.origin : ''
221
204
  const extra = props.iframeAllowedOrigins ?? []
222
205
  return [origin, ...extra].filter(Boolean)
223
206
  })
224
- /** 处理 iframe 发来的 postMessage,校验 origin 后调用 openTab */
225
- const handleMessage = (ev: MessageEvent) => {
226
- if (!allowedOrigins.value.includes(ev.origin)) return
227
- const data = ev.data
228
- if (!data || typeof data !== 'object' || data.type !== 'vue-stack-tabs:openTab') return
229
- const payload = data.payload
230
- if (payload && typeof payload === 'object' && payload.title && payload.path) {
231
- openTab(payload)
232
- }
233
- }
207
+ const handleMessage = createMessageHandler(allowedOrigins, openTab)
234
208
 
235
- ;(emitter as { on: (t: string, h: (p: unknown) => void) => void }).on(
236
- 'REFRESH_IFRAME_POSTMESSAGE',
237
- handleRefreshIframePostMessage
238
- )
209
+ if (isRuntimeContextOwner) {
210
+ emitter.on(TabEventType.REFRESH_IFRAME_POSTMESSAGE, handleRefreshIframePostMessage)
211
+ }
239
212
 
240
213
  onBeforeMount(() => {
214
+ if (!isRuntimeContextOwner) return
241
215
  setSessionPrefix(props.sessionPrefix)
242
216
  initialize(props.defaultTabs)
243
217
  window.addEventListener('message', handleMessage)
244
218
  })
245
219
  onBeforeUnmount(() => {
220
+ if (!isRuntimeContextOwner) return
246
221
  window.removeEventListener('message', handleMessage)
247
- ;(emitter as { off: (t: string, h: (p: unknown) => void) => void }).off(
248
- 'REFRESH_IFRAME_POSTMESSAGE',
249
- handleRefreshIframePostMessage
250
- )
251
- emitter.off('FORWARD', forwardHandler)
252
- emitter.off('BACKWARD', backwardHandler)
222
+ emitter.off(TabEventType.REFRESH_IFRAME_POSTMESSAGE, handleRefreshIframePostMessage)
223
+ emitter.off(TabEventType.FORWARD, forwardHandler)
224
+ emitter.off(TabEventType.BACKWARD, backwardHandler)
253
225
  destroy()
226
+ unregisterStackTabsRuntimeContext(
227
+ runtimeContext,
228
+ stackTabsApp ? { app: stackTabsApp } : undefined
229
+ )
254
230
  })
255
231
  </script>
256
232
  <template>
257
233
  <div
234
+ v-if="isRuntimeContextOwner"
258
235
  class="stack-tab"
259
236
  :style="{
260
237
  width: width,
261
238
  height: height,
262
- 'z-index': maximum ? getMaxZIndex('body *:not(.stack-tab,.stack-tab *)') : undefined
239
+ 'z-index': maximumZIndex
263
240
  }"
264
241
  :class="{ 'stack-tab__maximum': maximum }"
265
242
  >
266
- <tab-header :space="space" :tab-transition="tabTransition" :max="max" @active="onTabActive">
243
+ <tab-header
244
+ :space="space"
245
+ :tab-transition="tabTransition"
246
+ :tab-scroll-mode="tabScrollMode"
247
+ :contextmenu="contextmenu"
248
+ :max="max"
249
+ @active="onTabActive"
250
+ >
267
251
  <template #leftButton>
268
252
  <slot name="leftButton" />
269
253
  </template>
@@ -272,49 +256,89 @@ onBeforeUnmount(() => {
272
256
  </template>
273
257
  </tab-header>
274
258
  <div class="stack-tab__container">
275
- <router-view v-slot="{ Component, route }">
276
- <transition :name="pageSwitch" appear mode="out-in">
277
- <keep-alive :include="caches" :exclude="excludedCacheIdsForRefresh">
278
- <component
279
- :is="tabWrapper(route, Component)"
280
- :key="`${route.fullPath}_${refreshKey}`"
281
- @on-loaded="onComponentLoaded"
282
- />
283
- </keep-alive>
284
- </transition>
285
- </router-view>
286
- <transition-group
287
- :name="pageTransition"
288
- tag="div"
289
- class="stack-tab__iframes"
290
- appear
291
- >
259
+ <Transition :name="pageSwitch" appear>
260
+ <div
261
+ v-show="hasActiveNonIframeTab"
262
+ :id="activeNonIframeTab ? getStackTabPanelId(activeNonIframeTab.id) : undefined"
263
+ :key="'keep-alive'"
264
+ class="stack-tab__keep-alive-panel"
265
+ :role="activeNonIframeTab ? 'tabpanel' : undefined"
266
+ :aria-labelledby="
267
+ activeNonIframeTab ? getStackTabTabId(activeNonIframeTab.id) : undefined
268
+ "
269
+ :aria-hidden="activeNonIframeTab ? undefined : 'true'"
270
+ :hidden="!hasActiveNonIframeTab"
271
+ >
272
+ <StackKeepAlive :transition-name="pageSwitch" @loaded="onComponentLoaded" />
273
+ </div>
274
+ </Transition>
275
+ <div class="stack-tab__iframes">
292
276
  <Transition
293
277
  v-for="frame of iframeTabs"
294
278
  :key="getIframeKey(frame)"
295
- :name="pageTransition"
279
+ :name="pageSwitch"
296
280
  appear
297
281
  >
298
282
  <div
299
283
  v-show="frame.active"
284
+ :id="getStackTabPanelId(frame.id)"
285
+ :key="getIframeKey(frame)"
300
286
  class="stack-tab__iframe-wrapper"
287
+ role="tabpanel"
288
+ :aria-labelledby="getStackTabTabId(frame.id)"
301
289
  >
302
290
  <div
303
- v-show="iframeLoadingStates[frame.id] !== false"
291
+ v-if="shouldShowIframeLoading(frame.id)"
304
292
  class="stack-tab__iframe-loading"
293
+ role="status"
294
+ aria-live="polite"
295
+ :aria-label="t('VueStackTab.loading')"
296
+ >
297
+ <slot name="iframeLoading" :tab="frame">
298
+ <span class="stack-tab__iframe-loading-text">{{ t('VueStackTab.loading') }}</span>
299
+ </slot>
300
+ </div>
301
+ <div
302
+ v-if="shouldShowIframeError(frame.id)"
303
+ class="stack-tab__iframe-error"
304
+ role="alert"
305
305
  >
306
- <span class="stack-tab__iframe-loading-text">{{ t('VueStackTab.loading') }}</span>
306
+ <slot name="iframeError" :tab="frame" :retry="retryIframe">
307
+ <span class="stack-tab__iframe-error-text">
308
+ {{ getIframeLoadState(frame.id).message ?? t('VueStackTab.iframeLoadTimeout') }}
309
+ </span>
310
+ <button
311
+ type="button"
312
+ class="stack-tab__iframe-error-retry"
313
+ @click="retryIframe(frame.id)"
314
+ >
315
+ {{ t('VueStackTab.retry') }}
316
+ </button>
317
+ <button
318
+ v-if="frame.url && isAllowedTabUrl(frame.url)"
319
+ type="button"
320
+ class="stack-tab__iframe-error-open"
321
+ @click="openInNewWindow(frame.id)"
322
+ >
323
+ {{ t('VueStackTab.openInNewWindow') }}
324
+ </button>
325
+ </slot>
307
326
  </div>
308
327
  <iframe
309
328
  :ref="(el) => setIframeRef(frame.id, el as HTMLIFrameElement | null)"
310
329
  class="stack-tab__iframe"
311
- :src="getIframeSrc(frame)"
330
+ :src="iframeSrcMap[frame.id] || 'about:blank'"
331
+ :data-refresh-key="getIframeRefreshKey(frame.id)"
332
+ :title="frame.title || t('VueStackTab.iframeTitle') || 'Stack tab iframe'"
333
+ :sandbox="iframeSandbox || undefined"
334
+ :referrerpolicy="iframeReferrerPolicy"
335
+ :allow="iframeAllow || undefined"
312
336
  frameborder="0"
313
- @load="setIframeLoaded(frame.id)"
337
+ @load="handleIframeLoad(frame, $event)"
314
338
  />
315
339
  </div>
316
340
  </Transition>
317
- </transition-group>
341
+ </div>
318
342
  </div>
319
343
  </div>
320
344
  </template>
@@ -14,6 +14,14 @@
14
14
  position: relative;
15
15
  overflow: hidden;
16
16
  }
17
+ &__keep-alive-panel {
18
+ position: absolute;
19
+ top: 0;
20
+ left: 0;
21
+ width: 100%;
22
+ height: 100%;
23
+ }
24
+
17
25
  /*缓存页面wrapper*/
18
26
  .cache-page-wrapper {
19
27
  width: 100%;
@@ -75,6 +83,12 @@
75
83
  cursor: pointer;
76
84
  display: flex;
77
85
  align-items: center;
86
+ border: 0;
87
+ padding: 0;
88
+ color: inherit;
89
+ font: inherit;
90
+ text-align: inherit;
91
+ appearance: none;
78
92
 
79
93
  //按钮禁用样式
80
94
  &[disabled] {
@@ -216,6 +230,22 @@
216
230
  }
217
231
  }
218
232
 
233
+ &-tab {
234
+ display: flex;
235
+ flex: 1 1 auto;
236
+ min-width: 0;
237
+ height: 100%;
238
+ padding: 0;
239
+ color: inherit;
240
+ cursor: inherit;
241
+ background: transparent;
242
+ border: 0;
243
+ font: inherit;
244
+ text-align: inherit;
245
+ appearance: none;
246
+ align-items: center;
247
+ }
248
+
219
249
  /*
220
250
  页签标题
221
251
  */
@@ -247,6 +277,10 @@
247
277
  width: 0;
248
278
  background-color: $stack-tab-text-color;
249
279
  transition: $stack-tab__trans;
280
+ border: 0;
281
+ font: inherit;
282
+ appearance: none;
283
+ padding: 0;
250
284
 
251
285
  &:hover {
252
286
  background-color: $stack-tab-color-primary;
@@ -299,17 +333,7 @@
299
333
  left: 0;
300
334
  width: 100%;
301
335
  height: 100%;
302
- pointer-events: none; /* 允许鼠标事件穿透到下层,避免遮挡其他标签 */
303
- /* 进入的 iframe 置于上层;延迟进入,等待其他 tab 离开动画完成后再播放入场 */
304
- .stack-tab-swap-enter-active {
305
- z-index: 1;
306
- transition-duration: 0.5s;
307
- transition-delay: 0.3s;
308
- }
309
- .stack-tab-swap-leave-active {
310
- z-index: 0;
311
- position: absolute;
312
- }
336
+ pointer-events: none;
313
337
  }
314
338
 
315
339
  &__iframe-wrapper {
@@ -347,6 +371,39 @@
347
371
  font-size: var(--stack-tab-font-size, 14px);
348
372
  }
349
373
  }
374
+
375
+ &__iframe-error {
376
+ position: absolute;
377
+ top: 0;
378
+ left: 0;
379
+ z-index: 2;
380
+ display: flex;
381
+ gap: 8px;
382
+ align-items: center;
383
+ justify-content: center;
384
+ width: 100%;
385
+ height: 100%;
386
+ padding: 16px;
387
+ pointer-events: auto;
388
+ background: var(--stack-tab-iframe-error-bg, rgb(255 255 255 / 92%));
389
+ box-sizing: border-box;
390
+
391
+ &-text {
392
+ color: var(--stack-tab-iframe-error-color, #666);
393
+ font-size: var(--stack-tab-font-size, 14px);
394
+ }
395
+
396
+ &-retry,
397
+ &-open {
398
+ border: $stack-tab-border;
399
+ padding: 4px 10px;
400
+ color: $stack-tab-text-color;
401
+ cursor: pointer;
402
+ background: $stack-tab-background-color-base;
403
+ border-radius: 6px;
404
+ font: inherit;
405
+ }
406
+ }
350
407
  /*
351
408
  右键菜单
352
409
  */
@@ -367,16 +424,27 @@
367
424
  display: flex;
368
425
  position: relative;
369
426
  align-items: center;
427
+ justify-content: flex-start;
428
+ width: 100%;
429
+ min-height: 30px;
430
+ margin: 0;
370
431
  padding: 0 10px;
371
432
  line-height: 30px;
433
+ font: inherit;
434
+ text-align: inherit;
435
+ appearance: none;
436
+ box-sizing: border-box;
372
437
  color: $stack-tab-text-color;
373
438
  cursor: pointer;
439
+ background: transparent;
440
+ border: 0;
374
441
  border-radius: 10px 0;
375
442
  transition: all 0.2s ease-in-out;
376
443
  user-select: none;
377
444
 
378
445
  &:hover,
379
- &:active {
446
+ &:active,
447
+ &:focus-visible {
380
448
  color: $stack-tab-color-white;
381
449
  background-color: $stack-tab-color-primary;
382
450
 
@@ -385,6 +453,10 @@
385
453
  }
386
454
  }
387
455
 
456
+ &:focus {
457
+ outline: none;
458
+ }
459
+
388
460
  &[disabled] {
389
461
  color: $stack-tab-color-disabled;
390
462
  pointer-events: none;