vue-stack-tabs 1.3.1 → 1.4.2
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 +23 -0
- package/dist/{src/lib/hooks → hooks}/tabPanel/refresh.d.ts +6 -3
- package/dist/{src/lib/hooks → hooks}/tabPanel/scroll.d.ts +2 -0
- package/dist/hooks/tabPanel/tabStore.d.ts +69 -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 +16 -4
- 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 +136 -0
- package/src/lib/hooks/tabPanel/refresh.ts +20 -13
- package/src/lib/hooks/tabPanel/scroll.ts +110 -7
- package/src/lib/hooks/tabPanel/session.ts +29 -3
- package/src/lib/hooks/tabPanel/tabStore.ts +595 -0
- 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 +183 -302
- package/src/lib/hooks/useTabRouter.ts +42 -38
- 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/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
|
@@ -2,7 +2,6 @@ import { defu } from 'defu'
|
|
|
2
2
|
import { getCurrentInstance } from 'vue'
|
|
3
3
|
import type { ComponentInternalInstance } from 'vue'
|
|
4
4
|
import type { LocationQueryRaw, RouteLocationPathRaw } from 'vue-router'
|
|
5
|
-
import type { ITabPage } from '../model/TabModel'
|
|
6
5
|
import { isNavigationFailure, useRoute, useRouter } from 'vue-router'
|
|
7
6
|
|
|
8
7
|
import useTabPanel from './useTabPanel'
|
|
@@ -18,18 +17,6 @@ import { runNavigationTransaction } from './tabPanel/navigationTransaction'
|
|
|
18
17
|
|
|
19
18
|
import { createPageId } from '../utils/tabInfoEncoder'
|
|
20
19
|
|
|
21
|
-
interface PageStackLike {
|
|
22
|
-
clear: () => void
|
|
23
|
-
push: (page: ITabPage) => void
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const restorePages = (stack: PageStackLike, pages: ITabPage[]) => {
|
|
27
|
-
stack.clear()
|
|
28
|
-
for (const page of pages) {
|
|
29
|
-
stack.push(clonePage(page))
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
20
|
/**
|
|
34
21
|
* useTabRouter - 标签内路由 Hook
|
|
35
22
|
*
|
|
@@ -62,7 +49,19 @@ export default function useTabRouter() {
|
|
|
62
49
|
const route = useRoute()
|
|
63
50
|
const router = useRouter()
|
|
64
51
|
const emitter = useTabEmitter()
|
|
65
|
-
const {
|
|
52
|
+
const {
|
|
53
|
+
getTab,
|
|
54
|
+
getTopPage,
|
|
55
|
+
getPages,
|
|
56
|
+
appendPage,
|
|
57
|
+
popPages,
|
|
58
|
+
getPageSnapshot,
|
|
59
|
+
replacePages,
|
|
60
|
+
updatePage,
|
|
61
|
+
evictPageCache,
|
|
62
|
+
addPageScroller,
|
|
63
|
+
active: activateTab
|
|
64
|
+
} = useTabPanel()
|
|
66
65
|
|
|
67
66
|
/**
|
|
68
67
|
* 当前标签的编码信息字符串,存于 URL query.__tab。
|
|
@@ -87,7 +86,7 @@ export default function useTabRouter() {
|
|
|
87
86
|
const targetQuery = (to.query || {}) as LocationQueryRaw
|
|
88
87
|
|
|
89
88
|
if (tab) {
|
|
90
|
-
const top = tab.
|
|
89
|
+
const top = getTopPage(tab.id)
|
|
91
90
|
if (top && top.path === to.path) {
|
|
92
91
|
if (isSameQueryIgnoringReserved(targetQuery, top.query || {})) {
|
|
93
92
|
if (!tab.active) {
|
|
@@ -98,17 +97,19 @@ export default function useTabRouter() {
|
|
|
98
97
|
}
|
|
99
98
|
}
|
|
100
99
|
|
|
101
|
-
|
|
100
|
+
// __tab 是 StackTabs 解析路由归属标签的关键,优先沿用目标 query 已有的值,
|
|
101
|
+
// 避免被 to.query 中非字符串的同名 key 或缺失值覆盖。
|
|
102
|
+
const query = defu(to.query, { __tab: tabInfo })
|
|
102
103
|
const transactionVersion = ++navigationVersion
|
|
103
104
|
emitter.emit(TabEventType.FORWARD)
|
|
104
105
|
|
|
105
106
|
let optimisticPageId = ''
|
|
106
107
|
runNavigationTransaction({
|
|
107
108
|
apply: () => {
|
|
108
|
-
const snapshot = tab ? tab.
|
|
109
|
+
const snapshot = tab ? getPageSnapshot(tab.id) : []
|
|
109
110
|
if (tab) {
|
|
110
111
|
optimisticPageId = createPageId()
|
|
111
|
-
tab.
|
|
112
|
+
appendPage(tab.id, {
|
|
112
113
|
id: optimisticPageId,
|
|
113
114
|
tabId: tab.id,
|
|
114
115
|
path: to.path,
|
|
@@ -119,14 +120,14 @@ export default function useTabRouter() {
|
|
|
119
120
|
},
|
|
120
121
|
navigate: () => router.push({ path: to.path, query }),
|
|
121
122
|
rollback: (snapshot) => {
|
|
122
|
-
if (tab)
|
|
123
|
+
if (tab) replacePages(tab.id, snapshot)
|
|
123
124
|
},
|
|
124
125
|
isFailureResult: isNavigationFailure,
|
|
125
126
|
isCurrent: () => navigationVersion === transactionVersion,
|
|
126
127
|
cleanupStale: () => {
|
|
127
128
|
if (!tab || !optimisticPageId) return
|
|
128
|
-
const pages = tab.
|
|
129
|
-
|
|
129
|
+
const pages = getPages(tab.id).filter((page) => page.id !== optimisticPageId)
|
|
130
|
+
replacePages(tab.id, pages)
|
|
130
131
|
}
|
|
131
132
|
}).catch((error: unknown) => {
|
|
132
133
|
if (!import.meta.env.PROD) {
|
|
@@ -147,7 +148,7 @@ export default function useTabRouter() {
|
|
|
147
148
|
const tab = getTab(tabId)
|
|
148
149
|
if (!tab) return false
|
|
149
150
|
|
|
150
|
-
const
|
|
151
|
+
const pages = getPages(tab.id)
|
|
151
152
|
let steps = 0
|
|
152
153
|
|
|
153
154
|
if (typeof to === 'number') {
|
|
@@ -155,10 +156,9 @@ export default function useTabRouter() {
|
|
|
155
156
|
} else {
|
|
156
157
|
const parsed = parseUrl(to)
|
|
157
158
|
const targetQuery = parsed.query || {}
|
|
158
|
-
const pages = stack.readonlyList()
|
|
159
159
|
let found = false
|
|
160
160
|
|
|
161
|
-
for (let i =
|
|
161
|
+
for (let i = pages.length - 2; i >= 0; i--) {
|
|
162
162
|
steps++
|
|
163
163
|
const page = pages[i]
|
|
164
164
|
if (
|
|
@@ -182,38 +182,42 @@ export default function useTabRouter() {
|
|
|
182
182
|
|
|
183
183
|
if (steps <= 0) return false
|
|
184
184
|
|
|
185
|
-
|
|
186
|
-
|
|
185
|
+
const availableSteps = pages.length - 1
|
|
186
|
+
if (availableSteps < steps) {
|
|
187
|
+
// 请求退栈步数大于当前可用层数时,仅退到栈底(首页);开发模式下提示维护者。
|
|
188
|
+
if (!import.meta.env.PROD) {
|
|
189
|
+
console.warn(
|
|
190
|
+
`[vue-stack-tabs] backward requested ${steps} steps but stack only has ${availableSteps} available.`
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
steps = availableSteps
|
|
187
194
|
}
|
|
188
195
|
|
|
189
196
|
if (steps <= 0) return false
|
|
190
197
|
|
|
191
|
-
const originalPages =
|
|
192
|
-
const recyclingBin
|
|
193
|
-
for (let i = 0; i < steps; i++) {
|
|
194
|
-
const popped = stack.pop()
|
|
195
|
-
if (popped) recyclingBin.push(popped.id)
|
|
196
|
-
}
|
|
198
|
+
const originalPages = getPageSnapshot(tab.id)
|
|
199
|
+
const recyclingBin = popPages(tab.id, steps).map((page) => page.id)
|
|
197
200
|
|
|
198
|
-
const target =
|
|
201
|
+
const target = getTopPage(tab.id)
|
|
199
202
|
if (!target) {
|
|
200
|
-
|
|
203
|
+
replacePages(tab.id, originalPages)
|
|
201
204
|
return false
|
|
202
205
|
}
|
|
203
206
|
|
|
204
207
|
if (backQuery) {
|
|
205
|
-
target._backParams
|
|
208
|
+
updatePage(tab.id, target.id, { _backParams: backQuery })
|
|
206
209
|
}
|
|
207
210
|
|
|
208
211
|
emitter.emit(TabEventType.BACKWARD)
|
|
209
212
|
|
|
210
|
-
|
|
213
|
+
// 栈顶 query 已带 __tab 时优先沿用,避免被默认 tabInfo 覆盖导致解析时识别错乱。
|
|
214
|
+
const targetQueryWithTab = defu(target.query || {}, { __tab: tabInfo })
|
|
211
215
|
const transactionVersion = ++navigationVersion
|
|
212
216
|
|
|
213
217
|
runNavigationTransaction({
|
|
214
218
|
apply: () => originalPages,
|
|
215
219
|
navigate: () => router.push({ path: target.path, query: targetQueryWithTab }),
|
|
216
|
-
rollback: (snapshot) =>
|
|
220
|
+
rollback: (snapshot) => replacePages(tab.id, snapshot),
|
|
217
221
|
isFailureResult: isNavigationFailure,
|
|
218
222
|
isCurrent: () => navigationVersion === transactionVersion,
|
|
219
223
|
commit: () => {
|
|
@@ -222,7 +226,7 @@ export default function useTabRouter() {
|
|
|
222
226
|
}
|
|
223
227
|
},
|
|
224
228
|
cleanupStale: () => {
|
|
225
|
-
const activePageIds = new Set(
|
|
229
|
+
const activePageIds = new Set(getPages(tab.id).map((page) => page.id))
|
|
226
230
|
for (const id of recyclingBin) {
|
|
227
231
|
if (!activePageIds.has(id)) evictPageCache(id)
|
|
228
232
|
}
|
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()
|
|
@@ -91,13 +91,46 @@ export const getScrollbarWidth = (function () {
|
|
|
91
91
|
|
|
92
92
|
const Z_INDEX_CEILING = 89999
|
|
93
93
|
|
|
94
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* 缓存条目:保存某选择器下最近一次的最大 z-index 结果。
|
|
96
|
+
* 在 Z_INDEX_CACHE_TTL 毫秒内的重复请求直接复用,避免反复遍历 DOM。
|
|
97
|
+
*/
|
|
98
|
+
interface ZIndexCacheEntry {
|
|
99
|
+
value: number
|
|
100
|
+
timestamp: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const Z_INDEX_CACHE_TTL = 100
|
|
104
|
+
const zIndexCache = new Map<string, ZIndexCacheEntry>()
|
|
105
|
+
|
|
106
|
+
/** 在用户主动布局变化后调用,使缓存失效 */
|
|
107
|
+
export const invalidateZIndexCache = (selector?: string): void => {
|
|
108
|
+
if (selector === undefined) {
|
|
109
|
+
zIndexCache.clear()
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
zIndexCache.delete(selector)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 获取指定选择器下元素的最大 z-index + 1,用于最大化时置顶。
|
|
117
|
+
* 同一选择器在 Z_INDEX_CACHE_TTL 毫秒内的重复请求会复用上次结果,避免重复 querySelectorAll / getComputedStyle。
|
|
118
|
+
* 当 DOM 结构发生显著变化时,请调用 invalidateZIndexCache 让缓存失效。
|
|
119
|
+
*/
|
|
95
120
|
export const getMaxZIndex = (key = '.stack-tab__container *'): number => {
|
|
121
|
+
const now = Date.now()
|
|
122
|
+
const cached = zIndexCache.get(key)
|
|
123
|
+
if (cached && now - cached.timestamp < Z_INDEX_CACHE_TTL) {
|
|
124
|
+
return cached.value
|
|
125
|
+
}
|
|
126
|
+
|
|
96
127
|
const elements = document.querySelectorAll(key)
|
|
97
128
|
let max = 0
|
|
98
129
|
for (let i = 0; i < elements.length; i++) {
|
|
99
130
|
const z = +window.getComputedStyle(elements[i]!).zIndex || 0
|
|
100
131
|
if (z < Z_INDEX_CEILING && z > max) max = z
|
|
101
132
|
}
|
|
102
|
-
|
|
133
|
+
const value = max + 1
|
|
134
|
+
zIndexCache.set(key, { value, timestamp: now })
|
|
135
|
+
return value
|
|
103
136
|
}
|
|
@@ -20,9 +20,14 @@ interface EncodedTabInfoV1 {
|
|
|
20
20
|
const MAX_DECODED_TAB_INFO_LENGTH = 2048
|
|
21
21
|
const MAX_ENCODED_TAB_INFO_LENGTH = 4096
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
/**
|
|
24
|
+
* 把任意 UTF-8 字符串安全地编码为 base64(避免中文/特殊字符直接 btoa 抛错)。
|
|
25
|
+
* 使用 encodeURIComponent + unescape 是经典跨浏览器方案,比手动管理 TextEncoder 更简洁。
|
|
26
|
+
*/
|
|
27
|
+
const encodeBase64 = (value: string): string => btoa(unescape(encodeURIComponent(value)))
|
|
28
|
+
|
|
29
|
+
/** encodeBase64 的逆操作,非合法 base64 时抛错,由调用方 try/catch。 */
|
|
30
|
+
const decodeBase64 = (encoded: string): string => decodeURIComponent(escape(atob(encoded)))
|
|
26
31
|
|
|
27
32
|
const DEFAULT_TAB_INFO: ITabBase = {
|
|
28
33
|
id: '',
|
|
@@ -39,7 +44,7 @@ const toTabInfo = (payload: unknown): ITabBase => {
|
|
|
39
44
|
if (!isRecord(payload)) return { ...DEFAULT_TAB_INFO }
|
|
40
45
|
|
|
41
46
|
return {
|
|
42
|
-
id: typeof payload.id === 'string' ? payload.id :
|
|
47
|
+
id: typeof payload.id === 'string' ? payload.id : undefined,
|
|
43
48
|
title: typeof payload.title === 'string' ? payload.title : DEFAULT_TAB_INFO.title,
|
|
44
49
|
iframe: typeof payload.iframe === 'boolean' ? payload.iframe : DEFAULT_TAB_INFO.iframe,
|
|
45
50
|
closable: typeof payload.closable === 'boolean' ? payload.closable : DEFAULT_TAB_INFO.closable,
|
|
@@ -76,13 +81,21 @@ export const encodeTabInfo = (tabData: ITabBase): string => {
|
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
export const decodeTabInfo = (encoded: string): ITabBase => {
|
|
79
|
-
if (encoded.length > MAX_ENCODED_TAB_INFO_LENGTH)
|
|
84
|
+
if (typeof encoded !== 'string' || encoded.length > MAX_ENCODED_TAB_INFO_LENGTH) {
|
|
85
|
+
return { ...DEFAULT_TAB_INFO }
|
|
86
|
+
}
|
|
80
87
|
|
|
88
|
+
let tabString: string
|
|
81
89
|
try {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
tabString = decodeBase64(encoded)
|
|
91
|
+
} catch {
|
|
92
|
+
return { ...DEFAULT_TAB_INFO }
|
|
93
|
+
}
|
|
85
94
|
|
|
95
|
+
if (tabString.length > MAX_DECODED_TAB_INFO_LENGTH) return { ...DEFAULT_TAB_INFO }
|
|
96
|
+
if (!tabString.trim().startsWith('{')) return decodeLegacyTabInfo(tabString)
|
|
97
|
+
|
|
98
|
+
try {
|
|
86
99
|
return toTabInfo(JSON.parse(tabString))
|
|
87
100
|
} catch {
|
|
88
101
|
return { ...DEFAULT_TAB_INFO }
|
|
@@ -137,7 +137,9 @@ export function isSameQueryIgnoringReserved(
|
|
|
137
137
|
const result: Record<string, string | string[]> = {}
|
|
138
138
|
for (const [key, value] of Object.entries(q)) {
|
|
139
139
|
if (STACK_TABS_RESERVED_QUERY_KEYS.has(key) || key === '_back') continue
|
|
140
|
-
result[key] = Array.isArray(value)
|
|
140
|
+
result[key] = Array.isArray(value)
|
|
141
|
+
? value.map((item) => String(item ?? ''))
|
|
142
|
+
: String(value ?? '')
|
|
141
143
|
}
|
|
142
144
|
return result
|
|
143
145
|
}
|
package/src/lib/versionLogger.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/** 在浏览器控制台输出带样式的库版本标识 */
|
|
2
|
-
const logVersion = (version
|
|
2
|
+
const logVersion = (version?: string) => {
|
|
3
3
|
if (typeof window === 'undefined') return
|
|
4
4
|
|
|
5
|
+
// 兜底:Vitest / SSR 等环境下 import.meta.env.PACKAGE_VERSION 缺失,
|
|
6
|
+
// 避免打印出 "vundefined" 这种丑陋的版本号。
|
|
7
|
+
const displayVersion = typeof version === 'string' && version.length > 0 ? version : 'dev'
|
|
8
|
+
|
|
5
9
|
window.console.log(
|
|
6
|
-
'%c vue-stack-tabs %c v' +
|
|
10
|
+
'%c vue-stack-tabs %c v' + displayVersion + ' %c',
|
|
7
11
|
'background:#306fff; padding:2px 8px; border-radius:3px 0 0 3px; color:#fff;',
|
|
8
12
|
'background:#00d797; padding:2px 8px; border-radius:0 3px 3px 0; color:#fff;',
|
|
9
13
|
'background:transparent'
|
package/dist/favicon.svg
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<svg id="_图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 199.49 199.67">
|
|
3
|
-
<path d="M79.24,19.73h108.91v29.7h-108.91v-29.7Z" style="fill: #79a7ff; stroke-width: 0px;"/>
|
|
4
|
-
<path d="M179.24,199.67H20.26C9.05,199.6,0,190.5,0,179.29V20.39C0,9.18,9.05.07,20.26,0h59.49c3.51.07,6.34,2.9,6.41,6.42v44.36c0,3.54-2.87,6.41-6.41,6.41-3.54,0-6.41-2.86-6.42-6.4,0,0,0,0,0,0V12.36H20.25c-4.13.07-7.44,3.44-7.44,7.56v159.41c0,4.13,3.31,7.49,7.44,7.56h158.98c4.13-.07,7.44-3.44,7.44-7.56V51.84c0-3.54,2.87-6.41,6.41-6.42,3.54,0,6.42,2.87,6.42,6.42h0v127.45c0,11.21-9.05,20.32-20.26,20.39h0Z" style="fill: #377dff; stroke-width: 0px;"/>
|
|
5
|
-
<path d="M192.8,60.77c-3.66-.07-6.61-3.02-6.68-6.68v-18.23c.09-4.29-3.32-7.83-7.6-7.92-.05,0-.11,0-.16,0h-96.27c-3.69,0-6.69-2.99-6.69-6.69s2.99-6.69,6.69-6.69h96.27c11.69.08,21.13,9.58,21.13,21.27v18.25c0,3.69-2.99,6.68-6.68,6.68,0,0,0,0,0,0h0Z" style="fill: #377dff; stroke-width: 0px;"/>
|
|
6
|
-
<path d="M193.08,58.67H10.15c-3.61-.08-6.48-3.07-6.41-6.68-.08-3.62,2.79-6.61,6.41-6.69h182.93c3.62.08,6.48,3.08,6.41,6.69.07,3.61-2.79,6.6-6.41,6.68Z" style="fill: #377dff; stroke-width: 0px;"/>
|
|
7
|
-
<path d="M137.2,58.67c-3.42.24-6.4-2.32-6.68-5.74v-28.15c.55-3.69,3.99-6.24,7.69-5.69,2.94.44,5.25,2.75,5.69,5.69v28.15c-.28,3.42-3.26,5.98-6.68,5.74h0Z" style="fill: #377dff; stroke-width: 0px;"/>
|
|
8
|
-
<path d="M99.78,100.48l-12.83-22.22h-18.39l31.22,54.05,31.22-54.05h-18.39l-12.83,22.22Z" style="fill: #00d797; stroke-width: 0px;"/>
|
|
9
|
-
<path d="M135.28,78.26l-35.49,61.43-35.44-61.43h-20.11l55.55,96.2,55.55-96.2h-20.05Z" style="fill: #306fff; stroke-width: 0px;"/>
|
|
10
|
-
</svg>
|
package/dist/iframe-bridge.html
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="zh-CN">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
-
<title>Iframe Bridge 测试页面</title>
|
|
7
|
-
<style>
|
|
8
|
-
body {
|
|
9
|
-
font-family: system-ui, sans-serif;
|
|
10
|
-
padding: 20px;
|
|
11
|
-
background: #f0f2f5;
|
|
12
|
-
}
|
|
13
|
-
.card {
|
|
14
|
-
background: white;
|
|
15
|
-
padding: 20px;
|
|
16
|
-
border-radius: 8px;
|
|
17
|
-
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
|
18
|
-
}
|
|
19
|
-
button {
|
|
20
|
-
padding: 10px 20px;
|
|
21
|
-
background: #67c23a;
|
|
22
|
-
color: white;
|
|
23
|
-
border: none;
|
|
24
|
-
border-radius: 4px;
|
|
25
|
-
cursor: pointer;
|
|
26
|
-
}
|
|
27
|
-
.status {
|
|
28
|
-
margin-top: 10px;
|
|
29
|
-
color: #606266;
|
|
30
|
-
}
|
|
31
|
-
</style>
|
|
32
|
-
</head>
|
|
33
|
-
<body>
|
|
34
|
-
<div class="card">
|
|
35
|
-
<h2>此页面演示使用封装好的 Bridge 对接方式</h2>
|
|
36
|
-
<p>页面加载时间: <span id="time"></span></p>
|
|
37
|
-
<p>
|
|
38
|
-
使用 <code>iframeBridge.ts</code> 中提供的 <code>postOpenTab</code> 和
|
|
39
|
-
<code>onRefreshRequest</code>。
|
|
40
|
-
</p>
|
|
41
|
-
|
|
42
|
-
<div style="margin: 20px 0">
|
|
43
|
-
<button id="btnOpen">使用 Bridge 方法请求打开新 Tab</button>
|
|
44
|
-
</div>
|
|
45
|
-
<div class="status" id="status"></div>
|
|
46
|
-
</div>
|
|
47
|
-
|
|
48
|
-
<!-- 注意:在实际项目中,如果是原生 HTML 需要引入编译后的 Bridge 脚本,或者直接使用 type="module" -->
|
|
49
|
-
<script type="module">
|
|
50
|
-
// 这里的导入路径仅为本地调试示意,实际推荐将 Bridge 打包为公共库或 CDN 引用
|
|
51
|
-
import { postOpenTab, onRefreshRequest } from './src/lib/utils/iframeBridge.ts'
|
|
52
|
-
|
|
53
|
-
const timeEl = document.getElementById('time')
|
|
54
|
-
timeEl.innerText = new Date().toLocaleTimeString()
|
|
55
|
-
|
|
56
|
-
// 1. 使用 Bridge 封装的方法打开 Tab
|
|
57
|
-
document.getElementById('btnOpen').onclick = () => {
|
|
58
|
-
postOpenTab({
|
|
59
|
-
id: 'bridge_triggered_tab',
|
|
60
|
-
title: 'Bridge 弹出的新页签',
|
|
61
|
-
path: '/demo/page1',
|
|
62
|
-
closable: true
|
|
63
|
-
})
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// 2. 使用 Bridge 封装的方法监听刷新请求
|
|
67
|
-
onRefreshRequest(() => {
|
|
68
|
-
console.log('[Bridge Child] Received refresh request from parent')
|
|
69
|
-
const statusEl = document.getElementById('status')
|
|
70
|
-
statusEl.innerText = '正在执行刷新 logic...'
|
|
71
|
-
setTimeout(() => {
|
|
72
|
-
location.reload()
|
|
73
|
-
}, 500)
|
|
74
|
-
})
|
|
75
|
-
</script>
|
|
76
|
-
</body>
|
|
77
|
-
</html>
|
package/dist/iframe-child.html
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="zh-CN">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
-
<title>Iframe 子页面测试</title>
|
|
7
|
-
<style>
|
|
8
|
-
body {
|
|
9
|
-
font-family: system-ui, sans-serif;
|
|
10
|
-
padding: 20px;
|
|
11
|
-
background: #f0f9eb;
|
|
12
|
-
}
|
|
13
|
-
.card {
|
|
14
|
-
background: white;
|
|
15
|
-
padding: 20px;
|
|
16
|
-
border-radius: 8px;
|
|
17
|
-
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
|
18
|
-
}
|
|
19
|
-
button {
|
|
20
|
-
padding: 10px 20px;
|
|
21
|
-
background: #409eff;
|
|
22
|
-
color: white;
|
|
23
|
-
border: none;
|
|
24
|
-
border-radius: 4px;
|
|
25
|
-
cursor: pointer;
|
|
26
|
-
}
|
|
27
|
-
input {
|
|
28
|
-
padding: 8px;
|
|
29
|
-
width: 200px;
|
|
30
|
-
margin-right: 10px;
|
|
31
|
-
border: 1px solid #dcdfe6;
|
|
32
|
-
border-radius: 4px;
|
|
33
|
-
}
|
|
34
|
-
</style>
|
|
35
|
-
</head>
|
|
36
|
-
<body>
|
|
37
|
-
<div class="card">
|
|
38
|
-
<h2>此页面运行在隔离的 iframe 中</h2>
|
|
39
|
-
<p>页面加载时间: <span id="time"></span></p>
|
|
40
|
-
<p>
|
|
41
|
-
用于测试 iframe 页面的加载、刷新隔离,以及通过 `postMessage` 向外层通信请求打开新 Tab
|
|
42
|
-
的功能。
|
|
43
|
-
</p>
|
|
44
|
-
|
|
45
|
-
<div style="margin: 20px 0">
|
|
46
|
-
<label>隔离缓存测试输入框:</label>
|
|
47
|
-
<input type="text" id="ipt" placeholder="切走该页签是否会销毁内容?" />
|
|
48
|
-
</div>
|
|
49
|
-
|
|
50
|
-
<div style="margin: 20px 0">
|
|
51
|
-
<button onclick="requestNewTab()">向父级发送 postMessage 请求在外部打开新 Tab</button>
|
|
52
|
-
</div>
|
|
53
|
-
</div>
|
|
54
|
-
|
|
55
|
-
<script>
|
|
56
|
-
const timeEl = document.getElementById('time')
|
|
57
|
-
const updateTime = () => {
|
|
58
|
-
timeEl.innerText = new Date().toLocaleTimeString()
|
|
59
|
-
}
|
|
60
|
-
updateTime()
|
|
61
|
-
|
|
62
|
-
// 1. 原生对接:向父窗口请求打开新标签
|
|
63
|
-
function requestNewTab() {
|
|
64
|
-
window.parent.postMessage(
|
|
65
|
-
{
|
|
66
|
-
type: 'vue-stack-tabs:openTab', // 推荐使用该标准类型,StackTabs 已支持兼容 'openTab'
|
|
67
|
-
payload: {
|
|
68
|
-
id: 'native_triggered_tab',
|
|
69
|
-
title: '原生弹出的新页签',
|
|
70
|
-
path: '/demo/page2',
|
|
71
|
-
closable: true
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
'*'
|
|
75
|
-
)
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// 2. 原生对接:监听父窗口发送的刷新请求
|
|
79
|
-
window.addEventListener('message', (ev) => {
|
|
80
|
-
if (ev.data?.type === 'vue-stack-tabs:refresh') {
|
|
81
|
-
console.log('[Native Child] Received refresh request from parent')
|
|
82
|
-
// 执行刷新逻辑(例如重新加载页面或局部数据更新)
|
|
83
|
-
location.reload()
|
|
84
|
-
}
|
|
85
|
-
})
|
|
86
|
-
</script>
|
|
87
|
-
</body>
|
|
88
|
-
</html>
|
package/dist/nuxt/favicon.svg
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<svg id="_图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 199.49 199.67">
|
|
3
|
-
<path d="M79.24,19.73h108.91v29.7h-108.91v-29.7Z" style="fill: #79a7ff; stroke-width: 0px;"/>
|
|
4
|
-
<path d="M179.24,199.67H20.26C9.05,199.6,0,190.5,0,179.29V20.39C0,9.18,9.05.07,20.26,0h59.49c3.51.07,6.34,2.9,6.41,6.42v44.36c0,3.54-2.87,6.41-6.41,6.41-3.54,0-6.41-2.86-6.42-6.4,0,0,0,0,0,0V12.36H20.25c-4.13.07-7.44,3.44-7.44,7.56v159.41c0,4.13,3.31,7.49,7.44,7.56h158.98c4.13-.07,7.44-3.44,7.44-7.56V51.84c0-3.54,2.87-6.41,6.41-6.42,3.54,0,6.42,2.87,6.42,6.42h0v127.45c0,11.21-9.05,20.32-20.26,20.39h0Z" style="fill: #377dff; stroke-width: 0px;"/>
|
|
5
|
-
<path d="M192.8,60.77c-3.66-.07-6.61-3.02-6.68-6.68v-18.23c.09-4.29-3.32-7.83-7.6-7.92-.05,0-.11,0-.16,0h-96.27c-3.69,0-6.69-2.99-6.69-6.69s2.99-6.69,6.69-6.69h96.27c11.69.08,21.13,9.58,21.13,21.27v18.25c0,3.69-2.99,6.68-6.68,6.68,0,0,0,0,0,0h0Z" style="fill: #377dff; stroke-width: 0px;"/>
|
|
6
|
-
<path d="M193.08,58.67H10.15c-3.61-.08-6.48-3.07-6.41-6.68-.08-3.62,2.79-6.61,6.41-6.69h182.93c3.62.08,6.48,3.08,6.41,6.69.07,3.61-2.79,6.6-6.41,6.68Z" style="fill: #377dff; stroke-width: 0px;"/>
|
|
7
|
-
<path d="M137.2,58.67c-3.42.24-6.4-2.32-6.68-5.74v-28.15c.55-3.69,3.99-6.24,7.69-5.69,2.94.44,5.25,2.75,5.69,5.69v28.15c-.28,3.42-3.26,5.98-6.68,5.74h0Z" style="fill: #377dff; stroke-width: 0px;"/>
|
|
8
|
-
<path d="M99.78,100.48l-12.83-22.22h-18.39l31.22,54.05,31.22-54.05h-18.39l-12.83,22.22Z" style="fill: #00d797; stroke-width: 0px;"/>
|
|
9
|
-
<path d="M135.28,78.26l-35.49,61.43-35.44-61.43h-20.11l55.55,96.2,55.55-96.2h-20.05Z" style="fill: #306fff; stroke-width: 0px;"/>
|
|
10
|
-
</svg>
|