vue-stack-tabs 1.3.1 → 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 +103 -42
- 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 +64 -40
- package/src/lib/StackTabs.vue +16 -3
- package/src/lib/assets/style/common/var.scss +2 -0
- package/src/lib/assets/style/stackTab.scss +17 -3
- 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/index.vue +4 -3
- 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 +18 -8
- package/src/lib/hooks/useTabEventBus.ts +5 -3
- package/src/lib/hooks/useTabPanel.tsx +108 -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,6 +174,31 @@ export default () => {
|
|
|
189
174
|
|
|
190
175
|
const getTab = (id: string) => tabs.value.find((t) => t.id === id) ?? null
|
|
191
176
|
|
|
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
|
+
|
|
192
202
|
/**
|
|
193
203
|
* 将指定标签移动到目标索引。标签对象本身不变,因此其页面栈和缓存关联保持不受影响。
|
|
194
204
|
* @returns 是否实际调整了顺序
|
|
@@ -212,7 +222,22 @@ export default () => {
|
|
|
212
222
|
return true
|
|
213
223
|
}
|
|
214
224
|
|
|
215
|
-
/**
|
|
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
|
+
*/
|
|
216
241
|
const parseTabInfoFromRoute = (route: RouteLocationNormalizedLoaded): ITabBase => {
|
|
217
242
|
if (typeof route.query.__tab === 'string') {
|
|
218
243
|
const tabInfo = decodeTabInfo(route.query.__tab)
|
|
@@ -223,16 +248,15 @@ export default () => {
|
|
|
223
248
|
const activeTab = tabs.value.find((tab) => tab.active)
|
|
224
249
|
const activePage = activeTab?.pages.peek()
|
|
225
250
|
if (activeTab && activePage?.path === path) {
|
|
226
|
-
return
|
|
227
|
-
id: activeTab.id,
|
|
228
|
-
title: activeTab.title,
|
|
229
|
-
closable: activeTab.closable,
|
|
230
|
-
refreshable: activeTab.refreshable,
|
|
231
|
-
iframe: activeTab.iframe,
|
|
232
|
-
iframeRefreshMode: activeTab.iframeRefreshMode
|
|
233
|
-
}
|
|
251
|
+
return toTabBase(activeTab)
|
|
234
252
|
}
|
|
235
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
|
+
|
|
236
260
|
return {
|
|
237
261
|
id: crypto.randomUUID(),
|
|
238
262
|
title: t('VueStackTab.undefined'),
|
|
@@ -251,11 +275,7 @@ export default () => {
|
|
|
251
275
|
cacheName: string,
|
|
252
276
|
page: ITabPage
|
|
253
277
|
): ITabItem => {
|
|
254
|
-
let targetTab
|
|
255
|
-
for (const tab of tabs.value) {
|
|
256
|
-
tab.active = false
|
|
257
|
-
if (tab.id === tabInfo.id) targetTab = tab as ITabItem
|
|
258
|
-
}
|
|
278
|
+
let targetTab = tabInfo.id ? getTab(tabInfo.id) : null
|
|
259
279
|
|
|
260
280
|
if (targetTab === null) {
|
|
261
281
|
const pages = new Stack<ITabPage>()
|
|
@@ -272,10 +292,11 @@ export default () => {
|
|
|
272
292
|
active: true,
|
|
273
293
|
pages
|
|
274
294
|
} as unknown as ITabItem
|
|
295
|
+
setActiveTab('')
|
|
275
296
|
addTab(targetTab)
|
|
276
297
|
emitter.emit(TabEventType.TAB_ACTIVE, { id: tabInfo.id!, isRoute: false })
|
|
277
298
|
} else {
|
|
278
|
-
targetTab.
|
|
299
|
+
setActiveTab(targetTab.id)
|
|
279
300
|
if (targetTab.pages.isEmpty() || targetTab.pages.peek()!.id !== page.id) {
|
|
280
301
|
targetTab.pages.push(page)
|
|
281
302
|
}
|
|
@@ -331,7 +352,9 @@ export default () => {
|
|
|
331
352
|
const stepsToPop = pagesList.length - 1 - foundIndex
|
|
332
353
|
for (let i = 0; i < stepsToPop; i++) {
|
|
333
354
|
const popped = targetTab.pages.pop()
|
|
334
|
-
|
|
355
|
+
// 仅标记待驱逐,由 onActivated 或下一拍 evictMarkedCaches 统一清理,
|
|
356
|
+
// 避免 watcher 同步执行与守卫拒绝路由之间产生不一致。
|
|
357
|
+
if (popped) markCacheForEviction(popped.id)
|
|
335
358
|
}
|
|
336
359
|
const newTop = targetTab.pages.peek()
|
|
337
360
|
cacheName = newTop ? newTop.id : createPageId()
|
|
@@ -373,111 +396,21 @@ export default () => {
|
|
|
373
396
|
if (page) {
|
|
374
397
|
targetTab = findOrCreateTargetTab(tabInfo, route, cacheName, page)
|
|
375
398
|
} else {
|
|
376
|
-
|
|
377
|
-
targetTab!.active = true
|
|
399
|
+
setActiveTab(targetTab!.id)
|
|
378
400
|
}
|
|
379
401
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
cacheName: string
|
|
389
|
-
tabInfo: ITabBase
|
|
390
|
-
targetTab: ITabItem
|
|
391
|
-
}): DefineComponent => {
|
|
392
|
-
const { cacheName, tabInfo } = ctx
|
|
393
|
-
|
|
394
|
-
if (components.has(cacheName)) {
|
|
395
|
-
addCache(cacheName)
|
|
396
|
-
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
|
+
})
|
|
397
410
|
}
|
|
398
411
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
props: {
|
|
402
|
-
vnode: {
|
|
403
|
-
type: Object as () => VNode,
|
|
404
|
-
required: false,
|
|
405
|
-
default: undefined
|
|
406
|
-
}
|
|
407
|
-
},
|
|
408
|
-
emits: ['onLoaded'],
|
|
409
|
-
setup(props, context) {
|
|
410
|
-
const localBackParams = ref<Record<string, unknown> | null>(null)
|
|
411
|
-
const lastVnode = shallowRef<VNode | null>(null)
|
|
412
|
-
const lastCloned = shallowRef<VNode | null>(null)
|
|
413
|
-
const lastBackParams = shallowRef<Record<string, unknown> | null>(null)
|
|
414
|
-
|
|
415
|
-
const checkAndConsumeBackParams = () => {
|
|
416
|
-
try {
|
|
417
|
-
const tab = getTab(tabInfo.id!)
|
|
418
|
-
const top = tab?.pages.peek()
|
|
419
|
-
if (top && top.id === cacheName && top._backParams) {
|
|
420
|
-
localBackParams.value = { ...top._backParams }
|
|
421
|
-
delete top._backParams
|
|
422
|
-
} else {
|
|
423
|
-
localBackParams.value = null
|
|
424
|
-
}
|
|
425
|
-
} catch {
|
|
426
|
-
localBackParams.value = null
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
onMounted(() => {
|
|
431
|
-
context.emit('onLoaded')
|
|
432
|
-
addPageScroller(cacheName, `#W-${cacheName}`)
|
|
433
|
-
checkAndConsumeBackParams()
|
|
434
|
-
})
|
|
435
|
-
|
|
436
|
-
onDeactivated(() => saveScroller(cacheName))
|
|
437
|
-
|
|
438
|
-
onActivated(() => {
|
|
439
|
-
context.emit('onLoaded')
|
|
440
|
-
restoreScroller(cacheName)
|
|
441
|
-
evictMarkedCaches()
|
|
442
|
-
tabIdsToEvict.clear()
|
|
443
|
-
checkAndConsumeBackParams()
|
|
444
|
-
})
|
|
445
|
-
|
|
446
|
-
onUnmounted(() => removeScroller(cacheName))
|
|
447
|
-
|
|
448
|
-
return () => {
|
|
449
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
450
|
-
const Node = (props as any).vnode as VNode
|
|
451
|
-
const backParams = localBackParams.value
|
|
452
|
-
|
|
453
|
-
if (Node !== lastVnode.value || backParams !== lastBackParams.value) {
|
|
454
|
-
const dynamicBackProps = backParams ? { _back: backParams } : {}
|
|
455
|
-
lastCloned.value = Node
|
|
456
|
-
? cloneVNode(Node, { tId: tabInfo.id, pId: cacheName, ...dynamicBackProps })
|
|
457
|
-
: null
|
|
458
|
-
lastVnode.value = Node
|
|
459
|
-
lastBackParams.value = backParams
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
return (
|
|
463
|
-
<div
|
|
464
|
-
class="cache-page-wrapper"
|
|
465
|
-
id={`W-${cacheName}`}
|
|
466
|
-
style={[
|
|
467
|
-
runtimeContext.useGlobalScroll.value ? 'overflow:auto' : 'overflow:hidden',
|
|
468
|
-
'height: 100%'
|
|
469
|
-
]}
|
|
470
|
-
>
|
|
471
|
-
{lastCloned.value}
|
|
472
|
-
<PageLoading tabId={tabInfo.id!} />
|
|
473
|
-
</div>
|
|
474
|
-
)
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
}) as DefineComponent
|
|
478
|
-
components.set(cacheName, cacheComponent)
|
|
479
|
-
addCache(cacheName)
|
|
480
|
-
return cacheComponent
|
|
412
|
+
saveActiveTabToSession(targetTab!)
|
|
413
|
+
return { cacheName, tabInfo, targetTab: targetTab! }
|
|
481
414
|
}
|
|
482
415
|
|
|
483
416
|
/** 当前正在渲染的页面的缓存 Key(ULID),供 StackTabs.vue 的 :key 绑定使用 */
|
|
@@ -495,7 +428,7 @@ export default () => {
|
|
|
495
428
|
route: RouteLocationNormalizedLoaded,
|
|
496
429
|
component?: VNode | null
|
|
497
430
|
): DefineComponent => {
|
|
498
|
-
if (!component) return
|
|
431
|
+
if (!component) return getEmptyPlaceholderComponent()
|
|
499
432
|
|
|
500
433
|
const routeKey = route.fullPath + (route.query.__tab || '')
|
|
501
434
|
if (routeKey === lastRouteKey && lastAddPageResult) {
|
|
@@ -503,7 +436,7 @@ export default () => {
|
|
|
503
436
|
}
|
|
504
437
|
|
|
505
438
|
const state = updatePageState(route)
|
|
506
|
-
if ('empty' in state) return
|
|
439
|
+
if ('empty' in state) return getEmptyPlaceholderComponent()
|
|
507
440
|
if ('recovered' in state) {
|
|
508
441
|
addCache(state.cacheName)
|
|
509
442
|
activeCacheKey.value = state.cacheName
|
|
@@ -554,7 +487,7 @@ export default () => {
|
|
|
554
487
|
if (tabList.length > 1 && tab.active) {
|
|
555
488
|
activeTabId = (i === 0 ? tabList[1] : tabList[i - 1])?.id ?? ''
|
|
556
489
|
}
|
|
557
|
-
tabList.
|
|
490
|
+
tabs.value = [...tabList.slice(0, i), ...tabList.slice(i + 1)]
|
|
558
491
|
|
|
559
492
|
if (activeTabId) {
|
|
560
493
|
emitter.emit(TabEventType.TAB_ACTIVE, { id: activeTabId })
|
|
@@ -569,32 +502,32 @@ export default () => {
|
|
|
569
502
|
|
|
570
503
|
/** 批量关闭满足条件的可关闭标签,并统一标记其页面缓存待驱逐。 */
|
|
571
504
|
const removeTabs = (shouldRemove: (tab: ITabItem, index: number) => boolean) => {
|
|
572
|
-
const
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
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
|
|
576
508
|
markTabPagesForEviction(tab)
|
|
577
509
|
tab.pages.clear()
|
|
578
|
-
|
|
579
|
-
|
|
510
|
+
removed.push(tab)
|
|
511
|
+
return false
|
|
512
|
+
})
|
|
513
|
+
tabs.value = remaining
|
|
514
|
+
return removed
|
|
580
515
|
}
|
|
581
516
|
|
|
582
517
|
const removeAllTabs = () => {
|
|
583
|
-
const tabList = tabs.value
|
|
584
518
|
removeTabs(() => true)
|
|
585
|
-
const hasActive =
|
|
519
|
+
const hasActive = tabs.value.some((t) => t.active)
|
|
586
520
|
if (hasActive) {
|
|
587
521
|
evictMarkedCaches()
|
|
588
522
|
tabIdsToEvict.clear()
|
|
589
523
|
return
|
|
590
524
|
}
|
|
591
|
-
const last =
|
|
525
|
+
const last = tabs.value.at(-1)
|
|
592
526
|
if (last) emitter.emit(TabEventType.TAB_ACTIVE, { id: last.id })
|
|
593
527
|
}
|
|
594
528
|
|
|
595
529
|
const removeOtherTabs = (id: string) => {
|
|
596
|
-
const
|
|
597
|
-
const activeTab = tabList.find((tab) => tab.id === id)
|
|
530
|
+
const activeTab = getTab(id)
|
|
598
531
|
removeTabs((tab) => tab.id !== id)
|
|
599
532
|
if (activeTab && !activeTab.active) {
|
|
600
533
|
emitter.emit(TabEventType.TAB_ACTIVE, { id })
|
|
@@ -605,32 +538,30 @@ export default () => {
|
|
|
605
538
|
}
|
|
606
539
|
|
|
607
540
|
const removeLeftTabs = (id: string) => {
|
|
608
|
-
const
|
|
609
|
-
const pivot = tabList.findIndex((t) => t?.id === id)
|
|
541
|
+
const pivot = tabs.value.findIndex((t) => t?.id === id)
|
|
610
542
|
if (pivot <= 0) return
|
|
611
543
|
|
|
612
544
|
removeTabs((_, index) => index < pivot)
|
|
613
|
-
if (
|
|
545
|
+
if (tabs.value.some((t) => t.active)) {
|
|
614
546
|
evictMarkedCaches()
|
|
615
547
|
tabIdsToEvict.clear()
|
|
616
548
|
return
|
|
617
549
|
}
|
|
618
|
-
const first =
|
|
550
|
+
const first = tabs.value[0]
|
|
619
551
|
if (first) emitter.emit(TabEventType.TAB_ACTIVE, { id: first.id })
|
|
620
552
|
}
|
|
621
553
|
|
|
622
554
|
const removeRightTabs = (id: string) => {
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
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
|
|
626
557
|
|
|
627
558
|
removeTabs((_, index) => index > pivot)
|
|
628
|
-
if (
|
|
559
|
+
if (tabs.value.some((t) => t.active)) {
|
|
629
560
|
evictMarkedCaches()
|
|
630
561
|
tabIdsToEvict.clear()
|
|
631
562
|
return
|
|
632
563
|
}
|
|
633
|
-
const last =
|
|
564
|
+
const last = tabs.value.at(-1)
|
|
634
565
|
if (last) emitter.emit(TabEventType.TAB_ACTIVE, { id: last.id })
|
|
635
566
|
}
|
|
636
567
|
|
|
@@ -660,11 +591,8 @@ export default () => {
|
|
|
660
591
|
|
|
661
592
|
const top = target.pages.peek()
|
|
662
593
|
const activateTarget = () => {
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
}
|
|
666
|
-
saveActiveTabToSession(target as ITabItem)
|
|
667
|
-
tabs.value = tabs.value.slice()
|
|
594
|
+
const activeTab = setActiveTab(id)
|
|
595
|
+
if (activeTab) saveActiveTabToSession(activeTab)
|
|
668
596
|
}
|
|
669
597
|
|
|
670
598
|
if (!route) {
|
|
@@ -683,21 +611,21 @@ export default () => {
|
|
|
683
611
|
refreshable: target.refreshable,
|
|
684
612
|
iframe: target.iframe
|
|
685
613
|
})
|
|
686
|
-
|
|
614
|
+
// __tab 是兜底标识,目标栈顶已带 __tab 时优先沿用,否则用我们刚编码的版本。
|
|
615
|
+
const query = defu(top.query || {}, { __tab })
|
|
687
616
|
|
|
688
617
|
return runNavigationTransaction({
|
|
689
618
|
apply: () => {
|
|
690
619
|
const prevActiveId = tabs.value.find((tab) => tab.active)?.id
|
|
691
|
-
|
|
620
|
+
// SSR 环境无 sessionStorage;快照为 null 时 rollback 视为「无变化」。
|
|
621
|
+
const sessionSnapshot =
|
|
622
|
+
typeof window !== 'undefined' ? window.sessionStorage.getItem(getSessionKey()) : null
|
|
692
623
|
activateTarget()
|
|
693
624
|
return { prevActiveId, sessionSnapshot }
|
|
694
625
|
},
|
|
695
626
|
navigate: () => router.push({ path: top.path, query }),
|
|
696
627
|
rollback: (snapshot) => {
|
|
697
|
-
|
|
698
|
-
tab.active = tab.id === snapshot.prevActiveId
|
|
699
|
-
}
|
|
700
|
-
tabs.value = tabs.value.slice()
|
|
628
|
+
setActiveTab(snapshot.prevActiveId ?? '')
|
|
701
629
|
restoreActiveTabSession(snapshot.sessionSnapshot)
|
|
702
630
|
},
|
|
703
631
|
isFailureResult: isNavigationFailure,
|
|
@@ -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
|
})
|
|
@@ -53,8 +53,14 @@ export function postOpenTab(
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
* 在 iframe 内调用:注册刷新监听,收到父窗口刷新指令时执行 callback
|
|
56
|
+
* 在 iframe 内调用:注册刷新监听,收到父窗口刷新指令时执行 callback。
|
|
57
|
+
*
|
|
58
|
+
* **安全提示**:`options.allowedOrigins` 为空数组(默认值)时**不校验 origin**,
|
|
59
|
+
* 等同 `targetOrigin: '*'`,任何嵌入此 iframe 的父页面都能触发回调。
|
|
60
|
+
* 生产环境务必显式传入可信 origin 列表,例如 `['https://app.example.com']`。
|
|
61
|
+
*
|
|
57
62
|
* @param callback 默认 location.reload(),可自定义
|
|
63
|
+
* @param options 可选配置,`allowedOrigins` 强烈建议显式传入
|
|
58
64
|
* @returns 取消监听的函数
|
|
59
65
|
*/
|
|
60
66
|
export function onRefreshRequest(
|
|
@@ -66,6 +72,8 @@ export function onRefreshRequest(
|
|
|
66
72
|
const allowedOrigins = options.allowedOrigins ?? []
|
|
67
73
|
const handler = (ev: MessageEvent) => {
|
|
68
74
|
if (ev.source !== window.parent) return
|
|
75
|
+
// allowedOrigins 为空时跳过 origin 校验:刻意保留以便本地调试,
|
|
76
|
+
// 但必须在文档中向用户强调风险(见上方 JSDoc)。
|
|
69
77
|
if (allowedOrigins.length > 0 && !allowedOrigins.includes(ev.origin)) return
|
|
70
78
|
if (ev.data?.type === MSG_REFRESH) {
|
|
71
79
|
callback()
|