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.
Files changed (50) hide show
  1. package/README.en.md +1 -0
  2. package/README.md +1 -0
  3. package/README.ru.md +1 -0
  4. package/dist/StackTabs.vue.d.ts +3 -0
  5. package/dist/components/ContextMenu/ContextMenuItem.vue.d.ts +1 -1
  6. package/dist/hooks/tabPanel/pageComponentFactory.d.ts +5 -2
  7. package/dist/hooks/tabPanel/refresh.d.ts +8 -3
  8. package/dist/hooks/tabPanel/tabStore.d.ts +80 -0
  9. package/dist/hooks/useTabActions.d.ts +1 -0
  10. package/dist/hooks/useTabPanel.d.ts +27 -5
  11. package/dist/model/TabModel.d.ts +5 -0
  12. package/dist/style.css +1 -1
  13. package/dist/utils/tabInfoEncoder.d.ts +10 -0
  14. package/dist/utils/urlParser.d.ts +14 -3
  15. package/dist/vue-stack-tabs.es.js +1 -1
  16. package/package.json +4 -7
  17. package/src/lib/StackTabs.vue +24 -2
  18. package/src/lib/assets/image/close_all.svg +1 -9
  19. package/src/lib/assets/image/close_lefts.svg +1 -1
  20. package/src/lib/assets/image/close_other.svg +1 -1
  21. package/src/lib/assets/image/close_rights.svg +1 -1
  22. package/src/lib/assets/image/reload.svg +1 -3
  23. package/src/lib/assets/image/reload_all.svg +1 -6
  24. package/src/lib/assets/style/common/mixins.scss +58 -19
  25. package/src/lib/assets/style/common/var.scss +100 -60
  26. package/src/lib/assets/style/icon.scss +31 -98
  27. package/src/lib/assets/style/index.scss +2 -2
  28. package/src/lib/assets/style/loading.scss +45 -0
  29. package/src/lib/assets/style/{stackTab.scss → stack-tab.scss} +105 -177
  30. package/src/lib/assets/style/transition.scss +12 -17
  31. package/src/lib/components/ContextMenu/ContextMenuItem.vue +5 -5
  32. package/src/lib/components/ContextMenu/index.vue +22 -19
  33. package/src/lib/components/PageLoading.vue +5 -5
  34. package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +5 -3
  35. package/src/lib/components/TabHeader/TabHeaderItem.vue +13 -7
  36. package/src/lib/components/TabHeader/TabHeaderScroll.vue +8 -6
  37. package/src/lib/components/TabHeader/index.vue +16 -2
  38. package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +96 -26
  39. package/src/lib/hooks/tabPanel/refresh.ts +40 -18
  40. package/src/lib/hooks/tabPanel/tabStore.ts +665 -0
  41. package/src/lib/hooks/useContextMenu.ts +1 -1
  42. package/src/lib/hooks/useIframeManager.ts +10 -3
  43. package/src/lib/hooks/useTabActions.ts +10 -6
  44. package/src/lib/hooks/useTabPanel.tsx +325 -195
  45. package/src/lib/hooks/useTabRouter.ts +50 -48
  46. package/src/lib/model/TabModel.ts +5 -0
  47. package/src/lib/utils/tabInfoEncoder.ts +101 -17
  48. package/src/lib/utils/urlParser.ts +35 -11
  49. package/dist/vue-stack-tabs.css +0 -2
  50. 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-tab__contextmenu"
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': menuZIndex()
16
+ 'z-index': menuZIndexValue
17
17
  }"
18
18
  @keydown="handleMenuKeydown"
19
19
  >
20
20
  <context-menu-item
21
- icon="stack-tab__icon-reload svg-mask"
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 svg-mask"
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 svg-mask"
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 svg-mask"
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 svg-mask"
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 svg-mask"
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 svg-mask"
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 svg-mask"
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
- * 菜单挂载时 z-index 必须重新计算(DOM 层叠已变化),先用 invalidateZIndexCache 失效再读取。
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
- const menuWidth = menuElementRef.value?.clientWidth ?? 0
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-loading-mask"
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-loading--spin turn" aria-hidden="true" />
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 遮罩每次显隐都会改变 .cache-page-wrapper 子树层叠,先失效缓存再读取避免旧值。
26
+ * loading 遮罩每次显隐都会改变 .stack-tab__cache-page-wrapper 子树层叠,先失效缓存再读取避免旧值。
27
27
  */
28
28
  const loadingZIndex = () => {
29
- invalidateZIndexCache('.cache-page-wrapper *')
30
- return getMaxZIndex('.cache-page-wrapper *')
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, activePageRefreshVersion, component }"
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="`${activeCacheKey}-${activePageRefreshVersion}`"
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
- 'is-active': item.active,
8
- 'is-icon': item.closable,
9
- 'is-dragging': dragging,
10
- 'is-drop-before': dropPosition === 'before',
11
- 'is-drop-after': dropPosition === 'after'
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="true"
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="{ 'is-dragging': scrollDragging }"
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="{ 'is-dragging': scrollDragging }"
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.is-active'
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
- if (!hasScroller.value) ensureActiveTabInView()
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="maximum ? 'stack-tab__icon-restore' : 'stack-tab__icon-fullscreen'"
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
- position: event.clientX < left + width / 2 ? 'before' : 'after'
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, ITabItem } from '../../model/TabModel'
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
- getTab: (id: string) => ITabItem | null
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
- getTab,
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 = components.get(cacheName)
55
+ const existing = getComponent(cacheName)
50
56
  if (existing) {
51
57
  addCache(cacheName)
52
58
  return existing
53
59
  }
54
60
 
55
- const cacheComponent = defineComponent<{ vnode?: VNode }>({
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
- try {
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
- tabIdsToEvict.clear()
145
+ clearTabEvictionMarks()
98
146
  checkAndConsumeBackParams()
99
147
  })
100
148
  onUnmounted(() => removeScroller(cacheName))
101
149
 
102
- // 给 .cache-page-wrapper 挂滚动阴影,确保 keep-alive 的 suspended/deactivated 生命周期下表现一致
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
- if (vnode !== lastVnode.value || backParams !== lastBackParams.value) {
109
- lastCloned.value = vnode
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="cache-page-wrapper"
177
+ class="stack-tab__cache-page-wrapper"
123
178
  id={`W-${cacheName}`}
124
179
  style={[
125
- runtimeContext.useGlobalScroll.value ? 'overflow:auto' : 'overflow:hidden',
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
- components.set(cacheName, cacheComponent)
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 { triggerRef } from 'vue'
8
- import type { ITabItem } from '../../model/TabModel'
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
- tabs: Ref<ITabItem[]>
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
- tabs,
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 = (tab: ITabItem, cacheNamesToEvict?: string[], cacheNamesToAdd?: string[]) => {
55
- const currentPage = tab.pages.peek()
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
- currentPage.refreshVersion = (currentPage.refreshVersion ?? 0) + 1
60
- // 通知 activePageRefreshVersion 这类依赖 tabs.value 的计算属性重新计算
61
- triggerRef(tabs)
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
- currentPage.id = createPageId()
67
- currentPage.refreshVersion = 0
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(currentPage.id)
90
+ cacheNamesToAdd.push(nextPageId)
71
91
  return
72
92
  }
73
93
  evictPageCache(oldCacheName)
74
- addCache(currentPage.id)
94
+ addCache(nextPageId)
75
95
  }
76
96
 
77
97
  const refreshTab = (id: string) => {
78
- const tab = getTab(id)
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 tabs.value) {
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