vue-stack-tabs 1.4.2 → 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/README.en.md +1 -0
- package/README.md +1 -0
- package/README.ru.md +1 -0
- package/dist/StackTabs.vue.d.ts +5 -2
- package/dist/components/ContextMenu/ContextMenuItem.vue.d.ts +1 -1
- 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/refresh.d.ts +4 -2
- package/dist/hooks/tabPanel/scroll.d.ts +0 -2
- package/dist/hooks/tabPanel/state.d.ts +0 -1
- package/dist/hooks/tabPanel/tabStore.d.ts +12 -1
- package/dist/hooks/useIframeManager.d.ts +1 -1
- package/dist/hooks/useScrollShadow.d.ts +11 -2
- package/dist/hooks/useTabActions.d.ts +1 -0
- package/dist/hooks/useTabEventBus.d.ts +5 -0
- package/dist/hooks/useTabPanel.d.ts +14 -2
- package/dist/model/TabModel.d.ts +17 -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 +8 -0
- package/dist/utils/urlParser.d.ts +20 -10
- package/dist/vue-stack-tabs.es.js +1 -1
- package/package.json +4 -7
- package/src/lib/StackTabs.vue +40 -3
- 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} +116 -182
- 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 +12 -11
- package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +1 -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 +170 -94
- package/src/lib/components/TabHeader/index.vue +67 -12
- package/src/lib/hooks/stackTabsContext.ts +0 -1
- package/src/lib/hooks/tabPanel/evict.ts +14 -6
- package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +252 -7
- package/src/lib/hooks/tabPanel/refresh.ts +27 -9
- 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 +114 -26
- package/src/lib/hooks/useContextMenu.ts +1 -1
- package/src/lib/hooks/useIframeManager.ts +21 -18
- package/src/lib/hooks/useScrollShadow.ts +74 -19
- package/src/lib/hooks/useTabActions.ts +18 -8
- package/src/lib/hooks/useTabEventBus.ts +3 -0
- package/src/lib/hooks/useTabPanel.tsx +247 -58
- package/src/lib/hooks/useTabRouter.ts +18 -11
- package/src/lib/model/TabModel.ts +19 -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 +121 -18
- package/src/lib/utils/urlParser.ts +75 -48
- package/dist/vue-stack-tabs.css +0 -2
- package/src/lib/assets/style/overlayer.scss +0 -46
|
@@ -1,8 +1,8 @@
|
|
|
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'
|
|
5
|
-
import { encodeTabInfo } from '../utils/tabInfoEncoder'
|
|
5
|
+
import { createId, encodeTabInfo } from '../utils/tabInfoEncoder'
|
|
6
6
|
import {
|
|
7
7
|
parseUrl,
|
|
8
8
|
isCrossOriginUrl,
|
|
@@ -31,6 +31,7 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
|
|
|
31
31
|
const runtimeContext = providedRuntimeContext ?? resolveStackTabsRuntimeContext()
|
|
32
32
|
const {
|
|
33
33
|
active,
|
|
34
|
+
updateTabTitle,
|
|
34
35
|
hasTab,
|
|
35
36
|
refreshKey,
|
|
36
37
|
reset,
|
|
@@ -61,7 +62,7 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
|
|
|
61
62
|
defaults.iframeRefreshMode = (tab.iframeRefreshMode ?? 'postMessage') as IframeRefreshMode
|
|
62
63
|
}
|
|
63
64
|
const info = defu(tab, defaults)
|
|
64
|
-
if (!info.id) info.id =
|
|
65
|
+
if (!info.id) info.id = createId()
|
|
65
66
|
return info
|
|
66
67
|
}
|
|
67
68
|
|
|
@@ -94,7 +95,7 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
|
|
|
94
95
|
return
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
navigateToTab(tab, tabInfo).then(
|
|
98
|
+
navigateToTab(tab, tabInfo, isExistingTab && renew).then(
|
|
98
99
|
() => resolve(tabInfo.id),
|
|
99
100
|
(error) => {
|
|
100
101
|
rollbackRenew?.()
|
|
@@ -106,15 +107,17 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
|
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
/** 执行路由跳转到标签对应页面(普通路由或 iframe 占位路由) */
|
|
109
|
-
const navigateToTab = (tab: ITabData, tabInfo: ITabData) => {
|
|
110
|
+
const navigateToTab = (tab: ITabData, tabInfo: ITabData, allowDuplicatedNavigation = false) => {
|
|
110
111
|
const __tab = encodeTabInfo(tabInfo)
|
|
111
112
|
const tabQuery = omitStackTabsReservedQuery(tab.query)
|
|
112
113
|
let query = defu(tabQuery, { __tab }) as LocationQueryRaw
|
|
113
114
|
let path: string
|
|
115
|
+
let hash: string | undefined
|
|
114
116
|
|
|
115
117
|
if (!tab.iframe) {
|
|
116
118
|
const url = parseUrl(tab.path)
|
|
117
119
|
path = url.path
|
|
120
|
+
hash = url.hash || undefined
|
|
118
121
|
query = defu(omitStackTabsReservedQuery(url.query), query, { __tab }) as LocationQueryRaw
|
|
119
122
|
query = {
|
|
120
123
|
...query,
|
|
@@ -132,9 +135,15 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
|
|
|
132
135
|
if (!isAllowedTabUrl(tab.path)) return Promise.reject(new Error('Invalid tab URL'))
|
|
133
136
|
return runNavigationTransaction({
|
|
134
137
|
apply: () => undefined,
|
|
135
|
-
navigate: () => router.push({ path, query }),
|
|
138
|
+
navigate: () => router.push({ path, query, hash }),
|
|
136
139
|
rollback: () => undefined,
|
|
137
|
-
|
|
140
|
+
// renew 当前标签时地址可能不变:vue-router 会返回 duplicated NavigationFailure,
|
|
141
|
+
// 这属于预期结果(refreshKey 会触发页面重新解析),不应让 openTab reject。
|
|
142
|
+
isFailureResult: (result) =>
|
|
143
|
+
isNavigationFailure(result) &&
|
|
144
|
+
!(
|
|
145
|
+
allowDuplicatedNavigation && isNavigationFailure(result, NavigationFailureType.duplicated)
|
|
146
|
+
),
|
|
138
147
|
rejectFailureResult: true
|
|
139
148
|
})
|
|
140
149
|
}
|
|
@@ -155,7 +164,7 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
|
|
|
155
164
|
if (typeof document === 'undefined') return undefined
|
|
156
165
|
const pageId = target.pageId ?? (target.tabId ? getTopPage(target.tabId)?.id : undefined)
|
|
157
166
|
if (pageId) return document.getElementById(`W-${pageId}`) ?? undefined
|
|
158
|
-
return document.querySelector<HTMLElement>('.
|
|
167
|
+
return document.querySelector<HTMLElement>('.stack-tab__cache-page-wrapper') ?? undefined
|
|
159
168
|
}
|
|
160
169
|
|
|
161
170
|
/** iframe 标签在新窗口打开(无法嵌入时降级) */
|
|
@@ -181,6 +190,7 @@ export default function useTabActions(providedRuntimeContext?: StackTabsRuntimeC
|
|
|
181
190
|
// 激活与重置
|
|
182
191
|
activeTab: active,
|
|
183
192
|
moveTab,
|
|
193
|
+
updateTabTitle,
|
|
184
194
|
reset,
|
|
185
195
|
|
|
186
196
|
// 数据与配置
|
|
@@ -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
|
|
@@ -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,12 +57,11 @@ 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
|
-
const { tabs,
|
|
64
|
+
const { tabs, caches, refreshKey, iframeRefreshKeys, isInitialized } = runtimeContext
|
|
61
65
|
const store = getTabStore(runtimeContext)
|
|
62
66
|
const {
|
|
63
67
|
getTabs,
|
|
@@ -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,30 +121,57 @@ 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[]) => {
|
|
121
|
-
|
|
128
|
+
const initialize = (staticTabs: ITabData[], homeTab?: ITabData) => {
|
|
129
|
+
// 重复调用会重复注册标签;初始化后直接忽略。
|
|
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
|
+
}
|
|
122
136
|
const newTabs: ITabItem[] = []
|
|
137
|
+
let homeTabId = ''
|
|
123
138
|
|
|
124
|
-
|
|
139
|
+
/** 由 ITabData 构建运行时标签;homeTab 通过 options 强制固定属性 */
|
|
140
|
+
const buildTab = (
|
|
141
|
+
item: ITabData,
|
|
142
|
+
options: { pinned?: boolean; closable?: boolean } = {}
|
|
143
|
+
): ITabItem => {
|
|
125
144
|
// 先用默认值填充非空缺字段,避免在循环里提前求值生成用不到的 UUID。
|
|
126
145
|
const fullItem = defu(item, {
|
|
127
146
|
refreshable: true,
|
|
128
147
|
closable: true,
|
|
129
148
|
iframe: false
|
|
130
149
|
})
|
|
131
|
-
// id
|
|
132
|
-
fullItem.id = item.id ??
|
|
150
|
+
// homeTab 未指定 id 时使用固定 id,避免每次初始化生成新 id 后与 session 恢复标签重复。
|
|
151
|
+
fullItem.id = item.id ?? (options.pinned ? DEFAULT_HOME_TAB_ID : createId())
|
|
152
|
+
if (options.closable !== undefined) fullItem.closable = options.closable
|
|
133
153
|
const tabId = fullItem.id
|
|
154
|
+
const isIframe = Boolean(fullItem.iframe)
|
|
134
155
|
const uri = parseUrl(fullItem.path)
|
|
135
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)
|
|
136
170
|
const page: ITabPage = {
|
|
137
171
|
id: cacheName,
|
|
138
172
|
tabId,
|
|
139
|
-
path:
|
|
140
|
-
query: defu(
|
|
173
|
+
path: pagePath,
|
|
174
|
+
query: defu(pageQuery, { __tab: encodeTabInfo(fullItem) })
|
|
141
175
|
}
|
|
142
176
|
const pages = new Stack<ITabPage>()
|
|
143
177
|
pages.push(page)
|
|
@@ -146,21 +180,39 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
146
180
|
title: fullItem.title ?? '',
|
|
147
181
|
closable: fullItem.closable!,
|
|
148
182
|
refreshable: fullItem.refreshable!,
|
|
149
|
-
iframe:
|
|
150
|
-
iframeRefreshMode
|
|
151
|
-
|
|
152
|
-
? 'reload'
|
|
153
|
-
: 'postMessage',
|
|
183
|
+
iframe: isIframe,
|
|
184
|
+
iframeRefreshMode,
|
|
185
|
+
url: isIframe ? toSafeTabUrl(fullItem.path) : undefined,
|
|
154
186
|
active: false,
|
|
155
|
-
pages
|
|
187
|
+
pages,
|
|
188
|
+
pinned: options.pinned
|
|
156
189
|
}
|
|
157
190
|
|
|
158
191
|
addCache(cacheName)
|
|
159
|
-
|
|
192
|
+
return tab
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (homeTab) {
|
|
196
|
+
const home = buildTab(homeTab, { pinned: true, closable: false })
|
|
197
|
+
homeTabId = home.id
|
|
198
|
+
newTabs.push({ ...home })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (const item of staticTabs) {
|
|
202
|
+
// home 已单独创建;相同 id 的配置跳过,避免出现两个固定首页。
|
|
203
|
+
if (homeTabId && item.id === homeTabId) {
|
|
204
|
+
if (!import.meta.env.PROD) {
|
|
205
|
+
console.warn(
|
|
206
|
+
'[vue-stack-tabs] defaultTabs contains the same id as homeTab; skipped:',
|
|
207
|
+
item.id
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
const tab = buildTab(item)
|
|
160
213
|
newTabs.push({ ...tab })
|
|
161
214
|
}
|
|
162
215
|
|
|
163
|
-
defaultTabs.value = [...defaultTabs.value, ...newDefaults]
|
|
164
216
|
addTabs(newTabs)
|
|
165
217
|
|
|
166
218
|
// SSR 环境无 sessionStorage,直接跳过恢复。
|
|
@@ -209,15 +261,95 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
209
261
|
iframeRefreshMode: tab.iframeRefreshMode
|
|
210
262
|
})
|
|
211
263
|
|
|
264
|
+
/**
|
|
265
|
+
* 动态更新标签标题。
|
|
266
|
+
*
|
|
267
|
+
* 同步 TabRecord、对外视图与 session;激活标签还会把新的 __tab 写回当前 URL,
|
|
268
|
+
* 避免刷新或分享链接后标题回退。
|
|
269
|
+
*/
|
|
270
|
+
const updateTabTitle = (tabId: string, title: string): boolean => {
|
|
271
|
+
if (!updateTabTitleInStore(tabId, title)) return false
|
|
272
|
+
const tab = getTab(tabId)
|
|
273
|
+
if (!tab) return false
|
|
274
|
+
|
|
275
|
+
if (tab.active) {
|
|
276
|
+
saveActiveTabToSession(tab)
|
|
277
|
+
const top = getTopPage(tabId)
|
|
278
|
+
if (top) {
|
|
279
|
+
void Promise.resolve(
|
|
280
|
+
router.replace({
|
|
281
|
+
path: top.path,
|
|
282
|
+
query: { ...cloneLocationQuery(top.query), __tab: encodeTabInfo(toTabBase(tab)) },
|
|
283
|
+
hash: router.currentRoute.value.hash
|
|
284
|
+
})
|
|
285
|
+
).catch((error: unknown) => {
|
|
286
|
+
if (!import.meta.env.PROD) {
|
|
287
|
+
console.warn('[vue-stack-tabs] Failed to sync tab title to URL:', error)
|
|
288
|
+
}
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return true
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** 当前固定首页(homeTab);未配置时返回 null。 */
|
|
296
|
+
const getHomeTab = (): ITabItem | null => getTabs().find((tab) => tab.pinned) ?? null
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 无 __tab 且当前路径没有归属标签时,导航到 homeTab 的栈顶页面。
|
|
300
|
+
* 使用 replace,避免在浏览器历史里留下中间状态。
|
|
301
|
+
*/
|
|
302
|
+
const redirectToHome = (): boolean => {
|
|
303
|
+
const home = getHomeTab()
|
|
304
|
+
const top = home ? getTopPage(home.id) : null
|
|
305
|
+
if (!home || !top) return false
|
|
306
|
+
|
|
307
|
+
const query = {
|
|
308
|
+
...cloneLocationQuery(top.query),
|
|
309
|
+
__tab: encodeTabInfo(toTabBase(home))
|
|
310
|
+
}
|
|
311
|
+
void Promise.resolve(
|
|
312
|
+
router.replace({
|
|
313
|
+
path: top.path,
|
|
314
|
+
query,
|
|
315
|
+
hash: ''
|
|
316
|
+
})
|
|
317
|
+
).catch((error: unknown) => {
|
|
318
|
+
if (!import.meta.env.PROD) {
|
|
319
|
+
console.warn('[vue-stack-tabs] Failed to redirect to home tab:', error)
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
return true
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 是否应回落到 homeTab:仅限 URL 没有 __tab,且当前路径没有被任何标签的栈顶承载。
|
|
327
|
+
* 带 __tab 的地址即使页面不存在也不回落,交给应用自己的 404 路由处理。
|
|
328
|
+
*/
|
|
329
|
+
const shouldRedirectToHome = (route: RouteLocationNormalizedLoaded): boolean => {
|
|
330
|
+
if (typeof route.query.__tab === 'string') return false
|
|
331
|
+
|
|
332
|
+
const path = normalizePathForCache(route)
|
|
333
|
+
const activeTab = getActiveTab()
|
|
334
|
+
if (activeTab && getTopPage(activeTab.id)?.path === path) return false
|
|
335
|
+
if (findTabByTopPagePath(path)) return false
|
|
336
|
+
|
|
337
|
+
const home = getHomeTab()
|
|
338
|
+
return Boolean(home && getTopPage(home.id))
|
|
339
|
+
}
|
|
340
|
+
|
|
212
341
|
/**
|
|
213
342
|
* 从路由解析 tabInfo,若无则创建默认信息,调用方负责把编码信息写入内部 page.query。
|
|
214
343
|
*
|
|
215
344
|
* 首次进入应用时 URL 上通常还没有 __tab:此时优先接管 path 匹配的默认标签(defaultTabs),
|
|
216
|
-
* 由调用方把 __tab 写回地址,保证「URL
|
|
345
|
+
* 由调用方把 __tab 写回地址,保证「URL 指向默认标签」;确实找不到归属且配置了 homeTab 时
|
|
346
|
+
* 返回 null,由调用方重定向到首页;未配置 homeTab 时保持原有兜底标签行为。
|
|
217
347
|
*/
|
|
218
|
-
const parseTabInfoFromRoute = (route: RouteLocationNormalizedLoaded): ITabBase => {
|
|
219
|
-
|
|
220
|
-
|
|
348
|
+
const parseTabInfoFromRoute = (route: RouteLocationNormalizedLoaded): ITabBase | null => {
|
|
349
|
+
const encodedTab = route.query.__tab
|
|
350
|
+
const hasEncodedTab = typeof encodedTab === 'string'
|
|
351
|
+
if (hasEncodedTab) {
|
|
352
|
+
const tabInfo = decodeTabInfo(encodedTab)
|
|
221
353
|
if (tabInfo.id) return tabInfo
|
|
222
354
|
}
|
|
223
355
|
|
|
@@ -234,8 +366,12 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
234
366
|
const pathMatchedTab = findTabByTopPagePath(path)
|
|
235
367
|
if (pathMatchedTab) return toTabBase(pathMatchedTab)
|
|
236
368
|
|
|
369
|
+
// 无 __tab 且无任何标签承载当前路径:配置了 homeTab 时交给调用方重定向。
|
|
370
|
+
// 注意:__tab 存在但无效时仍保持原行为(新建兜底标签),不把错误地址变成首页。
|
|
371
|
+
if (!hasEncodedTab && getHomeTab()) return null
|
|
372
|
+
|
|
237
373
|
return {
|
|
238
|
-
id:
|
|
374
|
+
id: createId(),
|
|
239
375
|
title: t('VueStackTab.undefined'),
|
|
240
376
|
closable: true,
|
|
241
377
|
refreshable: true,
|
|
@@ -268,7 +404,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
268
404
|
url: decodeSafeTabUrl(src),
|
|
269
405
|
active: true,
|
|
270
406
|
pages
|
|
271
|
-
}
|
|
407
|
+
}
|
|
272
408
|
activateTab('')
|
|
273
409
|
addTab(targetTab)
|
|
274
410
|
emitter.emit(TabEventType.TAB_ACTIVE, { id: tabInfo.id!, isRoute: false })
|
|
@@ -295,27 +431,44 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
295
431
|
| { cacheName: string; tabInfo: ITabBase; targetTab: ITabItem }
|
|
296
432
|
| { empty: true }
|
|
297
433
|
| { recovered: DefineComponent; cacheName: string } => {
|
|
298
|
-
const
|
|
434
|
+
const resolvedTabInfo = parseTabInfoFromRoute(route)
|
|
435
|
+
if (resolvedTabInfo === null) {
|
|
436
|
+
// 无 __tab 且没有标签承载当前路径:回落到 homeTab。
|
|
437
|
+
redirectToHome()
|
|
438
|
+
return { empty: true }
|
|
439
|
+
}
|
|
440
|
+
const tabInfo = resolvedTabInfo
|
|
299
441
|
if (isTabMarkedForEviction(tabInfo.id!)) return { empty: true }
|
|
300
442
|
|
|
301
443
|
const path = normalizePathForCache(route)
|
|
444
|
+
const routeQuery = cloneRouteQuery(route, tabInfo)
|
|
445
|
+
/** path 与业务 query 都一致才算同一个页面(忽略 __tab 等保留参数) */
|
|
446
|
+
const isSameLocation = (candidate: ITabPage): boolean =>
|
|
447
|
+
candidate.path === path && isSameQueryIgnoringReserved(routeQuery, candidate.query || {})
|
|
302
448
|
|
|
303
449
|
let targetTab: ITabItem | null = tabInfo.id ? (getTab(tabInfo.id) as ITabItem | null) : null
|
|
304
450
|
let cacheName: string
|
|
305
451
|
let page: ITabPage | null = null
|
|
306
452
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
if (
|
|
310
|
-
|
|
311
|
-
if (top.path === path) {
|
|
453
|
+
const top = targetTab ? getTopPage(targetTab.id) : null
|
|
454
|
+
if (targetTab && top) {
|
|
455
|
+
if (isSameLocation(top)) {
|
|
312
456
|
cacheName = top.id
|
|
457
|
+
} else if (top.path === path) {
|
|
458
|
+
// 同 path、不同 query:按产品语义作为新页面入栈,backward 才能按 query 精确回退。
|
|
459
|
+
cacheName = createPageId()
|
|
460
|
+
page = {
|
|
461
|
+
id: cacheName,
|
|
462
|
+
tabId: tabInfo.id!,
|
|
463
|
+
path,
|
|
464
|
+
query: cloneLocationQuery(routeQuery)
|
|
465
|
+
}
|
|
313
466
|
} else {
|
|
314
467
|
const pagesList = getPages(targetTab.id)
|
|
315
468
|
let foundIndex = -1
|
|
316
469
|
for (let i = pagesList.length - 2; i >= 0; i--) {
|
|
317
470
|
const pastPage = pagesList[i]
|
|
318
|
-
if (pastPage && pastPage
|
|
471
|
+
if (pastPage && isSameLocation(pastPage)) {
|
|
319
472
|
foundIndex = i
|
|
320
473
|
break
|
|
321
474
|
}
|
|
@@ -339,8 +492,8 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
339
492
|
page = {
|
|
340
493
|
id: cacheName,
|
|
341
494
|
tabId: tabInfo.id!,
|
|
342
|
-
path
|
|
343
|
-
query:
|
|
495
|
+
path,
|
|
496
|
+
query: cloneLocationQuery(routeQuery)
|
|
344
497
|
}
|
|
345
498
|
}
|
|
346
499
|
} else {
|
|
@@ -348,8 +501,8 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
348
501
|
page = {
|
|
349
502
|
id: cacheName,
|
|
350
503
|
tabId: tabInfo.id!,
|
|
351
|
-
path
|
|
352
|
-
query:
|
|
504
|
+
path,
|
|
505
|
+
query: cloneLocationQuery(routeQuery)
|
|
353
506
|
}
|
|
354
507
|
}
|
|
355
508
|
}
|
|
@@ -358,8 +511,8 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
358
511
|
page = {
|
|
359
512
|
id: cacheName,
|
|
360
513
|
tabId: tabInfo.id!,
|
|
361
|
-
path
|
|
362
|
-
query:
|
|
514
|
+
path,
|
|
515
|
+
query: cloneLocationQuery(routeQuery)
|
|
363
516
|
}
|
|
364
517
|
}
|
|
365
518
|
|
|
@@ -378,11 +531,17 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
378
531
|
// 首屏 URL 往往没有 __tab(用户直接打开默认路由):把解析出的标签编码写回地址,
|
|
379
532
|
// 保证 URL 始终指向当前标签;之后的导航各自会带上自己的 __tab。
|
|
380
533
|
if (typeof route.query.__tab !== 'string') {
|
|
381
|
-
void router
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
+
})
|
|
386
545
|
}
|
|
387
546
|
|
|
388
547
|
saveActiveTabToSession(targetTab!)
|
|
@@ -404,9 +563,15 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
404
563
|
route: RouteLocationNormalizedLoaded,
|
|
405
564
|
component?: VNode | null
|
|
406
565
|
): DefineComponent => {
|
|
407
|
-
if (!component)
|
|
566
|
+
if (!component) {
|
|
567
|
+
// 路由没有匹配到组件时不会进入 updatePageState;仍需处理无 __tab 的未知地址回落到首页。
|
|
568
|
+
// 带 __tab 的未知页面不做重定向,交给应用自己的 404 路由(或空占位)处理。
|
|
569
|
+
if (shouldRedirectToHome(route)) redirectToHome()
|
|
570
|
+
return getEmptyPlaceholderComponent()
|
|
571
|
+
}
|
|
408
572
|
|
|
409
|
-
|
|
573
|
+
// fullPath 已包含完整 query(含 __tab)与 hash;refreshKey 让 renew 能强制重新解析同一地址。
|
|
574
|
+
const routeKey = `${route.fullPath}::${refreshKey.value}`
|
|
410
575
|
if (routeKey === lastRouteKey && lastAddPageResult) {
|
|
411
576
|
return lastAddPageResult
|
|
412
577
|
}
|
|
@@ -517,6 +682,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
517
682
|
getTabs,
|
|
518
683
|
getTopPage,
|
|
519
684
|
updatePage,
|
|
685
|
+
updatePages,
|
|
520
686
|
iframeRefreshKeys,
|
|
521
687
|
createPageId,
|
|
522
688
|
addCache,
|
|
@@ -534,7 +700,11 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
534
700
|
emitter.emit(TabEventType.FORWARD)
|
|
535
701
|
const target = getTab(id)
|
|
536
702
|
if (!target) return Promise.resolve()
|
|
537
|
-
if (target.active)
|
|
703
|
+
if (target.active) {
|
|
704
|
+
// 已是激活标签时仍需通知标签栏确保其可见(用户可能已把标签栏滚开)。
|
|
705
|
+
emitter.emit(TabEventType.TAB_SCROLL_TO, { id })
|
|
706
|
+
return Promise.resolve()
|
|
707
|
+
}
|
|
538
708
|
|
|
539
709
|
const top = getTopPage(id)
|
|
540
710
|
const activateTarget = () => {
|
|
@@ -551,15 +721,11 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
551
721
|
return Promise.resolve()
|
|
552
722
|
}
|
|
553
723
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
iframe: target.iframe
|
|
560
|
-
})
|
|
561
|
-
// __tab 是兜底标识,目标栈顶已带 __tab 时优先沿用,否则用我们刚编码的版本。
|
|
562
|
-
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
|
+
}
|
|
563
729
|
|
|
564
730
|
return runNavigationTransaction({
|
|
565
731
|
apply: () => {
|
|
@@ -584,7 +750,6 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
584
750
|
|
|
585
751
|
const destroy = () => {
|
|
586
752
|
resetStore()
|
|
587
|
-
defaultTabs.value = []
|
|
588
753
|
iframeRefreshKeys.value = {}
|
|
589
754
|
runtimeContext.scrollPositionsByPageId.clear()
|
|
590
755
|
runtimeContext.isInitialized.value = false
|
|
@@ -606,6 +771,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
606
771
|
initialize,
|
|
607
772
|
size,
|
|
608
773
|
active,
|
|
774
|
+
updateTabTitle,
|
|
609
775
|
getCacheName: createCacheName,
|
|
610
776
|
destroy,
|
|
611
777
|
reset,
|
|
@@ -615,6 +781,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
615
781
|
getActiveTab,
|
|
616
782
|
getTopPage,
|
|
617
783
|
getPages,
|
|
784
|
+
getPageRefreshVersion,
|
|
618
785
|
getPageSnapshot,
|
|
619
786
|
moveTab,
|
|
620
787
|
addTab,
|
|
@@ -623,6 +790,7 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
623
790
|
replacePages,
|
|
624
791
|
clearPages,
|
|
625
792
|
updatePage,
|
|
793
|
+
updatePages,
|
|
626
794
|
removeTab,
|
|
627
795
|
removeAllTabs,
|
|
628
796
|
removeLeftTabs,
|
|
@@ -651,3 +819,24 @@ export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
|
651
819
|
}
|
|
652
820
|
}
|
|
653
821
|
}
|
|
822
|
+
|
|
823
|
+
type TabPanelApi = ReturnType<typeof createTabPanelApi>
|
|
824
|
+
|
|
825
|
+
const panelApiByContext = new WeakMap<StackTabsRuntimeContext, TabPanelApi>()
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* 同一个运行时上下文复用同一套 panel API。
|
|
829
|
+
*
|
|
830
|
+
* StackTabs、StackKeepAlive、StackCacheRenderer、ContextMenu 与页面内的 useTabRouter
|
|
831
|
+
* 都会调用本 hook;首次调用在 setup 上下文中完成 useRouter/useI18n 注入,
|
|
832
|
+
* 后续调用直接返回缓存,避免重复创建 scroll/session/refresh 闭包与局部状态。
|
|
833
|
+
*/
|
|
834
|
+
export default (providedRuntimeContext?: StackTabsRuntimeContext): TabPanelApi => {
|
|
835
|
+
const runtimeContext = providedRuntimeContext ?? resolveStackTabsRuntimeContext()
|
|
836
|
+
const existing = panelApiByContext.get(runtimeContext)
|
|
837
|
+
if (existing) return existing
|
|
838
|
+
|
|
839
|
+
const panelApi = createTabPanelApi(runtimeContext)
|
|
840
|
+
panelApiByContext.set(runtimeContext, panelApi)
|
|
841
|
+
return panelApi
|
|
842
|
+
}
|