vue-stack-tabs 1.4.2 → 2.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 +1 -0
- package/README.md +1 -0
- package/README.ru.md +1 -0
- package/dist/StackTabs.vue.d.ts +5 -2
- package/dist/components/ContextMenu/ContextMenuItem.vue.d.ts +1 -1
- package/dist/components/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -2
- package/dist/hooks/stackTabsContext.d.ts +0 -1
- package/dist/hooks/tabPanel/evict.d.ts +2 -2
- package/dist/hooks/tabPanel/refresh.d.ts +4 -2
- package/dist/hooks/tabPanel/scroll.d.ts +0 -2
- package/dist/hooks/tabPanel/state.d.ts +0 -1
- package/dist/hooks/tabPanel/tabStore.d.ts +12 -1
- package/dist/hooks/useIframeManager.d.ts +1 -1
- package/dist/hooks/useScrollShadow.d.ts +11 -2
- package/dist/hooks/useTabActions.d.ts +1 -0
- package/dist/hooks/useTabEventBus.d.ts +5 -0
- package/dist/hooks/useTabPanel.d.ts +14 -2
- package/dist/model/TabModel.d.ts +17 -5
- package/dist/nuxt/module.mjs +1 -1
- package/dist/nuxt/runtime/plugin.mjs +8 -1598
- package/dist/style.css +1 -1
- package/dist/utils/scrollUtils.d.ts +0 -2
- package/dist/utils/tabInfoEncoder.d.ts +8 -0
- package/dist/utils/urlParser.d.ts +20 -10
- package/dist/vue-stack-tabs.es.js +1 -1
- package/package.json +4 -7
- package/src/lib/StackTabs.vue +40 -3
- package/src/lib/assets/image/close_all.svg +1 -9
- package/src/lib/assets/image/close_lefts.svg +1 -1
- package/src/lib/assets/image/close_other.svg +1 -1
- package/src/lib/assets/image/close_rights.svg +1 -1
- package/src/lib/assets/image/reload.svg +1 -3
- package/src/lib/assets/image/reload_all.svg +1 -6
- package/src/lib/assets/style/common/mixins.scss +58 -19
- package/src/lib/assets/style/common/var.scss +100 -60
- package/src/lib/assets/style/icon.scss +31 -98
- package/src/lib/assets/style/index.scss +2 -2
- package/src/lib/assets/style/loading.scss +45 -0
- package/src/lib/assets/style/{stackTab.scss → stack-tab.scss} +116 -182
- package/src/lib/assets/style/transition.scss +12 -17
- package/src/lib/components/ContextMenu/ContextMenuItem.vue +5 -5
- package/src/lib/components/ContextMenu/index.vue +22 -19
- package/src/lib/components/PageLoading.vue +12 -11
- package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +1 -5
- package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +5 -3
- package/src/lib/components/TabHeader/TabHeaderItem.vue +13 -7
- package/src/lib/components/TabHeader/TabHeaderScroll.vue +170 -94
- package/src/lib/components/TabHeader/index.vue +67 -12
- package/src/lib/hooks/stackTabsContext.ts +0 -1
- package/src/lib/hooks/tabPanel/evict.ts +14 -6
- package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +252 -7
- package/src/lib/hooks/tabPanel/refresh.ts +27 -9
- package/src/lib/hooks/tabPanel/scroll.ts +4 -15
- package/src/lib/hooks/tabPanel/session.ts +11 -4
- package/src/lib/hooks/tabPanel/state.ts +0 -2
- package/src/lib/hooks/tabPanel/tabStore.ts +114 -26
- package/src/lib/hooks/useContextMenu.ts +1 -1
- package/src/lib/hooks/useIframeManager.ts +21 -18
- package/src/lib/hooks/useScrollShadow.ts +74 -19
- package/src/lib/hooks/useTabActions.ts +18 -8
- package/src/lib/hooks/useTabEventBus.ts +3 -0
- package/src/lib/hooks/useTabPanel.tsx +247 -58
- package/src/lib/hooks/useTabRouter.ts +18 -11
- package/src/lib/model/TabModel.ts +19 -5
- package/src/lib/nuxt/module.ts +3 -1
- package/src/lib/nuxt/runtime/plugin.ts +7 -3
- package/src/lib/utils/scrollUtils.ts +0 -18
- package/src/lib/utils/tabInfoEncoder.ts +121 -18
- package/src/lib/utils/urlParser.ts +75 -48
- package/dist/vue-stack-tabs.css +0 -2
- package/src/lib/assets/style/overlayer.scss +0 -46
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { defu } from 'defu'
|
|
2
1
|
import { getCurrentInstance } from 'vue'
|
|
3
2
|
import type { ComponentInternalInstance } from 'vue'
|
|
4
3
|
import type { LocationQueryRaw, RouteLocationPathRaw } from 'vue-router'
|
|
@@ -9,7 +8,9 @@ import {
|
|
|
9
8
|
parseUrl,
|
|
10
9
|
cloneLocationQuery,
|
|
11
10
|
clonePage,
|
|
12
|
-
isSameQueryIgnoringReserved
|
|
11
|
+
isSameQueryIgnoringReserved,
|
|
12
|
+
normalizePathname,
|
|
13
|
+
omitStackTabsReservedQuery
|
|
13
14
|
} from '../utils/urlParser'
|
|
14
15
|
import { TabEventType, useTabEmitter } from './useTabEventBus'
|
|
15
16
|
|
|
@@ -84,10 +85,11 @@ export default function useTabRouter() {
|
|
|
84
85
|
const forward = (to: RouteLocationPathRaw) => {
|
|
85
86
|
const tab = getTab(tabId)
|
|
86
87
|
const targetQuery = (to.query || {}) as LocationQueryRaw
|
|
88
|
+
const targetPath = normalizePathname(to.path)
|
|
87
89
|
|
|
88
90
|
if (tab) {
|
|
89
91
|
const top = getTopPage(tab.id)
|
|
90
|
-
if (top && top.path ===
|
|
92
|
+
if (top && top.path === targetPath) {
|
|
91
93
|
if (isSameQueryIgnoringReserved(targetQuery, top.query || {})) {
|
|
92
94
|
if (!tab.active) {
|
|
93
95
|
activateTab(tab.id)
|
|
@@ -97,9 +99,10 @@ export default function useTabRouter() {
|
|
|
97
99
|
}
|
|
98
100
|
}
|
|
99
101
|
|
|
100
|
-
// __tab 是 StackTabs
|
|
101
|
-
//
|
|
102
|
-
const query =
|
|
102
|
+
// __tab 是 StackTabs 解析路由归属标签的关键:强制使用当前标签的编码,
|
|
103
|
+
// 业务 query 传入同名 key 时也不能把导航带到其它标签。
|
|
104
|
+
const query: LocationQueryRaw = { ...omitStackTabsReservedQuery(targetQuery) }
|
|
105
|
+
if (typeof tabInfo === 'string' && tabInfo) query.__tab = tabInfo
|
|
103
106
|
const transactionVersion = ++navigationVersion
|
|
104
107
|
emitter.emit(TabEventType.FORWARD)
|
|
105
108
|
|
|
@@ -112,8 +115,8 @@ export default function useTabRouter() {
|
|
|
112
115
|
appendPage(tab.id, {
|
|
113
116
|
id: optimisticPageId,
|
|
114
117
|
tabId: tab.id,
|
|
115
|
-
path:
|
|
116
|
-
query: cloneLocationQuery(
|
|
118
|
+
path: targetPath,
|
|
119
|
+
query: cloneLocationQuery(query)
|
|
117
120
|
})
|
|
118
121
|
}
|
|
119
122
|
return snapshot
|
|
@@ -156,13 +159,14 @@ export default function useTabRouter() {
|
|
|
156
159
|
} else {
|
|
157
160
|
const parsed = parseUrl(to)
|
|
158
161
|
const targetQuery = parsed.query || {}
|
|
162
|
+
const targetPath = normalizePathname(parsed.path)
|
|
159
163
|
let found = false
|
|
160
164
|
|
|
161
165
|
for (let i = pages.length - 2; i >= 0; i--) {
|
|
162
166
|
steps++
|
|
163
167
|
const page = pages[i]
|
|
164
168
|
if (
|
|
165
|
-
page?.path ===
|
|
169
|
+
page?.path === targetPath &&
|
|
166
170
|
isSameQueryIgnoringReserved(targetQuery, page?.query || {})
|
|
167
171
|
) {
|
|
168
172
|
found = true
|
|
@@ -210,8 +214,11 @@ export default function useTabRouter() {
|
|
|
210
214
|
|
|
211
215
|
emitter.emit(TabEventType.BACKWARD)
|
|
212
216
|
|
|
213
|
-
//
|
|
214
|
-
const targetQueryWithTab =
|
|
217
|
+
// 与 forward 一致:目标页固定归属当前标签,保留其余业务 query。
|
|
218
|
+
const targetQueryWithTab: LocationQueryRaw = {
|
|
219
|
+
...omitStackTabsReservedQuery(target.query || {})
|
|
220
|
+
}
|
|
221
|
+
if (typeof tabInfo === 'string' && tabInfo) targetQueryWithTab.__tab = tabInfo
|
|
215
222
|
const transactionVersion = ++navigationVersion
|
|
216
223
|
|
|
217
224
|
runNavigationTransaction({
|
|
@@ -17,22 +17,31 @@ export enum TabScrollMode {
|
|
|
17
17
|
BOTH = 'both'
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* @deprecated 标签栏拖拽内部已不再使用该类型;保留仅为兼容 2.x 的公开类型导出,
|
|
22
|
+
* 将在下一个大版本移除。
|
|
23
|
+
*/
|
|
21
24
|
export interface DragData {
|
|
22
25
|
thumbLeft: number
|
|
23
26
|
startScrollLeft: number
|
|
24
27
|
startThumbLeft: number
|
|
25
28
|
startPageX: number
|
|
26
29
|
}
|
|
27
|
-
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @deprecated 内部滚轮/滚动事件常量已不再使用该类型;保留仅为兼容 2.x 的公开类型导出,
|
|
33
|
+
* 将在下一个大版本移除。
|
|
34
|
+
*/
|
|
35
|
+
export type ScrollEvents = {
|
|
36
|
+
ScrollUpdate: void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 滚动条滑块计算所需的数据(滚动过程中保持非响应式) */
|
|
28
40
|
export interface ScrollData {
|
|
29
41
|
clientWidth: number
|
|
30
42
|
scrollWidth: number
|
|
31
43
|
scrollLeft: number
|
|
32
44
|
}
|
|
33
|
-
export type ScrollEvents = {
|
|
34
|
-
ScrollUpdate: void
|
|
35
|
-
}
|
|
36
45
|
|
|
37
46
|
/** 打开标签时传入的完整数据(含 path、query) */
|
|
38
47
|
export interface ITabData extends ITabBase {
|
|
@@ -64,6 +73,11 @@ export interface ITabItem extends ITabBase {
|
|
|
64
73
|
url?: string
|
|
65
74
|
active: boolean
|
|
66
75
|
pages: Stack<ITabPage>
|
|
76
|
+
/**
|
|
77
|
+
* 固定在标签栏最前方的内部标记(目前仅 homeTab 使用)。
|
|
78
|
+
* pinned 标签不可被拖动,其他标签也不能排到它前面;仅存在于运行时,不写入 __tab。
|
|
79
|
+
*/
|
|
80
|
+
pinned?: boolean
|
|
67
81
|
}
|
|
68
82
|
|
|
69
83
|
/** 标签内的单个页面(对应 keep-alive 的一个缓存实例) */
|
package/src/lib/nuxt/module.ts
CHANGED
|
@@ -34,7 +34,9 @@ export default defineNuxtModule<ModuleOptions>({
|
|
|
34
34
|
addComponent({
|
|
35
35
|
name: 'VueStackTabs',
|
|
36
36
|
export: 'VueStackTabs',
|
|
37
|
-
|
|
37
|
+
// 指向发布产物(dist/vue-stack-tabs.es.js)中的命名导出;
|
|
38
|
+
// 源码入口 ../index.ts 不会被发布,Nuxt 构建时会解析失败。
|
|
39
|
+
filePath: resolve('../vue-stack-tabs.es.js')
|
|
38
40
|
})
|
|
39
41
|
|
|
40
42
|
addImports([
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { defineNuxtPlugin, useRuntimeConfig } from 'nuxt/app'
|
|
5
5
|
import VueStackTabs from 'vue-stack-tabs'
|
|
6
6
|
import { applyStackTabsLocale } from '../../i18n/stackTabsLocale'
|
|
7
|
-
import { useI18n } from 'vue-i18n-lite'
|
|
7
|
+
import { useI18n, type I18n } from 'vue-i18n-lite'
|
|
8
8
|
|
|
9
9
|
export default defineNuxtPlugin((nuxtApp) => {
|
|
10
10
|
const config = useRuntimeConfig().public.vueStackTabs as {
|
|
@@ -17,6 +17,10 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|
|
17
17
|
|
|
18
18
|
// 兜底:应用 vue-stack-tabs 内部 i18n 实例的 locale 切换逻辑,
|
|
19
19
|
// 避免某些场景下 plugin options 未能正确初始化时 locale 仍是默认 zh-CN。
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
//
|
|
21
|
+
// 必须在 app 的注入上下文中取实例:插件顶层直接 inject() 拿不到 app.provide 的值。
|
|
22
|
+
nuxtApp.vueApp.runWithContext(() => {
|
|
23
|
+
const i18n = useI18n() as I18n | undefined
|
|
24
|
+
if (i18n?.changeLocale) applyStackTabsLocale(i18n.changeLocale, locale)
|
|
25
|
+
})
|
|
22
26
|
})
|
|
@@ -71,24 +71,6 @@ export function scrollIntoView({
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/** 获取系统滚动条宽度(用于布局计算) */
|
|
75
|
-
export const getScrollbarWidth = (function () {
|
|
76
|
-
let width: number | null = null
|
|
77
|
-
|
|
78
|
-
return function () {
|
|
79
|
-
if (width !== null) return width
|
|
80
|
-
|
|
81
|
-
const div = document.createElement('div')
|
|
82
|
-
|
|
83
|
-
div.style.cssText = 'width: 100px; height: 100px;overflow-y: scroll'
|
|
84
|
-
document.body.appendChild(div)
|
|
85
|
-
width = div.offsetWidth - div.clientWidth
|
|
86
|
-
div.parentElement?.removeChild(div)
|
|
87
|
-
|
|
88
|
-
return width
|
|
89
|
-
}
|
|
90
|
-
})()
|
|
91
|
-
|
|
92
74
|
const Z_INDEX_CEILING = 89999
|
|
93
75
|
|
|
94
76
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ITabBase } from '../model/TabModel'
|
|
2
|
+
import { isRecord } from './typeGuards'
|
|
2
3
|
|
|
3
4
|
interface EncodedTabInfoV1 {
|
|
4
5
|
version: 1
|
|
@@ -14,20 +15,42 @@ interface EncodedTabInfoV1 {
|
|
|
14
15
|
* 标签信息编解码器
|
|
15
16
|
*
|
|
16
17
|
* 职责:将标签元数据编码为 URL query 可用的短字符串,用于 __tab 参数。
|
|
17
|
-
* 当前格式为 JSON + Base64
|
|
18
|
+
* 当前格式为 JSON + Base64URL;解码同时兼容标准 Base64(历史链接)与
|
|
19
|
+
* 旧版 id|title|iframe|closable|refreshable|mode 编码。
|
|
18
20
|
*/
|
|
19
21
|
|
|
20
22
|
const MAX_DECODED_TAB_INFO_LENGTH = 2048
|
|
21
23
|
const MAX_ENCODED_TAB_INFO_LENGTH = 4096
|
|
24
|
+
/** URL 中 __tab 的 title 上限,避免超长标题把 payload 顶到解码上限导致 id 丢失。 */
|
|
25
|
+
const MAX_TAB_TITLE_LENGTH = 256
|
|
26
|
+
/** legacy 格式中 id 的合理上限,用于拒绝任意字符串伪装成标签 id。 */
|
|
27
|
+
const MAX_LEGACY_TAB_ID_LENGTH = 128
|
|
22
28
|
|
|
23
29
|
/**
|
|
24
|
-
*
|
|
25
|
-
* 使用
|
|
30
|
+
* 把 UTF-8 字符串编码为 Base64URL(无填充、`+`→`-`、`/`→`_`)。
|
|
31
|
+
* 使用 TextEncoder 取代已废弃的 escape/unescape,同时让结果可直接放进 URL。
|
|
26
32
|
*/
|
|
27
|
-
const
|
|
33
|
+
const encodeBase64Url = (value: string): string => {
|
|
34
|
+
const bytes = new TextEncoder().encode(value)
|
|
35
|
+
let binary = ''
|
|
36
|
+
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
37
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
38
|
+
}
|
|
28
39
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
40
|
+
/**
|
|
41
|
+
* 解码 Base64URL / 标准 Base64。
|
|
42
|
+
* 兼容旧链接:先还原 URL-safe 字符与填充,再按 UTF-8 解码。
|
|
43
|
+
*/
|
|
44
|
+
const decodeBase64 = (encoded: string): string => {
|
|
45
|
+
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
|
|
46
|
+
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
|
|
47
|
+
const binary = atob(padded)
|
|
48
|
+
const bytes = new Uint8Array(binary.length)
|
|
49
|
+
for (let index = 0; index < binary.length; index++) {
|
|
50
|
+
bytes[index] = binary.charCodeAt(index)
|
|
51
|
+
}
|
|
52
|
+
return new TextDecoder().decode(bytes)
|
|
53
|
+
}
|
|
31
54
|
|
|
32
55
|
const DEFAULT_TAB_INFO: ITabBase = {
|
|
33
56
|
id: '',
|
|
@@ -38,7 +61,14 @@ const DEFAULT_TAB_INFO: ITabBase = {
|
|
|
38
61
|
iframeRefreshMode: 'postMessage'
|
|
39
62
|
}
|
|
40
63
|
|
|
41
|
-
|
|
64
|
+
/** 检测控制字符,避免 legacy id 携带不可见字符。 */
|
|
65
|
+
const hasControlCharacter = (value: string): boolean => {
|
|
66
|
+
for (const char of value) {
|
|
67
|
+
const code = char.charCodeAt(0)
|
|
68
|
+
if (code <= 31 || code === 127) return true
|
|
69
|
+
}
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
42
72
|
|
|
43
73
|
const toTabInfo = (payload: unknown): ITabBase => {
|
|
44
74
|
if (!isRecord(payload)) return { ...DEFAULT_TAB_INFO }
|
|
@@ -54,16 +84,36 @@ const toTabInfo = (payload: unknown): ITabBase => {
|
|
|
54
84
|
}
|
|
55
85
|
}
|
|
56
86
|
|
|
87
|
+
/**
|
|
88
|
+
* 旧版分隔符格式:id|title|iframe|closable|refreshable[|mode]。
|
|
89
|
+
*
|
|
90
|
+
* 严格校验字段数量与 Y/N 标记:非法字符串(例如 `?__tab=invalid`)不能再被解析成
|
|
91
|
+
* 任意 id 的标签。格式不合法时返回默认值,由路由层走兜底标签逻辑。
|
|
92
|
+
*/
|
|
57
93
|
const decodeLegacyTabInfo = (tabString: string): ITabBase => {
|
|
58
94
|
const tabValues: string[] = tabString.split('|')
|
|
59
|
-
|
|
95
|
+
if (tabValues.length < 5 || tabValues.length > 6) return { ...DEFAULT_TAB_INFO }
|
|
96
|
+
|
|
97
|
+
const [id = '', title = '', iframe, closable, refreshable, mode] = tabValues
|
|
98
|
+
const isFlag = (value: string | undefined): value is 'Y' | 'N' => value === 'Y' || value === 'N'
|
|
99
|
+
const hasValidId =
|
|
100
|
+
id.length > 0 &&
|
|
101
|
+
id.length <= MAX_LEGACY_TAB_ID_LENGTH &&
|
|
102
|
+
id.trim() === id &&
|
|
103
|
+
!hasControlCharacter(id)
|
|
104
|
+
|
|
105
|
+
if (!hasValidId || !isFlag(iframe) || !isFlag(closable) || !isFlag(refreshable)) {
|
|
106
|
+
return { ...DEFAULT_TAB_INFO }
|
|
107
|
+
}
|
|
108
|
+
if (mode !== undefined && mode !== '' && mode !== 'R') return { ...DEFAULT_TAB_INFO }
|
|
109
|
+
|
|
60
110
|
return {
|
|
61
|
-
id
|
|
62
|
-
title
|
|
63
|
-
iframe:
|
|
64
|
-
closable:
|
|
65
|
-
refreshable:
|
|
66
|
-
iframeRefreshMode:
|
|
111
|
+
id,
|
|
112
|
+
title,
|
|
113
|
+
iframe: iframe === 'Y',
|
|
114
|
+
closable: closable === 'Y',
|
|
115
|
+
refreshable: refreshable === 'Y',
|
|
116
|
+
iframeRefreshMode: mode === 'R' ? 'reload' : 'postMessage'
|
|
67
117
|
}
|
|
68
118
|
}
|
|
69
119
|
|
|
@@ -71,16 +121,16 @@ export const encodeTabInfo = (tabData: ITabBase): string => {
|
|
|
71
121
|
const payload: EncodedTabInfoV1 = {
|
|
72
122
|
version: 1,
|
|
73
123
|
id: tabData.id,
|
|
74
|
-
title: tabData.title,
|
|
124
|
+
title: tabData.title.slice(0, MAX_TAB_TITLE_LENGTH),
|
|
75
125
|
iframe: tabData.iframe,
|
|
76
126
|
closable: tabData.closable,
|
|
77
127
|
refreshable: tabData.refreshable,
|
|
78
128
|
iframeRefreshMode: tabData.iframeRefreshMode === 'reload' ? 'reload' : 'postMessage'
|
|
79
129
|
}
|
|
80
|
-
return
|
|
130
|
+
return encodeBase64Url(JSON.stringify(payload))
|
|
81
131
|
}
|
|
82
132
|
|
|
83
|
-
|
|
133
|
+
const decodeTabInfoUncached = (encoded: string): ITabBase => {
|
|
84
134
|
if (typeof encoded !== 'string' || encoded.length > MAX_ENCODED_TAB_INFO_LENGTH) {
|
|
85
135
|
return { ...DEFAULT_TAB_INFO }
|
|
86
136
|
}
|
|
@@ -102,4 +152,57 @@ export const decodeTabInfo = (encoded: string): ITabBase => {
|
|
|
102
152
|
}
|
|
103
153
|
}
|
|
104
154
|
|
|
105
|
-
|
|
155
|
+
/**
|
|
156
|
+
* 单条解码缓存:同一 __tab 在一次会话/连续导航中会反复出现,
|
|
157
|
+
* 缓存最近一次结果可省掉 Base64 + TextDecoder + JSON.parse。
|
|
158
|
+
* 返回值始终是副本,避免调用方修改缓存对象。
|
|
159
|
+
*/
|
|
160
|
+
let cachedEncodedTabInfo = ''
|
|
161
|
+
let cachedTabInfo: ITabBase | null = null
|
|
162
|
+
|
|
163
|
+
export const decodeTabInfo = (encoded: string): ITabBase => {
|
|
164
|
+
if (encoded === cachedEncodedTabInfo && cachedTabInfo) {
|
|
165
|
+
return { ...cachedTabInfo }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const decoded = decodeTabInfoUncached(encoded)
|
|
169
|
+
cachedEncodedTabInfo = encoded
|
|
170
|
+
cachedTabInfo = decoded
|
|
171
|
+
return { ...decoded }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 生成运行时 id。
|
|
176
|
+
*
|
|
177
|
+
* `crypto.randomUUID` 仅在安全上下文(https / localhost)可用;内网 http 部署时
|
|
178
|
+
* 回退到 `crypto.getRandomValues`(非安全上下文同样可用),最后才退回 Math.random,
|
|
179
|
+
* 避免在受限环境下直接抛 TypeError 导致整个组件不可用。
|
|
180
|
+
*/
|
|
181
|
+
export const createId = (): string => {
|
|
182
|
+
const cryptoObject = globalThis.crypto
|
|
183
|
+
|
|
184
|
+
if (typeof cryptoObject?.randomUUID === 'function') {
|
|
185
|
+
return cryptoObject.randomUUID()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const bytes = new Uint8Array(16)
|
|
189
|
+
if (typeof cryptoObject?.getRandomValues === 'function') {
|
|
190
|
+
cryptoObject.getRandomValues(bytes)
|
|
191
|
+
} else {
|
|
192
|
+
for (let index = 0; index < bytes.length; index++) {
|
|
193
|
+
bytes[index] = Math.floor(Math.random() * 256)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// RFC 4122 version 4 + variant bits
|
|
198
|
+
bytes[6] = (bytes[6]! & 0x0f) | 0x40
|
|
199
|
+
bytes[8] = (bytes[8]! & 0x3f) | 0x80
|
|
200
|
+
|
|
201
|
+
const hex: string[] = []
|
|
202
|
+
for (const byte of bytes) hex.push(byte.toString(16).padStart(2, '0'))
|
|
203
|
+
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex
|
|
204
|
+
.slice(6, 8)
|
|
205
|
+
.join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export const createPageId = (): string => createId()
|
|
@@ -44,15 +44,6 @@ export function isAllowedTabUrl(url: string): boolean {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/**
|
|
48
|
-
* 判断是否为不允许嵌入 iframe 的 URL
|
|
49
|
-
* @param url - 待检查的 URL 字符串
|
|
50
|
-
* @returns 非 allowlist URL 返回 true
|
|
51
|
-
*/
|
|
52
|
-
export function isInvalidIframeUrl(url: string): boolean {
|
|
53
|
-
return !isAllowedTabUrl(url)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
47
|
/** 非法 URL 降级为 about:blank,避免进入 iframe/window.open 等 URL sink */
|
|
57
48
|
export function toSafeTabUrl(url: string): string {
|
|
58
49
|
return isAllowedTabUrl(url) ? url : 'about:blank'
|
|
@@ -68,28 +59,42 @@ export function decodeSafeTabUrl(encodedUrl: unknown): string {
|
|
|
68
59
|
}
|
|
69
60
|
|
|
70
61
|
/**
|
|
71
|
-
* 判断 URL
|
|
62
|
+
* 判断 URL 是否为跨域(相对路径或同源视为同域)。
|
|
63
|
+
*
|
|
64
|
+
* 统一用 `new URL(url, origin)` 解析,协议相对 URL(`//host/path`)也会按实际
|
|
65
|
+
* origin 判定,避免被误判为同源。`about:blank` 与相对路径视为同源。
|
|
72
66
|
* @param url - 待检查的 URL
|
|
73
67
|
*/
|
|
74
68
|
export function isCrossOriginUrl(url: string): boolean {
|
|
75
|
-
if (!url
|
|
69
|
+
if (!url) return false
|
|
70
|
+
const base = getUrlBase()
|
|
76
71
|
try {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
72
|
+
const parsed = new URL(url, base)
|
|
73
|
+
// 非 http(s) 不是「跨域」,而是非法 URL;由 isAllowedTabUrl 负责拒绝。
|
|
74
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
|
|
75
|
+
return parsed.origin !== new URL(base).origin
|
|
80
76
|
} catch {
|
|
81
77
|
return true
|
|
82
78
|
}
|
|
83
79
|
}
|
|
84
80
|
|
|
85
81
|
/**
|
|
86
|
-
* 解析 URL 字符串为 path 和
|
|
87
|
-
*
|
|
82
|
+
* 解析 URL 字符串为 path、query 和 hash。
|
|
83
|
+
*
|
|
84
|
+
* hash 会先从 query 中剥离,避免 `/page?x=1#section` 被解析成 `query.x = '1#section'`。
|
|
85
|
+
* @param uri 完整 URL 或 path?query#hash 字符串
|
|
88
86
|
*/
|
|
89
|
-
export function parseUrl(uri: string): {
|
|
90
|
-
|
|
87
|
+
export function parseUrl(uri: string): {
|
|
88
|
+
path: string
|
|
89
|
+
query: LocationQueryRaw
|
|
90
|
+
hash: string
|
|
91
|
+
} {
|
|
92
|
+
const hashIndex = uri.indexOf('#')
|
|
93
|
+
const hash = hashIndex >= 0 ? uri.slice(hashIndex) : ''
|
|
94
|
+
const withoutHash = hashIndex >= 0 ? uri.slice(0, hashIndex) : uri
|
|
95
|
+
const [pathPart = '', queryPart] = withoutHash.split('?')
|
|
91
96
|
if (queryPart === undefined) {
|
|
92
|
-
return { path: pathPart, query: {} }
|
|
97
|
+
return { path: pathPart, query: {}, hash }
|
|
93
98
|
}
|
|
94
99
|
|
|
95
100
|
const params = new URLSearchParams(queryPart)
|
|
@@ -102,7 +107,17 @@ export function parseUrl(uri: string): { path: string; query: LocationQueryRaw }
|
|
|
102
107
|
}
|
|
103
108
|
query[key] = Array.isArray(existing) ? [...existing, value] : [existing, value]
|
|
104
109
|
}
|
|
105
|
-
return { path: pathPart, query }
|
|
110
|
+
return { path: pathPart, query, hash }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 规范化 pathname:去掉末尾多余斜杠(根路径 `/` 保留)。
|
|
115
|
+
* 页面栈比较与写入统一使用,避免 `/list` 与 `/list/` 被当成两个页面。
|
|
116
|
+
*/
|
|
117
|
+
export function normalizePathname(path: string): string {
|
|
118
|
+
if (!path || path === '/') return path
|
|
119
|
+
const normalized = path.replace(/\/+$/, '')
|
|
120
|
+
return normalized || '/'
|
|
106
121
|
}
|
|
107
122
|
|
|
108
123
|
/** 浅克隆 LocationQueryRaw,避免引用共享导致的副作用 */
|
|
@@ -128,39 +143,51 @@ export function clonePage<T extends CloneablePage>(page: T): T {
|
|
|
128
143
|
}
|
|
129
144
|
}
|
|
130
145
|
|
|
131
|
-
/**
|
|
146
|
+
/** 判断 key 是否为 vue-stack-tabs 内部保留参数(含后退参数 _back) */
|
|
147
|
+
const isIgnoredQueryKey = (key: string): boolean =>
|
|
148
|
+
STACK_TABS_RESERVED_QUERY_KEYS.has(key) || key === '_back'
|
|
149
|
+
|
|
150
|
+
/** 比较两个 query 值:数组按元素逐个比较,标量按字符串比较,并区分数组与标量 */
|
|
151
|
+
const isSameQueryValue = (a: LocationQueryRaw[string], b: LocationQueryRaw[string]): boolean => {
|
|
152
|
+
const isArrayA = Array.isArray(a)
|
|
153
|
+
const isArrayB = Array.isArray(b)
|
|
154
|
+
if (isArrayA || isArrayB) {
|
|
155
|
+
if (!isArrayA || !isArrayB || a.length !== b.length) return false
|
|
156
|
+
for (let index = 0; index < a.length; index++) {
|
|
157
|
+
const itemA = a[index]
|
|
158
|
+
const itemB = b[index]
|
|
159
|
+
if (itemA !== itemB && String(itemA ?? '') !== String(itemB ?? '')) return false
|
|
160
|
+
}
|
|
161
|
+
return true
|
|
162
|
+
}
|
|
163
|
+
// 绝大多数 query 值是字符串:先做值直比,避免每次都 String() 分配。
|
|
164
|
+
return a === b || String(a ?? '') === String(b ?? '')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 忽略 __tab/__src/_back 等内部保留 key 后比较两个 query 是否等价。
|
|
169
|
+
*
|
|
170
|
+
* 直接按键逐一比较,不构造中间对象;同时要求 key 集合完全一致
|
|
171
|
+
* (旧实现只比较 key 数量,`{ a: '' }` 与 `{ b: '' }` 会被误判为相等)。
|
|
172
|
+
*/
|
|
132
173
|
export function isSameQueryIgnoringReserved(
|
|
133
174
|
a: LocationQueryRaw = {},
|
|
134
175
|
b: LocationQueryRaw = {}
|
|
135
176
|
): boolean {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
: String(value ?? '')
|
|
143
|
-
}
|
|
144
|
-
return result
|
|
177
|
+
let countA = 0
|
|
178
|
+
for (const key of Object.keys(a)) {
|
|
179
|
+
if (isIgnoredQueryKey(key)) continue
|
|
180
|
+
countA++
|
|
181
|
+
if (!Object.prototype.hasOwnProperty.call(b, key)) return false
|
|
182
|
+
if (!isSameQueryValue(a[key], b[key])) return false
|
|
145
183
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const bValue = cb[key]
|
|
154
|
-
if (Array.isArray(aValue) || Array.isArray(bValue)) {
|
|
155
|
-
return (
|
|
156
|
-
Array.isArray(aValue) &&
|
|
157
|
-
Array.isArray(bValue) &&
|
|
158
|
-
aValue.length === bValue.length &&
|
|
159
|
-
aValue.every((value, index) => value === bValue[index])
|
|
160
|
-
)
|
|
161
|
-
}
|
|
162
|
-
return aValue === bValue
|
|
163
|
-
})
|
|
184
|
+
|
|
185
|
+
let countB = 0
|
|
186
|
+
for (const key of Object.keys(b)) {
|
|
187
|
+
if (isIgnoredQueryKey(key)) continue
|
|
188
|
+
countB++
|
|
189
|
+
}
|
|
190
|
+
return countA === countB
|
|
164
191
|
}
|
|
165
192
|
|
|
166
193
|
/** 移除 query 中的 vue-stack-tabs 保留参数(__tab、__src),返回干净的 query */
|