vue-stack-tabs 2.2.0 → 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 (42) hide show
  1. package/dist/StackTabs.vue.d.ts +2 -2
  2. package/dist/components/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -2
  3. package/dist/hooks/stackTabsContext.d.ts +0 -1
  4. package/dist/hooks/tabPanel/evict.d.ts +2 -2
  5. package/dist/hooks/tabPanel/scroll.d.ts +0 -2
  6. package/dist/hooks/tabPanel/state.d.ts +0 -1
  7. package/dist/hooks/useIframeManager.d.ts +1 -1
  8. package/dist/hooks/useScrollShadow.d.ts +11 -2
  9. package/dist/hooks/useTabEventBus.d.ts +5 -0
  10. package/dist/model/TabModel.d.ts +12 -5
  11. package/dist/nuxt/module.mjs +1 -1
  12. package/dist/nuxt/runtime/plugin.mjs +8 -1598
  13. package/dist/style.css +1 -1
  14. package/dist/utils/scrollUtils.d.ts +0 -2
  15. package/dist/utils/tabInfoEncoder.d.ts +0 -2
  16. package/dist/utils/urlParser.d.ts +6 -7
  17. package/dist/vue-stack-tabs.es.js +1 -1
  18. package/package.json +1 -1
  19. package/src/lib/StackTabs.vue +16 -1
  20. package/src/lib/assets/style/stack-tab.scss +12 -6
  21. package/src/lib/components/PageLoading.vue +10 -9
  22. package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +1 -5
  23. package/src/lib/components/TabHeader/TabHeaderScroll.vue +164 -90
  24. package/src/lib/components/TabHeader/index.vue +51 -10
  25. package/src/lib/hooks/stackTabsContext.ts +0 -1
  26. package/src/lib/hooks/tabPanel/evict.ts +14 -6
  27. package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +211 -42
  28. package/src/lib/hooks/tabPanel/scroll.ts +4 -15
  29. package/src/lib/hooks/tabPanel/session.ts +11 -4
  30. package/src/lib/hooks/tabPanel/state.ts +0 -2
  31. package/src/lib/hooks/tabPanel/tabStore.ts +28 -10
  32. package/src/lib/hooks/useIframeManager.ts +11 -15
  33. package/src/lib/hooks/useScrollShadow.ts +74 -19
  34. package/src/lib/hooks/useTabActions.ts +10 -4
  35. package/src/lib/hooks/useTabEventBus.ts +3 -0
  36. package/src/lib/hooks/useTabPanel.tsx +46 -34
  37. package/src/lib/model/TabModel.ts +14 -5
  38. package/src/lib/nuxt/module.ts +3 -1
  39. package/src/lib/nuxt/runtime/plugin.ts +7 -3
  40. package/src/lib/utils/scrollUtils.ts +0 -18
  41. package/src/lib/utils/tabInfoEncoder.ts +21 -2
  42. package/src/lib/utils/urlParser.ts +40 -37
@@ -13,6 +13,8 @@
13
13
  * - 阈值(默认 5px)内视为已贴边(≤ 阈值)→ 隐藏阴影;超过阈值视为可继续滚动 → 显示阴影
14
14
  * - SSR 安全:在 document 不存在时直接返回
15
15
  * - 自动清理:onScopeDispose 移除监听并取消 rAF/disconnect observer
16
+ * - 阴影类可以挂到另一个不参与 transform 的元素上:滚动容器的 ::after 一旦
17
+ * 跟随 transform(例如页面刷新动画),绝对定位的包含块就会变化,阴影会错位/消失
16
18
  */
17
19
  import { onScopeDispose, type Ref } from 'vue'
18
20
 
@@ -22,8 +24,15 @@ const SHADOW_VERTICAL_CLASS = 'stack-tab__shadow-inset-vertical'
22
24
  const DEFAULT_EDGE_THRESHOLD = 5
23
25
 
24
26
  export interface UseScrollShadowOptions {
25
- /** 容器元素的 ref,可以是 Ref<HTMLElement | null> 或 getter 函数 */
27
+ /** 滚动容器:用于监听 scroll / resize 并读取滚动尺寸 */
26
28
  target: Ref<HTMLElement | null> | (() => HTMLElement | null)
29
+ /**
30
+ * 阴影类挂载目标,默认与 target 相同。
31
+ * 当 target 参与 transform 动画(如刷新转场)时,应传入不参与动画的祖先
32
+ * (例如 .stack-tab__keep-alive-panel),否则 ::after 的绝对定位包含块会变化,
33
+ * 阴影会随动画错位或消失。
34
+ */
35
+ classTarget?: Ref<HTMLElement | null> | (() => HTMLElement | null)
27
36
  /**
28
37
  * 是否监听内容缩放/尺寸变化(默认 true)。
29
38
  * 启用后会用 ResizeObserver 监视目标尺寸,容器或子树尺寸变化都会重新计算阴影边界。
@@ -37,8 +46,10 @@ export interface UseScrollShadowOptions {
37
46
  }
38
47
 
39
48
  export interface UseScrollShadowReturn {
40
- /** 手动同步一次(用于内容变化/resize 后) */
49
+ /** 重新解析滚动/阴影目标并同步一次(用于 keep-alive 激活、DOM 重新挂载后) */
41
50
  sync: () => void
51
+ /** 移除阴影类并取消待执行的 rAF(用于 keep-alive 失活) */
52
+ clear: () => void
42
53
  }
43
54
 
44
55
  const resolveTarget = (
@@ -47,18 +58,36 @@ const resolveTarget = (
47
58
 
48
59
  export const useScrollShadow = (options: UseScrollShadowOptions): UseScrollShadowReturn => {
49
60
  if (typeof document === 'undefined') {
50
- return { sync: () => undefined }
61
+ return { sync: () => undefined, clear: () => undefined }
51
62
  }
52
63
 
53
64
  const watchResize = options.watchResize !== false
54
65
  const edgeThreshold = Math.max(0, options.edgeThreshold ?? DEFAULT_EDGE_THRESHOLD)
55
66
  let rafId: number | null = null
56
67
  let attached: HTMLElement | null = null
68
+ let classElement: HTMLElement | null = null
57
69
  let resizeObserver: ResizeObserver | null = null
58
70
 
71
+ const clearClasses = (element: HTMLElement | null): void => {
72
+ if (!element) return
73
+ element.classList.remove(SHADOW_TOP_CLASS, SHADOW_BOTTOM_CLASS, SHADOW_VERTICAL_CLASS)
74
+ }
75
+
76
+ /** 阴影类目标:配置了 classTarget 时必须真实解析到,否则宁可不显示(激活后会 sync 重试) */
77
+ const resolveClassTarget = (): HTMLElement | null =>
78
+ options.classTarget ? resolveTarget(options.classTarget) : attached
79
+
59
80
  const recompute = (): void => {
60
81
  if (!attached) return
61
82
  const target = attached
83
+ const nextClassTarget = resolveClassTarget()
84
+ if (classElement && classElement !== nextClassTarget) {
85
+ clearClasses(classElement)
86
+ classElement = null
87
+ }
88
+ classElement = nextClassTarget
89
+ if (!classElement) return
90
+
62
91
  const { scrollTop, scrollHeight, clientHeight } = target
63
92
  const distanceToTop = scrollTop
64
93
  const distanceToBottom = scrollHeight - clientHeight - scrollTop
@@ -69,9 +98,9 @@ export const useScrollShadow = (options: UseScrollShadowOptions): UseScrollShado
69
98
  // 注意:-top / -bottom 是两个独立 CSS 规则、作用在同一个 ::after 上,
70
99
  // 同时加会互相覆盖(后定义的 -bottom 胜出)。因此这里互斥地只加一个类:
71
100
  // 两侧都有内容用 -vertical,只有一侧用 -top / -bottom,贴边时都不加。
72
- target.classList.toggle(SHADOW_VERTICAL_CLASS, showTopShadow && showBottomShadow)
73
- target.classList.toggle(SHADOW_TOP_CLASS, showTopShadow && !showBottomShadow)
74
- target.classList.toggle(SHADOW_BOTTOM_CLASS, !showTopShadow && showBottomShadow)
101
+ classElement.classList.toggle(SHADOW_VERTICAL_CLASS, showTopShadow && showBottomShadow)
102
+ classElement.classList.toggle(SHADOW_TOP_CLASS, showTopShadow && !showBottomShadow)
103
+ classElement.classList.toggle(SHADOW_BOTTOM_CLASS, !showTopShadow && showBottomShadow)
75
104
  }
76
105
 
77
106
  const schedule = (): void => {
@@ -84,11 +113,17 @@ export const useScrollShadow = (options: UseScrollShadowOptions): UseScrollShado
84
113
 
85
114
  const attach = (): void => {
86
115
  const el = resolveTarget(options.target)
87
- if (!el || el === attached) return
116
+ if (!el) return
117
+ if (el === attached) {
118
+ schedule()
119
+ return
120
+ }
88
121
  if (attached) {
89
122
  attached.removeEventListener('scroll', schedule)
90
123
  resizeObserver?.disconnect()
91
124
  resizeObserver = null
125
+ clearClasses(classElement)
126
+ classElement = null
92
127
  }
93
128
  attached = el
94
129
  el.addEventListener('scroll', schedule, { passive: true })
@@ -99,18 +134,40 @@ export const useScrollShadow = (options: UseScrollShadowOptions): UseScrollShado
99
134
  schedule()
100
135
  }
101
136
 
102
- const detach = (): void => {
103
- if (attached) {
104
- attached.removeEventListener('scroll', schedule)
105
- attached.classList.remove(SHADOW_TOP_CLASS, SHADOW_BOTTOM_CLASS, SHADOW_VERTICAL_CLASS)
106
- attached = null
137
+ const cancelScheduled = (): void => {
138
+ if (rafId === null) return
139
+ cancelAnimationFrame(rafId)
140
+ rafId = null
141
+ }
142
+
143
+ const clear = (): void => {
144
+ clearClasses(classElement)
145
+ classElement = null
146
+ cancelScheduled()
147
+ }
148
+
149
+ /** 重新解析目标:keep-alive 激活或 DOM 重新挂载后由调用方触发 */
150
+ const sync = (): void => {
151
+ const el = resolveTarget(options.target)
152
+ if (!el) {
153
+ clear()
154
+ return
155
+ }
156
+ if (el !== attached) {
157
+ attach()
158
+ return
107
159
  }
160
+ schedule()
161
+ }
162
+
163
+ const detach = (): void => {
164
+ if (attached) attached.removeEventListener('scroll', schedule)
165
+ attached = null
108
166
  resizeObserver?.disconnect()
109
167
  resizeObserver = null
110
- if (rafId !== null) {
111
- cancelAnimationFrame(rafId)
112
- rafId = null
113
- }
168
+ clearClasses(classElement)
169
+ classElement = null
170
+ cancelScheduled()
114
171
  }
115
172
 
116
173
  // 初始挂载(target 可能是异步插入的 DOM,使用 microtask 等一帧)
@@ -118,7 +175,5 @@ export const useScrollShadow = (options: UseScrollShadowOptions): UseScrollShado
118
175
 
119
176
  onScopeDispose(detach)
120
177
 
121
- return {
122
- sync: () => schedule()
123
- }
178
+ return { sync, clear }
124
179
  }
@@ -1,4 +1,4 @@
1
- import { isNavigationFailure, useRouter } from 'vue-router'
1
+ import { isNavigationFailure, NavigationFailureType, useRouter } from 'vue-router'
2
2
  import type { LocationQueryRaw } from 'vue-router'
3
3
  import { defu } from 'defu'
4
4
  import useTabPanel from './useTabPanel'
@@ -95,7 +95,7 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
95
95
  return
96
96
  }
97
97
 
98
- navigateToTab(tab, tabInfo).then(
98
+ navigateToTab(tab, tabInfo, isExistingTab && renew).then(
99
99
  () => resolve(tabInfo.id),
100
100
  (error) => {
101
101
  rollbackRenew?.()
@@ -107,7 +107,7 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
107
107
  }
108
108
 
109
109
  /** 执行路由跳转到标签对应页面(普通路由或 iframe 占位路由) */
110
- const navigateToTab = (tab: ITabData, tabInfo: ITabData) => {
110
+ const navigateToTab = (tab: ITabData, tabInfo: ITabData, allowDuplicatedNavigation = false) => {
111
111
  const __tab = encodeTabInfo(tabInfo)
112
112
  const tabQuery = omitStackTabsReservedQuery(tab.query)
113
113
  let query = defu(tabQuery, { __tab }) as LocationQueryRaw
@@ -137,7 +137,13 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
137
137
  apply: () => undefined,
138
138
  navigate: () => router.push({ path, query, hash }),
139
139
  rollback: () => undefined,
140
- isFailureResult: isNavigationFailure,
140
+ // renew 当前标签时地址可能不变:vue-router 会返回 duplicated NavigationFailure,
141
+ // 这属于预期结果(refreshKey 会触发页面重新解析),不应让 openTab reject。
142
+ isFailureResult: (result) =>
143
+ isNavigationFailure(result) &&
144
+ !(
145
+ allowDuplicatedNavigation && isNavigationFailure(result, NavigationFailureType.duplicated)
146
+ ),
141
147
  rejectFailureResult: true
142
148
  })
143
149
  }
@@ -19,6 +19,8 @@ declare module '@vue/runtime-core' {
19
19
  export enum TabEventType {
20
20
  PAGE_LOADING = 'PAGE_LOADING',
21
21
  TAB_ACTIVE = 'TAB_ACTIVE',
22
+ /** 请求标签栏将指定标签滚动到可视区域(不改变激活状态) */
23
+ TAB_SCROLL_TO = 'TAB_SCROLL_TO',
22
24
  FORWARD = 'FORWARD',
23
25
  BACKWARD = 'BACKWARD',
24
26
  REFRESH_IFRAME_POSTMESSAGE = 'REFRESH_IFRAME_POSTMESSAGE'
@@ -27,6 +29,7 @@ export enum TabEventType {
27
29
  export interface TabEventPayloadMap extends Record<EventType, unknown> {
28
30
  [TabEventType.PAGE_LOADING]: { tId: string; value: boolean }
29
31
  [TabEventType.TAB_ACTIVE]: { id: string; isRoute?: boolean }
32
+ [TabEventType.TAB_SCROLL_TO]: { id: string }
30
33
  [TabEventType.FORWARD]: void
31
34
  [TabEventType.BACKWARD]: void
32
35
  [TabEventType.REFRESH_IFRAME_POSTMESSAGE]: string
@@ -61,7 +61,7 @@ const createTabPanelApi = (runtimeContext: StackTabsRuntimeContext) => {
61
61
  const router = useRouter()
62
62
  const emitter = useTabEmitter(runtimeContext)
63
63
  const { t } = useI18n()
64
- const { tabs, defaultTabs, caches, refreshKey, iframeRefreshKeys, isInitialized } = runtimeContext
64
+ const { tabs, caches, refreshKey, iframeRefreshKeys, isInitialized } = runtimeContext
65
65
  const store = getTabStore(runtimeContext)
66
66
  const {
67
67
  getTabs,
@@ -126,14 +126,13 @@ const createTabPanelApi = (runtimeContext: StackTabsRuntimeContext) => {
126
126
  * @param homeTab - 固定首页;始终位于第一位、不可关闭、不可移动
127
127
  */
128
128
  const initialize = (staticTabs: ITabData[], homeTab?: ITabData) => {
129
- // 重复调用会累加 defaultTabs 并重复注册标签;初始化后直接忽略。
129
+ // 重复调用会重复注册标签;初始化后直接忽略。
130
130
  if (isInitialized.value) {
131
131
  if (!import.meta.env.PROD) {
132
132
  console.warn('[vue-stack-tabs] initialize() called more than once; ignored.')
133
133
  }
134
134
  return
135
135
  }
136
- const newDefaults: ITabItem[] = []
137
136
  const newTabs: ITabItem[] = []
138
137
  let homeTabId = ''
139
138
 
@@ -152,13 +151,27 @@ const createTabPanelApi = (runtimeContext: StackTabsRuntimeContext) => {
152
151
  fullItem.id = item.id ?? (options.pinned ? DEFAULT_HOME_TAB_ID : createId())
153
152
  if (options.closable !== undefined) fullItem.closable = options.closable
154
153
  const tabId = fullItem.id
154
+ const isIframe = Boolean(fullItem.iframe)
155
155
  const uri = parseUrl(fullItem.path)
156
156
  const cacheName = createPageId()
157
+ // iframe 标签的 path 是外部 URL:路由侧用 iframePath + __src 承载,
158
+ // 与 openTab 的语义保持一致(否则默认 iframe 标签会渲染 about:blank)。
159
+ const pagePath = isIframe
160
+ ? normalizePathname(runtimeContext.iframePath.value)
161
+ : normalizePathname(uri.path)
162
+ const iframeRefreshMode = isIframe
163
+ ? (fullItem.iframeRefreshMode ??
164
+ (isCrossOriginUrl(fullItem.path) ? 'reload' : 'postMessage'))
165
+ : 'postMessage'
166
+ // 把生效的刷新模式写回 fullItem,保证 __tab 与会话恢复得到一致的 iframe 刷新语义。
167
+ fullItem.iframeRefreshMode = iframeRefreshMode
168
+ const pageQuery = omitStackTabsReservedQuery(isIframe ? fullItem.query : uri.query)
169
+ if (isIframe) pageQuery.__src = encodeURIComponent(fullItem.path)
157
170
  const page: ITabPage = {
158
171
  id: cacheName,
159
172
  tabId,
160
- path: normalizePathname(uri.path),
161
- query: defu(omitStackTabsReservedQuery(uri.query), { __tab: encodeTabInfo(fullItem) })
173
+ path: pagePath,
174
+ query: defu(pageQuery, { __tab: encodeTabInfo(fullItem) })
162
175
  }
163
176
  const pages = new Stack<ITabPage>()
164
177
  pages.push(page)
@@ -167,11 +180,9 @@ const createTabPanelApi = (runtimeContext: StackTabsRuntimeContext) => {
167
180
  title: fullItem.title ?? '',
168
181
  closable: fullItem.closable!,
169
182
  refreshable: fullItem.refreshable!,
170
- iframe: fullItem.iframe!,
171
- iframeRefreshMode:
172
- (fullItem as { iframeRefreshMode?: string }).iframeRefreshMode === 'reload'
173
- ? 'reload'
174
- : 'postMessage',
183
+ iframe: isIframe,
184
+ iframeRefreshMode,
185
+ url: isIframe ? toSafeTabUrl(fullItem.path) : undefined,
175
186
  active: false,
176
187
  pages,
177
188
  pinned: options.pinned
@@ -184,7 +195,6 @@ const createTabPanelApi = (runtimeContext: StackTabsRuntimeContext) => {
184
195
  if (homeTab) {
185
196
  const home = buildTab(homeTab, { pinned: true, closable: false })
186
197
  homeTabId = home.id
187
- newDefaults.push(home)
188
198
  newTabs.push({ ...home })
189
199
  }
190
200
 
@@ -200,11 +210,9 @@ const createTabPanelApi = (runtimeContext: StackTabsRuntimeContext) => {
200
210
  continue
201
211
  }
202
212
  const tab = buildTab(item)
203
- newDefaults.push(tab)
204
213
  newTabs.push({ ...tab })
205
214
  }
206
215
 
207
- defaultTabs.value = [...defaultTabs.value, ...newDefaults]
208
216
  addTabs(newTabs)
209
217
 
210
218
  // SSR 环境无 sessionStorage,直接跳过恢复。
@@ -442,10 +450,8 @@ const createTabPanelApi = (runtimeContext: StackTabsRuntimeContext) => {
442
450
  let cacheName: string
443
451
  let page: ITabPage | null = null
444
452
 
445
- if (targetTab && getTopPage(targetTab.id)) {
446
- const top = getTopPage(targetTab.id)!
447
- if (!top) throw new Error('[vue-stack-tabs] Stack is not empty but peek() returned undefined')
448
-
453
+ const top = targetTab ? getTopPage(targetTab.id) : null
454
+ if (targetTab && top) {
449
455
  if (isSameLocation(top)) {
450
456
  cacheName = top.id
451
457
  } else if (top.path === path) {
@@ -525,11 +531,17 @@ const createTabPanelApi = (runtimeContext: StackTabsRuntimeContext) => {
525
531
  // 首屏 URL 往往没有 __tab(用户直接打开默认路由):把解析出的标签编码写回地址,
526
532
  // 保证 URL 始终指向当前标签;之后的导航各自会带上自己的 __tab。
527
533
  if (typeof route.query.__tab !== 'string') {
528
- void router.replace({
529
- path: route.path,
530
- query: { ...cloneLocationQuery(route.query), __tab: encodeTabInfo(tabInfo) },
531
- hash: route.hash
532
- })
534
+ void router
535
+ .replace({
536
+ path: route.path,
537
+ query: { ...cloneLocationQuery(route.query), __tab: encodeTabInfo(tabInfo) },
538
+ hash: route.hash
539
+ })
540
+ .catch((error: unknown) => {
541
+ if (!import.meta.env.PROD) {
542
+ console.warn('[vue-stack-tabs] Failed to attach __tab to the current URL:', error)
543
+ }
544
+ })
533
545
  }
534
546
 
535
547
  saveActiveTabToSession(targetTab!)
@@ -558,7 +570,8 @@ const createTabPanelApi = (runtimeContext: StackTabsRuntimeContext) => {
558
570
  return getEmptyPlaceholderComponent()
559
571
  }
560
572
 
561
- const routeKey = route.fullPath + (route.query.__tab || '')
573
+ // fullPath 已包含完整 query(含 __tab)与 hash;refreshKey 让 renew 能强制重新解析同一地址。
574
+ const routeKey = `${route.fullPath}::${refreshKey.value}`
562
575
  if (routeKey === lastRouteKey && lastAddPageResult) {
563
576
  return lastAddPageResult
564
577
  }
@@ -687,7 +700,11 @@ const createTabPanelApi = (runtimeContext: StackTabsRuntimeContext) => {
687
700
  emitter.emit(TabEventType.FORWARD)
688
701
  const target = getTab(id)
689
702
  if (!target) return Promise.resolve()
690
- if (target.active) return Promise.resolve()
703
+ if (target.active) {
704
+ // 已是激活标签时仍需通知标签栏确保其可见(用户可能已把标签栏滚开)。
705
+ emitter.emit(TabEventType.TAB_SCROLL_TO, { id })
706
+ return Promise.resolve()
707
+ }
691
708
 
692
709
  const top = getTopPage(id)
693
710
  const activateTarget = () => {
@@ -704,15 +721,11 @@ const createTabPanelApi = (runtimeContext: StackTabsRuntimeContext) => {
704
721
  return Promise.resolve()
705
722
  }
706
723
 
707
- const __tab = encodeTabInfo({
708
- id: target.id,
709
- title: target.title,
710
- closable: target.closable,
711
- refreshable: target.refreshable,
712
- iframe: target.iframe
713
- })
714
- // __tab 是兜底标识,目标栈顶已带 __tab 时优先沿用,否则用我们刚编码的版本。
715
- const query = defu(top.query || {}, { __tab })
724
+ // __tab 是兜底标识:目标栈顶已带 __tab 时直接沿用,避免每次激活都重新编码。
725
+ const query = cloneLocationQuery(top.query)
726
+ if (typeof query.__tab !== 'string' || !query.__tab) {
727
+ query.__tab = encodeTabInfo(toTabBase(target))
728
+ }
716
729
 
717
730
  return runNavigationTransaction({
718
731
  apply: () => {
@@ -737,7 +750,6 @@ const createTabPanelApi = (runtimeContext: StackTabsRuntimeContext) => {
737
750
 
738
751
  const destroy = () => {
739
752
  resetStore()
740
- defaultTabs.value = []
741
753
  iframeRefreshKeys.value = {}
742
754
  runtimeContext.scrollPositionsByPageId.clear()
743
755
  runtimeContext.isInitialized.value = false
@@ -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 {
@@ -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
  /**
@@ -22,7 +22,7 @@ interface EncodedTabInfoV1 {
22
22
  const MAX_DECODED_TAB_INFO_LENGTH = 2048
23
23
  const MAX_ENCODED_TAB_INFO_LENGTH = 4096
24
24
  /** URL 中 __tab 的 title 上限,避免超长标题把 payload 顶到解码上限导致 id 丢失。 */
25
- export const MAX_TAB_TITLE_LENGTH = 256
25
+ const MAX_TAB_TITLE_LENGTH = 256
26
26
  /** legacy 格式中 id 的合理上限,用于拒绝任意字符串伪装成标签 id。 */
27
27
  const MAX_LEGACY_TAB_ID_LENGTH = 128
28
28
 
@@ -130,7 +130,7 @@ export const encodeTabInfo = (tabData: ITabBase): string => {
130
130
  return encodeBase64Url(JSON.stringify(payload))
131
131
  }
132
132
 
133
- export const decodeTabInfo = (encoded: string): ITabBase => {
133
+ const decodeTabInfoUncached = (encoded: string): ITabBase => {
134
134
  if (typeof encoded !== 'string' || encoded.length > MAX_ENCODED_TAB_INFO_LENGTH) {
135
135
  return { ...DEFAULT_TAB_INFO }
136
136
  }
@@ -152,6 +152,25 @@ export const decodeTabInfo = (encoded: string): ITabBase => {
152
152
  }
153
153
  }
154
154
 
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
+
155
174
  /**
156
175
  * 生成运行时 id。
157
176
  *
@@ -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'
@@ -152,39 +143,51 @@ export function clonePage<T extends CloneablePage>(page: T): T {
152
143
  }
153
144
  }
154
145
 
155
- /** 忽略 __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
+ */
156
173
  export function isSameQueryIgnoringReserved(
157
174
  a: LocationQueryRaw = {},
158
175
  b: LocationQueryRaw = {}
159
176
  ): boolean {
160
- const clean = (q: LocationQueryRaw): Record<string, string | string[]> => {
161
- const result: Record<string, string | string[]> = {}
162
- for (const [key, value] of Object.entries(q)) {
163
- if (STACK_TABS_RESERVED_QUERY_KEYS.has(key) || key === '_back') continue
164
- result[key] = Array.isArray(value)
165
- ? value.map((item) => String(item ?? ''))
166
- : String(value ?? '')
167
- }
168
- 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
169
183
  }
170
- const ca = clean(a)
171
- const cb = clean(b)
172
- const keysA = Object.keys(ca)
173
- const keysB = Object.keys(cb)
174
- if (keysA.length !== keysB.length) return false
175
- return keysA.every((key) => {
176
- const aValue = ca[key]
177
- const bValue = cb[key]
178
- if (Array.isArray(aValue) || Array.isArray(bValue)) {
179
- return (
180
- Array.isArray(aValue) &&
181
- Array.isArray(bValue) &&
182
- aValue.length === bValue.length &&
183
- aValue.every((value, index) => value === bValue[index])
184
- )
185
- }
186
- return aValue === bValue
187
- })
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
188
191
  }
189
192
 
190
193
  /** 移除 query 中的 vue-stack-tabs 保留参数(__tab、__src),返回干净的 query */