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.
- package/README.en.md +526 -0
- package/README.md +337 -520
- 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 +88 -0
- 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 -23
- 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 -73
- 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 +17 -74
- package/dist/src/lib/hooks/useTabRouter.d.ts +5 -5
- 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 +14 -33
- 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 +1 -16
- 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 -2411
- package/package.json +68 -47
- package/src/lib/StackTabs.vue +216 -192
- package/src/lib/assets/style/stackTab.scss +84 -12
- 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 +45 -16
- package/src/lib/components/TabHeader/TabHeaderScroll.vue +47 -25
- package/src/lib/components/TabHeader/index.vue +109 -12
- package/src/lib/hooks/stackTabsContext.ts +135 -0
- package/src/lib/hooks/tabPanel/evict.ts +123 -41
- 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 -36
- 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 +62 -30
- package/src/lib/hooks/useTabEventBus.ts +29 -19
- package/src/lib/hooks/useTabLoading.ts +3 -2
- package/src/lib/hooks/useTabPanel.tsx +415 -213
- package/src/lib/hooks/useTabRouter.ts +172 -43
- 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 +34 -86
- 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 +2 -3
- 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 +69 -42
- 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 -42
- 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,112 +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
|
|
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
|
-
|
|
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
|
-
|
|
90
|
+
protected items: T[]
|
|
104
91
|
|
|
105
92
|
constructor(items?: T[]) {
|
|
106
|
-
this.items =
|
|
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.
|
|
97
|
+
this.items.push(element)
|
|
120
98
|
}
|
|
121
99
|
|
|
122
|
-
/**
|
|
123
|
-
* @description: 出栈
|
|
124
|
-
* @return {T} 返回出栈的元素
|
|
125
|
-
*/
|
|
126
100
|
pop(): T | undefined {
|
|
127
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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.
|
|
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.
|
|
117
|
+
return this.items.length
|
|
160
118
|
}
|
|
161
119
|
|
|
162
|
-
/**
|
|
163
|
-
* @description: 清空栈
|
|
164
|
-
*/
|
|
165
120
|
clear() {
|
|
166
|
-
this.items.
|
|
121
|
+
this.items.length = 0
|
|
167
122
|
}
|
|
168
123
|
|
|
169
|
-
list() {
|
|
170
|
-
return
|
|
124
|
+
list(): T[] {
|
|
125
|
+
return [...this.items]
|
|
171
126
|
}
|
|
172
127
|
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
return
|
|
131
|
+
|
|
132
|
+
toJSON(): T[] {
|
|
133
|
+
return this.list()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
toString(): string {
|
|
137
|
+
return this.items.toString()
|
|
190
138
|
}
|
|
191
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([
|
|
@@ -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?:
|
|
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,28 +1,55 @@
|
|
|
1
1
|
import type { ITabBase } from '../model/TabModel'
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
*
|
|
10
|
-
*
|
|
16
|
+
* 职责:将标签元数据编码为 URL query 可用的短字符串,用于 __tab 参数。
|
|
17
|
+
* 当前格式为 JSON + Base64,兼容旧版 id|title|iframe|closable|refreshable|mode 编码。
|
|
11
18
|
*/
|
|
12
19
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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()
|