vue-stack-tabs 1.3.0 → 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 (96) hide show
  1. package/README.en.md +119 -52
  2. package/README.md +104 -41
  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 +83 -59
  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 +43 -4
  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/TabHeaderItem.vue +30 -2
  36. package/src/lib/components/TabHeader/index.vue +76 -4
  37. package/src/lib/hooks/stackTabsContext.ts +23 -18
  38. package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +142 -0
  39. package/src/lib/hooks/tabPanel/refresh.ts +3 -0
  40. package/src/lib/hooks/tabPanel/scroll.ts +110 -7
  41. package/src/lib/hooks/tabPanel/session.ts +29 -3
  42. package/src/lib/hooks/useContextMenu.ts +57 -11
  43. package/src/lib/hooks/useIframeManager.ts +18 -2
  44. package/src/lib/hooks/useScrollShadow.ts +124 -0
  45. package/src/lib/hooks/useTabActions.ts +20 -8
  46. package/src/lib/hooks/useTabEventBus.ts +5 -3
  47. package/src/lib/hooks/useTabPanel.tsx +132 -180
  48. package/src/lib/hooks/useTabRouter.ts +11 -2
  49. package/src/lib/i18n/index.ts +3 -3
  50. package/src/lib/model/TabModel.ts +40 -1
  51. package/src/lib/nuxt/module.ts +10 -3
  52. package/src/lib/nuxt/runtime/plugin.ts +13 -2
  53. package/src/lib/utils/iframeBridge.ts +9 -1
  54. package/src/lib/utils/scrollUtils.ts +35 -2
  55. package/src/lib/utils/tabInfoEncoder.ts +21 -8
  56. package/src/lib/utils/urlParser.ts +3 -1
  57. package/src/lib/versionLogger.ts +6 -2
  58. package/dist/favicon.svg +0 -10
  59. package/dist/iframe-bridge.html +0 -77
  60. package/dist/iframe-child.html +0 -88
  61. package/dist/nuxt/favicon.svg +0 -10
  62. package/dist/nuxt/iframe-bridge.html +0 -77
  63. package/dist/nuxt/iframe-child.html +0 -88
  64. package/dist/nuxt/plugin.mjs +0 -9
  65. package/dist/src/lib/iframe-bridge.d.ts +0 -8
  66. package/dist/src/lib/index.d.ts +0 -22
  67. package/dist/src/lib/nuxt/module.d.ts +0 -6
  68. package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +0 -2
  69. package/dist/src/lib/nuxt/runtime/plugin.d.ts +0 -2
  70. package/dist/src/lib/utils/iframeBridge.d.ts +0 -39
  71. package/src/lib/nuxt/runtime/plugin.client.ts +0 -10
  72. /package/dist/{src/lib/components → components}/ContextMenu/ContextMenuItem.vue.d.ts +0 -0
  73. /package/dist/{src/lib/components → components}/PageLoading.vue.d.ts +0 -0
  74. /package/dist/{src/lib/components → components}/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -0
  75. /package/dist/{src/lib/components → components}/StackKeepAlive/StackKeepAlive.vue.d.ts +0 -0
  76. /package/dist/{src/lib/components → components}/TabHeader/TabHeaderButton.vue.d.ts +0 -0
  77. /package/dist/{src/lib/components → components}/TabHeader/TabHeaderScroll.vue.d.ts +0 -0
  78. /package/dist/{src/lib/hooks → hooks}/tabPanel/evict.d.ts +0 -0
  79. /package/dist/{src/lib/hooks → hooks}/tabPanel/navigationTransaction.d.ts +0 -0
  80. /package/dist/{src/lib/hooks → hooks}/tabPanel/refresh.d.ts +0 -0
  81. /package/dist/{src/lib/hooks → hooks}/tabPanel/session.d.ts +0 -0
  82. /package/dist/{src/lib/hooks → hooks}/tabPanel/state.d.ts +0 -0
  83. /package/dist/{src/lib/hooks → hooks}/useContextMenu.d.ts +0 -0
  84. /package/dist/{src/lib/hooks → hooks}/useIframeManager.d.ts +0 -0
  85. /package/dist/{src/lib/hooks → hooks}/useTabLoading.d.ts +0 -0
  86. /package/dist/{src/lib/hooks → hooks}/useTabRouter.d.ts +0 -0
  87. /package/dist/{src/lib/i18n → i18n}/index.d.ts +0 -0
  88. /package/dist/{src/lib/i18n → i18n}/lang/en.d.ts +0 -0
  89. /package/dist/{src/lib/i18n → i18n}/lang/zh-CN.d.ts +0 -0
  90. /package/dist/{src/lib/i18n → i18n}/stackTabsLocale.d.ts +0 -0
  91. /package/dist/{src/lib/iframe.vue.d.ts → iframe.vue.d.ts} +0 -0
  92. /package/dist/{src/lib/utils → utils}/stackTabsA11y.d.ts +0 -0
  93. /package/dist/{src/lib/utils → utils}/stackTabsMessage.d.ts +0 -0
  94. /package/dist/{src/lib/utils → utils}/tabInfoEncoder.d.ts +0 -0
  95. /package/dist/{src/lib/utils → utils}/typeGuards.d.ts +0 -0
  96. /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,7 +174,70 @@ export default () => {
189
174
 
190
175
  const getTab = (id: string) => tabs.value.find((t) => t.id === id) ?? null
191
176
 
192
- /** 从路由解析 tabInfo,若无则创建默认信息,调用方负责把编码信息写入内部 page.query */
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
+
202
+ /**
203
+ * 将指定标签移动到目标索引。标签对象本身不变,因此其页面栈和缓存关联保持不受影响。
204
+ * @returns 是否实际调整了顺序
205
+ */
206
+ const moveTab = (id: string, targetIndex: number): boolean => {
207
+ const sourceIndex = tabs.value.findIndex((tab) => tab.id === id)
208
+ if (
209
+ sourceIndex < 0 ||
210
+ targetIndex < 0 ||
211
+ targetIndex >= tabs.value.length ||
212
+ sourceIndex === targetIndex
213
+ ) {
214
+ return false
215
+ }
216
+
217
+ const reorderedTabs = tabs.value.slice()
218
+ const [tab] = reorderedTabs.splice(sourceIndex, 1)
219
+ if (!tab) return false
220
+ reorderedTabs.splice(targetIndex, 0, tab)
221
+ tabs.value = reorderedTabs
222
+ return true
223
+ }
224
+
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
+ */
193
241
  const parseTabInfoFromRoute = (route: RouteLocationNormalizedLoaded): ITabBase => {
194
242
  if (typeof route.query.__tab === 'string') {
195
243
  const tabInfo = decodeTabInfo(route.query.__tab)
@@ -200,16 +248,15 @@ export default () => {
200
248
  const activeTab = tabs.value.find((tab) => tab.active)
201
249
  const activePage = activeTab?.pages.peek()
202
250
  if (activeTab && activePage?.path === path) {
203
- return {
204
- id: activeTab.id,
205
- title: activeTab.title,
206
- closable: activeTab.closable,
207
- refreshable: activeTab.refreshable,
208
- iframe: activeTab.iframe,
209
- iframeRefreshMode: activeTab.iframeRefreshMode
210
- }
251
+ return toTabBase(activeTab)
211
252
  }
212
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
+
213
260
  return {
214
261
  id: crypto.randomUUID(),
215
262
  title: t('VueStackTab.undefined'),
@@ -228,11 +275,7 @@ export default () => {
228
275
  cacheName: string,
229
276
  page: ITabPage
230
277
  ): ITabItem => {
231
- let targetTab: ITabItem | null = null
232
- for (const tab of tabs.value) {
233
- tab.active = false
234
- if (tab.id === tabInfo.id) targetTab = tab as ITabItem
235
- }
278
+ let targetTab = tabInfo.id ? getTab(tabInfo.id) : null
236
279
 
237
280
  if (targetTab === null) {
238
281
  const pages = new Stack<ITabPage>()
@@ -249,10 +292,11 @@ export default () => {
249
292
  active: true,
250
293
  pages
251
294
  } as unknown as ITabItem
295
+ setActiveTab('')
252
296
  addTab(targetTab)
253
297
  emitter.emit(TabEventType.TAB_ACTIVE, { id: tabInfo.id!, isRoute: false })
254
298
  } else {
255
- targetTab.active = true
299
+ setActiveTab(targetTab.id)
256
300
  if (targetTab.pages.isEmpty() || targetTab.pages.peek()!.id !== page.id) {
257
301
  targetTab.pages.push(page)
258
302
  }
@@ -308,7 +352,9 @@ export default () => {
308
352
  const stepsToPop = pagesList.length - 1 - foundIndex
309
353
  for (let i = 0; i < stepsToPop; i++) {
310
354
  const popped = targetTab.pages.pop()
311
- if (popped) evictPageCache(popped.id)
355
+ // 仅标记待驱逐,由 onActivated 或下一拍 evictMarkedCaches 统一清理,
356
+ // 避免 watcher 同步执行与守卫拒绝路由之间产生不一致。
357
+ if (popped) markCacheForEviction(popped.id)
312
358
  }
313
359
  const newTop = targetTab.pages.peek()
314
360
  cacheName = newTop ? newTop.id : createPageId()
@@ -350,111 +396,21 @@ export default () => {
350
396
  if (page) {
351
397
  targetTab = findOrCreateTargetTab(tabInfo, route, cacheName, page)
352
398
  } else {
353
- for (const t of tabs.value) t.active = false
354
- targetTab!.active = true
399
+ setActiveTab(targetTab!.id)
355
400
  }
356
401
 
357
- saveActiveTabToSession(targetTab!)
358
- return { cacheName, tabInfo, targetTab: targetTab! }
359
- }
360
-
361
- /**
362
- * 获取或创建与 cacheName 对应的 cacheComponent(keep-alive 包装组件)。
363
- */
364
- const resolvePageComponent = (ctx: {
365
- cacheName: string
366
- tabInfo: ITabBase
367
- targetTab: ITabItem
368
- }): DefineComponent => {
369
- const { cacheName, tabInfo } = ctx
370
-
371
- if (components.has(cacheName)) {
372
- addCache(cacheName)
373
- 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
+ })
374
410
  }
375
411
 
376
- const cacheComponent = defineComponent({
377
- name: cacheName,
378
- props: {
379
- vnode: {
380
- type: Object as () => VNode,
381
- required: false,
382
- default: undefined
383
- }
384
- },
385
- emits: ['onLoaded'],
386
- setup(props, context) {
387
- const localBackParams = ref<Record<string, unknown> | null>(null)
388
- const lastVnode = shallowRef<VNode | null>(null)
389
- const lastCloned = shallowRef<VNode | null>(null)
390
- const lastBackParams = shallowRef<Record<string, unknown> | null>(null)
391
-
392
- const checkAndConsumeBackParams = () => {
393
- try {
394
- const tab = getTab(tabInfo.id!)
395
- const top = tab?.pages.peek()
396
- if (top && top.id === cacheName && top._backParams) {
397
- localBackParams.value = { ...top._backParams }
398
- delete top._backParams
399
- } else {
400
- localBackParams.value = null
401
- }
402
- } catch {
403
- localBackParams.value = null
404
- }
405
- }
406
-
407
- onMounted(() => {
408
- context.emit('onLoaded')
409
- addPageScroller(cacheName, `#W-${cacheName}`)
410
- checkAndConsumeBackParams()
411
- })
412
-
413
- onDeactivated(() => saveScroller(cacheName))
414
-
415
- onActivated(() => {
416
- context.emit('onLoaded')
417
- restoreScroller(cacheName)
418
- evictMarkedCaches()
419
- tabIdsToEvict.clear()
420
- checkAndConsumeBackParams()
421
- })
422
-
423
- onUnmounted(() => removeScroller(cacheName))
424
-
425
- return () => {
426
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
427
- const Node = (props as any).vnode as VNode
428
- const backParams = localBackParams.value
429
-
430
- if (Node !== lastVnode.value || backParams !== lastBackParams.value) {
431
- const dynamicBackProps = backParams ? { _back: backParams } : {}
432
- lastCloned.value = Node
433
- ? cloneVNode(Node, { tId: tabInfo.id, pId: cacheName, ...dynamicBackProps })
434
- : null
435
- lastVnode.value = Node
436
- lastBackParams.value = backParams
437
- }
438
-
439
- return (
440
- <div
441
- class="cache-page-wrapper"
442
- id={`W-${cacheName}`}
443
- style={[
444
- runtimeContext.useGlobalScroll.value ? 'overflow:auto' : 'overflow:hidden',
445
- 'height: 100%'
446
- ]}
447
- >
448
- {lastCloned.value}
449
- <PageLoading tabId={tabInfo.id!} />
450
- </div>
451
- )
452
- }
453
- }
454
- }) as DefineComponent
455
- components.set(cacheName, cacheComponent)
456
- addCache(cacheName)
457
- return cacheComponent
412
+ saveActiveTabToSession(targetTab!)
413
+ return { cacheName, tabInfo, targetTab: targetTab! }
458
414
  }
459
415
 
460
416
  /** 当前正在渲染的页面的缓存 Key(ULID),供 StackTabs.vue 的 :key 绑定使用 */
@@ -472,7 +428,7 @@ export default () => {
472
428
  route: RouteLocationNormalizedLoaded,
473
429
  component?: VNode | null
474
430
  ): DefineComponent => {
475
- if (!component) return EmptyPlaceholderComponent as DefineComponent
431
+ if (!component) return getEmptyPlaceholderComponent()
476
432
 
477
433
  const routeKey = route.fullPath + (route.query.__tab || '')
478
434
  if (routeKey === lastRouteKey && lastAddPageResult) {
@@ -480,7 +436,7 @@ export default () => {
480
436
  }
481
437
 
482
438
  const state = updatePageState(route)
483
- if ('empty' in state) return EmptyPlaceholderComponent as DefineComponent
439
+ if ('empty' in state) return getEmptyPlaceholderComponent()
484
440
  if ('recovered' in state) {
485
441
  addCache(state.cacheName)
486
442
  activeCacheKey.value = state.cacheName
@@ -531,7 +487,7 @@ export default () => {
531
487
  if (tabList.length > 1 && tab.active) {
532
488
  activeTabId = (i === 0 ? tabList[1] : tabList[i - 1])?.id ?? ''
533
489
  }
534
- tabList.splice(i, 1)
490
+ tabs.value = [...tabList.slice(0, i), ...tabList.slice(i + 1)]
535
491
 
536
492
  if (activeTabId) {
537
493
  emitter.emit(TabEventType.TAB_ACTIVE, { id: activeTabId })
@@ -546,32 +502,32 @@ export default () => {
546
502
 
547
503
  /** 批量关闭满足条件的可关闭标签,并统一标记其页面缓存待驱逐。 */
548
504
  const removeTabs = (shouldRemove: (tab: ITabItem, index: number) => boolean) => {
549
- const tabList = tabs.value
550
- for (let index = tabList.length - 1; index >= 0; index--) {
551
- const tab = tabList[index]
552
- 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
553
508
  markTabPagesForEviction(tab)
554
509
  tab.pages.clear()
555
- tabList.splice(index, 1)
556
- }
510
+ removed.push(tab)
511
+ return false
512
+ })
513
+ tabs.value = remaining
514
+ return removed
557
515
  }
558
516
 
559
517
  const removeAllTabs = () => {
560
- const tabList = tabs.value
561
518
  removeTabs(() => true)
562
- const hasActive = tabList.some((t) => t.active)
519
+ const hasActive = tabs.value.some((t) => t.active)
563
520
  if (hasActive) {
564
521
  evictMarkedCaches()
565
522
  tabIdsToEvict.clear()
566
523
  return
567
524
  }
568
- const last = tabList[tabList.length - 1]
525
+ const last = tabs.value.at(-1)
569
526
  if (last) emitter.emit(TabEventType.TAB_ACTIVE, { id: last.id })
570
527
  }
571
528
 
572
529
  const removeOtherTabs = (id: string) => {
573
- const tabList = tabs.value
574
- const activeTab = tabList.find((tab) => tab.id === id)
530
+ const activeTab = getTab(id)
575
531
  removeTabs((tab) => tab.id !== id)
576
532
  if (activeTab && !activeTab.active) {
577
533
  emitter.emit(TabEventType.TAB_ACTIVE, { id })
@@ -582,32 +538,30 @@ export default () => {
582
538
  }
583
539
 
584
540
  const removeLeftTabs = (id: string) => {
585
- const tabList = tabs.value
586
- const pivot = tabList.findIndex((t) => t?.id === id)
541
+ const pivot = tabs.value.findIndex((t) => t?.id === id)
587
542
  if (pivot <= 0) return
588
543
 
589
544
  removeTabs((_, index) => index < pivot)
590
- if (tabList.some((t) => t.active)) {
545
+ if (tabs.value.some((t) => t.active)) {
591
546
  evictMarkedCaches()
592
547
  tabIdsToEvict.clear()
593
548
  return
594
549
  }
595
- const first = tabList[0]
550
+ const first = tabs.value[0]
596
551
  if (first) emitter.emit(TabEventType.TAB_ACTIVE, { id: first.id })
597
552
  }
598
553
 
599
554
  const removeRightTabs = (id: string) => {
600
- const tabList = tabs.value
601
- const pivot = tabList.findIndex((t) => t?.id === id)
602
- 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
603
557
 
604
558
  removeTabs((_, index) => index > pivot)
605
- if (tabList.some((t) => t.active)) {
559
+ if (tabs.value.some((t) => t.active)) {
606
560
  evictMarkedCaches()
607
561
  tabIdsToEvict.clear()
608
562
  return
609
563
  }
610
- const last = tabList[tabList.length - 1]
564
+ const last = tabs.value.at(-1)
611
565
  if (last) emitter.emit(TabEventType.TAB_ACTIVE, { id: last.id })
612
566
  }
613
567
 
@@ -637,11 +591,8 @@ export default () => {
637
591
 
638
592
  const top = target.pages.peek()
639
593
  const activateTarget = () => {
640
- for (const tab of tabs.value) {
641
- tab.active = tab.id === id
642
- }
643
- saveActiveTabToSession(target as ITabItem)
644
- tabs.value = tabs.value.slice()
594
+ const activeTab = setActiveTab(id)
595
+ if (activeTab) saveActiveTabToSession(activeTab)
645
596
  }
646
597
 
647
598
  if (!route) {
@@ -660,21 +611,21 @@ export default () => {
660
611
  refreshable: target.refreshable,
661
612
  iframe: target.iframe
662
613
  })
663
- const query = defu({ __tab }, top.query || {})
614
+ // __tab 是兜底标识,目标栈顶已带 __tab 时优先沿用,否则用我们刚编码的版本。
615
+ const query = defu(top.query || {}, { __tab })
664
616
 
665
617
  return runNavigationTransaction({
666
618
  apply: () => {
667
619
  const prevActiveId = tabs.value.find((tab) => tab.active)?.id
668
- const sessionSnapshot = window.sessionStorage.getItem(getSessionKey())
620
+ // SSR 环境无 sessionStorage;快照为 null 时 rollback 视为「无变化」。
621
+ const sessionSnapshot =
622
+ typeof window !== 'undefined' ? window.sessionStorage.getItem(getSessionKey()) : null
669
623
  activateTarget()
670
624
  return { prevActiveId, sessionSnapshot }
671
625
  },
672
626
  navigate: () => router.push({ path: top.path, query }),
673
627
  rollback: (snapshot) => {
674
- for (const tab of tabs.value) {
675
- tab.active = tab.id === snapshot.prevActiveId
676
- }
677
- tabs.value = tabs.value.slice()
628
+ setActiveTab(snapshot.prevActiveId ?? '')
678
629
  restoreActiveTabSession(snapshot.sessionSnapshot)
679
630
  },
680
631
  isFailureResult: isNavigationFailure,
@@ -718,6 +669,7 @@ export default () => {
718
669
  addCache,
719
670
  removeCache,
720
671
  getTab,
672
+ moveTab,
721
673
  addTab,
722
674
  removeTab,
723
675
  removeAllTabs,
@@ -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
  })