vue-stack-tabs 1.3.1 → 1.4.2

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 (97) hide show
  1. package/README.en.md +119 -52
  2. package/README.md +103 -42
  3. package/README.ru.md +125 -54
  4. package/dist/{src/lib/StackTabs.vue.d.ts → StackTabs.vue.d.ts} +5 -5
  5. package/dist/{src/lib/components → components}/ContextMenu/index.vue.d.ts +1 -1
  6. package/dist/components/StackKeepAlive/emptyPlaceholder.d.ts +3 -0
  7. package/dist/{src/lib/components → components}/TabHeader/TabHeaderItem.vue.d.ts +9 -0
  8. package/dist/{src/lib/components → components}/TabHeader/index.vue.d.ts +3 -3
  9. package/dist/{src/lib/hooks → hooks}/stackTabsContext.d.ts +5 -2
  10. package/dist/hooks/tabPanel/pageComponentFactory.d.ts +23 -0
  11. package/dist/{src/lib/hooks → hooks}/tabPanel/refresh.d.ts +6 -3
  12. package/dist/{src/lib/hooks → hooks}/tabPanel/scroll.d.ts +2 -0
  13. package/dist/hooks/tabPanel/tabStore.d.ts +69 -0
  14. package/dist/hooks/useScrollShadow.d.ts +20 -0
  15. package/dist/{src/lib/hooks → hooks}/useTabActions.d.ts +7 -2
  16. package/dist/{src/lib/hooks → hooks}/useTabEventBus.d.ts +2 -1
  17. package/dist/{src/lib/hooks → hooks}/useTabPanel.d.ts +16 -4
  18. package/dist/iframe-bridge.mjs +1 -1
  19. package/dist/index.d.ts +22 -6
  20. package/dist/{src/lib/model → model}/TabModel.d.ts +21 -0
  21. package/dist/nuxt/module.mjs +10 -6
  22. package/dist/nuxt/runtime/plugin.mjs +1600 -4
  23. package/dist/style.css +1 -1
  24. package/dist/utils/iframeBridge.d.ts +7 -1
  25. package/dist/{src/lib/utils → utils}/scrollUtils.d.ts +7 -1
  26. package/dist/{src/lib/versionLogger.d.ts → versionLogger.d.ts} +1 -1
  27. package/dist/vue-stack-tabs.css +1 -1
  28. package/dist/vue-stack-tabs.es.js +1 -1
  29. package/package.json +64 -40
  30. package/src/lib/StackTabs.vue +16 -3
  31. package/src/lib/assets/style/common/var.scss +2 -0
  32. package/src/lib/assets/style/stackTab.scss +17 -3
  33. package/src/lib/components/ContextMenu/index.vue +12 -3
  34. package/src/lib/components/PageLoading.vue +10 -2
  35. package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +3 -9
  36. package/src/lib/components/StackKeepAlive/emptyPlaceholder.tsx +18 -0
  37. package/src/lib/components/TabHeader/index.vue +4 -3
  38. package/src/lib/hooks/stackTabsContext.ts +23 -18
  39. package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +136 -0
  40. package/src/lib/hooks/tabPanel/refresh.ts +20 -13
  41. package/src/lib/hooks/tabPanel/scroll.ts +110 -7
  42. package/src/lib/hooks/tabPanel/session.ts +29 -3
  43. package/src/lib/hooks/tabPanel/tabStore.ts +595 -0
  44. package/src/lib/hooks/useContextMenu.ts +57 -11
  45. package/src/lib/hooks/useIframeManager.ts +18 -2
  46. package/src/lib/hooks/useScrollShadow.ts +124 -0
  47. package/src/lib/hooks/useTabActions.ts +18 -8
  48. package/src/lib/hooks/useTabEventBus.ts +5 -3
  49. package/src/lib/hooks/useTabPanel.tsx +183 -302
  50. package/src/lib/hooks/useTabRouter.ts +42 -38
  51. package/src/lib/i18n/index.ts +3 -3
  52. package/src/lib/model/TabModel.ts +40 -1
  53. package/src/lib/nuxt/module.ts +10 -3
  54. package/src/lib/nuxt/runtime/plugin.ts +13 -2
  55. package/src/lib/utils/iframeBridge.ts +9 -1
  56. package/src/lib/utils/scrollUtils.ts +35 -2
  57. package/src/lib/utils/tabInfoEncoder.ts +21 -8
  58. package/src/lib/utils/urlParser.ts +3 -1
  59. package/src/lib/versionLogger.ts +6 -2
  60. package/dist/favicon.svg +0 -10
  61. package/dist/iframe-bridge.html +0 -77
  62. package/dist/iframe-child.html +0 -88
  63. package/dist/nuxt/favicon.svg +0 -10
  64. package/dist/nuxt/iframe-bridge.html +0 -77
  65. package/dist/nuxt/iframe-child.html +0 -88
  66. package/dist/nuxt/plugin.mjs +0 -9
  67. package/dist/src/lib/iframe-bridge.d.ts +0 -8
  68. package/dist/src/lib/index.d.ts +0 -22
  69. package/dist/src/lib/nuxt/module.d.ts +0 -6
  70. package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +0 -2
  71. package/dist/src/lib/nuxt/runtime/plugin.d.ts +0 -2
  72. package/dist/src/lib/utils/iframeBridge.d.ts +0 -39
  73. package/src/lib/nuxt/runtime/plugin.client.ts +0 -10
  74. /package/dist/{src/lib/components → components}/ContextMenu/ContextMenuItem.vue.d.ts +0 -0
  75. /package/dist/{src/lib/components → components}/PageLoading.vue.d.ts +0 -0
  76. /package/dist/{src/lib/components → components}/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -0
  77. /package/dist/{src/lib/components → components}/StackKeepAlive/StackKeepAlive.vue.d.ts +0 -0
  78. /package/dist/{src/lib/components → components}/TabHeader/TabHeaderButton.vue.d.ts +0 -0
  79. /package/dist/{src/lib/components → components}/TabHeader/TabHeaderScroll.vue.d.ts +0 -0
  80. /package/dist/{src/lib/hooks → hooks}/tabPanel/evict.d.ts +0 -0
  81. /package/dist/{src/lib/hooks → hooks}/tabPanel/navigationTransaction.d.ts +0 -0
  82. /package/dist/{src/lib/hooks → hooks}/tabPanel/session.d.ts +0 -0
  83. /package/dist/{src/lib/hooks → hooks}/tabPanel/state.d.ts +0 -0
  84. /package/dist/{src/lib/hooks → hooks}/useContextMenu.d.ts +0 -0
  85. /package/dist/{src/lib/hooks → hooks}/useIframeManager.d.ts +0 -0
  86. /package/dist/{src/lib/hooks → hooks}/useTabLoading.d.ts +0 -0
  87. /package/dist/{src/lib/hooks → hooks}/useTabRouter.d.ts +0 -0
  88. /package/dist/{src/lib/i18n → i18n}/index.d.ts +0 -0
  89. /package/dist/{src/lib/i18n → i18n}/lang/en.d.ts +0 -0
  90. /package/dist/{src/lib/i18n → i18n}/lang/zh-CN.d.ts +0 -0
  91. /package/dist/{src/lib/i18n → i18n}/stackTabsLocale.d.ts +0 -0
  92. /package/dist/{src/lib/iframe.vue.d.ts → iframe.vue.d.ts} +0 -0
  93. /package/dist/{src/lib/utils → utils}/stackTabsA11y.d.ts +0 -0
  94. /package/dist/{src/lib/utils → utils}/stackTabsMessage.d.ts +0 -0
  95. /package/dist/{src/lib/utils → utils}/tabInfoEncoder.d.ts +0 -0
  96. /package/dist/{src/lib/utils → utils}/typeGuards.d.ts +0 -0
  97. /package/dist/{src/lib/utils → utils}/urlParser.d.ts +0 -0
@@ -2,7 +2,6 @@ import { defu } from 'defu'
2
2
  import { getCurrentInstance } from 'vue'
3
3
  import type { ComponentInternalInstance } from 'vue'
4
4
  import type { LocationQueryRaw, RouteLocationPathRaw } from 'vue-router'
5
- import type { ITabPage } from '../model/TabModel'
6
5
  import { isNavigationFailure, useRoute, useRouter } from 'vue-router'
7
6
 
8
7
  import useTabPanel from './useTabPanel'
@@ -18,18 +17,6 @@ import { runNavigationTransaction } from './tabPanel/navigationTransaction'
18
17
 
19
18
  import { createPageId } from '../utils/tabInfoEncoder'
20
19
 
21
- interface PageStackLike {
22
- clear: () => void
23
- push: (page: ITabPage) => void
24
- }
25
-
26
- const restorePages = (stack: PageStackLike, pages: ITabPage[]) => {
27
- stack.clear()
28
- for (const page of pages) {
29
- stack.push(clonePage(page))
30
- }
31
- }
32
-
33
20
  /**
34
21
  * useTabRouter - 标签内路由 Hook
35
22
  *
@@ -62,7 +49,19 @@ export default function useTabRouter() {
62
49
  const route = useRoute()
63
50
  const router = useRouter()
64
51
  const emitter = useTabEmitter()
65
- const { getTab, evictPageCache, addPageScroller, active: activateTab } = useTabPanel()
52
+ const {
53
+ getTab,
54
+ getTopPage,
55
+ getPages,
56
+ appendPage,
57
+ popPages,
58
+ getPageSnapshot,
59
+ replacePages,
60
+ updatePage,
61
+ evictPageCache,
62
+ addPageScroller,
63
+ active: activateTab
64
+ } = useTabPanel()
66
65
 
67
66
  /**
68
67
  * 当前标签的编码信息字符串,存于 URL query.__tab。
@@ -87,7 +86,7 @@ export default function useTabRouter() {
87
86
  const targetQuery = (to.query || {}) as LocationQueryRaw
88
87
 
89
88
  if (tab) {
90
- const top = tab.pages.peek()
89
+ const top = getTopPage(tab.id)
91
90
  if (top && top.path === to.path) {
92
91
  if (isSameQueryIgnoringReserved(targetQuery, top.query || {})) {
93
92
  if (!tab.active) {
@@ -98,17 +97,19 @@ export default function useTabRouter() {
98
97
  }
99
98
  }
100
99
 
101
- const query = defu({ __tab: tabInfo }, to.query)
100
+ // __tab 是 StackTabs 解析路由归属标签的关键,优先沿用目标 query 已有的值,
101
+ // 避免被 to.query 中非字符串的同名 key 或缺失值覆盖。
102
+ const query = defu(to.query, { __tab: tabInfo })
102
103
  const transactionVersion = ++navigationVersion
103
104
  emitter.emit(TabEventType.FORWARD)
104
105
 
105
106
  let optimisticPageId = ''
106
107
  runNavigationTransaction({
107
108
  apply: () => {
108
- const snapshot = tab ? tab.pages.readonlyList().map((p) => clonePage(p)) : []
109
+ const snapshot = tab ? getPageSnapshot(tab.id) : []
109
110
  if (tab) {
110
111
  optimisticPageId = createPageId()
111
- tab.pages.push({
112
+ appendPage(tab.id, {
112
113
  id: optimisticPageId,
113
114
  tabId: tab.id,
114
115
  path: to.path,
@@ -119,14 +120,14 @@ export default function useTabRouter() {
119
120
  },
120
121
  navigate: () => router.push({ path: to.path, query }),
121
122
  rollback: (snapshot) => {
122
- if (tab) restorePages(tab.pages, snapshot)
123
+ if (tab) replacePages(tab.id, snapshot)
123
124
  },
124
125
  isFailureResult: isNavigationFailure,
125
126
  isCurrent: () => navigationVersion === transactionVersion,
126
127
  cleanupStale: () => {
127
128
  if (!tab || !optimisticPageId) return
128
- const pages = tab.pages.list().filter((page) => page.id !== optimisticPageId)
129
- restorePages(tab.pages, pages)
129
+ const pages = getPages(tab.id).filter((page) => page.id !== optimisticPageId)
130
+ replacePages(tab.id, pages)
130
131
  }
131
132
  }).catch((error: unknown) => {
132
133
  if (!import.meta.env.PROD) {
@@ -147,7 +148,7 @@ export default function useTabRouter() {
147
148
  const tab = getTab(tabId)
148
149
  if (!tab) return false
149
150
 
150
- const stack = tab.pages
151
+ const pages = getPages(tab.id)
151
152
  let steps = 0
152
153
 
153
154
  if (typeof to === 'number') {
@@ -155,10 +156,9 @@ export default function useTabRouter() {
155
156
  } else {
156
157
  const parsed = parseUrl(to)
157
158
  const targetQuery = parsed.query || {}
158
- const pages = stack.readonlyList()
159
159
  let found = false
160
160
 
161
- for (let i = stack.size() - 2; i >= 0; i--) {
161
+ for (let i = pages.length - 2; i >= 0; i--) {
162
162
  steps++
163
163
  const page = pages[i]
164
164
  if (
@@ -182,38 +182,42 @@ export default function useTabRouter() {
182
182
 
183
183
  if (steps <= 0) return false
184
184
 
185
- if (stack.size() <= steps) {
186
- steps = stack.size() - 1
185
+ const availableSteps = pages.length - 1
186
+ if (availableSteps < steps) {
187
+ // 请求退栈步数大于当前可用层数时,仅退到栈底(首页);开发模式下提示维护者。
188
+ if (!import.meta.env.PROD) {
189
+ console.warn(
190
+ `[vue-stack-tabs] backward requested ${steps} steps but stack only has ${availableSteps} available.`
191
+ )
192
+ }
193
+ steps = availableSteps
187
194
  }
188
195
 
189
196
  if (steps <= 0) return false
190
197
 
191
- const originalPages = stack.readonlyList().map((p) => clonePage(p))
192
- const recyclingBin: string[] = []
193
- for (let i = 0; i < steps; i++) {
194
- const popped = stack.pop()
195
- if (popped) recyclingBin.push(popped.id)
196
- }
198
+ const originalPages = getPageSnapshot(tab.id)
199
+ const recyclingBin = popPages(tab.id, steps).map((page) => page.id)
197
200
 
198
- const target = stack.peek()
201
+ const target = getTopPage(tab.id)
199
202
  if (!target) {
200
- restorePages(stack, originalPages)
203
+ replacePages(tab.id, originalPages)
201
204
  return false
202
205
  }
203
206
 
204
207
  if (backQuery) {
205
- target._backParams = backQuery
208
+ updatePage(tab.id, target.id, { _backParams: backQuery })
206
209
  }
207
210
 
208
211
  emitter.emit(TabEventType.BACKWARD)
209
212
 
210
- const targetQueryWithTab = defu({ __tab: tabInfo }, target.query || {})
213
+ // 栈顶 query 已带 __tab 时优先沿用,避免被默认 tabInfo 覆盖导致解析时识别错乱。
214
+ const targetQueryWithTab = defu(target.query || {}, { __tab: tabInfo })
211
215
  const transactionVersion = ++navigationVersion
212
216
 
213
217
  runNavigationTransaction({
214
218
  apply: () => originalPages,
215
219
  navigate: () => router.push({ path: target.path, query: targetQueryWithTab }),
216
- rollback: (snapshot) => restorePages(stack, snapshot),
220
+ rollback: (snapshot) => replacePages(tab.id, snapshot),
217
221
  isFailureResult: isNavigationFailure,
218
222
  isCurrent: () => navigationVersion === transactionVersion,
219
223
  commit: () => {
@@ -222,7 +226,7 @@ export default function useTabRouter() {
222
226
  }
223
227
  },
224
228
  cleanupStale: () => {
225
- const activePageIds = new Set(stack.list().map((page) => page.id))
229
+ const activePageIds = new Set(getPages(tab.id).map((page) => page.id))
226
230
  for (const id of recyclingBin) {
227
231
  if (!activePageIds.has(id)) evictPageCache(id)
228
232
  }
@@ -49,8 +49,8 @@ export const mergeLocaleMessages = (
49
49
  }
50
50
 
51
51
  export default () => {
52
- // 引入lang目录下文件
53
- // 此处使用了 VITE 的 import.meta.globEager。非 VITE 的 可以使用 require.context
52
+ // 引入 lang 目录下文件
53
+ // 非 Vite 构建环境可改用 require.context 替换;当前使用 Vite 原生 glob 即可。
54
54
  const modules = import.meta.glob('./lang/*', { eager: true })
55
55
 
56
56
  /** 从 glob 导入结果中提取语言文件并合并到 msg 对象 */
@@ -58,7 +58,7 @@ export default () => {
58
58
  mList: Record<string, { default?: Record<string, unknown> }>,
59
59
  msg: Record<string, Record<string, unknown>>
60
60
  ) {
61
- for (const path in mList) {
61
+ for (const path of Object.keys(mList)) {
62
62
  const mod = mList[path] as { default?: Record<string, unknown> }
63
63
  if (mod?.default) {
64
64
  const pathName = path.substring(path.lastIndexOf('/') + 1, path.lastIndexOf('.'))
@@ -126,7 +126,46 @@ export class Stack<T> {
126
126
  }
127
127
 
128
128
  readonlyList(): readonly T[] {
129
- return this.items
129
+ // 返回浅拷贝避免外部修改内部 items 引用,同时类型为 readonly T[] 提示调用方只读。
130
+ return [...this.items]
131
+ }
132
+
133
+ /**
134
+ * 查找首个匹配的元素。
135
+ * @returns 匹配的元素,未找到返回 undefined。
136
+ */
137
+ find(predicate: (element: T, index: number) => boolean): T | undefined {
138
+ return this.items.find(predicate)
139
+ }
140
+
141
+ /**
142
+ * 查找首个匹配元素的索引,未找到返回 -1。
143
+ */
144
+ findIndex(predicate: (element: T, index: number) => boolean): number {
145
+ return this.items.findIndex(predicate)
146
+ }
147
+
148
+ /**
149
+ * 返回所有匹配元素组成的浅拷贝数组。
150
+ */
151
+ filter(predicate: (element: T, index: number) => boolean): T[] {
152
+ return this.items.filter(predicate)
153
+ }
154
+
155
+ /**
156
+ * 是否存在任意匹配元素。
157
+ */
158
+ some(predicate: (element: T, index: number) => boolean): boolean {
159
+ return this.items.some(predicate)
160
+ }
161
+
162
+ /**
163
+ * 从顶到底反向遍历每个元素,便于实现「从栈顶开始找」等场景。
164
+ */
165
+ forEachTopDown(visit: (element: T, index: number) => void): void {
166
+ for (let i = this.items.length - 1; i >= 0; i--) {
167
+ visit(this.items[i] as T, i)
168
+ }
130
169
  }
131
170
 
132
171
  toJSON(): T[] {
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * vue-stack-tabs Nuxt 模块。
3
3
  *
4
- * 自动注册客户端插件、注入 composables(useTabActions 等),
5
- * 并通过 runtimeConfig 传递 locale 配置。
4
+ * 自动注册客户端插件、注入 composables(useTabActions 等)、
5
+ * 注册全局组件 <VueStackTabs>,并通过 runtimeConfig 传递 locale 配置。
6
6
  */
7
- import { addPlugin, addImports, createResolver, defineNuxtModule } from 'nuxt/kit'
7
+ import { addComponent, addImports, addPlugin, createResolver, defineNuxtModule } from 'nuxt/kit'
8
8
 
9
9
  export interface ModuleOptions {
10
10
  /** 默认语言 */
@@ -30,6 +30,13 @@ export default defineNuxtModule<ModuleOptions>({
30
30
  src: resolve('./runtime/plugin')
31
31
  })
32
32
 
33
+ // 自动注册全局组件 <VueStackTabs>,使用方无需在每个页面再 import。
34
+ addComponent({
35
+ name: 'VueStackTabs',
36
+ export: 'VueStackTabs',
37
+ filePath: resolve('../index.ts')
38
+ })
39
+
33
40
  addImports([
34
41
  { name: 'useTabActions', from: 'vue-stack-tabs' },
35
42
  { name: 'useTabRouter', from: 'vue-stack-tabs' },
@@ -1,11 +1,22 @@
1
- /** Nuxt 客户端插件:自动注册 VueStackTabs 并应用 runtimeConfig 中的 locale 配置 */
1
+ /**
2
+ * Nuxt 客户端插件:自动注册 VueStackTabs 并应用 runtimeConfig 中的 locale 配置。
3
+ */
2
4
  import { defineNuxtPlugin, useRuntimeConfig } from 'nuxt/app'
3
5
  import VueStackTabs from 'vue-stack-tabs'
6
+ import { applyStackTabsLocale } from '../../i18n/stackTabsLocale'
7
+ import { useI18n } from 'vue-i18n-lite'
4
8
 
5
9
  export default defineNuxtPlugin((nuxtApp) => {
6
10
  const config = useRuntimeConfig().public.vueStackTabs as {
7
11
  locale?: string
8
12
  }
13
+ const locale = config?.locale ?? 'zh-CN'
9
14
 
10
- nuxtApp.vueApp.use(VueStackTabs, [{ locale: config?.locale ?? 'zh-CN' }])
15
+ // Vue.use 时把 locale 作为 plugin options 传入,让 i18n 实例按指定 locale 初始化。
16
+ nuxtApp.vueApp.use(VueStackTabs, [{ locale }])
17
+
18
+ // 兜底:应用 vue-stack-tabs 内部 i18n 实例的 locale 切换逻辑,
19
+ // 避免某些场景下 plugin options 未能正确初始化时 locale 仍是默认 zh-CN。
20
+ const { changeLocale } = useI18n()
21
+ applyStackTabsLocale(changeLocale, locale)
11
22
  })
@@ -53,8 +53,14 @@ export function postOpenTab(
53
53
  }
54
54
 
55
55
  /**
56
- * 在 iframe 内调用:注册刷新监听,收到父窗口刷新指令时执行 callback
56
+ * 在 iframe 内调用:注册刷新监听,收到父窗口刷新指令时执行 callback。
57
+ *
58
+ * **安全提示**:`options.allowedOrigins` 为空数组(默认值)时**不校验 origin**,
59
+ * 等同 `targetOrigin: '*'`,任何嵌入此 iframe 的父页面都能触发回调。
60
+ * 生产环境务必显式传入可信 origin 列表,例如 `['https://app.example.com']`。
61
+ *
57
62
  * @param callback 默认 location.reload(),可自定义
63
+ * @param options 可选配置,`allowedOrigins` 强烈建议显式传入
58
64
  * @returns 取消监听的函数
59
65
  */
60
66
  export function onRefreshRequest(
@@ -66,6 +72,8 @@ export function onRefreshRequest(
66
72
  const allowedOrigins = options.allowedOrigins ?? []
67
73
  const handler = (ev: MessageEvent) => {
68
74
  if (ev.source !== window.parent) return
75
+ // allowedOrigins 为空时跳过 origin 校验:刻意保留以便本地调试,
76
+ // 但必须在文档中向用户强调风险(见上方 JSDoc)。
69
77
  if (allowedOrigins.length > 0 && !allowedOrigins.includes(ev.origin)) return
70
78
  if (ev.data?.type === MSG_REFRESH) {
71
79
  callback()
@@ -91,13 +91,46 @@ export const getScrollbarWidth = (function () {
91
91
 
92
92
  const Z_INDEX_CEILING = 89999
93
93
 
94
- /** 获取指定选择器下元素的最大 z-index + 1,用于最大化时置顶 */
94
+ /**
95
+ * 缓存条目:保存某选择器下最近一次的最大 z-index 结果。
96
+ * 在 Z_INDEX_CACHE_TTL 毫秒内的重复请求直接复用,避免反复遍历 DOM。
97
+ */
98
+ interface ZIndexCacheEntry {
99
+ value: number
100
+ timestamp: number
101
+ }
102
+
103
+ const Z_INDEX_CACHE_TTL = 100
104
+ const zIndexCache = new Map<string, ZIndexCacheEntry>()
105
+
106
+ /** 在用户主动布局变化后调用,使缓存失效 */
107
+ export const invalidateZIndexCache = (selector?: string): void => {
108
+ if (selector === undefined) {
109
+ zIndexCache.clear()
110
+ return
111
+ }
112
+ zIndexCache.delete(selector)
113
+ }
114
+
115
+ /**
116
+ * 获取指定选择器下元素的最大 z-index + 1,用于最大化时置顶。
117
+ * 同一选择器在 Z_INDEX_CACHE_TTL 毫秒内的重复请求会复用上次结果,避免重复 querySelectorAll / getComputedStyle。
118
+ * 当 DOM 结构发生显著变化时,请调用 invalidateZIndexCache 让缓存失效。
119
+ */
95
120
  export const getMaxZIndex = (key = '.stack-tab__container *'): number => {
121
+ const now = Date.now()
122
+ const cached = zIndexCache.get(key)
123
+ if (cached && now - cached.timestamp < Z_INDEX_CACHE_TTL) {
124
+ return cached.value
125
+ }
126
+
96
127
  const elements = document.querySelectorAll(key)
97
128
  let max = 0
98
129
  for (let i = 0; i < elements.length; i++) {
99
130
  const z = +window.getComputedStyle(elements[i]!).zIndex || 0
100
131
  if (z < Z_INDEX_CEILING && z > max) max = z
101
132
  }
102
- return max + 1
133
+ const value = max + 1
134
+ zIndexCache.set(key, { value, timestamp: now })
135
+ return value
103
136
  }
@@ -20,9 +20,14 @@ interface EncodedTabInfoV1 {
20
20
  const MAX_DECODED_TAB_INFO_LENGTH = 2048
21
21
  const MAX_ENCODED_TAB_INFO_LENGTH = 4096
22
22
 
23
- const encodeBase64 = (value: string): string => encodeURIComponent(btoa(encodeURIComponent(value)))
24
- const decodeBase64 = (encoded: string): string =>
25
- decodeURIComponent(atob(decodeURIComponent(encoded)))
23
+ /**
24
+ * 把任意 UTF-8 字符串安全地编码为 base64(避免中文/特殊字符直接 btoa 抛错)。
25
+ * 使用 encodeURIComponent + unescape 是经典跨浏览器方案,比手动管理 TextEncoder 更简洁。
26
+ */
27
+ const encodeBase64 = (value: string): string => btoa(unescape(encodeURIComponent(value)))
28
+
29
+ /** encodeBase64 的逆操作,非合法 base64 时抛错,由调用方 try/catch。 */
30
+ const decodeBase64 = (encoded: string): string => decodeURIComponent(escape(atob(encoded)))
26
31
 
27
32
  const DEFAULT_TAB_INFO: ITabBase = {
28
33
  id: '',
@@ -39,7 +44,7 @@ const toTabInfo = (payload: unknown): ITabBase => {
39
44
  if (!isRecord(payload)) return { ...DEFAULT_TAB_INFO }
40
45
 
41
46
  return {
42
- id: typeof payload.id === 'string' ? payload.id : DEFAULT_TAB_INFO.id,
47
+ id: typeof payload.id === 'string' ? payload.id : undefined,
43
48
  title: typeof payload.title === 'string' ? payload.title : DEFAULT_TAB_INFO.title,
44
49
  iframe: typeof payload.iframe === 'boolean' ? payload.iframe : DEFAULT_TAB_INFO.iframe,
45
50
  closable: typeof payload.closable === 'boolean' ? payload.closable : DEFAULT_TAB_INFO.closable,
@@ -76,13 +81,21 @@ export const encodeTabInfo = (tabData: ITabBase): string => {
76
81
  }
77
82
 
78
83
  export const decodeTabInfo = (encoded: string): ITabBase => {
79
- if (encoded.length > MAX_ENCODED_TAB_INFO_LENGTH) return { ...DEFAULT_TAB_INFO }
84
+ if (typeof encoded !== 'string' || encoded.length > MAX_ENCODED_TAB_INFO_LENGTH) {
85
+ return { ...DEFAULT_TAB_INFO }
86
+ }
80
87
 
88
+ let tabString: string
81
89
  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)
90
+ tabString = decodeBase64(encoded)
91
+ } catch {
92
+ return { ...DEFAULT_TAB_INFO }
93
+ }
85
94
 
95
+ if (tabString.length > MAX_DECODED_TAB_INFO_LENGTH) return { ...DEFAULT_TAB_INFO }
96
+ if (!tabString.trim().startsWith('{')) return decodeLegacyTabInfo(tabString)
97
+
98
+ try {
86
99
  return toTabInfo(JSON.parse(tabString))
87
100
  } catch {
88
101
  return { ...DEFAULT_TAB_INFO }
@@ -137,7 +137,9 @@ export function isSameQueryIgnoringReserved(
137
137
  const result: Record<string, string | string[]> = {}
138
138
  for (const [key, value] of Object.entries(q)) {
139
139
  if (STACK_TABS_RESERVED_QUERY_KEYS.has(key) || key === '_back') continue
140
- result[key] = Array.isArray(value) ? value.map((item) => String(item ?? '')) : String(value ?? '')
140
+ result[key] = Array.isArray(value)
141
+ ? value.map((item) => String(item ?? ''))
142
+ : String(value ?? '')
141
143
  }
142
144
  return result
143
145
  }
@@ -1,9 +1,13 @@
1
1
  /** 在浏览器控制台输出带样式的库版本标识 */
2
- const logVersion = (version: string) => {
2
+ const logVersion = (version?: string) => {
3
3
  if (typeof window === 'undefined') return
4
4
 
5
+ // 兜底:Vitest / SSR 等环境下 import.meta.env.PACKAGE_VERSION 缺失,
6
+ // 避免打印出 "vundefined" 这种丑陋的版本号。
7
+ const displayVersion = typeof version === 'string' && version.length > 0 ? version : 'dev'
8
+
5
9
  window.console.log(
6
- '%c vue-stack-tabs %c v' + version + ' %c',
10
+ '%c vue-stack-tabs %c v' + displayVersion + ' %c',
7
11
  'background:#306fff; padding:2px 8px; border-radius:3px 0 0 3px; color:#fff;',
8
12
  'background:#00d797; padding:2px 8px; border-radius:0 3px 3px 0; color:#fff;',
9
13
  'background:transparent'
package/dist/favicon.svg DELETED
@@ -1,10 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <svg id="_图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 199.49 199.67">
3
- <path d="M79.24,19.73h108.91v29.7h-108.91v-29.7Z" style="fill: #79a7ff; stroke-width: 0px;"/>
4
- <path d="M179.24,199.67H20.26C9.05,199.6,0,190.5,0,179.29V20.39C0,9.18,9.05.07,20.26,0h59.49c3.51.07,6.34,2.9,6.41,6.42v44.36c0,3.54-2.87,6.41-6.41,6.41-3.54,0-6.41-2.86-6.42-6.4,0,0,0,0,0,0V12.36H20.25c-4.13.07-7.44,3.44-7.44,7.56v159.41c0,4.13,3.31,7.49,7.44,7.56h158.98c4.13-.07,7.44-3.44,7.44-7.56V51.84c0-3.54,2.87-6.41,6.41-6.42,3.54,0,6.42,2.87,6.42,6.42h0v127.45c0,11.21-9.05,20.32-20.26,20.39h0Z" style="fill: #377dff; stroke-width: 0px;"/>
5
- <path d="M192.8,60.77c-3.66-.07-6.61-3.02-6.68-6.68v-18.23c.09-4.29-3.32-7.83-7.6-7.92-.05,0-.11,0-.16,0h-96.27c-3.69,0-6.69-2.99-6.69-6.69s2.99-6.69,6.69-6.69h96.27c11.69.08,21.13,9.58,21.13,21.27v18.25c0,3.69-2.99,6.68-6.68,6.68,0,0,0,0,0,0h0Z" style="fill: #377dff; stroke-width: 0px;"/>
6
- <path d="M193.08,58.67H10.15c-3.61-.08-6.48-3.07-6.41-6.68-.08-3.62,2.79-6.61,6.41-6.69h182.93c3.62.08,6.48,3.08,6.41,6.69.07,3.61-2.79,6.6-6.41,6.68Z" style="fill: #377dff; stroke-width: 0px;"/>
7
- <path d="M137.2,58.67c-3.42.24-6.4-2.32-6.68-5.74v-28.15c.55-3.69,3.99-6.24,7.69-5.69,2.94.44,5.25,2.75,5.69,5.69v28.15c-.28,3.42-3.26,5.98-6.68,5.74h0Z" style="fill: #377dff; stroke-width: 0px;"/>
8
- <path d="M99.78,100.48l-12.83-22.22h-18.39l31.22,54.05,31.22-54.05h-18.39l-12.83,22.22Z" style="fill: #00d797; stroke-width: 0px;"/>
9
- <path d="M135.28,78.26l-35.49,61.43-35.44-61.43h-20.11l55.55,96.2,55.55-96.2h-20.05Z" style="fill: #306fff; stroke-width: 0px;"/>
10
- </svg>
@@ -1,77 +0,0 @@
1
- <!doctype html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Iframe Bridge 测试页面</title>
7
- <style>
8
- body {
9
- font-family: system-ui, sans-serif;
10
- padding: 20px;
11
- background: #f0f2f5;
12
- }
13
- .card {
14
- background: white;
15
- padding: 20px;
16
- border-radius: 8px;
17
- box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
18
- }
19
- button {
20
- padding: 10px 20px;
21
- background: #67c23a;
22
- color: white;
23
- border: none;
24
- border-radius: 4px;
25
- cursor: pointer;
26
- }
27
- .status {
28
- margin-top: 10px;
29
- color: #606266;
30
- }
31
- </style>
32
- </head>
33
- <body>
34
- <div class="card">
35
- <h2>此页面演示使用封装好的 Bridge 对接方式</h2>
36
- <p>页面加载时间: <span id="time"></span></p>
37
- <p>
38
- 使用 <code>iframeBridge.ts</code> 中提供的 <code>postOpenTab</code> 和
39
- <code>onRefreshRequest</code>。
40
- </p>
41
-
42
- <div style="margin: 20px 0">
43
- <button id="btnOpen">使用 Bridge 方法请求打开新 Tab</button>
44
- </div>
45
- <div class="status" id="status"></div>
46
- </div>
47
-
48
- <!-- 注意:在实际项目中,如果是原生 HTML 需要引入编译后的 Bridge 脚本,或者直接使用 type="module" -->
49
- <script type="module">
50
- // 这里的导入路径仅为本地调试示意,实际推荐将 Bridge 打包为公共库或 CDN 引用
51
- import { postOpenTab, onRefreshRequest } from './src/lib/utils/iframeBridge.ts'
52
-
53
- const timeEl = document.getElementById('time')
54
- timeEl.innerText = new Date().toLocaleTimeString()
55
-
56
- // 1. 使用 Bridge 封装的方法打开 Tab
57
- document.getElementById('btnOpen').onclick = () => {
58
- postOpenTab({
59
- id: 'bridge_triggered_tab',
60
- title: 'Bridge 弹出的新页签',
61
- path: '/demo/page1',
62
- closable: true
63
- })
64
- }
65
-
66
- // 2. 使用 Bridge 封装的方法监听刷新请求
67
- onRefreshRequest(() => {
68
- console.log('[Bridge Child] Received refresh request from parent')
69
- const statusEl = document.getElementById('status')
70
- statusEl.innerText = '正在执行刷新 logic...'
71
- setTimeout(() => {
72
- location.reload()
73
- }, 500)
74
- })
75
- </script>
76
- </body>
77
- </html>
@@ -1,88 +0,0 @@
1
- <!doctype html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Iframe 子页面测试</title>
7
- <style>
8
- body {
9
- font-family: system-ui, sans-serif;
10
- padding: 20px;
11
- background: #f0f9eb;
12
- }
13
- .card {
14
- background: white;
15
- padding: 20px;
16
- border-radius: 8px;
17
- box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
18
- }
19
- button {
20
- padding: 10px 20px;
21
- background: #409eff;
22
- color: white;
23
- border: none;
24
- border-radius: 4px;
25
- cursor: pointer;
26
- }
27
- input {
28
- padding: 8px;
29
- width: 200px;
30
- margin-right: 10px;
31
- border: 1px solid #dcdfe6;
32
- border-radius: 4px;
33
- }
34
- </style>
35
- </head>
36
- <body>
37
- <div class="card">
38
- <h2>此页面运行在隔离的 iframe 中</h2>
39
- <p>页面加载时间: <span id="time"></span></p>
40
- <p>
41
- 用于测试 iframe 页面的加载、刷新隔离,以及通过 `postMessage` 向外层通信请求打开新 Tab
42
- 的功能。
43
- </p>
44
-
45
- <div style="margin: 20px 0">
46
- <label>隔离缓存测试输入框:</label>
47
- <input type="text" id="ipt" placeholder="切走该页签是否会销毁内容?" />
48
- </div>
49
-
50
- <div style="margin: 20px 0">
51
- <button onclick="requestNewTab()">向父级发送 postMessage 请求在外部打开新 Tab</button>
52
- </div>
53
- </div>
54
-
55
- <script>
56
- const timeEl = document.getElementById('time')
57
- const updateTime = () => {
58
- timeEl.innerText = new Date().toLocaleTimeString()
59
- }
60
- updateTime()
61
-
62
- // 1. 原生对接:向父窗口请求打开新标签
63
- function requestNewTab() {
64
- window.parent.postMessage(
65
- {
66
- type: 'vue-stack-tabs:openTab', // 推荐使用该标准类型,StackTabs 已支持兼容 'openTab'
67
- payload: {
68
- id: 'native_triggered_tab',
69
- title: '原生弹出的新页签',
70
- path: '/demo/page2',
71
- closable: true
72
- }
73
- },
74
- '*'
75
- )
76
- }
77
-
78
- // 2. 原生对接:监听父窗口发送的刷新请求
79
- window.addEventListener('message', (ev) => {
80
- if (ev.data?.type === 'vue-stack-tabs:refresh') {
81
- console.log('[Native Child] Received refresh request from parent')
82
- // 执行刷新逻辑(例如重新加载页面或局部数据更新)
83
- location.reload()
84
- }
85
- })
86
- </script>
87
- </body>
88
- </html>
@@ -1,10 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <svg id="_图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 199.49 199.67">
3
- <path d="M79.24,19.73h108.91v29.7h-108.91v-29.7Z" style="fill: #79a7ff; stroke-width: 0px;"/>
4
- <path d="M179.24,199.67H20.26C9.05,199.6,0,190.5,0,179.29V20.39C0,9.18,9.05.07,20.26,0h59.49c3.51.07,6.34,2.9,6.41,6.42v44.36c0,3.54-2.87,6.41-6.41,6.41-3.54,0-6.41-2.86-6.42-6.4,0,0,0,0,0,0V12.36H20.25c-4.13.07-7.44,3.44-7.44,7.56v159.41c0,4.13,3.31,7.49,7.44,7.56h158.98c4.13-.07,7.44-3.44,7.44-7.56V51.84c0-3.54,2.87-6.41,6.41-6.42,3.54,0,6.42,2.87,6.42,6.42h0v127.45c0,11.21-9.05,20.32-20.26,20.39h0Z" style="fill: #377dff; stroke-width: 0px;"/>
5
- <path d="M192.8,60.77c-3.66-.07-6.61-3.02-6.68-6.68v-18.23c.09-4.29-3.32-7.83-7.6-7.92-.05,0-.11,0-.16,0h-96.27c-3.69,0-6.69-2.99-6.69-6.69s2.99-6.69,6.69-6.69h96.27c11.69.08,21.13,9.58,21.13,21.27v18.25c0,3.69-2.99,6.68-6.68,6.68,0,0,0,0,0,0h0Z" style="fill: #377dff; stroke-width: 0px;"/>
6
- <path d="M193.08,58.67H10.15c-3.61-.08-6.48-3.07-6.41-6.68-.08-3.62,2.79-6.61,6.41-6.69h182.93c3.62.08,6.48,3.08,6.41,6.69.07,3.61-2.79,6.6-6.41,6.68Z" style="fill: #377dff; stroke-width: 0px;"/>
7
- <path d="M137.2,58.67c-3.42.24-6.4-2.32-6.68-5.74v-28.15c.55-3.69,3.99-6.24,7.69-5.69,2.94.44,5.25,2.75,5.69,5.69v28.15c-.28,3.42-3.26,5.98-6.68,5.74h0Z" style="fill: #377dff; stroke-width: 0px;"/>
8
- <path d="M99.78,100.48l-12.83-22.22h-18.39l31.22,54.05,31.22-54.05h-18.39l-12.83,22.22Z" style="fill: #00d797; stroke-width: 0px;"/>
9
- <path d="M135.28,78.26l-35.49,61.43-35.44-61.43h-20.11l55.55,96.2,55.55-96.2h-20.05Z" style="fill: #306fff; stroke-width: 0px;"/>
10
- </svg>