vue-stack-tabs 0.1.51 → 1.0.1

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 +46 -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
@@ -1,103 +1,114 @@
1
- import { defu } from 'defu'
2
-
3
- import { getCurrentInstance } from 'vue'
4
- import type { ComponentInternalInstance } from 'vue'
5
- import { type RouteLocationPathRaw, useRoute, useRouter } from 'vue-router'
6
-
7
- import useTabpanel from '@/lib/hooks/useTabpanel'
8
- import { uriDecode } from '@/lib/utils/UriHelper'
9
- import { useEmitter } from '@/lib/hooks/useTabMitt'
10
- export default () => {
11
- const { attrs, props } = getCurrentInstance() as ComponentInternalInstance
12
- const { pageShown, getTab, markDeletableCache, removeComponent, getComponent, addPageScroller } =
13
- useTabpanel()
14
- const route = useRoute()
15
- const router = useRouter()
16
- const emitter = useEmitter()
17
- let currentId = ''
18
- // const currentPageId = ''
19
- let currentTab = ''
20
- currentId = (attrs.tId || props.tId) as string
21
- const currentPageId = (attrs.pId || props.pId) as string
22
- // currentPageId = attrs.pId as string
23
- currentTab = route.query.__tab as string
24
-
25
- const addScroller = (...scroller: string[]) => {
26
- addPageScroller(currentPageId, ...scroller)
27
- }
28
- const forward = (to: RouteLocationPathRaw) => {
29
- // pageShown.value = false
30
- const query = defu(
31
- {
32
- __tab: currentTab
33
- },
34
- to.query
35
- )
36
- emitter.emit('FORWARD')
37
- router.push({
38
- path: to.path,
39
- query
40
- })
41
- }
42
- /**
43
- * 返回
44
- * @param to url or step
45
- * @param query 返回时要加截的数据
46
- */
47
- const backward = (to: number | string, query?: Object) => {
48
- // pageShown.value = false
49
-
50
- const tab = getTab(currentId)
51
- if (!tab) {
52
- return
53
- }
54
- const tabStack = tab.pages
55
- let absTo = 0
56
- if (typeof to === 'number') {
57
- absTo = Math.abs(to)
58
- } else {
59
- let index = 1
60
- let hasPath = false
61
- const pageItems = tabStack.list()
62
- const url = uriDecode(to)
63
- query = defu(url.query, query)
64
- for (let i = tabStack.size() - 2; i >= 0; i--) {
65
- if (pageItems[i].path === url.path) {
66
- hasPath = true
67
- break
68
- }
69
- index += 1
70
- }
71
- if (hasPath) {
72
- absTo = index
73
- }
74
- }
75
-
76
- if (absTo === 0 || tabStack!.size() <= absTo) {
77
- return
78
- }
79
- // const removeCaches = new Set()
80
- for (let i = 0; i < absTo; i++) {
81
- const popRoute = tabStack.pop()
82
- // removeComponent(popRoute!.id)
83
- markDeletableCache(popRoute!.id)
84
- }
85
- const pageRoute = tabStack?.peek()
86
- const component = getComponent(pageRoute!.id)
87
- if (component !== null && component !== undefined) {
88
- const sourceComponent = component.components.DynamicComponent
89
- sourceComponent.props = defu(query, sourceComponent.props)
90
- }
91
- emitter.emit('BACKWARD')
92
- router.push({
93
- path: pageRoute!.path,
94
- query: pageRoute!.query
95
- })
96
- }
97
-
98
- return {
99
- forward,
100
- backward,
101
- addScroller
102
- }
103
- }
1
+ import { defu } from 'defu'
2
+ import { getCurrentInstance } from 'vue'
3
+ import type { ComponentInternalInstance } from 'vue'
4
+ import type { RouteLocationPathRaw } from 'vue-router'
5
+ import { useRoute, useRouter } from 'vue-router'
6
+
7
+ import useTabPanel from './useTabPanel'
8
+ import { parseUrl } from '../utils/urlParser'
9
+ import { useTabEmitter } from './useTabEventBus'
10
+
11
+ /** StackTabs 监听此事件切换前进动画 */
12
+ const ROUTER_EVENT_FORWARD = 'FORWARD'
13
+ /** StackTabs 监听此事件切换后退动画 */
14
+ const ROUTER_EVENT_BACKWARD = 'BACKWARD'
15
+
16
+ /**
17
+ * useTabRouter - 标签内路由 Hook
18
+ *
19
+ * 职责:在单个标签内实现「前进/后退」栈式导航,以及滚动位置的记录与恢复
20
+ * 使用范围:仅在标签内的页面组件中使用,依赖 router-view 传入的 tId、pId
21
+ *
22
+ * 主要 API:
23
+ * - forward(to) 前进:push 新页面到该标签的 pages 栈
24
+ * - backward(to, backQuery?) 后退:pop 栈顶、markCacheForEviction,跳转到新栈顶
25
+ * - addScrollTarget(...selectors) 注册需要记录滚动位置的选择器
26
+ */
27
+ export default function useTabRouter() {
28
+ const instance = getCurrentInstance() as ComponentInternalInstance
29
+ const { attrs, props } = instance
30
+ /** 当前标签 id,由 cache 组件的 dynamic-component 传入 */
31
+ const tabId = (attrs.tId ?? props.tId) as string
32
+ /** 当前页面缓存 id */
33
+ const pageId = (attrs.pId ?? props.pId) as string
34
+
35
+ const route = useRoute()
36
+ const router = useRouter()
37
+ const emitter = useTabEmitter()
38
+ const { getTab, markCacheForEviction, getComponent, addPageScroller } = useTabPanel()
39
+
40
+ /** 当前标签的编码信息,用于 push 时保持 __tab */
41
+ const tabInfo = route.query.__tab as string
42
+
43
+ /** 注册需记录滚动位置的容器选择器 */
44
+ const addScrollTarget = (...selectors: string[]) => {
45
+ addPageScroller(pageId, ...selectors)
46
+ }
47
+
48
+ /** 前进:进栈,跳转到新页面 */
49
+ const forward = (to: RouteLocationPathRaw) => {
50
+ const query = defu({ __tab: tabInfo }, to.query)
51
+ emitter.emit(ROUTER_EVENT_FORWARD)
52
+ router.push({ path: to.path, query })
53
+ }
54
+
55
+ /**
56
+ * 后退:从该标签的 pages 栈中 pop,并跳转到新栈顶
57
+ * @param to 数字=退几步,字符串=回退到首个路径匹配的页面
58
+ * @param backQuery 回退后传给目标页面的 props 数据(目标页通过 props 接收)
59
+ */
60
+ const backward = (to: number | string, backQuery?: object) => {
61
+ const tab = getTab(tabId)
62
+ if (!tab) return
63
+
64
+ const stack = tab.pages
65
+ let steps = 0
66
+
67
+ if (typeof to === 'number') {
68
+ steps = Math.abs(to)
69
+ } else {
70
+ const parsed = parseUrl(to)
71
+ backQuery = defu(parsed.query, backQuery)
72
+ const pages = stack.list()
73
+ let found = false
74
+ for (let i = stack.size() - 2; i >= 0; i--) {
75
+ steps++
76
+ if (pages[i]?.path === parsed.path) {
77
+ found = true
78
+ break
79
+ }
80
+ }
81
+ if (!found) return
82
+ }
83
+
84
+ if (steps <= 0 || stack.size() <= steps) return
85
+
86
+ for (let i = 0; i < steps; i++) {
87
+ const popped = stack.pop()
88
+ if (popped) markCacheForEviction(popped.id)
89
+ }
90
+
91
+ const target = stack.peek()
92
+ if (!target) return
93
+
94
+ const component = getComponent(target.id)
95
+ if (component && backQuery) {
96
+ const comp = component as {
97
+ components?: { DynamicComponent?: { props?: object } }
98
+ }
99
+ const dynamic = comp.components?.DynamicComponent
100
+ if (dynamic?.props) {
101
+ dynamic.props = defu(backQuery, dynamic.props)
102
+ }
103
+ }
104
+
105
+ emitter.emit(ROUTER_EVENT_BACKWARD)
106
+ router.push({ path: target.path, query: target.query })
107
+ }
108
+
109
+ return {
110
+ forward,
111
+ backward,
112
+ addScrollTarget
113
+ }
114
+ }
@@ -9,20 +9,16 @@ export default () => {
9
9
  * 获取所有语言文件
10
10
  * @param {Object} mList
11
11
  */
12
- function getLangFiles(mList: any, msg: any) {
12
+ function getLangFiles(
13
+ mList: Record<string, { default?: Record<string, unknown> }>,
14
+ msg: Record<string, Record<string, unknown>>
15
+ ) {
13
16
  for (const path in mList) {
14
- if (mList[path].default) {
15
- // 获取文件名
17
+ const mod = mList[path] as { default?: Record<string, unknown> }
18
+ if (mod?.default) {
16
19
  const pathName = path.substring(path.lastIndexOf('/') + 1, path.lastIndexOf('.'))
17
-
18
- if (msg[pathName]) {
19
- msg[pathName] = {
20
- ...mList[pathName],
21
- ...mList[path].default
22
- }
23
- } else {
24
- msg[pathName] = mList[path].default
25
- }
20
+ const existing = msg[pathName] as Record<string, unknown> | undefined
21
+ msg[pathName] = existing ? { ...existing, ...mod.default } : mod.default
26
22
  }
27
23
  }
28
24
  }
@@ -52,16 +48,16 @@ export default () => {
52
48
  // 那么你就可以 使用 :lower:(小写) :upper:(大写) 来引入文件
53
49
  // const viewModules = import.meta.globEager('../views/**/locales/[[:lower:]][[:lower:]]-[[:upper:]][[:upper:]].ts')
54
50
 
55
- const allLangs: any = () => {
56
- const message: any = {}
57
- getLangFiles(modules, message)
51
+ const allLangs = (): Record<string, Record<string, unknown>> => {
52
+ const message: Record<string, Record<string, unknown>> = {}
53
+ getLangFiles(modules as Record<string, { default?: Record<string, unknown> }>, message)
58
54
  return message
59
55
  }
60
56
  // const localeI18n = inject('locales') as { locale: string; messages: object }
61
- const getI18n = (localeI18n?: { locale: string; messages: any }[]) => {
57
+ const getI18n = (localeI18n?: { locale: string; messages: Record<string, unknown> }[]) => {
62
58
  const combinateMessage = { ...allLangs() }
63
59
  if (localeI18n) {
64
- for (const l of localeI18n!) {
60
+ for (const l of localeI18n) {
65
61
  combinateMessage[l.locale] = l.messages
66
62
  }
67
63
  // combinateMessage = { ...allLangs(), ...localeI18n.messages }
@@ -70,7 +66,7 @@ export default () => {
70
66
  return createI18n({
71
67
  locale: 'zh-CN',
72
68
  fallbackLocale: 'en',
73
- messages: combinateMessage
69
+ messages: combinateMessage as Record<string, Record<string, string>>
74
70
  })
75
71
  // const combinateMessage = { ...allLangs(), ...localeI18n.messages }
76
72
  // return useI18n({
@@ -10,6 +10,7 @@ export default {
10
10
  maximum: 'maximum',
11
11
  restore: 'restore',
12
12
  undefined: 'undefined',
13
- loading: 'loading'
13
+ loading: 'loading',
14
+ openInNewWindow: 'Open in new window'
14
15
  }
15
16
  }
@@ -10,6 +10,7 @@ export default {
10
10
  maximum: '最大化',
11
11
  restore: '还原',
12
12
  undefined: '未命名',
13
- loading: '加载中'
13
+ loading: '加载中',
14
+ openInNewWindow: '在新窗口打开'
14
15
  }
15
16
  }
@@ -1,3 +1,9 @@
1
+ <!--
2
+ IFrame - iframe 占位组件
3
+
4
+ 职责:路由 /iframe 的占位页,仅挂载 useTabRouter 以注入标签内路由能力
5
+ 说明:实际 iframe 内容由 StackTabs 在 iframeTabs 中渲染
6
+ -->
1
7
  <script setup lang="ts">
2
8
  import useTabRouter from './hooks/useTabRouter'
3
9
  useTabRouter()
package/src/lib/index.ts CHANGED
@@ -1,14 +1,19 @@
1
+ /**
2
+ * vue-stack-tabs - 入口与导出
3
+ *
4
+ * 导出:StackTab(组件)、useTabActions、useTabRouter、useTabLoading、IFrame、TabHeaderButton
5
+ * 安装:Vue.use(plugin) 注册全局组件 VueStackTabs、事件总线、i18n
6
+ */
1
7
  import type { App } from 'vue'
2
- // import { createI18n } from 'vue-i18n'
3
8
  import i18n from './i18n'
4
9
  import StackTab from './StackTabs.vue'
5
- import tabVersion from './banner'
10
+ import logVersion from './versionLogger'
6
11
  import IFrame from './iframe.vue'
7
12
  import useTabLoading from './hooks/useTabLoading'
8
13
  import useTabRouter from './hooks/useTabRouter'
9
- import useStackTab from './hooks/useStackTab'
14
+ import useTabActions from './hooks/useTabActions'
10
15
  import TabHeaderButton from '@/lib/components/TabHeader/TabHeaderButton.vue'
11
- import useTabMitt from '@/lib/hooks/useTabMitt'
16
+ import useTabEventBus from '@/lib/hooks/useTabEventBus'
12
17
  import './assets/style/index.scss'
13
18
  //
14
19
  // const i18n = createI18n({
@@ -25,10 +30,24 @@ declare module '@vue/runtime-core' {
25
30
  }
26
31
  }
27
32
 
28
- export { IFrame, useTabLoading, useTabRouter, useStackTab, TabHeaderButton, StackTab }
33
+ export {
34
+ IFrame,
35
+ useTabActions,
36
+ useTabLoading,
37
+ useTabRouter,
38
+ TabHeaderButton,
39
+ StackTab
40
+ }
41
+ export {
42
+ postOpenTab,
43
+ onRefreshRequest,
44
+ MSG_REFRESH,
45
+ MSG_OPEN_TAB
46
+ } from './utils/iframeBridge'
47
+ export type { IframeOpenTabPayload } from './utils/iframeBridge'
29
48
  export default {
30
- install(Vue: App, options?: { locale: string; messages: any }[]): void {
31
- tabVersion(import.meta.env.PACKAGE_VERSION)
32
- Vue.component('VueStackTabs', StackTab).use(useTabMitt).use(i18n().getI18n(options))
49
+ install(Vue: App, options?: { locale: string; messages: Record<string, unknown> }[]): void {
50
+ logVersion(import.meta.env.PACKAGE_VERSION)
51
+ Vue.component('VueStackTabs', StackTab).use(useTabEventBus).use(i18n().getI18n(options))
33
52
  }
34
53
  }
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { Stack } from './TabModel'
3
+
4
+ describe('Stack', () => {
5
+ it('push 与 peek', () => {
6
+ const s = new Stack<number>()
7
+ s.push(1)
8
+ expect(s.peek()).toBe(1)
9
+ s.push(2)
10
+ expect(s.peek()).toBe(2)
11
+ })
12
+
13
+ it('pop 返回栈顶并移除', () => {
14
+ const s = new Stack<string>(['a', 'b'])
15
+ expect(s.pop()).toBe('b')
16
+ expect(s.pop()).toBe('a')
17
+ expect(s.pop()).toBeUndefined()
18
+ })
19
+
20
+ it('size 与 isEmpty', () => {
21
+ const s = new Stack<number>()
22
+ expect(s.isEmpty()).toBe(true)
23
+ expect(s.size()).toBe(0)
24
+ s.push(1)
25
+ expect(s.isEmpty()).toBe(false)
26
+ expect(s.size()).toBe(1)
27
+ })
28
+
29
+ it('list 返回元素数组', () => {
30
+ const s = new Stack<number>([1, 2, 3])
31
+ expect(s.list()).toEqual([1, 2, 3])
32
+ })
33
+
34
+ it('clear 清空栈', () => {
35
+ const s = new Stack<number>([1, 2])
36
+ s.clear()
37
+ expect(s.isEmpty()).toBe(true)
38
+ expect(s.peek()).toBeUndefined()
39
+ })
40
+ })
@@ -1,4 +1,11 @@
1
- // Tab的滚动方式
1
+ /**
2
+ * TabModel - 标签页数据模型
3
+ *
4
+ * 职责:定义标签、页面、栈结构及右键菜单的数据结构
5
+ * 使用:useTabPanel、useTabActions、ContextMenu 等
6
+ */
7
+
8
+ /** 标签栏滚动方式:滚轮、按钮、或两者 */
2
9
  export enum TabScrollMode {
3
10
  // 使用滚轮
4
11
  WHEEL = 'wheel',
@@ -19,56 +26,63 @@ export enum TabScrollMode {
19
26
  // TAB_REFRESH = 'TAB_REFRESH',
20
27
  // TAB_REFRESH_ALL = 'TAB_REFRESH_ALL'
21
28
  // }
22
- // 拖拽数据接口
29
+ /** 标签栏滚动条拖拽时的临时状态 */
23
30
  export interface DragData {
24
31
  thumbLeft: number
25
32
  startScrollLeft: number
26
33
  startThumbLeft: number
27
34
  startPageX: number
28
35
  }
29
- // 滑块数据接口
36
+ /** 滚动条滑块计算所需的数据 */
30
37
  export interface ScrollData {
31
38
  clientWidth: number
32
39
  scrollWidth: number
33
40
  scrollLeft: number
34
41
  }
35
- // 滚动条事件类型
36
42
  export type ScrollEvents = {
37
43
  ScrollUpdate: void
38
44
  }
39
45
 
40
- /**
41
- * Tab data for open
42
- */
46
+ /** 打开标签时传入的完整数据(含 path、query) */
43
47
  export interface ITabData extends ITabBase {
44
48
  // tab name
45
49
  title: string
46
50
  path: string
47
51
  query?: Record<string, string>
48
52
  }
53
+ /** iframe 刷新方式:postMessage 由 iframe 内页自行刷新(不重建 DOM,动画正常);reload 强制重载 iframe */
54
+ export type IframeRefreshMode = 'postMessage' | 'reload'
55
+
56
+ /** 标签基础信息(id、title、是否可关闭/刷新、是否 iframe) */
49
57
  export interface ITabBase {
50
58
  id?: string
51
59
  title: string
52
60
  closable?: boolean
53
61
  refreshable?: boolean
54
62
  iframe?: boolean
63
+ /** iframe 刷新方式,默认 postMessage */
64
+ iframeRefreshMode?: IframeRefreshMode
55
65
  }
66
+ /** 内部使用的标签项,含 active 状态和 pages 栈 */
56
67
  export interface ITabItem extends ITabBase {
57
68
  id: string
58
69
  closable: boolean
59
70
  refreshable: boolean
60
71
  iframe: boolean
72
+ iframeRefreshMode?: IframeRefreshMode
61
73
  url?: string
62
74
  active: boolean
63
75
  pages: Stack<ITabPage>
64
76
  }
65
77
 
78
+ /** 标签内的单个页面(对应 keep-alive 的一个缓存实例) */
66
79
  export interface ITabPage {
67
80
  id: string
68
81
  tabId: string
69
82
  path: string
70
83
  query?: Record<string, string>
71
84
  }
85
+ /** 右键菜单项:图标、标题、回调、禁用条件 */
72
86
  export interface IContextMenu {
73
87
  icon?: string
74
88
  title: string
@@ -84,11 +98,10 @@ export interface IContextMenu {
84
98
  // path: string
85
99
  // query: any
86
100
  // }
101
+ /** 使用 Map 实现的栈结构,用于标签内的 pages 页面栈 */
87
102
  export class Stack<T> {
88
- // 存储的Map
89
103
  private items: Map<number, T>
90
104
 
91
- //
92
105
  constructor(items?: T[]) {
93
106
  this.items = new Map()
94
107
  if (items) {
@@ -0,0 +1,19 @@
1
+ import type { ModuleOptions } from './module'
2
+
3
+ declare module 'nuxt/schema' {
4
+ interface PublicRuntimeConfig {
5
+ vueStackTabs: {
6
+ locale: string
7
+ }
8
+ }
9
+ }
10
+
11
+ declare module '@nuxt/schema' {
12
+ interface PublicRuntimeConfig {
13
+ vueStackTabs: {
14
+ locale: string
15
+ }
16
+ }
17
+ }
18
+
19
+ export type { ModuleOptions }
@@ -0,0 +1,34 @@
1
+ import { addPlugin, addImports, createResolver, defineNuxtModule } from '@nuxt/kit'
2
+
3
+ export interface ModuleOptions {
4
+ /** 默认语言 */
5
+ locale?: string
6
+ }
7
+
8
+ export default defineNuxtModule<ModuleOptions>({
9
+ meta: {
10
+ name: 'vue-stack-tabs',
11
+ configKey: 'vueStackTabs'
12
+ },
13
+ defaults: {
14
+ locale: 'zh-CN'
15
+ },
16
+ setup(options, nuxt) {
17
+ const { resolve } = createResolver(import.meta.url)
18
+
19
+ nuxt.options.runtimeConfig.public.vueStackTabs = {
20
+ locale: options.locale
21
+ }
22
+
23
+ addPlugin({
24
+ src: resolve('./runtime/plugin.client'),
25
+ mode: 'client'
26
+ })
27
+
28
+ addImports([
29
+ { name: 'useTabActions', from: 'vue-stack-tabs' },
30
+ { name: 'useTabRouter', from: 'vue-stack-tabs' },
31
+ { name: 'useTabLoading', from: 'vue-stack-tabs' }
32
+ ])
33
+ }
34
+ })
@@ -0,0 +1,11 @@
1
+ import VueStackTabs from 'vue-stack-tabs'
2
+
3
+ export default defineNuxtPlugin((nuxtApp) => {
4
+ const config = useRuntimeConfig().public.vueStackTabs as {
5
+ locale?: string
6
+ }
7
+
8
+ nuxtApp.vueApp.use(VueStackTabs, {
9
+ locale: config?.locale ?? 'zh-CN'
10
+ })
11
+ })
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { describe, it, expect, vi } from 'vitest'
5
+ import { MSG_REFRESH, MSG_OPEN_TAB, postOpenTab, onRefreshRequest } from './iframeBridge'
6
+
7
+ describe('iframeBridge', () => {
8
+ it('MSG_REFRESH 和 MSG_OPEN_TAB 为预期常量', () => {
9
+ expect(MSG_REFRESH).toBe('vue-stack-tabs:refresh')
10
+ expect(MSG_OPEN_TAB).toBe('vue-stack-tabs:openTab')
11
+ })
12
+
13
+ describe('postOpenTab', () => {
14
+ it('有 window.parent 时调用 postMessage', () => {
15
+ const spy = vi.spyOn(window.parent, 'postMessage')
16
+ postOpenTab({ title: 't', path: '/p' })
17
+ expect(spy).toHaveBeenCalledWith(
18
+ { type: MSG_OPEN_TAB, payload: { title: 't', path: '/p' } },
19
+ '*'
20
+ )
21
+ spy.mockRestore()
22
+ })
23
+ })
24
+
25
+ describe('onRefreshRequest', () => {
26
+ it('注册监听并返回取消函数', () => {
27
+ const cb = vi.fn()
28
+ const off = onRefreshRequest(cb)
29
+ expect(typeof off).toBe('function')
30
+ window.dispatchEvent(new MessageEvent('message', { data: { type: MSG_REFRESH } }))
31
+ expect(cb).toHaveBeenCalled()
32
+ off()
33
+ window.dispatchEvent(new MessageEvent('message', { data: { type: MSG_REFRESH } }))
34
+ expect(cb).toHaveBeenCalledTimes(1)
35
+ })
36
+ })
37
+ })
@@ -0,0 +1,52 @@
1
+ /**
2
+ * iframe 与父窗口的 postMessage 桥接
3
+ *
4
+ * 在 iframe 内的页面中引入,用于:
5
+ * - 接收父窗口的刷新指令并执行
6
+ * - 向父窗口发送打开新标签的请求
7
+ */
8
+
9
+ /** 刷新消息:父 -> iframe,iframe 内页面需监听并执行 location.reload() 或自身刷新逻辑 */
10
+ export const MSG_REFRESH = 'vue-stack-tabs:refresh'
11
+
12
+ /** 打开标签消息:iframe -> 父,payload 为 ITabData */
13
+ export const MSG_OPEN_TAB = 'vue-stack-tabs:openTab'
14
+
15
+ export interface IframeOpenTabPayload {
16
+ id?: string
17
+ title: string
18
+ path: string
19
+ query?: Record<string, string>
20
+ closable?: boolean
21
+ refreshable?: boolean
22
+ iframe?: boolean
23
+ }
24
+
25
+ /**
26
+ * 在 iframe 内调用:向父窗口请求打开新标签
27
+ * @param payload 标签数据,至少需 title、path
28
+ */
29
+ export function postOpenTab(payload: IframeOpenTabPayload) {
30
+ if (typeof window === 'undefined' || !window.parent) return
31
+ window.parent.postMessage(
32
+ { type: MSG_OPEN_TAB, payload },
33
+ '*' // 父窗口可校验 origin,此处用 * 便于跨子域
34
+ )
35
+ }
36
+
37
+ /**
38
+ * 在 iframe 内调用:注册刷新监听,收到父窗口刷新指令时执行 callback
39
+ * @param callback 默认 location.reload(),可自定义
40
+ * @returns 取消监听的函数
41
+ */
42
+ export function onRefreshRequest(callback: () => void = () => window.location.reload()) {
43
+ if (typeof window === 'undefined') return () => {}
44
+
45
+ const handler = (ev: MessageEvent) => {
46
+ if (ev.data?.type === MSG_REFRESH) {
47
+ callback()
48
+ }
49
+ }
50
+ window.addEventListener('message', handler)
51
+ return () => window.removeEventListener('message', handler)
52
+ }