vue-stack-tabs 1.0.1 → 1.3.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 (116) hide show
  1. package/README.en.md +526 -0
  2. package/README.md +337 -520
  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 +88 -0
  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 -23
  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 -73
  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 +17 -74
  37. package/dist/src/lib/hooks/useTabRouter.d.ts +5 -5
  38. package/dist/src/lib/i18n/index.d.ts +6 -4
  39. package/dist/src/lib/i18n/lang/en.d.ts +5 -0
  40. package/dist/src/lib/i18n/lang/zh-CN.d.ts +5 -0
  41. package/dist/src/lib/i18n/stackTabsLocale.d.ts +2 -0
  42. package/dist/src/lib/iframe-bridge.d.ts +8 -0
  43. package/dist/src/lib/iframe.vue.d.ts +2 -1
  44. package/dist/src/lib/index.d.ts +4 -5
  45. package/dist/src/lib/model/TabModel.d.ts +14 -33
  46. package/dist/src/lib/nuxt/module.d.ts +6 -0
  47. package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +2 -0
  48. package/dist/src/lib/nuxt/runtime/plugin.d.ts +2 -0
  49. package/dist/src/lib/utils/iframeBridge.d.ts +11 -3
  50. package/dist/src/lib/utils/stackTabsA11y.d.ts +8 -0
  51. package/dist/src/lib/utils/stackTabsMessage.d.ts +7 -0
  52. package/dist/src/lib/utils/tabInfoEncoder.d.ts +1 -16
  53. package/dist/src/lib/utils/typeGuards.d.ts +2 -0
  54. package/dist/src/lib/utils/urlParser.d.ts +24 -3
  55. package/dist/src/lib/versionLogger.d.ts +1 -0
  56. package/dist/style.css +2 -1
  57. package/dist/utils/iframeBridge.d.ts +39 -0
  58. package/dist/vue-stack-tabs.css +2 -1
  59. package/dist/vue-stack-tabs.es.js +1 -2411
  60. package/package.json +68 -47
  61. package/src/lib/StackTabs.vue +216 -192
  62. package/src/lib/assets/style/stackTab.scss +84 -12
  63. package/src/lib/components/ContextMenu/ContextMenuItem.vue +23 -11
  64. package/src/lib/components/ContextMenu/index.vue +94 -10
  65. package/src/lib/components/PageLoading.vue +9 -7
  66. package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +70 -0
  67. package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +57 -0
  68. package/src/lib/components/TabHeader/TabHeaderButton.vue +19 -8
  69. package/src/lib/components/TabHeader/TabHeaderItem.vue +45 -16
  70. package/src/lib/components/TabHeader/TabHeaderScroll.vue +47 -25
  71. package/src/lib/components/TabHeader/index.vue +109 -12
  72. package/src/lib/hooks/stackTabsContext.ts +135 -0
  73. package/src/lib/hooks/tabPanel/evict.ts +123 -41
  74. package/src/lib/hooks/tabPanel/navigationTransaction.ts +61 -0
  75. package/src/lib/hooks/tabPanel/refresh.ts +109 -0
  76. package/src/lib/hooks/tabPanel/scroll.ts +57 -36
  77. package/src/lib/hooks/tabPanel/session.ts +84 -22
  78. package/src/lib/hooks/tabPanel/state.ts +41 -44
  79. package/src/lib/hooks/useIframeManager.ts +253 -0
  80. package/src/lib/hooks/useTabActions.ts +62 -30
  81. package/src/lib/hooks/useTabEventBus.ts +29 -19
  82. package/src/lib/hooks/useTabLoading.ts +3 -2
  83. package/src/lib/hooks/useTabPanel.tsx +415 -213
  84. package/src/lib/hooks/useTabRouter.ts +172 -43
  85. package/src/lib/i18n/index.ts +53 -45
  86. package/src/lib/i18n/lang/en.ts +6 -1
  87. package/src/lib/i18n/lang/zh-CN.ts +6 -1
  88. package/src/lib/i18n/stackTabsLocale.ts +8 -0
  89. package/src/lib/iframe-bridge.ts +12 -0
  90. package/src/lib/index.ts +14 -19
  91. package/src/lib/model/TabModel.ts +34 -86
  92. package/src/lib/nuxt/module.d.ts +4 -0
  93. package/src/lib/nuxt/module.ts +8 -3
  94. package/src/lib/nuxt/runtime/plugin.client.ts +2 -3
  95. package/src/lib/nuxt/runtime/plugin.ts +11 -0
  96. package/src/lib/utils/iframeBridge.ts +29 -5
  97. package/src/lib/utils/scrollUtils.ts +9 -4
  98. package/src/lib/utils/stackTabsA11y.ts +12 -0
  99. package/src/lib/utils/stackTabsMessage.ts +99 -0
  100. package/src/lib/utils/tabInfoEncoder.ts +69 -42
  101. package/src/lib/utils/typeGuards.ts +3 -0
  102. package/src/lib/utils/urlParser.ts +144 -14
  103. package/src/lib/versionLogger.ts +7 -19
  104. package/dist/src/lib/hooks/tabPanel/evict.spec.d.ts +0 -1
  105. package/dist/src/lib/hooks/useTabPanel.spec.d.ts +0 -1
  106. package/dist/src/lib/model/TabModel.spec.d.ts +0 -1
  107. package/dist/src/lib/utils/iframeBridge.spec.d.ts +0 -1
  108. package/dist/src/lib/utils/tabInfoEncoder.spec.d.ts +0 -1
  109. package/dist/src/lib/utils/urlParser.spec.d.ts +0 -1
  110. package/dist/vue-stack-tabs.umd.js +0 -1
  111. package/src/lib/hooks/tabPanel/evict.spec.ts +0 -85
  112. package/src/lib/hooks/useTabPanel.spec.ts +0 -34
  113. package/src/lib/model/TabModel.spec.ts +0 -40
  114. package/src/lib/utils/iframeBridge.spec.ts +0 -37
  115. package/src/lib/utils/tabInfoEncoder.spec.ts +0 -42
  116. 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,112 +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
77
+ /** 该页面从别的页面被后退唤醒时附带的临时闭包参数,不污染 URL */
78
+ _backParams?: Record<string, unknown>
84
79
  }
85
- /** 右键菜单项:图标、标题、回调、禁用条件 */
80
+ /** 右键菜单项:重复或动态菜单应提供稳定 key,避免 fallback 标题/图标重复 */
86
81
  export interface IContextMenu {
82
+ key?: string
87
83
  icon?: string
88
84
  title: string
89
85
  callback(id: string): void
90
86
  disabled: (tabData: ITabBase) => boolean
91
87
  }
92
- // export interface TabRouteLocationRaw {
93
- // path: string
94
- // query?: any
95
- // }
96
- // export interface PageRouteLocationRaw {
97
- // pId: string
98
- // path: string
99
- // query: any
100
- // }
101
- /** 使用 Map 实现的栈结构,用于标签内的 pages 页面栈 */
88
+ /** 数组实现的栈结构,用于标签内的 pages 页面栈 */
102
89
  export class Stack<T> {
103
- private items: Map<number, T>
90
+ protected items: T[]
104
91
 
105
92
  constructor(items?: T[]) {
106
- this.items = new Map()
107
- if (items) {
108
- for (const item of items) {
109
- this.push(item)
110
- }
111
- }
93
+ this.items = items ? [...items] : []
112
94
  }
113
95
 
114
- /**
115
- * @description: 入栈
116
- * @param {T} element 要入栈的元素
117
- */
118
96
  push(element: T) {
119
- this.items.set(this.items.size, element)
97
+ this.items.push(element)
120
98
  }
121
99
 
122
- /**
123
- * @description: 出栈
124
- * @return {T} 返回出栈的元素
125
- */
126
100
  pop(): T | undefined {
127
- if (this.isEmpty()) {
128
- return undefined
129
- }
130
- const result = this.items.get(this.items.size - 1)
131
- this.items.delete(this.items.size - 1)
132
- return result
101
+ return this.items.pop()
133
102
  }
134
103
 
135
- /**
136
- * @description: 返回栈顶的元素
137
- * @return {T}
138
- */
139
104
  peek(): T | undefined {
140
- if (this.isEmpty()) {
141
- return undefined
142
- }
143
- 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]
144
110
  }
145
111
 
146
- /**
147
- * @description: 返回栈是否为空
148
- * @return {Boolean}
149
- */
150
112
  isEmpty(): boolean {
151
- return this.items.size === 0
113
+ return this.items.length === 0
152
114
  }
153
115
 
154
- /**
155
- * @description: 返回栈里的元素个数
156
- * @return {Number}
157
- */
158
116
  size(): number {
159
- return this.items.size
117
+ return this.items.length
160
118
  }
161
119
 
162
- /**
163
- * @description: 清空栈
164
- */
165
120
  clear() {
166
- this.items.clear()
121
+ this.items.length = 0
167
122
  }
168
123
 
169
- list() {
170
- return Array.from(this.items.values())
124
+ list(): T[] {
125
+ return [...this.items]
171
126
  }
172
127
 
173
- /**
174
- * @description: 覆盖Object默认的toString
175
- * @return {String}
176
- */
177
- toString(): string {
178
- if (this.isEmpty()) {
179
- return ''
180
- }
181
- let result: string = ''
182
- this.items.forEach((value, key) => {
183
- result = `${result}${key === 0 ? '' : ', '}${value}`
184
- })
185
- return result
128
+ readonlyList(): readonly T[] {
129
+ return this.items
186
130
  }
187
- toJSON() {
188
- // return Object.fromEntries(this.items)
189
- return Array.from(this.items.values())
131
+
132
+ toJSON(): T[] {
133
+ return this.list()
134
+ }
135
+
136
+ toString(): string {
137
+ return this.items.toString()
190
138
  }
191
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) => {
@@ -5,7 +6,5 @@ export default defineNuxtPlugin((nuxtApp) => {
5
6
  locale?: string
6
7
  }
7
8
 
8
- nuxtApp.vueApp.use(VueStackTabs, {
9
- locale: config?.locale ?? 'zh-CN'
10
- })
9
+ nuxtApp.vueApp.use(VueStackTabs, [{ locale: config?.locale ?? 'zh-CN' }])
11
10
  })
@@ -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,28 +1,55 @@
1
1
  import type { ITabBase } from '../model/TabModel'
2
- import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string'
3
- import Hex from 'hex-encoding'
4
- import { blake3 } from '@noble/hashes/blake3.js'
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
+ }
5
12
 
6
13
  /**
7
14
  * 标签信息编解码器
8
15
  *
9
- * 职责:将标签元数据压缩为 URL query 可用的短字符串,用于 __tab 参数
10
- * 格式: id|title|iframe|closable|refreshable,压缩后写入 query.__tab
16
+ * 职责:将标签元数据编码为 URL query 可用的短字符串,用于 __tab 参数。
17
+ * 当前格式为 JSON + Base64,兼容旧版 id|title|iframe|closable|refreshable|mode 编码。
11
18
  */
12
19
 
13
- /**
14
- * 编码标签基础信息为压缩字符串(用于 query.__tab)
15
- * @param tabData 标签基础数据
16
- */
17
- export const encodeTabInfo = (tabData: ITabBase): string => {
18
- const mode = (tabData as { iframeRefreshMode?: string }).iframeRefreshMode === 'reload' ? 'R' : 'P'
19
- const info = `${tabData.id}|${tabData.title}|${tabData.iframe ? 'Y' : 'N'}|${tabData.closable ? 'Y' : 'N'}|${tabData.refreshable ? 'Y' : 'N'}|${mode}`
20
- 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'
21
34
  }
22
35
 
23
- /** 从 query.__tab 解压并解析为 ITabBase */
24
- export const decodeTabInfo = (encoded: string): ITabBase => {
25
- 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 => {
26
53
  const tabValues: string[] = tabString.split('|')
27
54
  const refreshMode = tabValues[5] === 'R' ? ('reload' as const) : ('postMessage' as const)
28
55
  return {
@@ -35,31 +62,31 @@ export const decodeTabInfo = (encoded: string): ITabBase => {
35
62
  }
36
63
  }
37
64
 
38
- /**
39
- * 根据 tabId + path + query 生成页面缓存 ID
40
- * 用于 keep-alive 的 include 和 exclude,需稳定且唯一
41
- */
42
- export const createPageId = (tabId: string, path: string, query: object): string => {
43
- const queryEntries: { key: string; value: string }[] = []
44
- type ObjectKey = keyof typeof query
45
- for (const key in query) {
46
- if (!key.startsWith('__')) {
47
- queryEntries.push({
48
- key,
49
- value: query[key as ObjectKey]
50
- })
51
- }
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
+ }
77
+
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 }
52
89
  }
53
- queryEntries.sort((a, b) => {
54
- let result = 0
55
- if (a.key > b.key) {
56
- result = 1
57
- } else if (a.key < b.key) {
58
- result = -1
59
- }
60
- return result
61
- })
62
- const msg = new TextEncoder().encode(`${tabId}|${path}|${JSON.stringify(queryEntries)}`)
63
- const hash = blake3(msg)
64
- return Hex.encode(hash)
65
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)