vue-stack-tabs 1.3.1 → 1.4.2
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 +119 -52
- package/README.md +103 -42
- package/README.ru.md +125 -54
- package/dist/{src/lib/StackTabs.vue.d.ts → StackTabs.vue.d.ts} +5 -5
- package/dist/{src/lib/components → components}/ContextMenu/index.vue.d.ts +1 -1
- package/dist/components/StackKeepAlive/emptyPlaceholder.d.ts +3 -0
- package/dist/{src/lib/components → components}/TabHeader/TabHeaderItem.vue.d.ts +9 -0
- package/dist/{src/lib/components → components}/TabHeader/index.vue.d.ts +3 -3
- package/dist/{src/lib/hooks → hooks}/stackTabsContext.d.ts +5 -2
- package/dist/hooks/tabPanel/pageComponentFactory.d.ts +23 -0
- package/dist/{src/lib/hooks → hooks}/tabPanel/refresh.d.ts +6 -3
- package/dist/{src/lib/hooks → hooks}/tabPanel/scroll.d.ts +2 -0
- package/dist/hooks/tabPanel/tabStore.d.ts +69 -0
- package/dist/hooks/useScrollShadow.d.ts +20 -0
- package/dist/{src/lib/hooks → hooks}/useTabActions.d.ts +7 -2
- package/dist/{src/lib/hooks → hooks}/useTabEventBus.d.ts +2 -1
- package/dist/{src/lib/hooks → hooks}/useTabPanel.d.ts +16 -4
- package/dist/iframe-bridge.mjs +1 -1
- package/dist/index.d.ts +22 -6
- package/dist/{src/lib/model → model}/TabModel.d.ts +21 -0
- package/dist/nuxt/module.mjs +10 -6
- package/dist/nuxt/runtime/plugin.mjs +1600 -4
- package/dist/style.css +1 -1
- package/dist/utils/iframeBridge.d.ts +7 -1
- package/dist/{src/lib/utils → utils}/scrollUtils.d.ts +7 -1
- package/dist/{src/lib/versionLogger.d.ts → versionLogger.d.ts} +1 -1
- package/dist/vue-stack-tabs.css +1 -1
- package/dist/vue-stack-tabs.es.js +1 -1
- package/package.json +64 -40
- package/src/lib/StackTabs.vue +16 -3
- package/src/lib/assets/style/common/var.scss +2 -0
- package/src/lib/assets/style/stackTab.scss +17 -3
- package/src/lib/components/ContextMenu/index.vue +12 -3
- package/src/lib/components/PageLoading.vue +10 -2
- package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +3 -9
- package/src/lib/components/StackKeepAlive/emptyPlaceholder.tsx +18 -0
- package/src/lib/components/TabHeader/index.vue +4 -3
- package/src/lib/hooks/stackTabsContext.ts +23 -18
- package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +136 -0
- package/src/lib/hooks/tabPanel/refresh.ts +20 -13
- package/src/lib/hooks/tabPanel/scroll.ts +110 -7
- package/src/lib/hooks/tabPanel/session.ts +29 -3
- package/src/lib/hooks/tabPanel/tabStore.ts +595 -0
- package/src/lib/hooks/useContextMenu.ts +57 -11
- package/src/lib/hooks/useIframeManager.ts +18 -2
- package/src/lib/hooks/useScrollShadow.ts +124 -0
- package/src/lib/hooks/useTabActions.ts +18 -8
- package/src/lib/hooks/useTabEventBus.ts +5 -3
- package/src/lib/hooks/useTabPanel.tsx +183 -302
- package/src/lib/hooks/useTabRouter.ts +42 -38
- package/src/lib/i18n/index.ts +3 -3
- package/src/lib/model/TabModel.ts +40 -1
- package/src/lib/nuxt/module.ts +10 -3
- package/src/lib/nuxt/runtime/plugin.ts +13 -2
- package/src/lib/utils/iframeBridge.ts +9 -1
- package/src/lib/utils/scrollUtils.ts +35 -2
- package/src/lib/utils/tabInfoEncoder.ts +21 -8
- package/src/lib/utils/urlParser.ts +3 -1
- package/src/lib/versionLogger.ts +6 -2
- package/dist/favicon.svg +0 -10
- package/dist/iframe-bridge.html +0 -77
- package/dist/iframe-child.html +0 -88
- package/dist/nuxt/favicon.svg +0 -10
- package/dist/nuxt/iframe-bridge.html +0 -77
- package/dist/nuxt/iframe-child.html +0 -88
- package/dist/nuxt/plugin.mjs +0 -9
- package/dist/src/lib/iframe-bridge.d.ts +0 -8
- package/dist/src/lib/index.d.ts +0 -22
- package/dist/src/lib/nuxt/module.d.ts +0 -6
- package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +0 -2
- package/dist/src/lib/nuxt/runtime/plugin.d.ts +0 -2
- package/dist/src/lib/utils/iframeBridge.d.ts +0 -39
- package/src/lib/nuxt/runtime/plugin.client.ts +0 -10
- /package/dist/{src/lib/components → components}/ContextMenu/ContextMenuItem.vue.d.ts +0 -0
- /package/dist/{src/lib/components → components}/PageLoading.vue.d.ts +0 -0
- /package/dist/{src/lib/components → components}/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -0
- /package/dist/{src/lib/components → components}/StackKeepAlive/StackKeepAlive.vue.d.ts +0 -0
- /package/dist/{src/lib/components → components}/TabHeader/TabHeaderButton.vue.d.ts +0 -0
- /package/dist/{src/lib/components → components}/TabHeader/TabHeaderScroll.vue.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/tabPanel/evict.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/tabPanel/navigationTransaction.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/tabPanel/session.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/tabPanel/state.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/useContextMenu.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/useIframeManager.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/useTabLoading.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/useTabRouter.d.ts +0 -0
- /package/dist/{src/lib/i18n → i18n}/index.d.ts +0 -0
- /package/dist/{src/lib/i18n → i18n}/lang/en.d.ts +0 -0
- /package/dist/{src/lib/i18n → i18n}/lang/zh-CN.d.ts +0 -0
- /package/dist/{src/lib/i18n → i18n}/stackTabsLocale.d.ts +0 -0
- /package/dist/{src/lib/iframe.vue.d.ts → iframe.vue.d.ts} +0 -0
- /package/dist/{src/lib/utils → utils}/stackTabsA11y.d.ts +0 -0
- /package/dist/{src/lib/utils → utils}/stackTabsMessage.d.ts +0 -0
- /package/dist/{src/lib/utils → utils}/tabInfoEncoder.d.ts +0 -0
- /package/dist/{src/lib/utils → utils}/typeGuards.d.ts +0 -0
- /package/dist/{src/lib/utils → utils}/urlParser.d.ts +0 -0
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
:style="{
|
|
14
14
|
left: `${menuPosition.left}px`,
|
|
15
15
|
top: `${menuPosition.top}px`,
|
|
16
|
-
'z-index':
|
|
16
|
+
'z-index': menuZIndex()
|
|
17
17
|
}"
|
|
18
18
|
@keydown="handleMenuKeydown"
|
|
19
19
|
>
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
import { ref, onMounted, reactive, nextTick } from 'vue'
|
|
85
85
|
import type { ITabItem, IContextMenu } from '../../model/TabModel'
|
|
86
86
|
import { useI18n } from 'vue-i18n-lite'
|
|
87
|
-
import { getMaxZIndex } from '../../utils/scrollUtils'
|
|
87
|
+
import { getMaxZIndex, invalidateZIndexCache } from '../../utils/scrollUtils'
|
|
88
88
|
import useTabActions from '../../hooks/useTabActions'
|
|
89
89
|
import useTabPanel from '../../hooks/useTabPanel'
|
|
90
90
|
import ContextMenuItem from './ContextMenuItem.vue'
|
|
@@ -115,6 +115,14 @@ const menuPosition = reactive({ left: props.left, top: props.top })
|
|
|
115
115
|
const { closeTab, closeAllTabs, refreshTab, refreshAllTabs, openInNewWindow } = useTabActions()
|
|
116
116
|
const { removeLeftTabs, removeRightTabs, removeOtherTabs } = useTabPanel()
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* 菜单挂载时 z-index 必须重新计算(DOM 层叠已变化),先用 invalidateZIndexCache 失效再读取。
|
|
120
|
+
*/
|
|
121
|
+
const menuZIndex = () => {
|
|
122
|
+
invalidateZIndexCache('.stack-tab,.stack-tab *')
|
|
123
|
+
return getMaxZIndex('.stack-tab,.stack-tab *')
|
|
124
|
+
}
|
|
125
|
+
|
|
118
126
|
const focusActiveTabFallback = () => {
|
|
119
127
|
const activeTab = document.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')
|
|
120
128
|
activeTab?.focus()
|
|
@@ -193,7 +201,8 @@ const handleMenuKeydown = (event: KeyboardEvent) => {
|
|
|
193
201
|
}
|
|
194
202
|
}
|
|
195
203
|
|
|
196
|
-
const getCustomMenuKey = (item: IContextMenu): string =>
|
|
204
|
+
const getCustomMenuKey = (item: IContextMenu): string =>
|
|
205
|
+
item.key ?? `${item.title}-${item.icon ?? ''}`
|
|
197
206
|
|
|
198
207
|
/** 挂载后修正菜单位置,避免超出右边界 */
|
|
199
208
|
onMounted(() => {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
role="status"
|
|
12
12
|
aria-live="polite"
|
|
13
13
|
:aria-label="t('VueStackTab.loading')"
|
|
14
|
-
:style="{ zIndex:
|
|
14
|
+
:style="{ zIndex: loadingZIndex() }"
|
|
15
15
|
>
|
|
16
16
|
<div class="stack-tab-loading--spin turn" aria-hidden="true" />
|
|
17
17
|
</div>
|
|
@@ -20,7 +20,15 @@
|
|
|
20
20
|
import { ref, onUnmounted } from 'vue'
|
|
21
21
|
import { useI18n } from 'vue-i18n-lite'
|
|
22
22
|
import { TabEventType, useTabEmitter } from '../hooks/useTabEventBus'
|
|
23
|
-
import { getMaxZIndex } from '../utils/scrollUtils'
|
|
23
|
+
import { getMaxZIndex, invalidateZIndexCache } from '../utils/scrollUtils'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* loading 遮罩每次显隐都会改变 .cache-page-wrapper 子树层叠,先失效缓存再读取避免旧值。
|
|
27
|
+
*/
|
|
28
|
+
const loadingZIndex = () => {
|
|
29
|
+
invalidateZIndexCache('.cache-page-wrapper *')
|
|
30
|
+
return getMaxZIndex('.cache-page-wrapper *')
|
|
31
|
+
}
|
|
24
32
|
const props = defineProps<{
|
|
25
33
|
/** 当前标签 id,用于过滤 PAGE_LOADING 事件 */
|
|
26
34
|
tabId: string
|
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
- 不负责 transition、keep-alive、TabHeader、iframe 或 session 初始化
|
|
12
12
|
-->
|
|
13
13
|
<script lang="ts" setup>
|
|
14
|
-
import {
|
|
14
|
+
import { shallowRef, watch } from 'vue'
|
|
15
15
|
import type { DefineComponent, VNode } from 'vue'
|
|
16
16
|
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
|
17
17
|
import useTabPanel from '../../hooks/useTabPanel'
|
|
18
|
+
import { getEmptyPlaceholderComponent } from './emptyPlaceholder'
|
|
18
19
|
|
|
19
20
|
interface StackCacheRendererProps {
|
|
20
21
|
route: RouteLocationNormalizedLoaded
|
|
@@ -37,14 +38,7 @@ defineSlots<{
|
|
|
37
38
|
|
|
38
39
|
const { refreshKey, activeCacheKey, activePageRefreshVersion, addPage } = useTabPanel()
|
|
39
40
|
|
|
40
|
-
const
|
|
41
|
-
name: 'StackCacheRendererEmpty',
|
|
42
|
-
setup() {
|
|
43
|
-
return () => null
|
|
44
|
-
}
|
|
45
|
-
}) as DefineComponent
|
|
46
|
-
|
|
47
|
-
const wrappedComponent = shallowRef<DefineComponent>(EmptyRendererComponent)
|
|
41
|
+
const wrappedComponent = shallowRef<DefineComponent>(getEmptyPlaceholderComponent())
|
|
48
42
|
|
|
49
43
|
const getComponentIdentity = (component?: VNode | null): unknown => {
|
|
50
44
|
return component?.type ?? component
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共享的 Empty 占位组件。
|
|
3
|
+
*
|
|
4
|
+
* useTabPanel 在「empty」/「recovered」分支会返回一个不渲染任何内容的占位组件。
|
|
5
|
+
* 原先在 pageComponentFactory.tsx 和 StackCacheRenderer.vue 各自定义了一份,
|
|
6
|
+
* 这里抽出唯一来源,避免两份略有差异的副本带来维护负担。
|
|
7
|
+
*/
|
|
8
|
+
import { defineComponent } from 'vue'
|
|
9
|
+
import type { DefineComponent } from 'vue'
|
|
10
|
+
|
|
11
|
+
export const EmptyPlaceholderComponent = defineComponent({
|
|
12
|
+
name: 'StackTabEmptyPlaceholder',
|
|
13
|
+
setup() {
|
|
14
|
+
return () => null
|
|
15
|
+
}
|
|
16
|
+
}) as DefineComponent
|
|
17
|
+
|
|
18
|
+
export const getEmptyPlaceholderComponent = (): DefineComponent => EmptyPlaceholderComponent
|
|
@@ -243,17 +243,18 @@ const handleTabListKeydown = (event: KeyboardEvent) => {
|
|
|
243
243
|
if (count <= 0) return
|
|
244
244
|
|
|
245
245
|
const currentIndex = getActiveTabIndex()
|
|
246
|
-
const safeIndex = currentIndex >= 0 ? currentIndex : 0
|
|
247
246
|
|
|
248
247
|
if (event.key === 'ArrowRight') {
|
|
249
248
|
event.preventDefault()
|
|
250
|
-
|
|
249
|
+
// 无 active 标签时从 -1 起步,按右方向键跳到第 0 个;正常情况环绕到下一个。
|
|
250
|
+
activateTabByIndex(currentIndex === -1 ? 0 : (currentIndex + 1) % count)
|
|
251
251
|
return
|
|
252
252
|
}
|
|
253
253
|
|
|
254
254
|
if (event.key === 'ArrowLeft') {
|
|
255
255
|
event.preventDefault()
|
|
256
|
-
|
|
256
|
+
// 无 active 标签时按左方向键跳到最后一个;正常情况环绕到上一个。
|
|
257
|
+
activateTabByIndex(currentIndex === -1 ? count - 1 : (currentIndex - 1 + count) % count)
|
|
257
258
|
return
|
|
258
259
|
}
|
|
259
260
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* VueStackTabs 全局运行时上下文管理。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 负责创建、注册、解析 StackTabsRuntimeContext。
|
|
5
|
+
* 业务运行时通过 Vue provide/inject 隔离;模块级回退仅保留给没有 App
|
|
6
|
+
* 容器的兼容调用(例如旧的单元测试),绝不能承载某个 Vue App 的状态。
|
|
6
7
|
* 每个 Vue app 只允许注册一个 <VueStackTabs> 实例。
|
|
7
8
|
*/
|
|
8
9
|
import type { App, DefineComponent, InjectionKey, Ref, ShallowRef } from 'vue'
|
|
@@ -77,7 +78,10 @@ export const createStackTabsRuntimeContext = (
|
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* 获取非 App 容器下注册的兼容上下文。
|
|
83
|
+
* @deprecated App 内的 composable 必须依赖 provide/inject,不应使用这个回退。
|
|
84
|
+
*/
|
|
81
85
|
export const getActiveStackTabsRuntimeContext = (): StackTabsRuntimeContext | null =>
|
|
82
86
|
activeRuntimeContext
|
|
83
87
|
|
|
@@ -86,22 +90,21 @@ export const registerStackTabsRuntimeContext = (
|
|
|
86
90
|
context: StackTabsRuntimeContext,
|
|
87
91
|
options: RegisterStackTabsRuntimeContextOptions = {}
|
|
88
92
|
): boolean => {
|
|
89
|
-
if (!activeRuntimeContext) {
|
|
90
|
-
activeRuntimeContext = context
|
|
91
|
-
}
|
|
92
|
-
|
|
93
93
|
if (options.app && !runtimeContextOwnersByApp.has(options.app)) {
|
|
94
94
|
runtimeContextOwnersByApp.add(options.app)
|
|
95
95
|
return true
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
if (
|
|
99
|
-
!
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
98
|
+
if (!options.app) {
|
|
99
|
+
if (!activeRuntimeContext) {
|
|
100
|
+
activeRuntimeContext = context
|
|
101
|
+
isFallbackRuntimeContextOwnerRegistered = true
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
104
|
+
if (activeRuntimeContext === context && !isFallbackRuntimeContextOwnerRegistered) {
|
|
105
|
+
isFallbackRuntimeContextOwnerRegistered = true
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
const isProduction = options.isProduction ?? import.meta.env.PROD
|
|
@@ -118,13 +121,15 @@ export const unregisterStackTabsRuntimeContext = (
|
|
|
118
121
|
context: StackTabsRuntimeContext,
|
|
119
122
|
options: UnregisterStackTabsRuntimeContextOptions = {}
|
|
120
123
|
): void => {
|
|
121
|
-
if (options.app)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
+
if (options.app) {
|
|
125
|
+
runtimeContextOwnersByApp.delete(options.app)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
if (activeRuntimeContext === context) isFallbackRuntimeContextOwnerRegistered = false
|
|
124
129
|
if (activeRuntimeContext === context) activeRuntimeContext = null
|
|
125
130
|
}
|
|
126
131
|
|
|
127
|
-
/** 解析当前可用的运行时上下文,优先使用 inject
|
|
132
|
+
/** 解析当前可用的运行时上下文,优先使用 inject,兼容回退仅限非 App 容器。 */
|
|
128
133
|
export const resolveStackTabsRuntimeContext = (): StackTabsRuntimeContext => {
|
|
129
134
|
const injected = hasInjectionContext() ? inject(stackTabsContextKey, null) : null
|
|
130
135
|
const context = injected ?? activeRuntimeContext
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 页面缓存组件工厂。
|
|
3
|
+
*
|
|
4
|
+
* 将 keep-alive 包装、VNode 注入和页面激活生命周期从 tab 状态编排中隔离,
|
|
5
|
+
* 使 useTabPanel 只负责领域状态与路由协调。
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
cloneVNode,
|
|
9
|
+
defineComponent,
|
|
10
|
+
onActivated,
|
|
11
|
+
onDeactivated,
|
|
12
|
+
onMounted,
|
|
13
|
+
onUnmounted,
|
|
14
|
+
ref,
|
|
15
|
+
shallowRef
|
|
16
|
+
} from 'vue'
|
|
17
|
+
import type { DefineComponent, PropType, VNode } from 'vue'
|
|
18
|
+
import type { ITabBase } from '../../model/TabModel'
|
|
19
|
+
import PageLoading from '../../components/PageLoading.vue'
|
|
20
|
+
import type { StackTabsRuntimeContext } from '../stackTabsContext'
|
|
21
|
+
import { useScrollShadow } from '../useScrollShadow'
|
|
22
|
+
|
|
23
|
+
interface PageComponentFactoryOptions {
|
|
24
|
+
runtimeContext: StackTabsRuntimeContext
|
|
25
|
+
consumeBackParams: (tabId: string, pageId: string) => Record<string, unknown> | null
|
|
26
|
+
getComponent: (id: string) => DefineComponent | undefined
|
|
27
|
+
setComponent: (id: string, component: DefineComponent) => void
|
|
28
|
+
addCache: (cacheName: string) => void
|
|
29
|
+
evictMarkedCaches: () => void
|
|
30
|
+
clearTabEvictionMarks: () => void
|
|
31
|
+
addPageScroller: (pageId: string, ...selectors: string[]) => void
|
|
32
|
+
saveScroller: (pageId: string) => void
|
|
33
|
+
restoreScroller: (pageId: string) => void
|
|
34
|
+
removeScroller: (pageId: string) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const createPageComponentFactory = (options: PageComponentFactoryOptions) => {
|
|
38
|
+
const {
|
|
39
|
+
runtimeContext,
|
|
40
|
+
consumeBackParams,
|
|
41
|
+
getComponent,
|
|
42
|
+
setComponent,
|
|
43
|
+
addCache,
|
|
44
|
+
evictMarkedCaches,
|
|
45
|
+
clearTabEvictionMarks,
|
|
46
|
+
addPageScroller,
|
|
47
|
+
saveScroller,
|
|
48
|
+
restoreScroller,
|
|
49
|
+
removeScroller
|
|
50
|
+
} = options
|
|
51
|
+
|
|
52
|
+
const resolvePageComponent = (ctx: { cacheName: string; tabInfo: ITabBase }): DefineComponent => {
|
|
53
|
+
const { cacheName, tabInfo } = ctx
|
|
54
|
+
const existing = getComponent(cacheName)
|
|
55
|
+
if (existing) {
|
|
56
|
+
addCache(cacheName)
|
|
57
|
+
return existing
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const cacheComponent = defineComponent<{ vnode?: VNode }>({
|
|
61
|
+
name: cacheName,
|
|
62
|
+
props: {
|
|
63
|
+
vnode: {
|
|
64
|
+
type: Object as PropType<VNode>,
|
|
65
|
+
required: false,
|
|
66
|
+
default: undefined
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
emits: ['onLoaded'],
|
|
70
|
+
setup(props, context) {
|
|
71
|
+
const localBackParams = ref<Record<string, unknown> | null>(null)
|
|
72
|
+
const lastVnode = shallowRef<VNode | null | undefined>(null)
|
|
73
|
+
const lastCloned = shallowRef<VNode | null>(null)
|
|
74
|
+
const lastBackParams = shallowRef<Record<string, unknown> | null>(null)
|
|
75
|
+
const pageRoot = ref<HTMLElement | null>(null)
|
|
76
|
+
|
|
77
|
+
const checkAndConsumeBackParams = () => {
|
|
78
|
+
localBackParams.value = consumeBackParams(tabInfo.id!, cacheName)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
onMounted(() => {
|
|
82
|
+
context.emit('onLoaded')
|
|
83
|
+
addPageScroller(cacheName, `#W-${cacheName}`)
|
|
84
|
+
checkAndConsumeBackParams()
|
|
85
|
+
})
|
|
86
|
+
onDeactivated(() => saveScroller(cacheName))
|
|
87
|
+
onActivated(() => {
|
|
88
|
+
context.emit('onLoaded')
|
|
89
|
+
restoreScroller(cacheName)
|
|
90
|
+
evictMarkedCaches()
|
|
91
|
+
clearTabEvictionMarks()
|
|
92
|
+
checkAndConsumeBackParams()
|
|
93
|
+
})
|
|
94
|
+
onUnmounted(() => removeScroller(cacheName))
|
|
95
|
+
|
|
96
|
+
// 给 .cache-page-wrapper 挂滚动阴影,确保 keep-alive 的 suspended/deactivated 生命周期下表现一致
|
|
97
|
+
useScrollShadow({ target: pageRoot })
|
|
98
|
+
|
|
99
|
+
return () => {
|
|
100
|
+
const vnode = props.vnode
|
|
101
|
+
const backParams = localBackParams.value
|
|
102
|
+
if (vnode !== lastVnode.value || backParams !== lastBackParams.value) {
|
|
103
|
+
lastCloned.value = vnode
|
|
104
|
+
? cloneVNode(vnode, {
|
|
105
|
+
tId: tabInfo.id,
|
|
106
|
+
pId: cacheName,
|
|
107
|
+
...(backParams ? { _back: backParams } : {})
|
|
108
|
+
})
|
|
109
|
+
: null
|
|
110
|
+
lastVnode.value = vnode
|
|
111
|
+
lastBackParams.value = backParams
|
|
112
|
+
}
|
|
113
|
+
return (
|
|
114
|
+
<div
|
|
115
|
+
ref={pageRoot}
|
|
116
|
+
class="cache-page-wrapper"
|
|
117
|
+
id={`W-${cacheName}`}
|
|
118
|
+
style={[
|
|
119
|
+
runtimeContext.useGlobalScroll.value ? 'overflow:auto' : 'overflow:hidden',
|
|
120
|
+
'height: 100%'
|
|
121
|
+
]}
|
|
122
|
+
>
|
|
123
|
+
{lastCloned.value}
|
|
124
|
+
<PageLoading tabId={tabInfo.id!} />
|
|
125
|
+
</div>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}) as DefineComponent
|
|
130
|
+
setComponent(cacheName, cacheComponent)
|
|
131
|
+
addCache(cacheName)
|
|
132
|
+
return cacheComponent
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { resolvePageComponent }
|
|
136
|
+
}
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* 活动普通页面保持 cache id 稳定,只递增页面级刷新版本;非活动页面可直接替换缓存。
|
|
5
5
|
*/
|
|
6
6
|
import type { Ref } from 'vue'
|
|
7
|
-
import type { ITabItem } from '../../model/TabModel'
|
|
7
|
+
import type { ITabItem, ITabPage } from '../../model/TabModel'
|
|
8
|
+
import type { PagePatch } from './tabStore'
|
|
8
9
|
|
|
9
10
|
export interface TabPanelRefreshApi {
|
|
10
11
|
refreshTab: (id: string) => void
|
|
@@ -12,9 +13,11 @@ export interface TabPanelRefreshApi {
|
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export interface CreateTabPanelRefreshOptions {
|
|
15
|
-
|
|
16
|
+
getTabs: () => readonly ITabItem[]
|
|
17
|
+
getTopPage: (tabId: string) => ITabPage | null
|
|
18
|
+
/** 修改页面字段(刷新版本 / 页面 id)并触发 tabs 更新 */
|
|
19
|
+
updatePage: (tabId: string, pageId: string, patch: PagePatch) => boolean
|
|
16
20
|
iframeRefreshKeys: Ref<Record<string, number>>
|
|
17
|
-
getTab: (id: string) => ITabItem | null
|
|
18
21
|
createPageId: () => string
|
|
19
22
|
addCache: (cacheName: string) => void
|
|
20
23
|
evictPageCache: (cacheName: string) => void
|
|
@@ -29,9 +32,10 @@ export const createTabPanelRefresh = (
|
|
|
29
32
|
options: CreateTabPanelRefreshOptions
|
|
30
33
|
): TabPanelRefreshApi => {
|
|
31
34
|
const {
|
|
32
|
-
|
|
35
|
+
getTabs,
|
|
36
|
+
getTopPage,
|
|
37
|
+
updatePage,
|
|
33
38
|
iframeRefreshKeys,
|
|
34
|
-
getTab,
|
|
35
39
|
createPageId,
|
|
36
40
|
addCache,
|
|
37
41
|
evictPageCache,
|
|
@@ -51,28 +55,31 @@ export const createTabPanelRefresh = (
|
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
const refreshPage = (tab: ITabItem, cacheNamesToEvict?: string[], cacheNamesToAdd?: string[]) => {
|
|
54
|
-
const currentPage = tab.
|
|
58
|
+
const currentPage = getTopPage(tab.id)
|
|
55
59
|
if (!currentPage) return
|
|
56
60
|
|
|
57
61
|
if (tab.active) {
|
|
58
|
-
|
|
62
|
+
// updatePage 内部会触发 tabs 更新,activePageRefreshVersion 随之重新求值。
|
|
63
|
+
updatePage(tab.id, currentPage.id, {
|
|
64
|
+
refreshVersion: (currentPage.refreshVersion ?? 0) + 1
|
|
65
|
+
})
|
|
59
66
|
return
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
const oldCacheName = currentPage.id
|
|
63
|
-
|
|
64
|
-
|
|
70
|
+
const nextPageId = createPageId()
|
|
71
|
+
updatePage(tab.id, oldCacheName, { id: nextPageId, refreshVersion: 0 })
|
|
65
72
|
if (cacheNamesToEvict && cacheNamesToAdd) {
|
|
66
73
|
cacheNamesToEvict.push(oldCacheName)
|
|
67
|
-
cacheNamesToAdd.push(
|
|
74
|
+
cacheNamesToAdd.push(nextPageId)
|
|
68
75
|
return
|
|
69
76
|
}
|
|
70
77
|
evictPageCache(oldCacheName)
|
|
71
|
-
addCache(
|
|
78
|
+
addCache(nextPageId)
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
const refreshTab = (id: string) => {
|
|
75
|
-
const tab =
|
|
82
|
+
const tab = getTabs().find((item) => item.id === id)
|
|
76
83
|
if (!tab?.refreshable) return
|
|
77
84
|
if (tab.iframe) {
|
|
78
85
|
refreshIframe(tab)
|
|
@@ -87,7 +94,7 @@ export const createTabPanelRefresh = (
|
|
|
87
94
|
const iframeKeysUpdate = { ...iframeRefreshKeys.value }
|
|
88
95
|
let iframeKeysChanged = false
|
|
89
96
|
|
|
90
|
-
for (const tab of
|
|
97
|
+
for (const tab of getTabs()) {
|
|
91
98
|
if (!tab.refreshable) continue
|
|
92
99
|
if (tab.iframe) {
|
|
93
100
|
if (tab.iframeRefreshMode === 'reload') {
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* tabPanel/scroll - 滚动位置保存与恢复
|
|
3
3
|
*
|
|
4
4
|
* 职责:基于当前 StackTabsRuntimeContext 在切出/切入页面时保存和恢复滚动位置。
|
|
5
|
+
* 优化:用 MutationObserver 缓存 [data-stack-tab-scroll] 元素列表,
|
|
6
|
+
* 避免每次 deactivate 都全量 querySelectorAll。
|
|
5
7
|
*/
|
|
6
8
|
import type { StackTabsRuntimeContext } from '../stackTabsContext'
|
|
7
9
|
|
|
@@ -10,6 +12,8 @@ export interface TabPanelScrollApi {
|
|
|
10
12
|
saveScroller: (pageCacheId: string) => void
|
|
11
13
|
removeScroller: (pageCacheId: string) => void
|
|
12
14
|
addPageScroller: (pageCacheId: string, ...selectorIds: string[]) => void
|
|
15
|
+
/** 主动失效某页面的滚动元素缓存,例如外部强制刷新缓存页时 */
|
|
16
|
+
invalidateAutoScrollCache: (pageCacheId: string) => void
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
const resolveScrollElement = (selector: string): HTMLElement | null =>
|
|
@@ -17,6 +21,73 @@ const resolveScrollElement = (selector: string): HTMLElement | null =>
|
|
|
17
21
|
? document.getElementById(selector.slice(1))
|
|
18
22
|
: (document.querySelector(selector) as HTMLElement | null)
|
|
19
23
|
|
|
24
|
+
const AUTO_SCROLLER_PREFIX = '__stack-tab-auto-scroll__:'
|
|
25
|
+
|
|
26
|
+
interface AutoScrollCache {
|
|
27
|
+
elements: HTMLElement[]
|
|
28
|
+
observer: MutationObserver | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 在 pageRoot 上挂一个 MutationObserver,自动维护 [data-stack-tab-scroll] 元素列表;
|
|
33
|
+
* 列表变化时无需上层主动失效,下次读到的就是最新值。
|
|
34
|
+
*/
|
|
35
|
+
const createAutoScrollCache = (pageRoot: HTMLElement): AutoScrollCache => {
|
|
36
|
+
const collect = (): HTMLElement[] =>
|
|
37
|
+
Array.from(pageRoot.querySelectorAll<HTMLElement>('[data-stack-tab-scroll]'))
|
|
38
|
+
|
|
39
|
+
const cache: AutoScrollCache = {
|
|
40
|
+
elements: collect(),
|
|
41
|
+
observer: null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
45
|
+
cache.observer = new MutationObserver(() => {
|
|
46
|
+
cache.elements = collect()
|
|
47
|
+
})
|
|
48
|
+
// 仅监听子树增删与属性变化,不监听字符数据,减少开销。
|
|
49
|
+
cache.observer.observe(pageRoot, {
|
|
50
|
+
childList: true,
|
|
51
|
+
subtree: true,
|
|
52
|
+
attributes: true,
|
|
53
|
+
attributeFilter: ['data-stack-tab-scroll']
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return cache
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const autoScrollCaches = new Map<string, AutoScrollCache>()
|
|
61
|
+
|
|
62
|
+
const getAutoScrollCache = (pageCacheId: string): AutoScrollCache | null => {
|
|
63
|
+
const pageRoot = document.getElementById(`W-${pageCacheId}`)
|
|
64
|
+
if (!pageRoot) {
|
|
65
|
+
autoScrollCaches.delete(pageCacheId)
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let cache = autoScrollCaches.get(pageCacheId)
|
|
70
|
+
if (!cache) {
|
|
71
|
+
cache = createAutoScrollCache(pageRoot)
|
|
72
|
+
autoScrollCaches.set(pageCacheId, cache)
|
|
73
|
+
} else {
|
|
74
|
+
// 防御:检查 pageRoot 是否已被替换(极端情况:同一 pageCacheId 重新挂载)
|
|
75
|
+
if (cache.elements.length === 0 && cache.observer) {
|
|
76
|
+
cache.elements = Array.from(pageRoot.querySelectorAll<HTMLElement>('[data-stack-tab-scroll]'))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return cache
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const getAutoScrollerKey = (index: number): string => `${AUTO_SCROLLER_PREFIX}${index}`
|
|
83
|
+
|
|
84
|
+
const resolveAutoScrollElement = (pageCacheId: string, key: string): HTMLElement | null => {
|
|
85
|
+
const index = Number(key.slice(AUTO_SCROLLER_PREFIX.length))
|
|
86
|
+
if (!Number.isInteger(index)) return null
|
|
87
|
+
const cache = getAutoScrollCache(pageCacheId)
|
|
88
|
+
return cache?.elements[index] ?? null
|
|
89
|
+
}
|
|
90
|
+
|
|
20
91
|
export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanelScrollApi => {
|
|
21
92
|
const { scrollPositionsByPageId } = context
|
|
22
93
|
|
|
@@ -25,7 +96,9 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
|
|
|
25
96
|
if (!positions) return
|
|
26
97
|
|
|
27
98
|
for (const [selector, position] of positions) {
|
|
28
|
-
const element =
|
|
99
|
+
const element = selector.startsWith(AUTO_SCROLLER_PREFIX)
|
|
100
|
+
? resolveAutoScrollElement(pageCacheId, selector)
|
|
101
|
+
: resolveScrollElement(selector)
|
|
29
102
|
if (element) {
|
|
30
103
|
element.scrollTop = position.top
|
|
31
104
|
element.scrollLeft = position.left
|
|
@@ -37,8 +110,23 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
|
|
|
37
110
|
const positions = scrollPositionsByPageId.get(pageCacheId)
|
|
38
111
|
if (!positions) return
|
|
39
112
|
|
|
113
|
+
// 与当前页面 DOM 同步自动标记的容器;移除的元素不保留过期位置。
|
|
114
|
+
for (const key of positions.keys()) {
|
|
115
|
+
if (key.startsWith(AUTO_SCROLLER_PREFIX)) positions.delete(key)
|
|
116
|
+
}
|
|
117
|
+
const cache = getAutoScrollCache(pageCacheId)
|
|
118
|
+
const elements = cache?.elements ?? []
|
|
119
|
+
elements.forEach((element, index) => {
|
|
120
|
+
positions.set(getAutoScrollerKey(index), {
|
|
121
|
+
top: element.scrollTop,
|
|
122
|
+
left: element.scrollLeft
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
40
126
|
for (const selector of positions.keys()) {
|
|
41
|
-
const element =
|
|
127
|
+
const element = selector.startsWith(AUTO_SCROLLER_PREFIX)
|
|
128
|
+
? resolveAutoScrollElement(pageCacheId, selector)
|
|
129
|
+
: resolveScrollElement(selector)
|
|
42
130
|
positions.set(selector, {
|
|
43
131
|
top: element?.scrollTop ?? 0,
|
|
44
132
|
left: element?.scrollLeft ?? 0
|
|
@@ -48,10 +136,16 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
|
|
|
48
136
|
|
|
49
137
|
const removeScroller = (pageCacheId: string): void => {
|
|
50
138
|
const positions = scrollPositionsByPageId.get(pageCacheId)
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
139
|
+
if (positions) {
|
|
140
|
+
positions.clear()
|
|
141
|
+
scrollPositionsByPageId.delete(pageCacheId)
|
|
142
|
+
}
|
|
143
|
+
// 同步卸载 MutationObserver,避免页面被驱逐后 observer 仍持有 DOM 引用。
|
|
144
|
+
const cache = autoScrollCaches.get(pageCacheId)
|
|
145
|
+
if (cache) {
|
|
146
|
+
cache.observer?.disconnect()
|
|
147
|
+
autoScrollCaches.delete(pageCacheId)
|
|
148
|
+
}
|
|
55
149
|
}
|
|
56
150
|
|
|
57
151
|
const addPageScroller = (pageCacheId: string, ...selectorIds: string[]): void => {
|
|
@@ -64,10 +158,19 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
|
|
|
64
158
|
}
|
|
65
159
|
}
|
|
66
160
|
|
|
161
|
+
const invalidateAutoScrollCache = (pageCacheId: string): void => {
|
|
162
|
+
const cache = autoScrollCaches.get(pageCacheId)
|
|
163
|
+
if (!cache) return
|
|
164
|
+
cache.elements = []
|
|
165
|
+
cache.observer?.disconnect()
|
|
166
|
+
autoScrollCaches.delete(pageCacheId)
|
|
167
|
+
}
|
|
168
|
+
|
|
67
169
|
return {
|
|
68
170
|
restoreScroller,
|
|
69
171
|
saveScroller,
|
|
70
172
|
removeScroller,
|
|
71
|
-
addPageScroller
|
|
173
|
+
addPageScroller,
|
|
174
|
+
invalidateAutoScrollCache
|
|
72
175
|
}
|
|
73
176
|
}
|
|
@@ -33,12 +33,38 @@ export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPane
|
|
|
33
33
|
let writeVersion = 0
|
|
34
34
|
let writeScheduled = false
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* 写入 sessionStorage,捕获隐私模式(QuotaExceededError)等异常,
|
|
38
|
+
* 避免在受限浏览器环境下抛出未处理错误。SSR 环境无 window 时直接跳过。
|
|
39
|
+
*/
|
|
40
|
+
const safeSetItem = (key: string, value: string): void => {
|
|
41
|
+
if (typeof window === 'undefined') return
|
|
42
|
+
try {
|
|
43
|
+
window.sessionStorage.setItem(key, value)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (!import.meta.env.PROD) {
|
|
46
|
+
console.warn('[vue-stack-tabs] Failed to write sessionStorage:', error)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const safeRemoveItem = (key: string): void => {
|
|
52
|
+
if (typeof window === 'undefined') return
|
|
53
|
+
try {
|
|
54
|
+
window.sessionStorage.removeItem(key)
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (!import.meta.env.PROD) {
|
|
57
|
+
console.warn('[vue-stack-tabs] Failed to clear sessionStorage:', error)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
36
62
|
const flushPendingWrite = () => {
|
|
37
63
|
writeScheduled = false
|
|
38
64
|
const write = pendingWrite
|
|
39
65
|
pendingWrite = null
|
|
40
66
|
if (!write || write.version !== writeVersion) return
|
|
41
|
-
|
|
67
|
+
safeSetItem(getSessionKey(), JSON.stringify(write.tab))
|
|
42
68
|
}
|
|
43
69
|
|
|
44
70
|
const invalidatePendingWrite = () => {
|
|
@@ -56,7 +82,7 @@ export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPane
|
|
|
56
82
|
|
|
57
83
|
const clearSession = (): void => {
|
|
58
84
|
invalidatePendingWrite()
|
|
59
|
-
|
|
85
|
+
safeRemoveItem(getSessionKey())
|
|
60
86
|
}
|
|
61
87
|
|
|
62
88
|
const restoreActiveTabSession = (storedJson: string | null): void => {
|
|
@@ -65,7 +91,7 @@ export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPane
|
|
|
65
91
|
return
|
|
66
92
|
}
|
|
67
93
|
invalidatePendingWrite()
|
|
68
|
-
|
|
94
|
+
safeSetItem(getSessionKey(), storedJson)
|
|
69
95
|
}
|
|
70
96
|
|
|
71
97
|
const restoreTabFromSession = (storedJson: string | null): ITabItem | null => {
|