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.
Files changed (71) hide show
  1. package/README.en.md +1 -0
  2. package/README.md +1 -0
  3. package/README.ru.md +1 -0
  4. package/dist/StackTabs.vue.d.ts +5 -2
  5. package/dist/components/ContextMenu/ContextMenuItem.vue.d.ts +1 -1
  6. package/dist/components/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -2
  7. package/dist/hooks/stackTabsContext.d.ts +0 -1
  8. package/dist/hooks/tabPanel/evict.d.ts +2 -2
  9. package/dist/hooks/tabPanel/refresh.d.ts +4 -2
  10. package/dist/hooks/tabPanel/scroll.d.ts +0 -2
  11. package/dist/hooks/tabPanel/state.d.ts +0 -1
  12. package/dist/hooks/tabPanel/tabStore.d.ts +12 -1
  13. package/dist/hooks/useIframeManager.d.ts +1 -1
  14. package/dist/hooks/useScrollShadow.d.ts +11 -2
  15. package/dist/hooks/useTabActions.d.ts +1 -0
  16. package/dist/hooks/useTabEventBus.d.ts +5 -0
  17. package/dist/hooks/useTabPanel.d.ts +14 -2
  18. package/dist/model/TabModel.d.ts +17 -5
  19. package/dist/nuxt/module.mjs +1 -1
  20. package/dist/nuxt/runtime/plugin.mjs +8 -1598
  21. package/dist/style.css +1 -1
  22. package/dist/utils/scrollUtils.d.ts +0 -2
  23. package/dist/utils/tabInfoEncoder.d.ts +8 -0
  24. package/dist/utils/urlParser.d.ts +20 -10
  25. package/dist/vue-stack-tabs.es.js +1 -1
  26. package/package.json +4 -7
  27. package/src/lib/StackTabs.vue +40 -3
  28. package/src/lib/assets/image/close_all.svg +1 -9
  29. package/src/lib/assets/image/close_lefts.svg +1 -1
  30. package/src/lib/assets/image/close_other.svg +1 -1
  31. package/src/lib/assets/image/close_rights.svg +1 -1
  32. package/src/lib/assets/image/reload.svg +1 -3
  33. package/src/lib/assets/image/reload_all.svg +1 -6
  34. package/src/lib/assets/style/common/mixins.scss +58 -19
  35. package/src/lib/assets/style/common/var.scss +100 -60
  36. package/src/lib/assets/style/icon.scss +31 -98
  37. package/src/lib/assets/style/index.scss +2 -2
  38. package/src/lib/assets/style/loading.scss +45 -0
  39. package/src/lib/assets/style/{stackTab.scss → stack-tab.scss} +116 -182
  40. package/src/lib/assets/style/transition.scss +12 -17
  41. package/src/lib/components/ContextMenu/ContextMenuItem.vue +5 -5
  42. package/src/lib/components/ContextMenu/index.vue +22 -19
  43. package/src/lib/components/PageLoading.vue +12 -11
  44. package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +1 -5
  45. package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +5 -3
  46. package/src/lib/components/TabHeader/TabHeaderItem.vue +13 -7
  47. package/src/lib/components/TabHeader/TabHeaderScroll.vue +170 -94
  48. package/src/lib/components/TabHeader/index.vue +67 -12
  49. package/src/lib/hooks/stackTabsContext.ts +0 -1
  50. package/src/lib/hooks/tabPanel/evict.ts +14 -6
  51. package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +252 -7
  52. package/src/lib/hooks/tabPanel/refresh.ts +27 -9
  53. package/src/lib/hooks/tabPanel/scroll.ts +4 -15
  54. package/src/lib/hooks/tabPanel/session.ts +11 -4
  55. package/src/lib/hooks/tabPanel/state.ts +0 -2
  56. package/src/lib/hooks/tabPanel/tabStore.ts +114 -26
  57. package/src/lib/hooks/useContextMenu.ts +1 -1
  58. package/src/lib/hooks/useIframeManager.ts +21 -18
  59. package/src/lib/hooks/useScrollShadow.ts +74 -19
  60. package/src/lib/hooks/useTabActions.ts +18 -8
  61. package/src/lib/hooks/useTabEventBus.ts +3 -0
  62. package/src/lib/hooks/useTabPanel.tsx +247 -58
  63. package/src/lib/hooks/useTabRouter.ts +18 -11
  64. package/src/lib/model/TabModel.ts +19 -5
  65. package/src/lib/nuxt/module.ts +3 -1
  66. package/src/lib/nuxt/runtime/plugin.ts +7 -3
  67. package/src/lib/utils/scrollUtils.ts +0 -18
  68. package/src/lib/utils/tabInfoEncoder.ts +121 -18
  69. package/src/lib/utils/urlParser.ts +75 -48
  70. package/dist/vue-stack-tabs.css +0 -2
  71. 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 === to.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 解析路由归属标签的关键,优先沿用目标 query 已有的值,
101
- // 避免被 to.query 中非字符串的同名 key 或缺失值覆盖。
102
- const query = defu(to.query, { __tab: tabInfo })
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: to.path,
116
- query: cloneLocationQuery(targetQuery)
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 === parsed.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
- // 栈顶 query 已带 __tab 时优先沿用,避免被默认 tabInfo 覆盖导致解析时识别错乱。
214
- const targetQueryWithTab = defu(target.query || {}, { __tab: tabInfo })
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 的一个缓存实例) */
@@ -34,7 +34,9 @@ export default defineNuxtModule<ModuleOptions>({
34
34
  addComponent({
35
35
  name: 'VueStackTabs',
36
36
  export: 'VueStackTabs',
37
- filePath: resolve('../index.ts')
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
- const { changeLocale } = useI18n()
21
- applyStackTabsLocale(changeLocale, locale)
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,兼容旧版 id|title|iframe|closable|refreshable|mode 编码。
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
- * 把任意 UTF-8 字符串安全地编码为 base64(避免中文/特殊字符直接 btoa 抛错)。
25
- * 使用 encodeURIComponent + unescape 是经典跨浏览器方案,比手动管理 TextEncoder 更简洁。
30
+ * 把 UTF-8 字符串编码为 Base64URL(无填充、`+`→`-`、`/`→`_`)。
31
+ * 使用 TextEncoder 取代已废弃的 escape/unescape,同时让结果可直接放进 URL。
26
32
  */
27
- const encodeBase64 = (value: string): string => btoa(unescape(encodeURIComponent(value)))
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
- /** encodeBase64 的逆操作,非合法 base64 时抛错,由调用方 try/catch。 */
30
- const decodeBase64 = (encoded: string): string => decodeURIComponent(escape(atob(encoded)))
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
- import { isRecord } from './typeGuards'
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
- const refreshMode = tabValues[5] === 'R' ? ('reload' as const) : ('postMessage' as const)
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: tabValues[0] ?? '',
62
- title: tabValues[1] ?? '',
63
- iframe: tabValues[2] === 'Y',
64
- closable: tabValues[3] === 'Y',
65
- refreshable: tabValues[4] === 'Y',
66
- iframeRefreshMode: refreshMode
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 encodeBase64(JSON.stringify(payload))
130
+ return encodeBase64Url(JSON.stringify(payload))
81
131
  }
82
132
 
83
- export const decodeTabInfo = (encoded: string): ITabBase => {
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
- export const createPageId = (): string => crypto.randomUUID()
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 || !/^https?:\/\//i.test(url)) return false
69
+ if (!url) return false
70
+ const base = getUrlBase()
76
71
  try {
77
- const cur = typeof window !== 'undefined' ? window.location : { origin: 'http://localhost' }
78
- const u = new URL(url, cur.origin)
79
- return u.origin !== cur.origin
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 和 query
87
- * @param uri 完整 URL 或 path?query 字符串
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): { path: string; query: LocationQueryRaw } {
90
- const [pathPart = '', queryPart] = uri.split('?')
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
- /** 忽略 __tab/__src/_back 等内部保留 key 后比较两个 query 是否等价 */
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
- const clean = (q: LocationQueryRaw): Record<string, string | string[]> => {
137
- const result: Record<string, string | string[]> = {}
138
- for (const [key, value] of Object.entries(q)) {
139
- if (STACK_TABS_RESERVED_QUERY_KEYS.has(key) || key === '_back') continue
140
- result[key] = Array.isArray(value)
141
- ? value.map((item) => String(item ?? ''))
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
- const ca = clean(a)
147
- const cb = clean(b)
148
- const keysA = Object.keys(ca)
149
- const keysB = Object.keys(cb)
150
- if (keysA.length !== keysB.length) return false
151
- return keysA.every((key) => {
152
- const aValue = ca[key]
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 */