vue-stack-tabs 1.4.2 → 2.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 (71) 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 +5 -2
  5. package/dist/components/ContextMenu/ContextMenuItem.vue.d.ts +1 -1
  6. package/dist/components/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -2
  7. package/dist/hooks/stackTabsContext.d.ts +0 -1
  8. package/dist/hooks/tabPanel/evict.d.ts +2 -2
  9. package/dist/hooks/tabPanel/refresh.d.ts +4 -2
  10. package/dist/hooks/tabPanel/scroll.d.ts +0 -2
  11. package/dist/hooks/tabPanel/state.d.ts +0 -1
  12. package/dist/hooks/tabPanel/tabStore.d.ts +12 -1
  13. package/dist/hooks/useIframeManager.d.ts +1 -1
  14. package/dist/hooks/useScrollShadow.d.ts +11 -2
  15. package/dist/hooks/useTabActions.d.ts +1 -0
  16. package/dist/hooks/useTabEventBus.d.ts +5 -0
  17. package/dist/hooks/useTabPanel.d.ts +14 -2
  18. package/dist/model/TabModel.d.ts +17 -5
  19. package/dist/nuxt/module.mjs +1 -1
  20. package/dist/nuxt/runtime/plugin.mjs +8 -1598
  21. package/dist/style.css +1 -1
  22. package/dist/utils/scrollUtils.d.ts +0 -2
  23. package/dist/utils/tabInfoEncoder.d.ts +8 -0
  24. package/dist/utils/urlParser.d.ts +20 -10
  25. package/dist/vue-stack-tabs.es.js +1 -1
  26. package/package.json +4 -7
  27. package/src/lib/StackTabs.vue +40 -3
  28. package/src/lib/assets/image/close_all.svg +1 -9
  29. package/src/lib/assets/image/close_lefts.svg +1 -1
  30. package/src/lib/assets/image/close_other.svg +1 -1
  31. package/src/lib/assets/image/close_rights.svg +1 -1
  32. package/src/lib/assets/image/reload.svg +1 -3
  33. package/src/lib/assets/image/reload_all.svg +1 -6
  34. package/src/lib/assets/style/common/mixins.scss +58 -19
  35. package/src/lib/assets/style/common/var.scss +100 -60
  36. package/src/lib/assets/style/icon.scss +31 -98
  37. package/src/lib/assets/style/index.scss +2 -2
  38. package/src/lib/assets/style/loading.scss +45 -0
  39. package/src/lib/assets/style/{stackTab.scss → stack-tab.scss} +116 -182
  40. package/src/lib/assets/style/transition.scss +12 -17
  41. package/src/lib/components/ContextMenu/ContextMenuItem.vue +5 -5
  42. package/src/lib/components/ContextMenu/index.vue +22 -19
  43. package/src/lib/components/PageLoading.vue +12 -11
  44. package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +1 -5
  45. package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +5 -3
  46. package/src/lib/components/TabHeader/TabHeaderItem.vue +13 -7
  47. package/src/lib/components/TabHeader/TabHeaderScroll.vue +170 -94
  48. package/src/lib/components/TabHeader/index.vue +67 -12
  49. package/src/lib/hooks/stackTabsContext.ts +0 -1
  50. package/src/lib/hooks/tabPanel/evict.ts +14 -6
  51. package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +252 -7
  52. package/src/lib/hooks/tabPanel/refresh.ts +27 -9
  53. package/src/lib/hooks/tabPanel/scroll.ts +4 -15
  54. package/src/lib/hooks/tabPanel/session.ts +11 -4
  55. package/src/lib/hooks/tabPanel/state.ts +0 -2
  56. package/src/lib/hooks/tabPanel/tabStore.ts +114 -26
  57. package/src/lib/hooks/useContextMenu.ts +1 -1
  58. package/src/lib/hooks/useIframeManager.ts +21 -18
  59. package/src/lib/hooks/useScrollShadow.ts +74 -19
  60. package/src/lib/hooks/useTabActions.ts +18 -8
  61. package/src/lib/hooks/useTabEventBus.ts +3 -0
  62. package/src/lib/hooks/useTabPanel.tsx +247 -58
  63. package/src/lib/hooks/useTabRouter.ts +18 -11
  64. package/src/lib/model/TabModel.ts +19 -5
  65. package/src/lib/nuxt/module.ts +3 -1
  66. package/src/lib/nuxt/runtime/plugin.ts +7 -3
  67. package/src/lib/utils/scrollUtils.ts +0 -18
  68. package/src/lib/utils/tabInfoEncoder.ts +121 -18
  69. package/src/lib/utils/urlParser.ts +75 -48
  70. package/dist/vue-stack-tabs.css +0 -2
  71. package/src/lib/assets/style/overlayer.scss +0 -46
@@ -23,13 +23,10 @@
23
23
  <tab-header-item
24
24
  v-for="(item, index) in tabs"
25
25
  :key="item.id"
26
- :item="item as ITabItem"
26
+ :item="item"
27
27
  :drop-position="dropIndicator?.tabId === item.id ? dropIndicator.position : undefined"
28
- @contextmenu="
29
- (e: MouseEvent) =>
30
- handleTabContextMenu(e, item as unknown as ITabItem, index, tabs.length)
31
- "
32
- @click.middle.prevent="handleCloseTab(item as ITabItem)"
28
+ @contextmenu="(e: MouseEvent) => handleTabContextMenu(e, item, index, tabs.length)"
29
+ @click.middle.prevent="handleCloseTab(item)"
33
30
  @close="handleCloseTab"
34
31
  @active="handleActivateTab"
35
32
  @dragstart="handleTabDragStart"
@@ -42,7 +39,11 @@
42
39
  </tab-header-scroll>
43
40
  <slot name="rightButton" />
44
41
  <tab-header-button
45
- :icon-class="maximum ? 'stack-tab__icon-restore' : 'stack-tab__icon-fullscreen'"
42
+ :icon-class="
43
+ maximum
44
+ ? 'stack-tab__icon stack-tab__icon--restore'
45
+ : 'stack-tab__icon stack-tab__icon--fullscreen'
46
+ "
46
47
  :title="maximum ? t('VueStackTab.restore') : t('VueStackTab.maximum')"
47
48
  :aria-label="maximum ? t('VueStackTab.restore') : t('VueStackTab.maximum')"
48
49
  @click="maximum = !maximum"
@@ -71,7 +72,7 @@
71
72
  依赖:useTabActions(tabs/closeTab/activeTab)、useContextMenu
72
73
  -->
73
74
  <script lang="ts" setup>
74
- import { inject, computed, ref, nextTick } from 'vue'
75
+ import { inject, computed, ref, nextTick, watch, onUnmounted } from 'vue'
75
76
  import type { TransitionProps } from 'vue'
76
77
  import { TabScrollMode } from '../../model/TabModel'
77
78
  import type { IContextMenu, ITabItem } from '../../model/TabModel'
@@ -83,6 +84,7 @@ import TabHeaderScroll from './TabHeaderScroll.vue'
83
84
  import TabHeaderButton from './TabHeaderButton.vue'
84
85
  import { useI18n } from 'vue-i18n-lite'
85
86
  import { maximumKey } from '../../hooks/stackTabsContext'
87
+ import { TabEventType, useTabEmitter } from '../../hooks/useTabEventBus'
86
88
  const { t } = useI18n()
87
89
  /** 是否最大化,由上层 provide */
88
90
  const maximum = inject(maximumKey, ref(false))
@@ -117,6 +119,47 @@ const props = withDefaults(
117
119
  }
118
120
  )
119
121
  const { closeTab, activeTab, moveTab, tabs } = useTabActions()
122
+
123
+ /** 当前激活标签 id */
124
+ const activeTabId = computed<string>(() => tabs.value.find((tab) => tab.active)?.id ?? '')
125
+
126
+ /**
127
+ * 将指定标签滚动到可视区域;未指定 id 时使用当前激活标签。
128
+ * 仅在标签确实不可见时才滚动,避免干扰用户的手动滚动。
129
+ */
130
+ const ensureTabInView = (tabId?: string) => {
131
+ const scrollContainer = scrollContainerRef.value
132
+ if (!scrollContainer) return
133
+
134
+ const target = headerRef.value?.querySelector<HTMLElement>(
135
+ tabId
136
+ ? `.stack-tab__nav [role="tab"][data-tab-id="${CSS.escape(tabId)}"]`
137
+ : '.stack-tab__nav .stack-tab__item--active'
138
+ )
139
+ const tabElement = target?.closest<HTMLElement>('.stack-tab__item')
140
+ if (tabElement && !scrollContainer.isInView(tabElement)) {
141
+ scrollContainer.scrollIntoView(tabElement)
142
+ }
143
+ }
144
+
145
+ /**
146
+ * 激活标签变化后确保其位于可视区域:
147
+ * 覆盖新开标签、通过 openTab 唤醒已有标签、关闭标签后顺延激活等场景。
148
+ * 仅响应用户主动滚动时不会触发(滚动不改变 active),因此不会把视图强行拉回。
149
+ */
150
+ watch(activeTabId, () => {
151
+ nextTick(() => ensureTabInView())
152
+ })
153
+
154
+ /** 标签已是 active 但被滚开时,显式打开/激活请求仍需把标签栏滚动到它。 */
155
+ const emitter = useTabEmitter()
156
+ const handleTabScrollTo = ({ id }: { id: string }) => {
157
+ nextTick(() => ensureTabInView(id))
158
+ }
159
+ emitter.on(TabEventType.TAB_SCROLL_TO, handleTabScrollTo)
160
+ onUnmounted(() => {
161
+ emitter.off(TabEventType.TAB_SCROLL_TO, handleTabScrollTo)
162
+ })
120
163
  const draggingTabId = ref<string | null>(null)
121
164
  type DropPosition = 'before' | 'after'
122
165
  const dropIndicator = ref<{ tabId: string; position: DropPosition } | null>(null)
@@ -149,6 +192,7 @@ const handleTabContextMenu = (e: MouseEvent, item: ITabItem, index: number, max:
149
192
 
150
193
  /** 原生拖放只在释放鼠标时更新顺序,避免拖动经过标签时反复变更响应式列表。 */
151
194
  const handleTabDragStart = (item: ITabItem) => {
195
+ if (item.pinned) return
152
196
  draggingTabId.value = item.id
153
197
  }
154
198
 
@@ -161,9 +205,11 @@ const handleTabDragOver = (item: ITabItem, event: DragEvent) => {
161
205
  if (!tabElement) return
162
206
 
163
207
  const { left, width } = tabElement.getBoundingClientRect()
208
+ const position = event.clientX < left + width / 2 ? 'before' : 'after'
164
209
  dropIndicator.value = {
165
210
  tabId: item.id,
166
- position: event.clientX < left + width / 2 ? 'before' : 'after'
211
+ // pinned 标签固定在左侧,拖到它前半区时改为插到它后面。
212
+ position: item.pinned && position === 'before' ? 'after' : position
167
213
  }
168
214
  if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
169
215
  }
@@ -181,6 +227,13 @@ const handleTabDrop = (target: ITabItem) => {
181
227
  let destinationIndex = targetIndex
182
228
  if (indicator.position === 'before' && sourceIndex < targetIndex) destinationIndex--
183
229
  if (indicator.position === 'after' && sourceIndex > targetIndex) destinationIndex++
230
+
231
+ // home 等 pinned 标签固定在左侧,任何标签都不能插到它前面(store 层还会再兜底)。
232
+ let firstUnpinnedIndex = 0
233
+ tabs.value.forEach((tab, index) => {
234
+ if (tab.pinned) firstUnpinnedIndex = index + 1
235
+ })
236
+ destinationIndex = Math.max(destinationIndex, firstUnpinnedIndex)
184
237
  moveTab(sourceId, destinationIndex)
185
238
  }
186
239
 
@@ -205,7 +258,7 @@ const handleTabListDragOver = (event: DragEvent) => {
205
258
  const handleTabListDrop = (event: DragEvent) => {
206
259
  if (isTabItemDragEvent(event)) return
207
260
  const lastTab = tabs.value[tabs.value.length - 1]
208
- if (lastTab) handleTabDrop(lastTab as ITabItem)
261
+ if (lastTab) handleTabDrop(lastTab)
209
262
  }
210
263
 
211
264
  /** 标签列表 transition 的 props,支持 string 或对象 */
@@ -234,7 +287,7 @@ const focusTabById = (tabId: string) => {
234
287
  const activateTabByIndex = (index: number) => {
235
288
  const item = tabs.value[index]
236
289
  if (!item) return
237
- handleActivateTab(item as ITabItem, undefined, true)
290
+ handleActivateTab(item, undefined, true)
238
291
  focusTabById(String(item.id))
239
292
  }
240
293
 
@@ -303,7 +356,9 @@ const handleActivateTab = (
303
356
  Promise.resolve(activeTab(item.id, isRoute))
304
357
  .then(() => emit('active', item.id))
305
358
  .catch((error: unknown) => {
306
- console.warn('[vue-stack-tabs] Failed to activate tab:', error)
359
+ if (!import.meta.env.PROD) {
360
+ console.warn('[vue-stack-tabs] Failed to activate tab:', error)
361
+ }
307
362
  })
308
363
  }
309
364
  </script>
@@ -23,7 +23,6 @@ export const STACK_TABS_DUPLICATE_INSTANCE_MESSAGE =
23
23
 
24
24
  export interface StackTabsRuntimeContext {
25
25
  tabs: Ref<ITabItem[]>
26
- defaultTabs: Ref<ITabItem[]>
27
26
  caches: ShallowRef<string[]>
28
27
  components: Map<string, DefineComponent>
29
28
  cacheIdsToEvict: Set<string>
@@ -9,8 +9,11 @@ import type { StackTabsRuntimeContext } from '../stackTabsContext'
9
9
 
10
10
  export interface TabPanelEvictionApi {
11
11
  markCacheForEviction: (cacheName: string) => void
12
- markTabPagesForEviction: (tab: { id: string; pages: { list(): ITabPage[] } }) => void
13
- markTabPagesForEvictionOnly: (tab: { pages: { list(): ITabPage[] } }) => void
12
+ markTabPagesForEviction: (tab: {
13
+ id: string
14
+ pages: { readonlyList(): readonly ITabPage[] }
15
+ }) => void
16
+ markTabPagesForEvictionOnly: (tab: { pages: { readonlyList(): readonly ITabPage[] } }) => void
14
17
  addCache: (cacheName: string) => void
15
18
  removeCache: (cacheName: string) => void
16
19
  evictPageCache: (cacheName: string) => void
@@ -39,13 +42,18 @@ export const createTabPanelEviction = (context: StackTabsRuntimeContext): TabPan
39
42
  cacheIdsToEvict.add(cacheName)
40
43
  }
41
44
 
42
- const markTabPagesForEviction = (tab: { id: string; pages: { list(): ITabPage[] } }): void => {
43
- for (const page of tab.pages.list()) markCacheForEviction(page.id)
45
+ const markTabPagesForEviction = (tab: {
46
+ id: string
47
+ pages: { readonlyList(): readonly ITabPage[] }
48
+ }): void => {
49
+ for (const page of tab.pages.readonlyList()) markCacheForEviction(page.id)
44
50
  tabIdsToEvict.add(tab.id)
45
51
  }
46
52
 
47
- const markTabPagesForEvictionOnly = (tab: { pages: { list(): ITabPage[] } }): void => {
48
- for (const page of tab.pages.list()) markCacheForEviction(page.id)
53
+ const markTabPagesForEvictionOnly = (tab: {
54
+ pages: { readonlyList(): readonly ITabPage[] }
55
+ }): void => {
56
+ for (const page of tab.pages.readonlyList()) markCacheForEviction(page.id)
49
57
  }
50
58
 
51
59
  const addCache = (cacheName: string): void => {
@@ -7,12 +7,15 @@
7
7
  import {
8
8
  cloneVNode,
9
9
  defineComponent,
10
+ nextTick,
10
11
  onActivated,
12
+ onBeforeUnmount,
11
13
  onDeactivated,
12
14
  onMounted,
13
15
  onUnmounted,
14
16
  ref,
15
- shallowRef
17
+ shallowRef,
18
+ watch
16
19
  } from 'vue'
17
20
  import type { DefineComponent, PropType, VNode } from 'vue'
18
21
  import type { ITabBase } from '../../model/TabModel'
@@ -34,6 +37,36 @@ interface PageComponentFactoryOptions {
34
37
  removeScroller: (pageId: string) => void
35
38
  }
36
39
 
40
+ const parseTimeList = (value: string): number[] =>
41
+ value.split(',').map((item) => Number.parseFloat(item.trim()) || 0)
42
+
43
+ /** 读取元素当前 transition 的总时长(秒转毫秒),用于等待离场/入场结束。 */
44
+ const getTransitionDurationMs = (element: HTMLElement): number => {
45
+ if (typeof window === 'undefined') return 0
46
+ const style = window.getComputedStyle(element)
47
+ const durations = parseTimeList(style.transitionDuration || '0s')
48
+ const delays = parseTimeList(style.transitionDelay || '0s')
49
+ if (durations.length === 0) return 0
50
+ return (
51
+ Math.max(
52
+ ...durations.map((duration, index) => duration + (delays[index % delays.length] ?? 0))
53
+ ) * 1000
54
+ )
55
+ }
56
+
57
+ /** 等待下一帧,让刚添加的 transition 起始样式先生效。 */
58
+ const nextFrame = (): Promise<void> =>
59
+ new Promise((resolve) => {
60
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => resolve())
61
+ else setTimeout(resolve, 16)
62
+ })
63
+
64
+ /** 用户开启「减少动态效果」时直接切换页面,不执行转场。 */
65
+ const prefersReducedMotion = (): boolean =>
66
+ typeof window !== 'undefined' &&
67
+ typeof window.matchMedia === 'function' &&
68
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
69
+
37
70
  export const createPageComponentFactory = (options: PageComponentFactoryOptions) => {
38
71
  const {
39
72
  runtimeContext,
@@ -57,13 +90,51 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
57
90
  return existing
58
91
  }
59
92
 
60
- const cacheComponent = defineComponent<{ vnode?: VNode }>({
93
+ /**
94
+ * 页面壳组件:承接刷新时的 key 变化,让页面重建发生在缓存 wrapper 内部,
95
+ * 避免直接修改 router-view 页面 vnode 的 key 破坏 keep-alive 的实例复用。
96
+ * 不额外渲染元素,页面可以是单根,也可以是多根节点(Fragment)。
97
+ */
98
+ const PageShell = defineComponent({
99
+ name: `${cacheName}-shell`,
100
+ props: {
101
+ vnode: {
102
+ type: Object as PropType<VNode | null>,
103
+ required: false,
104
+ default: null
105
+ }
106
+ },
107
+ setup(props) {
108
+ return () => props.vnode
109
+ }
110
+ })
111
+
112
+ const cacheComponent = defineComponent<{
113
+ vnode?: VNode
114
+ refreshVersion?: number
115
+ transitionName?: string
116
+ }>({
61
117
  name: cacheName,
62
118
  props: {
63
119
  vnode: {
64
120
  type: Object as PropType<VNode>,
65
121
  required: false,
66
122
  default: undefined
123
+ },
124
+ /**
125
+ * 页面级刷新版本。外层 keep-alive 的 key 保持稳定(避免旧实例滞留缓存),
126
+ * 刷新时只让内部页面 vnode 的 key 变化,从而重建页面组件而保留包装层与滚动位置。
127
+ */
128
+ refreshVersion: {
129
+ type: Number,
130
+ required: false,
131
+ default: 0
132
+ },
133
+ /** 页面转场名,与 StackKeepAlive 外层 Transition 保持一致 */
134
+ transitionName: {
135
+ type: String,
136
+ required: false,
137
+ default: ''
67
138
  }
68
139
  },
69
140
  emits: ['onLoaded'],
@@ -73,6 +144,154 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
73
144
  const lastCloned = shallowRef<VNode | null>(null)
74
145
  const lastBackParams = shallowRef<Record<string, unknown> | null>(null)
75
146
  const pageRoot = ref<HTMLElement | null>(null)
147
+ /**
148
+ * 实际渲染的刷新版本。
149
+ *
150
+ * 刷新时先把 wrapper(真实元素,兼容多根节点页面)作为动画主体执行离场,
151
+ * 离场结束再提升该值重建页面,最后执行入场;因此它会短暂落后于
152
+ * props.refreshVersion。外层 keep-alive key 保持不变,避免实例累积。
153
+ */
154
+ const renderedRefreshVersion = ref(props.refreshVersion ?? 0)
155
+
156
+ let refreshDisposed = false
157
+ let isFlushingRefresh = false
158
+ let queuedRefreshVersion: number | null = null
159
+ let refreshTimer: ReturnType<typeof setTimeout> | null = null
160
+ let cancelRefreshWait: (() => void) | null = null
161
+ let appliedTransitionName = ''
162
+
163
+ const clearRefreshTimer = () => {
164
+ if (refreshTimer === null) return
165
+ clearTimeout(refreshTimer)
166
+ refreshTimer = null
167
+ }
168
+
169
+ const removeRefreshClasses = () => {
170
+ const element = pageRoot.value
171
+ const name = appliedTransitionName
172
+ if (!element || !name) return
173
+ element.classList.remove(
174
+ `${name}-leave-active`,
175
+ `${name}-leave-to`,
176
+ `${name}-enter-active`,
177
+ `${name}-enter-from`
178
+ )
179
+ appliedTransitionName = ''
180
+ }
181
+
182
+ /**
183
+ * 等待一段 transition 结束;transitionend 不可用时用时长兜底。
184
+ * 元素可能同时有多个过渡属性(opacity / transform),任意一个结束即可。
185
+ */
186
+ const waitForRefreshEnd = (duration: number): Promise<void> =>
187
+ new Promise((resolve) => {
188
+ const element = pageRoot.value
189
+ if (!element || duration <= 0) {
190
+ resolve()
191
+ return
192
+ }
193
+ let settled = false
194
+ const finish = () => {
195
+ if (settled) return
196
+ settled = true
197
+ cancelRefreshWait = null
198
+ clearRefreshTimer()
199
+ element.removeEventListener('transitionend', handleEnd)
200
+ resolve()
201
+ }
202
+ const handleEnd = (event: TransitionEvent) => {
203
+ if (event.target === element) finish()
204
+ }
205
+ cancelRefreshWait = finish
206
+ element.addEventListener('transitionend', handleEnd)
207
+ refreshTimer = setTimeout(finish, duration + 80)
208
+ })
209
+
210
+ /**
211
+ * 执行一次完整的刷新转场:旧内容离场 → 提升版本重建页面 → 新内容入场。
212
+ *
213
+ * 动画类名沿用 Vue Transition 的约定(`${name}-leave-active` 等),
214
+ * 因此自定义页面过渡 CSS 仍然有效;动画主体是缓存 wrapper 本身,
215
+ * 页面是单根还是多根(Fragment)都不影响。
216
+ */
217
+ const runRefreshAnimation = async (targetVersion: number): Promise<void> => {
218
+ const element = pageRoot.value
219
+ const name = props.transitionName
220
+ if (!element || !name || prefersReducedMotion()) {
221
+ renderedRefreshVersion.value = targetVersion
222
+ return
223
+ }
224
+
225
+ appliedTransitionName = name
226
+ element.classList.add(`${name}-leave-active`)
227
+ const leaveDuration = getTransitionDurationMs(element)
228
+ if (leaveDuration <= 0) {
229
+ removeRefreshClasses()
230
+ renderedRefreshVersion.value = targetVersion
231
+ return
232
+ }
233
+
234
+ // 先让 leave-active 生效,再添加 leave-to,触发离场过渡。
235
+ await nextFrame()
236
+ if (refreshDisposed) {
237
+ removeRefreshClasses()
238
+ return
239
+ }
240
+ element.classList.add(`${name}-leave-to`)
241
+ await waitForRefreshEnd(leaveDuration)
242
+ removeRefreshClasses()
243
+ if (refreshDisposed) return
244
+
245
+ renderedRefreshVersion.value = targetVersion
246
+ await nextTick()
247
+ if (refreshDisposed) return
248
+
249
+ appliedTransitionName = name
250
+ // 先把元素落到入场起点(不加 transition,避免从离场终点反向缓动),
251
+ // 再启用 enter-active,下一帧移除 enter-from,形成标准的入场过渡。
252
+ element.classList.add(`${name}-enter-from`)
253
+ void element.offsetWidth
254
+ element.classList.add(`${name}-enter-active`)
255
+ const enterDuration = getTransitionDurationMs(element)
256
+ if (enterDuration <= 0) {
257
+ removeRefreshClasses()
258
+ return
259
+ }
260
+
261
+ await nextFrame()
262
+ if (refreshDisposed) {
263
+ removeRefreshClasses()
264
+ return
265
+ }
266
+ element.classList.remove(`${name}-enter-from`)
267
+ await waitForRefreshEnd(enterDuration)
268
+ removeRefreshClasses()
269
+ }
270
+
271
+ const flushRefreshQueue = async (): Promise<void> => {
272
+ if (isFlushingRefresh) return
273
+ isFlushingRefresh = true
274
+ try {
275
+ while (!refreshDisposed && queuedRefreshVersion !== null) {
276
+ const targetVersion = queuedRefreshVersion
277
+ queuedRefreshVersion = null
278
+ if (targetVersion === renderedRefreshVersion.value) continue
279
+ await runRefreshAnimation(targetVersion)
280
+ }
281
+ } finally {
282
+ isFlushingRefresh = false
283
+ }
284
+ }
285
+
286
+ watch(
287
+ () => props.refreshVersion ?? 0,
288
+ (nextVersion) => {
289
+ if (nextVersion === renderedRefreshVersion.value && queuedRefreshVersion === null)
290
+ return
291
+ queuedRefreshVersion = nextVersion
292
+ void flushRefreshQueue()
293
+ }
294
+ )
76
295
 
77
296
  const checkAndConsumeBackParams = () => {
78
297
  localBackParams.value = consumeBackParams(tabInfo.id!, cacheName)
@@ -81,20 +300,38 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
81
300
  onMounted(() => {
82
301
  context.emit('onLoaded')
83
302
  addPageScroller(cacheName, `#W-${cacheName}`)
303
+ // 新页面经 Transition(out-in) 挂载时,setup 后的 microtask 可能早于 ref 赋值;
304
+ // 挂载后重新 sync,确保 scroll shadow 监听与阴影类目标正确绑定。
305
+ scrollShadow.sync()
84
306
  checkAndConsumeBackParams()
85
307
  })
86
- onDeactivated(() => saveScroller(cacheName))
308
+ onDeactivated(() => {
309
+ scrollShadow.clear()
310
+ saveScroller(cacheName)
311
+ })
87
312
  onActivated(() => {
88
313
  context.emit('onLoaded')
89
314
  restoreScroller(cacheName)
315
+ scrollShadow.sync()
90
316
  evictMarkedCaches()
91
317
  clearTabEvictionMarks()
92
318
  checkAndConsumeBackParams()
93
319
  })
320
+ onBeforeUnmount(() => {
321
+ refreshDisposed = true
322
+ queuedRefreshVersion = null
323
+ cancelRefreshWait?.()
324
+ clearRefreshTimer()
325
+ removeRefreshClasses()
326
+ })
94
327
  onUnmounted(() => removeScroller(cacheName))
95
328
 
96
- // 给 .cache-page-wrapper 挂滚动阴影,确保 keep-alive 的 suspended/deactivated 生命周期下表现一致
97
- useScrollShadow({ target: pageRoot })
329
+ // 阴影类挂到不被刷新动画 transform 的 panel 上;滚动监听仍绑定 wrapper。
330
+ // 否则 ::after 的绝对定位包含块会随 transform 变化,顶部阴影会错位/消失。
331
+ const scrollShadow = useScrollShadow({
332
+ target: pageRoot,
333
+ classTarget: () => pageRoot.value?.parentElement ?? null
334
+ })
98
335
 
99
336
  return () => {
100
337
  const vnode = props.vnode
@@ -110,17 +347,25 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
110
347
  lastVnode.value = vnode
111
348
  lastBackParams.value = backParams
112
349
  }
350
+
113
351
  return (
114
352
  <div
115
353
  ref={pageRoot}
116
- class="cache-page-wrapper"
354
+ class="stack-tab__cache-page-wrapper"
117
355
  id={`W-${cacheName}`}
118
356
  style={[
357
+ // 动画作用于 wrapper 本身,页面内容不会相对滚动容器位移,
358
+ // 因此无需临时隐藏 overflow,避免动画期间滚动条消失导致内容宽度跳动。
119
359
  runtimeContext.useGlobalScroll.value ? 'overflow:auto' : 'overflow:hidden',
120
360
  'height: 100%'
121
361
  ]}
122
362
  >
123
- {lastCloned.value}
363
+ {lastCloned.value ? (
364
+ <PageShell
365
+ key={`${cacheName}-${renderedRefreshVersion.value}`}
366
+ vnode={lastCloned.value}
367
+ />
368
+ ) : null}
124
369
  <PageLoading tabId={tabInfo.id!} />
125
370
  </div>
126
371
  )
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import type { Ref } from 'vue'
7
7
  import type { ITabItem, ITabPage } from '../../model/TabModel'
8
- import type { PagePatch } from './tabStore'
8
+ import type { PagePatch, PageUpdate } from './tabStore'
9
9
 
10
10
  export interface TabPanelRefreshApi {
11
11
  refreshTab: (id: string) => void
@@ -15,8 +15,10 @@ export interface TabPanelRefreshApi {
15
15
  export interface CreateTabPanelRefreshOptions {
16
16
  getTabs: () => readonly ITabItem[]
17
17
  getTopPage: (tabId: string) => ITabPage | null
18
- /** 修改页面字段(刷新版本 / 页面 id)并触发 tabs 更新 */
18
+ /** 修改页面字段(刷新版本 / 页面 id) */
19
19
  updatePage: (tabId: string, pageId: string, patch: PagePatch) => boolean
20
+ /** 批量修改页面字段,refreshAllTabs 最后统一触发一次 tabs 更新 */
21
+ updatePages: (updates: readonly PageUpdate[]) => void
20
22
  iframeRefreshKeys: Ref<Record<string, number>>
21
23
  createPageId: () => string
22
24
  addCache: (cacheName: string) => void
@@ -35,6 +37,7 @@ export const createTabPanelRefresh = (
35
37
  getTabs,
36
38
  getTopPage,
37
39
  updatePage,
40
+ updatePages,
38
41
  iframeRefreshKeys,
39
42
  createPageId,
40
43
  addCache,
@@ -54,21 +57,34 @@ export const createTabPanelRefresh = (
54
57
  refreshIframeByPostMessage(tab.id)
55
58
  }
56
59
 
57
- const refreshPage = (tab: ITabItem, cacheNamesToEvict?: string[], cacheNamesToAdd?: string[]) => {
60
+ const refreshPage = (
61
+ tab: ITabItem,
62
+ cacheNamesToEvict?: string[],
63
+ cacheNamesToAdd?: string[],
64
+ pageUpdates?: PageUpdate[]
65
+ ) => {
58
66
  const currentPage = getTopPage(tab.id)
59
67
  if (!currentPage) return
60
68
 
61
69
  if (tab.active) {
62
- // updatePage 内部会触发 tabs 更新,activePageRefreshVersion 随之重新求值。
63
- updatePage(tab.id, currentPage.id, {
64
- refreshVersion: (currentPage.refreshVersion ?? 0) + 1
65
- })
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
+ }
66
77
  return
67
78
  }
68
79
 
69
80
  const oldCacheName = currentPage.id
70
81
  const nextPageId = createPageId()
71
- updatePage(tab.id, oldCacheName, { id: nextPageId, refreshVersion: 0 })
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
+ }
72
88
  if (cacheNamesToEvict && cacheNamesToAdd) {
73
89
  cacheNamesToEvict.push(oldCacheName)
74
90
  cacheNamesToAdd.push(nextPageId)
@@ -91,6 +107,7 @@ export const createTabPanelRefresh = (
91
107
  const refreshAllTabs = () => {
92
108
  const cacheNamesToEvict: string[] = []
93
109
  const cacheNamesToAdd: string[] = []
110
+ const pageUpdates: PageUpdate[] = []
94
111
  const iframeKeysUpdate = { ...iframeRefreshKeys.value }
95
112
  let iframeKeysChanged = false
96
113
 
@@ -105,10 +122,11 @@ export const createTabPanelRefresh = (
105
122
  }
106
123
  continue
107
124
  }
108
- refreshPage(tab, cacheNamesToEvict, cacheNamesToAdd)
125
+ refreshPage(tab, cacheNamesToEvict, cacheNamesToAdd, pageUpdates)
109
126
  }
110
127
 
111
128
  if (iframeKeysChanged) iframeRefreshKeys.value = iframeKeysUpdate
129
+ updatePages(pageUpdates)
112
130
  replacePageCaches(cacheNamesToEvict, cacheNamesToAdd)
113
131
  }
114
132
 
@@ -12,8 +12,6 @@ export interface TabPanelScrollApi {
12
12
  saveScroller: (pageCacheId: string) => void
13
13
  removeScroller: (pageCacheId: string) => void
14
14
  addPageScroller: (pageCacheId: string, ...selectorIds: string[]) => void
15
- /** 主动失效某页面的滚动元素缓存,例如外部强制刷新缓存页时 */
16
- invalidateAutoScrollCache: (pageCacheId: string) => void
17
15
  }
18
16
 
19
17
  const resolveScrollElement = (selector: string): HTMLElement | null =>
@@ -124,9 +122,9 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
124
122
  })
125
123
 
126
124
  for (const selector of positions.keys()) {
127
- const element = selector.startsWith(AUTO_SCROLLER_PREFIX)
128
- ? resolveAutoScrollElement(pageCacheId, selector)
129
- : resolveScrollElement(selector)
125
+ // auto key 已在上一步按 DOM 顺序写入,无需再次解析。
126
+ if (selector.startsWith(AUTO_SCROLLER_PREFIX)) continue
127
+ const element = resolveScrollElement(selector)
130
128
  positions.set(selector, {
131
129
  top: element?.scrollTop ?? 0,
132
130
  left: element?.scrollLeft ?? 0
@@ -158,19 +156,10 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
158
156
  }
159
157
  }
160
158
 
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
-
169
159
  return {
170
160
  restoreScroller,
171
161
  saveScroller,
172
162
  removeScroller,
173
- addPageScroller,
174
- invalidateAutoScrollCache
163
+ addPageScroller
175
164
  }
176
165
  }