vue-stack-tabs 1.4.1 → 2.2.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.
- package/README.en.md +1 -0
- package/README.md +1 -0
- package/README.ru.md +1 -0
- package/dist/StackTabs.vue.d.ts +3 -0
- package/dist/components/ContextMenu/ContextMenuItem.vue.d.ts +1 -1
- package/dist/hooks/tabPanel/pageComponentFactory.d.ts +5 -2
- package/dist/hooks/tabPanel/refresh.d.ts +8 -3
- package/dist/hooks/tabPanel/tabStore.d.ts +80 -0
- package/dist/hooks/useTabActions.d.ts +1 -0
- package/dist/hooks/useTabPanel.d.ts +27 -5
- package/dist/model/TabModel.d.ts +5 -0
- package/dist/style.css +1 -1
- package/dist/utils/tabInfoEncoder.d.ts +10 -0
- package/dist/utils/urlParser.d.ts +14 -3
- package/dist/vue-stack-tabs.es.js +1 -1
- package/package.json +4 -7
- package/src/lib/StackTabs.vue +24 -2
- package/src/lib/assets/image/close_all.svg +1 -9
- package/src/lib/assets/image/close_lefts.svg +1 -1
- package/src/lib/assets/image/close_other.svg +1 -1
- package/src/lib/assets/image/close_rights.svg +1 -1
- package/src/lib/assets/image/reload.svg +1 -3
- package/src/lib/assets/image/reload_all.svg +1 -6
- package/src/lib/assets/style/common/mixins.scss +58 -19
- package/src/lib/assets/style/common/var.scss +100 -60
- package/src/lib/assets/style/icon.scss +31 -98
- package/src/lib/assets/style/index.scss +2 -2
- package/src/lib/assets/style/loading.scss +45 -0
- package/src/lib/assets/style/{stackTab.scss → stack-tab.scss} +105 -177
- package/src/lib/assets/style/transition.scss +12 -17
- package/src/lib/components/ContextMenu/ContextMenuItem.vue +5 -5
- package/src/lib/components/ContextMenu/index.vue +22 -19
- package/src/lib/components/PageLoading.vue +5 -5
- package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +5 -3
- package/src/lib/components/TabHeader/TabHeaderItem.vue +13 -7
- package/src/lib/components/TabHeader/TabHeaderScroll.vue +8 -6
- package/src/lib/components/TabHeader/index.vue +16 -2
- package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +96 -26
- package/src/lib/hooks/tabPanel/refresh.ts +40 -18
- package/src/lib/hooks/tabPanel/tabStore.ts +665 -0
- package/src/lib/hooks/useContextMenu.ts +1 -1
- package/src/lib/hooks/useIframeManager.ts +10 -3
- package/src/lib/hooks/useTabActions.ts +10 -6
- package/src/lib/hooks/useTabPanel.tsx +325 -195
- package/src/lib/hooks/useTabRouter.ts +50 -48
- package/src/lib/model/TabModel.ts +5 -0
- package/src/lib/utils/tabInfoEncoder.ts +101 -17
- package/src/lib/utils/urlParser.ts +35 -11
- package/dist/vue-stack-tabs.css +0 -2
- package/src/lib/assets/style/overlayer.scss +0 -46
|
@@ -7,58 +7,58 @@
|
|
|
7
7
|
<template>
|
|
8
8
|
<div
|
|
9
9
|
ref="menuElementRef"
|
|
10
|
-
class="stack-
|
|
10
|
+
class="stack-tab__context-menu"
|
|
11
11
|
role="menu"
|
|
12
12
|
tabindex="-1"
|
|
13
13
|
:style="{
|
|
14
14
|
left: `${menuPosition.left}px`,
|
|
15
15
|
top: `${menuPosition.top}px`,
|
|
16
|
-
'z-index':
|
|
16
|
+
'z-index': menuZIndexValue
|
|
17
17
|
}"
|
|
18
18
|
@keydown="handleMenuKeydown"
|
|
19
19
|
>
|
|
20
20
|
<context-menu-item
|
|
21
|
-
icon="stack-tab__icon-reload
|
|
21
|
+
icon="stack-tab__icon stack-tab__icon--reload stack-tab__icon--mask"
|
|
22
22
|
:title="t('VueStackTab.reload')"
|
|
23
23
|
:disabled="!tabItem.refreshable"
|
|
24
24
|
@click="handleMenuItemClick(() => refreshTab(tabItem.id))"
|
|
25
25
|
/>
|
|
26
26
|
<context-menu-item
|
|
27
|
-
icon="stack-tab__icon-reload-all
|
|
27
|
+
icon="stack-tab__icon stack-tab__icon--reload-all stack-tab__icon--mask"
|
|
28
28
|
:title="t('VueStackTab.reloadAll')"
|
|
29
29
|
@click="handleMenuItemClick(refreshAllTabs)"
|
|
30
30
|
/>
|
|
31
31
|
<context-menu-item
|
|
32
|
-
icon="stack-tab__icon-close
|
|
32
|
+
icon="stack-tab__icon stack-tab__icon--close stack-tab__icon--mask"
|
|
33
33
|
:title="t('VueStackTab.close')"
|
|
34
34
|
:disabled="!tabItem.closable"
|
|
35
35
|
@click="handleMenuItemClick(() => closeTab(tabItem.id))"
|
|
36
36
|
/>
|
|
37
37
|
<context-menu-item
|
|
38
|
-
icon="stack-tab__icon-close-lefts
|
|
38
|
+
icon="stack-tab__icon stack-tab__icon--close-lefts stack-tab__icon--mask"
|
|
39
39
|
:title="t('VueStackTab.closeLeft')"
|
|
40
40
|
:disabled="index <= 0"
|
|
41
41
|
@click="handleMenuItemClick(() => removeLeftTabs(tabItem.id))"
|
|
42
42
|
/>
|
|
43
43
|
<context-menu-item
|
|
44
|
-
icon="stack-tab__icon-close-rights
|
|
44
|
+
icon="stack-tab__icon stack-tab__icon--close-rights stack-tab__icon--mask"
|
|
45
45
|
:title="t('VueStackTab.closeRight')"
|
|
46
46
|
:disabled="index >= max - 1"
|
|
47
47
|
@click="handleMenuItemClick(() => removeRightTabs(tabItem.id))"
|
|
48
48
|
/>
|
|
49
49
|
<context-menu-item
|
|
50
|
-
icon="stack-tab__icon-close-others
|
|
50
|
+
icon="stack-tab__icon stack-tab__icon--close-others stack-tab__icon--mask"
|
|
51
51
|
:title="t('VueStackTab.closeOthers')"
|
|
52
52
|
@click="handleMenuItemClick(() => removeOtherTabs(tabItem.id))"
|
|
53
53
|
/>
|
|
54
54
|
<context-menu-item
|
|
55
|
-
icon="stack-tab__icon-close-all
|
|
55
|
+
icon="stack-tab__icon stack-tab__icon--close-all stack-tab__icon--mask"
|
|
56
56
|
:title="t('VueStackTab.closeAll')"
|
|
57
57
|
@click="handleMenuItemClick(closeAllTabs)"
|
|
58
58
|
/>
|
|
59
59
|
<context-menu-item
|
|
60
60
|
v-if="tabItem.iframe && tabItem.url"
|
|
61
|
-
icon="stack-tab__icon-open
|
|
61
|
+
icon="stack-tab__icon stack-tab__icon--open stack-tab__icon--mask"
|
|
62
62
|
:title="t('VueStackTab.openInNewWindow')"
|
|
63
63
|
@click="handleMenuItemClick(() => openInNewWindow(tabItem.id))"
|
|
64
64
|
/>
|
|
@@ -115,13 +115,8 @@ 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
|
-
|
|
120
|
-
*/
|
|
121
|
-
const menuZIndex = () => {
|
|
122
|
-
invalidateZIndexCache('.stack-tab,.stack-tab *')
|
|
123
|
-
return getMaxZIndex('.stack-tab,.stack-tab *')
|
|
124
|
-
}
|
|
118
|
+
/** 菜单挂载时计算一次 z-index;菜单打开期间层叠关系不会变化。 */
|
|
119
|
+
const menuZIndexValue = ref(0)
|
|
125
120
|
|
|
126
121
|
const focusActiveTabFallback = () => {
|
|
127
122
|
const activeTab = document.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')
|
|
@@ -204,12 +199,20 @@ const handleMenuKeydown = (event: KeyboardEvent) => {
|
|
|
204
199
|
const getCustomMenuKey = (item: IContextMenu): string =>
|
|
205
200
|
item.key ?? `${item.title}-${item.icon ?? ''}`
|
|
206
201
|
|
|
207
|
-
/**
|
|
202
|
+
/** 挂载后计算 z-index 并修正右/下边界,避免菜单溢出视口 */
|
|
208
203
|
onMounted(() => {
|
|
209
|
-
|
|
204
|
+
invalidateZIndexCache('.stack-tab,.stack-tab *')
|
|
205
|
+
menuZIndexValue.value = getMaxZIndex('.stack-tab,.stack-tab *')
|
|
206
|
+
|
|
207
|
+
const menuElement = menuElementRef.value
|
|
208
|
+
const menuWidth = menuElement?.clientWidth ?? 0
|
|
209
|
+
const menuHeight = menuElement?.clientHeight ?? 0
|
|
210
210
|
if (menuPosition.left + menuWidth > window.innerWidth) {
|
|
211
211
|
menuPosition.left = window.innerWidth - menuWidth - 5
|
|
212
212
|
}
|
|
213
|
+
if (menuPosition.top + menuHeight > window.innerHeight) {
|
|
214
|
+
menuPosition.top = Math.max(0, window.innerHeight - menuHeight - 5)
|
|
215
|
+
}
|
|
213
216
|
focusMenuItem(0)
|
|
214
217
|
})
|
|
215
218
|
</script>
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
<template>
|
|
8
8
|
<div
|
|
9
9
|
v-if="isLoading"
|
|
10
|
-
class="stack-tab-
|
|
10
|
+
class="stack-tab-loading__mask"
|
|
11
11
|
role="status"
|
|
12
12
|
aria-live="polite"
|
|
13
13
|
:aria-label="t('VueStackTab.loading')"
|
|
14
14
|
:style="{ zIndex: loadingZIndex() }"
|
|
15
15
|
>
|
|
16
|
-
<div class="stack-tab-
|
|
16
|
+
<div class="stack-tab-loading__spin" aria-hidden="true" />
|
|
17
17
|
</div>
|
|
18
18
|
</template>
|
|
19
19
|
<script setup lang="ts">
|
|
@@ -23,11 +23,11 @@ import { TabEventType, useTabEmitter } from '../hooks/useTabEventBus'
|
|
|
23
23
|
import { getMaxZIndex, invalidateZIndexCache } from '../utils/scrollUtils'
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* loading 遮罩每次显隐都会改变 .
|
|
26
|
+
* loading 遮罩每次显隐都会改变 .stack-tab__cache-page-wrapper 子树层叠,先失效缓存再读取避免旧值。
|
|
27
27
|
*/
|
|
28
28
|
const loadingZIndex = () => {
|
|
29
|
-
invalidateZIndexCache('.
|
|
30
|
-
return getMaxZIndex('.
|
|
29
|
+
invalidateZIndexCache('.stack-tab__cache-page-wrapper *')
|
|
30
|
+
return getMaxZIndex('.stack-tab__cache-page-wrapper *')
|
|
31
31
|
}
|
|
32
32
|
const props = defineProps<{
|
|
33
33
|
/** 当前标签 id,用于过滤 PAGE_LOADING 事件 */
|
|
@@ -27,7 +27,7 @@ const emit = defineEmits<{
|
|
|
27
27
|
loaded: []
|
|
28
28
|
}>()
|
|
29
29
|
|
|
30
|
-
const { caches } = useTabPanel()
|
|
30
|
+
const { caches, getPageRefreshVersion } = useTabPanel()
|
|
31
31
|
|
|
32
32
|
/** 页面组件加载完成时向父组件转发 */
|
|
33
33
|
const emitLoaded = () => {
|
|
@@ -38,7 +38,7 @@ const emitLoaded = () => {
|
|
|
38
38
|
<template>
|
|
39
39
|
<router-view v-slot="{ Component, route }">
|
|
40
40
|
<StackCacheRenderer
|
|
41
|
-
v-slot="{ wrappedComponent, activeCacheKey,
|
|
41
|
+
v-slot="{ wrappedComponent, activeCacheKey, component }"
|
|
42
42
|
:route="route"
|
|
43
43
|
:component="Component"
|
|
44
44
|
>
|
|
@@ -46,8 +46,10 @@ const emitLoaded = () => {
|
|
|
46
46
|
<keep-alive :include="caches">
|
|
47
47
|
<component
|
|
48
48
|
:is="wrappedComponent"
|
|
49
|
-
:key="
|
|
49
|
+
:key="activeCacheKey"
|
|
50
50
|
:vnode="component"
|
|
51
|
+
:refresh-version="getPageRefreshVersion(activeCacheKey)"
|
|
52
|
+
:transition-name="transitionName"
|
|
51
53
|
@on-loaded="emitLoaded"
|
|
52
54
|
/>
|
|
53
55
|
</keep-alive>
|
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
role="presentation"
|
|
5
5
|
class="stack-tab__item"
|
|
6
6
|
:class="{
|
|
7
|
-
'
|
|
8
|
-
'
|
|
9
|
-
'
|
|
10
|
-
'
|
|
11
|
-
'
|
|
7
|
+
'stack-tab__item--active': item.active,
|
|
8
|
+
'stack-tab__item--closable': item.closable,
|
|
9
|
+
'stack-tab__item--pinned': item.pinned,
|
|
10
|
+
'stack-tab__item--dragging': dragging,
|
|
11
|
+
'stack-tab__item--drop-before': dropPosition === 'before',
|
|
12
|
+
'stack-tab__item--drop-after': dropPosition === 'after'
|
|
12
13
|
}"
|
|
13
|
-
:draggable="
|
|
14
|
+
:draggable="!item.pinned"
|
|
14
15
|
@click.self="handleActivate(true)"
|
|
15
16
|
@contextmenu="$emit('contextmenu', $event)"
|
|
16
17
|
@dragstart="handleDragStart"
|
|
@@ -40,7 +41,7 @@
|
|
|
40
41
|
<button
|
|
41
42
|
v-if="item.closable"
|
|
42
43
|
type="button"
|
|
43
|
-
class="stack-tab__icon-close-fill stack-tab__item-button"
|
|
44
|
+
class="stack-tab__icon stack-tab__icon--close-fill stack-tab__item-button"
|
|
44
45
|
:title="t('VueStackTab.close')"
|
|
45
46
|
:aria-label="`${t('VueStackTab.close')} ${title}`"
|
|
46
47
|
:tabindex="item.active ? 0 : -1"
|
|
@@ -99,6 +100,11 @@ const handleClose = () => {
|
|
|
99
100
|
emit('close', props.item)
|
|
100
101
|
}
|
|
101
102
|
const handleDragStart = (event: DragEvent) => {
|
|
103
|
+
// pinned 标签(home)固定在左侧,不参与拖拽。
|
|
104
|
+
if (props.item.pinned) {
|
|
105
|
+
event.preventDefault()
|
|
106
|
+
return
|
|
107
|
+
}
|
|
102
108
|
dragging.value = true
|
|
103
109
|
event.dataTransfer?.setData('text/plain', props.item.id)
|
|
104
110
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<template>
|
|
7
7
|
<tab-header-button
|
|
8
8
|
v-if="isScrollButtonVisible"
|
|
9
|
-
icon-class="stack-tab__icon-left-arrow"
|
|
9
|
+
icon-class="stack-tab__icon stack-tab__icon--left-arrow"
|
|
10
10
|
:disabled="isDisabledLeftButton"
|
|
11
11
|
:title="t('VueStackTab.scrollLeft')"
|
|
12
12
|
:aria-label="t('VueStackTab.scrollLeft')"
|
|
@@ -29,12 +29,12 @@
|
|
|
29
29
|
v-show="hasScroller"
|
|
30
30
|
ref="bar"
|
|
31
31
|
class="stack-tab__scrollbar"
|
|
32
|
-
:class="{ '
|
|
32
|
+
:class="{ 'stack-tab__scrollbar--dragging': scrollDragging }"
|
|
33
33
|
>
|
|
34
34
|
<div
|
|
35
35
|
ref="thumb"
|
|
36
36
|
class="stack-tab__scrollbar-thumb"
|
|
37
|
-
:class="{ '
|
|
37
|
+
:class="{ 'stack-tab__scrollbar-thumb--dragging': scrollDragging }"
|
|
38
38
|
:style="{
|
|
39
39
|
width: `${thumbWidth}px`,
|
|
40
40
|
transform: `translateX(${thumbLeft}px)`
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
</div>
|
|
46
46
|
<tab-header-button
|
|
47
47
|
v-if="isScrollButtonVisible"
|
|
48
|
-
icon-class="stack-tab__icon-right-arrow"
|
|
48
|
+
icon-class="stack-tab__icon stack-tab__icon--right-arrow"
|
|
49
49
|
:disabled="isDisabledRightButton"
|
|
50
50
|
:title="t('VueStackTab.scrollRight')"
|
|
51
51
|
:aria-label="t('VueStackTab.scrollRight')"
|
|
@@ -161,7 +161,7 @@ const updateScrollState = () => {
|
|
|
161
161
|
/** 确保激活的标签在可视区域内 */
|
|
162
162
|
const ensureActiveTabInView = () => {
|
|
163
163
|
const activeTabEl = container.value?.querySelector(
|
|
164
|
-
'.stack-tab__item
|
|
164
|
+
'.stack-tab__item--active'
|
|
165
165
|
) as HTMLElement | null
|
|
166
166
|
if (activeTabEl && !isInView(activeTabEl)) {
|
|
167
167
|
scrollIntoView(activeTabEl)
|
|
@@ -229,7 +229,8 @@ onMounted(() => {
|
|
|
229
229
|
ensureActiveTabInView()
|
|
230
230
|
})
|
|
231
231
|
tabListResizeObserver = new ResizeObserver(() => {
|
|
232
|
-
|
|
232
|
+
// 内容溢出(存在滚动条)时才需要在标签增删后纠正滚动位置。
|
|
233
|
+
if (hasScroller.value) ensureActiveTabInView()
|
|
233
234
|
updateScrollState()
|
|
234
235
|
})
|
|
235
236
|
|
|
@@ -238,6 +239,7 @@ onMounted(() => {
|
|
|
238
239
|
if (tabNav) tabListResizeObserver.observe(tabNav)
|
|
239
240
|
})
|
|
240
241
|
onUnmounted(() => {
|
|
242
|
+
handleScrollbarDragEnd()
|
|
241
243
|
scrollContainerResizeObserver?.disconnect()
|
|
242
244
|
tabListResizeObserver?.disconnect()
|
|
243
245
|
scrollContainerResizeObserver = null
|
|
@@ -42,7 +42,11 @@
|
|
|
42
42
|
</tab-header-scroll>
|
|
43
43
|
<slot name="rightButton" />
|
|
44
44
|
<tab-header-button
|
|
45
|
-
:icon-class="
|
|
45
|
+
:icon-class="
|
|
46
|
+
maximum
|
|
47
|
+
? 'stack-tab__icon stack-tab__icon--restore'
|
|
48
|
+
: 'stack-tab__icon stack-tab__icon--fullscreen'
|
|
49
|
+
"
|
|
46
50
|
:title="maximum ? t('VueStackTab.restore') : t('VueStackTab.maximum')"
|
|
47
51
|
:aria-label="maximum ? t('VueStackTab.restore') : t('VueStackTab.maximum')"
|
|
48
52
|
@click="maximum = !maximum"
|
|
@@ -149,6 +153,7 @@ const handleTabContextMenu = (e: MouseEvent, item: ITabItem, index: number, max:
|
|
|
149
153
|
|
|
150
154
|
/** 原生拖放只在释放鼠标时更新顺序,避免拖动经过标签时反复变更响应式列表。 */
|
|
151
155
|
const handleTabDragStart = (item: ITabItem) => {
|
|
156
|
+
if (item.pinned) return
|
|
152
157
|
draggingTabId.value = item.id
|
|
153
158
|
}
|
|
154
159
|
|
|
@@ -161,9 +166,11 @@ const handleTabDragOver = (item: ITabItem, event: DragEvent) => {
|
|
|
161
166
|
if (!tabElement) return
|
|
162
167
|
|
|
163
168
|
const { left, width } = tabElement.getBoundingClientRect()
|
|
169
|
+
const position = event.clientX < left + width / 2 ? 'before' : 'after'
|
|
164
170
|
dropIndicator.value = {
|
|
165
171
|
tabId: item.id,
|
|
166
|
-
|
|
172
|
+
// pinned 标签固定在左侧,拖到它前半区时改为插到它后面。
|
|
173
|
+
position: item.pinned && position === 'before' ? 'after' : position
|
|
167
174
|
}
|
|
168
175
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
|
|
169
176
|
}
|
|
@@ -181,6 +188,13 @@ const handleTabDrop = (target: ITabItem) => {
|
|
|
181
188
|
let destinationIndex = targetIndex
|
|
182
189
|
if (indicator.position === 'before' && sourceIndex < targetIndex) destinationIndex--
|
|
183
190
|
if (indicator.position === 'after' && sourceIndex > targetIndex) destinationIndex++
|
|
191
|
+
|
|
192
|
+
// home 等 pinned 标签固定在左侧,任何标签都不能插到它前面(store 层还会再兜底)。
|
|
193
|
+
let firstUnpinnedIndex = 0
|
|
194
|
+
tabs.value.forEach((tab, index) => {
|
|
195
|
+
if (tab.pinned) firstUnpinnedIndex = index + 1
|
|
196
|
+
})
|
|
197
|
+
destinationIndex = Math.max(destinationIndex, firstUnpinnedIndex)
|
|
184
198
|
moveTab(sourceId, destinationIndex)
|
|
185
199
|
}
|
|
186
200
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* 使 useTabPanel 只负责领域状态与路由协调。
|
|
6
6
|
*/
|
|
7
7
|
import {
|
|
8
|
+
Transition,
|
|
8
9
|
cloneVNode,
|
|
9
10
|
defineComponent,
|
|
10
11
|
onActivated,
|
|
@@ -15,16 +16,19 @@ import {
|
|
|
15
16
|
shallowRef
|
|
16
17
|
} from 'vue'
|
|
17
18
|
import type { DefineComponent, PropType, VNode } from 'vue'
|
|
18
|
-
import type { ITabBase
|
|
19
|
+
import type { ITabBase } from '../../model/TabModel'
|
|
19
20
|
import PageLoading from '../../components/PageLoading.vue'
|
|
20
21
|
import type { StackTabsRuntimeContext } from '../stackTabsContext'
|
|
21
22
|
import { useScrollShadow } from '../useScrollShadow'
|
|
22
23
|
|
|
23
24
|
interface PageComponentFactoryOptions {
|
|
24
25
|
runtimeContext: StackTabsRuntimeContext
|
|
25
|
-
|
|
26
|
+
consumeBackParams: (tabId: string, pageId: string) => Record<string, unknown> | null
|
|
27
|
+
getComponent: (id: string) => DefineComponent | undefined
|
|
28
|
+
setComponent: (id: string, component: DefineComponent) => void
|
|
26
29
|
addCache: (cacheName: string) => void
|
|
27
30
|
evictMarkedCaches: () => void
|
|
31
|
+
clearTabEvictionMarks: () => void
|
|
28
32
|
addPageScroller: (pageId: string, ...selectors: string[]) => void
|
|
29
33
|
saveScroller: (pageId: string) => void
|
|
30
34
|
restoreScroller: (pageId: string) => void
|
|
@@ -34,31 +38,70 @@ interface PageComponentFactoryOptions {
|
|
|
34
38
|
export const createPageComponentFactory = (options: PageComponentFactoryOptions) => {
|
|
35
39
|
const {
|
|
36
40
|
runtimeContext,
|
|
37
|
-
|
|
41
|
+
consumeBackParams,
|
|
42
|
+
getComponent,
|
|
43
|
+
setComponent,
|
|
38
44
|
addCache,
|
|
39
45
|
evictMarkedCaches,
|
|
46
|
+
clearTabEvictionMarks,
|
|
40
47
|
addPageScroller,
|
|
41
48
|
saveScroller,
|
|
42
49
|
restoreScroller,
|
|
43
50
|
removeScroller
|
|
44
51
|
} = options
|
|
45
|
-
const { components, tabIdsToEvict } = runtimeContext
|
|
46
52
|
|
|
47
53
|
const resolvePageComponent = (ctx: { cacheName: string; tabInfo: ITabBase }): DefineComponent => {
|
|
48
54
|
const { cacheName, tabInfo } = ctx
|
|
49
|
-
const existing =
|
|
55
|
+
const existing = getComponent(cacheName)
|
|
50
56
|
if (existing) {
|
|
51
57
|
addCache(cacheName)
|
|
52
58
|
return existing
|
|
53
59
|
}
|
|
54
60
|
|
|
55
|
-
|
|
61
|
+
/**
|
|
62
|
+
* 页面壳组件:作为 Transition 的直接子节点,承接刷新时的 key 变化,
|
|
63
|
+
* 避免直接修改 router-view 页面 vnode 的 key 破坏 keep-alive 的实例复用。
|
|
64
|
+
*/
|
|
65
|
+
const PageShell = defineComponent({
|
|
66
|
+
name: `${cacheName}-shell`,
|
|
67
|
+
props: {
|
|
68
|
+
vnode: {
|
|
69
|
+
type: Object as PropType<VNode | null>,
|
|
70
|
+
required: false,
|
|
71
|
+
default: null
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
setup(props) {
|
|
75
|
+
return () => props.vnode
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const cacheComponent = defineComponent<{
|
|
80
|
+
vnode?: VNode
|
|
81
|
+
refreshVersion?: number
|
|
82
|
+
transitionName?: string
|
|
83
|
+
}>({
|
|
56
84
|
name: cacheName,
|
|
57
85
|
props: {
|
|
58
86
|
vnode: {
|
|
59
87
|
type: Object as PropType<VNode>,
|
|
60
88
|
required: false,
|
|
61
89
|
default: undefined
|
|
90
|
+
},
|
|
91
|
+
/**
|
|
92
|
+
* 页面级刷新版本。外层 keep-alive 的 key 保持稳定(避免旧实例滞留缓存),
|
|
93
|
+
* 刷新时只让内部页面 vnode 的 key 变化,从而重建页面组件而保留包装层与滚动位置。
|
|
94
|
+
*/
|
|
95
|
+
refreshVersion: {
|
|
96
|
+
type: Number,
|
|
97
|
+
required: false,
|
|
98
|
+
default: 0
|
|
99
|
+
},
|
|
100
|
+
/** 页面转场名,与 StackKeepAlive 外层 Transition 保持一致 */
|
|
101
|
+
transitionName: {
|
|
102
|
+
type: String,
|
|
103
|
+
required: false,
|
|
104
|
+
default: ''
|
|
62
105
|
}
|
|
63
106
|
},
|
|
64
107
|
emits: ['onLoaded'],
|
|
@@ -67,21 +110,26 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
67
110
|
const lastVnode = shallowRef<VNode | null | undefined>(null)
|
|
68
111
|
const lastCloned = shallowRef<VNode | null>(null)
|
|
69
112
|
const lastBackParams = shallowRef<Record<string, unknown> | null>(null)
|
|
113
|
+
const lastRefreshVersion = shallowRef<number>(0)
|
|
70
114
|
const pageRoot = ref<HTMLElement | null>(null)
|
|
115
|
+
/**
|
|
116
|
+
* 转场期间临时把 wrapper 的 overflow 锁为 hidden。
|
|
117
|
+
* 内部 Transition 的 translateX 作用于页面根元素,若 wrapper 是 overflow:auto,
|
|
118
|
+
* 位移会让内容短暂溢出并闪出滚动条。
|
|
119
|
+
*/
|
|
120
|
+
const isTransitioning = ref(false)
|
|
121
|
+
let transitionCount = 0
|
|
122
|
+
const beginTransition = () => {
|
|
123
|
+
transitionCount++
|
|
124
|
+
isTransitioning.value = true
|
|
125
|
+
}
|
|
126
|
+
const endTransition = () => {
|
|
127
|
+
transitionCount = Math.max(0, transitionCount - 1)
|
|
128
|
+
if (transitionCount === 0) isTransitioning.value = false
|
|
129
|
+
}
|
|
71
130
|
|
|
72
131
|
const checkAndConsumeBackParams = () => {
|
|
73
|
-
|
|
74
|
-
const tab = getTab(tabInfo.id!)
|
|
75
|
-
const top = tab?.pages.peek()
|
|
76
|
-
if (top && top.id === cacheName && top._backParams) {
|
|
77
|
-
localBackParams.value = { ...top._backParams }
|
|
78
|
-
delete top._backParams
|
|
79
|
-
} else {
|
|
80
|
-
localBackParams.value = null
|
|
81
|
-
}
|
|
82
|
-
} catch {
|
|
83
|
-
localBackParams.value = null
|
|
84
|
-
}
|
|
132
|
+
localBackParams.value = consumeBackParams(tabInfo.id!, cacheName)
|
|
85
133
|
}
|
|
86
134
|
|
|
87
135
|
onMounted(() => {
|
|
@@ -94,46 +142,68 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
94
142
|
context.emit('onLoaded')
|
|
95
143
|
restoreScroller(cacheName)
|
|
96
144
|
evictMarkedCaches()
|
|
97
|
-
|
|
145
|
+
clearTabEvictionMarks()
|
|
98
146
|
checkAndConsumeBackParams()
|
|
99
147
|
})
|
|
100
148
|
onUnmounted(() => removeScroller(cacheName))
|
|
101
149
|
|
|
102
|
-
// 给 .
|
|
150
|
+
// 给 .stack-tab__cache-page-wrapper 挂滚动阴影,确保 keep-alive 的 suspended/deactivated 生命周期下表现一致
|
|
103
151
|
useScrollShadow({ target: pageRoot })
|
|
104
152
|
|
|
105
153
|
return () => {
|
|
106
154
|
const vnode = props.vnode
|
|
107
155
|
const backParams = localBackParams.value
|
|
108
|
-
|
|
109
|
-
|
|
156
|
+
const refreshVersion = props.refreshVersion ?? 0
|
|
157
|
+
if (
|
|
158
|
+
vnode !== lastVnode.value ||
|
|
159
|
+
backParams !== lastBackParams.value ||
|
|
160
|
+
refreshVersion !== lastRefreshVersion.value
|
|
161
|
+
) {
|
|
162
|
+
const cloned = vnode
|
|
110
163
|
? cloneVNode(vnode, {
|
|
111
164
|
tId: tabInfo.id,
|
|
112
165
|
pId: cacheName,
|
|
113
166
|
...(backParams ? { _back: backParams } : {})
|
|
114
167
|
})
|
|
115
168
|
: null
|
|
169
|
+
lastCloned.value = cloned
|
|
116
170
|
lastVnode.value = vnode
|
|
117
171
|
lastBackParams.value = backParams
|
|
172
|
+
lastRefreshVersion.value = refreshVersion
|
|
118
173
|
}
|
|
119
174
|
return (
|
|
120
175
|
<div
|
|
121
176
|
ref={pageRoot}
|
|
122
|
-
class="
|
|
177
|
+
class="stack-tab__cache-page-wrapper"
|
|
123
178
|
id={`W-${cacheName}`}
|
|
124
179
|
style={[
|
|
125
|
-
runtimeContext.useGlobalScroll.value
|
|
180
|
+
runtimeContext.useGlobalScroll.value && !isTransitioning.value
|
|
181
|
+
? 'overflow:auto'
|
|
182
|
+
: 'overflow:hidden',
|
|
126
183
|
'height: 100%'
|
|
127
184
|
]}
|
|
128
185
|
>
|
|
129
|
-
{lastCloned.value
|
|
186
|
+
{lastCloned.value ? (
|
|
187
|
+
<Transition
|
|
188
|
+
name={props.transitionName}
|
|
189
|
+
mode="out-in"
|
|
190
|
+
onBeforeEnter={beginTransition}
|
|
191
|
+
onAfterEnter={endTransition}
|
|
192
|
+
onEnterCancelled={endTransition}
|
|
193
|
+
onBeforeLeave={beginTransition}
|
|
194
|
+
onAfterLeave={endTransition}
|
|
195
|
+
onLeaveCancelled={endTransition}
|
|
196
|
+
>
|
|
197
|
+
<PageShell key={`${cacheName}-${refreshVersion}`} vnode={lastCloned.value} />
|
|
198
|
+
</Transition>
|
|
199
|
+
) : null}
|
|
130
200
|
<PageLoading tabId={tabInfo.id!} />
|
|
131
201
|
</div>
|
|
132
202
|
)
|
|
133
203
|
}
|
|
134
204
|
}
|
|
135
205
|
}) as DefineComponent
|
|
136
|
-
|
|
206
|
+
setComponent(cacheName, cacheComponent)
|
|
137
207
|
addCache(cacheName)
|
|
138
208
|
return cacheComponent
|
|
139
209
|
}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* 活动普通页面保持 cache id 稳定,只递增页面级刷新版本;非活动页面可直接替换缓存。
|
|
5
5
|
*/
|
|
6
6
|
import type { Ref } from 'vue'
|
|
7
|
-
import {
|
|
8
|
-
import type {
|
|
7
|
+
import type { ITabItem, ITabPage } from '../../model/TabModel'
|
|
8
|
+
import type { PagePatch, PageUpdate } from './tabStore'
|
|
9
9
|
|
|
10
10
|
export interface TabPanelRefreshApi {
|
|
11
11
|
refreshTab: (id: string) => void
|
|
@@ -13,9 +13,13 @@ export interface TabPanelRefreshApi {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export interface CreateTabPanelRefreshOptions {
|
|
16
|
-
|
|
16
|
+
getTabs: () => readonly ITabItem[]
|
|
17
|
+
getTopPage: (tabId: string) => ITabPage | null
|
|
18
|
+
/** 修改页面字段(刷新版本 / 页面 id) */
|
|
19
|
+
updatePage: (tabId: string, pageId: string, patch: PagePatch) => boolean
|
|
20
|
+
/** 批量修改页面字段,refreshAllTabs 最后统一触发一次 tabs 更新 */
|
|
21
|
+
updatePages: (updates: readonly PageUpdate[]) => void
|
|
17
22
|
iframeRefreshKeys: Ref<Record<string, number>>
|
|
18
|
-
getTab: (id: string) => ITabItem | null
|
|
19
23
|
createPageId: () => string
|
|
20
24
|
addCache: (cacheName: string) => void
|
|
21
25
|
evictPageCache: (cacheName: string) => void
|
|
@@ -30,9 +34,11 @@ export const createTabPanelRefresh = (
|
|
|
30
34
|
options: CreateTabPanelRefreshOptions
|
|
31
35
|
): TabPanelRefreshApi => {
|
|
32
36
|
const {
|
|
33
|
-
|
|
37
|
+
getTabs,
|
|
38
|
+
getTopPage,
|
|
39
|
+
updatePage,
|
|
40
|
+
updatePages,
|
|
34
41
|
iframeRefreshKeys,
|
|
35
|
-
getTab,
|
|
36
42
|
createPageId,
|
|
37
43
|
addCache,
|
|
38
44
|
evictPageCache,
|
|
@@ -51,31 +57,45 @@ export const createTabPanelRefresh = (
|
|
|
51
57
|
refreshIframeByPostMessage(tab.id)
|
|
52
58
|
}
|
|
53
59
|
|
|
54
|
-
const refreshPage = (
|
|
55
|
-
|
|
60
|
+
const refreshPage = (
|
|
61
|
+
tab: ITabItem,
|
|
62
|
+
cacheNamesToEvict?: string[],
|
|
63
|
+
cacheNamesToAdd?: string[],
|
|
64
|
+
pageUpdates?: PageUpdate[]
|
|
65
|
+
) => {
|
|
66
|
+
const currentPage = getTopPage(tab.id)
|
|
56
67
|
if (!currentPage) return
|
|
57
68
|
|
|
58
69
|
if (tab.active) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
70
|
+
const patch = { refreshVersion: (currentPage.refreshVersion ?? 0) + 1 }
|
|
71
|
+
if (pageUpdates) {
|
|
72
|
+
pageUpdates.push({ tabId: tab.id, pageId: currentPage.id, patch })
|
|
73
|
+
} else {
|
|
74
|
+
// 只有 refreshVersion 变化:页面对象是响应式的,不需要重建 tabs 数组。
|
|
75
|
+
updatePage(tab.id, currentPage.id, patch)
|
|
76
|
+
}
|
|
62
77
|
return
|
|
63
78
|
}
|
|
64
79
|
|
|
65
80
|
const oldCacheName = currentPage.id
|
|
66
|
-
|
|
67
|
-
|
|
81
|
+
const nextPageId = createPageId()
|
|
82
|
+
const patch = { id: nextPageId, refreshVersion: 0 }
|
|
83
|
+
if (pageUpdates) {
|
|
84
|
+
pageUpdates.push({ tabId: tab.id, pageId: oldCacheName, patch })
|
|
85
|
+
} else {
|
|
86
|
+
updatePage(tab.id, oldCacheName, patch)
|
|
87
|
+
}
|
|
68
88
|
if (cacheNamesToEvict && cacheNamesToAdd) {
|
|
69
89
|
cacheNamesToEvict.push(oldCacheName)
|
|
70
|
-
cacheNamesToAdd.push(
|
|
90
|
+
cacheNamesToAdd.push(nextPageId)
|
|
71
91
|
return
|
|
72
92
|
}
|
|
73
93
|
evictPageCache(oldCacheName)
|
|
74
|
-
addCache(
|
|
94
|
+
addCache(nextPageId)
|
|
75
95
|
}
|
|
76
96
|
|
|
77
97
|
const refreshTab = (id: string) => {
|
|
78
|
-
const tab =
|
|
98
|
+
const tab = getTabs().find((item) => item.id === id)
|
|
79
99
|
if (!tab?.refreshable) return
|
|
80
100
|
if (tab.iframe) {
|
|
81
101
|
refreshIframe(tab)
|
|
@@ -87,10 +107,11 @@ export const createTabPanelRefresh = (
|
|
|
87
107
|
const refreshAllTabs = () => {
|
|
88
108
|
const cacheNamesToEvict: string[] = []
|
|
89
109
|
const cacheNamesToAdd: string[] = []
|
|
110
|
+
const pageUpdates: PageUpdate[] = []
|
|
90
111
|
const iframeKeysUpdate = { ...iframeRefreshKeys.value }
|
|
91
112
|
let iframeKeysChanged = false
|
|
92
113
|
|
|
93
|
-
for (const tab of
|
|
114
|
+
for (const tab of getTabs()) {
|
|
94
115
|
if (!tab.refreshable) continue
|
|
95
116
|
if (tab.iframe) {
|
|
96
117
|
if (tab.iframeRefreshMode === 'reload') {
|
|
@@ -101,10 +122,11 @@ export const createTabPanelRefresh = (
|
|
|
101
122
|
}
|
|
102
123
|
continue
|
|
103
124
|
}
|
|
104
|
-
refreshPage(tab, cacheNamesToEvict, cacheNamesToAdd)
|
|
125
|
+
refreshPage(tab, cacheNamesToEvict, cacheNamesToAdd, pageUpdates)
|
|
105
126
|
}
|
|
106
127
|
|
|
107
128
|
if (iframeKeysChanged) iframeRefreshKeys.value = iframeKeysUpdate
|
|
129
|
+
updatePages(pageUpdates)
|
|
108
130
|
replacePageCaches(cacheNamesToEvict, cacheNamesToAdd)
|
|
109
131
|
}
|
|
110
132
|
|