vue-stack-tabs 0.1.51 → 1.0.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 (94) hide show
  1. package/README.md +717 -3
  2. package/dist/index.d.ts +3 -0
  3. package/dist/src/lib/StackTabs.vue.d.ts +33 -161
  4. package/dist/src/lib/components/ContextMenu/ContextMenuItem.vue.d.ts +6 -6
  5. package/dist/src/lib/components/ContextMenu/index.vue.d.ts +14 -14
  6. package/dist/src/lib/components/PageLoading.vue.d.ts +5 -5
  7. package/dist/src/lib/components/TabHeader/TabHeaderButton.vue.d.ts +24 -15
  8. package/dist/src/lib/components/TabHeader/TabHeaderItem.vue.d.ts +10 -8
  9. package/dist/src/lib/components/TabHeader/TabHeaderScroll.vue.d.ts +31 -13
  10. package/dist/src/lib/components/TabHeader/index.vue.d.ts +172 -20
  11. package/dist/src/lib/hooks/tabPanel/evict.d.ts +24 -0
  12. package/dist/src/lib/hooks/tabPanel/evict.spec.d.ts +1 -0
  13. package/dist/src/lib/hooks/tabPanel/scroll.d.ts +8 -0
  14. package/dist/src/lib/hooks/tabPanel/session.d.ts +9 -0
  15. package/dist/src/lib/hooks/tabPanel/state.d.ts +77 -0
  16. package/dist/src/lib/hooks/useContextMenu.d.ts +6 -1
  17. package/dist/src/lib/hooks/useTabActions.d.ts +68 -0
  18. package/dist/src/lib/hooks/{useTabMitt.d.ts → useTabEventBus.d.ts} +4 -3
  19. package/dist/src/lib/hooks/useTabLoading.d.ts +6 -0
  20. package/dist/src/lib/hooks/{useTabpanel.d.ts → useTabPanel.d.ts} +20 -14
  21. package/dist/src/lib/hooks/useTabPanel.spec.d.ts +1 -0
  22. package/dist/src/lib/hooks/useTabRouter.d.ts +14 -5
  23. package/dist/src/lib/i18n/index.d.ts +1 -1
  24. package/dist/src/lib/i18n/lang/en.d.ts +1 -0
  25. package/dist/src/lib/i18n/lang/zh-CN.d.ts +1 -0
  26. package/dist/src/lib/index.d.ts +5 -4
  27. package/dist/src/lib/model/TabModel.d.ts +20 -3
  28. package/dist/src/lib/model/TabModel.spec.d.ts +1 -0
  29. package/dist/src/lib/utils/iframeBridge.d.ts +31 -0
  30. package/dist/src/lib/utils/iframeBridge.spec.d.ts +1 -0
  31. package/dist/src/lib/utils/scrollUtils.d.ts +28 -0
  32. package/dist/src/lib/utils/tabInfoEncoder.d.ts +19 -0
  33. package/dist/src/lib/utils/tabInfoEncoder.spec.d.ts +1 -0
  34. package/dist/src/lib/utils/urlParser.d.ts +19 -0
  35. package/dist/src/lib/utils/urlParser.spec.d.ts +1 -0
  36. package/dist/src/lib/versionLogger.d.ts +2 -0
  37. package/dist/style.css +1 -1
  38. package/dist/vue-stack-tabs.css +1 -0
  39. package/dist/vue-stack-tabs.es.js +1795 -1679
  40. package/dist/vue-stack-tabs.umd.js +1 -5
  41. package/package.json +76 -39
  42. package/src/lib/StackTabs.vue +222 -47
  43. package/src/lib/assets/image/open.svg +1 -0
  44. package/src/lib/assets/style/common/var.scss +1 -1
  45. package/src/lib/assets/style/icon.scss +6 -0
  46. package/src/lib/assets/style/stackTab.scss +44 -3
  47. package/src/lib/assets/style/transition.scss +63 -62
  48. package/src/lib/components/ContextMenu/ContextMenuItem.vue +7 -0
  49. package/src/lib/components/ContextMenu/index.vue +50 -38
  50. package/src/lib/components/PageLoading.vue +30 -10
  51. package/src/lib/components/TabHeader/TabHeaderButton.vue +12 -3
  52. package/src/lib/components/TabHeader/TabHeaderItem.vue +31 -27
  53. package/src/lib/components/TabHeader/TabHeaderScroll.vue +83 -77
  54. package/src/lib/components/TabHeader/index.vue +45 -40
  55. package/src/lib/hooks/tabPanel/evict.spec.ts +85 -0
  56. package/src/lib/hooks/tabPanel/evict.ts +57 -0
  57. package/src/lib/hooks/tabPanel/scroll.ts +52 -0
  58. package/src/lib/hooks/tabPanel/session.ts +33 -0
  59. package/src/lib/hooks/tabPanel/state.ts +52 -0
  60. package/src/lib/hooks/useContextMenu.ts +22 -20
  61. package/src/lib/hooks/useTabActions.ts +148 -0
  62. package/src/lib/hooks/useTabEventBus.ts +31 -0
  63. package/src/lib/hooks/useTabLoading.ts +12 -4
  64. package/src/lib/hooks/useTabPanel.spec.ts +34 -0
  65. package/src/lib/hooks/useTabPanel.tsx +546 -0
  66. package/src/lib/hooks/useTabRouter.ts +114 -103
  67. package/src/lib/i18n/index.ts +14 -18
  68. package/src/lib/i18n/lang/en.ts +2 -1
  69. package/src/lib/i18n/lang/zh-CN.ts +2 -1
  70. package/src/lib/iframe.vue +6 -0
  71. package/src/lib/index.ts +27 -8
  72. package/src/lib/model/TabModel.spec.ts +40 -0
  73. package/src/lib/model/TabModel.ts +22 -9
  74. package/src/lib/nuxt/module.d.ts +19 -0
  75. package/src/lib/nuxt/module.ts +34 -0
  76. package/src/lib/nuxt/runtime/plugin.client.ts +11 -0
  77. package/src/lib/utils/iframeBridge.spec.ts +37 -0
  78. package/src/lib/utils/iframeBridge.ts +52 -0
  79. package/src/lib/utils/{TabScrollHelper.ts → scrollUtils.ts} +23 -29
  80. package/src/lib/utils/tabInfoEncoder.spec.ts +42 -0
  81. package/src/lib/utils/tabInfoEncoder.ts +65 -0
  82. package/src/lib/utils/urlParser.spec.ts +45 -0
  83. package/src/lib/utils/urlParser.ts +42 -0
  84. package/src/lib/{banner.ts → versionLogger.ts} +4 -5
  85. package/dist/src/lib/banner.d.ts +0 -2
  86. package/dist/src/lib/hooks/useStackTab.d.ts +0 -11
  87. package/dist/src/lib/utils/TabIdHelper.d.ts +0 -11
  88. package/dist/src/lib/utils/TabScrollHelper.d.ts +0 -33
  89. package/dist/src/lib/utils/UriHelper.d.ts +0 -4
  90. package/src/lib/hooks/useStackTab.ts +0 -119
  91. package/src/lib/hooks/useTabMitt.ts +0 -30
  92. package/src/lib/hooks/useTabpanel.tsx +0 -708
  93. package/src/lib/utils/TabIdHelper.ts +0 -70
  94. package/src/lib/utils/UriHelper.ts +0 -22
@@ -2,30 +2,30 @@
2
2
  <div class="stack-tab__header">
3
3
  <slot name="leftButton" />
4
4
  <tab-header-scroll
5
- ref="scroll"
5
+ ref="scrollContainerRef"
6
6
  :space="space"
7
7
  :is-scroll-wheel="isScrollWheel"
8
8
  :is-scroll-button="isScrollButton"
9
9
  >
10
10
  <template #default>
11
11
  <transition-group
12
- key="tabItemTrans"
12
+ key="tab-list-transition"
13
13
  tag="ul"
14
14
  class="stack-tab__nav"
15
- v-bind="tabTrans"
15
+ v-bind="tabTransitionProps"
16
16
  appear
17
17
  >
18
18
  <tab-header-item
19
19
  v-for="(item, index) in tabs"
20
20
  :key="item.id"
21
21
  :contextmenu="contextmenu"
22
- :item="item"
22
+ :item="item as ITabItem"
23
23
  @contextmenu.prevent="
24
- (e: MouseEvent) => showContextMenu(e, item as ITabItem, index, tabs.length)
24
+ (e: MouseEvent) => showContextMenu(e, item as unknown as ITabItem, index, tabs.length)
25
25
  "
26
- @click.middle.prevent="closeTab(item as ITabItem)"
27
- @close="closeTab"
28
- @active="activeTab"
26
+ @click.middle.prevent="handleCloseTab(item as ITabItem)"
27
+ @close="handleCloseTab"
28
+ @active="handleActivateTab"
29
29
  />
30
30
  </transition-group>
31
31
  </template>
@@ -39,43 +39,52 @@
39
39
  <transition name="stack-tab-zoom" appear>
40
40
  <context-menu
41
41
  v-if="contextMenuShown"
42
- key="tabMenuTrans"
42
+ key="context-menu-transition"
43
43
  :left="contextMenuData.left"
44
44
  :top="contextMenuData.top"
45
45
  :tab-item="contextMenuData.item as ITabItem"
46
46
  :max="contextMenuData.max"
47
47
  :index="contextMenuData.index"
48
+ @close="handleCloseContextMenu"
48
49
  />
49
50
  </transition>
50
51
  </div>
51
52
  </template>
52
53
 
54
+ <!--
55
+ TabHeader - 标签栏头部组件
56
+
57
+ 职责:展示标签列表、左右滚动、最大化按钮、右键菜单
58
+ 依赖:useTabActions(tabs/closeTab/activeTab)、useContextMenu
59
+ -->
53
60
  <script lang="ts" setup>
54
- // @ts-nocheck
55
61
  import { inject, computed, ref, nextTick } from 'vue'
56
- import type { TransitionProps, Ref } from 'vue'
62
+ import type { TransitionProps } from 'vue'
57
63
  import { TabScrollMode } from '../../model/TabModel'
58
64
  import type { ITabItem } from '../../model/TabModel'
59
65
  import ContextMenu from '../ContextMenu/index.vue'
60
66
  import useContextMenu from '../../hooks/useContextMenu'
61
- import useTabpanel from '../../hooks/useTabpanel'
67
+ import useTabActions from '../../hooks/useTabActions'
62
68
  import TabHeaderItem from './TabHeaderItem.vue'
63
69
  import TabHeaderScroll from './TabHeaderScroll.vue'
64
70
  import TabHeaderButton from './TabHeaderButton.vue'
65
71
  import { useI18n } from 'vue-i18n-lite'
66
- // @ts-ignore
67
72
  const { t } = useI18n()
73
+ /** 是否最大化,由上层 provide */
68
74
  const maximum = inject<boolean>('maximum')
69
75
  const emit = defineEmits(['active'])
70
- const scroll = ref()
76
+ /** 滚动容器 ref,用于调用 scrollIntoView / isInView */
77
+ const scrollContainerRef = ref<InstanceType<typeof TabHeaderScroll>>()
71
78
  const { shown: contextMenuShown, contextMenuData, showContextMenu } = useContextMenu()
79
+ /** 关闭右键菜单 */
80
+ const handleCloseContextMenu = () => {
81
+ contextMenuShown.value = false
82
+ }
72
83
 
73
84
  const props = withDefaults(
74
85
  defineProps<{
75
86
  space: number
76
- // tab的滚动方式
77
87
  tabScrollMode?: TabScrollMode
78
- // tab动画
79
88
  tabTransition?: string | TransitionProps
80
89
  contextmenu?: boolean | object
81
90
  max?: number
@@ -91,46 +100,42 @@ const props = withDefaults(
91
100
  max: 20
92
101
  }
93
102
  )
94
- const { removeTab, active, tabs } = useTabpanel()
103
+ const { closeTab, activeTab, tabs } = useTabActions()
95
104
 
96
- // 转换tab动画
97
- const tabTrans = computed<TransitionProps>(() => {
98
- if (typeof props.tabTransition === 'string') {
99
- return {
100
- name: props.tabTransition
101
- } as TransitionProps
102
- }
103
- return props.tabTransition as TransitionProps
104
- })
105
- // 判断是否有滚动按钮
105
+ /** 标签列表 transition 的 props,支持 string 或对象 */
106
+ const tabTransitionProps = computed<TransitionProps>(() =>
107
+ typeof props.tabTransition === 'string'
108
+ ? ({ name: props.tabTransition } as TransitionProps)
109
+ : (props.tabTransition as TransitionProps)
110
+ )
111
+
112
+ /** 是否显示左右滚动按钮 */
106
113
  const isScrollButton = computed<boolean>(() => {
107
114
  return props.tabScrollMode === TabScrollMode.BOTH || props.tabScrollMode === TabScrollMode.BUTTON
108
115
  })
109
- // 判断是否可以使用滚轮
116
+ /** 是否支持滚轮滚动 */
110
117
  const isScrollWheel =
111
118
  props.tabScrollMode === TabScrollMode.BOTH || props.tabScrollMode === TabScrollMode.WHEEL
112
119
 
113
- // 关闭前,先判断是不最先中状态
114
- const closeTab = (item: ITabItem) => {
115
- // emit('close', item)
120
+ /** 关闭指定标签,并关闭右键菜单 */
121
+ const handleCloseTab = (item: ITabItem) => {
116
122
  if (item.closable) {
117
- const activeId = removeTab(item.id)
118
- // emit('active', activeId)
123
+ closeTab(item.id)
119
124
  }
120
125
  contextMenuShown.value = false
121
126
  }
122
127
 
123
- const activeTab = (item: ITabItem, curActiveTab: Ref<HTMLElement>, isRoute: boolean) => {
124
- if (scroll.value) {
128
+ /** 激活指定标签,滚动到可视区域,并触发路由跳转 */
129
+ const handleActivateTab = (item: ITabItem, clickedTabElement: HTMLElement | undefined, isRoute: boolean) => {
130
+ if (scrollContainerRef.value && clickedTabElement) {
125
131
  nextTick(() => {
126
- if (!scroll.value!.isInView(curActiveTab.value)) {
127
- scroll.value!.scrollIntoView(curActiveTab.value)
132
+ if (!scrollContainerRef.value!.isInView(clickedTabElement)) {
133
+ scrollContainerRef.value!.scrollIntoView(clickedTabElement)
128
134
  }
129
135
  })
130
-
131
- active(item.id, isRoute)
132
- emit('active', item.id)
133
136
  }
137
+ activeTab(item.id, isRoute)
138
+ emit('active', item.id)
134
139
  }
135
140
  </script>
136
141
 
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import {
3
+ markCacheForEviction,
4
+ markTabPagesForEviction,
5
+ markTabPagesForEvictionOnly,
6
+ addCache,
7
+ removeCache,
8
+ evictPageCache,
9
+ evictMarkedCaches
10
+ } from './evict'
11
+ import { caches, cacheIdsToEvict, tabIdsToEvict, components } from './state'
12
+ import { Stack } from '../../model/TabModel'
13
+ import type { ITabPage } from '../../model/TabModel'
14
+
15
+ function resetState() {
16
+ caches.value = []
17
+ cacheIdsToEvict.clear()
18
+ tabIdsToEvict.clear()
19
+ components.clear()
20
+ }
21
+
22
+ beforeEach(resetState)
23
+
24
+ describe('evict', () => {
25
+ it('addCache 添加缓存 id', () => {
26
+ addCache('c1')
27
+ expect(caches.value).toContain('c1')
28
+ addCache('c1')
29
+ expect(caches.value.filter((x) => x === 'c1')).toHaveLength(1)
30
+ })
31
+
32
+ it('removeCache 移除指定 id', () => {
33
+ addCache('c1')
34
+ removeCache('c1')
35
+ expect(caches.value).not.toContain('c1')
36
+ })
37
+
38
+ it('markCacheForEviction 标记待驱逐', () => {
39
+ markCacheForEviction('c1')
40
+ expect(cacheIdsToEvict.has('c1')).toBe(true)
41
+ })
42
+
43
+ it('markTabPagesForEviction 标记标签下所有页面并加入 tabIdsToEvict', () => {
44
+ const page: ITabPage = { id: 'p1', tabId: 't1', path: '/x' }
45
+ const stack = new Stack<ITabPage>()
46
+ stack.push(page)
47
+ const tab = { id: 't1', pages: { list: () => stack.list() } }
48
+ markTabPagesForEviction(tab)
49
+ expect(cacheIdsToEvict.has('p1')).toBe(true)
50
+ expect(tabIdsToEvict.has('t1')).toBe(true)
51
+ })
52
+
53
+ it('markTabPagesForEvictionOnly 只标记页面不标记 tab', () => {
54
+ const page: ITabPage = { id: 'p1', tabId: 't1', path: '/x' }
55
+ const stack = new Stack<ITabPage>()
56
+ stack.push(page)
57
+ const tab = { pages: { list: () => stack.list() } }
58
+ markTabPagesForEvictionOnly(tab)
59
+ expect(cacheIdsToEvict.has('p1')).toBe(true)
60
+ expect(tabIdsToEvict.size).toBe(0)
61
+ })
62
+
63
+ it('evictPageCache 立即驱逐单个缓存', () => {
64
+ addCache('c1')
65
+ components.set('c1', {} as never)
66
+ evictPageCache('c1')
67
+ expect(caches.value).not.toContain('c1')
68
+ expect(components.has('c1')).toBe(false)
69
+ expect(cacheIdsToEvict.has('c1')).toBe(false)
70
+ })
71
+
72
+ it('evictMarkedCaches 执行所有待驱逐', () => {
73
+ addCache('c1')
74
+ addCache('c2')
75
+ components.set('c1', {} as never)
76
+ components.set('c2', {} as never)
77
+ markCacheForEviction('c1')
78
+ markCacheForEviction('c2')
79
+ evictMarkedCaches()
80
+ expect(caches.value).not.toContain('c1')
81
+ expect(caches.value).not.toContain('c2')
82
+ expect(components.size).toBe(0)
83
+ expect(cacheIdsToEvict.size).toBe(0)
84
+ })
85
+ })
@@ -0,0 +1,57 @@
1
+ /**
2
+ * tabPanel/evict - 缓存驱逐逻辑
3
+ *
4
+ * 职责:标记待驱逐缓存、执行驱逐、维护 caches/components
5
+ */
6
+ import type { ITabPage } from '../../model/TabModel'
7
+ import {
8
+ caches,
9
+ components,
10
+ cacheIdsToEvict,
11
+ tabIdsToEvict
12
+ } from './state'
13
+
14
+ /** 标记缓存为待驱逐 */
15
+ export const markCacheForEviction = (cacheName: string) => {
16
+ cacheIdsToEvict.add(cacheName)
17
+ }
18
+
19
+ /** 标记标签下所有页面为可驱逐,并标记该标签(关闭场景) */
20
+ export const markTabPagesForEviction = (tab: { id: string; pages: { list(): ITabPage[] } }) => {
21
+ for (const page of tab.pages.list()) markCacheForEviction(page.id)
22
+ tabIdsToEvict.add(tab.id)
23
+ }
24
+
25
+ /** 仅标记标签下所有页面为可驱逐(续签场景) */
26
+ export const markTabPagesForEvictionOnly = (tab: { pages: { list(): ITabPage[] } }) => {
27
+ for (const page of tab.pages.list()) markCacheForEviction(page.id)
28
+ }
29
+
30
+ /** 将缓存 id 加入 caches */
31
+ export const addCache = (cacheName: string) => {
32
+ if (!caches.value.includes(cacheName)) caches.value.push(cacheName)
33
+ }
34
+
35
+ /** 从 caches 中移除指定 id */
36
+ export const removeCache = (cacheName: string) => {
37
+ const i = caches.value.indexOf(cacheName)
38
+ if (i >= 0) caches.value.splice(i, 1)
39
+ }
40
+
41
+ /** 立即驱逐单个页面缓存 */
42
+ export const evictPageCache = (cacheName: string) => {
43
+ if (!cacheName) return
44
+ cacheIdsToEvict.delete(cacheName)
45
+ removeCache(cacheName)
46
+ components.delete(cacheName)
47
+ }
48
+
49
+ /** 执行所有待驱逐缓存的驱逐 */
50
+ export const evictMarkedCaches = () => {
51
+ if (cacheIdsToEvict.size > 0) {
52
+ for (const cacheName of [...cacheIdsToEvict]) {
53
+ evictPageCache(cacheName)
54
+ }
55
+ cacheIdsToEvict.clear()
56
+ }
57
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * tabPanel/scroll - 滚动位置保存与恢复
3
+ *
4
+ * 职责:在切出/切入页面时保存和恢复滚动位置
5
+ */
6
+ import { scrollPositionsByPageId } from './state'
7
+
8
+ /** 恢复指定页面的滚动位置 */
9
+ export const restoreScroller = (pageCacheId: string) => {
10
+ const positions = scrollPositionsByPageId.get(pageCacheId)
11
+ if (!positions) return
12
+ for (const [sel, pos] of positions) {
13
+ const el = document.querySelector(sel) as HTMLElement | null
14
+ if (el) {
15
+ el.scrollTop = pos.top
16
+ el.scrollLeft = pos.left
17
+ }
18
+ }
19
+ }
20
+
21
+ /** 保存指定页面的滚动位置 */
22
+ export const saveScroller = (pageCacheId: string) => {
23
+ const positions = scrollPositionsByPageId.get(pageCacheId)
24
+ if (!positions) return
25
+ for (const sel of positions.keys()) {
26
+ const el = document.querySelector(sel) as HTMLElement | null
27
+ positions.set(sel, {
28
+ top: el?.scrollTop ?? 0,
29
+ left: el?.scrollLeft ?? 0
30
+ })
31
+ }
32
+ }
33
+
34
+ /** 移除指定页面的滚动记录 */
35
+ export const removeScroller = (pageCacheId: string) => {
36
+ const positions = scrollPositionsByPageId.get(pageCacheId)
37
+ if (positions) {
38
+ positions.clear()
39
+ scrollPositionsByPageId.delete(pageCacheId)
40
+ }
41
+ }
42
+
43
+ /** 为页面注册需要记录滚动位置的选择器 */
44
+ export const addPageScroller = (pageCacheId: string, ...selectorIds: string[]) => {
45
+ if (!scrollPositionsByPageId.has(pageCacheId)) {
46
+ scrollPositionsByPageId.set(pageCacheId, new Map<string, { top: number; left: number }>())
47
+ }
48
+ const positions = scrollPositionsByPageId.get(pageCacheId)!
49
+ for (const sel of selectorIds) {
50
+ positions.set(sel, { top: 0, left: 0 })
51
+ }
52
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * tabPanel/session - Session 持久化
3
+ *
4
+ * 职责:将当前激活标签保存到 sessionStorage,刷新后恢复
5
+ */
6
+ import type { ITabItem } from '../../model/TabModel'
7
+ import { Stack } from '../../model/TabModel'
8
+ import type { ITabPage } from '../../model/TabModel'
9
+ import { SESSION_TAB_NAME, sessionPrefix } from './state'
10
+
11
+ /** 获取 sessionStorage 的 key */
12
+ export const getSessionKey = () => sessionPrefix + SESSION_TAB_NAME
13
+
14
+ /** 将当前激活的标签保存到 sessionStorage */
15
+ export const saveActiveTabToSession = (tab: ITabItem) => {
16
+ if (!tab.id) return
17
+ window.sessionStorage.setItem(getSessionKey(), JSON.stringify(tab))
18
+ }
19
+
20
+ /** 清除 sessionStorage 中的激活标签 */
21
+ export const clearSession = () => {
22
+ window.sessionStorage.removeItem(getSessionKey())
23
+ }
24
+
25
+ /** 从 sessionStorage 解析恢复的标签(含 Stack 反序列化) */
26
+ export const restoreTabFromSession = (
27
+ storedJson: string | null
28
+ ): ITabItem | null => {
29
+ if (storedJson == null) return null
30
+ return JSON.parse(storedJson, (k, v) =>
31
+ k === 'pages' ? new Stack<ITabPage>(v) : v
32
+ ) as ITabItem
33
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * tabPanel/state - 标签页核心共享状态
3
+ *
4
+ * 职责:集中管理 tabs、caches、components、驱逐集合、滚动位置等
5
+ * 被 useTabPanel、evict、scroll、session 导入使用
6
+ */
7
+ import type { ITabItem } from '../../model/TabModel'
8
+ import { ref } from 'vue'
9
+
10
+ /** 当前打开的标签列表(含每个标签的页面栈) */
11
+ export const tabs = ref<ITabItem[]>([])
12
+ /** 初始化时传入的默认标签(用于对比 session 恢复) */
13
+ export const defaultTabs: ITabItem[] = []
14
+ /** keep-alive include 数组,与 Vue 的 keep-alive :include 绑定 */
15
+ export const caches = ref<string[]>([])
16
+ /** 页面缓存 ID -> 包装后的 DefineComponent */
17
+ export const components = new Map<string, import('vue').DefineComponent>()
18
+ /** 待驱逐的页面缓存 ID 集合 */
19
+ export const cacheIdsToEvict = new Set<string>()
20
+ /** 待驱逐的标签 ID 集合 */
21
+ export const tabIdsToEvict = new Set<string>()
22
+ /** 刷新计数器,变更时触发 component :key 变化 */
23
+ export const refreshKey = ref<number>(0)
24
+ /** 刷新时临时加入 keep-alive exclude 的缓存 ID */
25
+ export const excludedCacheIdsForRefresh = ref<string[]>([])
26
+ /** pageCacheId -> Map<selector, { top, left }> 滚动位置存储 */
27
+ export const scrollPositionsByPageId = new Map<string, Map<string, { top: number; left: number }>>()
28
+ /** 是否已完成初始化 */
29
+ export const isInitialized = ref<boolean>(false)
30
+
31
+ /** sessionStorage 中存储当前激活标签的 key 前缀 */
32
+ export const SESSION_TAB_NAME = 'stacktab-active-tab'
33
+
34
+ /** iframe 标签 tabId -> 刷新序号,变更时触发 iframe 重载 */
35
+ export const iframeRefreshKeys = ref<Record<string, number>>({})
36
+
37
+ /** 最大标签数量,0 表示不限制 */
38
+ export let maxTabCount = 0
39
+ /** 是否使用页面级滚动 */
40
+ export let useGlobalScroll = false
41
+ /** sessionStorage key 前缀 */
42
+ export let sessionPrefix = ''
43
+
44
+ export const setMaxTabCount = (n: number) => {
45
+ maxTabCount = n
46
+ }
47
+ export const setUseGlobalScroll = (v: boolean) => {
48
+ useGlobalScroll = v
49
+ }
50
+ export const setSessionPrefix = (p: string) => {
51
+ sessionPrefix = p
52
+ }
@@ -1,6 +1,14 @@
1
- import { onUnmounted, onMounted, ref, reactive, nextTick } from 'vue'
1
+ import { onUnmounted, ref, reactive, nextTick } from 'vue'
2
2
  import type { ITabItem } from '../model/TabModel'
3
+
4
+ /**
5
+ * useContextMenu - 标签右键菜单 Hook
6
+ *
7
+ * 职责:管理右键菜单的显示/隐藏、定位,点击菜单外区域关闭
8
+ * 使用:TabHeader 中右键标签时调用 showContextMenu
9
+ */
3
10
  export default () => {
11
+ /** 菜单展示所需数据:当前标签、索引、坐标、最大数量 */
4
12
  const contextMenuData = reactive({
5
13
  item: {},
6
14
  index: -1,
@@ -8,38 +16,32 @@ export default () => {
8
16
  top: 0,
9
17
  max: 0
10
18
  })
19
+ /** 菜单是否显示 */
11
20
  const shown = ref<boolean>(false)
12
21
 
13
- onMounted(() => {
14
- document.addEventListener('click', clickOutSide)
15
- })
22
+ /** 点击菜单外部时关闭菜单并移除监听 */
23
+ const handleClickOutside = (ev: MouseEvent) => {
24
+ const target = ev.target as Element
25
+ if (target.closest?.('.stack-tab__contextmenu')) return
26
+ shown.value = false
27
+ document.removeEventListener('click', handleClickOutside)
28
+ }
29
+
16
30
  onUnmounted(() => {
17
- document.removeEventListener('click', clickOutSide)
31
+ document.removeEventListener('click', handleClickOutside)
18
32
  })
19
33
 
34
+ /** 显示右键菜单;nextTick 后设置位置与数据,避免与关闭逻辑冲突 */
20
35
  const showContextMenu = async (e: MouseEvent, item: ITabItem, index: number, max: number) => {
21
- // 关闭已打开的菜单
22
36
  shown.value = false
37
+ document.removeEventListener('click', handleClickOutside)
23
38
 
24
39
  await nextTick(() => {
25
40
  const { clientY: top, clientX: left } = e
26
41
  shown.value = true
27
42
  Object.assign(contextMenuData, { item, index, top, left, max })
28
-
29
- console.log('showContextMenu in callback')
43
+ nextTick(() => document.addEventListener('click', handleClickOutside))
30
44
  })
31
-
32
- // .then(() => {
33
-
34
- // })
35
- }
36
-
37
- const clickOutSide = (ev: MouseEvent) => {
38
- const classList = (ev.target as Element).classList
39
- if (classList.contains('stack-tab__contextmenu')) {
40
- return
41
- }
42
- shown.value = false
43
45
  }
44
46
 
45
47
  return {
@@ -0,0 +1,148 @@
1
+ import { useRouter } from 'vue-router'
2
+ import { throttle } from 'throttle-debounce'
3
+ import { defu } from 'defu'
4
+ import { ulid } from 'ulid'
5
+
6
+ import useTabPanel from './useTabPanel'
7
+ import { encodeTabInfo } from '../utils/tabInfoEncoder'
8
+ import { parseUrl, isCrossOriginUrl } from '../utils/urlParser'
9
+ import type { ITabData, IframeRefreshMode } from '../model/TabModel'
10
+ import { TabEventType, useTabEmitter } from './useTabEventBus'
11
+
12
+ /** iframe 占位路由的 path,由 StackTabs 通过 setIFramePath 注入 */
13
+ let iframePath: string
14
+
15
+ /**
16
+ * useTabActions - 标签操作对外 Hook
17
+ *
18
+ * 职责:提供打开、关闭、刷新标签等用户级 API,内部委托 useTabPanel 执行
19
+ * 使用范围:业务代码、ContextMenu、TabHeader 等
20
+ *
21
+ * 主要 API:
22
+ * - openTab(tab, renew?) 打开或续签标签
23
+ * - closeTab(id) / closeAllTabs() 关闭标签
24
+ * - refreshTab(id) / refreshAllTabs() 刷新
25
+ * - activeTab(id, isRoute?) 激活标签
26
+ * - reset() 重置(关闭所有)
27
+ */
28
+ export default function useTabActions() {
29
+ const router = useRouter()
30
+ const emitter = useTabEmitter()
31
+ const {
32
+ active,
33
+ hasTab,
34
+ refreshKey,
35
+ reset,
36
+ canAddTab,
37
+ renewTab,
38
+ getTab,
39
+ tabs,
40
+ removeTab,
41
+ removeAllTabs,
42
+ refreshTab,
43
+ refreshAllTabs
44
+ } = useTabPanel()
45
+
46
+ /** 合并默认值,生成完整 tabInfo */
47
+ const prepareTabInfo = (tab: ITabData): ITabData => {
48
+ const defaults: Partial<ITabData> = {
49
+ refreshable: true,
50
+ closable: true,
51
+ iframe: false
52
+ }
53
+ if (tab.iframe && tab.iframeRefreshMode === undefined) {
54
+ defaults.iframeRefreshMode = isCrossOriginUrl(tab.path ?? '')
55
+ ? ('reload' as IframeRefreshMode)
56
+ : ('postMessage' as IframeRefreshMode)
57
+ } else if (tab.iframe) {
58
+ defaults.iframeRefreshMode = (tab.iframeRefreshMode ?? 'postMessage') as IframeRefreshMode
59
+ }
60
+ const info = defu(tab, defaults)
61
+ if (!info.id) info.id = ulid()
62
+ return info
63
+ }
64
+
65
+ /** 打开新标签;renew=true 时若已存在则清空该标签的页面栈后重新打开;500ms 节流 */
66
+ const openTab = throttle(500, (tab: ITabData, renew = false) => {
67
+ return new Promise<string | undefined>((resolve, reject) => {
68
+ const tabInfo = prepareTabInfo(tab)
69
+
70
+ if (hasTab(tabInfo.id!) && !renew) {
71
+ emitter.emit(TabEventType.TAB_ACTIVE, { id: tabInfo.id! })
72
+ resolve(tabInfo.id)
73
+ return
74
+ }
75
+
76
+ if (tabInfo.id && renew && hasTab(tabInfo.id)) {
77
+ renewTab(tab)
78
+ const currentTab = getTab(tab.id!)
79
+ if (currentTab?.active) refreshKey.value++
80
+ }
81
+
82
+ if (!canAddTab()) {
83
+ reject(new Error('Max Size'))
84
+ return
85
+ }
86
+
87
+ navigateToTab(tab, tabInfo)
88
+ resolve(tabInfo.id)
89
+ })
90
+ })
91
+
92
+ /** 执行路由跳转到标签对应页面(普通路由或 iframe 占位路由) */
93
+ const navigateToTab = (tab: ITabData, tabInfo: ITabData) => {
94
+ const __tab = encodeTabInfo(tabInfo)
95
+ let query = defu({ __tab }, tab.query) as Record<string, string>
96
+ let path: string
97
+ if (!tab.iframe) {
98
+ const url = parseUrl(tab.path)
99
+ path = url.path
100
+ query = defu(url.query, query)
101
+ } else {
102
+ query['__src'] = encodeURIComponent(tab.path as string)
103
+ path = iframePath
104
+ }
105
+ router.push({ path, query })
106
+ }
107
+
108
+ /** 设置 iframe 占位路由的 path,由 StackTabs 在挂载时调用 */
109
+ const setIFramePath = (path: string) => {
110
+ iframePath = path
111
+ }
112
+
113
+ /** 获取当前页面的缓存容器 DOM(用于某些需要直接操作 DOM 的场景) */
114
+ const getWrapper = () => {
115
+ return document.getElementsByClassName('cache-page-wrapper')[0]
116
+ }
117
+
118
+ /** iframe 标签在新窗口打开(无法嵌入时降级) */
119
+ const openInNewWindow = (id: string) => {
120
+ const tab = getTab(id)
121
+ if (tab?.iframe && tab.url) {
122
+ window.open(tab.url, '_blank', 'noopener,noreferrer')
123
+ }
124
+ }
125
+
126
+ return {
127
+ // 打开
128
+ openTab,
129
+
130
+ // 关闭
131
+ closeTab: removeTab,
132
+ closeAllTabs: removeAllTabs,
133
+
134
+ // 刷新
135
+ refreshTab,
136
+ refreshAllTabs,
137
+
138
+ // 激活与重置
139
+ activeTab: active,
140
+ reset,
141
+
142
+ // 数据与配置
143
+ tabs,
144
+ setIFramePath,
145
+ getWrapper,
146
+ openInNewWindow
147
+ }
148
+ }