vue-stack-tabs 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +119 -52
- package/README.md +104 -41
- package/README.ru.md +125 -54
- package/dist/{src/lib/StackTabs.vue.d.ts → StackTabs.vue.d.ts} +5 -5
- package/dist/{src/lib/components → components}/ContextMenu/index.vue.d.ts +1 -1
- package/dist/components/StackKeepAlive/emptyPlaceholder.d.ts +3 -0
- package/dist/{src/lib/components → components}/TabHeader/TabHeaderItem.vue.d.ts +9 -0
- package/dist/{src/lib/components → components}/TabHeader/index.vue.d.ts +3 -3
- package/dist/{src/lib/hooks → hooks}/stackTabsContext.d.ts +5 -2
- package/dist/hooks/tabPanel/pageComponentFactory.d.ts +20 -0
- package/dist/{src/lib/hooks → hooks}/tabPanel/scroll.d.ts +2 -0
- package/dist/hooks/useScrollShadow.d.ts +20 -0
- package/dist/{src/lib/hooks → hooks}/useTabActions.d.ts +7 -2
- package/dist/{src/lib/hooks → hooks}/useTabEventBus.d.ts +2 -1
- package/dist/{src/lib/hooks → hooks}/useTabPanel.d.ts +3 -1
- package/dist/iframe-bridge.mjs +1 -1
- package/dist/index.d.ts +22 -6
- package/dist/{src/lib/model → model}/TabModel.d.ts +21 -0
- package/dist/nuxt/module.mjs +10 -6
- package/dist/nuxt/runtime/plugin.mjs +1600 -4
- package/dist/style.css +1 -1
- package/dist/utils/iframeBridge.d.ts +7 -1
- package/dist/{src/lib/utils → utils}/scrollUtils.d.ts +7 -1
- package/dist/{src/lib/versionLogger.d.ts → versionLogger.d.ts} +1 -1
- package/dist/vue-stack-tabs.css +1 -1
- package/dist/vue-stack-tabs.es.js +1 -1
- package/package.json +83 -59
- package/src/lib/StackTabs.vue +16 -3
- package/src/lib/assets/style/common/var.scss +2 -0
- package/src/lib/assets/style/stackTab.scss +43 -4
- package/src/lib/components/ContextMenu/index.vue +12 -3
- package/src/lib/components/PageLoading.vue +10 -2
- package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +3 -9
- package/src/lib/components/StackKeepAlive/emptyPlaceholder.tsx +18 -0
- package/src/lib/components/TabHeader/TabHeaderItem.vue +30 -2
- package/src/lib/components/TabHeader/index.vue +76 -4
- package/src/lib/hooks/stackTabsContext.ts +23 -18
- package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +142 -0
- package/src/lib/hooks/tabPanel/refresh.ts +3 -0
- package/src/lib/hooks/tabPanel/scroll.ts +110 -7
- package/src/lib/hooks/tabPanel/session.ts +29 -3
- package/src/lib/hooks/useContextMenu.ts +57 -11
- package/src/lib/hooks/useIframeManager.ts +18 -2
- package/src/lib/hooks/useScrollShadow.ts +124 -0
- package/src/lib/hooks/useTabActions.ts +20 -8
- package/src/lib/hooks/useTabEventBus.ts +5 -3
- package/src/lib/hooks/useTabPanel.tsx +132 -180
- package/src/lib/hooks/useTabRouter.ts +11 -2
- package/src/lib/i18n/index.ts +3 -3
- package/src/lib/model/TabModel.ts +40 -1
- package/src/lib/nuxt/module.ts +10 -3
- package/src/lib/nuxt/runtime/plugin.ts +13 -2
- package/src/lib/utils/iframeBridge.ts +9 -1
- package/src/lib/utils/scrollUtils.ts +35 -2
- package/src/lib/utils/tabInfoEncoder.ts +21 -8
- package/src/lib/utils/urlParser.ts +3 -1
- package/src/lib/versionLogger.ts +6 -2
- package/dist/favicon.svg +0 -10
- package/dist/iframe-bridge.html +0 -77
- package/dist/iframe-child.html +0 -88
- package/dist/nuxt/favicon.svg +0 -10
- package/dist/nuxt/iframe-bridge.html +0 -77
- package/dist/nuxt/iframe-child.html +0 -88
- package/dist/nuxt/plugin.mjs +0 -9
- package/dist/src/lib/iframe-bridge.d.ts +0 -8
- package/dist/src/lib/index.d.ts +0 -22
- package/dist/src/lib/nuxt/module.d.ts +0 -6
- package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +0 -2
- package/dist/src/lib/nuxt/runtime/plugin.d.ts +0 -2
- package/dist/src/lib/utils/iframeBridge.d.ts +0 -39
- package/src/lib/nuxt/runtime/plugin.client.ts +0 -10
- /package/dist/{src/lib/components → components}/ContextMenu/ContextMenuItem.vue.d.ts +0 -0
- /package/dist/{src/lib/components → components}/PageLoading.vue.d.ts +0 -0
- /package/dist/{src/lib/components → components}/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -0
- /package/dist/{src/lib/components → components}/StackKeepAlive/StackKeepAlive.vue.d.ts +0 -0
- /package/dist/{src/lib/components → components}/TabHeader/TabHeaderButton.vue.d.ts +0 -0
- /package/dist/{src/lib/components → components}/TabHeader/TabHeaderScroll.vue.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/tabPanel/evict.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/tabPanel/navigationTransaction.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/tabPanel/refresh.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/tabPanel/session.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/tabPanel/state.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/useContextMenu.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/useIframeManager.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/useTabLoading.d.ts +0 -0
- /package/dist/{src/lib/hooks → hooks}/useTabRouter.d.ts +0 -0
- /package/dist/{src/lib/i18n → i18n}/index.d.ts +0 -0
- /package/dist/{src/lib/i18n → i18n}/lang/en.d.ts +0 -0
- /package/dist/{src/lib/i18n → i18n}/lang/zh-CN.d.ts +0 -0
- /package/dist/{src/lib/i18n → i18n}/stackTabsLocale.d.ts +0 -0
- /package/dist/{src/lib/iframe.vue.d.ts → iframe.vue.d.ts} +0 -0
- /package/dist/{src/lib/utils → utils}/stackTabsA11y.d.ts +0 -0
- /package/dist/{src/lib/utils → utils}/stackTabsMessage.d.ts +0 -0
- /package/dist/{src/lib/utils → utils}/tabInfoEncoder.d.ts +0 -0
- /package/dist/{src/lib/utils → utils}/typeGuards.d.ts +0 -0
- /package/dist/{src/lib/utils → utils}/urlParser.d.ts +0 -0
|
@@ -12,17 +12,7 @@
|
|
|
12
12
|
* 使用范围:内部 hook,由 StackTabs、useTabActions、useTabRouter、ContextMenu 调用
|
|
13
13
|
*/
|
|
14
14
|
import type { ITabBase, ITabData, ITabItem, ITabPage } from '../model/TabModel'
|
|
15
|
-
import {
|
|
16
|
-
defineComponent,
|
|
17
|
-
onActivated,
|
|
18
|
-
onDeactivated,
|
|
19
|
-
onMounted,
|
|
20
|
-
onUnmounted,
|
|
21
|
-
computed,
|
|
22
|
-
ref,
|
|
23
|
-
shallowRef,
|
|
24
|
-
cloneVNode
|
|
25
|
-
} from 'vue'
|
|
15
|
+
import { computed, ref } from 'vue'
|
|
26
16
|
import type { DefineComponent, VNode } from 'vue'
|
|
27
17
|
import { isNavigationFailure, useRouter } from 'vue-router'
|
|
28
18
|
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
|
@@ -38,25 +28,17 @@ import {
|
|
|
38
28
|
cloneLocationQuery,
|
|
39
29
|
clonePage
|
|
40
30
|
} from '../utils/urlParser'
|
|
41
|
-
import PageLoading from '../components/PageLoading.vue'
|
|
42
31
|
import { TabEventType, useTabEmitter } from './useTabEventBus'
|
|
43
32
|
import { runNavigationTransaction } from './tabPanel/navigationTransaction'
|
|
44
33
|
import { useI18n } from 'vue-i18n-lite'
|
|
45
34
|
|
|
46
|
-
import { resolveStackTabsRuntimeContext } from './stackTabsContext'
|
|
35
|
+
import { resolveStackTabsRuntimeContext, type StackTabsRuntimeContext } from './stackTabsContext'
|
|
47
36
|
import { createTabPanelEviction } from './tabPanel/evict'
|
|
48
37
|
import { createTabPanelScroll } from './tabPanel/scroll'
|
|
49
38
|
import { createTabPanelSession } from './tabPanel/session'
|
|
50
39
|
import { createTabPanelRefresh } from './tabPanel/refresh'
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
/** 占位组件:当路由组件为空或标签已被标记删除时返回 */
|
|
54
|
-
const EmptyPlaceholderComponent = defineComponent({
|
|
55
|
-
name: 'StackTabEmptyPlaceholder',
|
|
56
|
-
setup() {
|
|
57
|
-
return () => null
|
|
58
|
-
}
|
|
59
|
-
})
|
|
40
|
+
import { createPageComponentFactory } from './tabPanel/pageComponentFactory'
|
|
41
|
+
import { getEmptyPlaceholderComponent } from '../components/StackKeepAlive/emptyPlaceholder'
|
|
60
42
|
|
|
61
43
|
const cloneRouteQuery = (route: RouteLocationNormalizedLoaded, tabInfo: ITabBase) => ({
|
|
62
44
|
...cloneLocationQuery(route.query),
|
|
@@ -71,11 +53,11 @@ export const normalizePathForCache = (route: RouteLocationNormalizedLoaded): str
|
|
|
71
53
|
return matchPath === path ? path : route.path
|
|
72
54
|
}
|
|
73
55
|
|
|
74
|
-
export default () => {
|
|
56
|
+
export default (providedRuntimeContext?: StackTabsRuntimeContext) => {
|
|
75
57
|
const router = useRouter()
|
|
76
|
-
const
|
|
58
|
+
const runtimeContext = providedRuntimeContext ?? resolveStackTabsRuntimeContext()
|
|
59
|
+
const emitter = useTabEmitter(runtimeContext)
|
|
77
60
|
const { t } = useI18n()
|
|
78
|
-
const runtimeContext = resolveStackTabsRuntimeContext()
|
|
79
61
|
const {
|
|
80
62
|
tabs,
|
|
81
63
|
defaultTabs,
|
|
@@ -118,14 +100,15 @@ export default () => {
|
|
|
118
100
|
const newTabs: ITabItem[] = []
|
|
119
101
|
|
|
120
102
|
for (const item of staticTabs) {
|
|
103
|
+
// 先用默认值填充非空缺字段,避免在循环里提前求值生成用不到的 UUID。
|
|
121
104
|
const fullItem = defu(item, {
|
|
122
|
-
id: crypto.randomUUID(),
|
|
123
105
|
refreshable: true,
|
|
124
106
|
closable: true,
|
|
125
107
|
iframe: false
|
|
126
108
|
})
|
|
127
|
-
|
|
128
|
-
fullItem.id =
|
|
109
|
+
// id 单独兜底,避免每次循环都无意义地调用 crypto.randomUUID。
|
|
110
|
+
fullItem.id = item.id ?? crypto.randomUUID()
|
|
111
|
+
const tabId = fullItem.id
|
|
129
112
|
const uri = parseUrl(fullItem.path)
|
|
130
113
|
const cacheName = createPageId()
|
|
131
114
|
const page: ITabPage = {
|
|
@@ -158,7 +141,9 @@ export default () => {
|
|
|
158
141
|
defaultTabs.value = [...defaultTabs.value, ...newDefaults]
|
|
159
142
|
tabs.value = [...tabs.value, ...newTabs]
|
|
160
143
|
|
|
161
|
-
|
|
144
|
+
// SSR 环境无 sessionStorage,直接跳过恢复。
|
|
145
|
+
const storedTabJson =
|
|
146
|
+
typeof window !== 'undefined' ? sessionStorage.getItem(getSessionKey()) : null
|
|
162
147
|
const restoredTab = restoreTabFromSession(storedTabJson)
|
|
163
148
|
if (restoredTab && !tabs.value.some((t) => t.id === restoredTab.id)) {
|
|
164
149
|
if (restoredTab.url) restoredTab.url = toSafeTabUrl(restoredTab.url)
|
|
@@ -189,7 +174,70 @@ export default () => {
|
|
|
189
174
|
|
|
190
175
|
const getTab = (id: string) => tabs.value.find((t) => t.id === id) ?? null
|
|
191
176
|
|
|
192
|
-
/**
|
|
177
|
+
/**
|
|
178
|
+
* 唯一的标签激活状态写入口。即使标签对象保持不变,也替换列表引用,
|
|
179
|
+
* 让 TabHeader 与 iframe 视图在同一个响应式提交中观察到一致状态。
|
|
180
|
+
*/
|
|
181
|
+
const setActiveTab = (id: string): ITabItem | null => {
|
|
182
|
+
let activeTab: ITabItem | null = null
|
|
183
|
+
tabs.value = tabs.value.map((tab) => {
|
|
184
|
+
tab.active = tab.id === id
|
|
185
|
+
if (tab.active) activeTab = tab
|
|
186
|
+
return tab
|
|
187
|
+
})
|
|
188
|
+
return activeTab
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const { resolvePageComponent } = createPageComponentFactory({
|
|
192
|
+
runtimeContext,
|
|
193
|
+
getTab,
|
|
194
|
+
addCache,
|
|
195
|
+
evictMarkedCaches,
|
|
196
|
+
addPageScroller,
|
|
197
|
+
saveScroller,
|
|
198
|
+
restoreScroller,
|
|
199
|
+
removeScroller
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 将指定标签移动到目标索引。标签对象本身不变,因此其页面栈和缓存关联保持不受影响。
|
|
204
|
+
* @returns 是否实际调整了顺序
|
|
205
|
+
*/
|
|
206
|
+
const moveTab = (id: string, targetIndex: number): boolean => {
|
|
207
|
+
const sourceIndex = tabs.value.findIndex((tab) => tab.id === id)
|
|
208
|
+
if (
|
|
209
|
+
sourceIndex < 0 ||
|
|
210
|
+
targetIndex < 0 ||
|
|
211
|
+
targetIndex >= tabs.value.length ||
|
|
212
|
+
sourceIndex === targetIndex
|
|
213
|
+
) {
|
|
214
|
+
return false
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const reorderedTabs = tabs.value.slice()
|
|
218
|
+
const [tab] = reorderedTabs.splice(sourceIndex, 1)
|
|
219
|
+
if (!tab) return false
|
|
220
|
+
reorderedTabs.splice(targetIndex, 0, tab)
|
|
221
|
+
tabs.value = reorderedTabs
|
|
222
|
+
return true
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** 把已有标签的对外字段提取为 ITabBase(用于让路由归属回该标签) */
|
|
226
|
+
const toTabBase = (tab: ITabItem): ITabBase => ({
|
|
227
|
+
id: tab.id,
|
|
228
|
+
title: tab.title,
|
|
229
|
+
closable: tab.closable,
|
|
230
|
+
refreshable: tab.refreshable,
|
|
231
|
+
iframe: tab.iframe,
|
|
232
|
+
iframeRefreshMode: tab.iframeRefreshMode
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 从路由解析 tabInfo,若无则创建默认信息,调用方负责把编码信息写入内部 page.query。
|
|
237
|
+
*
|
|
238
|
+
* 首次进入应用时 URL 上通常还没有 __tab:此时优先接管 path 匹配的默认标签(defaultTabs),
|
|
239
|
+
* 由调用方把 __tab 写回地址,保证「URL 指向默认标签」;确实找不到归属时才新建兜底标签。
|
|
240
|
+
*/
|
|
193
241
|
const parseTabInfoFromRoute = (route: RouteLocationNormalizedLoaded): ITabBase => {
|
|
194
242
|
if (typeof route.query.__tab === 'string') {
|
|
195
243
|
const tabInfo = decodeTabInfo(route.query.__tab)
|
|
@@ -200,16 +248,15 @@ export default () => {
|
|
|
200
248
|
const activeTab = tabs.value.find((tab) => tab.active)
|
|
201
249
|
const activePage = activeTab?.pages.peek()
|
|
202
250
|
if (activeTab && activePage?.path === path) {
|
|
203
|
-
return
|
|
204
|
-
id: activeTab.id,
|
|
205
|
-
title: activeTab.title,
|
|
206
|
-
closable: activeTab.closable,
|
|
207
|
-
refreshable: activeTab.refreshable,
|
|
208
|
-
iframe: activeTab.iframe,
|
|
209
|
-
iframeRefreshMode: activeTab.iframeRefreshMode
|
|
210
|
-
}
|
|
251
|
+
return toTabBase(activeTab)
|
|
211
252
|
}
|
|
212
253
|
|
|
254
|
+
// 当前激活标签与路由不一致:首屏 URL 无 __tab,或 session 恢复了上次激活的其它标签
|
|
255
|
+
//(演示应用在 iframe 中重载后 hash 为空即属此类)。此时接管栈顶 path 与当前路由一致的
|
|
256
|
+
// 标签(默认标签或已有标签),避免同一个页面既留下已有标签、又生成一个「未命名」的兜底标签。
|
|
257
|
+
const pathMatchedTab = tabs.value.find((tab) => tab.pages.peek()?.path === path)
|
|
258
|
+
if (pathMatchedTab) return toTabBase(pathMatchedTab)
|
|
259
|
+
|
|
213
260
|
return {
|
|
214
261
|
id: crypto.randomUUID(),
|
|
215
262
|
title: t('VueStackTab.undefined'),
|
|
@@ -228,11 +275,7 @@ export default () => {
|
|
|
228
275
|
cacheName: string,
|
|
229
276
|
page: ITabPage
|
|
230
277
|
): ITabItem => {
|
|
231
|
-
let targetTab
|
|
232
|
-
for (const tab of tabs.value) {
|
|
233
|
-
tab.active = false
|
|
234
|
-
if (tab.id === tabInfo.id) targetTab = tab as ITabItem
|
|
235
|
-
}
|
|
278
|
+
let targetTab = tabInfo.id ? getTab(tabInfo.id) : null
|
|
236
279
|
|
|
237
280
|
if (targetTab === null) {
|
|
238
281
|
const pages = new Stack<ITabPage>()
|
|
@@ -249,10 +292,11 @@ export default () => {
|
|
|
249
292
|
active: true,
|
|
250
293
|
pages
|
|
251
294
|
} as unknown as ITabItem
|
|
295
|
+
setActiveTab('')
|
|
252
296
|
addTab(targetTab)
|
|
253
297
|
emitter.emit(TabEventType.TAB_ACTIVE, { id: tabInfo.id!, isRoute: false })
|
|
254
298
|
} else {
|
|
255
|
-
targetTab.
|
|
299
|
+
setActiveTab(targetTab.id)
|
|
256
300
|
if (targetTab.pages.isEmpty() || targetTab.pages.peek()!.id !== page.id) {
|
|
257
301
|
targetTab.pages.push(page)
|
|
258
302
|
}
|
|
@@ -308,7 +352,9 @@ export default () => {
|
|
|
308
352
|
const stepsToPop = pagesList.length - 1 - foundIndex
|
|
309
353
|
for (let i = 0; i < stepsToPop; i++) {
|
|
310
354
|
const popped = targetTab.pages.pop()
|
|
311
|
-
|
|
355
|
+
// 仅标记待驱逐,由 onActivated 或下一拍 evictMarkedCaches 统一清理,
|
|
356
|
+
// 避免 watcher 同步执行与守卫拒绝路由之间产生不一致。
|
|
357
|
+
if (popped) markCacheForEviction(popped.id)
|
|
312
358
|
}
|
|
313
359
|
const newTop = targetTab.pages.peek()
|
|
314
360
|
cacheName = newTop ? newTop.id : createPageId()
|
|
@@ -350,111 +396,21 @@ export default () => {
|
|
|
350
396
|
if (page) {
|
|
351
397
|
targetTab = findOrCreateTargetTab(tabInfo, route, cacheName, page)
|
|
352
398
|
} else {
|
|
353
|
-
|
|
354
|
-
targetTab!.active = true
|
|
399
|
+
setActiveTab(targetTab!.id)
|
|
355
400
|
}
|
|
356
401
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
cacheName: string
|
|
366
|
-
tabInfo: ITabBase
|
|
367
|
-
targetTab: ITabItem
|
|
368
|
-
}): DefineComponent => {
|
|
369
|
-
const { cacheName, tabInfo } = ctx
|
|
370
|
-
|
|
371
|
-
if (components.has(cacheName)) {
|
|
372
|
-
addCache(cacheName)
|
|
373
|
-
return components.get(cacheName)!
|
|
402
|
+
// 首屏 URL 往往没有 __tab(用户直接打开默认路由):把解析出的标签编码写回地址,
|
|
403
|
+
// 保证 URL 始终指向当前标签;之后的导航各自会带上自己的 __tab。
|
|
404
|
+
if (typeof route.query.__tab !== 'string') {
|
|
405
|
+
void router.replace({
|
|
406
|
+
path: route.path,
|
|
407
|
+
query: { ...cloneLocationQuery(route.query), __tab: encodeTabInfo(tabInfo) },
|
|
408
|
+
hash: route.hash
|
|
409
|
+
})
|
|
374
410
|
}
|
|
375
411
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
props: {
|
|
379
|
-
vnode: {
|
|
380
|
-
type: Object as () => VNode,
|
|
381
|
-
required: false,
|
|
382
|
-
default: undefined
|
|
383
|
-
}
|
|
384
|
-
},
|
|
385
|
-
emits: ['onLoaded'],
|
|
386
|
-
setup(props, context) {
|
|
387
|
-
const localBackParams = ref<Record<string, unknown> | null>(null)
|
|
388
|
-
const lastVnode = shallowRef<VNode | null>(null)
|
|
389
|
-
const lastCloned = shallowRef<VNode | null>(null)
|
|
390
|
-
const lastBackParams = shallowRef<Record<string, unknown> | null>(null)
|
|
391
|
-
|
|
392
|
-
const checkAndConsumeBackParams = () => {
|
|
393
|
-
try {
|
|
394
|
-
const tab = getTab(tabInfo.id!)
|
|
395
|
-
const top = tab?.pages.peek()
|
|
396
|
-
if (top && top.id === cacheName && top._backParams) {
|
|
397
|
-
localBackParams.value = { ...top._backParams }
|
|
398
|
-
delete top._backParams
|
|
399
|
-
} else {
|
|
400
|
-
localBackParams.value = null
|
|
401
|
-
}
|
|
402
|
-
} catch {
|
|
403
|
-
localBackParams.value = null
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
onMounted(() => {
|
|
408
|
-
context.emit('onLoaded')
|
|
409
|
-
addPageScroller(cacheName, `#W-${cacheName}`)
|
|
410
|
-
checkAndConsumeBackParams()
|
|
411
|
-
})
|
|
412
|
-
|
|
413
|
-
onDeactivated(() => saveScroller(cacheName))
|
|
414
|
-
|
|
415
|
-
onActivated(() => {
|
|
416
|
-
context.emit('onLoaded')
|
|
417
|
-
restoreScroller(cacheName)
|
|
418
|
-
evictMarkedCaches()
|
|
419
|
-
tabIdsToEvict.clear()
|
|
420
|
-
checkAndConsumeBackParams()
|
|
421
|
-
})
|
|
422
|
-
|
|
423
|
-
onUnmounted(() => removeScroller(cacheName))
|
|
424
|
-
|
|
425
|
-
return () => {
|
|
426
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
427
|
-
const Node = (props as any).vnode as VNode
|
|
428
|
-
const backParams = localBackParams.value
|
|
429
|
-
|
|
430
|
-
if (Node !== lastVnode.value || backParams !== lastBackParams.value) {
|
|
431
|
-
const dynamicBackProps = backParams ? { _back: backParams } : {}
|
|
432
|
-
lastCloned.value = Node
|
|
433
|
-
? cloneVNode(Node, { tId: tabInfo.id, pId: cacheName, ...dynamicBackProps })
|
|
434
|
-
: null
|
|
435
|
-
lastVnode.value = Node
|
|
436
|
-
lastBackParams.value = backParams
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
return (
|
|
440
|
-
<div
|
|
441
|
-
class="cache-page-wrapper"
|
|
442
|
-
id={`W-${cacheName}`}
|
|
443
|
-
style={[
|
|
444
|
-
runtimeContext.useGlobalScroll.value ? 'overflow:auto' : 'overflow:hidden',
|
|
445
|
-
'height: 100%'
|
|
446
|
-
]}
|
|
447
|
-
>
|
|
448
|
-
{lastCloned.value}
|
|
449
|
-
<PageLoading tabId={tabInfo.id!} />
|
|
450
|
-
</div>
|
|
451
|
-
)
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
}) as DefineComponent
|
|
455
|
-
components.set(cacheName, cacheComponent)
|
|
456
|
-
addCache(cacheName)
|
|
457
|
-
return cacheComponent
|
|
412
|
+
saveActiveTabToSession(targetTab!)
|
|
413
|
+
return { cacheName, tabInfo, targetTab: targetTab! }
|
|
458
414
|
}
|
|
459
415
|
|
|
460
416
|
/** 当前正在渲染的页面的缓存 Key(ULID),供 StackTabs.vue 的 :key 绑定使用 */
|
|
@@ -472,7 +428,7 @@ export default () => {
|
|
|
472
428
|
route: RouteLocationNormalizedLoaded,
|
|
473
429
|
component?: VNode | null
|
|
474
430
|
): DefineComponent => {
|
|
475
|
-
if (!component) return
|
|
431
|
+
if (!component) return getEmptyPlaceholderComponent()
|
|
476
432
|
|
|
477
433
|
const routeKey = route.fullPath + (route.query.__tab || '')
|
|
478
434
|
if (routeKey === lastRouteKey && lastAddPageResult) {
|
|
@@ -480,7 +436,7 @@ export default () => {
|
|
|
480
436
|
}
|
|
481
437
|
|
|
482
438
|
const state = updatePageState(route)
|
|
483
|
-
if ('empty' in state) return
|
|
439
|
+
if ('empty' in state) return getEmptyPlaceholderComponent()
|
|
484
440
|
if ('recovered' in state) {
|
|
485
441
|
addCache(state.cacheName)
|
|
486
442
|
activeCacheKey.value = state.cacheName
|
|
@@ -531,7 +487,7 @@ export default () => {
|
|
|
531
487
|
if (tabList.length > 1 && tab.active) {
|
|
532
488
|
activeTabId = (i === 0 ? tabList[1] : tabList[i - 1])?.id ?? ''
|
|
533
489
|
}
|
|
534
|
-
tabList.
|
|
490
|
+
tabs.value = [...tabList.slice(0, i), ...tabList.slice(i + 1)]
|
|
535
491
|
|
|
536
492
|
if (activeTabId) {
|
|
537
493
|
emitter.emit(TabEventType.TAB_ACTIVE, { id: activeTabId })
|
|
@@ -546,32 +502,32 @@ export default () => {
|
|
|
546
502
|
|
|
547
503
|
/** 批量关闭满足条件的可关闭标签,并统一标记其页面缓存待驱逐。 */
|
|
548
504
|
const removeTabs = (shouldRemove: (tab: ITabItem, index: number) => boolean) => {
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
if (!tab || !tab.closable || !shouldRemove(tab, index)) continue
|
|
505
|
+
const removed: ITabItem[] = []
|
|
506
|
+
const remaining = tabs.value.filter((tab, index) => {
|
|
507
|
+
if (!tab.closable || !shouldRemove(tab, index)) return true
|
|
553
508
|
markTabPagesForEviction(tab)
|
|
554
509
|
tab.pages.clear()
|
|
555
|
-
|
|
556
|
-
|
|
510
|
+
removed.push(tab)
|
|
511
|
+
return false
|
|
512
|
+
})
|
|
513
|
+
tabs.value = remaining
|
|
514
|
+
return removed
|
|
557
515
|
}
|
|
558
516
|
|
|
559
517
|
const removeAllTabs = () => {
|
|
560
|
-
const tabList = tabs.value
|
|
561
518
|
removeTabs(() => true)
|
|
562
|
-
const hasActive =
|
|
519
|
+
const hasActive = tabs.value.some((t) => t.active)
|
|
563
520
|
if (hasActive) {
|
|
564
521
|
evictMarkedCaches()
|
|
565
522
|
tabIdsToEvict.clear()
|
|
566
523
|
return
|
|
567
524
|
}
|
|
568
|
-
const last =
|
|
525
|
+
const last = tabs.value.at(-1)
|
|
569
526
|
if (last) emitter.emit(TabEventType.TAB_ACTIVE, { id: last.id })
|
|
570
527
|
}
|
|
571
528
|
|
|
572
529
|
const removeOtherTabs = (id: string) => {
|
|
573
|
-
const
|
|
574
|
-
const activeTab = tabList.find((tab) => tab.id === id)
|
|
530
|
+
const activeTab = getTab(id)
|
|
575
531
|
removeTabs((tab) => tab.id !== id)
|
|
576
532
|
if (activeTab && !activeTab.active) {
|
|
577
533
|
emitter.emit(TabEventType.TAB_ACTIVE, { id })
|
|
@@ -582,32 +538,30 @@ export default () => {
|
|
|
582
538
|
}
|
|
583
539
|
|
|
584
540
|
const removeLeftTabs = (id: string) => {
|
|
585
|
-
const
|
|
586
|
-
const pivot = tabList.findIndex((t) => t?.id === id)
|
|
541
|
+
const pivot = tabs.value.findIndex((t) => t?.id === id)
|
|
587
542
|
if (pivot <= 0) return
|
|
588
543
|
|
|
589
544
|
removeTabs((_, index) => index < pivot)
|
|
590
|
-
if (
|
|
545
|
+
if (tabs.value.some((t) => t.active)) {
|
|
591
546
|
evictMarkedCaches()
|
|
592
547
|
tabIdsToEvict.clear()
|
|
593
548
|
return
|
|
594
549
|
}
|
|
595
|
-
const first =
|
|
550
|
+
const first = tabs.value[0]
|
|
596
551
|
if (first) emitter.emit(TabEventType.TAB_ACTIVE, { id: first.id })
|
|
597
552
|
}
|
|
598
553
|
|
|
599
554
|
const removeRightTabs = (id: string) => {
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
if (pivot < 0 || pivot >= tabList.length - 1) return
|
|
555
|
+
const pivot = tabs.value.findIndex((t) => t?.id === id)
|
|
556
|
+
if (pivot < 0 || pivot >= tabs.value.length - 1) return
|
|
603
557
|
|
|
604
558
|
removeTabs((_, index) => index > pivot)
|
|
605
|
-
if (
|
|
559
|
+
if (tabs.value.some((t) => t.active)) {
|
|
606
560
|
evictMarkedCaches()
|
|
607
561
|
tabIdsToEvict.clear()
|
|
608
562
|
return
|
|
609
563
|
}
|
|
610
|
-
const last =
|
|
564
|
+
const last = tabs.value.at(-1)
|
|
611
565
|
if (last) emitter.emit(TabEventType.TAB_ACTIVE, { id: last.id })
|
|
612
566
|
}
|
|
613
567
|
|
|
@@ -637,11 +591,8 @@ export default () => {
|
|
|
637
591
|
|
|
638
592
|
const top = target.pages.peek()
|
|
639
593
|
const activateTarget = () => {
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
}
|
|
643
|
-
saveActiveTabToSession(target as ITabItem)
|
|
644
|
-
tabs.value = tabs.value.slice()
|
|
594
|
+
const activeTab = setActiveTab(id)
|
|
595
|
+
if (activeTab) saveActiveTabToSession(activeTab)
|
|
645
596
|
}
|
|
646
597
|
|
|
647
598
|
if (!route) {
|
|
@@ -660,21 +611,21 @@ export default () => {
|
|
|
660
611
|
refreshable: target.refreshable,
|
|
661
612
|
iframe: target.iframe
|
|
662
613
|
})
|
|
663
|
-
|
|
614
|
+
// __tab 是兜底标识,目标栈顶已带 __tab 时优先沿用,否则用我们刚编码的版本。
|
|
615
|
+
const query = defu(top.query || {}, { __tab })
|
|
664
616
|
|
|
665
617
|
return runNavigationTransaction({
|
|
666
618
|
apply: () => {
|
|
667
619
|
const prevActiveId = tabs.value.find((tab) => tab.active)?.id
|
|
668
|
-
|
|
620
|
+
// SSR 环境无 sessionStorage;快照为 null 时 rollback 视为「无变化」。
|
|
621
|
+
const sessionSnapshot =
|
|
622
|
+
typeof window !== 'undefined' ? window.sessionStorage.getItem(getSessionKey()) : null
|
|
669
623
|
activateTarget()
|
|
670
624
|
return { prevActiveId, sessionSnapshot }
|
|
671
625
|
},
|
|
672
626
|
navigate: () => router.push({ path: top.path, query }),
|
|
673
627
|
rollback: (snapshot) => {
|
|
674
|
-
|
|
675
|
-
tab.active = tab.id === snapshot.prevActiveId
|
|
676
|
-
}
|
|
677
|
-
tabs.value = tabs.value.slice()
|
|
628
|
+
setActiveTab(snapshot.prevActiveId ?? '')
|
|
678
629
|
restoreActiveTabSession(snapshot.sessionSnapshot)
|
|
679
630
|
},
|
|
680
631
|
isFailureResult: isNavigationFailure,
|
|
@@ -718,6 +669,7 @@ export default () => {
|
|
|
718
669
|
addCache,
|
|
719
670
|
removeCache,
|
|
720
671
|
getTab,
|
|
672
|
+
moveTab,
|
|
721
673
|
addTab,
|
|
722
674
|
removeTab,
|
|
723
675
|
removeAllTabs,
|
|
@@ -98,7 +98,9 @@ export default function useTabRouter() {
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
// __tab 是 StackTabs 解析路由归属标签的关键,优先沿用目标 query 已有的值,
|
|
102
|
+
// 避免被 to.query 中非字符串的同名 key 或缺失值覆盖。
|
|
103
|
+
const query = defu(to.query, { __tab: tabInfo })
|
|
102
104
|
const transactionVersion = ++navigationVersion
|
|
103
105
|
emitter.emit(TabEventType.FORWARD)
|
|
104
106
|
|
|
@@ -183,6 +185,12 @@ export default function useTabRouter() {
|
|
|
183
185
|
if (steps <= 0) return false
|
|
184
186
|
|
|
185
187
|
if (stack.size() <= steps) {
|
|
188
|
+
// 请求退栈步数大于当前可用层数时,仅退到栈底(首页);开发模式下提示维护者。
|
|
189
|
+
if (!import.meta.env.PROD) {
|
|
190
|
+
console.warn(
|
|
191
|
+
`[vue-stack-tabs] backward requested ${steps} steps but stack only has ${stack.size() - 1} available.`
|
|
192
|
+
)
|
|
193
|
+
}
|
|
186
194
|
steps = stack.size() - 1
|
|
187
195
|
}
|
|
188
196
|
|
|
@@ -207,7 +215,8 @@ export default function useTabRouter() {
|
|
|
207
215
|
|
|
208
216
|
emitter.emit(TabEventType.BACKWARD)
|
|
209
217
|
|
|
210
|
-
|
|
218
|
+
// 栈顶 query 已带 __tab 时优先沿用,避免被默认 tabInfo 覆盖导致解析时识别错乱。
|
|
219
|
+
const targetQueryWithTab = defu(target.query || {}, { __tab: tabInfo })
|
|
211
220
|
const transactionVersion = ++navigationVersion
|
|
212
221
|
|
|
213
222
|
runNavigationTransaction({
|
package/src/lib/i18n/index.ts
CHANGED
|
@@ -49,8 +49,8 @@ export const mergeLocaleMessages = (
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
export default () => {
|
|
52
|
-
// 引入lang目录下文件
|
|
53
|
-
//
|
|
52
|
+
// 引入 lang 目录下文件
|
|
53
|
+
// 非 Vite 构建环境可改用 require.context 替换;当前使用 Vite 原生 glob 即可。
|
|
54
54
|
const modules = import.meta.glob('./lang/*', { eager: true })
|
|
55
55
|
|
|
56
56
|
/** 从 glob 导入结果中提取语言文件并合并到 msg 对象 */
|
|
@@ -58,7 +58,7 @@ export default () => {
|
|
|
58
58
|
mList: Record<string, { default?: Record<string, unknown> }>,
|
|
59
59
|
msg: Record<string, Record<string, unknown>>
|
|
60
60
|
) {
|
|
61
|
-
for (const path
|
|
61
|
+
for (const path of Object.keys(mList)) {
|
|
62
62
|
const mod = mList[path] as { default?: Record<string, unknown> }
|
|
63
63
|
if (mod?.default) {
|
|
64
64
|
const pathName = path.substring(path.lastIndexOf('/') + 1, path.lastIndexOf('.'))
|
|
@@ -126,7 +126,46 @@ export class Stack<T> {
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
readonlyList(): readonly T[] {
|
|
129
|
-
|
|
129
|
+
// 返回浅拷贝避免外部修改内部 items 引用,同时类型为 readonly T[] 提示调用方只读。
|
|
130
|
+
return [...this.items]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 查找首个匹配的元素。
|
|
135
|
+
* @returns 匹配的元素,未找到返回 undefined。
|
|
136
|
+
*/
|
|
137
|
+
find(predicate: (element: T, index: number) => boolean): T | undefined {
|
|
138
|
+
return this.items.find(predicate)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 查找首个匹配元素的索引,未找到返回 -1。
|
|
143
|
+
*/
|
|
144
|
+
findIndex(predicate: (element: T, index: number) => boolean): number {
|
|
145
|
+
return this.items.findIndex(predicate)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 返回所有匹配元素组成的浅拷贝数组。
|
|
150
|
+
*/
|
|
151
|
+
filter(predicate: (element: T, index: number) => boolean): T[] {
|
|
152
|
+
return this.items.filter(predicate)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 是否存在任意匹配元素。
|
|
157
|
+
*/
|
|
158
|
+
some(predicate: (element: T, index: number) => boolean): boolean {
|
|
159
|
+
return this.items.some(predicate)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 从顶到底反向遍历每个元素,便于实现「从栈顶开始找」等场景。
|
|
164
|
+
*/
|
|
165
|
+
forEachTopDown(visit: (element: T, index: number) => void): void {
|
|
166
|
+
for (let i = this.items.length - 1; i >= 0; i--) {
|
|
167
|
+
visit(this.items[i] as T, i)
|
|
168
|
+
}
|
|
130
169
|
}
|
|
131
170
|
|
|
132
171
|
toJSON(): T[] {
|
package/src/lib/nuxt/module.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* vue-stack-tabs Nuxt 模块。
|
|
3
3
|
*
|
|
4
|
-
* 自动注册客户端插件、注入 composables(useTabActions
|
|
5
|
-
*
|
|
4
|
+
* 自动注册客户端插件、注入 composables(useTabActions 等)、
|
|
5
|
+
* 注册全局组件 <VueStackTabs>,并通过 runtimeConfig 传递 locale 配置。
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { addComponent, addImports, addPlugin, createResolver, defineNuxtModule } from 'nuxt/kit'
|
|
8
8
|
|
|
9
9
|
export interface ModuleOptions {
|
|
10
10
|
/** 默认语言 */
|
|
@@ -30,6 +30,13 @@ export default defineNuxtModule<ModuleOptions>({
|
|
|
30
30
|
src: resolve('./runtime/plugin')
|
|
31
31
|
})
|
|
32
32
|
|
|
33
|
+
// 自动注册全局组件 <VueStackTabs>,使用方无需在每个页面再 import。
|
|
34
|
+
addComponent({
|
|
35
|
+
name: 'VueStackTabs',
|
|
36
|
+
export: 'VueStackTabs',
|
|
37
|
+
filePath: resolve('../index.ts')
|
|
38
|
+
})
|
|
39
|
+
|
|
33
40
|
addImports([
|
|
34
41
|
{ name: 'useTabActions', from: 'vue-stack-tabs' },
|
|
35
42
|
{ name: 'useTabRouter', from: 'vue-stack-tabs' },
|
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Nuxt 客户端插件:自动注册 VueStackTabs 并应用 runtimeConfig 中的 locale 配置。
|
|
3
|
+
*/
|
|
2
4
|
import { defineNuxtPlugin, useRuntimeConfig } from 'nuxt/app'
|
|
3
5
|
import VueStackTabs from 'vue-stack-tabs'
|
|
6
|
+
import { applyStackTabsLocale } from '../../i18n/stackTabsLocale'
|
|
7
|
+
import { useI18n } from 'vue-i18n-lite'
|
|
4
8
|
|
|
5
9
|
export default defineNuxtPlugin((nuxtApp) => {
|
|
6
10
|
const config = useRuntimeConfig().public.vueStackTabs as {
|
|
7
11
|
locale?: string
|
|
8
12
|
}
|
|
13
|
+
const locale = config?.locale ?? 'zh-CN'
|
|
9
14
|
|
|
10
|
-
|
|
15
|
+
// Vue.use 时把 locale 作为 plugin options 传入,让 i18n 实例按指定 locale 初始化。
|
|
16
|
+
nuxtApp.vueApp.use(VueStackTabs, [{ locale }])
|
|
17
|
+
|
|
18
|
+
// 兜底:应用 vue-stack-tabs 内部 i18n 实例的 locale 切换逻辑,
|
|
19
|
+
// 避免某些场景下 plugin options 未能正确初始化时 locale 仍是默认 zh-CN。
|
|
20
|
+
const { changeLocale } = useI18n()
|
|
21
|
+
applyStackTabsLocale(changeLocale, locale)
|
|
11
22
|
})
|