vue-stack-tabs 2.2.0 → 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.
- package/dist/StackTabs.vue.d.ts +2 -2
- package/dist/components/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -2
- package/dist/hooks/stackTabsContext.d.ts +0 -1
- package/dist/hooks/tabPanel/evict.d.ts +2 -2
- package/dist/hooks/tabPanel/scroll.d.ts +0 -2
- package/dist/hooks/tabPanel/state.d.ts +0 -1
- package/dist/hooks/useIframeManager.d.ts +1 -1
- package/dist/hooks/useScrollShadow.d.ts +11 -2
- package/dist/hooks/useTabEventBus.d.ts +5 -0
- package/dist/model/TabModel.d.ts +12 -5
- package/dist/nuxt/module.mjs +1 -1
- package/dist/nuxt/runtime/plugin.mjs +8 -1598
- package/dist/style.css +1 -1
- package/dist/utils/scrollUtils.d.ts +0 -2
- package/dist/utils/tabInfoEncoder.d.ts +0 -2
- package/dist/utils/urlParser.d.ts +6 -7
- package/dist/vue-stack-tabs.es.js +1 -1
- package/package.json +1 -1
- package/src/lib/StackTabs.vue +16 -1
- package/src/lib/assets/style/stack-tab.scss +12 -6
- package/src/lib/components/PageLoading.vue +10 -9
- package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +1 -5
- package/src/lib/components/TabHeader/TabHeaderScroll.vue +164 -90
- package/src/lib/components/TabHeader/index.vue +51 -10
- package/src/lib/hooks/stackTabsContext.ts +0 -1
- package/src/lib/hooks/tabPanel/evict.ts +14 -6
- package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +211 -42
- package/src/lib/hooks/tabPanel/scroll.ts +4 -15
- package/src/lib/hooks/tabPanel/session.ts +11 -4
- package/src/lib/hooks/tabPanel/state.ts +0 -2
- package/src/lib/hooks/tabPanel/tabStore.ts +28 -10
- package/src/lib/hooks/useIframeManager.ts +11 -15
- package/src/lib/hooks/useScrollShadow.ts +74 -19
- package/src/lib/hooks/useTabActions.ts +10 -4
- package/src/lib/hooks/useTabEventBus.ts +3 -0
- package/src/lib/hooks/useTabPanel.tsx +46 -34
- package/src/lib/model/TabModel.ts +14 -5
- package/src/lib/nuxt/module.ts +3 -1
- package/src/lib/nuxt/runtime/plugin.ts +7 -3
- package/src/lib/utils/scrollUtils.ts +0 -18
- package/src/lib/utils/tabInfoEncoder.ts +21 -2
- package/src/lib/utils/urlParser.ts +40 -37
|
@@ -5,15 +5,17 @@
|
|
|
5
5
|
* 使 useTabPanel 只负责领域状态与路由协调。
|
|
6
6
|
*/
|
|
7
7
|
import {
|
|
8
|
-
Transition,
|
|
9
8
|
cloneVNode,
|
|
10
9
|
defineComponent,
|
|
10
|
+
nextTick,
|
|
11
11
|
onActivated,
|
|
12
|
+
onBeforeUnmount,
|
|
12
13
|
onDeactivated,
|
|
13
14
|
onMounted,
|
|
14
15
|
onUnmounted,
|
|
15
16
|
ref,
|
|
16
|
-
shallowRef
|
|
17
|
+
shallowRef,
|
|
18
|
+
watch
|
|
17
19
|
} from 'vue'
|
|
18
20
|
import type { DefineComponent, PropType, VNode } from 'vue'
|
|
19
21
|
import type { ITabBase } from '../../model/TabModel'
|
|
@@ -35,6 +37,36 @@ interface PageComponentFactoryOptions {
|
|
|
35
37
|
removeScroller: (pageId: string) => void
|
|
36
38
|
}
|
|
37
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
|
+
|
|
38
70
|
export const createPageComponentFactory = (options: PageComponentFactoryOptions) => {
|
|
39
71
|
const {
|
|
40
72
|
runtimeContext,
|
|
@@ -59,8 +91,9 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
59
91
|
}
|
|
60
92
|
|
|
61
93
|
/**
|
|
62
|
-
*
|
|
94
|
+
* 页面壳组件:承接刷新时的 key 变化,让页面重建发生在缓存 wrapper 内部,
|
|
63
95
|
* 避免直接修改 router-view 页面 vnode 的 key 破坏 keep-alive 的实例复用。
|
|
96
|
+
* 不额外渲染元素,页面可以是单根,也可以是多根节点(Fragment)。
|
|
64
97
|
*/
|
|
65
98
|
const PageShell = defineComponent({
|
|
66
99
|
name: `${cacheName}-shell`,
|
|
@@ -110,24 +143,156 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
110
143
|
const lastVnode = shallowRef<VNode | null | undefined>(null)
|
|
111
144
|
const lastCloned = shallowRef<VNode | null>(null)
|
|
112
145
|
const lastBackParams = shallowRef<Record<string, unknown> | null>(null)
|
|
113
|
-
const lastRefreshVersion = shallowRef<number>(0)
|
|
114
146
|
const pageRoot = ref<HTMLElement | null>(null)
|
|
115
147
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
148
|
+
* 实际渲染的刷新版本。
|
|
149
|
+
*
|
|
150
|
+
* 刷新时先把 wrapper(真实元素,兼容多根节点页面)作为动画主体执行离场,
|
|
151
|
+
* 离场结束再提升该值重建页面,最后执行入场;因此它会短暂落后于
|
|
152
|
+
* props.refreshVersion。外层 keep-alive key 保持不变,避免实例累积。
|
|
119
153
|
*/
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
125
167
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 = ''
|
|
129
180
|
}
|
|
130
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
|
+
)
|
|
295
|
+
|
|
131
296
|
const checkAndConsumeBackParams = () => {
|
|
132
297
|
localBackParams.value = consumeBackParams(tabInfo.id!, cacheName)
|
|
133
298
|
}
|
|
@@ -135,67 +300,71 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
|
|
|
135
300
|
onMounted(() => {
|
|
136
301
|
context.emit('onLoaded')
|
|
137
302
|
addPageScroller(cacheName, `#W-${cacheName}`)
|
|
303
|
+
// 新页面经 Transition(out-in) 挂载时,setup 后的 microtask 可能早于 ref 赋值;
|
|
304
|
+
// 挂载后重新 sync,确保 scroll shadow 监听与阴影类目标正确绑定。
|
|
305
|
+
scrollShadow.sync()
|
|
138
306
|
checkAndConsumeBackParams()
|
|
139
307
|
})
|
|
140
|
-
onDeactivated(() =>
|
|
308
|
+
onDeactivated(() => {
|
|
309
|
+
scrollShadow.clear()
|
|
310
|
+
saveScroller(cacheName)
|
|
311
|
+
})
|
|
141
312
|
onActivated(() => {
|
|
142
313
|
context.emit('onLoaded')
|
|
143
314
|
restoreScroller(cacheName)
|
|
315
|
+
scrollShadow.sync()
|
|
144
316
|
evictMarkedCaches()
|
|
145
317
|
clearTabEvictionMarks()
|
|
146
318
|
checkAndConsumeBackParams()
|
|
147
319
|
})
|
|
320
|
+
onBeforeUnmount(() => {
|
|
321
|
+
refreshDisposed = true
|
|
322
|
+
queuedRefreshVersion = null
|
|
323
|
+
cancelRefreshWait?.()
|
|
324
|
+
clearRefreshTimer()
|
|
325
|
+
removeRefreshClasses()
|
|
326
|
+
})
|
|
148
327
|
onUnmounted(() => removeScroller(cacheName))
|
|
149
328
|
|
|
150
|
-
//
|
|
151
|
-
|
|
329
|
+
// 阴影类挂到不被刷新动画 transform 的 panel 上;滚动监听仍绑定 wrapper。
|
|
330
|
+
// 否则 ::after 的绝对定位包含块会随 transform 变化,顶部阴影会错位/消失。
|
|
331
|
+
const scrollShadow = useScrollShadow({
|
|
332
|
+
target: pageRoot,
|
|
333
|
+
classTarget: () => pageRoot.value?.parentElement ?? null
|
|
334
|
+
})
|
|
152
335
|
|
|
153
336
|
return () => {
|
|
154
337
|
const vnode = props.vnode
|
|
155
338
|
const backParams = localBackParams.value
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
vnode !== lastVnode.value ||
|
|
159
|
-
backParams !== lastBackParams.value ||
|
|
160
|
-
refreshVersion !== lastRefreshVersion.value
|
|
161
|
-
) {
|
|
162
|
-
const cloned = vnode
|
|
339
|
+
if (vnode !== lastVnode.value || backParams !== lastBackParams.value) {
|
|
340
|
+
lastCloned.value = vnode
|
|
163
341
|
? cloneVNode(vnode, {
|
|
164
342
|
tId: tabInfo.id,
|
|
165
343
|
pId: cacheName,
|
|
166
344
|
...(backParams ? { _back: backParams } : {})
|
|
167
345
|
})
|
|
168
346
|
: null
|
|
169
|
-
lastCloned.value = cloned
|
|
170
347
|
lastVnode.value = vnode
|
|
171
348
|
lastBackParams.value = backParams
|
|
172
|
-
lastRefreshVersion.value = refreshVersion
|
|
173
349
|
}
|
|
350
|
+
|
|
174
351
|
return (
|
|
175
352
|
<div
|
|
176
353
|
ref={pageRoot}
|
|
177
354
|
class="stack-tab__cache-page-wrapper"
|
|
178
355
|
id={`W-${cacheName}`}
|
|
179
356
|
style={[
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
357
|
+
// 动画作用于 wrapper 本身,页面内容不会相对滚动容器位移,
|
|
358
|
+
// 因此无需临时隐藏 overflow,避免动画期间滚动条消失导致内容宽度跳动。
|
|
359
|
+
runtimeContext.useGlobalScroll.value ? 'overflow:auto' : 'overflow:hidden',
|
|
183
360
|
'height: 100%'
|
|
184
361
|
]}
|
|
185
362
|
>
|
|
186
363
|
{lastCloned.value ? (
|
|
187
|
-
<
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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>
|
|
364
|
+
<PageShell
|
|
365
|
+
key={`${cacheName}-${renderedRefreshVersion.value}`}
|
|
366
|
+
vnode={lastCloned.value}
|
|
367
|
+
/>
|
|
199
368
|
) : null}
|
|
200
369
|
<PageLoading tabId={tabInfo.id!} />
|
|
201
370
|
</div>
|
|
@@ -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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
}
|
|
@@ -32,19 +32,23 @@ export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPane
|
|
|
32
32
|
let pendingWrite: { tab: ITabItem; version: number } | null = null
|
|
33
33
|
let writeVersion = 0
|
|
34
34
|
let writeScheduled = false
|
|
35
|
+
/** 最近一次成功写入的内容;与待写入内容相同时跳过 setItem。 */
|
|
36
|
+
let lastPersistedJson: string | null = null
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
39
|
* 写入 sessionStorage,捕获隐私模式(QuotaExceededError)等异常,
|
|
38
40
|
* 避免在受限浏览器环境下抛出未处理错误。SSR 环境无 window 时直接跳过。
|
|
39
41
|
*/
|
|
40
|
-
const safeSetItem = (key: string, value: string):
|
|
41
|
-
if (typeof window === 'undefined') return
|
|
42
|
+
const safeSetItem = (key: string, value: string): boolean => {
|
|
43
|
+
if (typeof window === 'undefined') return false
|
|
42
44
|
try {
|
|
43
45
|
window.sessionStorage.setItem(key, value)
|
|
46
|
+
return true
|
|
44
47
|
} catch (error) {
|
|
45
48
|
if (!import.meta.env.PROD) {
|
|
46
49
|
console.warn('[vue-stack-tabs] Failed to write sessionStorage:', error)
|
|
47
50
|
}
|
|
51
|
+
return false
|
|
48
52
|
}
|
|
49
53
|
}
|
|
50
54
|
|
|
@@ -64,7 +68,9 @@ export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPane
|
|
|
64
68
|
const write = pendingWrite
|
|
65
69
|
pendingWrite = null
|
|
66
70
|
if (!write || write.version !== writeVersion) return
|
|
67
|
-
|
|
71
|
+
const serialized = JSON.stringify(write.tab)
|
|
72
|
+
if (serialized === lastPersistedJson) return
|
|
73
|
+
if (safeSetItem(getSessionKey(), serialized)) lastPersistedJson = serialized
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
const invalidatePendingWrite = () => {
|
|
@@ -83,6 +89,7 @@ export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPane
|
|
|
83
89
|
const clearSession = (): void => {
|
|
84
90
|
invalidatePendingWrite()
|
|
85
91
|
safeRemoveItem(getSessionKey())
|
|
92
|
+
lastPersistedJson = null
|
|
86
93
|
}
|
|
87
94
|
|
|
88
95
|
const restoreActiveTabSession = (storedJson: string | null): void => {
|
|
@@ -91,7 +98,7 @@ export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPane
|
|
|
91
98
|
return
|
|
92
99
|
}
|
|
93
100
|
invalidatePendingWrite()
|
|
94
|
-
safeSetItem(getSessionKey(), storedJson)
|
|
101
|
+
if (safeSetItem(getSessionKey(), storedJson)) lastPersistedJson = storedJson
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
const restoreTabFromSession = (storedJson: string | null): ITabItem | null => {
|
|
@@ -15,7 +15,6 @@ export interface ScrollPosition {
|
|
|
15
15
|
|
|
16
16
|
export interface TabPanelRuntimeState {
|
|
17
17
|
tabs: Ref<ITabItem[]>
|
|
18
|
-
defaultTabs: Ref<ITabItem[]>
|
|
19
18
|
caches: ShallowRef<string[]>
|
|
20
19
|
components: Map<string, DefineComponent>
|
|
21
20
|
cacheIdsToEvict: Set<string>
|
|
@@ -34,7 +33,6 @@ export const SESSION_TAB_NAME = 'stacktab-active-tab'
|
|
|
34
33
|
|
|
35
34
|
export const createTabPanelRuntimeState = (): TabPanelRuntimeState => ({
|
|
36
35
|
tabs: ref<ITabItem[]>([]) as Ref<ITabItem[]>,
|
|
37
|
-
defaultTabs: ref<ITabItem[]>([]) as Ref<ITabItem[]>,
|
|
38
36
|
caches: shallowRef<string[]>([]),
|
|
39
37
|
components: new Map<string, DefineComponent>(),
|
|
40
38
|
cacheIdsToEvict: new Set<string>(),
|
|
@@ -121,6 +121,9 @@ interface TabRecord {
|
|
|
121
121
|
|
|
122
122
|
interface PageStackAccess {
|
|
123
123
|
getPages: (tabId: string) => readonly ITabPage[]
|
|
124
|
+
getPageIds: (tabId: string) => readonly string[]
|
|
125
|
+
getPageAt: (tabId: string, index: number) => ITabPage | undefined
|
|
126
|
+
getPageCount: (tabId: string) => number
|
|
124
127
|
getTopPage: (tabId: string) => ITabPage | null
|
|
125
128
|
/** 读取任意页面的刷新版本;渲染层据此只重建对应页面 */
|
|
126
129
|
getPageRefreshVersion: (pageId: string) => number
|
|
@@ -156,15 +159,16 @@ class PageStackView extends Stack<ITabPage> {
|
|
|
156
159
|
}
|
|
157
160
|
|
|
158
161
|
override at(index: number): ITabPage | undefined {
|
|
159
|
-
|
|
162
|
+
// 只取单个元素时不必构造整条页面数组。
|
|
163
|
+
return this.access.getPageAt(this.tabId, index)
|
|
160
164
|
}
|
|
161
165
|
|
|
162
166
|
override isEmpty(): boolean {
|
|
163
|
-
return this.access.
|
|
167
|
+
return this.access.getPageCount(this.tabId) === 0
|
|
164
168
|
}
|
|
165
169
|
|
|
166
170
|
override size(): number {
|
|
167
|
-
return this.access.
|
|
171
|
+
return this.access.getPageCount(this.tabId)
|
|
168
172
|
}
|
|
169
173
|
|
|
170
174
|
override clear(): void {
|
|
@@ -251,6 +255,15 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
|
251
255
|
return pagesById.get(ids[ids.length - 1] as string) ?? null
|
|
252
256
|
}
|
|
253
257
|
|
|
258
|
+
const getPageCount = (tabId: string): number => pageIdsByTabId.get(tabId)?.length ?? 0
|
|
259
|
+
|
|
260
|
+
/** 按下标读取页面;越界(含负数)返回 undefined,与数组下标语义一致 */
|
|
261
|
+
const getPageAt = (tabId: string, index: number): ITabPage | undefined => {
|
|
262
|
+
const ids = pageIdsByTabId.get(tabId)
|
|
263
|
+
if (!ids || index < 0 || index >= ids.length) return undefined
|
|
264
|
+
return pagesById.get(ids[index] as string)
|
|
265
|
+
}
|
|
266
|
+
|
|
254
267
|
const getPageRefreshVersion = (pageId: string): number =>
|
|
255
268
|
pagesById.get(pageId)?.refreshVersion ?? 0
|
|
256
269
|
|
|
@@ -310,8 +323,12 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
|
310
323
|
}
|
|
311
324
|
|
|
312
325
|
const activateTab = (id: string): ITabItem | null => {
|
|
313
|
-
|
|
314
|
-
|
|
326
|
+
const nextId = tabsById.has(id) ? id : ''
|
|
327
|
+
// 重复激活同一标签(页面内导航经常走到这里)无需重建 tabs 数组。
|
|
328
|
+
if (nextId !== activeTabId.value) {
|
|
329
|
+
activeTabId.value = nextId
|
|
330
|
+
syncTabsView()
|
|
331
|
+
}
|
|
315
332
|
return getTab(id)
|
|
316
333
|
}
|
|
317
334
|
|
|
@@ -416,12 +433,13 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
|
416
433
|
}
|
|
417
434
|
|
|
418
435
|
// ---- 页面栈写入 ----
|
|
436
|
+
// 页面栈只修改 pageIdsByTabId / pagesById(两者都是响应式 Map),
|
|
437
|
+
// 不重建 tabs 数组:标签栏、iframe 与刷新版本计算都无需因入栈/出栈重新求值。
|
|
419
438
|
const appendPage = (tabId: string, page: ITabPage): void => {
|
|
420
439
|
if (!tabsById.has(tabId)) return
|
|
421
440
|
const ids = pageIdsByTabId.get(tabId) ?? []
|
|
422
441
|
pageIdsByTabId.set(tabId, [...ids, page.id])
|
|
423
442
|
pagesById.set(page.id, page)
|
|
424
|
-
syncTabsView()
|
|
425
443
|
}
|
|
426
444
|
|
|
427
445
|
const popPages = (tabId: string, steps: number): ITabPage[] => {
|
|
@@ -437,7 +455,6 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
|
437
455
|
pagesById.delete(pageId)
|
|
438
456
|
}
|
|
439
457
|
pageIdsByTabId.set(tabId, ids)
|
|
440
|
-
syncTabsView()
|
|
441
458
|
return popped
|
|
442
459
|
}
|
|
443
460
|
|
|
@@ -452,14 +469,12 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
|
452
469
|
nextPageIds.push(cloned.id)
|
|
453
470
|
}
|
|
454
471
|
pageIdsByTabId.set(tabId, nextPageIds)
|
|
455
|
-
syncTabsView()
|
|
456
472
|
}
|
|
457
473
|
|
|
458
474
|
const clearPages = (tabId: string): void => {
|
|
459
475
|
if (!tabsById.has(tabId)) return
|
|
460
476
|
for (const pageId of getPageIds(tabId)) pagesById.delete(pageId)
|
|
461
477
|
pageIdsByTabId.set(tabId, [])
|
|
462
|
-
syncTabsView()
|
|
463
478
|
}
|
|
464
479
|
|
|
465
480
|
const applyPagePatch = (
|
|
@@ -512,8 +527,8 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
|
512
527
|
const top = getTopPage(tabId)
|
|
513
528
|
if (!top || top.id !== pageId || !top._backParams) return null
|
|
514
529
|
const backParams = { ...top._backParams }
|
|
530
|
+
// _backParams 是页面对象上的响应式字段,删除后读取方会自行更新。
|
|
515
531
|
delete top._backParams
|
|
516
|
-
syncTabsView()
|
|
517
532
|
return backParams
|
|
518
533
|
}
|
|
519
534
|
|
|
@@ -548,6 +563,9 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
|
548
563
|
// ---- 兼容视图 ----
|
|
549
564
|
const pageAccess: PageStackAccess = {
|
|
550
565
|
getPages,
|
|
566
|
+
getPageIds,
|
|
567
|
+
getPageAt,
|
|
568
|
+
getPageCount,
|
|
551
569
|
getTopPage,
|
|
552
570
|
getPageRefreshVersion,
|
|
553
571
|
appendPage,
|
|
@@ -19,7 +19,7 @@ interface IframeLoadState {
|
|
|
19
19
|
export interface UseIframeManagerOptions {
|
|
20
20
|
tabs: Ref<ITabItem[]>
|
|
21
21
|
iframeRefreshKeys: Ref<Record<string, number>>
|
|
22
|
-
iframeLoadTimeout: number
|
|
22
|
+
iframeLoadTimeout: Ref<number>
|
|
23
23
|
t: (key: string) => string
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -106,7 +106,7 @@ export function useIframeManager(options: UseIframeManagerOptions) {
|
|
|
106
106
|
message: t('VueStackTab.iframeLoadTimeout')
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
|
-
}, iframeLoadTimeout)
|
|
109
|
+
}, iframeLoadTimeout.value)
|
|
110
110
|
)
|
|
111
111
|
}
|
|
112
112
|
|
|
@@ -176,20 +176,16 @@ export function useIframeManager(options: UseIframeManagerOptions) {
|
|
|
176
176
|
)
|
|
177
177
|
|
|
178
178
|
// refreshKey 变化时重新触发对应 iframe 的加载状态
|
|
179
|
-
watch(
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (nextKey !== previousKey) {
|
|
187
|
-
setIframeLoading(frame.id, { force: true })
|
|
188
|
-
}
|
|
179
|
+
watch(iframeRefreshKeys, (keys, previousKeys = {}) => {
|
|
180
|
+
for (const frame of iframeTabs.value) {
|
|
181
|
+
if (!frame.active) continue
|
|
182
|
+
const nextKey = keys[frame.id] ?? 0
|
|
183
|
+
const previousKey = previousKeys[frame.id] ?? 0
|
|
184
|
+
if (nextKey !== previousKey) {
|
|
185
|
+
setIframeLoading(frame.id, { force: true })
|
|
189
186
|
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
)
|
|
187
|
+
}
|
|
188
|
+
})
|
|
193
189
|
|
|
194
190
|
const handleIframeLoad = (frame: { id: string; url?: string }, event: Event) => {
|
|
195
191
|
const iframe = event.currentTarget as HTMLIFrameElement | null
|