vue-stack-tabs 1.4.2 → 2.2.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/README.en.md +1 -0
- package/README.md +1 -0
- package/README.ru.md +1 -0
- package/dist/StackTabs.vue.d.ts +3 -0
- package/dist/components/ContextMenu/ContextMenuItem.vue.d.ts +1 -1
- package/dist/hooks/tabPanel/refresh.d.ts +4 -2
- package/dist/hooks/tabPanel/tabStore.d.ts +12 -1
- package/dist/hooks/useTabActions.d.ts +1 -0
- package/dist/hooks/useTabPanel.d.ts +14 -2
- package/dist/model/TabModel.d.ts +5 -0
- package/dist/style.css +1 -1
- package/dist/utils/tabInfoEncoder.d.ts +10 -0
- package/dist/utils/urlParser.d.ts +14 -3
- package/dist/vue-stack-tabs.es.js +1 -1
- package/package.json +4 -7
- package/src/lib/StackTabs.vue +24 -2
- package/src/lib/assets/image/close_all.svg +1 -9
- package/src/lib/assets/image/close_lefts.svg +1 -1
- package/src/lib/assets/image/close_other.svg +1 -1
- package/src/lib/assets/image/close_rights.svg +1 -1
- package/src/lib/assets/image/reload.svg +1 -3
- package/src/lib/assets/image/reload_all.svg +1 -6
- package/src/lib/assets/style/common/mixins.scss +58 -19
- package/src/lib/assets/style/common/var.scss +100 -60
- package/src/lib/assets/style/icon.scss +31 -98
- package/src/lib/assets/style/index.scss +2 -2
- package/src/lib/assets/style/loading.scss +45 -0
- package/src/lib/assets/style/{stackTab.scss → stack-tab.scss} +105 -177
- package/src/lib/assets/style/transition.scss +12 -17
- package/src/lib/components/ContextMenu/ContextMenuItem.vue +5 -5
- package/src/lib/components/ContextMenu/index.vue +22 -19
- package/src/lib/components/PageLoading.vue +5 -5
- package/src/lib/components/StackKeepAlive/StackKeepAlive.vue +5 -3
- package/src/lib/components/TabHeader/TabHeaderItem.vue +13 -7
- package/src/lib/components/TabHeader/TabHeaderScroll.vue +8 -6
- package/src/lib/components/TabHeader/index.vue +16 -2
- package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +83 -7
- package/src/lib/hooks/tabPanel/refresh.ts +27 -9
- package/src/lib/hooks/tabPanel/tabStore.ts +86 -16
- package/src/lib/hooks/useContextMenu.ts +1 -1
- package/src/lib/hooks/useIframeManager.ts +10 -3
- package/src/lib/hooks/useTabActions.ts +8 -4
- package/src/lib/hooks/useTabPanel.tsx +204 -27
- package/src/lib/hooks/useTabRouter.ts +18 -11
- package/src/lib/model/TabModel.ts +5 -0
- package/src/lib/utils/tabInfoEncoder.ts +101 -17
- package/src/lib/utils/urlParser.ts +35 -11
- package/dist/vue-stack-tabs.css +0 -2
- package/src/lib/assets/style/overlayer.scss +0 -46
|
@@ -16,7 +16,7 @@ import { computed, ref } from 'vue'
|
|
|
16
16
|
import type { DefineComponent, VNode } from 'vue'
|
|
17
17
|
import { isNavigationFailure, useRouter } from 'vue-router'
|
|
18
18
|
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
|
19
|
-
import { encodeTabInfo, createPageId, decodeTabInfo } from '../utils/tabInfoEncoder'
|
|
19
|
+
import { encodeTabInfo, createId, createPageId, decodeTabInfo } from '../utils/tabInfoEncoder'
|
|
20
20
|
import { defu } from 'defu'
|
|
21
21
|
import { Stack } from '../model/TabModel'
|
|
22
22
|
import {
|
|
@@ -25,7 +25,9 @@ import {
|
|
|
25
25
|
omitStackTabsReservedQuery,
|
|
26
26
|
toSafeTabUrl,
|
|
27
27
|
decodeSafeTabUrl,
|
|
28
|
-
cloneLocationQuery
|
|
28
|
+
cloneLocationQuery,
|
|
29
|
+
isSameQueryIgnoringReserved,
|
|
30
|
+
normalizePathname
|
|
29
31
|
} from '../utils/urlParser'
|
|
30
32
|
import { TabEventType, useTabEmitter } from './useTabEventBus'
|
|
31
33
|
import { runNavigationTransaction } from './tabPanel/navigationTransaction'
|
|
@@ -44,6 +46,9 @@ const cloneRouteQuery = (route: RouteLocationNormalizedLoaded, tabInfo: ITabBase
|
|
|
44
46
|
__tab: route.query.__tab ?? encodeTabInfo(tabInfo)
|
|
45
47
|
})
|
|
46
48
|
|
|
49
|
+
/** homeTab 未显式指定 id 时使用的固定 id,保证刷新前后 __tab 与标签身份一致。 */
|
|
50
|
+
const DEFAULT_HOME_TAB_ID = '__stack_tabs_home__'
|
|
51
|
+
|
|
47
52
|
/** 规范化路径(nginx 等可能加尾部斜杠) */
|
|
48
53
|
export const normalizePathForCache = (route: RouteLocationNormalizedLoaded): string => {
|
|
49
54
|
const lastMatch = route.matched[route.matched.length - 1]
|
|
@@ -52,9 +57,8 @@ export const normalizePathForCache = (route: RouteLocationNormalizedLoaded): str
|
|
|
52
57
|
return matchPath === path ? path : route.path
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
|
|
60
|
+
const createTabPanelApi = (runtimeContext: StackTabsRuntimeContext) => {
|
|
56
61
|
const router = useRouter()
|
|
57
|
-
const runtimeContext = providedRuntimeContext ?? resolveStackTabsRuntimeContext()
|
|
58
62
|
const emitter = useTabEmitter(runtimeContext)
|
|
59
63
|
const { t } = useI18n()
|
|
60
64
|
const { tabs, defaultTabs, caches, refreshKey, iframeRefreshKeys, isInitialized } = runtimeContext
|
|
@@ -68,10 +72,12 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
68
72
|
canAddTab,
|
|
69
73
|
getPages,
|
|
70
74
|
getTopPage,
|
|
75
|
+
getPageRefreshVersion,
|
|
71
76
|
getPageSnapshot,
|
|
72
77
|
addTab,
|
|
73
78
|
addTabs,
|
|
74
79
|
activateTab,
|
|
80
|
+
updateTabTitle: updateTabTitleInStore,
|
|
75
81
|
moveTab,
|
|
76
82
|
removeTab: removeTabFromStore,
|
|
77
83
|
removeTabs: removeTabsFromStore,
|
|
@@ -80,6 +86,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
80
86
|
replacePages,
|
|
81
87
|
clearPages,
|
|
82
88
|
updatePage,
|
|
89
|
+
updatePages,
|
|
83
90
|
consumeBackParams,
|
|
84
91
|
markCacheForEviction,
|
|
85
92
|
markTabPagesForEvictionOnly,
|
|
@@ -114,29 +121,43 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
114
121
|
const size = (): number => getTabs().length
|
|
115
122
|
|
|
116
123
|
/**
|
|
117
|
-
*
|
|
124
|
+
* 初始化:先创建固定首页(homeTab),再创建 defaultTabs,并尝试从 sessionStorage 恢复上次激活的标签
|
|
118
125
|
* @param staticTabs - 初始标签配置
|
|
126
|
+
* @param homeTab - 固定首页;始终位于第一位、不可关闭、不可移动
|
|
119
127
|
*/
|
|
120
|
-
const initialize = (staticTabs: ITabData[]) => {
|
|
128
|
+
const initialize = (staticTabs: ITabData[], homeTab?: ITabData) => {
|
|
129
|
+
// 重复调用会累加 defaultTabs 并重复注册标签;初始化后直接忽略。
|
|
130
|
+
if (isInitialized.value) {
|
|
131
|
+
if (!import.meta.env.PROD) {
|
|
132
|
+
console.warn('[vue-stack-tabs] initialize() called more than once; ignored.')
|
|
133
|
+
}
|
|
134
|
+
return
|
|
135
|
+
}
|
|
121
136
|
const newDefaults: ITabItem[] = []
|
|
122
137
|
const newTabs: ITabItem[] = []
|
|
138
|
+
let homeTabId = ''
|
|
123
139
|
|
|
124
|
-
|
|
140
|
+
/** 由 ITabData 构建运行时标签;homeTab 通过 options 强制固定属性 */
|
|
141
|
+
const buildTab = (
|
|
142
|
+
item: ITabData,
|
|
143
|
+
options: { pinned?: boolean; closable?: boolean } = {}
|
|
144
|
+
): ITabItem => {
|
|
125
145
|
// 先用默认值填充非空缺字段,避免在循环里提前求值生成用不到的 UUID。
|
|
126
146
|
const fullItem = defu(item, {
|
|
127
147
|
refreshable: true,
|
|
128
148
|
closable: true,
|
|
129
149
|
iframe: false
|
|
130
150
|
})
|
|
131
|
-
// id
|
|
132
|
-
fullItem.id = item.id ??
|
|
151
|
+
// homeTab 未指定 id 时使用固定 id,避免每次初始化生成新 id 后与 session 恢复标签重复。
|
|
152
|
+
fullItem.id = item.id ?? (options.pinned ? DEFAULT_HOME_TAB_ID : createId())
|
|
153
|
+
if (options.closable !== undefined) fullItem.closable = options.closable
|
|
133
154
|
const tabId = fullItem.id
|
|
134
155
|
const uri = parseUrl(fullItem.path)
|
|
135
156
|
const cacheName = createPageId()
|
|
136
157
|
const page: ITabPage = {
|
|
137
158
|
id: cacheName,
|
|
138
159
|
tabId,
|
|
139
|
-
path: uri.path,
|
|
160
|
+
path: normalizePathname(uri.path),
|
|
140
161
|
query: defu(omitStackTabsReservedQuery(uri.query), { __tab: encodeTabInfo(fullItem) })
|
|
141
162
|
}
|
|
142
163
|
const pages = new Stack<ITabPage>()
|
|
@@ -152,10 +173,33 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
152
173
|
? 'reload'
|
|
153
174
|
: 'postMessage',
|
|
154
175
|
active: false,
|
|
155
|
-
pages
|
|
176
|
+
pages,
|
|
177
|
+
pinned: options.pinned
|
|
156
178
|
}
|
|
157
179
|
|
|
158
180
|
addCache(cacheName)
|
|
181
|
+
return tab
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (homeTab) {
|
|
185
|
+
const home = buildTab(homeTab, { pinned: true, closable: false })
|
|
186
|
+
homeTabId = home.id
|
|
187
|
+
newDefaults.push(home)
|
|
188
|
+
newTabs.push({ ...home })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for (const item of staticTabs) {
|
|
192
|
+
// home 已单独创建;相同 id 的配置跳过,避免出现两个固定首页。
|
|
193
|
+
if (homeTabId && item.id === homeTabId) {
|
|
194
|
+
if (!import.meta.env.PROD) {
|
|
195
|
+
console.warn(
|
|
196
|
+
'[vue-stack-tabs] defaultTabs contains the same id as homeTab; skipped:',
|
|
197
|
+
item.id
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
const tab = buildTab(item)
|
|
159
203
|
newDefaults.push(tab)
|
|
160
204
|
newTabs.push({ ...tab })
|
|
161
205
|
}
|
|
@@ -209,15 +253,95 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
209
253
|
iframeRefreshMode: tab.iframeRefreshMode
|
|
210
254
|
})
|
|
211
255
|
|
|
256
|
+
/**
|
|
257
|
+
* 动态更新标签标题。
|
|
258
|
+
*
|
|
259
|
+
* 同步 TabRecord、对外视图与 session;激活标签还会把新的 __tab 写回当前 URL,
|
|
260
|
+
* 避免刷新或分享链接后标题回退。
|
|
261
|
+
*/
|
|
262
|
+
const updateTabTitle = (tabId: string, title: string): boolean => {
|
|
263
|
+
if (!updateTabTitleInStore(tabId, title)) return false
|
|
264
|
+
const tab = getTab(tabId)
|
|
265
|
+
if (!tab) return false
|
|
266
|
+
|
|
267
|
+
if (tab.active) {
|
|
268
|
+
saveActiveTabToSession(tab)
|
|
269
|
+
const top = getTopPage(tabId)
|
|
270
|
+
if (top) {
|
|
271
|
+
void Promise.resolve(
|
|
272
|
+
router.replace({
|
|
273
|
+
path: top.path,
|
|
274
|
+
query: { ...cloneLocationQuery(top.query), __tab: encodeTabInfo(toTabBase(tab)) },
|
|
275
|
+
hash: router.currentRoute.value.hash
|
|
276
|
+
})
|
|
277
|
+
).catch((error: unknown) => {
|
|
278
|
+
if (!import.meta.env.PROD) {
|
|
279
|
+
console.warn('[vue-stack-tabs] Failed to sync tab title to URL:', error)
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return true
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 当前固定首页(homeTab);未配置时返回 null。 */
|
|
288
|
+
const getHomeTab = (): ITabItem | null => getTabs().find((tab) => tab.pinned) ?? null
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 无 __tab 且当前路径没有归属标签时,导航到 homeTab 的栈顶页面。
|
|
292
|
+
* 使用 replace,避免在浏览器历史里留下中间状态。
|
|
293
|
+
*/
|
|
294
|
+
const redirectToHome = (): boolean => {
|
|
295
|
+
const home = getHomeTab()
|
|
296
|
+
const top = home ? getTopPage(home.id) : null
|
|
297
|
+
if (!home || !top) return false
|
|
298
|
+
|
|
299
|
+
const query = {
|
|
300
|
+
...cloneLocationQuery(top.query),
|
|
301
|
+
__tab: encodeTabInfo(toTabBase(home))
|
|
302
|
+
}
|
|
303
|
+
void Promise.resolve(
|
|
304
|
+
router.replace({
|
|
305
|
+
path: top.path,
|
|
306
|
+
query,
|
|
307
|
+
hash: ''
|
|
308
|
+
})
|
|
309
|
+
).catch((error: unknown) => {
|
|
310
|
+
if (!import.meta.env.PROD) {
|
|
311
|
+
console.warn('[vue-stack-tabs] Failed to redirect to home tab:', error)
|
|
312
|
+
}
|
|
313
|
+
})
|
|
314
|
+
return true
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 是否应回落到 homeTab:仅限 URL 没有 __tab,且当前路径没有被任何标签的栈顶承载。
|
|
319
|
+
* 带 __tab 的地址即使页面不存在也不回落,交给应用自己的 404 路由处理。
|
|
320
|
+
*/
|
|
321
|
+
const shouldRedirectToHome = (route: RouteLocationNormalizedLoaded): boolean => {
|
|
322
|
+
if (typeof route.query.__tab === 'string') return false
|
|
323
|
+
|
|
324
|
+
const path = normalizePathForCache(route)
|
|
325
|
+
const activeTab = getActiveTab()
|
|
326
|
+
if (activeTab && getTopPage(activeTab.id)?.path === path) return false
|
|
327
|
+
if (findTabByTopPagePath(path)) return false
|
|
328
|
+
|
|
329
|
+
const home = getHomeTab()
|
|
330
|
+
return Boolean(home && getTopPage(home.id))
|
|
331
|
+
}
|
|
332
|
+
|
|
212
333
|
/**
|
|
213
334
|
* 从路由解析 tabInfo,若无则创建默认信息,调用方负责把编码信息写入内部 page.query。
|
|
214
335
|
*
|
|
215
336
|
* 首次进入应用时 URL 上通常还没有 __tab:此时优先接管 path 匹配的默认标签(defaultTabs),
|
|
216
|
-
* 由调用方把 __tab 写回地址,保证「URL
|
|
337
|
+
* 由调用方把 __tab 写回地址,保证「URL 指向默认标签」;确实找不到归属且配置了 homeTab 时
|
|
338
|
+
* 返回 null,由调用方重定向到首页;未配置 homeTab 时保持原有兜底标签行为。
|
|
217
339
|
*/
|
|
218
|
-
const parseTabInfoFromRoute = (route: RouteLocationNormalizedLoaded): ITabBase => {
|
|
219
|
-
|
|
220
|
-
|
|
340
|
+
const parseTabInfoFromRoute = (route: RouteLocationNormalizedLoaded): ITabBase | null => {
|
|
341
|
+
const encodedTab = route.query.__tab
|
|
342
|
+
const hasEncodedTab = typeof encodedTab === 'string'
|
|
343
|
+
if (hasEncodedTab) {
|
|
344
|
+
const tabInfo = decodeTabInfo(encodedTab)
|
|
221
345
|
if (tabInfo.id) return tabInfo
|
|
222
346
|
}
|
|
223
347
|
|
|
@@ -234,8 +358,12 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
234
358
|
const pathMatchedTab = findTabByTopPagePath(path)
|
|
235
359
|
if (pathMatchedTab) return toTabBase(pathMatchedTab)
|
|
236
360
|
|
|
361
|
+
// 无 __tab 且无任何标签承载当前路径:配置了 homeTab 时交给调用方重定向。
|
|
362
|
+
// 注意:__tab 存在但无效时仍保持原行为(新建兜底标签),不把错误地址变成首页。
|
|
363
|
+
if (!hasEncodedTab && getHomeTab()) return null
|
|
364
|
+
|
|
237
365
|
return {
|
|
238
|
-
id:
|
|
366
|
+
id: createId(),
|
|
239
367
|
title: t('VueStackTab.undefined'),
|
|
240
368
|
closable: true,
|
|
241
369
|
refreshable: true,
|
|
@@ -268,7 +396,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
268
396
|
url: decodeSafeTabUrl(src),
|
|
269
397
|
active: true,
|
|
270
398
|
pages
|
|
271
|
-
}
|
|
399
|
+
}
|
|
272
400
|
activateTab('')
|
|
273
401
|
addTab(targetTab)
|
|
274
402
|
emitter.emit(TabEventType.TAB_ACTIVE, { id: tabInfo.id!, isRoute: false })
|
|
@@ -295,10 +423,20 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
295
423
|
| { cacheName: string; tabInfo: ITabBase; targetTab: ITabItem }
|
|
296
424
|
| { empty: true }
|
|
297
425
|
| { recovered: DefineComponent; cacheName: string } => {
|
|
298
|
-
const
|
|
426
|
+
const resolvedTabInfo = parseTabInfoFromRoute(route)
|
|
427
|
+
if (resolvedTabInfo === null) {
|
|
428
|
+
// 无 __tab 且没有标签承载当前路径:回落到 homeTab。
|
|
429
|
+
redirectToHome()
|
|
430
|
+
return { empty: true }
|
|
431
|
+
}
|
|
432
|
+
const tabInfo = resolvedTabInfo
|
|
299
433
|
if (isTabMarkedForEviction(tabInfo.id!)) return { empty: true }
|
|
300
434
|
|
|
301
435
|
const path = normalizePathForCache(route)
|
|
436
|
+
const routeQuery = cloneRouteQuery(route, tabInfo)
|
|
437
|
+
/** path 与业务 query 都一致才算同一个页面(忽略 __tab 等保留参数) */
|
|
438
|
+
const isSameLocation = (candidate: ITabPage): boolean =>
|
|
439
|
+
candidate.path === path && isSameQueryIgnoringReserved(routeQuery, candidate.query || {})
|
|
302
440
|
|
|
303
441
|
let targetTab: ITabItem | null = tabInfo.id ? (getTab(tabInfo.id) as ITabItem | null) : null
|
|
304
442
|
let cacheName: string
|
|
@@ -308,14 +446,23 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
308
446
|
const top = getTopPage(targetTab.id)!
|
|
309
447
|
if (!top) throw new Error('[vue-stack-tabs] Stack is not empty but peek() returned undefined')
|
|
310
448
|
|
|
311
|
-
if (top
|
|
449
|
+
if (isSameLocation(top)) {
|
|
312
450
|
cacheName = top.id
|
|
451
|
+
} else if (top.path === path) {
|
|
452
|
+
// 同 path、不同 query:按产品语义作为新页面入栈,backward 才能按 query 精确回退。
|
|
453
|
+
cacheName = createPageId()
|
|
454
|
+
page = {
|
|
455
|
+
id: cacheName,
|
|
456
|
+
tabId: tabInfo.id!,
|
|
457
|
+
path,
|
|
458
|
+
query: cloneLocationQuery(routeQuery)
|
|
459
|
+
}
|
|
313
460
|
} else {
|
|
314
461
|
const pagesList = getPages(targetTab.id)
|
|
315
462
|
let foundIndex = -1
|
|
316
463
|
for (let i = pagesList.length - 2; i >= 0; i--) {
|
|
317
464
|
const pastPage = pagesList[i]
|
|
318
|
-
if (pastPage && pastPage
|
|
465
|
+
if (pastPage && isSameLocation(pastPage)) {
|
|
319
466
|
foundIndex = i
|
|
320
467
|
break
|
|
321
468
|
}
|
|
@@ -339,8 +486,8 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
339
486
|
page = {
|
|
340
487
|
id: cacheName,
|
|
341
488
|
tabId: tabInfo.id!,
|
|
342
|
-
path
|
|
343
|
-
query:
|
|
489
|
+
path,
|
|
490
|
+
query: cloneLocationQuery(routeQuery)
|
|
344
491
|
}
|
|
345
492
|
}
|
|
346
493
|
} else {
|
|
@@ -348,8 +495,8 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
348
495
|
page = {
|
|
349
496
|
id: cacheName,
|
|
350
497
|
tabId: tabInfo.id!,
|
|
351
|
-
path
|
|
352
|
-
query:
|
|
498
|
+
path,
|
|
499
|
+
query: cloneLocationQuery(routeQuery)
|
|
353
500
|
}
|
|
354
501
|
}
|
|
355
502
|
}
|
|
@@ -358,8 +505,8 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
358
505
|
page = {
|
|
359
506
|
id: cacheName,
|
|
360
507
|
tabId: tabInfo.id!,
|
|
361
|
-
path
|
|
362
|
-
query:
|
|
508
|
+
path,
|
|
509
|
+
query: cloneLocationQuery(routeQuery)
|
|
363
510
|
}
|
|
364
511
|
}
|
|
365
512
|
|
|
@@ -404,7 +551,12 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
404
551
|
route: RouteLocationNormalizedLoaded,
|
|
405
552
|
component?: VNode | null
|
|
406
553
|
): DefineComponent => {
|
|
407
|
-
if (!component)
|
|
554
|
+
if (!component) {
|
|
555
|
+
// 路由没有匹配到组件时不会进入 updatePageState;仍需处理无 __tab 的未知地址回落到首页。
|
|
556
|
+
// 带 __tab 的未知页面不做重定向,交给应用自己的 404 路由(或空占位)处理。
|
|
557
|
+
if (shouldRedirectToHome(route)) redirectToHome()
|
|
558
|
+
return getEmptyPlaceholderComponent()
|
|
559
|
+
}
|
|
408
560
|
|
|
409
561
|
const routeKey = route.fullPath + (route.query.__tab || '')
|
|
410
562
|
if (routeKey === lastRouteKey && lastAddPageResult) {
|
|
@@ -517,6 +669,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
517
669
|
getTabs,
|
|
518
670
|
getTopPage,
|
|
519
671
|
updatePage,
|
|
672
|
+
updatePages,
|
|
520
673
|
iframeRefreshKeys,
|
|
521
674
|
createPageId,
|
|
522
675
|
addCache,
|
|
@@ -606,6 +759,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
606
759
|
initialize,
|
|
607
760
|
size,
|
|
608
761
|
active,
|
|
762
|
+
updateTabTitle,
|
|
609
763
|
getCacheName: createCacheName,
|
|
610
764
|
destroy,
|
|
611
765
|
reset,
|
|
@@ -615,6 +769,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
615
769
|
getActiveTab,
|
|
616
770
|
getTopPage,
|
|
617
771
|
getPages,
|
|
772
|
+
getPageRefreshVersion,
|
|
618
773
|
getPageSnapshot,
|
|
619
774
|
moveTab,
|
|
620
775
|
addTab,
|
|
@@ -623,6 +778,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
623
778
|
replacePages,
|
|
624
779
|
clearPages,
|
|
625
780
|
updatePage,
|
|
781
|
+
updatePages,
|
|
626
782
|
removeTab,
|
|
627
783
|
removeAllTabs,
|
|
628
784
|
removeLeftTabs,
|
|
@@ -651,3 +807,24 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
651
807
|
}
|
|
652
808
|
}
|
|
653
809
|
}
|
|
810
|
+
|
|
811
|
+
type TabPanelApi = ReturnType<typeof createTabPanelApi>
|
|
812
|
+
|
|
813
|
+
const panelApiByContext = new WeakMap<StackTabsRuntimeContext, TabPanelApi>()
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* 同一个运行时上下文复用同一套 panel API。
|
|
817
|
+
*
|
|
818
|
+
* StackTabs、StackKeepAlive、StackCacheRenderer、ContextMenu 与页面内的 useTabRouter
|
|
819
|
+
* 都会调用本 hook;首次调用在 setup 上下文中完成 useRouter/useI18n 注入,
|
|
820
|
+
* 后续调用直接返回缓存,避免重复创建 scroll/session/refresh 闭包与局部状态。
|
|
821
|
+
*/
|
|
822
|
+
export default (providedRuntimeContext?: StackTabsRuntimeContext): TabPanelApi => {
|
|
823
|
+
const runtimeContext = providedRuntimeContext ?? resolveStackTabsRuntimeContext()
|
|
824
|
+
const existing = panelApiByContext.get(runtimeContext)
|
|
825
|
+
if (existing) return existing
|
|
826
|
+
|
|
827
|
+
const panelApi = createTabPanelApi(runtimeContext)
|
|
828
|
+
panelApiByContext.set(runtimeContext, panelApi)
|
|
829
|
+
return panelApi
|
|
830
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { defu } from 'defu'
|
|
2
1
|
import { getCurrentInstance } from 'vue'
|
|
3
2
|
import type { ComponentInternalInstance } from 'vue'
|
|
4
3
|
import type { LocationQueryRaw, RouteLocationPathRaw } from 'vue-router'
|
|
@@ -9,7 +8,9 @@ import {
|
|
|
9
8
|
parseUrl,
|
|
10
9
|
cloneLocationQuery,
|
|
11
10
|
clonePage,
|
|
12
|
-
isSameQueryIgnoringReserved
|
|
11
|
+
isSameQueryIgnoringReserved,
|
|
12
|
+
normalizePathname,
|
|
13
|
+
omitStackTabsReservedQuery
|
|
13
14
|
} from '../utils/urlParser'
|
|
14
15
|
import { TabEventType, useTabEmitter } from './useTabEventBus'
|
|
15
16
|
|
|
@@ -84,10 +85,11 @@ export default function useTabRouter() {
|
|
|
84
85
|
const forward = (to: RouteLocationPathRaw) => {
|
|
85
86
|
const tab = getTab(tabId)
|
|
86
87
|
const targetQuery = (to.query || {}) as LocationQueryRaw
|
|
88
|
+
const targetPath = normalizePathname(to.path)
|
|
87
89
|
|
|
88
90
|
if (tab) {
|
|
89
91
|
const top = getTopPage(tab.id)
|
|
90
|
-
if (top && top.path ===
|
|
92
|
+
if (top && top.path === targetPath) {
|
|
91
93
|
if (isSameQueryIgnoringReserved(targetQuery, top.query || {})) {
|
|
92
94
|
if (!tab.active) {
|
|
93
95
|
activateTab(tab.id)
|
|
@@ -97,9 +99,10 @@ export default function useTabRouter() {
|
|
|
97
99
|
}
|
|
98
100
|
}
|
|
99
101
|
|
|
100
|
-
// __tab 是 StackTabs
|
|
101
|
-
//
|
|
102
|
-
const query =
|
|
102
|
+
// __tab 是 StackTabs 解析路由归属标签的关键:强制使用当前标签的编码,
|
|
103
|
+
// 业务 query 传入同名 key 时也不能把导航带到其它标签。
|
|
104
|
+
const query: LocationQueryRaw = { ...omitStackTabsReservedQuery(targetQuery) }
|
|
105
|
+
if (typeof tabInfo === 'string' && tabInfo) query.__tab = tabInfo
|
|
103
106
|
const transactionVersion = ++navigationVersion
|
|
104
107
|
emitter.emit(TabEventType.FORWARD)
|
|
105
108
|
|
|
@@ -112,8 +115,8 @@ export default function useTabRouter() {
|
|
|
112
115
|
appendPage(tab.id, {
|
|
113
116
|
id: optimisticPageId,
|
|
114
117
|
tabId: tab.id,
|
|
115
|
-
path:
|
|
116
|
-
query: cloneLocationQuery(
|
|
118
|
+
path: targetPath,
|
|
119
|
+
query: cloneLocationQuery(query)
|
|
117
120
|
})
|
|
118
121
|
}
|
|
119
122
|
return snapshot
|
|
@@ -156,13 +159,14 @@ export default function useTabRouter() {
|
|
|
156
159
|
} else {
|
|
157
160
|
const parsed = parseUrl(to)
|
|
158
161
|
const targetQuery = parsed.query || {}
|
|
162
|
+
const targetPath = normalizePathname(parsed.path)
|
|
159
163
|
let found = false
|
|
160
164
|
|
|
161
165
|
for (let i = pages.length - 2; i >= 0; i--) {
|
|
162
166
|
steps++
|
|
163
167
|
const page = pages[i]
|
|
164
168
|
if (
|
|
165
|
-
page?.path ===
|
|
169
|
+
page?.path === targetPath &&
|
|
166
170
|
isSameQueryIgnoringReserved(targetQuery, page?.query || {})
|
|
167
171
|
) {
|
|
168
172
|
found = true
|
|
@@ -210,8 +214,11 @@ export default function useTabRouter() {
|
|
|
210
214
|
|
|
211
215
|
emitter.emit(TabEventType.BACKWARD)
|
|
212
216
|
|
|
213
|
-
//
|
|
214
|
-
const targetQueryWithTab =
|
|
217
|
+
// 与 forward 一致:目标页固定归属当前标签,保留其余业务 query。
|
|
218
|
+
const targetQueryWithTab: LocationQueryRaw = {
|
|
219
|
+
...omitStackTabsReservedQuery(target.query || {})
|
|
220
|
+
}
|
|
221
|
+
if (typeof tabInfo === 'string' && tabInfo) targetQueryWithTab.__tab = tabInfo
|
|
215
222
|
const transactionVersion = ++navigationVersion
|
|
216
223
|
|
|
217
224
|
runNavigationTransaction({
|
|
@@ -64,6 +64,11 @@ export interface ITabItem extends ITabBase {
|
|
|
64
64
|
url?: string
|
|
65
65
|
active: boolean
|
|
66
66
|
pages: Stack<ITabPage>
|
|
67
|
+
/**
|
|
68
|
+
* 固定在标签栏最前方的内部标记(目前仅 homeTab 使用)。
|
|
69
|
+
* pinned 标签不可被拖动,其他标签也不能排到它前面;仅存在于运行时,不写入 __tab。
|
|
70
|
+
*/
|
|
71
|
+
pinned?: boolean
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
/** 标签内的单个页面(对应 keep-alive 的一个缓存实例) */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ITabBase } from '../model/TabModel'
|
|
2
|
+
import { isRecord } from './typeGuards'
|
|
2
3
|
|
|
3
4
|
interface EncodedTabInfoV1 {
|
|
4
5
|
version: 1
|
|
@@ -14,20 +15,42 @@ interface EncodedTabInfoV1 {
|
|
|
14
15
|
* 标签信息编解码器
|
|
15
16
|
*
|
|
16
17
|
* 职责:将标签元数据编码为 URL query 可用的短字符串,用于 __tab 参数。
|
|
17
|
-
* 当前格式为 JSON + Base64
|
|
18
|
+
* 当前格式为 JSON + Base64URL;解码同时兼容标准 Base64(历史链接)与
|
|
19
|
+
* 旧版 id|title|iframe|closable|refreshable|mode 编码。
|
|
18
20
|
*/
|
|
19
21
|
|
|
20
22
|
const MAX_DECODED_TAB_INFO_LENGTH = 2048
|
|
21
23
|
const MAX_ENCODED_TAB_INFO_LENGTH = 4096
|
|
24
|
+
/** URL 中 __tab 的 title 上限,避免超长标题把 payload 顶到解码上限导致 id 丢失。 */
|
|
25
|
+
export const MAX_TAB_TITLE_LENGTH = 256
|
|
26
|
+
/** legacy 格式中 id 的合理上限,用于拒绝任意字符串伪装成标签 id。 */
|
|
27
|
+
const MAX_LEGACY_TAB_ID_LENGTH = 128
|
|
22
28
|
|
|
23
29
|
/**
|
|
24
|
-
*
|
|
25
|
-
* 使用
|
|
30
|
+
* 把 UTF-8 字符串编码为 Base64URL(无填充、`+`→`-`、`/`→`_`)。
|
|
31
|
+
* 使用 TextEncoder 取代已废弃的 escape/unescape,同时让结果可直接放进 URL。
|
|
26
32
|
*/
|
|
27
|
-
const
|
|
33
|
+
const encodeBase64Url = (value: string): string => {
|
|
34
|
+
const bytes = new TextEncoder().encode(value)
|
|
35
|
+
let binary = ''
|
|
36
|
+
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
37
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
38
|
+
}
|
|
28
39
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
40
|
+
/**
|
|
41
|
+
* 解码 Base64URL / 标准 Base64。
|
|
42
|
+
* 兼容旧链接:先还原 URL-safe 字符与填充,再按 UTF-8 解码。
|
|
43
|
+
*/
|
|
44
|
+
const decodeBase64 = (encoded: string): string => {
|
|
45
|
+
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
|
|
46
|
+
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
|
|
47
|
+
const binary = atob(padded)
|
|
48
|
+
const bytes = new Uint8Array(binary.length)
|
|
49
|
+
for (let index = 0; index < binary.length; index++) {
|
|
50
|
+
bytes[index] = binary.charCodeAt(index)
|
|
51
|
+
}
|
|
52
|
+
return new TextDecoder().decode(bytes)
|
|
53
|
+
}
|
|
31
54
|
|
|
32
55
|
const DEFAULT_TAB_INFO: ITabBase = {
|
|
33
56
|
id: '',
|
|
@@ -38,7 +61,14 @@ const DEFAULT_TAB_INFO: ITabBase = {
|
|
|
38
61
|
iframeRefreshMode: 'postMessage'
|
|
39
62
|
}
|
|
40
63
|
|
|
41
|
-
|
|
64
|
+
/** 检测控制字符,避免 legacy id 携带不可见字符。 */
|
|
65
|
+
const hasControlCharacter = (value: string): boolean => {
|
|
66
|
+
for (const char of value) {
|
|
67
|
+
const code = char.charCodeAt(0)
|
|
68
|
+
if (code <= 31 || code === 127) return true
|
|
69
|
+
}
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
42
72
|
|
|
43
73
|
const toTabInfo = (payload: unknown): ITabBase => {
|
|
44
74
|
if (!isRecord(payload)) return { ...DEFAULT_TAB_INFO }
|
|
@@ -54,16 +84,36 @@ const toTabInfo = (payload: unknown): ITabBase => {
|
|
|
54
84
|
}
|
|
55
85
|
}
|
|
56
86
|
|
|
87
|
+
/**
|
|
88
|
+
* 旧版分隔符格式:id|title|iframe|closable|refreshable[|mode]。
|
|
89
|
+
*
|
|
90
|
+
* 严格校验字段数量与 Y/N 标记:非法字符串(例如 `?__tab=invalid`)不能再被解析成
|
|
91
|
+
* 任意 id 的标签。格式不合法时返回默认值,由路由层走兜底标签逻辑。
|
|
92
|
+
*/
|
|
57
93
|
const decodeLegacyTabInfo = (tabString: string): ITabBase => {
|
|
58
94
|
const tabValues: string[] = tabString.split('|')
|
|
59
|
-
|
|
95
|
+
if (tabValues.length < 5 || tabValues.length > 6) return { ...DEFAULT_TAB_INFO }
|
|
96
|
+
|
|
97
|
+
const [id = '', title = '', iframe, closable, refreshable, mode] = tabValues
|
|
98
|
+
const isFlag = (value: string | undefined): value is 'Y' | 'N' => value === 'Y' || value === 'N'
|
|
99
|
+
const hasValidId =
|
|
100
|
+
id.length > 0 &&
|
|
101
|
+
id.length <= MAX_LEGACY_TAB_ID_LENGTH &&
|
|
102
|
+
id.trim() === id &&
|
|
103
|
+
!hasControlCharacter(id)
|
|
104
|
+
|
|
105
|
+
if (!hasValidId || !isFlag(iframe) || !isFlag(closable) || !isFlag(refreshable)) {
|
|
106
|
+
return { ...DEFAULT_TAB_INFO }
|
|
107
|
+
}
|
|
108
|
+
if (mode !== undefined && mode !== '' && mode !== 'R') return { ...DEFAULT_TAB_INFO }
|
|
109
|
+
|
|
60
110
|
return {
|
|
61
|
-
id
|
|
62
|
-
title
|
|
63
|
-
iframe:
|
|
64
|
-
closable:
|
|
65
|
-
refreshable:
|
|
66
|
-
iframeRefreshMode:
|
|
111
|
+
id,
|
|
112
|
+
title,
|
|
113
|
+
iframe: iframe === 'Y',
|
|
114
|
+
closable: closable === 'Y',
|
|
115
|
+
refreshable: refreshable === 'Y',
|
|
116
|
+
iframeRefreshMode: mode === 'R' ? 'reload' : 'postMessage'
|
|
67
117
|
}
|
|
68
118
|
}
|
|
69
119
|
|
|
@@ -71,13 +121,13 @@ export const encodeTabInfo = (tabData: ITabBase): string => {
|
|
|
71
121
|
const payload: EncodedTabInfoV1 = {
|
|
72
122
|
version: 1,
|
|
73
123
|
id: tabData.id,
|
|
74
|
-
title: tabData.title,
|
|
124
|
+
title: tabData.title.slice(0, MAX_TAB_TITLE_LENGTH),
|
|
75
125
|
iframe: tabData.iframe,
|
|
76
126
|
closable: tabData.closable,
|
|
77
127
|
refreshable: tabData.refreshable,
|
|
78
128
|
iframeRefreshMode: tabData.iframeRefreshMode === 'reload' ? 'reload' : 'postMessage'
|
|
79
129
|
}
|
|
80
|
-
return
|
|
130
|
+
return encodeBase64Url(JSON.stringify(payload))
|
|
81
131
|
}
|
|
82
132
|
|
|
83
133
|
export const decodeTabInfo = (encoded: string): ITabBase => {
|
|
@@ -102,4 +152,38 @@ export const decodeTabInfo = (encoded: string): ITabBase => {
|
|
|
102
152
|
}
|
|
103
153
|
}
|
|
104
154
|
|
|
105
|
-
|
|
155
|
+
/**
|
|
156
|
+
* 生成运行时 id。
|
|
157
|
+
*
|
|
158
|
+
* `crypto.randomUUID` 仅在安全上下文(https / localhost)可用;内网 http 部署时
|
|
159
|
+
* 回退到 `crypto.getRandomValues`(非安全上下文同样可用),最后才退回 Math.random,
|
|
160
|
+
* 避免在受限环境下直接抛 TypeError 导致整个组件不可用。
|
|
161
|
+
*/
|
|
162
|
+
export const createId = (): string => {
|
|
163
|
+
const cryptoObject = globalThis.crypto
|
|
164
|
+
|
|
165
|
+
if (typeof cryptoObject?.randomUUID === 'function') {
|
|
166
|
+
return cryptoObject.randomUUID()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const bytes = new Uint8Array(16)
|
|
170
|
+
if (typeof cryptoObject?.getRandomValues === 'function') {
|
|
171
|
+
cryptoObject.getRandomValues(bytes)
|
|
172
|
+
} else {
|
|
173
|
+
for (let index = 0; index < bytes.length; index++) {
|
|
174
|
+
bytes[index] = Math.floor(Math.random() * 256)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// RFC 4122 version 4 + variant bits
|
|
179
|
+
bytes[6] = (bytes[6]! & 0x0f) | 0x40
|
|
180
|
+
bytes[8] = (bytes[8]! & 0x3f) | 0x80
|
|
181
|
+
|
|
182
|
+
const hex: string[] = []
|
|
183
|
+
for (const byte of bytes) hex.push(byte.toString(16).padStart(2, '0'))
|
|
184
|
+
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex
|
|
185
|
+
.slice(6, 8)
|
|
186
|
+
.join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export const createPageId = (): string => createId()
|