vue-stack-tabs 1.3.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) 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 +20 -0
  11. package/dist/{src/lib/hooks → hooks}/tabPanel/scroll.d.ts +2 -0
  12. package/dist/hooks/useScrollShadow.d.ts +20 -0
  13. package/dist/{src/lib/hooks → hooks}/useTabActions.d.ts +7 -2
  14. package/dist/{src/lib/hooks → hooks}/useTabEventBus.d.ts +2 -1
  15. package/dist/{src/lib/hooks → hooks}/useTabPanel.d.ts +3 -1
  16. package/dist/iframe-bridge.mjs +1 -1
  17. package/dist/index.d.ts +22 -6
  18. package/dist/{src/lib/model → model}/TabModel.d.ts +21 -0
  19. package/dist/nuxt/module.mjs +10 -6
  20. package/dist/nuxt/runtime/plugin.mjs +1600 -4
  21. package/dist/style.css +1 -1
  22. package/dist/utils/iframeBridge.d.ts +7 -1
  23. package/dist/{src/lib/utils → utils}/scrollUtils.d.ts +7 -1
  24. package/dist/{src/lib/versionLogger.d.ts → versionLogger.d.ts} +1 -1
  25. package/dist/vue-stack-tabs.css +1 -1
  26. package/dist/vue-stack-tabs.es.js +1 -1
  27. package/package.json +64 -40
  28. package/src/lib/StackTabs.vue +16 -3
  29. package/src/lib/assets/style/common/var.scss +2 -0
  30. package/src/lib/assets/style/stackTab.scss +17 -3
  31. package/src/lib/components/ContextMenu/index.vue +12 -3
  32. package/src/lib/components/PageLoading.vue +10 -2
  33. package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +3 -9
  34. package/src/lib/components/StackKeepAlive/emptyPlaceholder.tsx +18 -0
  35. package/src/lib/components/TabHeader/index.vue +4 -3
  36. package/src/lib/hooks/stackTabsContext.ts +23 -18
  37. package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +142 -0
  38. package/src/lib/hooks/tabPanel/refresh.ts +3 -0
  39. package/src/lib/hooks/tabPanel/scroll.ts +110 -7
  40. package/src/lib/hooks/tabPanel/session.ts +29 -3
  41. package/src/lib/hooks/useContextMenu.ts +57 -11
  42. package/src/lib/hooks/useIframeManager.ts +18 -2
  43. package/src/lib/hooks/useScrollShadow.ts +124 -0
  44. package/src/lib/hooks/useTabActions.ts +18 -8
  45. package/src/lib/hooks/useTabEventBus.ts +5 -3
  46. package/src/lib/hooks/useTabPanel.tsx +108 -180
  47. package/src/lib/hooks/useTabRouter.ts +11 -2
  48. package/src/lib/i18n/index.ts +3 -3
  49. package/src/lib/model/TabModel.ts +40 -1
  50. package/src/lib/nuxt/module.ts +10 -3
  51. package/src/lib/nuxt/runtime/plugin.ts +13 -2
  52. package/src/lib/utils/iframeBridge.ts +9 -1
  53. package/src/lib/utils/scrollUtils.ts +35 -2
  54. package/src/lib/utils/tabInfoEncoder.ts +21 -8
  55. package/src/lib/utils/urlParser.ts +3 -1
  56. package/src/lib/versionLogger.ts +6 -2
  57. package/dist/favicon.svg +0 -10
  58. package/dist/iframe-bridge.html +0 -77
  59. package/dist/iframe-child.html +0 -88
  60. package/dist/nuxt/favicon.svg +0 -10
  61. package/dist/nuxt/iframe-bridge.html +0 -77
  62. package/dist/nuxt/iframe-child.html +0 -88
  63. package/dist/nuxt/plugin.mjs +0 -9
  64. package/dist/src/lib/iframe-bridge.d.ts +0 -8
  65. package/dist/src/lib/index.d.ts +0 -22
  66. package/dist/src/lib/nuxt/module.d.ts +0 -6
  67. package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +0 -2
  68. package/dist/src/lib/nuxt/runtime/plugin.d.ts +0 -2
  69. package/dist/src/lib/utils/iframeBridge.d.ts +0 -39
  70. package/src/lib/nuxt/runtime/plugin.client.ts +0 -10
  71. /package/dist/{src/lib/components → components}/ContextMenu/ContextMenuItem.vue.d.ts +0 -0
  72. /package/dist/{src/lib/components → components}/PageLoading.vue.d.ts +0 -0
  73. /package/dist/{src/lib/components → components}/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -0
  74. /package/dist/{src/lib/components → components}/StackKeepAlive/StackKeepAlive.vue.d.ts +0 -0
  75. /package/dist/{src/lib/components → components}/TabHeader/TabHeaderButton.vue.d.ts +0 -0
  76. /package/dist/{src/lib/components → components}/TabHeader/TabHeaderScroll.vue.d.ts +0 -0
  77. /package/dist/{src/lib/hooks → hooks}/tabPanel/evict.d.ts +0 -0
  78. /package/dist/{src/lib/hooks → hooks}/tabPanel/navigationTransaction.d.ts +0 -0
  79. /package/dist/{src/lib/hooks → hooks}/tabPanel/refresh.d.ts +0 -0
  80. /package/dist/{src/lib/hooks → hooks}/tabPanel/session.d.ts +0 -0
  81. /package/dist/{src/lib/hooks → hooks}/tabPanel/state.d.ts +0 -0
  82. /package/dist/{src/lib/hooks → hooks}/useContextMenu.d.ts +0 -0
  83. /package/dist/{src/lib/hooks → hooks}/useIframeManager.d.ts +0 -0
  84. /package/dist/{src/lib/hooks → hooks}/useTabLoading.d.ts +0 -0
  85. /package/dist/{src/lib/hooks → hooks}/useTabRouter.d.ts +0 -0
  86. /package/dist/{src/lib/i18n → i18n}/index.d.ts +0 -0
  87. /package/dist/{src/lib/i18n → i18n}/lang/en.d.ts +0 -0
  88. /package/dist/{src/lib/i18n → i18n}/lang/zh-CN.d.ts +0 -0
  89. /package/dist/{src/lib/i18n → i18n}/stackTabsLocale.d.ts +0 -0
  90. /package/dist/{src/lib/iframe.vue.d.ts → iframe.vue.d.ts} +0 -0
  91. /package/dist/{src/lib/utils → utils}/stackTabsA11y.d.ts +0 -0
  92. /package/dist/{src/lib/utils → utils}/stackTabsMessage.d.ts +0 -0
  93. /package/dist/{src/lib/utils → utils}/tabInfoEncoder.d.ts +0 -0
  94. /package/dist/{src/lib/utils → utils}/typeGuards.d.ts +0 -0
  95. /package/dist/{src/lib/utils → utils}/urlParser.d.ts +0 -0
@@ -12,17 +12,7 @@
12
12
  * 使用范围:内部 hook,由 StackTabs、useTabActions、useTabRouter、ContextMenu 调用
13
13
  */
14
14
  import type { ITabBase, ITabData, ITabItem, ITabPage } from '../model/TabModel'
15
- import {
16
- defineComponent,
17
- onActivated,
18
- onDeactivated,
19
- onMounted,
20
- onUnmounted,
21
- computed,
22
- ref,
23
- shallowRef,
24
- cloneVNode
25
- } from 'vue'
15
+ import { computed, ref } from 'vue'
26
16
  import type { DefineComponent, VNode } from 'vue'
27
17
  import { isNavigationFailure, useRouter } from 'vue-router'
28
18
  import type { RouteLocationNormalizedLoaded } from 'vue-router'
@@ -38,25 +28,17 @@ import {
38
28
  cloneLocationQuery,
39
29
  clonePage
40
30
  } from '../utils/urlParser'
41
- import PageLoading from '../components/PageLoading.vue'
42
31
  import { TabEventType, useTabEmitter } from './useTabEventBus'
43
32
  import { runNavigationTransaction } from './tabPanel/navigationTransaction'
44
33
  import { useI18n } from 'vue-i18n-lite'
45
34
 
46
- import { resolveStackTabsRuntimeContext } from './stackTabsContext'
35
+ import { resolveStackTabsRuntimeContext, type StackTabsRuntimeContext } from './stackTabsContext'
47
36
  import { createTabPanelEviction } from './tabPanel/evict'
48
37
  import { createTabPanelScroll } from './tabPanel/scroll'
49
38
  import { createTabPanelSession } from './tabPanel/session'
50
39
  import { createTabPanelRefresh } from './tabPanel/refresh'
51
-
52
- /* eslint-disable vue/one-component-per-file */
53
- /** 占位组件:当路由组件为空或标签已被标记删除时返回 */
54
- const EmptyPlaceholderComponent = defineComponent({
55
- name: 'StackTabEmptyPlaceholder',
56
- setup() {
57
- return () => null
58
- }
59
- })
40
+ import { createPageComponentFactory } from './tabPanel/pageComponentFactory'
41
+ import { getEmptyPlaceholderComponent } from '../components/StackKeepAlive/emptyPlaceholder'
60
42
 
61
43
  const cloneRouteQuery = (route: RouteLocationNormalizedLoaded, tabInfo: ITabBase) => ({
62
44
  ...cloneLocationQuery(route.query),
@@ -71,11 +53,11 @@ export const normalizePathForCache = (route: RouteLocationNormalizedLoaded): str
71
53
  return matchPath === path ? path : route.path
72
54
  }
73
55
 
74
- export default () => {
56
+ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
75
57
  const router = useRouter()
76
- const emitter = useTabEmitter()
58
+ const runtimeContext = providedRuntimeContext ?? resolveStackTabsRuntimeContext()
59
+ const emitter = useTabEmitter(runtimeContext)
77
60
  const { t } = useI18n()
78
- const runtimeContext = resolveStackTabsRuntimeContext()
79
61
  const {
80
62
  tabs,
81
63
  defaultTabs,
@@ -118,14 +100,15 @@ export default () => {
118
100
  const newTabs: ITabItem[] = []
119
101
 
120
102
  for (const item of staticTabs) {
103
+ // 先用默认值填充非空缺字段,避免在循环里提前求值生成用不到的 UUID。
121
104
  const fullItem = defu(item, {
122
- id: crypto.randomUUID(),
123
105
  refreshable: true,
124
106
  closable: true,
125
107
  iframe: false
126
108
  })
127
- const tabId = fullItem.id ?? crypto.randomUUID()
128
- fullItem.id = tabId
109
+ // id 单独兜底,避免每次循环都无意义地调用 crypto.randomUUID。
110
+ fullItem.id = item.id ?? crypto.randomUUID()
111
+ const tabId = fullItem.id
129
112
  const uri = parseUrl(fullItem.path)
130
113
  const cacheName = createPageId()
131
114
  const page: ITabPage = {
@@ -158,7 +141,9 @@ export default () => {
158
141
  defaultTabs.value = [...defaultTabs.value, ...newDefaults]
159
142
  tabs.value = [...tabs.value, ...newTabs]
160
143
 
161
- const storedTabJson = sessionStorage.getItem(getSessionKey())
144
+ // SSR 环境无 sessionStorage,直接跳过恢复。
145
+ const storedTabJson =
146
+ typeof window !== 'undefined' ? sessionStorage.getItem(getSessionKey()) : null
162
147
  const restoredTab = restoreTabFromSession(storedTabJson)
163
148
  if (restoredTab && !tabs.value.some((t) => t.id === restoredTab.id)) {
164
149
  if (restoredTab.url) restoredTab.url = toSafeTabUrl(restoredTab.url)
@@ -189,6 +174,31 @@ export default () => {
189
174
 
190
175
  const getTab = (id: string) => tabs.value.find((t) => t.id === id) ?? null
191
176
 
177
+ /**
178
+ * 唯一的标签激活状态写入口。即使标签对象保持不变,也替换列表引用,
179
+ * 让 TabHeader 与 iframe 视图在同一个响应式提交中观察到一致状态。
180
+ */
181
+ const setActiveTab = (id: string): ITabItem | null => {
182
+ let activeTab: ITabItem | null = null
183
+ tabs.value = tabs.value.map((tab) => {
184
+ tab.active = tab.id === id
185
+ if (tab.active) activeTab = tab
186
+ return tab
187
+ })
188
+ return activeTab
189
+ }
190
+
191
+ const { resolvePageComponent } = createPageComponentFactory({
192
+ runtimeContext,
193
+ getTab,
194
+ addCache,
195
+ evictMarkedCaches,
196
+ addPageScroller,
197
+ saveScroller,
198
+ restoreScroller,
199
+ removeScroller
200
+ })
201
+
192
202
  /**
193
203
  * 将指定标签移动到目标索引。标签对象本身不变,因此其页面栈和缓存关联保持不受影响。
194
204
  * @returns 是否实际调整了顺序
@@ -212,7 +222,22 @@ export default () => {
212
222
  return true
213
223
  }
214
224
 
215
- /** 从路由解析 tabInfo,若无则创建默认信息,调用方负责把编码信息写入内部 page.query */
225
+ /** 把已有标签的对外字段提取为 ITabBase(用于让路由归属回该标签) */
226
+ const toTabBase = (tab: ITabItem): ITabBase => ({
227
+ id: tab.id,
228
+ title: tab.title,
229
+ closable: tab.closable,
230
+ refreshable: tab.refreshable,
231
+ iframe: tab.iframe,
232
+ iframeRefreshMode: tab.iframeRefreshMode
233
+ })
234
+
235
+ /**
236
+ * 从路由解析 tabInfo,若无则创建默认信息,调用方负责把编码信息写入内部 page.query。
237
+ *
238
+ * 首次进入应用时 URL 上通常还没有 __tab:此时优先接管 path 匹配的默认标签(defaultTabs),
239
+ * 由调用方把 __tab 写回地址,保证「URL 指向默认标签」;确实找不到归属时才新建兜底标签。
240
+ */
216
241
  const parseTabInfoFromRoute = (route: RouteLocationNormalizedLoaded): ITabBase => {
217
242
  if (typeof route.query.__tab === 'string') {
218
243
  const tabInfo = decodeTabInfo(route.query.__tab)
@@ -223,16 +248,15 @@ export default () => {
223
248
  const activeTab = tabs.value.find((tab) => tab.active)
224
249
  const activePage = activeTab?.pages.peek()
225
250
  if (activeTab && activePage?.path === path) {
226
- return {
227
- id: activeTab.id,
228
- title: activeTab.title,
229
- closable: activeTab.closable,
230
- refreshable: activeTab.refreshable,
231
- iframe: activeTab.iframe,
232
- iframeRefreshMode: activeTab.iframeRefreshMode
233
- }
251
+ return toTabBase(activeTab)
234
252
  }
235
253
 
254
+ // 当前激活标签与路由不一致:首屏 URL 无 __tab,或 session 恢复了上次激活的其它标签
255
+ //(演示应用在 iframe 中重载后 hash 为空即属此类)。此时接管栈顶 path 与当前路由一致的
256
+ // 标签(默认标签或已有标签),避免同一个页面既留下已有标签、又生成一个「未命名」的兜底标签。
257
+ const pathMatchedTab = tabs.value.find((tab) => tab.pages.peek()?.path === path)
258
+ if (pathMatchedTab) return toTabBase(pathMatchedTab)
259
+
236
260
  return {
237
261
  id: crypto.randomUUID(),
238
262
  title: t('VueStackTab.undefined'),
@@ -251,11 +275,7 @@ export default () => {
251
275
  cacheName: string,
252
276
  page: ITabPage
253
277
  ): ITabItem => {
254
- let targetTab: ITabItem | null = null
255
- for (const tab of tabs.value) {
256
- tab.active = false
257
- if (tab.id === tabInfo.id) targetTab = tab as ITabItem
258
- }
278
+ let targetTab = tabInfo.id ? getTab(tabInfo.id) : null
259
279
 
260
280
  if (targetTab === null) {
261
281
  const pages = new Stack<ITabPage>()
@@ -272,10 +292,11 @@ export default () => {
272
292
  active: true,
273
293
  pages
274
294
  } as unknown as ITabItem
295
+ setActiveTab('')
275
296
  addTab(targetTab)
276
297
  emitter.emit(TabEventType.TAB_ACTIVE, { id: tabInfo.id!, isRoute: false })
277
298
  } else {
278
- targetTab.active = true
299
+ setActiveTab(targetTab.id)
279
300
  if (targetTab.pages.isEmpty() || targetTab.pages.peek()!.id !== page.id) {
280
301
  targetTab.pages.push(page)
281
302
  }
@@ -331,7 +352,9 @@ export default () => {
331
352
  const stepsToPop = pagesList.length - 1 - foundIndex
332
353
  for (let i = 0; i < stepsToPop; i++) {
333
354
  const popped = targetTab.pages.pop()
334
- if (popped) evictPageCache(popped.id)
355
+ // 仅标记待驱逐,由 onActivated 或下一拍 evictMarkedCaches 统一清理,
356
+ // 避免 watcher 同步执行与守卫拒绝路由之间产生不一致。
357
+ if (popped) markCacheForEviction(popped.id)
335
358
  }
336
359
  const newTop = targetTab.pages.peek()
337
360
  cacheName = newTop ? newTop.id : createPageId()
@@ -373,111 +396,21 @@ export default () => {
373
396
  if (page) {
374
397
  targetTab = findOrCreateTargetTab(tabInfo, route, cacheName, page)
375
398
  } else {
376
- for (const t of tabs.value) t.active = false
377
- targetTab!.active = true
399
+ setActiveTab(targetTab!.id)
378
400
  }
379
401
 
380
- saveActiveTabToSession(targetTab!)
381
- return { cacheName, tabInfo, targetTab: targetTab! }
382
- }
383
-
384
- /**
385
- * 获取或创建与 cacheName 对应的 cacheComponent(keep-alive 包装组件)。
386
- */
387
- const resolvePageComponent = (ctx: {
388
- cacheName: string
389
- tabInfo: ITabBase
390
- targetTab: ITabItem
391
- }): DefineComponent => {
392
- const { cacheName, tabInfo } = ctx
393
-
394
- if (components.has(cacheName)) {
395
- addCache(cacheName)
396
- return components.get(cacheName)!
402
+ // 首屏 URL 往往没有 __tab(用户直接打开默认路由):把解析出的标签编码写回地址,
403
+ // 保证 URL 始终指向当前标签;之后的导航各自会带上自己的 __tab。
404
+ if (typeof route.query.__tab !== 'string') {
405
+ void router.replace({
406
+ path: route.path,
407
+ query: { ...cloneLocationQuery(route.query), __tab: encodeTabInfo(tabInfo) },
408
+ hash: route.hash
409
+ })
397
410
  }
398
411
 
399
- const cacheComponent = defineComponent({
400
- name: cacheName,
401
- props: {
402
- vnode: {
403
- type: Object as () => VNode,
404
- required: false,
405
- default: undefined
406
- }
407
- },
408
- emits: ['onLoaded'],
409
- setup(props, context) {
410
- const localBackParams = ref<Record<string, unknown> | null>(null)
411
- const lastVnode = shallowRef<VNode | null>(null)
412
- const lastCloned = shallowRef<VNode | null>(null)
413
- const lastBackParams = shallowRef<Record<string, unknown> | null>(null)
414
-
415
- const checkAndConsumeBackParams = () => {
416
- try {
417
- const tab = getTab(tabInfo.id!)
418
- const top = tab?.pages.peek()
419
- if (top && top.id === cacheName && top._backParams) {
420
- localBackParams.value = { ...top._backParams }
421
- delete top._backParams
422
- } else {
423
- localBackParams.value = null
424
- }
425
- } catch {
426
- localBackParams.value = null
427
- }
428
- }
429
-
430
- onMounted(() => {
431
- context.emit('onLoaded')
432
- addPageScroller(cacheName, `#W-${cacheName}`)
433
- checkAndConsumeBackParams()
434
- })
435
-
436
- onDeactivated(() => saveScroller(cacheName))
437
-
438
- onActivated(() => {
439
- context.emit('onLoaded')
440
- restoreScroller(cacheName)
441
- evictMarkedCaches()
442
- tabIdsToEvict.clear()
443
- checkAndConsumeBackParams()
444
- })
445
-
446
- onUnmounted(() => removeScroller(cacheName))
447
-
448
- return () => {
449
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
450
- const Node = (props as any).vnode as VNode
451
- const backParams = localBackParams.value
452
-
453
- if (Node !== lastVnode.value || backParams !== lastBackParams.value) {
454
- const dynamicBackProps = backParams ? { _back: backParams } : {}
455
- lastCloned.value = Node
456
- ? cloneVNode(Node, { tId: tabInfo.id, pId: cacheName, ...dynamicBackProps })
457
- : null
458
- lastVnode.value = Node
459
- lastBackParams.value = backParams
460
- }
461
-
462
- return (
463
- <div
464
- class="cache-page-wrapper"
465
- id={`W-${cacheName}`}
466
- style={[
467
- runtimeContext.useGlobalScroll.value ? 'overflow:auto' : 'overflow:hidden',
468
- 'height: 100%'
469
- ]}
470
- >
471
- {lastCloned.value}
472
- <PageLoading tabId={tabInfo.id!} />
473
- </div>
474
- )
475
- }
476
- }
477
- }) as DefineComponent
478
- components.set(cacheName, cacheComponent)
479
- addCache(cacheName)
480
- return cacheComponent
412
+ saveActiveTabToSession(targetTab!)
413
+ return { cacheName, tabInfo, targetTab: targetTab! }
481
414
  }
482
415
 
483
416
  /** 当前正在渲染的页面的缓存 Key(ULID),供 StackTabs.vue 的 :key 绑定使用 */
@@ -495,7 +428,7 @@ export default () => {
495
428
  route: RouteLocationNormalizedLoaded,
496
429
  component?: VNode | null
497
430
  ): DefineComponent => {
498
- if (!component) return EmptyPlaceholderComponent as DefineComponent
431
+ if (!component) return getEmptyPlaceholderComponent()
499
432
 
500
433
  const routeKey = route.fullPath + (route.query.__tab || '')
501
434
  if (routeKey === lastRouteKey && lastAddPageResult) {
@@ -503,7 +436,7 @@ export default () => {
503
436
  }
504
437
 
505
438
  const state = updatePageState(route)
506
- if ('empty' in state) return EmptyPlaceholderComponent as DefineComponent
439
+ if ('empty' in state) return getEmptyPlaceholderComponent()
507
440
  if ('recovered' in state) {
508
441
  addCache(state.cacheName)
509
442
  activeCacheKey.value = state.cacheName
@@ -554,7 +487,7 @@ export default () => {
554
487
  if (tabList.length > 1 && tab.active) {
555
488
  activeTabId = (i === 0 ? tabList[1] : tabList[i - 1])?.id ?? ''
556
489
  }
557
- tabList.splice(i, 1)
490
+ tabs.value = [...tabList.slice(0, i), ...tabList.slice(i + 1)]
558
491
 
559
492
  if (activeTabId) {
560
493
  emitter.emit(TabEventType.TAB_ACTIVE, { id: activeTabId })
@@ -569,32 +502,32 @@ export default () => {
569
502
 
570
503
  /** 批量关闭满足条件的可关闭标签,并统一标记其页面缓存待驱逐。 */
571
504
  const removeTabs = (shouldRemove: (tab: ITabItem, index: number) => boolean) => {
572
- const tabList = tabs.value
573
- for (let index = tabList.length - 1; index >= 0; index--) {
574
- const tab = tabList[index]
575
- if (!tab || !tab.closable || !shouldRemove(tab, index)) continue
505
+ const removed: ITabItem[] = []
506
+ const remaining = tabs.value.filter((tab, index) => {
507
+ if (!tab.closable || !shouldRemove(tab, index)) return true
576
508
  markTabPagesForEviction(tab)
577
509
  tab.pages.clear()
578
- tabList.splice(index, 1)
579
- }
510
+ removed.push(tab)
511
+ return false
512
+ })
513
+ tabs.value = remaining
514
+ return removed
580
515
  }
581
516
 
582
517
  const removeAllTabs = () => {
583
- const tabList = tabs.value
584
518
  removeTabs(() => true)
585
- const hasActive = tabList.some((t) => t.active)
519
+ const hasActive = tabs.value.some((t) => t.active)
586
520
  if (hasActive) {
587
521
  evictMarkedCaches()
588
522
  tabIdsToEvict.clear()
589
523
  return
590
524
  }
591
- const last = tabList[tabList.length - 1]
525
+ const last = tabs.value.at(-1)
592
526
  if (last) emitter.emit(TabEventType.TAB_ACTIVE, { id: last.id })
593
527
  }
594
528
 
595
529
  const removeOtherTabs = (id: string) => {
596
- const tabList = tabs.value
597
- const activeTab = tabList.find((tab) => tab.id === id)
530
+ const activeTab = getTab(id)
598
531
  removeTabs((tab) => tab.id !== id)
599
532
  if (activeTab && !activeTab.active) {
600
533
  emitter.emit(TabEventType.TAB_ACTIVE, { id })
@@ -605,32 +538,30 @@ export default () => {
605
538
  }
606
539
 
607
540
  const removeLeftTabs = (id: string) => {
608
- const tabList = tabs.value
609
- const pivot = tabList.findIndex((t) => t?.id === id)
541
+ const pivot = tabs.value.findIndex((t) => t?.id === id)
610
542
  if (pivot <= 0) return
611
543
 
612
544
  removeTabs((_, index) => index < pivot)
613
- if (tabList.some((t) => t.active)) {
545
+ if (tabs.value.some((t) => t.active)) {
614
546
  evictMarkedCaches()
615
547
  tabIdsToEvict.clear()
616
548
  return
617
549
  }
618
- const first = tabList[0]
550
+ const first = tabs.value[0]
619
551
  if (first) emitter.emit(TabEventType.TAB_ACTIVE, { id: first.id })
620
552
  }
621
553
 
622
554
  const removeRightTabs = (id: string) => {
623
- const tabList = tabs.value
624
- const pivot = tabList.findIndex((t) => t?.id === id)
625
- if (pivot < 0 || pivot >= tabList.length - 1) return
555
+ const pivot = tabs.value.findIndex((t) => t?.id === id)
556
+ if (pivot < 0 || pivot >= tabs.value.length - 1) return
626
557
 
627
558
  removeTabs((_, index) => index > pivot)
628
- if (tabList.some((t) => t.active)) {
559
+ if (tabs.value.some((t) => t.active)) {
629
560
  evictMarkedCaches()
630
561
  tabIdsToEvict.clear()
631
562
  return
632
563
  }
633
- const last = tabList[tabList.length - 1]
564
+ const last = tabs.value.at(-1)
634
565
  if (last) emitter.emit(TabEventType.TAB_ACTIVE, { id: last.id })
635
566
  }
636
567
 
@@ -660,11 +591,8 @@ export default () => {
660
591
 
661
592
  const top = target.pages.peek()
662
593
  const activateTarget = () => {
663
- for (const tab of tabs.value) {
664
- tab.active = tab.id === id
665
- }
666
- saveActiveTabToSession(target as ITabItem)
667
- tabs.value = tabs.value.slice()
594
+ const activeTab = setActiveTab(id)
595
+ if (activeTab) saveActiveTabToSession(activeTab)
668
596
  }
669
597
 
670
598
  if (!route) {
@@ -683,21 +611,21 @@ export default () => {
683
611
  refreshable: target.refreshable,
684
612
  iframe: target.iframe
685
613
  })
686
- const query = defu({ __tab }, top.query || {})
614
+ // __tab 是兜底标识,目标栈顶已带 __tab 时优先沿用,否则用我们刚编码的版本。
615
+ const query = defu(top.query || {}, { __tab })
687
616
 
688
617
  return runNavigationTransaction({
689
618
  apply: () => {
690
619
  const prevActiveId = tabs.value.find((tab) => tab.active)?.id
691
- const sessionSnapshot = window.sessionStorage.getItem(getSessionKey())
620
+ // SSR 环境无 sessionStorage;快照为 null 时 rollback 视为「无变化」。
621
+ const sessionSnapshot =
622
+ typeof window !== 'undefined' ? window.sessionStorage.getItem(getSessionKey()) : null
692
623
  activateTarget()
693
624
  return { prevActiveId, sessionSnapshot }
694
625
  },
695
626
  navigate: () => router.push({ path: top.path, query }),
696
627
  rollback: (snapshot) => {
697
- for (const tab of tabs.value) {
698
- tab.active = tab.id === snapshot.prevActiveId
699
- }
700
- tabs.value = tabs.value.slice()
628
+ setActiveTab(snapshot.prevActiveId ?? '')
701
629
  restoreActiveTabSession(snapshot.sessionSnapshot)
702
630
  },
703
631
  isFailureResult: isNavigationFailure,
@@ -98,7 +98,9 @@ export default function useTabRouter() {
98
98
  }
99
99
  }
100
100
 
101
- const query = defu({ __tab: tabInfo }, to.query)
101
+ // __tab 是 StackTabs 解析路由归属标签的关键,优先沿用目标 query 已有的值,
102
+ // 避免被 to.query 中非字符串的同名 key 或缺失值覆盖。
103
+ const query = defu(to.query, { __tab: tabInfo })
102
104
  const transactionVersion = ++navigationVersion
103
105
  emitter.emit(TabEventType.FORWARD)
104
106
 
@@ -183,6 +185,12 @@ export default function useTabRouter() {
183
185
  if (steps <= 0) return false
184
186
 
185
187
  if (stack.size() <= steps) {
188
+ // 请求退栈步数大于当前可用层数时,仅退到栈底(首页);开发模式下提示维护者。
189
+ if (!import.meta.env.PROD) {
190
+ console.warn(
191
+ `[vue-stack-tabs] backward requested ${steps} steps but stack only has ${stack.size() - 1} available.`
192
+ )
193
+ }
186
194
  steps = stack.size() - 1
187
195
  }
188
196
 
@@ -207,7 +215,8 @@ export default function useTabRouter() {
207
215
 
208
216
  emitter.emit(TabEventType.BACKWARD)
209
217
 
210
- const targetQueryWithTab = defu({ __tab: tabInfo }, target.query || {})
218
+ // 栈顶 query 已带 __tab 时优先沿用,避免被默认 tabInfo 覆盖导致解析时识别错乱。
219
+ const targetQueryWithTab = defu(target.query || {}, { __tab: tabInfo })
211
220
  const transactionVersion = ++navigationVersion
212
221
 
213
222
  runNavigationTransaction({
@@ -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()