vue-stack-tabs 1.4.2 → 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 (49) 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/refresh.d.ts +4 -2
  7. package/dist/hooks/tabPanel/tabStore.d.ts +12 -1
  8. package/dist/hooks/useTabActions.d.ts +1 -0
  9. package/dist/hooks/useTabPanel.d.ts +14 -2
  10. package/dist/model/TabModel.d.ts +5 -0
  11. package/dist/style.css +1 -1
  12. package/dist/utils/tabInfoEncoder.d.ts +10 -0
  13. package/dist/utils/urlParser.d.ts +14 -3
  14. package/dist/vue-stack-tabs.es.js +1 -1
  15. package/package.json +4 -7
  16. package/src/lib/StackTabs.vue +24 -2
  17. package/src/lib/assets/image/close_all.svg +1 -9
  18. package/src/lib/assets/image/close_lefts.svg +1 -1
  19. package/src/lib/assets/image/close_other.svg +1 -1
  20. package/src/lib/assets/image/close_rights.svg +1 -1
  21. package/src/lib/assets/image/reload.svg +1 -3
  22. package/src/lib/assets/image/reload_all.svg +1 -6
  23. package/src/lib/assets/style/common/mixins.scss +58 -19
  24. package/src/lib/assets/style/common/var.scss +100 -60
  25. package/src/lib/assets/style/icon.scss +31 -98
  26. package/src/lib/assets/style/index.scss +2 -2
  27. package/src/lib/assets/style/loading.scss +45 -0
  28. package/src/lib/assets/style/{stackTab.scss → stack-tab.scss} +105 -177
  29. package/src/lib/assets/style/transition.scss +12 -17
  30. package/src/lib/components/ContextMenu/ContextMenuItem.vue +5 -5
  31. package/src/lib/components/ContextMenu/index.vue +22 -19
  32. package/src/lib/components/PageLoading.vue +5 -5
  33. package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +5 -3
  34. package/src/lib/components/TabHeader/TabHeaderItem.vue +13 -7
  35. package/src/lib/components/TabHeader/TabHeaderScroll.vue +8 -6
  36. package/src/lib/components/TabHeader/index.vue +16 -2
  37. package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +83 -7
  38. package/src/lib/hooks/tabPanel/refresh.ts +27 -9
  39. package/src/lib/hooks/tabPanel/tabStore.ts +86 -16
  40. package/src/lib/hooks/useContextMenu.ts +1 -1
  41. package/src/lib/hooks/useIframeManager.ts +10 -3
  42. package/src/lib/hooks/useTabActions.ts +8 -4
  43. package/src/lib/hooks/useTabPanel.tsx +204 -27
  44. package/src/lib/hooks/useTabRouter.ts +18 -11
  45. package/src/lib/model/TabModel.ts +5 -0
  46. package/src/lib/utils/tabInfoEncoder.ts +101 -17
  47. package/src/lib/utils/urlParser.ts +35 -11
  48. package/dist/vue-stack-tabs.css +0 -2
  49. package/src/lib/assets/style/overlayer.scss +0 -46
@@ -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),并触发 tabs 更新 */
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,15 @@ 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[]
110
124
  getTopPage: (tabId: string) => ITabPage | null
125
+ /** 读取任意页面的刷新版本;渲染层据此只重建对应页面 */
126
+ getPageRefreshVersion: (pageId: string) => number
111
127
  appendPage: (tabId: string, page: ITabPage) => void
112
128
  popPages: (tabId: string, steps: number) => ITabPage[]
113
129
  clearPages: (tabId: string) => void
@@ -211,6 +227,7 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
211
227
  // ---- 查询 ----
212
228
  const getTabs = (): readonly ITabItem[] => tabs.value
213
229
 
230
+ // 返回 tabs.value 中的对象(深度代理后的视图),保持调用方的对象身份比较语义。
214
231
  const getTab = (id: string): ITabItem | null => tabs.value.find((tab) => tab.id === id) ?? null
215
232
 
216
233
  const getActiveTab = (): ITabItem | null => tabs.value.find((tab) => tab.active) ?? null
@@ -234,9 +251,13 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
234
251
  return pagesById.get(ids[ids.length - 1] as string) ?? null
235
252
  }
236
253
 
254
+ const getPageRefreshVersion = (pageId: string): number =>
255
+ pagesById.get(pageId)?.refreshVersion ?? 0
256
+
237
257
  const findTabByTopPagePath = (path: string): ITabItem | null => {
258
+ // 直接遍历 tabViews,避免原先「getTopPage + getTab(find)」的 O(n²) 组合。
238
259
  for (const id of tabOrder.value) {
239
- if (getTopPage(id)?.path === path) return getTab(id)
260
+ if (getTopPage(id)?.path === path) return tabViews.get(id) ?? null
240
261
  }
241
262
  return null
242
263
  }
@@ -258,7 +279,8 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
258
279
  refreshable: tab.refreshable,
259
280
  iframe: tab.iframe,
260
281
  iframeRefreshMode: tab.iframeRefreshMode,
261
- url: tab.url
282
+ url: tab.url,
283
+ pinned: tab.pinned
262
284
  })
263
285
 
264
286
  const registerTab = (tab: ITabItem): void => {
@@ -293,22 +315,37 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
293
315
  return getTab(id)
294
316
  }
295
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
+
296
327
  const moveTab = (id: string, targetIndex: number): boolean => {
297
328
  const order = tabOrder.value
298
329
  const sourceIndex = order.indexOf(id)
299
- if (
300
- sourceIndex < 0 ||
301
- targetIndex < 0 ||
302
- targetIndex >= order.length ||
303
- sourceIndex === targetIndex
304
- ) {
330
+ if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) {
305
331
  return false
306
332
  }
307
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
+
308
345
  const reordered = [...order]
309
346
  const [tabId] = reordered.splice(sourceIndex, 1)
310
347
  if (!tabId) return false
311
- reordered.splice(targetIndex, 0, tabId)
348
+ reordered.splice(safeTargetIndex, 0, tabId)
312
349
  tabOrder.value = reordered
313
350
  syncTabsView()
314
351
  return true
@@ -425,12 +462,17 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
425
462
  syncTabsView()
426
463
  }
427
464
 
428
- const updatePage = (tabId: string, pageId: string, patch: PagePatch): boolean => {
465
+ const applyPagePatch = (
466
+ tabId: string,
467
+ pageId: string,
468
+ patch: PagePatch
469
+ ): 'missing' | 'conflict' | 'same-id' | 'new-id' => {
429
470
  const ids = pageIdsByTabId.get(tabId)
430
- if (!ids || !ids.includes(pageId)) return false
471
+ if (!ids || !ids.includes(pageId)) return 'missing'
431
472
 
432
473
  const page = pagesById.get(pageId)
433
- if (!page) return false
474
+ if (!page) return 'missing'
475
+ if (patch.id && patch.id !== pageId && pagesById.has(patch.id)) return 'conflict'
434
476
 
435
477
  Object.assign(page, patch)
436
478
  if (patch.id && patch.id !== pageId) {
@@ -440,11 +482,32 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
440
482
  tabId,
441
483
  ids.map((id) => (id === pageId ? (patch.id as string) : id))
442
484
  )
485
+ return 'new-id'
443
486
  }
444
- syncTabsView()
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()
445
498
  return true
446
499
  }
447
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
+
448
511
  const consumeBackParams = (tabId: string, pageId: string): Record<string, unknown> | null => {
449
512
  const top = getTopPage(tabId)
450
513
  if (!top || top.id !== pageId || !top._backParams) return null
@@ -486,6 +549,7 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
486
549
  const pageAccess: PageStackAccess = {
487
550
  getPages,
488
551
  getTopPage,
552
+ getPageRefreshVersion,
489
553
  appendPage,
490
554
  popPages,
491
555
  clearPages
@@ -506,7 +570,9 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
506
570
  iframeRefreshMode: record.iframeRefreshMode,
507
571
  url: record.url,
508
572
  active: activeTabId.value === id,
509
- pages: new PageStackView(id, pageAccess)
573
+ // pages 的读写都转发给 store,本身不需要被 ref 深度代理。
574
+ pages: markRaw(new PageStackView(id, pageAccess)),
575
+ pinned: record.pinned
510
576
  }
511
577
  tabViews.set(id, view)
512
578
  } else {
@@ -515,6 +581,7 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
515
581
  view.refreshable = record.refreshable
516
582
  view.iframe = record.iframe
517
583
  view.active = activeTabId.value === id
584
+ view.pinned = record.pinned
518
585
  if (record.iframeRefreshMode === undefined) {
519
586
  delete view.iframeRefreshMode
520
587
  } else {
@@ -548,10 +615,12 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
548
615
  canAddTab,
549
616
  getPages,
550
617
  getTopPage,
618
+ getPageRefreshVersion,
551
619
  getPageSnapshot,
552
620
  addTab,
553
621
  addTabs,
554
622
  activateTab,
623
+ updateTabTitle,
555
624
  moveTab,
556
625
  removeTab,
557
626
  removeTabs,
@@ -560,6 +629,7 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
560
629
  replacePages,
561
630
  clearPages,
562
631
  updatePage,
632
+ updatePages,
563
633
  consumeBackParams,
564
634
  isTabMarkedForEviction: (id) => tabIdsToEvict.has(id),
565
635
  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-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
@@ -2,7 +2,7 @@ import { isNavigationFailure, useRouter } from 'vue-router'
2
2
  import type { LocationQueryRaw } from 'vue-router'
3
3
  import { defu } from 'defu'
4
4
  import useTabPanel from './useTabPanel'
5
- import { encodeTabInfo } from '../utils/tabInfoEncoder'
5
+ import { createId, encodeTabInfo } from '../utils/tabInfoEncoder'
6
6
  import {
7
7
  parseUrl,
8
8
  isCrossOriginUrl,
@@ -31,6 +31,7 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
31
31
  const runtimeContext = providedRuntimeContext ?? resolveStackTabsRuntimeContext()
32
32
  const {
33
33
  active,
34
+ updateTabTitle,
34
35
  hasTab,
35
36
  refreshKey,
36
37
  reset,
@@ -61,7 +62,7 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
61
62
  defaults.iframeRefreshMode = (tab.iframeRefreshMode ?? 'postMessage') as IframeRefreshMode
62
63
  }
63
64
  const info = defu(tab, defaults)
64
- if (!info.id) info.id = crypto.randomUUID()
65
+ if (!info.id) info.id = createId()
65
66
  return info
66
67
  }
67
68
 
@@ -111,10 +112,12 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
111
112
  const tabQuery = omitStackTabsReservedQuery(tab.query)
112
113
  let query = defu(tabQuery, { __tab }) as LocationQueryRaw
113
114
  let path: string
115
+ let hash: string | undefined
114
116
 
115
117
  if (!tab.iframe) {
116
118
  const url = parseUrl(tab.path)
117
119
  path = url.path
120
+ hash = url.hash || undefined
118
121
  query = defu(omitStackTabsReservedQuery(url.query), query, { __tab }) as LocationQueryRaw
119
122
  query = {
120
123
  ...query,
@@ -132,7 +135,7 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
132
135
  if (!isAllowedTabUrl(tab.path)) return Promise.reject(new Error('Invalid tab URL'))
133
136
  return runNavigationTransaction({
134
137
  apply: () => undefined,
135
- navigate: () => router.push({ path, query }),
138
+ navigate: () => router.push({ path, query, hash }),
136
139
  rollback: () => undefined,
137
140
  isFailureResult: isNavigationFailure,
138
141
  rejectFailureResult: true
@@ -155,7 +158,7 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
155
158
  if (typeof document === 'undefined') return undefined
156
159
  const pageId = target.pageId ?? (target.tabId ? getTopPage(target.tabId)?.id : undefined)
157
160
  if (pageId) return document.getElementById(`W-${pageId}`) ?? undefined
158
- return document.querySelector<HTMLElement>('.cache-page-wrapper') ?? undefined
161
+ return document.querySelector<HTMLElement>('.stack-tab__cache-page-wrapper') ?? undefined
159
162
  }
160
163
 
161
164
  /** iframe 标签在新窗口打开(无法嵌入时降级) */
@@ -181,6 +184,7 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
181
184
  // 激活与重置
182
185
  activeTab: active,
183
186
  moveTab,
187
+ updateTabTitle,
184
188
  reset,
185
189
 
186
190
  // 数据与配置