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
@@ -0,0 +1,665 @@
1
+ /**
2
+ * tabPanel/tabStore - 标签/页面栈的唯一状态入口
3
+ *
4
+ * 内部使用规范化的三张“表”保存状态:
5
+ * - tabOrder + tabsById:标签顺序与标签记录
6
+ * - pageIdsByTabId:标签到页面 id 的一对多关系
7
+ * - pagesById:页面记录
8
+ *
9
+ * 对外仍通过 tabs Ref 暴露兼容的 ITabItem[];每个 tab.pages 是 PageStackView,
10
+ * 读写会转发到 store,因此渲染层与公开类型不需要感知内部结构变化。
11
+ *
12
+ * 注意:本模块不依赖 vue-router,也不触发业务事件;路由事务与事件由 useTabPanel /
13
+ * useTabRouter 等上层编排。
14
+ */
15
+ import { markRaw, reactive, ref } from 'vue'
16
+ import type { DefineComponent } from 'vue'
17
+ import type { ITabItem, ITabPage } from '../../model/TabModel'
18
+ import { Stack } from '../../model/TabModel'
19
+ import { clonePage } from '../../utils/urlParser'
20
+ import type { StackTabsRuntimeContext } from '../stackTabsContext'
21
+ import { createTabPanelEviction, type TabPanelEvictionApi } from './evict'
22
+
23
+ export interface RemoveTabResult {
24
+ status: 'not-found' | 'not-closable' | 'removed'
25
+ removed: ITabItem | null
26
+ /** 关闭后应激活的相邻标签;没有可激活标签时为空字符串 */
27
+ nextActiveId: string
28
+ }
29
+
30
+ export interface PageUpdate {
31
+ tabId: string
32
+ pageId: string
33
+ patch: PagePatch
34
+ }
35
+
36
+ /** updatePage 允许修改的字段;页面身份与刷新版本必须通过 store 写入 */
37
+ export interface PagePatch {
38
+ id?: string
39
+ refreshVersion?: number
40
+ _backParams?: Record<string, unknown>
41
+ }
42
+
43
+ export interface TabStoreApi extends TabPanelEvictionApi {
44
+ // ---- 查询 ----
45
+ /** 返回标签列表的只读视图(运行时为同一个响应式数组) */
46
+ getTabs: () => readonly ITabItem[]
47
+ getTab: (id: string) => ITabItem | null
48
+ getActiveTab: () => ITabItem | null
49
+ /** 查找栈顶页面 path 与给定 path 相同的标签 */
50
+ findTabByTopPagePath: (path: string) => ITabItem | null
51
+ hasTab: (id: string) => boolean
52
+ canAddTab: () => boolean
53
+ getPages: (tabId: string) => readonly ITabPage[]
54
+ getTopPage: (tabId: string) => ITabPage | null
55
+ /** 读取任意页面的刷新版本;渲染层据此只重建对应页面 */
56
+ getPageRefreshVersion: (pageId: string) => number
57
+ /** 返回深拷贝的页面栈快照,可直接用于 rollback */
58
+ getPageSnapshot: (tabId: string) => ITabPage[]
59
+
60
+ // ---- 标签写入 ----
61
+ addTab: (tab: ITabItem) => void
62
+ addTabs: (tabs: readonly ITabItem[]) => void
63
+ /** 唯一 active 写入口 */
64
+ activateTab: (id: string) => ITabItem | null
65
+ /** 动态更新标签标题;返回 false 表示标签不存在 */
66
+ updateTabTitle: (id: string, title: string) => boolean
67
+ moveTab: (id: string, targetIndex: number) => boolean
68
+ removeTab: (id: string) => RemoveTabResult
69
+ removeTabs: (shouldRemove: (tab: ITabItem, index: number) => boolean) => ITabItem[]
70
+
71
+ // ---- 页面栈写入 ----
72
+ appendPage: (tabId: string, page: ITabPage) => void
73
+ /** 从栈顶弹出若干页,返回被弹出的页面(由调用方决定驱逐/回滚) */
74
+ popPages: (tabId: string, steps: number) => ITabPage[]
75
+ /** 用给定页面替换整个栈(内部深拷贝页面) */
76
+ replacePages: (tabId: string, pages: readonly ITabPage[]) => void
77
+ clearPages: (tabId: string) => void
78
+ /** 修改页面字段(id / refreshVersion / _backParams);仅 id 变化时重建 tabs 数组 */
79
+ updatePage: (tabId: string, pageId: string, patch: PagePatch) => boolean
80
+ /** 批量修改页面字段,最后最多重建一次 tabs 数组(供 refreshAllTabs 使用) */
81
+ updatePages: (updates: readonly PageUpdate[]) => void
82
+ /** 取出并清除栈顶页面的 _backParams;pageId 不是当前栈顶时返回 null */
83
+ consumeBackParams: (tabId: string, pageId: string) => Record<string, unknown> | null
84
+
85
+ // ---- 驱逐标记辅助 ----
86
+ isTabMarkedForEviction: (id: string) => boolean
87
+ isCacheMarkedForEviction: (id: string) => boolean
88
+ unmarkCacheForEviction: (id: string) => void
89
+ clearTabEvictionMarks: () => void
90
+
91
+ // ---- 组件注册表 ----
92
+ getComponent: (id: string) => DefineComponent | undefined
93
+ setComponent: (id: string, component: DefineComponent) => void
94
+ deleteComponent: (id: string) => void
95
+ snapshotComponents: () => Map<string, DefineComponent>
96
+ restoreComponents: (snapshot: Map<string, DefineComponent>) => void
97
+
98
+ // ---- 缓存快照 / 响应式 ----
99
+ snapshotCaches: () => string[]
100
+ restoreCaches: (cacheNames: readonly string[]) => void
101
+ /** 通知依赖 tabs 的计算属性重新求值(如 activePageRefreshVersion) */
102
+ touchTabs: () => void
103
+
104
+ // ---- 生命周期 ----
105
+ /** 清空 tabs / caches / components / 驱逐标记,供 destroy 使用 */
106
+ reset: () => void
107
+ }
108
+
109
+ /** 标签表中保存的纯净记录;active 由 activeTabId 派生 */
110
+ interface TabRecord {
111
+ id: string
112
+ title: string
113
+ closable: boolean
114
+ refreshable: boolean
115
+ iframe: boolean
116
+ iframeRefreshMode?: 'postMessage' | 'reload'
117
+ url?: string
118
+ /** 固定在标签栏最前方,不可拖动,其他标签也不能排到它前面 */
119
+ pinned?: boolean
120
+ }
121
+
122
+ interface PageStackAccess {
123
+ getPages: (tabId: string) => readonly ITabPage[]
124
+ getTopPage: (tabId: string) => ITabPage | null
125
+ /** 读取任意页面的刷新版本;渲染层据此只重建对应页面 */
126
+ getPageRefreshVersion: (pageId: string) => number
127
+ appendPage: (tabId: string, page: ITabPage) => void
128
+ popPages: (tabId: string, steps: number) => ITabPage[]
129
+ clearPages: (tabId: string) => void
130
+ }
131
+
132
+ /**
133
+ * 兼容视图:继承 Stack 以保持 ITabItem.pages 的类型不变,
134
+ * 所有读写都转发给 store 的规范化状态。
135
+ */
136
+ class PageStackView extends Stack<ITabPage> {
137
+ private readonly tabId: string
138
+ private readonly access: PageStackAccess
139
+
140
+ constructor(tabId: string, access: PageStackAccess) {
141
+ super()
142
+ this.tabId = tabId
143
+ this.access = access
144
+ }
145
+
146
+ override push(page: ITabPage): void {
147
+ this.access.appendPage(this.tabId, page)
148
+ }
149
+
150
+ override pop(): ITabPage | undefined {
151
+ return this.access.popPages(this.tabId, 1)[0]
152
+ }
153
+
154
+ override peek(): ITabPage | undefined {
155
+ return this.access.getTopPage(this.tabId) ?? undefined
156
+ }
157
+
158
+ override at(index: number): ITabPage | undefined {
159
+ return this.access.getPages(this.tabId)[index]
160
+ }
161
+
162
+ override isEmpty(): boolean {
163
+ return this.access.getPages(this.tabId).length === 0
164
+ }
165
+
166
+ override size(): number {
167
+ return this.access.getPages(this.tabId).length
168
+ }
169
+
170
+ override clear(): void {
171
+ this.access.clearPages(this.tabId)
172
+ }
173
+
174
+ override list(): ITabPage[] {
175
+ return [...this.access.getPages(this.tabId)]
176
+ }
177
+
178
+ override readonlyList(): readonly ITabPage[] {
179
+ return this.access.getPages(this.tabId)
180
+ }
181
+
182
+ override find(predicate: (element: ITabPage, index: number) => boolean): ITabPage | undefined {
183
+ return this.list().find(predicate)
184
+ }
185
+
186
+ override findIndex(predicate: (element: ITabPage, index: number) => boolean): number {
187
+ return this.list().findIndex(predicate)
188
+ }
189
+
190
+ override filter(predicate: (element: ITabPage, index: number) => boolean): ITabPage[] {
191
+ return this.list().filter(predicate)
192
+ }
193
+
194
+ override some(predicate: (element: ITabPage, index: number) => boolean): boolean {
195
+ return this.list().some(predicate)
196
+ }
197
+
198
+ override forEachTopDown(visit: (element: ITabPage, index: number) => void): void {
199
+ const pages = this.list()
200
+ for (let index = pages.length - 1; index >= 0; index--) {
201
+ visit(pages[index] as ITabPage, index)
202
+ }
203
+ }
204
+
205
+ override toJSON(): ITabPage[] {
206
+ return this.list()
207
+ }
208
+
209
+ override toString(): string {
210
+ return this.list().toString()
211
+ }
212
+ }
213
+
214
+ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
215
+ const { tabs, caches, components, cacheIdsToEvict, tabIdsToEvict } = context
216
+ const eviction = createTabPanelEviction(context)
217
+
218
+ // ---- 规范化状态 ----
219
+ const tabOrder = ref<string[]>([])
220
+ const tabsById = reactive(new Map<string, TabRecord>())
221
+ const pageIdsByTabId = reactive(new Map<string, string[]>())
222
+ const pagesById = reactive(new Map<string, ITabPage>())
223
+ const activeTabId = ref('')
224
+ /** 稳定的兼容视图,保证 TabHeader 等使用方的对象身份不变 */
225
+ const tabViews = new Map<string, ITabItem>()
226
+
227
+ // ---- 查询 ----
228
+ const getTabs = (): readonly ITabItem[] => tabs.value
229
+
230
+ // 返回 tabs.value 中的对象(深度代理后的视图),保持调用方的对象身份比较语义。
231
+ const getTab = (id: string): ITabItem | null => tabs.value.find((tab) => tab.id === id) ?? null
232
+
233
+ const getActiveTab = (): ITabItem | null => tabs.value.find((tab) => tab.active) ?? null
234
+
235
+ const getPageIds = (tabId: string): readonly string[] => pageIdsByTabId.get(tabId) ?? []
236
+
237
+ const getPages = (tabId: string): readonly ITabPage[] => {
238
+ const ids = pageIdsByTabId.get(tabId)
239
+ if (!ids || ids.length === 0) return []
240
+ const pages: ITabPage[] = []
241
+ for (const id of ids) {
242
+ const page = pagesById.get(id)
243
+ if (page) pages.push(page)
244
+ }
245
+ return pages
246
+ }
247
+
248
+ const getTopPage = (tabId: string): ITabPage | null => {
249
+ const ids = pageIdsByTabId.get(tabId)
250
+ if (!ids || ids.length === 0) return null
251
+ return pagesById.get(ids[ids.length - 1] as string) ?? null
252
+ }
253
+
254
+ const getPageRefreshVersion = (pageId: string): number =>
255
+ pagesById.get(pageId)?.refreshVersion ?? 0
256
+
257
+ const findTabByTopPagePath = (path: string): ITabItem | null => {
258
+ // 直接遍历 tabViews,避免原先「getTopPage + getTab(find)」的 O(n²) 组合。
259
+ for (const id of tabOrder.value) {
260
+ if (getTopPage(id)?.path === path) return tabViews.get(id) ?? null
261
+ }
262
+ return null
263
+ }
264
+
265
+ const hasTab = (id: string): boolean => tabsById.has(id)
266
+
267
+ const canAddTab = (): boolean =>
268
+ context.maxTabCount.value <= 0 ||
269
+ (context.maxTabCount.value > 0 && context.maxTabCount.value > tabOrder.value.length)
270
+
271
+ const getPageSnapshot = (tabId: string): ITabPage[] =>
272
+ getPages(tabId).map((page) => clonePage(page))
273
+
274
+ // ---- 标签写入 ----
275
+ const toRecord = (tab: ITabItem): TabRecord => ({
276
+ id: tab.id,
277
+ title: tab.title,
278
+ closable: tab.closable,
279
+ refreshable: tab.refreshable,
280
+ iframe: tab.iframe,
281
+ iframeRefreshMode: tab.iframeRefreshMode,
282
+ url: tab.url,
283
+ pinned: tab.pinned
284
+ })
285
+
286
+ const registerTab = (tab: ITabItem): void => {
287
+ tabsById.set(tab.id, toRecord(tab))
288
+
289
+ const pages = tab.pages.list()
290
+ const pageIds = pages.map((page) => page.id)
291
+ for (const page of pages) pagesById.set(page.id, page)
292
+ pageIdsByTabId.set(tab.id, pageIds)
293
+
294
+ tabViews.delete(tab.id)
295
+ if (!tabOrder.value.includes(tab.id)) {
296
+ tabOrder.value = [...tabOrder.value, tab.id]
297
+ }
298
+ if (tab.active) activeTabId.value = tab.id
299
+ }
300
+
301
+ const addTab = (tab: ITabItem): void => {
302
+ registerTab(tab)
303
+ syncTabsView()
304
+ }
305
+
306
+ const addTabs = (newTabs: readonly ITabItem[]): void => {
307
+ if (newTabs.length === 0) return
308
+ for (const tab of newTabs) registerTab(tab)
309
+ syncTabsView()
310
+ }
311
+
312
+ const activateTab = (id: string): ITabItem | null => {
313
+ activeTabId.value = tabsById.has(id) ? id : ''
314
+ syncTabsView()
315
+ return getTab(id)
316
+ }
317
+
318
+ const updateTabTitle = (id: string, title: string): boolean => {
319
+ const record = tabsById.get(id)
320
+ if (!record) return false
321
+ if (record.title === title) return true
322
+ record.title = title
323
+ syncTabsView()
324
+ return true
325
+ }
326
+
327
+ const moveTab = (id: string, targetIndex: number): boolean => {
328
+ const order = tabOrder.value
329
+ const sourceIndex = order.indexOf(id)
330
+ if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) {
331
+ return false
332
+ }
333
+
334
+ // pinned 标签(home)固定在最前方:自身不可移动,其他标签也不得插到它前面。
335
+ const sourceRecord = tabsById.get(id)
336
+ if (!sourceRecord || sourceRecord.pinned) return false
337
+
338
+ let firstUnpinnedIndex = 0
339
+ for (let index = 0; index < order.length; index++) {
340
+ if (tabsById.get(order[index] as string)?.pinned) firstUnpinnedIndex = index + 1
341
+ }
342
+ const safeTargetIndex = Math.max(targetIndex, firstUnpinnedIndex)
343
+ if (safeTargetIndex >= order.length || safeTargetIndex === sourceIndex) return false
344
+
345
+ const reordered = [...order]
346
+ const [tabId] = reordered.splice(sourceIndex, 1)
347
+ if (!tabId) return false
348
+ reordered.splice(safeTargetIndex, 0, tabId)
349
+ tabOrder.value = reordered
350
+ syncTabsView()
351
+ return true
352
+ }
353
+
354
+ const removeTab = (id: string): RemoveTabResult => {
355
+ const order = tabOrder.value
356
+ const index = order.indexOf(id)
357
+ if (index < 0) return { status: 'not-found', removed: null, nextActiveId: '' }
358
+
359
+ const record = tabsById.get(id)
360
+ if (!record || !record.closable) {
361
+ return { status: 'not-closable', removed: null, nextActiveId: id }
362
+ }
363
+
364
+ const removed = materializeTab(id)
365
+ const wasActive = activeTabId.value === id
366
+ if (removed) eviction.markTabPagesForEviction(removed)
367
+
368
+ for (const pageId of getPageIds(id)) pagesById.delete(pageId)
369
+ pageIdsByTabId.delete(id)
370
+ tabsById.delete(id)
371
+ tabViews.delete(id)
372
+
373
+ let nextActiveId = ''
374
+ if (order.length > 1 && wasActive) {
375
+ nextActiveId = (index === 0 ? order[1] : order[index - 1]) ?? ''
376
+ }
377
+ if (wasActive) activeTabId.value = ''
378
+
379
+ tabOrder.value = order.filter((tabId) => tabId !== id)
380
+ syncTabsView()
381
+
382
+ // 非激活标签关闭后没有 onActivated 兜底,立即执行驱逐。
383
+ if (!wasActive) {
384
+ eviction.evictMarkedCaches()
385
+ tabIdsToEvict.clear()
386
+ }
387
+
388
+ return { status: 'removed', removed, nextActiveId }
389
+ }
390
+
391
+ const removeTabs = (shouldRemove: (tab: ITabItem, index: number) => boolean): ITabItem[] => {
392
+ const removed: ITabItem[] = []
393
+ const remaining: string[] = []
394
+
395
+ tabOrder.value.forEach((id, index) => {
396
+ const record = tabsById.get(id)
397
+ const tab = materializeTab(id)
398
+ if (!record || !tab) return
399
+ if (!record.closable || !shouldRemove(tab, index)) {
400
+ remaining.push(id)
401
+ return
402
+ }
403
+
404
+ eviction.markTabPagesForEviction(tab)
405
+ for (const pageId of getPageIds(id)) pagesById.delete(pageId)
406
+ pageIdsByTabId.delete(id)
407
+ tabsById.delete(id)
408
+ tabViews.delete(id)
409
+ if (activeTabId.value === id) activeTabId.value = ''
410
+ removed.push(tab)
411
+ })
412
+
413
+ tabOrder.value = remaining
414
+ syncTabsView()
415
+ return removed
416
+ }
417
+
418
+ // ---- 页面栈写入 ----
419
+ const appendPage = (tabId: string, page: ITabPage): void => {
420
+ if (!tabsById.has(tabId)) return
421
+ const ids = pageIdsByTabId.get(tabId) ?? []
422
+ pageIdsByTabId.set(tabId, [...ids, page.id])
423
+ pagesById.set(page.id, page)
424
+ syncTabsView()
425
+ }
426
+
427
+ const popPages = (tabId: string, steps: number): ITabPage[] => {
428
+ if (!tabsById.has(tabId) || steps <= 0) return []
429
+
430
+ const ids = [...(pageIdsByTabId.get(tabId) ?? [])]
431
+ const popped: ITabPage[] = []
432
+ for (let index = 0; index < steps; index++) {
433
+ const pageId = ids.pop()
434
+ if (!pageId) break
435
+ const page = pagesById.get(pageId)
436
+ if (page) popped.push(page)
437
+ pagesById.delete(pageId)
438
+ }
439
+ pageIdsByTabId.set(tabId, ids)
440
+ syncTabsView()
441
+ return popped
442
+ }
443
+
444
+ const replacePages = (tabId: string, pages: readonly ITabPage[]): void => {
445
+ if (!tabsById.has(tabId)) return
446
+
447
+ for (const pageId of getPageIds(tabId)) pagesById.delete(pageId)
448
+ const nextPageIds: string[] = []
449
+ for (const page of pages) {
450
+ const cloned = clonePage(page)
451
+ pagesById.set(cloned.id, cloned)
452
+ nextPageIds.push(cloned.id)
453
+ }
454
+ pageIdsByTabId.set(tabId, nextPageIds)
455
+ syncTabsView()
456
+ }
457
+
458
+ const clearPages = (tabId: string): void => {
459
+ if (!tabsById.has(tabId)) return
460
+ for (const pageId of getPageIds(tabId)) pagesById.delete(pageId)
461
+ pageIdsByTabId.set(tabId, [])
462
+ syncTabsView()
463
+ }
464
+
465
+ const applyPagePatch = (
466
+ tabId: string,
467
+ pageId: string,
468
+ patch: PagePatch
469
+ ): 'missing' | 'conflict' | 'same-id' | 'new-id' => {
470
+ const ids = pageIdsByTabId.get(tabId)
471
+ if (!ids || !ids.includes(pageId)) return 'missing'
472
+
473
+ const page = pagesById.get(pageId)
474
+ if (!page) return 'missing'
475
+ if (patch.id && patch.id !== pageId && pagesById.has(patch.id)) return 'conflict'
476
+
477
+ Object.assign(page, patch)
478
+ if (patch.id && patch.id !== pageId) {
479
+ pagesById.delete(pageId)
480
+ pagesById.set(patch.id, page)
481
+ pageIdsByTabId.set(
482
+ tabId,
483
+ ids.map((id) => (id === pageId ? (patch.id as string) : id))
484
+ )
485
+ return 'new-id'
486
+ }
487
+ return 'same-id'
488
+ }
489
+
490
+ /**
491
+ * 刷新版本、_backParams 都是 page 上的响应式字段,读取方会自行更新;
492
+ * 只有页面 id 变化才需要重建 tabs 视图,避免刷新一个标签就 diff 整个标签栏。
493
+ */
494
+ const updatePage = (tabId: string, pageId: string, patch: PagePatch): boolean => {
495
+ const result = applyPagePatch(tabId, pageId, patch)
496
+ if (result === 'missing' || result === 'conflict') return false
497
+ if (result === 'new-id') syncTabsView()
498
+ return true
499
+ }
500
+
501
+ const updatePages = (updates: readonly PageUpdate[]): void => {
502
+ let needsSync = false
503
+ for (const update of updates) {
504
+ if (applyPagePatch(update.tabId, update.pageId, update.patch) === 'new-id') {
505
+ needsSync = true
506
+ }
507
+ }
508
+ if (needsSync) syncTabsView()
509
+ }
510
+
511
+ const consumeBackParams = (tabId: string, pageId: string): Record<string, unknown> | null => {
512
+ const top = getTopPage(tabId)
513
+ if (!top || top.id !== pageId || !top._backParams) return null
514
+ const backParams = { ...top._backParams }
515
+ delete top._backParams
516
+ syncTabsView()
517
+ return backParams
518
+ }
519
+
520
+ // ---- 缓存与组件注册表 ----
521
+ const snapshotCaches = (): string[] => [...caches.value]
522
+
523
+ const restoreCaches = (cacheNames: readonly string[]): void => {
524
+ caches.value = [...cacheNames]
525
+ }
526
+
527
+ const snapshotComponents = (): Map<string, DefineComponent> => new Map(components)
528
+
529
+ const restoreComponents = (snapshot: Map<string, DefineComponent>): void => {
530
+ components.clear()
531
+ for (const [id, component] of snapshot) components.set(id, component)
532
+ }
533
+
534
+ const reset = (): void => {
535
+ tabOrder.value = []
536
+ tabsById.clear()
537
+ pageIdsByTabId.clear()
538
+ pagesById.clear()
539
+ tabViews.clear()
540
+ activeTabId.value = ''
541
+ tabs.value = []
542
+ caches.value = []
543
+ components.clear()
544
+ cacheIdsToEvict.clear()
545
+ tabIdsToEvict.clear()
546
+ }
547
+
548
+ // ---- 兼容视图 ----
549
+ const pageAccess: PageStackAccess = {
550
+ getPages,
551
+ getTopPage,
552
+ getPageRefreshVersion,
553
+ appendPage,
554
+ popPages,
555
+ clearPages
556
+ }
557
+
558
+ function materializeTab(id: string): ITabItem | null {
559
+ const record = tabsById.get(id)
560
+ if (!record) return null
561
+
562
+ let view = tabViews.get(id)
563
+ if (!view) {
564
+ view = {
565
+ id: record.id,
566
+ title: record.title,
567
+ closable: record.closable,
568
+ refreshable: record.refreshable,
569
+ iframe: record.iframe,
570
+ iframeRefreshMode: record.iframeRefreshMode,
571
+ url: record.url,
572
+ active: activeTabId.value === id,
573
+ // pages 的读写都转发给 store,本身不需要被 ref 深度代理。
574
+ pages: markRaw(new PageStackView(id, pageAccess)),
575
+ pinned: record.pinned
576
+ }
577
+ tabViews.set(id, view)
578
+ } else {
579
+ view.title = record.title
580
+ view.closable = record.closable
581
+ view.refreshable = record.refreshable
582
+ view.iframe = record.iframe
583
+ view.active = activeTabId.value === id
584
+ view.pinned = record.pinned
585
+ if (record.iframeRefreshMode === undefined) {
586
+ delete view.iframeRefreshMode
587
+ } else {
588
+ view.iframeRefreshMode = record.iframeRefreshMode
589
+ }
590
+ if (record.url === undefined) {
591
+ delete view.url
592
+ } else {
593
+ view.url = record.url
594
+ }
595
+ }
596
+ return view
597
+ }
598
+
599
+ function syncTabsView(): void {
600
+ const next: ITabItem[] = []
601
+ for (const id of tabOrder.value) {
602
+ const view = materializeTab(id)
603
+ if (view) next.push(view)
604
+ }
605
+ tabs.value = next
606
+ }
607
+
608
+ return {
609
+ ...eviction,
610
+ getTabs,
611
+ getTab,
612
+ getActiveTab,
613
+ findTabByTopPagePath,
614
+ hasTab,
615
+ canAddTab,
616
+ getPages,
617
+ getTopPage,
618
+ getPageRefreshVersion,
619
+ getPageSnapshot,
620
+ addTab,
621
+ addTabs,
622
+ activateTab,
623
+ updateTabTitle,
624
+ moveTab,
625
+ removeTab,
626
+ removeTabs,
627
+ appendPage,
628
+ popPages,
629
+ replacePages,
630
+ clearPages,
631
+ updatePage,
632
+ updatePages,
633
+ consumeBackParams,
634
+ isTabMarkedForEviction: (id) => tabIdsToEvict.has(id),
635
+ isCacheMarkedForEviction: (id) => cacheIdsToEvict.has(id),
636
+ unmarkCacheForEviction: (id) => cacheIdsToEvict.delete(id),
637
+ clearTabEvictionMarks: () => tabIdsToEvict.clear(),
638
+ getComponent: (id) => components.get(id),
639
+ setComponent: (id, component) => components.set(id, component),
640
+ deleteComponent: (id) => components.delete(id),
641
+ snapshotComponents,
642
+ restoreComponents,
643
+ snapshotCaches,
644
+ restoreCaches,
645
+ touchTabs: syncTabsView,
646
+ reset
647
+ }
648
+ }
649
+
650
+ const storesByContext = new WeakMap<StackTabsRuntimeContext, TabStoreApi>()
651
+
652
+ /**
653
+ * 每个运行时上下文只创建一个 store。
654
+ *
655
+ * useTabPanel 会被 StackKeepAlive、StackCacheRenderer、页面内的 useTabRouter 多次调用;
656
+ * 复用同一实例可避免 eviction 的 Set/数组出现多份互相不同步的状态。
657
+ */
658
+ export const getTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
659
+ const existing = storesByContext.get(context)
660
+ if (existing) return existing
661
+
662
+ const store = createTabStore(context)
663
+ storesByContext.set(context, store)
664
+ return store
665
+ }
@@ -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-tab__contextmenu')) return
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()
@@ -137,10 +137,17 @@ export function useIframeManager(options: UseIframeManagerOptions) {
137
137
  }
138
138
  }
139
139
 
140
- /** 同步 iframe 激活和移除状态,避免用多个 watcher 重复遍历 iframe 列表。 */
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
- () => iframeTabs.value.map((frame) => ({ id: frame.id, active: frame.active })),
143
- (frames) => {
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