vue-stack-tabs 1.1.0 → 1.3.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 (115) hide show
  1. package/README.en.md +526 -0
  2. package/README.md +141 -39
  3. package/README.ru.md +526 -0
  4. package/dist/iframe-bridge.d.ts +8 -0
  5. package/dist/iframe-bridge.html +77 -0
  6. package/dist/iframe-bridge.mjs +20 -0
  7. package/dist/iframe-child.html +19 -6
  8. package/dist/nuxt/favicon.svg +10 -0
  9. package/dist/nuxt/iframe-bridge.html +77 -0
  10. package/dist/nuxt/iframe-child.html +88 -0
  11. package/dist/nuxt/module.d.ts +6 -0
  12. package/dist/nuxt/module.mjs +28 -0
  13. package/dist/nuxt/plugin.mjs +9 -0
  14. package/dist/nuxt/runtime/plugin.d.ts +2 -0
  15. package/dist/nuxt/runtime/plugin.mjs +9 -0
  16. package/dist/src/lib/StackTabs.vue.d.ts +30 -16
  17. package/dist/src/lib/components/ContextMenu/ContextMenuItem.vue.d.ts +7 -1
  18. package/dist/src/lib/components/ContextMenu/index.vue.d.ts +5 -4
  19. package/dist/src/lib/components/PageLoading.vue.d.ts +2 -1
  20. package/dist/src/lib/components/StackKeepAlive/StackCacheRenderer.vue.d.ts +25 -0
  21. package/dist/src/lib/components/StackKeepAlive/StackKeepAlive.vue.d.ts +12 -0
  22. package/dist/src/lib/components/TabHeader/TabHeaderButton.vue.d.ts +11 -12
  23. package/dist/src/lib/components/TabHeader/TabHeaderItem.vue.d.ts +5 -4
  24. package/dist/src/lib/components/TabHeader/TabHeaderScroll.vue.d.ts +8 -22
  25. package/dist/src/lib/components/TabHeader/index.vue.d.ts +11 -164
  26. package/dist/src/lib/hooks/stackTabsContext.d.ts +49 -0
  27. package/dist/src/lib/hooks/tabPanel/evict.d.ts +21 -61
  28. package/dist/src/lib/hooks/tabPanel/navigationTransaction.d.ts +35 -0
  29. package/dist/src/lib/hooks/tabPanel/refresh.d.ts +17 -0
  30. package/dist/src/lib/hooks/tabPanel/scroll.d.ts +8 -8
  31. package/dist/src/lib/hooks/tabPanel/session.d.ts +9 -8
  32. package/dist/src/lib/hooks/tabPanel/state.d.ts +20 -72
  33. package/dist/src/lib/hooks/useIframeManager.d.ts +35 -0
  34. package/dist/src/lib/hooks/useTabActions.d.ts +5 -46
  35. package/dist/src/lib/hooks/useTabEventBus.d.ts +22 -9
  36. package/dist/src/lib/hooks/useTabPanel.d.ts +14 -73
  37. package/dist/src/lib/i18n/index.d.ts +6 -4
  38. package/dist/src/lib/i18n/lang/en.d.ts +5 -0
  39. package/dist/src/lib/i18n/lang/zh-CN.d.ts +5 -0
  40. package/dist/src/lib/i18n/stackTabsLocale.d.ts +2 -0
  41. package/dist/src/lib/iframe-bridge.d.ts +8 -0
  42. package/dist/src/lib/iframe.vue.d.ts +2 -1
  43. package/dist/src/lib/index.d.ts +4 -5
  44. package/dist/src/lib/model/TabModel.d.ts +11 -40
  45. package/dist/src/lib/nuxt/module.d.ts +6 -0
  46. package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +2 -0
  47. package/dist/src/lib/nuxt/runtime/plugin.d.ts +2 -0
  48. package/dist/src/lib/utils/iframeBridge.d.ts +11 -3
  49. package/dist/src/lib/utils/stackTabsA11y.d.ts +8 -0
  50. package/dist/src/lib/utils/stackTabsMessage.d.ts +7 -0
  51. package/dist/src/lib/utils/tabInfoEncoder.d.ts +0 -15
  52. package/dist/src/lib/utils/typeGuards.d.ts +2 -0
  53. package/dist/src/lib/utils/urlParser.d.ts +24 -3
  54. package/dist/src/lib/versionLogger.d.ts +1 -0
  55. package/dist/style.css +2 -1
  56. package/dist/utils/iframeBridge.d.ts +39 -0
  57. package/dist/vue-stack-tabs.css +2 -1
  58. package/dist/vue-stack-tabs.es.js +1 -2220
  59. package/package.json +71 -50
  60. package/src/lib/StackTabs.vue +223 -196
  61. package/src/lib/assets/style/stackTab.scss +110 -13
  62. package/src/lib/components/ContextMenu/ContextMenuItem.vue +23 -11
  63. package/src/lib/components/ContextMenu/index.vue +94 -10
  64. package/src/lib/components/PageLoading.vue +9 -7
  65. package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +70 -0
  66. package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +57 -0
  67. package/src/lib/components/TabHeader/TabHeaderButton.vue +19 -8
  68. package/src/lib/components/TabHeader/TabHeaderItem.vue +74 -17
  69. package/src/lib/components/TabHeader/TabHeaderScroll.vue +47 -25
  70. package/src/lib/components/TabHeader/index.vue +181 -13
  71. package/src/lib/hooks/stackTabsContext.ts +135 -0
  72. package/src/lib/hooks/tabPanel/evict.ts +122 -87
  73. package/src/lib/hooks/tabPanel/navigationTransaction.ts +61 -0
  74. package/src/lib/hooks/tabPanel/refresh.ts +109 -0
  75. package/src/lib/hooks/tabPanel/scroll.ts +57 -40
  76. package/src/lib/hooks/tabPanel/session.ts +84 -22
  77. package/src/lib/hooks/tabPanel/state.ts +41 -44
  78. package/src/lib/hooks/useIframeManager.ts +253 -0
  79. package/src/lib/hooks/useTabActions.ts +64 -30
  80. package/src/lib/hooks/useTabEventBus.ts +29 -19
  81. package/src/lib/hooks/useTabLoading.ts +3 -2
  82. package/src/lib/hooks/useTabPanel.tsx +290 -313
  83. package/src/lib/hooks/useTabRouter.ts +111 -97
  84. package/src/lib/i18n/index.ts +53 -45
  85. package/src/lib/i18n/lang/en.ts +6 -1
  86. package/src/lib/i18n/lang/zh-CN.ts +6 -1
  87. package/src/lib/i18n/stackTabsLocale.ts +8 -0
  88. package/src/lib/iframe-bridge.ts +12 -0
  89. package/src/lib/index.ts +14 -19
  90. package/src/lib/model/TabModel.ts +27 -82
  91. package/src/lib/nuxt/module.d.ts +4 -0
  92. package/src/lib/nuxt/module.ts +8 -3
  93. package/src/lib/nuxt/runtime/plugin.client.ts +1 -0
  94. package/src/lib/nuxt/runtime/plugin.ts +11 -0
  95. package/src/lib/utils/iframeBridge.ts +29 -5
  96. package/src/lib/utils/scrollUtils.ts +9 -4
  97. package/src/lib/utils/stackTabsA11y.ts +12 -0
  98. package/src/lib/utils/stackTabsMessage.ts +99 -0
  99. package/src/lib/utils/tabInfoEncoder.ts +70 -21
  100. package/src/lib/utils/typeGuards.ts +3 -0
  101. package/src/lib/utils/urlParser.ts +144 -14
  102. package/src/lib/versionLogger.ts +7 -19
  103. package/dist/src/lib/hooks/tabPanel/evict.spec.d.ts +0 -1
  104. package/dist/src/lib/hooks/useTabPanel.spec.d.ts +0 -1
  105. package/dist/src/lib/model/TabModel.spec.d.ts +0 -1
  106. package/dist/src/lib/utils/iframeBridge.spec.d.ts +0 -1
  107. package/dist/src/lib/utils/tabInfoEncoder.spec.d.ts +0 -1
  108. package/dist/src/lib/utils/urlParser.spec.d.ts +0 -1
  109. package/dist/vue-stack-tabs.umd.js +0 -1
  110. package/src/lib/hooks/tabPanel/evict.spec.ts +0 -85
  111. package/src/lib/hooks/useTabPanel.spec.ts +0 -34
  112. package/src/lib/model/TabModel.spec.ts +0 -40
  113. package/src/lib/utils/iframeBridge.spec.ts +0 -37
  114. package/src/lib/utils/tabInfoEncoder.spec.ts +0 -32
  115. package/src/lib/utils/urlParser.spec.ts +0 -45
@@ -1,3 +1,5 @@
1
+ import type { LocationQueryRaw } from 'vue-router'
2
+
1
3
  /**
2
4
  * TabModel - 标签页数据模型
3
5
  *
@@ -15,17 +17,6 @@ export enum TabScrollMode {
15
17
  BOTH = 'both'
16
18
  }
17
19
 
18
- // export enum EventType {
19
- // I18N_LOCALES = 'I18N_LOCALES',
20
- // PANEL_MAXIMUM = 'PANEL_MAXIMUM',
21
- // TAB_ACTIVE = 'TAB_ACTIVE',
22
- // TAB_CLOSE = 'TAB_CLOSE',
23
- // TAB_CLOSE_ALL = 'TAB_CLOSE_ALL',
24
- // TAB_CLOSE_RIGHT = 'TAB_CLOSE_RIGHT',
25
- // TAB_CLOSE_LEFT = 'TAB_CLOSE_LEFT',
26
- // TAB_REFRESH = 'TAB_REFRESH',
27
- // TAB_REFRESH_ALL = 'TAB_REFRESH_ALL'
28
- // }
29
20
  /** 标签栏滚动条拖拽时的临时状态 */
30
21
  export interface DragData {
31
22
  thumbLeft: number
@@ -48,7 +39,7 @@ export interface ITabData extends ITabBase {
48
39
  // tab name
49
40
  title: string
50
41
  path: string
51
- query?: Record<string, string>
42
+ query?: LocationQueryRaw
52
43
  }
53
44
  /** iframe 刷新方式:postMessage 由 iframe 内页自行刷新(不重建 DOM,动画正常);reload 强制重载 iframe */
54
45
  export type IframeRefreshMode = 'postMessage' | 'reload'
@@ -80,115 +71,69 @@ export interface ITabPage {
80
71
  id: string
81
72
  tabId: string
82
73
  path: string
83
- query?: Record<string, string>
74
+ query?: LocationQueryRaw
75
+ /** 页面级刷新版本;仅影响当前页面的 keep-alive 缓存 key */
76
+ refreshVersion?: number
84
77
  /** 该页面从别的页面被后退唤醒时附带的临时闭包参数,不污染 URL */
85
78
  _backParams?: Record<string, unknown>
86
79
  }
87
- /** 右键菜单项:图标、标题、回调、禁用条件 */
80
+ /** 右键菜单项:重复或动态菜单应提供稳定 key,避免 fallback 标题/图标重复 */
88
81
  export interface IContextMenu {
82
+ key?: string
89
83
  icon?: string
90
84
  title: string
91
85
  callback(id: string): void
92
86
  disabled: (tabData: ITabBase) => boolean
93
87
  }
94
- // export interface TabRouteLocationRaw {
95
- // path: string
96
- // query?: any
97
- // }
98
- // export interface PageRouteLocationRaw {
99
- // pId: string
100
- // path: string
101
- // query: any
102
- // }
103
- /** 使用 Map 实现的栈结构,用于标签内的 pages 页面栈 */
88
+ /** 数组实现的栈结构,用于标签内的 pages 页面栈 */
104
89
  export class Stack<T> {
105
- protected items: Map<number, T>
90
+ protected items: T[]
106
91
 
107
92
  constructor(items?: T[]) {
108
- this.items = new Map()
109
- if (items) {
110
- for (const item of items) {
111
- this.push(item)
112
- }
113
- }
93
+ this.items = items ? [...items] : []
114
94
  }
115
95
 
116
- /**
117
- * @description: 入栈
118
- * @param {T} element 要入栈的元素
119
- */
120
96
  push(element: T) {
121
- this.items.set(this.items.size, element)
97
+ this.items.push(element)
122
98
  }
123
99
 
124
- /**
125
- * @description: 出栈
126
- * @return {T} 返回出栈的元素
127
- */
128
100
  pop(): T | undefined {
129
- if (this.isEmpty()) {
130
- return undefined
131
- }
132
- const result = this.items.get(this.items.size - 1)
133
- this.items.delete(this.items.size - 1)
134
- return result
101
+ return this.items.pop()
135
102
  }
136
103
 
137
- /**
138
- * @description: 返回栈顶的元素
139
- * @return {T}
140
- */
141
104
  peek(): T | undefined {
142
- if (this.isEmpty()) {
143
- return undefined
144
- }
145
- return this.items.get(this.items.size - 1)
105
+ return this.items.length > 0 ? this.items[this.items.length - 1] : undefined
106
+ }
107
+
108
+ at(index: number): T | undefined {
109
+ return this.items[index]
146
110
  }
147
111
 
148
- /**
149
- * @description: 返回栈是否为空
150
- * @return {Boolean}
151
- */
152
112
  isEmpty(): boolean {
153
- return this.items.size === 0
113
+ return this.items.length === 0
154
114
  }
155
115
 
156
- /**
157
- * @description: 返回栈里的元素个数
158
- * @return {Number}
159
- */
160
116
  size(): number {
161
- return this.items.size
117
+ return this.items.length
162
118
  }
163
119
 
164
- /**
165
- * @description: 清空栈内存
166
- */
167
120
  clear() {
168
- this.items.clear()
121
+ this.items.length = 0
169
122
  }
170
123
 
171
- /**
172
- * @description: 将栈中的元素转换为数组
173
- * @return {T[]}
174
- */
175
124
  list(): T[] {
176
- return Array.from(this.items.values())
125
+ return [...this.items]
126
+ }
127
+
128
+ readonlyList(): readonly T[] {
129
+ return this.items
177
130
  }
178
131
 
179
- /**
180
- * @description: 用于控制 JSON.stringify 时的序列化输出
181
- * @return {T[]}
182
- */
183
132
  toJSON(): T[] {
184
133
  return this.list()
185
134
  }
186
135
 
187
- /**
188
- * @description: 覆盖Object默认的toString
189
- * @return {String}
190
- */
191
136
  toString(): string {
192
- return this.list().toString()
137
+ return this.items.toString()
193
138
  }
194
139
  }
@@ -1,3 +1,7 @@
1
+ /**
2
+ * Nuxt 模块类型声明。
3
+ * 扩展 PublicRuntimeConfig 以支持 vueStackTabs 配置的类型推导。
4
+ */
1
5
  import type { ModuleOptions } from './module'
2
6
 
3
7
  declare module 'nuxt/schema' {
@@ -1,4 +1,10 @@
1
- import { addPlugin, addImports, createResolver, defineNuxtModule } from '@nuxt/kit'
1
+ /**
2
+ * vue-stack-tabs Nuxt 模块。
3
+ *
4
+ * 自动注册客户端插件、注入 composables(useTabActions 等),
5
+ * 并通过 runtimeConfig 传递 locale 配置。
6
+ */
7
+ import { addPlugin, addImports, createResolver, defineNuxtModule } from 'nuxt/kit'
2
8
 
3
9
  export interface ModuleOptions {
4
10
  /** 默认语言 */
@@ -21,8 +27,7 @@ export default defineNuxtModule<ModuleOptions>({
21
27
  }
22
28
 
23
29
  addPlugin({
24
- src: resolve('./runtime/plugin.client'),
25
- mode: 'client'
30
+ src: resolve('./runtime/plugin')
26
31
  })
27
32
 
28
33
  addImports([
@@ -1,3 +1,4 @@
1
+ import { defineNuxtPlugin, useRuntimeConfig } from 'nuxt/app'
1
2
  import VueStackTabs from 'vue-stack-tabs'
2
3
 
3
4
  export default defineNuxtPlugin((nuxtApp) => {
@@ -0,0 +1,11 @@
1
+ /** Nuxt 客户端插件:自动注册 VueStackTabs 并应用 runtimeConfig 中的 locale 配置 */
2
+ import { defineNuxtPlugin, useRuntimeConfig } from 'nuxt/app'
3
+ import VueStackTabs from 'vue-stack-tabs'
4
+
5
+ export default defineNuxtPlugin((nuxtApp) => {
6
+ const config = useRuntimeConfig().public.vueStackTabs as {
7
+ locale?: string
8
+ }
9
+
10
+ nuxtApp.vueApp.use(VueStackTabs, [{ locale: config?.locale ?? 'zh-CN' }])
11
+ })
@@ -1,3 +1,5 @@
1
+ import type { LocationQueryRaw } from 'vue-router'
2
+
1
3
  /**
2
4
  * iframe 与父窗口的 postMessage 桥接
3
5
  *
@@ -12,25 +14,41 @@ export const MSG_REFRESH = 'vue-stack-tabs:refresh'
12
14
  /** 打开标签消息:iframe -> 父,payload 为 ITabData */
13
15
  export const MSG_OPEN_TAB = 'vue-stack-tabs:openTab'
14
16
 
17
+ export interface IframeBridgeOptions {
18
+ targetOrigin?: string
19
+ }
20
+
21
+ function getDefaultTargetOrigin(): string {
22
+ return window.location.origin
23
+ }
24
+
25
+ export interface RefreshRequestOptions {
26
+ allowedOrigins?: string[]
27
+ }
28
+
15
29
  export interface IframeOpenTabPayload {
16
30
  id?: string
17
31
  title: string
18
32
  path: string
19
- query?: Record<string, string>
33
+ query?: LocationQueryRaw
20
34
  closable?: boolean
21
35
  refreshable?: boolean
22
36
  iframe?: boolean
37
+ iframeRefreshMode?: 'postMessage' | 'reload'
23
38
  }
24
39
 
25
40
  /**
26
41
  * 在 iframe 内调用:向父窗口请求打开新标签
27
42
  * @param payload 标签数据,至少需 title、path
28
43
  */
29
- export function postOpenTab(payload: IframeOpenTabPayload) {
44
+ export function postOpenTab(
45
+ payload: IframeOpenTabPayload,
46
+ options: IframeBridgeOptions = {}
47
+ ): void {
30
48
  if (typeof window === 'undefined' || !window.parent) return
31
49
  window.parent.postMessage(
32
50
  { type: MSG_OPEN_TAB, payload },
33
- '*' // 父窗口可校验 origin,此处用 * 便于跨子域
51
+ options.targetOrigin ?? getDefaultTargetOrigin()
34
52
  )
35
53
  }
36
54
 
@@ -39,10 +57,16 @@ export function postOpenTab(payload: IframeOpenTabPayload) {
39
57
  * @param callback 默认 location.reload(),可自定义
40
58
  * @returns 取消监听的函数
41
59
  */
42
- export function onRefreshRequest(callback: () => void = () => window.location.reload()) {
43
- if (typeof window === 'undefined') return () => {}
60
+ export function onRefreshRequest(
61
+ callback: () => void = () => window.location.reload(),
62
+ options: RefreshRequestOptions = {}
63
+ ): () => void {
64
+ if (typeof window === 'undefined') return () => undefined
44
65
 
66
+ const allowedOrigins = options.allowedOrigins ?? []
45
67
  const handler = (ev: MessageEvent) => {
68
+ if (ev.source !== window.parent) return
69
+ if (allowedOrigins.length > 0 && !allowedOrigins.includes(ev.origin)) return
46
70
  if (ev.data?.type === MSG_REFRESH) {
47
71
  callback()
48
72
  }
@@ -89,10 +89,15 @@ export const getScrollbarWidth = (function () {
89
89
  }
90
90
  })()
91
91
 
92
+ const Z_INDEX_CEILING = 89999
93
+
92
94
  /** 获取指定选择器下元素的最大 z-index + 1,用于最大化时置顶 */
93
95
  export const getMaxZIndex = (key = '.stack-tab__container *'): number => {
94
- const allZIndex = Array.from(document.querySelectorAll(key)).map(
95
- (e) => +window.getComputedStyle(e).zIndex || 0
96
- )
97
- return allZIndex.length ? Math.max(...allZIndex.filter((item) => item < 90000)) + 1 : 1
96
+ const elements = document.querySelectorAll(key)
97
+ let max = 0
98
+ for (let i = 0; i < elements.length; i++) {
99
+ const z = +window.getComputedStyle(elements[i]!).zIndex || 0
100
+ if (z < Z_INDEX_CEILING && z > max) max = z
101
+ }
102
+ return max + 1
98
103
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * WAI-ARIA 无障碍 ID 生成工具。
3
+ * 为 tab/panel 元素生成符合 ARIA tablist 模式的关联 ID。
4
+ */
5
+
6
+ /** 生成 tab 触发器的 DOM id,用于 aria-controls 关联 */
7
+ export const getStackTabTabId = (tabId: string): string =>
8
+ `stack-tab-tab-${encodeURIComponent(tabId)}`
9
+
10
+ /** 生成 tab 面板的 DOM id,用于 aria-labelledby 关联 */
11
+ export const getStackTabPanelId = (tabId: string): string =>
12
+ `stack-tab-panel-${encodeURIComponent(tabId)}`
@@ -0,0 +1,99 @@
1
+ import type { LocationQueryRaw, LocationQueryValueRaw } from 'vue-router'
2
+ import type { ITabData, IframeRefreshMode } from '../model/TabModel'
3
+ import { isAllowedTabUrl } from './urlParser'
4
+
5
+ type ManagedSourceChecker = (source: MessageEventSource | null) => boolean
6
+
7
+ interface StackTabsMessageEnvelope {
8
+ type?: unknown
9
+ payload?: unknown
10
+ }
11
+
12
+ const OPEN_TAB_MESSAGE_TYPES = new Set(['vue-stack-tabs:openTab', 'openTab'])
13
+
14
+ import { isRecord } from './typeGuards'
15
+
16
+ const isLocationQueryValueRaw = (value: unknown): value is LocationQueryValueRaw =>
17
+ typeof value === 'string' || typeof value === 'number' || value === null || value === undefined
18
+
19
+ const pickLocationQueryRaw = (value: unknown): LocationQueryRaw | undefined => {
20
+ if (!isRecord(value)) return undefined
21
+
22
+ const query: LocationQueryRaw = {}
23
+ for (const [key, item] of Object.entries(value)) {
24
+ if (isLocationQueryValueRaw(item)) {
25
+ query[key] = item
26
+ continue
27
+ }
28
+ if (Array.isArray(item) && item.every(isLocationQueryValueRaw)) {
29
+ query[key] = item
30
+ }
31
+ }
32
+
33
+ return Object.keys(query).length > 0 ? query : undefined
34
+ }
35
+
36
+ const pickBoolean = (value: unknown): boolean | undefined =>
37
+ typeof value === 'boolean' ? value : undefined
38
+
39
+ const pickRefreshMode = (value: unknown): IframeRefreshMode | undefined => {
40
+ if (value === 'reload' || value === 'postMessage') return value
41
+ return undefined
42
+ }
43
+
44
+ const normalizeOpenTabPayload = (payload: unknown): ITabData | null => {
45
+ if (!isRecord(payload)) return null
46
+
47
+ const { id, title, path, query, iframe, closable, refreshable, iframeRefreshMode } = payload
48
+ if (typeof title !== 'string' || title.trim() === '') return null
49
+ if (typeof path !== 'string' || path.trim() === '' || !isAllowedTabUrl(path)) return null
50
+
51
+ const normalizedQuery = pickLocationQueryRaw(query)
52
+ const normalizedIframe = pickBoolean(iframe)
53
+ const normalizedClosable = pickBoolean(closable)
54
+ const normalizedRefreshable = pickBoolean(refreshable)
55
+ const normalizedRefreshMode = pickRefreshMode(iframeRefreshMode)
56
+
57
+ return {
58
+ ...(typeof id === 'string' && id ? { id } : {}),
59
+ title,
60
+ path,
61
+ ...(normalizedQuery ? { query: normalizedQuery } : {}),
62
+ ...(normalizedIframe !== undefined ? { iframe: normalizedIframe } : {}),
63
+ ...(normalizedClosable !== undefined ? { closable: normalizedClosable } : {}),
64
+ ...(normalizedRefreshable !== undefined ? { refreshable: normalizedRefreshable } : {}),
65
+ ...(normalizedRefreshMode ? { iframeRefreshMode: normalizedRefreshMode } : {})
66
+ }
67
+ }
68
+
69
+ /** 校验 MessageEvent 是否为合法的 openTab 请求,返回解析后的 ITabData 或 null */
70
+ export const isStackTabsOpenTabMessage = (
71
+ event: MessageEvent,
72
+ isManagedSource: ManagedSourceChecker
73
+ ): ITabData | null => {
74
+ if (!isManagedSource(event.source)) return null
75
+ if (!isRecord(event.data)) return null
76
+
77
+ const envelope = event.data as StackTabsMessageEnvelope
78
+ if (typeof envelope.type !== 'string' || !OPEN_TAB_MESSAGE_TYPES.has(envelope.type)) return null
79
+
80
+ return normalizeOpenTabPayload(envelope.payload)
81
+ }
82
+
83
+ /** 根据 iframe URL 计算安全的 postMessage targetOrigin,非法 URL 返回 null */
84
+ export const getPostMessageTargetOrigin = (url: string, fallbackOrigin?: string): string | null => {
85
+ if (!url || !isAllowedTabUrl(url)) return null
86
+
87
+ try {
88
+ const base =
89
+ fallbackOrigin ?? (typeof window !== 'undefined' ? window.location.origin : undefined)
90
+ if (!base && !/^https?:\/\//i.test(url)) return null
91
+
92
+ const parsed = new URL(url, base)
93
+ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return parsed.origin
94
+ } catch {
95
+ return null
96
+ }
97
+
98
+ return null
99
+ }
@@ -1,27 +1,55 @@
1
1
  import type { ITabBase } from '../model/TabModel'
2
- import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string'
2
+
3
+ interface EncodedTabInfoV1 {
4
+ version: 1
5
+ id?: string
6
+ title: string
7
+ iframe?: boolean
8
+ closable?: boolean
9
+ refreshable?: boolean
10
+ iframeRefreshMode?: 'postMessage' | 'reload'
11
+ }
3
12
 
4
13
  /**
5
14
  * 标签信息编解码器
6
15
  *
7
- * 职责:将标签元数据压缩为 URL query 可用的短字符串,用于 __tab 参数
8
- * 格式: id|title|iframe|closable|refreshable,压缩后写入 query.__tab
16
+ * 职责:将标签元数据编码为 URL query 可用的短字符串,用于 __tab 参数。
17
+ * 当前格式为 JSON + Base64,兼容旧版 id|title|iframe|closable|refreshable|mode 编码。
9
18
  */
10
19
 
11
- /**
12
- * 编码标签基础信息为压缩字符串(用于 query.__tab)
13
- * @param tabData 标签基础数据
14
- */
15
- export const encodeTabInfo = (tabData: ITabBase): string => {
16
- const mode =
17
- (tabData as { iframeRefreshMode?: string }).iframeRefreshMode === 'reload' ? 'R' : 'P'
18
- const info = `${tabData.id}|${tabData.title}|${tabData.iframe ? 'Y' : 'N'}|${tabData.closable ? 'Y' : 'N'}|${tabData.refreshable ? 'Y' : 'N'}|${mode}`
19
- return compressToEncodedURIComponent(info)
20
+ const MAX_DECODED_TAB_INFO_LENGTH = 2048
21
+ const MAX_ENCODED_TAB_INFO_LENGTH = 4096
22
+
23
+ const encodeBase64 = (value: string): string => encodeURIComponent(btoa(encodeURIComponent(value)))
24
+ const decodeBase64 = (encoded: string): string =>
25
+ decodeURIComponent(atob(decodeURIComponent(encoded)))
26
+
27
+ const DEFAULT_TAB_INFO: ITabBase = {
28
+ id: '',
29
+ title: '',
30
+ iframe: false,
31
+ closable: true,
32
+ refreshable: true,
33
+ iframeRefreshMode: 'postMessage'
20
34
  }
21
35
 
22
- /** 从 query.__tab 解压并解析为 ITabBase */
23
- export const decodeTabInfo = (encoded: string): ITabBase => {
24
- const tabString = decompressFromEncodedURIComponent(encoded)
36
+ import { isRecord } from './typeGuards'
37
+
38
+ const toTabInfo = (payload: unknown): ITabBase => {
39
+ if (!isRecord(payload)) return { ...DEFAULT_TAB_INFO }
40
+
41
+ return {
42
+ id: typeof payload.id === 'string' ? payload.id : DEFAULT_TAB_INFO.id,
43
+ title: typeof payload.title === 'string' ? payload.title : DEFAULT_TAB_INFO.title,
44
+ iframe: typeof payload.iframe === 'boolean' ? payload.iframe : DEFAULT_TAB_INFO.iframe,
45
+ closable: typeof payload.closable === 'boolean' ? payload.closable : DEFAULT_TAB_INFO.closable,
46
+ refreshable:
47
+ typeof payload.refreshable === 'boolean' ? payload.refreshable : DEFAULT_TAB_INFO.refreshable,
48
+ iframeRefreshMode: payload.iframeRefreshMode === 'reload' ? 'reload' : 'postMessage'
49
+ }
50
+ }
51
+
52
+ const decodeLegacyTabInfo = (tabString: string): ITabBase => {
25
53
  const tabValues: string[] = tabString.split('|')
26
54
  const refreshMode = tabValues[5] === 'R' ? ('reload' as const) : ('postMessage' as const)
27
55
  return {
@@ -34,10 +62,31 @@ export const decodeTabInfo = (encoded: string): ITabBase => {
34
62
  }
35
63
  }
36
64
 
37
- import { ulid } from 'ulid'
65
+ export const encodeTabInfo = (tabData: ITabBase): string => {
66
+ const payload: EncodedTabInfoV1 = {
67
+ version: 1,
68
+ id: tabData.id,
69
+ title: tabData.title,
70
+ iframe: tabData.iframe,
71
+ closable: tabData.closable,
72
+ refreshable: tabData.refreshable,
73
+ iframeRefreshMode: tabData.iframeRefreshMode === 'reload' ? 'reload' : 'postMessage'
74
+ }
75
+ return encodeBase64(JSON.stringify(payload))
76
+ }
38
77
 
39
- /**
40
- * 生成页面缓存 ID (CacheName)
41
- * 采用全局唯一的 ULID 确保同一个路由在栈内打开多少次都是绝对隔离的不同实例。
42
- */
43
- export const createPageId = (): string => ulid()
78
+ export const decodeTabInfo = (encoded: string): ITabBase => {
79
+ if (encoded.length > MAX_ENCODED_TAB_INFO_LENGTH) return { ...DEFAULT_TAB_INFO }
80
+
81
+ try {
82
+ const tabString = decodeBase64(encoded)
83
+ if (tabString.length > MAX_DECODED_TAB_INFO_LENGTH) return { ...DEFAULT_TAB_INFO }
84
+ if (!tabString.trim().startsWith('{')) return decodeLegacyTabInfo(tabString)
85
+
86
+ return toTabInfo(JSON.parse(tabString))
87
+ } catch {
88
+ return { ...DEFAULT_TAB_INFO }
89
+ }
90
+ }
91
+
92
+ export const createPageId = (): string => crypto.randomUUID()
@@ -0,0 +1,3 @@
1
+ /** 类型守卫:判断值是否为非数组的普通对象 */
2
+ export const isRecord = (value: unknown): value is Record<string, unknown> =>
3
+ typeof value === 'object' && value !== null && !Array.isArray(value)