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.
- package/README.en.md +526 -0
- package/README.md +141 -39
- package/README.ru.md +526 -0
- package/dist/iframe-bridge.d.ts +8 -0
- package/dist/iframe-bridge.html +77 -0
- package/dist/iframe-bridge.mjs +20 -0
- package/dist/iframe-child.html +19 -6
- package/dist/nuxt/favicon.svg +10 -0
- package/dist/nuxt/iframe-bridge.html +77 -0
- package/dist/nuxt/iframe-child.html +88 -0
- package/dist/nuxt/module.d.ts +6 -0
- package/dist/nuxt/module.mjs +28 -0
- package/dist/nuxt/plugin.mjs +9 -0
- package/dist/nuxt/runtime/plugin.d.ts +2 -0
- package/dist/nuxt/runtime/plugin.mjs +9 -0
- package/dist/src/lib/StackTabs.vue.d.ts +30 -16
- package/dist/src/lib/components/ContextMenu/ContextMenuItem.vue.d.ts +7 -1
- package/dist/src/lib/components/ContextMenu/index.vue.d.ts +5 -4
- package/dist/src/lib/components/PageLoading.vue.d.ts +2 -1
- package/dist/src/lib/components/StackKeepAlive/StackCacheRenderer.vue.d.ts +25 -0
- package/dist/src/lib/components/StackKeepAlive/StackKeepAlive.vue.d.ts +12 -0
- package/dist/src/lib/components/TabHeader/TabHeaderButton.vue.d.ts +11 -12
- package/dist/src/lib/components/TabHeader/TabHeaderItem.vue.d.ts +5 -4
- package/dist/src/lib/components/TabHeader/TabHeaderScroll.vue.d.ts +8 -22
- package/dist/src/lib/components/TabHeader/index.vue.d.ts +11 -164
- package/dist/src/lib/hooks/stackTabsContext.d.ts +49 -0
- package/dist/src/lib/hooks/tabPanel/evict.d.ts +21 -61
- package/dist/src/lib/hooks/tabPanel/navigationTransaction.d.ts +35 -0
- package/dist/src/lib/hooks/tabPanel/refresh.d.ts +17 -0
- package/dist/src/lib/hooks/tabPanel/scroll.d.ts +8 -8
- package/dist/src/lib/hooks/tabPanel/session.d.ts +9 -8
- package/dist/src/lib/hooks/tabPanel/state.d.ts +20 -72
- package/dist/src/lib/hooks/useIframeManager.d.ts +35 -0
- package/dist/src/lib/hooks/useTabActions.d.ts +5 -46
- package/dist/src/lib/hooks/useTabEventBus.d.ts +22 -9
- package/dist/src/lib/hooks/useTabPanel.d.ts +14 -73
- package/dist/src/lib/i18n/index.d.ts +6 -4
- package/dist/src/lib/i18n/lang/en.d.ts +5 -0
- package/dist/src/lib/i18n/lang/zh-CN.d.ts +5 -0
- package/dist/src/lib/i18n/stackTabsLocale.d.ts +2 -0
- package/dist/src/lib/iframe-bridge.d.ts +8 -0
- package/dist/src/lib/iframe.vue.d.ts +2 -1
- package/dist/src/lib/index.d.ts +4 -5
- package/dist/src/lib/model/TabModel.d.ts +11 -40
- package/dist/src/lib/nuxt/module.d.ts +6 -0
- package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +2 -0
- package/dist/src/lib/nuxt/runtime/plugin.d.ts +2 -0
- package/dist/src/lib/utils/iframeBridge.d.ts +11 -3
- package/dist/src/lib/utils/stackTabsA11y.d.ts +8 -0
- package/dist/src/lib/utils/stackTabsMessage.d.ts +7 -0
- package/dist/src/lib/utils/tabInfoEncoder.d.ts +0 -15
- package/dist/src/lib/utils/typeGuards.d.ts +2 -0
- package/dist/src/lib/utils/urlParser.d.ts +24 -3
- package/dist/src/lib/versionLogger.d.ts +1 -0
- package/dist/style.css +2 -1
- package/dist/utils/iframeBridge.d.ts +39 -0
- package/dist/vue-stack-tabs.css +2 -1
- package/dist/vue-stack-tabs.es.js +1 -2220
- package/package.json +71 -50
- package/src/lib/StackTabs.vue +223 -196
- package/src/lib/assets/style/stackTab.scss +110 -13
- package/src/lib/components/ContextMenu/ContextMenuItem.vue +23 -11
- package/src/lib/components/ContextMenu/index.vue +94 -10
- package/src/lib/components/PageLoading.vue +9 -7
- package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +70 -0
- package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +57 -0
- package/src/lib/components/TabHeader/TabHeaderButton.vue +19 -8
- package/src/lib/components/TabHeader/TabHeaderItem.vue +74 -17
- package/src/lib/components/TabHeader/TabHeaderScroll.vue +47 -25
- package/src/lib/components/TabHeader/index.vue +181 -13
- package/src/lib/hooks/stackTabsContext.ts +135 -0
- package/src/lib/hooks/tabPanel/evict.ts +122 -87
- package/src/lib/hooks/tabPanel/navigationTransaction.ts +61 -0
- package/src/lib/hooks/tabPanel/refresh.ts +109 -0
- package/src/lib/hooks/tabPanel/scroll.ts +57 -40
- package/src/lib/hooks/tabPanel/session.ts +84 -22
- package/src/lib/hooks/tabPanel/state.ts +41 -44
- package/src/lib/hooks/useIframeManager.ts +253 -0
- package/src/lib/hooks/useTabActions.ts +64 -30
- package/src/lib/hooks/useTabEventBus.ts +29 -19
- package/src/lib/hooks/useTabLoading.ts +3 -2
- package/src/lib/hooks/useTabPanel.tsx +290 -313
- package/src/lib/hooks/useTabRouter.ts +111 -97
- package/src/lib/i18n/index.ts +53 -45
- package/src/lib/i18n/lang/en.ts +6 -1
- package/src/lib/i18n/lang/zh-CN.ts +6 -1
- package/src/lib/i18n/stackTabsLocale.ts +8 -0
- package/src/lib/iframe-bridge.ts +12 -0
- package/src/lib/index.ts +14 -19
- package/src/lib/model/TabModel.ts +27 -82
- package/src/lib/nuxt/module.d.ts +4 -0
- package/src/lib/nuxt/module.ts +8 -3
- package/src/lib/nuxt/runtime/plugin.client.ts +1 -0
- package/src/lib/nuxt/runtime/plugin.ts +11 -0
- package/src/lib/utils/iframeBridge.ts +29 -5
- package/src/lib/utils/scrollUtils.ts +9 -4
- package/src/lib/utils/stackTabsA11y.ts +12 -0
- package/src/lib/utils/stackTabsMessage.ts +99 -0
- package/src/lib/utils/tabInfoEncoder.ts +70 -21
- package/src/lib/utils/typeGuards.ts +3 -0
- package/src/lib/utils/urlParser.ts +144 -14
- package/src/lib/versionLogger.ts +7 -19
- package/dist/src/lib/hooks/tabPanel/evict.spec.d.ts +0 -1
- package/dist/src/lib/hooks/useTabPanel.spec.d.ts +0 -1
- package/dist/src/lib/model/TabModel.spec.d.ts +0 -1
- package/dist/src/lib/utils/iframeBridge.spec.d.ts +0 -1
- package/dist/src/lib/utils/tabInfoEncoder.spec.d.ts +0 -1
- package/dist/src/lib/utils/urlParser.spec.d.ts +0 -1
- package/dist/vue-stack-tabs.umd.js +0 -1
- package/src/lib/hooks/tabPanel/evict.spec.ts +0 -85
- package/src/lib/hooks/useTabPanel.spec.ts +0 -34
- package/src/lib/model/TabModel.spec.ts +0 -40
- package/src/lib/utils/iframeBridge.spec.ts +0 -37
- package/src/lib/utils/tabInfoEncoder.spec.ts +0 -32
- 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?:
|
|
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?:
|
|
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
|
-
|
|
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:
|
|
90
|
+
protected items: T[]
|
|
106
91
|
|
|
107
92
|
constructor(items?: T[]) {
|
|
108
|
-
this.items =
|
|
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.
|
|
97
|
+
this.items.push(element)
|
|
122
98
|
}
|
|
123
99
|
|
|
124
|
-
/**
|
|
125
|
-
* @description: 出栈
|
|
126
|
-
* @return {T} 返回出栈的元素
|
|
127
|
-
*/
|
|
128
100
|
pop(): T | undefined {
|
|
129
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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.
|
|
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.
|
|
117
|
+
return this.items.length
|
|
162
118
|
}
|
|
163
119
|
|
|
164
|
-
/**
|
|
165
|
-
* @description: 清空栈内存
|
|
166
|
-
*/
|
|
167
120
|
clear() {
|
|
168
|
-
this.items.
|
|
121
|
+
this.items.length = 0
|
|
169
122
|
}
|
|
170
123
|
|
|
171
|
-
/**
|
|
172
|
-
* @description: 将栈中的元素转换为数组
|
|
173
|
-
* @return {T[]}
|
|
174
|
-
*/
|
|
175
124
|
list(): T[] {
|
|
176
|
-
return
|
|
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.
|
|
137
|
+
return this.items.toString()
|
|
193
138
|
}
|
|
194
139
|
}
|
package/src/lib/nuxt/module.d.ts
CHANGED
package/src/lib/nuxt/module.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
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
|
|
25
|
-
mode: 'client'
|
|
30
|
+
src: resolve('./runtime/plugin')
|
|
26
31
|
})
|
|
27
32
|
|
|
28
33
|
addImports([
|
|
@@ -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?:
|
|
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(
|
|
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
|
-
|
|
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(
|
|
43
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
)
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
8
|
-
*
|
|
16
|
+
* 职责:将标签元数据编码为 URL query 可用的短字符串,用于 __tab 参数。
|
|
17
|
+
* 当前格式为 JSON + Base64,兼容旧版 id|title|iframe|closable|refreshable|mode 编码。
|
|
9
18
|
*/
|
|
10
19
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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()
|