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.
- package/README.en.md +1 -0
- package/README.md +1 -0
- package/README.ru.md +1 -0
- package/dist/StackTabs.vue.d.ts +5 -2
- package/dist/components/ContextMenu/ContextMenuItem.vue.d.ts +1 -1
- 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/refresh.d.ts +4 -2
- package/dist/hooks/tabPanel/scroll.d.ts +0 -2
- package/dist/hooks/tabPanel/state.d.ts +0 -1
- package/dist/hooks/tabPanel/tabStore.d.ts +12 -1
- package/dist/hooks/useIframeManager.d.ts +1 -1
- package/dist/hooks/useScrollShadow.d.ts +11 -2
- package/dist/hooks/useTabActions.d.ts +1 -0
- package/dist/hooks/useTabEventBus.d.ts +5 -0
- package/dist/hooks/useTabPanel.d.ts +14 -2
- package/dist/model/TabModel.d.ts +17 -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 +8 -0
- package/dist/utils/urlParser.d.ts +20 -10
- package/dist/vue-stack-tabs.es.js +1 -1
- package/package.json +4 -7
- package/src/lib/StackTabs.vue +40 -3
- 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} +116 -182
- 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 +12 -11
- package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +1 -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 +170 -94
- package/src/lib/components/TabHeader/index.vue +67 -12
- package/src/lib/hooks/stackTabsContext.ts +0 -1
- package/src/lib/hooks/tabPanel/evict.ts +14 -6
- package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +252 -7
- package/src/lib/hooks/tabPanel/refresh.ts +27 -9
- 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 +114 -26
- package/src/lib/hooks/useContextMenu.ts +1 -1
- package/src/lib/hooks/useIframeManager.ts +21 -18
- package/src/lib/hooks/useScrollShadow.ts +74 -19
- package/src/lib/hooks/useTabActions.ts +18 -8
- package/src/lib/hooks/useTabEventBus.ts +3 -0
- package/src/lib/hooks/useTabPanel.tsx +247 -58
- package/src/lib/hooks/useTabRouter.ts +18 -11
- package/src/lib/model/TabModel.ts +19 -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 +121 -18
- package/src/lib/utils/urlParser.ts +75 -48
- package/dist/vue-stack-tabs.css +0 -2
- package/src/lib/assets/style/overlayer.scss +0 -46
|
@@ -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>(),
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* 注意:本模块不依赖 vue-router,也不触发业务事件;路由事务与事件由 useTabPanel /
|
|
13
13
|
* useTabRouter 等上层编排。
|
|
14
14
|
*/
|
|
15
|
-
import { reactive, ref } from 'vue'
|
|
15
|
+
import { markRaw, reactive, ref } from 'vue'
|
|
16
16
|
import type { DefineComponent } from 'vue'
|
|
17
17
|
import type { ITabItem, ITabPage } from '../../model/TabModel'
|
|
18
18
|
import { Stack } from '../../model/TabModel'
|
|
@@ -27,6 +27,12 @@ export interface RemoveTabResult {
|
|
|
27
27
|
nextActiveId: string
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
export interface PageUpdate {
|
|
31
|
+
tabId: string
|
|
32
|
+
pageId: string
|
|
33
|
+
patch: PagePatch
|
|
34
|
+
}
|
|
35
|
+
|
|
30
36
|
/** updatePage 允许修改的字段;页面身份与刷新版本必须通过 store 写入 */
|
|
31
37
|
export interface PagePatch {
|
|
32
38
|
id?: string
|
|
@@ -46,6 +52,8 @@ export interface TabStoreApi extends TabPanelEvictionApi {
|
|
|
46
52
|
canAddTab: () => boolean
|
|
47
53
|
getPages: (tabId: string) => readonly ITabPage[]
|
|
48
54
|
getTopPage: (tabId: string) => ITabPage | null
|
|
55
|
+
/** 读取任意页面的刷新版本;渲染层据此只重建对应页面 */
|
|
56
|
+
getPageRefreshVersion: (pageId: string) => number
|
|
49
57
|
/** 返回深拷贝的页面栈快照,可直接用于 rollback */
|
|
50
58
|
getPageSnapshot: (tabId: string) => ITabPage[]
|
|
51
59
|
|
|
@@ -54,6 +62,8 @@ export interface TabStoreApi extends TabPanelEvictionApi {
|
|
|
54
62
|
addTabs: (tabs: readonly ITabItem[]) => void
|
|
55
63
|
/** 唯一 active 写入口 */
|
|
56
64
|
activateTab: (id: string) => ITabItem | null
|
|
65
|
+
/** 动态更新标签标题;返回 false 表示标签不存在 */
|
|
66
|
+
updateTabTitle: (id: string, title: string) => boolean
|
|
57
67
|
moveTab: (id: string, targetIndex: number) => boolean
|
|
58
68
|
removeTab: (id: string) => RemoveTabResult
|
|
59
69
|
removeTabs: (shouldRemove: (tab: ITabItem, index: number) => boolean) => ITabItem[]
|
|
@@ -65,8 +75,10 @@ export interface TabStoreApi extends TabPanelEvictionApi {
|
|
|
65
75
|
/** 用给定页面替换整个栈(内部深拷贝页面) */
|
|
66
76
|
replacePages: (tabId: string, pages: readonly ITabPage[]) => void
|
|
67
77
|
clearPages: (tabId: string) => void
|
|
68
|
-
/** 修改页面字段(id / refreshVersion / _backParams
|
|
78
|
+
/** 修改页面字段(id / refreshVersion / _backParams);仅 id 变化时重建 tabs 数组 */
|
|
69
79
|
updatePage: (tabId: string, pageId: string, patch: PagePatch) => boolean
|
|
80
|
+
/** 批量修改页面字段,最后最多重建一次 tabs 数组(供 refreshAllTabs 使用) */
|
|
81
|
+
updatePages: (updates: readonly PageUpdate[]) => void
|
|
70
82
|
/** 取出并清除栈顶页面的 _backParams;pageId 不是当前栈顶时返回 null */
|
|
71
83
|
consumeBackParams: (tabId: string, pageId: string) => Record<string, unknown> | null
|
|
72
84
|
|
|
@@ -103,11 +115,18 @@ interface TabRecord {
|
|
|
103
115
|
iframe: boolean
|
|
104
116
|
iframeRefreshMode?: 'postMessage' | 'reload'
|
|
105
117
|
url?: string
|
|
118
|
+
/** 固定在标签栏最前方,不可拖动,其他标签也不能排到它前面 */
|
|
119
|
+
pinned?: boolean
|
|
106
120
|
}
|
|
107
121
|
|
|
108
122
|
interface PageStackAccess {
|
|
109
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
|
|
110
127
|
getTopPage: (tabId: string) => ITabPage | null
|
|
128
|
+
/** 读取任意页面的刷新版本;渲染层据此只重建对应页面 */
|
|
129
|
+
getPageRefreshVersion: (pageId: string) => number
|
|
111
130
|
appendPage: (tabId: string, page: ITabPage) => void
|
|
112
131
|
popPages: (tabId: string, steps: number) => ITabPage[]
|
|
113
132
|
clearPages: (tabId: string) => void
|
|
@@ -140,15 +159,16 @@ class PageStackView extends Stack<ITabPage> {
|
|
|
140
159
|
}
|
|
141
160
|
|
|
142
161
|
override at(index: number): ITabPage | undefined {
|
|
143
|
-
|
|
162
|
+
// 只取单个元素时不必构造整条页面数组。
|
|
163
|
+
return this.access.getPageAt(this.tabId, index)
|
|
144
164
|
}
|
|
145
165
|
|
|
146
166
|
override isEmpty(): boolean {
|
|
147
|
-
return this.access.
|
|
167
|
+
return this.access.getPageCount(this.tabId) === 0
|
|
148
168
|
}
|
|
149
169
|
|
|
150
170
|
override size(): number {
|
|
151
|
-
return this.access.
|
|
171
|
+
return this.access.getPageCount(this.tabId)
|
|
152
172
|
}
|
|
153
173
|
|
|
154
174
|
override clear(): void {
|
|
@@ -211,6 +231,7 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
|
211
231
|
// ---- 查询 ----
|
|
212
232
|
const getTabs = (): readonly ITabItem[] => tabs.value
|
|
213
233
|
|
|
234
|
+
// 返回 tabs.value 中的对象(深度代理后的视图),保持调用方的对象身份比较语义。
|
|
214
235
|
const getTab = (id: string): ITabItem | null => tabs.value.find((tab) => tab.id === id) ?? null
|
|
215
236
|
|
|
216
237
|
const getActiveTab = (): ITabItem | null => tabs.value.find((tab) => tab.active) ?? null
|
|
@@ -234,9 +255,22 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
|
234
255
|
return pagesById.get(ids[ids.length - 1] as string) ?? null
|
|
235
256
|
}
|
|
236
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
|
+
|
|
267
|
+
const getPageRefreshVersion = (pageId: string): number =>
|
|
268
|
+
pagesById.get(pageId)?.refreshVersion ?? 0
|
|
269
|
+
|
|
237
270
|
const findTabByTopPagePath = (path: string): ITabItem | null => {
|
|
271
|
+
// 直接遍历 tabViews,避免原先「getTopPage + getTab(find)」的 O(n²) 组合。
|
|
238
272
|
for (const id of tabOrder.value) {
|
|
239
|
-
if (getTopPage(id)?.path === path) return
|
|
273
|
+
if (getTopPage(id)?.path === path) return tabViews.get(id) ?? null
|
|
240
274
|
}
|
|
241
275
|
return null
|
|
242
276
|
}
|
|
@@ -258,7 +292,8 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
|
258
292
|
refreshable: tab.refreshable,
|
|
259
293
|
iframe: tab.iframe,
|
|
260
294
|
iframeRefreshMode: tab.iframeRefreshMode,
|
|
261
|
-
url: tab.url
|
|
295
|
+
url: tab.url,
|
|
296
|
+
pinned: tab.pinned
|
|
262
297
|
})
|
|
263
298
|
|
|
264
299
|
const registerTab = (tab: ITabItem): void => {
|
|
@@ -288,27 +323,46 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
|
288
323
|
}
|
|
289
324
|
|
|
290
325
|
const activateTab = (id: string): ITabItem | null => {
|
|
291
|
-
|
|
292
|
-
|
|
326
|
+
const nextId = tabsById.has(id) ? id : ''
|
|
327
|
+
// 重复激活同一标签(页面内导航经常走到这里)无需重建 tabs 数组。
|
|
328
|
+
if (nextId !== activeTabId.value) {
|
|
329
|
+
activeTabId.value = nextId
|
|
330
|
+
syncTabsView()
|
|
331
|
+
}
|
|
293
332
|
return getTab(id)
|
|
294
333
|
}
|
|
295
334
|
|
|
335
|
+
const updateTabTitle = (id: string, title: string): boolean => {
|
|
336
|
+
const record = tabsById.get(id)
|
|
337
|
+
if (!record) return false
|
|
338
|
+
if (record.title === title) return true
|
|
339
|
+
record.title = title
|
|
340
|
+
syncTabsView()
|
|
341
|
+
return true
|
|
342
|
+
}
|
|
343
|
+
|
|
296
344
|
const moveTab = (id: string, targetIndex: number): boolean => {
|
|
297
345
|
const order = tabOrder.value
|
|
298
346
|
const sourceIndex = order.indexOf(id)
|
|
299
|
-
if (
|
|
300
|
-
sourceIndex < 0 ||
|
|
301
|
-
targetIndex < 0 ||
|
|
302
|
-
targetIndex >= order.length ||
|
|
303
|
-
sourceIndex === targetIndex
|
|
304
|
-
) {
|
|
347
|
+
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) {
|
|
305
348
|
return false
|
|
306
349
|
}
|
|
307
350
|
|
|
351
|
+
// pinned 标签(home)固定在最前方:自身不可移动,其他标签也不得插到它前面。
|
|
352
|
+
const sourceRecord = tabsById.get(id)
|
|
353
|
+
if (!sourceRecord || sourceRecord.pinned) return false
|
|
354
|
+
|
|
355
|
+
let firstUnpinnedIndex = 0
|
|
356
|
+
for (let index = 0; index < order.length; index++) {
|
|
357
|
+
if (tabsById.get(order[index] as string)?.pinned) firstUnpinnedIndex = index + 1
|
|
358
|
+
}
|
|
359
|
+
const safeTargetIndex = Math.max(targetIndex, firstUnpinnedIndex)
|
|
360
|
+
if (safeTargetIndex >= order.length || safeTargetIndex === sourceIndex) return false
|
|
361
|
+
|
|
308
362
|
const reordered = [...order]
|
|
309
363
|
const [tabId] = reordered.splice(sourceIndex, 1)
|
|
310
364
|
if (!tabId) return false
|
|
311
|
-
reordered.splice(
|
|
365
|
+
reordered.splice(safeTargetIndex, 0, tabId)
|
|
312
366
|
tabOrder.value = reordered
|
|
313
367
|
syncTabsView()
|
|
314
368
|
return true
|
|
@@ -379,12 +433,13 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
|
379
433
|
}
|
|
380
434
|
|
|
381
435
|
// ---- 页面栈写入 ----
|
|
436
|
+
// 页面栈只修改 pageIdsByTabId / pagesById(两者都是响应式 Map),
|
|
437
|
+
// 不重建 tabs 数组:标签栏、iframe 与刷新版本计算都无需因入栈/出栈重新求值。
|
|
382
438
|
const appendPage = (tabId: string, page: ITabPage): void => {
|
|
383
439
|
if (!tabsById.has(tabId)) return
|
|
384
440
|
const ids = pageIdsByTabId.get(tabId) ?? []
|
|
385
441
|
pageIdsByTabId.set(tabId, [...ids, page.id])
|
|
386
442
|
pagesById.set(page.id, page)
|
|
387
|
-
syncTabsView()
|
|
388
443
|
}
|
|
389
444
|
|
|
390
445
|
const popPages = (tabId: string, steps: number): ITabPage[] => {
|
|
@@ -400,7 +455,6 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
|
400
455
|
pagesById.delete(pageId)
|
|
401
456
|
}
|
|
402
457
|
pageIdsByTabId.set(tabId, ids)
|
|
403
|
-
syncTabsView()
|
|
404
458
|
return popped
|
|
405
459
|
}
|
|
406
460
|
|
|
@@ -415,22 +469,25 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
|
415
469
|
nextPageIds.push(cloned.id)
|
|
416
470
|
}
|
|
417
471
|
pageIdsByTabId.set(tabId, nextPageIds)
|
|
418
|
-
syncTabsView()
|
|
419
472
|
}
|
|
420
473
|
|
|
421
474
|
const clearPages = (tabId: string): void => {
|
|
422
475
|
if (!tabsById.has(tabId)) return
|
|
423
476
|
for (const pageId of getPageIds(tabId)) pagesById.delete(pageId)
|
|
424
477
|
pageIdsByTabId.set(tabId, [])
|
|
425
|
-
syncTabsView()
|
|
426
478
|
}
|
|
427
479
|
|
|
428
|
-
const
|
|
480
|
+
const applyPagePatch = (
|
|
481
|
+
tabId: string,
|
|
482
|
+
pageId: string,
|
|
483
|
+
patch: PagePatch
|
|
484
|
+
): 'missing' | 'conflict' | 'same-id' | 'new-id' => {
|
|
429
485
|
const ids = pageIdsByTabId.get(tabId)
|
|
430
|
-
if (!ids || !ids.includes(pageId)) return
|
|
486
|
+
if (!ids || !ids.includes(pageId)) return 'missing'
|
|
431
487
|
|
|
432
488
|
const page = pagesById.get(pageId)
|
|
433
|
-
if (!page) return
|
|
489
|
+
if (!page) return 'missing'
|
|
490
|
+
if (patch.id && patch.id !== pageId && pagesById.has(patch.id)) return 'conflict'
|
|
434
491
|
|
|
435
492
|
Object.assign(page, patch)
|
|
436
493
|
if (patch.id && patch.id !== pageId) {
|
|
@@ -440,17 +497,38 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
|
440
497
|
tabId,
|
|
441
498
|
ids.map((id) => (id === pageId ? (patch.id as string) : id))
|
|
442
499
|
)
|
|
500
|
+
return 'new-id'
|
|
443
501
|
}
|
|
444
|
-
|
|
502
|
+
return 'same-id'
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* 刷新版本、_backParams 都是 page 上的响应式字段,读取方会自行更新;
|
|
507
|
+
* 只有页面 id 变化才需要重建 tabs 视图,避免刷新一个标签就 diff 整个标签栏。
|
|
508
|
+
*/
|
|
509
|
+
const updatePage = (tabId: string, pageId: string, patch: PagePatch): boolean => {
|
|
510
|
+
const result = applyPagePatch(tabId, pageId, patch)
|
|
511
|
+
if (result === 'missing' || result === 'conflict') return false
|
|
512
|
+
if (result === 'new-id') syncTabsView()
|
|
445
513
|
return true
|
|
446
514
|
}
|
|
447
515
|
|
|
516
|
+
const updatePages = (updates: readonly PageUpdate[]): void => {
|
|
517
|
+
let needsSync = false
|
|
518
|
+
for (const update of updates) {
|
|
519
|
+
if (applyPagePatch(update.tabId, update.pageId, update.patch) === 'new-id') {
|
|
520
|
+
needsSync = true
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (needsSync) syncTabsView()
|
|
524
|
+
}
|
|
525
|
+
|
|
448
526
|
const consumeBackParams = (tabId: string, pageId: string): Record<string, unknown> | null => {
|
|
449
527
|
const top = getTopPage(tabId)
|
|
450
528
|
if (!top || top.id !== pageId || !top._backParams) return null
|
|
451
529
|
const backParams = { ...top._backParams }
|
|
530
|
+
// _backParams 是页面对象上的响应式字段,删除后读取方会自行更新。
|
|
452
531
|
delete top._backParams
|
|
453
|
-
syncTabsView()
|
|
454
532
|
return backParams
|
|
455
533
|
}
|
|
456
534
|
|
|
@@ -485,7 +563,11 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
|
485
563
|
// ---- 兼容视图 ----
|
|
486
564
|
const pageAccess: PageStackAccess = {
|
|
487
565
|
getPages,
|
|
566
|
+
getPageIds,
|
|
567
|
+
getPageAt,
|
|
568
|
+
getPageCount,
|
|
488
569
|
getTopPage,
|
|
570
|
+
getPageRefreshVersion,
|
|
489
571
|
appendPage,
|
|
490
572
|
popPages,
|
|
491
573
|
clearPages
|
|
@@ -506,7 +588,9 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
|
506
588
|
iframeRefreshMode: record.iframeRefreshMode,
|
|
507
589
|
url: record.url,
|
|
508
590
|
active: activeTabId.value === id,
|
|
509
|
-
pages
|
|
591
|
+
// pages 的读写都转发给 store,本身不需要被 ref 深度代理。
|
|
592
|
+
pages: markRaw(new PageStackView(id, pageAccess)),
|
|
593
|
+
pinned: record.pinned
|
|
510
594
|
}
|
|
511
595
|
tabViews.set(id, view)
|
|
512
596
|
} else {
|
|
@@ -515,6 +599,7 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
|
515
599
|
view.refreshable = record.refreshable
|
|
516
600
|
view.iframe = record.iframe
|
|
517
601
|
view.active = activeTabId.value === id
|
|
602
|
+
view.pinned = record.pinned
|
|
518
603
|
if (record.iframeRefreshMode === undefined) {
|
|
519
604
|
delete view.iframeRefreshMode
|
|
520
605
|
} else {
|
|
@@ -548,10 +633,12 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
|
548
633
|
canAddTab,
|
|
549
634
|
getPages,
|
|
550
635
|
getTopPage,
|
|
636
|
+
getPageRefreshVersion,
|
|
551
637
|
getPageSnapshot,
|
|
552
638
|
addTab,
|
|
553
639
|
addTabs,
|
|
554
640
|
activateTab,
|
|
641
|
+
updateTabTitle,
|
|
555
642
|
moveTab,
|
|
556
643
|
removeTab,
|
|
557
644
|
removeTabs,
|
|
@@ -560,6 +647,7 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
|
|
|
560
647
|
replacePages,
|
|
561
648
|
clearPages,
|
|
562
649
|
updatePage,
|
|
650
|
+
updatePages,
|
|
563
651
|
consumeBackParams,
|
|
564
652
|
isTabMarkedForEviction: (id) => tabIdsToEvict.has(id),
|
|
565
653
|
isCacheMarkedForEviction: (id) => cacheIdsToEvict.has(id),
|
|
@@ -41,7 +41,7 @@ export default () => {
|
|
|
41
41
|
/** 点击菜单外部时关闭菜单。 */
|
|
42
42
|
const handleClickOutside = (ev: MouseEvent) => {
|
|
43
43
|
const target = ev.target as Element
|
|
44
|
-
if (target.closest?.('.stack-
|
|
44
|
+
if (target.closest?.('.stack-tab__context-menu')) return
|
|
45
45
|
// 同一手势的 click 仍在守卫窗口内,跳过关闭判断。
|
|
46
46
|
if (Date.now() - lastContextMenuAt < BUBBLE_GUARD_MS) return
|
|
47
47
|
hideContextMenu()
|
|
@@ -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
|
|
|
@@ -137,10 +137,17 @@ export function useIframeManager(options: UseIframeManagerOptions) {
|
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* 同步 iframe 激活和移除状态。
|
|
142
|
+
* 只监听 id:active 签名,普通标签的标题/页面栈变化不会触发遍历。
|
|
143
|
+
*/
|
|
144
|
+
const iframeActivationSignature = computed(() =>
|
|
145
|
+
iframeTabs.value.map((frame) => `${frame.id}:${frame.active ? 1 : 0}`).join('|')
|
|
146
|
+
)
|
|
141
147
|
watch(
|
|
142
|
-
|
|
143
|
-
(
|
|
148
|
+
iframeActivationSignature,
|
|
149
|
+
() => {
|
|
150
|
+
const frames = iframeTabs.value
|
|
144
151
|
for (const f of frames) {
|
|
145
152
|
if (!f.active) continue
|
|
146
153
|
iframeEverActivated[f.id] = true
|
|
@@ -169,20 +176,16 @@ export function useIframeManager(options: UseIframeManagerOptions) {
|
|
|
169
176
|
)
|
|
170
177
|
|
|
171
178
|
// refreshKey 变化时重新触发对应 iframe 的加载状态
|
|
172
|
-
watch(
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (nextKey !== previousKey) {
|
|
180
|
-
setIframeLoading(frame.id, { force: true })
|
|
181
|
-
}
|
|
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 })
|
|
182
186
|
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
)
|
|
187
|
+
}
|
|
188
|
+
})
|
|
186
189
|
|
|
187
190
|
const handleIframeLoad = (frame: { id: string; url?: string }, event: Event) => {
|
|
188
191
|
const iframe = event.currentTarget as HTMLIFrameElement | null
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* - 阈值(默认 5px)内视为已贴边(≤ 阈值)→ 隐藏阴影;超过阈值视为可继续滚动 → 显示阴影
|
|
14
14
|
* - SSR 安全:在 document 不存在时直接返回
|
|
15
15
|
* - 自动清理:onScopeDispose 移除监听并取消 rAF/disconnect observer
|
|
16
|
+
* - 阴影类可以挂到另一个不参与 transform 的元素上:滚动容器的 ::after 一旦
|
|
17
|
+
* 跟随 transform(例如页面刷新动画),绝对定位的包含块就会变化,阴影会错位/消失
|
|
16
18
|
*/
|
|
17
19
|
import { onScopeDispose, type Ref } from 'vue'
|
|
18
20
|
|
|
@@ -22,8 +24,15 @@ const SHADOW_VERTICAL_CLASS = 'stack-tab__shadow-inset-vertical'
|
|
|
22
24
|
const DEFAULT_EDGE_THRESHOLD = 5
|
|
23
25
|
|
|
24
26
|
export interface UseScrollShadowOptions {
|
|
25
|
-
/**
|
|
27
|
+
/** 滚动容器:用于监听 scroll / resize 并读取滚动尺寸 */
|
|
26
28
|
target: Ref<HTMLElement | null> | (() => HTMLElement | null)
|
|
29
|
+
/**
|
|
30
|
+
* 阴影类挂载目标,默认与 target 相同。
|
|
31
|
+
* 当 target 参与 transform 动画(如刷新转场)时,应传入不参与动画的祖先
|
|
32
|
+
* (例如 .stack-tab__keep-alive-panel),否则 ::after 的绝对定位包含块会变化,
|
|
33
|
+
* 阴影会随动画错位或消失。
|
|
34
|
+
*/
|
|
35
|
+
classTarget?: Ref<HTMLElement | null> | (() => HTMLElement | null)
|
|
27
36
|
/**
|
|
28
37
|
* 是否监听内容缩放/尺寸变化(默认 true)。
|
|
29
38
|
* 启用后会用 ResizeObserver 监视目标尺寸,容器或子树尺寸变化都会重新计算阴影边界。
|
|
@@ -37,8 +46,10 @@ export interface UseScrollShadowOptions {
|
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
export interface UseScrollShadowReturn {
|
|
40
|
-
/**
|
|
49
|
+
/** 重新解析滚动/阴影目标并同步一次(用于 keep-alive 激活、DOM 重新挂载后) */
|
|
41
50
|
sync: () => void
|
|
51
|
+
/** 移除阴影类并取消待执行的 rAF(用于 keep-alive 失活) */
|
|
52
|
+
clear: () => void
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
const resolveTarget = (
|
|
@@ -47,18 +58,36 @@ const resolveTarget = (
|
|
|
47
58
|
|
|
48
59
|
export const useScrollShadow = (options: UseScrollShadowOptions): UseScrollShadowReturn => {
|
|
49
60
|
if (typeof document === 'undefined') {
|
|
50
|
-
return { sync: () => undefined }
|
|
61
|
+
return { sync: () => undefined, clear: () => undefined }
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
const watchResize = options.watchResize !== false
|
|
54
65
|
const edgeThreshold = Math.max(0, options.edgeThreshold ?? DEFAULT_EDGE_THRESHOLD)
|
|
55
66
|
let rafId: number | null = null
|
|
56
67
|
let attached: HTMLElement | null = null
|
|
68
|
+
let classElement: HTMLElement | null = null
|
|
57
69
|
let resizeObserver: ResizeObserver | null = null
|
|
58
70
|
|
|
71
|
+
const clearClasses = (element: HTMLElement | null): void => {
|
|
72
|
+
if (!element) return
|
|
73
|
+
element.classList.remove(SHADOW_TOP_CLASS, SHADOW_BOTTOM_CLASS, SHADOW_VERTICAL_CLASS)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 阴影类目标:配置了 classTarget 时必须真实解析到,否则宁可不显示(激活后会 sync 重试) */
|
|
77
|
+
const resolveClassTarget = (): HTMLElement | null =>
|
|
78
|
+
options.classTarget ? resolveTarget(options.classTarget) : attached
|
|
79
|
+
|
|
59
80
|
const recompute = (): void => {
|
|
60
81
|
if (!attached) return
|
|
61
82
|
const target = attached
|
|
83
|
+
const nextClassTarget = resolveClassTarget()
|
|
84
|
+
if (classElement && classElement !== nextClassTarget) {
|
|
85
|
+
clearClasses(classElement)
|
|
86
|
+
classElement = null
|
|
87
|
+
}
|
|
88
|
+
classElement = nextClassTarget
|
|
89
|
+
if (!classElement) return
|
|
90
|
+
|
|
62
91
|
const { scrollTop, scrollHeight, clientHeight } = target
|
|
63
92
|
const distanceToTop = scrollTop
|
|
64
93
|
const distanceToBottom = scrollHeight - clientHeight - scrollTop
|
|
@@ -69,9 +98,9 @@ export const useScrollShadow = (options: UseScrollShadowOptions): UseScrollShado
|
|
|
69
98
|
// 注意:-top / -bottom 是两个独立 CSS 规则、作用在同一个 ::after 上,
|
|
70
99
|
// 同时加会互相覆盖(后定义的 -bottom 胜出)。因此这里互斥地只加一个类:
|
|
71
100
|
// 两侧都有内容用 -vertical,只有一侧用 -top / -bottom,贴边时都不加。
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
101
|
+
classElement.classList.toggle(SHADOW_VERTICAL_CLASS, showTopShadow && showBottomShadow)
|
|
102
|
+
classElement.classList.toggle(SHADOW_TOP_CLASS, showTopShadow && !showBottomShadow)
|
|
103
|
+
classElement.classList.toggle(SHADOW_BOTTOM_CLASS, !showTopShadow && showBottomShadow)
|
|
75
104
|
}
|
|
76
105
|
|
|
77
106
|
const schedule = (): void => {
|
|
@@ -84,11 +113,17 @@ export const useScrollShadow = (options: UseScrollShadowOptions): UseScrollShado
|
|
|
84
113
|
|
|
85
114
|
const attach = (): void => {
|
|
86
115
|
const el = resolveTarget(options.target)
|
|
87
|
-
if (!el
|
|
116
|
+
if (!el) return
|
|
117
|
+
if (el === attached) {
|
|
118
|
+
schedule()
|
|
119
|
+
return
|
|
120
|
+
}
|
|
88
121
|
if (attached) {
|
|
89
122
|
attached.removeEventListener('scroll', schedule)
|
|
90
123
|
resizeObserver?.disconnect()
|
|
91
124
|
resizeObserver = null
|
|
125
|
+
clearClasses(classElement)
|
|
126
|
+
classElement = null
|
|
92
127
|
}
|
|
93
128
|
attached = el
|
|
94
129
|
el.addEventListener('scroll', schedule, { passive: true })
|
|
@@ -99,18 +134,40 @@ export const useScrollShadow = (options: UseScrollShadowOptions): UseScrollShado
|
|
|
99
134
|
schedule()
|
|
100
135
|
}
|
|
101
136
|
|
|
102
|
-
const
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
137
|
+
const cancelScheduled = (): void => {
|
|
138
|
+
if (rafId === null) return
|
|
139
|
+
cancelAnimationFrame(rafId)
|
|
140
|
+
rafId = null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const clear = (): void => {
|
|
144
|
+
clearClasses(classElement)
|
|
145
|
+
classElement = null
|
|
146
|
+
cancelScheduled()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 重新解析目标:keep-alive 激活或 DOM 重新挂载后由调用方触发 */
|
|
150
|
+
const sync = (): void => {
|
|
151
|
+
const el = resolveTarget(options.target)
|
|
152
|
+
if (!el) {
|
|
153
|
+
clear()
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
if (el !== attached) {
|
|
157
|
+
attach()
|
|
158
|
+
return
|
|
107
159
|
}
|
|
160
|
+
schedule()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const detach = (): void => {
|
|
164
|
+
if (attached) attached.removeEventListener('scroll', schedule)
|
|
165
|
+
attached = null
|
|
108
166
|
resizeObserver?.disconnect()
|
|
109
167
|
resizeObserver = null
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
168
|
+
clearClasses(classElement)
|
|
169
|
+
classElement = null
|
|
170
|
+
cancelScheduled()
|
|
114
171
|
}
|
|
115
172
|
|
|
116
173
|
// 初始挂载(target 可能是异步插入的 DOM,使用 microtask 等一帧)
|
|
@@ -118,7 +175,5 @@ export const useScrollShadow = (options: UseScrollShadowOptions): UseScrollShado
|
|
|
118
175
|
|
|
119
176
|
onScopeDispose(detach)
|
|
120
177
|
|
|
121
|
-
return {
|
|
122
|
-
sync: () => schedule()
|
|
123
|
-
}
|
|
178
|
+
return { sync, clear }
|
|
124
179
|
}
|