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.
- package/dist/StackTabs.vue.d.ts +2 -2
- package/dist/components/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -2
- package/dist/hooks/stackTabsContext.d.ts +0 -1
- package/dist/hooks/tabPanel/evict.d.ts +2 -2
- package/dist/hooks/tabPanel/scroll.d.ts +0 -2
- package/dist/hooks/tabPanel/state.d.ts +0 -1
- package/dist/hooks/useIframeManager.d.ts +1 -1
- package/dist/hooks/useScrollShadow.d.ts +11 -2
- package/dist/hooks/useTabEventBus.d.ts +5 -0
- package/dist/model/TabModel.d.ts +12 -5
- package/dist/nuxt/module.mjs +1 -1
- package/dist/nuxt/runtime/plugin.mjs +8 -1598
- package/dist/style.css +1 -1
- package/dist/utils/scrollUtils.d.ts +0 -2
- package/dist/utils/tabInfoEncoder.d.ts +0 -2
- package/dist/utils/urlParser.d.ts +6 -7
- package/dist/vue-stack-tabs.es.js +1 -1
- package/package.json +1 -1
- package/src/lib/StackTabs.vue +16 -1
- package/src/lib/assets/style/stack-tab.scss +12 -6
- package/src/lib/components/PageLoading.vue +10 -9
- package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +1 -5
- package/src/lib/components/TabHeader/TabHeaderScroll.vue +164 -90
- package/src/lib/components/TabHeader/index.vue +51 -10
- package/src/lib/hooks/stackTabsContext.ts +0 -1
- package/src/lib/hooks/tabPanel/evict.ts +14 -6
- package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +211 -42
- package/src/lib/hooks/tabPanel/scroll.ts +4 -15
- package/src/lib/hooks/tabPanel/session.ts +11 -4
- package/src/lib/hooks/tabPanel/state.ts +0 -2
- package/src/lib/hooks/tabPanel/tabStore.ts +28 -10
- package/src/lib/hooks/useIframeManager.ts +11 -15
- package/src/lib/hooks/useScrollShadow.ts +74 -19
- package/src/lib/hooks/useTabActions.ts +10 -4
- package/src/lib/hooks/useTabEventBus.ts +3 -0
- package/src/lib/hooks/useTabPanel.tsx +46 -34
- package/src/lib/model/TabModel.ts +14 -5
- package/src/lib/nuxt/module.ts +3 -1
- package/src/lib/nuxt/runtime/plugin.ts +7 -3
- package/src/lib/utils/scrollUtils.ts +0 -18
- package/src/lib/utils/tabInfoEncoder.ts +21 -2
- 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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
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:
|
|
161
|
-
query: defu(
|
|
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:
|
|
171
|
-
iframeRefreshMode
|
|
172
|
-
|
|
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
|
-
|
|
446
|
-
|
|
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
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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 {
|
package/src/lib/nuxt/module.ts
CHANGED
|
@@ -34,7 +34,9 @@ export default defineNuxtModule<ModuleOptions>({
|
|
|
34
34
|
addComponent({
|
|
35
35
|
name: 'VueStackTabs',
|
|
36
36
|
export: 'VueStackTabs',
|
|
37
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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 */
|