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
package/package.json
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vue-stack-tabs",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"packageManager": "pnpm@11.5.0+sha512.dbfcc4f81cf48597afd4bc391ffdf12c11f1a9fb83a395bfa6b0a2d9cc2fd8ffebafdb1ccbd529632153f793904c2615b7f09fe1a345473fd1c35845172a8eb1",
|
|
3
|
+
"version": "1.4.1",
|
|
5
4
|
"private": false,
|
|
6
5
|
"type": "module",
|
|
6
|
+
"description": "Vue 3 multi-tab manager built on Vue Router with iframe-like tab isolation: per-tab component scope, stack-based forward/backward navigation, keep-alive page caching, scroll position memory, i18n and a Nuxt 3/4 module.",
|
|
7
7
|
"files": [
|
|
8
8
|
"src/lib",
|
|
9
9
|
"dist"
|
|
10
10
|
],
|
|
11
|
+
"sideEffects": [
|
|
12
|
+
"**/*.css",
|
|
13
|
+
"**/*.scss"
|
|
14
|
+
],
|
|
11
15
|
"exports": {
|
|
12
16
|
".": {
|
|
13
17
|
"types": "./dist/index.d.ts",
|
|
@@ -25,7 +29,9 @@
|
|
|
25
29
|
"./dist/vue-stack-tabs.css": "./dist/vue-stack-tabs.css"
|
|
26
30
|
},
|
|
27
31
|
"peerDependencies": {
|
|
28
|
-
"nuxt": "^3.0.0 || ^4.0.0"
|
|
32
|
+
"nuxt": "^3.0.0 || ^4.0.0",
|
|
33
|
+
"vue": "^3.5.43",
|
|
34
|
+
"vue-router": "^5.4.0"
|
|
29
35
|
},
|
|
30
36
|
"peerDependenciesMeta": {
|
|
31
37
|
"nuxt": {
|
|
@@ -34,6 +40,7 @@
|
|
|
34
40
|
},
|
|
35
41
|
"main": "./dist/vue-stack-tabs.es.js",
|
|
36
42
|
"module": "./dist/vue-stack-tabs.es.js",
|
|
43
|
+
"types": "./dist/index.d.ts",
|
|
37
44
|
"typings": "./dist/index.d.ts",
|
|
38
45
|
"repository": {
|
|
39
46
|
"type": "git",
|
|
@@ -41,20 +48,39 @@
|
|
|
41
48
|
},
|
|
42
49
|
"keywords": [
|
|
43
50
|
"vue",
|
|
44
|
-
"
|
|
51
|
+
"vue3",
|
|
52
|
+
"vue-router",
|
|
53
|
+
"router",
|
|
45
54
|
"tabs",
|
|
46
|
-
"
|
|
55
|
+
"tab",
|
|
56
|
+
"tab-panel",
|
|
57
|
+
"multi-tab",
|
|
58
|
+
"tabs-router",
|
|
59
|
+
"keep-alive",
|
|
60
|
+
"cache",
|
|
61
|
+
"iframe",
|
|
62
|
+
"nuxt",
|
|
63
|
+
"nuxt3",
|
|
64
|
+
"nuxt-module",
|
|
65
|
+
"navigation",
|
|
66
|
+
"vue-component",
|
|
67
|
+
"typescript"
|
|
47
68
|
],
|
|
48
69
|
"author": "tantan",
|
|
49
|
-
"license": "Apache
|
|
70
|
+
"license": "Apache-2.0",
|
|
50
71
|
"bugs": {
|
|
51
72
|
"url": "https://github.com/jktantan/vue-stack-tabs/issues"
|
|
52
73
|
},
|
|
53
|
-
"homepage": "https://github.
|
|
74
|
+
"homepage": "https://jktantan.github.io/vue-stack-tabs/",
|
|
75
|
+
"publishConfig": {
|
|
76
|
+
"access": "public"
|
|
77
|
+
},
|
|
54
78
|
"scripts": {
|
|
55
79
|
"clean:ports": "node -e \"const { execSync } = require('child_process'); try { if (process.platform === 'win32') { execSync('netstat -ano | findstr \":5173\" | findstr \"LISTENING\"').toString().split('\\n').forEach(line => { const pid = line.trim().split(/\\s+/)[4]; if (pid) try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' }); } catch(e) {} }); execSync('netstat -ano | findstr \":5174\" | findstr \"LISTENING\"').toString().split('\\n').forEach(line => { const pid = line.trim().split(/\\s+/)[4]; if (pid) try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' }); } catch(e) {} }); } else { execSync('lsof -ti:5173 | xargs kill -9 || true', { shell: '/bin/bash' }); execSync('lsof -ti:5174 | xargs kill -9 || true', { shell: '/bin/bash' }); } console.log('✅ 端口清理完成'); } catch(e) { console.log('✅ 端口已经空闲或清理完成'); }\"",
|
|
56
|
-
"docs:dev": "
|
|
57
|
-
"docs:
|
|
80
|
+
"docs:dev": "run-p docs:dev:docs docs:dev:demo",
|
|
81
|
+
"docs:dev:docs": "vitepress dev docs",
|
|
82
|
+
"docs:dev:demo": "vite --config vite.config.docs-demo.ts",
|
|
83
|
+
"docs:build": "vitepress build docs && vite build --config vite.config.docs-demo.ts",
|
|
58
84
|
"dev": "vite --host ",
|
|
59
85
|
"build": "run-p type-check \"build-only {@}\" --",
|
|
60
86
|
"preview": "vite preview",
|
|
@@ -64,83 +90,81 @@
|
|
|
64
90
|
"lint:check": "eslint .",
|
|
65
91
|
"format": "prettier --write .",
|
|
66
92
|
"format:check": "prettier --check .",
|
|
67
|
-
"lib:build": "vite --config vite.config.lib.ts build && vite --config vite.config.iframe-bridge.ts build && vite --config vite.config.nuxt.ts build && node -e \"const fs=require('fs');fs.copyFileSync('dist/vue-stack-tabs.css','dist/style.css');fs.
|
|
93
|
+
"lib:build": "vite --config vite.config.lib.ts build && vite --config vite.config.iframe-bridge.ts build && vite --config vite.config.nuxt.ts build && node -e \"const fs=require('fs');fs.copyFileSync('dist/vue-stack-tabs.css','dist/style.css');fs.rmSync('dist/src',{recursive:true,force:true});\"",
|
|
68
94
|
"test": "vitest run",
|
|
69
95
|
"test:watch": "vitest",
|
|
70
|
-
"test:coverage": "vitest run --coverage"
|
|
71
|
-
"test:prepack": "node scripts/verify-prepack.mjs",
|
|
72
|
-
"test:packaged": "pnpm run lib:build && node scripts/verify-packaged.mjs"
|
|
96
|
+
"test:coverage": "vitest run --coverage"
|
|
73
97
|
},
|
|
74
98
|
"dependencies": {
|
|
75
99
|
"defu": "^6.1.7",
|
|
76
100
|
"mitt": "^3.0.1",
|
|
101
|
+
"nostics": "^1.3.0",
|
|
77
102
|
"throttle-debounce": "^5.0.2",
|
|
78
|
-
"vue": "^
|
|
79
|
-
"vue-i18n-lite": "^1.0.2",
|
|
80
|
-
"vue-router": "^5.1.0"
|
|
103
|
+
"vue-i18n-lite": "^1.0.2"
|
|
81
104
|
},
|
|
82
105
|
"devDependencies": {
|
|
83
|
-
"@ctrl/tinycolor": "^4.2.
|
|
106
|
+
"@ctrl/tinycolor": "^4.2.1",
|
|
84
107
|
"@element-plus/icons-vue": "^2.3.2",
|
|
85
108
|
"@eslint/js": "^10.0.1",
|
|
86
|
-
"@floating-ui/core": "^1.
|
|
87
|
-
"@floating-ui/dom": "^1.
|
|
88
|
-
"@floating-ui/utils": "^0.2.
|
|
89
|
-
"@mdit/plugin-tasklist": "^
|
|
109
|
+
"@floating-ui/core": "^1.8.0",
|
|
110
|
+
"@floating-ui/dom": "^1.8.0",
|
|
111
|
+
"@floating-ui/utils": "^0.2.12",
|
|
112
|
+
"@mdit/plugin-tasklist": "^1.1.3",
|
|
90
113
|
"@popperjs/core": "^2.11.8",
|
|
91
114
|
"@rushstack/eslint-patch": "^1.16.1",
|
|
92
|
-
"@tsconfig/
|
|
115
|
+
"@tsconfig/node26": "^26.0.1",
|
|
93
116
|
"@types/lodash-es": "^4.17.12",
|
|
94
|
-
"@types/node": "^
|
|
117
|
+
"@types/node": "^26.6.5",
|
|
95
118
|
"@types/throttle-debounce": "^5.0.2",
|
|
96
|
-
"@vitejs/plugin-vue": "^6.0.
|
|
97
|
-
"@vitejs/plugin-vue-jsx": "^5.1.
|
|
98
|
-
"@vitest/coverage-v8": "4.1.
|
|
99
|
-
"@vue/compiler-core": "^3.5.
|
|
100
|
-
"@vue/compiler-dom": "^3.5.
|
|
101
|
-
"@vue/devtools-api": "^8.1
|
|
102
|
-
"@vue/devtools-kit": "^8.1
|
|
103
|
-
"@vue/devtools-shared": "^8.1
|
|
119
|
+
"@vitejs/plugin-vue": "^6.0.10",
|
|
120
|
+
"@vitejs/plugin-vue-jsx": "^5.1.6",
|
|
121
|
+
"@vitest/coverage-v8": "4.1.10",
|
|
122
|
+
"@vue/compiler-core": "^3.5.43",
|
|
123
|
+
"@vue/compiler-dom": "^3.5.43",
|
|
124
|
+
"@vue/devtools-api": "^8.2.1",
|
|
125
|
+
"@vue/devtools-kit": "^8.2.1",
|
|
126
|
+
"@vue/devtools-shared": "^8.2.1",
|
|
104
127
|
"@vue/eslint-config-prettier": "^10.2.0",
|
|
105
|
-
"@vue/eslint-config-typescript": "^14.
|
|
106
|
-
"@vue/reactivity": "^3.5.
|
|
107
|
-
"@vue/runtime-core": "^3.5.
|
|
108
|
-
"@vue/runtime-dom": "^3.5.
|
|
109
|
-
"@vue/shared": "^3.5.
|
|
110
|
-
"@vue/test-utils": "^2.
|
|
128
|
+
"@vue/eslint-config-typescript": "^14.9.0",
|
|
129
|
+
"@vue/reactivity": "^3.5.43",
|
|
130
|
+
"@vue/runtime-core": "^3.5.43",
|
|
131
|
+
"@vue/runtime-dom": "^3.5.43",
|
|
132
|
+
"@vue/shared": "^3.5.43",
|
|
133
|
+
"@vue/test-utils": "^2.5.1",
|
|
111
134
|
"@vue/tsconfig": "^0.9.1",
|
|
112
|
-
"@vueuse/core": "^14.
|
|
113
|
-
"@vueuse/shared": "^14.
|
|
135
|
+
"@vueuse/core": "^14.4.0",
|
|
136
|
+
"@vueuse/shared": "^14.4.0",
|
|
114
137
|
"async-validator": "^4.2.5",
|
|
115
|
-
"birpc": "^4.
|
|
116
|
-
"dayjs": "^1.11.
|
|
117
|
-
"element-plus": "^2.14.
|
|
118
|
-
"eslint": "^10.
|
|
138
|
+
"birpc": "^4.2.0",
|
|
139
|
+
"dayjs": "^1.11.23",
|
|
140
|
+
"element-plus": "^2.14.7",
|
|
141
|
+
"eslint": "^10.12.0",
|
|
119
142
|
"eslint-config-prettier": "^10.1.8",
|
|
120
|
-
"eslint-plugin-vue": "^10.
|
|
121
|
-
"globals": "^17.
|
|
122
|
-
"happy-dom": "^20.
|
|
123
|
-
"hookable": "^6.1.
|
|
143
|
+
"eslint-plugin-vue": "^10.11.1",
|
|
144
|
+
"globals": "^17.13.0",
|
|
145
|
+
"happy-dom": "^20.14.6",
|
|
146
|
+
"hookable": "^6.1.2",
|
|
124
147
|
"lodash-es": "^4.18.1",
|
|
125
148
|
"lodash-unified": "^1.0.3",
|
|
126
149
|
"memoize-one": "^6.0.0",
|
|
127
150
|
"normalize-wheel-es": "^1.2.0",
|
|
128
151
|
"normalize.css": "^8.0.1",
|
|
129
|
-
"npm-run-all2": "^9.0.
|
|
152
|
+
"npm-run-all2": "^9.0.3",
|
|
130
153
|
"perfect-debounce": "^2.1.0",
|
|
131
|
-
"prettier": "^3.
|
|
132
|
-
"sass": "^1.
|
|
133
|
-
"
|
|
134
|
-
"
|
|
135
|
-
"typescript": "
|
|
136
|
-
"
|
|
137
|
-
"unplugin-auto-import": "^21.0.0",
|
|
154
|
+
"prettier": "^3.9.10",
|
|
155
|
+
"sass-embedded": "^1.105.1",
|
|
156
|
+
"terser": "^5.51.2",
|
|
157
|
+
"typescript": "^6.0.3",
|
|
158
|
+
"typescript-eslint": "^8.71.1",
|
|
159
|
+
"unplugin-auto-import": "^21.1.0",
|
|
138
160
|
"unplugin-vue-components": "^32.1.0",
|
|
139
|
-
"vite": "^8.
|
|
140
|
-
"vite-plugin-dts": "5.0.
|
|
161
|
+
"vite": "^8.3.4",
|
|
162
|
+
"vite-plugin-dts": "5.0.3",
|
|
141
163
|
"vite-plugin-package-version": "^1.1.0",
|
|
142
164
|
"vitepress": "^1.6.4",
|
|
143
|
-
"vitest": "^4.1.
|
|
144
|
-
"vue
|
|
165
|
+
"vitest": "^4.1.11",
|
|
166
|
+
"vue": "^3.5.43",
|
|
167
|
+
"vue-router": "^5.4.0",
|
|
168
|
+
"vue-tsc": "^3.3.13"
|
|
145
169
|
}
|
|
146
170
|
}
|
package/src/lib/StackTabs.vue
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
watch
|
|
23
23
|
} from 'vue'
|
|
24
24
|
import type { TransitionProps } from 'vue'
|
|
25
|
-
import { getMaxZIndex } from './utils/scrollUtils'
|
|
25
|
+
import { getMaxZIndex, invalidateZIndexCache } from './utils/scrollUtils'
|
|
26
26
|
import { isAllowedTabUrl } from './utils/urlParser'
|
|
27
27
|
import { applyStackTabsLocale } from './i18n/stackTabsLocale'
|
|
28
28
|
import { type ITabData, TabScrollMode } from './model/TabModel'
|
|
@@ -96,6 +96,17 @@ const props = withDefaults(
|
|
|
96
96
|
iframeLoadTimeout: 15000
|
|
97
97
|
}
|
|
98
98
|
)
|
|
99
|
+
|
|
100
|
+
// iframePath 必填:用于 iframe 占位页(StackTabs 把 iframe 标签路由到这个 path 渲染)。
|
|
101
|
+
// 这里在开发期给出明确错误,避免空字符串导致 router.push({ path: '' }) 跳到首页的隐式行为。
|
|
102
|
+
if (typeof props.iframePath !== 'string' || props.iframePath.trim() === '') {
|
|
103
|
+
const message = '[vue-stack-tabs] <VueStackTabs> requires the "iframePath" prop (e.g. "/iframe").'
|
|
104
|
+
if (!import.meta.env.PROD) {
|
|
105
|
+
throw new Error(message)
|
|
106
|
+
}
|
|
107
|
+
console.error(message)
|
|
108
|
+
}
|
|
109
|
+
|
|
99
110
|
const { changeLocale, t } = useI18n()
|
|
100
111
|
const runtimeContext =
|
|
101
112
|
inject(stackTabsContextKey, null) ??
|
|
@@ -120,10 +131,12 @@ if (isRuntimeContextOwner) {
|
|
|
120
131
|
provide(maximumKey, maximum)
|
|
121
132
|
}
|
|
122
133
|
watch(maximum, (isMaximum) => {
|
|
134
|
+
// 最大化切换会显著改变 body 下元素层叠顺序,先失效缓存再读取避免过期结果。
|
|
135
|
+
invalidateZIndexCache()
|
|
123
136
|
maximumZIndex.value = isMaximum ? getMaxZIndex('body *:not(.stack-tab,.stack-tab *)') : undefined
|
|
124
137
|
})
|
|
125
138
|
const panelApi = isRuntimeContextOwner
|
|
126
|
-
? useTabPanel()
|
|
139
|
+
? useTabPanel(runtimeContext)
|
|
127
140
|
: {
|
|
128
141
|
tabs: runtimeContext.tabs,
|
|
129
142
|
iframeRefreshKeys: runtimeContext.iframeRefreshKeys,
|
|
@@ -143,7 +156,7 @@ const {
|
|
|
143
156
|
setSessionPrefix
|
|
144
157
|
} = panelApi
|
|
145
158
|
const tabActions = isRuntimeContextOwner
|
|
146
|
-
? useTabActions()
|
|
159
|
+
? useTabActions(runtimeContext)
|
|
147
160
|
: {
|
|
148
161
|
setIFramePath: () => undefined,
|
|
149
162
|
openTab: () => Promise.resolve(undefined),
|
|
@@ -63,4 +63,6 @@ $stack-tab-shadow-left: -5px 0 5px -5px rgb(0 0 0 / 60%); //#ccc;
|
|
|
63
63
|
$stack-tab-shadow-right: 5px 0 5px -5px rgb(0 0 0 / 60%); //#ccc;
|
|
64
64
|
$stack-tab-shadow-right-inset: inset -5px 0 5px -5px rgb(0 0 0 / 60%); //#ccc;
|
|
65
65
|
$stack-tab-shadow-left-inset: inset 5px 0 5px -5px rgb(0 0 0 / 60%); //#ccc;
|
|
66
|
+
$stack-tab-shadow-top-inset: inset 0 5px 5px -5px rgb(0 0 0 / 60%); //#ccc;
|
|
67
|
+
$stack-tab-shadow-bottom-inset: inset 0 -5px 5px -5px rgb(0 0 0 / 60%); //#ccc;
|
|
66
68
|
$stack-tab-shadow-context-menu: 5px 5px 5px rgb(0 0 0 / 10%);
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
position: relative;
|
|
165
165
|
display: inline-flex;
|
|
166
166
|
height: 100%;
|
|
167
|
-
padding: 0;
|
|
167
|
+
padding: 0 16px 0 0;
|
|
168
168
|
margin: 0;
|
|
169
169
|
list-style: none;
|
|
170
170
|
flex-wrap: nowrap;
|
|
@@ -197,6 +197,31 @@
|
|
|
197
197
|
border-left: $stack-tab-border;
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
&.is-dragging {
|
|
201
|
+
opacity: 0.55;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
&.is-drop-before::before,
|
|
205
|
+
&.is-drop-after::after {
|
|
206
|
+
position: absolute;
|
|
207
|
+
z-index: 2;
|
|
208
|
+
top: 2px;
|
|
209
|
+
bottom: 2px;
|
|
210
|
+
width: 5px;
|
|
211
|
+
content: '';
|
|
212
|
+
background: $stack-tab-color-primary;
|
|
213
|
+
border-radius: 2px;
|
|
214
|
+
box-shadow: 0 0 6px rgba($stack-tab-color-primary, 0.8);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
&.is-drop-before::before {
|
|
218
|
+
left: -3px;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
&.is-drop-after::after {
|
|
222
|
+
right: -3px;
|
|
223
|
+
}
|
|
224
|
+
|
|
200
225
|
/*
|
|
201
226
|
激活右键菜单的页签
|
|
202
227
|
*/
|
|
@@ -418,7 +443,6 @@
|
|
|
418
443
|
border-radius: 10px 0;
|
|
419
444
|
box-shadow: $stack-tab-shadow-context-menu;
|
|
420
445
|
transform-origin: left top;
|
|
421
|
-
transition: all 0.25s ease-in;
|
|
422
446
|
|
|
423
447
|
&-item {
|
|
424
448
|
display: flex;
|
|
@@ -439,7 +463,10 @@
|
|
|
439
463
|
background: transparent;
|
|
440
464
|
border: 0;
|
|
441
465
|
border-radius: 10px 0;
|
|
442
|
-
|
|
466
|
+
// 仅过渡悬停态实际会改变的颜色,避免触发菜单布局与合成层的无关动画。
|
|
467
|
+
transition:
|
|
468
|
+
color 0.2s ease-in-out,
|
|
469
|
+
background-color 0.2s ease-in-out;
|
|
443
470
|
user-select: none;
|
|
444
471
|
|
|
445
472
|
&:hover,
|
|
@@ -472,7 +499,7 @@
|
|
|
472
499
|
&-icon {
|
|
473
500
|
width: $stack-tab-font-size;
|
|
474
501
|
height: $stack-tab-font-size;
|
|
475
|
-
transition:
|
|
502
|
+
transition: background-color 0.2s ease-in-out;
|
|
476
503
|
|
|
477
504
|
&.svg-mask {
|
|
478
505
|
width: $stack-tab-font-size;
|
|
@@ -507,4 +534,16 @@
|
|
|
507
534
|
@include mixins.insetShadow();
|
|
508
535
|
box-shadow: $stack-tab-shadow-left-inset, $stack-tab-shadow-right-inset;
|
|
509
536
|
}
|
|
537
|
+
&-top::after {
|
|
538
|
+
@include mixins.insetShadow();
|
|
539
|
+
box-shadow: $stack-tab-shadow-top-inset;
|
|
540
|
+
}
|
|
541
|
+
&-bottom::after {
|
|
542
|
+
@include mixins.insetShadow();
|
|
543
|
+
box-shadow: $stack-tab-shadow-bottom-inset;
|
|
544
|
+
}
|
|
545
|
+
&-vertical::after {
|
|
546
|
+
@include mixins.insetShadow();
|
|
547
|
+
box-shadow: $stack-tab-shadow-top-inset, $stack-tab-shadow-bottom-inset;
|
|
548
|
+
}
|
|
510
549
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
:style="{
|
|
14
14
|
left: `${menuPosition.left}px`,
|
|
15
15
|
top: `${menuPosition.top}px`,
|
|
16
|
-
'z-index':
|
|
16
|
+
'z-index': menuZIndex()
|
|
17
17
|
}"
|
|
18
18
|
@keydown="handleMenuKeydown"
|
|
19
19
|
>
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
import { ref, onMounted, reactive, nextTick } from 'vue'
|
|
85
85
|
import type { ITabItem, IContextMenu } from '../../model/TabModel'
|
|
86
86
|
import { useI18n } from 'vue-i18n-lite'
|
|
87
|
-
import { getMaxZIndex } from '../../utils/scrollUtils'
|
|
87
|
+
import { getMaxZIndex, invalidateZIndexCache } from '../../utils/scrollUtils'
|
|
88
88
|
import useTabActions from '../../hooks/useTabActions'
|
|
89
89
|
import useTabPanel from '../../hooks/useTabPanel'
|
|
90
90
|
import ContextMenuItem from './ContextMenuItem.vue'
|
|
@@ -115,6 +115,14 @@ const menuPosition = reactive({ left: props.left, top: props.top })
|
|
|
115
115
|
const { closeTab, closeAllTabs, refreshTab, refreshAllTabs, openInNewWindow } = useTabActions()
|
|
116
116
|
const { removeLeftTabs, removeRightTabs, removeOtherTabs } = useTabPanel()
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* 菜单挂载时 z-index 必须重新计算(DOM 层叠已变化),先用 invalidateZIndexCache 失效再读取。
|
|
120
|
+
*/
|
|
121
|
+
const menuZIndex = () => {
|
|
122
|
+
invalidateZIndexCache('.stack-tab,.stack-tab *')
|
|
123
|
+
return getMaxZIndex('.stack-tab,.stack-tab *')
|
|
124
|
+
}
|
|
125
|
+
|
|
118
126
|
const focusActiveTabFallback = () => {
|
|
119
127
|
const activeTab = document.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')
|
|
120
128
|
activeTab?.focus()
|
|
@@ -193,7 +201,8 @@ const handleMenuKeydown = (event: KeyboardEvent) => {
|
|
|
193
201
|
}
|
|
194
202
|
}
|
|
195
203
|
|
|
196
|
-
const getCustomMenuKey = (item: IContextMenu): string =>
|
|
204
|
+
const getCustomMenuKey = (item: IContextMenu): string =>
|
|
205
|
+
item.key ?? `${item.title}-${item.icon ?? ''}`
|
|
197
206
|
|
|
198
207
|
/** 挂载后修正菜单位置,避免超出右边界 */
|
|
199
208
|
onMounted(() => {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
role="status"
|
|
12
12
|
aria-live="polite"
|
|
13
13
|
:aria-label="t('VueStackTab.loading')"
|
|
14
|
-
:style="{ zIndex:
|
|
14
|
+
:style="{ zIndex: loadingZIndex() }"
|
|
15
15
|
>
|
|
16
16
|
<div class="stack-tab-loading--spin turn" aria-hidden="true" />
|
|
17
17
|
</div>
|
|
@@ -20,7 +20,15 @@
|
|
|
20
20
|
import { ref, onUnmounted } from 'vue'
|
|
21
21
|
import { useI18n } from 'vue-i18n-lite'
|
|
22
22
|
import { TabEventType, useTabEmitter } from '../hooks/useTabEventBus'
|
|
23
|
-
import { getMaxZIndex } from '../utils/scrollUtils'
|
|
23
|
+
import { getMaxZIndex, invalidateZIndexCache } from '../utils/scrollUtils'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* loading 遮罩每次显隐都会改变 .cache-page-wrapper 子树层叠,先失效缓存再读取避免旧值。
|
|
27
|
+
*/
|
|
28
|
+
const loadingZIndex = () => {
|
|
29
|
+
invalidateZIndexCache('.cache-page-wrapper *')
|
|
30
|
+
return getMaxZIndex('.cache-page-wrapper *')
|
|
31
|
+
}
|
|
24
32
|
const props = defineProps<{
|
|
25
33
|
/** 当前标签 id,用于过滤 PAGE_LOADING 事件 */
|
|
26
34
|
tabId: string
|
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
- 不负责 transition、keep-alive、TabHeader、iframe 或 session 初始化
|
|
12
12
|
-->
|
|
13
13
|
<script lang="ts" setup>
|
|
14
|
-
import {
|
|
14
|
+
import { shallowRef, watch } from 'vue'
|
|
15
15
|
import type { DefineComponent, VNode } from 'vue'
|
|
16
16
|
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
|
17
17
|
import useTabPanel from '../../hooks/useTabPanel'
|
|
18
|
+
import { getEmptyPlaceholderComponent } from './emptyPlaceholder'
|
|
18
19
|
|
|
19
20
|
interface StackCacheRendererProps {
|
|
20
21
|
route: RouteLocationNormalizedLoaded
|
|
@@ -37,14 +38,7 @@ defineSlots<{
|
|
|
37
38
|
|
|
38
39
|
const { refreshKey, activeCacheKey, activePageRefreshVersion, addPage } = useTabPanel()
|
|
39
40
|
|
|
40
|
-
const
|
|
41
|
-
name: 'StackCacheRendererEmpty',
|
|
42
|
-
setup() {
|
|
43
|
-
return () => null
|
|
44
|
-
}
|
|
45
|
-
}) as DefineComponent
|
|
46
|
-
|
|
47
|
-
const wrappedComponent = shallowRef<DefineComponent>(EmptyRendererComponent)
|
|
41
|
+
const wrappedComponent = shallowRef<DefineComponent>(getEmptyPlaceholderComponent())
|
|
48
42
|
|
|
49
43
|
const getComponentIdentity = (component?: VNode | null): unknown => {
|
|
50
44
|
return component?.type ?? component
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共享的 Empty 占位组件。
|
|
3
|
+
*
|
|
4
|
+
* useTabPanel 在「empty」/「recovered」分支会返回一个不渲染任何内容的占位组件。
|
|
5
|
+
* 原先在 pageComponentFactory.tsx 和 StackCacheRenderer.vue 各自定义了一份,
|
|
6
|
+
* 这里抽出唯一来源,避免两份略有差异的副本带来维护负担。
|
|
7
|
+
*/
|
|
8
|
+
import { defineComponent } from 'vue'
|
|
9
|
+
import type { DefineComponent } from 'vue'
|
|
10
|
+
|
|
11
|
+
export const EmptyPlaceholderComponent = defineComponent({
|
|
12
|
+
name: 'StackTabEmptyPlaceholder',
|
|
13
|
+
setup() {
|
|
14
|
+
return () => null
|
|
15
|
+
}
|
|
16
|
+
}) as DefineComponent
|
|
17
|
+
|
|
18
|
+
export const getEmptyPlaceholderComponent = (): DefineComponent => EmptyPlaceholderComponent
|
|
@@ -5,10 +5,18 @@
|
|
|
5
5
|
class="stack-tab__item"
|
|
6
6
|
:class="{
|
|
7
7
|
'is-active': item.active,
|
|
8
|
-
'is-icon': item.closable
|
|
8
|
+
'is-icon': item.closable,
|
|
9
|
+
'is-dragging': dragging,
|
|
10
|
+
'is-drop-before': dropPosition === 'before',
|
|
11
|
+
'is-drop-after': dropPosition === 'after'
|
|
9
12
|
}"
|
|
13
|
+
:draggable="true"
|
|
10
14
|
@click.self="handleActivate(true)"
|
|
11
15
|
@contextmenu="$emit('contextmenu', $event)"
|
|
16
|
+
@dragstart="handleDragStart"
|
|
17
|
+
@dragend="handleDragEnd"
|
|
18
|
+
@dragover.prevent="$emit('dragover', item, $event)"
|
|
19
|
+
@drop.prevent="$emit('drop', item, $event)"
|
|
12
20
|
>
|
|
13
21
|
<button
|
|
14
22
|
:id="getStackTabTabId(item.id)"
|
|
@@ -52,13 +60,23 @@ import type { ITabItem } from '../../model/TabModel'
|
|
|
52
60
|
import { TabEventType, useTabEmitter } from '../../hooks/useTabEventBus'
|
|
53
61
|
import { getStackTabPanelId, getStackTabTabId } from '../../utils/stackTabsA11y'
|
|
54
62
|
import { useI18n } from 'vue-i18n-lite'
|
|
55
|
-
const emit = defineEmits([
|
|
63
|
+
const emit = defineEmits([
|
|
64
|
+
'close',
|
|
65
|
+
'active',
|
|
66
|
+
'contextmenu',
|
|
67
|
+
'dragstart',
|
|
68
|
+
'dragover',
|
|
69
|
+
'drop',
|
|
70
|
+
'dragend'
|
|
71
|
+
])
|
|
56
72
|
const emitter = useTabEmitter()
|
|
57
73
|
const { t } = useI18n()
|
|
58
74
|
/** 当前标签 DOM 元素,用于滚动到可视区域 */
|
|
59
75
|
const tabElementRef = ref<HTMLElement>()
|
|
76
|
+
const dragging = ref(false)
|
|
60
77
|
const props = defineProps<{
|
|
61
78
|
item: ITabItem
|
|
79
|
+
dropPosition?: 'before' | 'after'
|
|
62
80
|
}>()
|
|
63
81
|
|
|
64
82
|
/** TAB_ACTIVE 事件 payload */
|
|
@@ -80,6 +98,16 @@ const title = computed<string>(() => props.item.title || t('VueStackTab.undefine
|
|
|
80
98
|
const handleClose = () => {
|
|
81
99
|
emit('close', props.item)
|
|
82
100
|
}
|
|
101
|
+
const handleDragStart = (event: DragEvent) => {
|
|
102
|
+
dragging.value = true
|
|
103
|
+
event.dataTransfer?.setData('text/plain', props.item.id)
|
|
104
|
+
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
|
|
105
|
+
emit('dragstart', props.item, event)
|
|
106
|
+
}
|
|
107
|
+
const handleDragEnd = (event: DragEvent) => {
|
|
108
|
+
dragging.value = false
|
|
109
|
+
emit('dragend', props.item, event)
|
|
110
|
+
}
|
|
83
111
|
/** 激活当前标签,向父级 emit active 并传递 DOM 引用 */
|
|
84
112
|
const handleActivate = (isRoute = true) => {
|
|
85
113
|
emit('active', props.item, tabElementRef.value, isRoute)
|
|
@@ -17,11 +17,14 @@
|
|
|
17
17
|
v-bind="tabTransitionProps"
|
|
18
18
|
appear
|
|
19
19
|
@keydown="handleTabListKeydown"
|
|
20
|
+
@dragover.prevent="handleTabListDragOver"
|
|
21
|
+
@drop.prevent="handleTabListDrop"
|
|
20
22
|
>
|
|
21
23
|
<tab-header-item
|
|
22
24
|
v-for="(item, index) in tabs"
|
|
23
25
|
:key="item.id"
|
|
24
26
|
:item="item as ITabItem"
|
|
27
|
+
:drop-position="dropIndicator?.tabId === item.id ? dropIndicator.position : undefined"
|
|
25
28
|
@contextmenu="
|
|
26
29
|
(e: MouseEvent) =>
|
|
27
30
|
handleTabContextMenu(e, item as unknown as ITabItem, index, tabs.length)
|
|
@@ -29,6 +32,10 @@
|
|
|
29
32
|
@click.middle.prevent="handleCloseTab(item as ITabItem)"
|
|
30
33
|
@close="handleCloseTab"
|
|
31
34
|
@active="handleActivateTab"
|
|
35
|
+
@dragstart="handleTabDragStart"
|
|
36
|
+
@dragover="handleTabDragOver"
|
|
37
|
+
@drop="handleTabDrop"
|
|
38
|
+
@dragend="handleTabDragEnd"
|
|
32
39
|
/>
|
|
33
40
|
</transition-group>
|
|
34
41
|
</template>
|
|
@@ -109,7 +116,10 @@ const props = withDefaults(
|
|
|
109
116
|
max: 20
|
|
110
117
|
}
|
|
111
118
|
)
|
|
112
|
-
const { closeTab, activeTab, tabs } = useTabActions()
|
|
119
|
+
const { closeTab, activeTab, moveTab, tabs } = useTabActions()
|
|
120
|
+
const draggingTabId = ref<string | null>(null)
|
|
121
|
+
type DropPosition = 'before' | 'after'
|
|
122
|
+
const dropIndicator = ref<{ tabId: string; position: DropPosition } | null>(null)
|
|
113
123
|
|
|
114
124
|
/** 右键菜单是否启用;仅 false 明确禁用,其余旧对象值保持启用以兼容历史写法 */
|
|
115
125
|
const isContextMenuEnabled = computed<boolean>(() => props.contextmenu !== false)
|
|
@@ -137,6 +147,67 @@ const handleTabContextMenu = (e: MouseEvent, item: ITabItem, index: number, max:
|
|
|
137
147
|
showContextMenu(e, item, index, max)
|
|
138
148
|
}
|
|
139
149
|
|
|
150
|
+
/** 原生拖放只在释放鼠标时更新顺序,避免拖动经过标签时反复变更响应式列表。 */
|
|
151
|
+
const handleTabDragStart = (item: ITabItem) => {
|
|
152
|
+
draggingTabId.value = item.id
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const handleTabDragOver = (item: ITabItem, event: DragEvent) => {
|
|
156
|
+
if (!draggingTabId.value || draggingTabId.value === item.id) {
|
|
157
|
+
dropIndicator.value = null
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
const tabElement = event.currentTarget instanceof HTMLElement ? event.currentTarget : null
|
|
161
|
+
if (!tabElement) return
|
|
162
|
+
|
|
163
|
+
const { left, width } = tabElement.getBoundingClientRect()
|
|
164
|
+
dropIndicator.value = {
|
|
165
|
+
tabId: item.id,
|
|
166
|
+
position: event.clientX < left + width / 2 ? 'before' : 'after'
|
|
167
|
+
}
|
|
168
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const handleTabDrop = (target: ITabItem) => {
|
|
172
|
+
const sourceId = draggingTabId.value
|
|
173
|
+
const indicator = dropIndicator.value
|
|
174
|
+
draggingTabId.value = null
|
|
175
|
+
dropIndicator.value = null
|
|
176
|
+
if (!sourceId || sourceId === target.id || indicator?.tabId !== target.id) return
|
|
177
|
+
const sourceIndex = tabs.value.findIndex((tab) => tab.id === sourceId)
|
|
178
|
+
const targetIndex = tabs.value.findIndex((tab) => tab.id === target.id)
|
|
179
|
+
if (sourceIndex < 0 || targetIndex < 0) return
|
|
180
|
+
|
|
181
|
+
let destinationIndex = targetIndex
|
|
182
|
+
if (indicator.position === 'before' && sourceIndex < targetIndex) destinationIndex--
|
|
183
|
+
if (indicator.position === 'after' && sourceIndex > targetIndex) destinationIndex++
|
|
184
|
+
moveTab(sourceId, destinationIndex)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const handleTabDragEnd = () => {
|
|
188
|
+
draggingTabId.value = null
|
|
189
|
+
dropIndicator.value = null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 标签列表末端的留白也是投放区,便于明确地将标签移动到最后。 */
|
|
193
|
+
const isTabItemDragEvent = (event: DragEvent) =>
|
|
194
|
+
event.target instanceof Element && Boolean(event.target.closest('.stack-tab__item'))
|
|
195
|
+
|
|
196
|
+
const handleTabListDragOver = (event: DragEvent) => {
|
|
197
|
+
if (!draggingTabId.value || isTabItemDragEvent(event)) return
|
|
198
|
+
const lastTab = tabs.value[tabs.value.length - 1]
|
|
199
|
+
if (!lastTab || lastTab.id === draggingTabId.value) return
|
|
200
|
+
|
|
201
|
+
dropIndicator.value = { tabId: lastTab.id, position: 'after' }
|
|
202
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const handleTabListDrop = (event: DragEvent) => {
|
|
206
|
+
if (isTabItemDragEvent(event)) return
|
|
207
|
+
const lastTab = tabs.value[tabs.value.length - 1]
|
|
208
|
+
if (lastTab) handleTabDrop(lastTab as ITabItem)
|
|
209
|
+
}
|
|
210
|
+
|
|
140
211
|
/** 标签列表 transition 的 props,支持 string 或对象 */
|
|
141
212
|
const tabTransitionProps = computed<TransitionProps>(() =>
|
|
142
213
|
typeof props.tabTransition === 'string'
|
|
@@ -172,17 +243,18 @@ const handleTabListKeydown = (event: KeyboardEvent) => {
|
|
|
172
243
|
if (count <= 0) return
|
|
173
244
|
|
|
174
245
|
const currentIndex = getActiveTabIndex()
|
|
175
|
-
const safeIndex = currentIndex >= 0 ? currentIndex : 0
|
|
176
246
|
|
|
177
247
|
if (event.key === 'ArrowRight') {
|
|
178
248
|
event.preventDefault()
|
|
179
|
-
|
|
249
|
+
// 无 active 标签时从 -1 起步,按右方向键跳到第 0 个;正常情况环绕到下一个。
|
|
250
|
+
activateTabByIndex(currentIndex === -1 ? 0 : (currentIndex + 1) % count)
|
|
180
251
|
return
|
|
181
252
|
}
|
|
182
253
|
|
|
183
254
|
if (event.key === 'ArrowLeft') {
|
|
184
255
|
event.preventDefault()
|
|
185
|
-
|
|
256
|
+
// 无 active 标签时按左方向键跳到最后一个;正常情况环绕到上一个。
|
|
257
|
+
activateTabByIndex(currentIndex === -1 ? count - 1 : (currentIndex - 1 + count) % count)
|
|
186
258
|
return
|
|
187
259
|
}
|
|
188
260
|
|