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.
Files changed (95) hide show
  1. package/README.en.md +119 -52
  2. package/README.md +103 -42
  3. package/README.ru.md +125 -54
  4. package/dist/{src/lib/StackTabs.vue.d.ts → StackTabs.vue.d.ts} +5 -5
  5. package/dist/{src/lib/components → components}/ContextMenu/index.vue.d.ts +1 -1
  6. package/dist/components/StackKeepAlive/emptyPlaceholder.d.ts +3 -0
  7. package/dist/{src/lib/components → components}/TabHeader/TabHeaderItem.vue.d.ts +9 -0
  8. package/dist/{src/lib/components → components}/TabHeader/index.vue.d.ts +3 -3
  9. package/dist/{src/lib/hooks → hooks}/stackTabsContext.d.ts +5 -2
  10. package/dist/hooks/tabPanel/pageComponentFactory.d.ts +20 -0
  11. package/dist/{src/lib/hooks → hooks}/tabPanel/scroll.d.ts +2 -0
  12. package/dist/hooks/useScrollShadow.d.ts +20 -0
  13. package/dist/{src/lib/hooks → hooks}/useTabActions.d.ts +7 -2
  14. package/dist/{src/lib/hooks → hooks}/useTabEventBus.d.ts +2 -1
  15. package/dist/{src/lib/hooks → hooks}/useTabPanel.d.ts +3 -1
  16. package/dist/iframe-bridge.mjs +1 -1
  17. package/dist/index.d.ts +22 -6
  18. package/dist/{src/lib/model → model}/TabModel.d.ts +21 -0
  19. package/dist/nuxt/module.mjs +10 -6
  20. package/dist/nuxt/runtime/plugin.mjs +1600 -4
  21. package/dist/style.css +1 -1
  22. package/dist/utils/iframeBridge.d.ts +7 -1
  23. package/dist/{src/lib/utils → utils}/scrollUtils.d.ts +7 -1
  24. package/dist/{src/lib/versionLogger.d.ts → versionLogger.d.ts} +1 -1
  25. package/dist/vue-stack-tabs.css +1 -1
  26. package/dist/vue-stack-tabs.es.js +1 -1
  27. package/package.json +64 -40
  28. package/src/lib/StackTabs.vue +16 -3
  29. package/src/lib/assets/style/common/var.scss +2 -0
  30. package/src/lib/assets/style/stackTab.scss +17 -3
  31. package/src/lib/components/ContextMenu/index.vue +12 -3
  32. package/src/lib/components/PageLoading.vue +10 -2
  33. package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +3 -9
  34. package/src/lib/components/StackKeepAlive/emptyPlaceholder.tsx +18 -0
  35. package/src/lib/components/TabHeader/index.vue +4 -3
  36. package/src/lib/hooks/stackTabsContext.ts +23 -18
  37. package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +142 -0
  38. package/src/lib/hooks/tabPanel/refresh.ts +3 -0
  39. package/src/lib/hooks/tabPanel/scroll.ts +110 -7
  40. package/src/lib/hooks/tabPanel/session.ts +29 -3
  41. package/src/lib/hooks/useContextMenu.ts +57 -11
  42. package/src/lib/hooks/useIframeManager.ts +18 -2
  43. package/src/lib/hooks/useScrollShadow.ts +124 -0
  44. package/src/lib/hooks/useTabActions.ts +18 -8
  45. package/src/lib/hooks/useTabEventBus.ts +5 -3
  46. package/src/lib/hooks/useTabPanel.tsx +108 -180
  47. package/src/lib/hooks/useTabRouter.ts +11 -2
  48. package/src/lib/i18n/index.ts +3 -3
  49. package/src/lib/model/TabModel.ts +40 -1
  50. package/src/lib/nuxt/module.ts +10 -3
  51. package/src/lib/nuxt/runtime/plugin.ts +13 -2
  52. package/src/lib/utils/iframeBridge.ts +9 -1
  53. package/src/lib/utils/scrollUtils.ts +35 -2
  54. package/src/lib/utils/tabInfoEncoder.ts +21 -8
  55. package/src/lib/utils/urlParser.ts +3 -1
  56. package/src/lib/versionLogger.ts +6 -2
  57. package/dist/favicon.svg +0 -10
  58. package/dist/iframe-bridge.html +0 -77
  59. package/dist/iframe-child.html +0 -88
  60. package/dist/nuxt/favicon.svg +0 -10
  61. package/dist/nuxt/iframe-bridge.html +0 -77
  62. package/dist/nuxt/iframe-child.html +0 -88
  63. package/dist/nuxt/plugin.mjs +0 -9
  64. package/dist/src/lib/iframe-bridge.d.ts +0 -8
  65. package/dist/src/lib/index.d.ts +0 -22
  66. package/dist/src/lib/nuxt/module.d.ts +0 -6
  67. package/dist/src/lib/nuxt/runtime/plugin.client.d.ts +0 -2
  68. package/dist/src/lib/nuxt/runtime/plugin.d.ts +0 -2
  69. package/dist/src/lib/utils/iframeBridge.d.ts +0 -39
  70. package/src/lib/nuxt/runtime/plugin.client.ts +0 -10
  71. /package/dist/{src/lib/components → components}/ContextMenu/ContextMenuItem.vue.d.ts +0 -0
  72. /package/dist/{src/lib/components → components}/PageLoading.vue.d.ts +0 -0
  73. /package/dist/{src/lib/components → components}/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -0
  74. /package/dist/{src/lib/components → components}/StackKeepAlive/StackKeepAlive.vue.d.ts +0 -0
  75. /package/dist/{src/lib/components → components}/TabHeader/TabHeaderButton.vue.d.ts +0 -0
  76. /package/dist/{src/lib/components → components}/TabHeader/TabHeaderScroll.vue.d.ts +0 -0
  77. /package/dist/{src/lib/hooks → hooks}/tabPanel/evict.d.ts +0 -0
  78. /package/dist/{src/lib/hooks → hooks}/tabPanel/navigationTransaction.d.ts +0 -0
  79. /package/dist/{src/lib/hooks → hooks}/tabPanel/refresh.d.ts +0 -0
  80. /package/dist/{src/lib/hooks → hooks}/tabPanel/session.d.ts +0 -0
  81. /package/dist/{src/lib/hooks → hooks}/tabPanel/state.d.ts +0 -0
  82. /package/dist/{src/lib/hooks → hooks}/useContextMenu.d.ts +0 -0
  83. /package/dist/{src/lib/hooks → hooks}/useIframeManager.d.ts +0 -0
  84. /package/dist/{src/lib/hooks → hooks}/useTabLoading.d.ts +0 -0
  85. /package/dist/{src/lib/hooks → hooks}/useTabRouter.d.ts +0 -0
  86. /package/dist/{src/lib/i18n → i18n}/index.d.ts +0 -0
  87. /package/dist/{src/lib/i18n → i18n}/lang/en.d.ts +0 -0
  88. /package/dist/{src/lib/i18n → i18n}/lang/zh-CN.d.ts +0 -0
  89. /package/dist/{src/lib/i18n → i18n}/stackTabsLocale.d.ts +0 -0
  90. /package/dist/{src/lib/iframe.vue.d.ts → iframe.vue.d.ts} +0 -0
  91. /package/dist/{src/lib/utils → utils}/stackTabsA11y.d.ts +0 -0
  92. /package/dist/{src/lib/utils → utils}/stackTabsMessage.d.ts +0 -0
  93. /package/dist/{src/lib/utils → utils}/tabInfoEncoder.d.ts +0 -0
  94. /package/dist/{src/lib/utils → utils}/typeGuards.d.ts +0 -0
  95. /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.3.1",
4
- "packageManager": "pnpm@11.21.0+sha512.521705bce689924eac72f5a3587122f362689ef6571e55ba80076fd637c11132ecffada26fad4ea79c485bfddbfd3d5a2a5b05805a77e893de71ec8a6cca3bb1",
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
- "vue router",
51
+ "vue3",
52
+ "vue-router",
53
+ "router",
45
54
  "tabs",
46
- "nuxt"
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 License, Version 2.0",
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.com/jktantan/vue-stack-tabs.git",
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": "vitepress dev docs",
57
- "docs:build": "vitepress build docs && vite build --config vite.config.docs-demo.ts && node -e \"const fs=require('fs');const p='docs/.vitepress/dist/demo';fs.mkdirSync(p+'/demo',{recursive:true});fs.copyFileSync(p+'/index.html',p+'/demo/index.html');\"",
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,64 +90,60 @@
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.copyFileSync('dist/src/lib/iframe-bridge.d.ts','dist/iframe-bridge.d.ts');fs.mkdirSync('dist/utils',{recursive:true});fs.copyFileSync('dist/src/lib/utils/iframeBridge.d.ts','dist/utils/iframeBridge.d.ts');fs.mkdirSync('dist/nuxt',{recursive:true});fs.copyFileSync('dist/src/lib/nuxt/module.d.ts','dist/nuxt/module.d.ts');fs.mkdirSync('dist/nuxt/runtime',{recursive:true});fs.copyFileSync('dist/src/lib/nuxt/runtime/plugin.d.ts','dist/nuxt/runtime/plugin.d.ts');fs.copyFileSync('dist/nuxt/plugin.mjs','dist/nuxt/runtime/plugin.mjs')\"",
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",
77
- "nostics": "^1.2.0",
101
+ "nostics": "^1.3.0",
78
102
  "throttle-debounce": "^5.0.2",
79
- "vue": "^3.5.42",
80
- "vue-i18n-lite": "^1.0.2",
81
- "vue-router": "^5.3.0"
103
+ "vue-i18n-lite": "^1.0.2"
82
104
  },
83
105
  "devDependencies": {
84
- "@ctrl/tinycolor": "^4.2.0",
106
+ "@ctrl/tinycolor": "^4.2.1",
85
107
  "@element-plus/icons-vue": "^2.3.2",
86
108
  "@eslint/js": "^10.0.1",
87
109
  "@floating-ui/core": "^1.8.0",
88
110
  "@floating-ui/dom": "^1.8.0",
89
111
  "@floating-ui/utils": "^0.2.12",
90
- "@mdit/plugin-tasklist": "^1.1.0",
112
+ "@mdit/plugin-tasklist": "^1.1.3",
91
113
  "@popperjs/core": "^2.11.8",
92
114
  "@rushstack/eslint-patch": "^1.16.1",
93
115
  "@tsconfig/node26": "^26.0.1",
94
116
  "@types/lodash-es": "^4.17.12",
95
- "@types/node": "^26.4.0",
117
+ "@types/node": "^26.6.5",
96
118
  "@types/throttle-debounce": "^5.0.2",
97
- "@vitejs/plugin-vue": "^6.0.8",
119
+ "@vitejs/plugin-vue": "^6.0.10",
98
120
  "@vitejs/plugin-vue-jsx": "^5.1.6",
99
121
  "@vitest/coverage-v8": "4.1.10",
100
- "@vue/compiler-core": "^3.5.42",
101
- "@vue/compiler-dom": "^3.5.42",
122
+ "@vue/compiler-core": "^3.5.43",
123
+ "@vue/compiler-dom": "^3.5.43",
102
124
  "@vue/devtools-api": "^8.2.1",
103
125
  "@vue/devtools-kit": "^8.2.1",
104
126
  "@vue/devtools-shared": "^8.2.1",
105
127
  "@vue/eslint-config-prettier": "^10.2.0",
106
128
  "@vue/eslint-config-typescript": "^14.9.0",
107
- "@vue/reactivity": "^3.5.42",
108
- "@vue/runtime-core": "^3.5.42",
109
- "@vue/runtime-dom": "^3.5.42",
110
- "@vue/shared": "^3.5.42",
111
- "@vue/test-utils": "^2.5.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",
112
134
  "@vue/tsconfig": "^0.9.1",
113
135
  "@vueuse/core": "^14.4.0",
114
136
  "@vueuse/shared": "^14.4.0",
115
137
  "async-validator": "^4.2.5",
116
138
  "birpc": "^4.2.0",
117
139
  "dayjs": "^1.11.23",
118
- "element-plus": "^2.14.5",
119
- "eslint": "^10.9.1",
140
+ "element-plus": "^2.14.7",
141
+ "eslint": "^10.12.0",
120
142
  "eslint-config-prettier": "^10.1.8",
121
- "eslint-plugin-vue": "^10.10.0",
122
- "globals": "^17.11.0",
123
- "happy-dom": "^20.12.0",
124
- "hookable": "^6.1.1",
143
+ "eslint-plugin-vue": "^10.11.1",
144
+ "globals": "^17.13.0",
145
+ "happy-dom": "^20.14.6",
146
+ "hookable": "^6.1.2",
125
147
  "lodash-es": "^4.18.1",
126
148
  "lodash-unified": "^1.0.3",
127
149
  "memoize-one": "^6.0.0",
@@ -129,18 +151,20 @@
129
151
  "normalize.css": "^8.0.1",
130
152
  "npm-run-all2": "^9.0.3",
131
153
  "perfect-debounce": "^2.1.0",
132
- "prettier": "^3.9.6",
133
- "sass-embedded": "^1.103.1",
154
+ "prettier": "^3.9.10",
155
+ "sass-embedded": "^1.105.1",
134
156
  "terser": "^5.51.2",
135
- "typescript": "~7.0.2",
136
- "typescript-eslint": "^8.69.0",
157
+ "typescript": "^6.0.3",
158
+ "typescript-eslint": "^8.71.1",
137
159
  "unplugin-auto-import": "^21.1.0",
138
160
  "unplugin-vue-components": "^32.1.0",
139
- "vite": "^8.2.2",
161
+ "vite": "^8.3.4",
140
162
  "vite-plugin-dts": "5.0.3",
141
163
  "vite-plugin-package-version": "^1.1.0",
142
164
  "vitepress": "^1.6.4",
143
165
  "vitest": "^4.1.11",
144
- "vue-tsc": "^3.3.11"
166
+ "vue": "^3.5.43",
167
+ "vue-router": "^5.4.0",
168
+ "vue-tsc": "^3.3.13"
145
169
  }
146
170
  }
@@ -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%);
@@ -443,7 +443,6 @@
443
443
  border-radius: 10px 0;
444
444
  box-shadow: $stack-tab-shadow-context-menu;
445
445
  transform-origin: left top;
446
- transition: all 0.25s ease-in;
447
446
 
448
447
  &-item {
449
448
  display: flex;
@@ -464,7 +463,10 @@
464
463
  background: transparent;
465
464
  border: 0;
466
465
  border-radius: 10px 0;
467
- transition: all 0.2s ease-in-out;
466
+ // 仅过渡悬停态实际会改变的颜色,避免触发菜单布局与合成层的无关动画。
467
+ transition:
468
+ color 0.2s ease-in-out,
469
+ background-color 0.2s ease-in-out;
468
470
  user-select: none;
469
471
 
470
472
  &:hover,
@@ -497,7 +499,7 @@
497
499
  &-icon {
498
500
  width: $stack-tab-font-size;
499
501
  height: $stack-tab-font-size;
500
- transition: all 0.2s ease-in-out;
502
+ transition: background-color 0.2s ease-in-out;
501
503
 
502
504
  &.svg-mask {
503
505
  width: $stack-tab-font-size;
@@ -532,4 +534,16 @@
532
534
  @include mixins.insetShadow();
533
535
  box-shadow: $stack-tab-shadow-left-inset, $stack-tab-shadow-right-inset;
534
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
+ }
535
549
  }
@@ -13,7 +13,7 @@
13
13
  :style="{
14
14
  left: `${menuPosition.left}px`,
15
15
  top: `${menuPosition.top}px`,
16
- 'z-index': getMaxZIndex('.stack-tab,.stack-tab *')
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 => item.key ?? `${item.title}-${item.icon ?? ''}`
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: getMaxZIndex('.cache-page-wrapper *') }"
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 { defineComponent, shallowRef, watch } from 'vue'
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 EmptyRendererComponent = defineComponent({
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
@@ -243,17 +243,18 @@ const handleTabListKeydown = (event: KeyboardEvent) => {
243
243
  if (count <= 0) return
244
244
 
245
245
  const currentIndex = getActiveTabIndex()
246
- const safeIndex = currentIndex >= 0 ? currentIndex : 0
247
246
 
248
247
  if (event.key === 'ArrowRight') {
249
248
  event.preventDefault()
250
- activateTabByIndex((safeIndex + 1) % count)
249
+ // 无 active 标签时从 -1 起步,按右方向键跳到第 0 个;正常情况环绕到下一个。
250
+ activateTabByIndex(currentIndex === -1 ? 0 : (currentIndex + 1) % count)
251
251
  return
252
252
  }
253
253
 
254
254
  if (event.key === 'ArrowLeft') {
255
255
  event.preventDefault()
256
- activateTabByIndex((safeIndex - 1 + count) % count)
256
+ // 无 active 标签时按左方向键跳到最后一个;正常情况环绕到上一个。
257
+ activateTabByIndex(currentIndex === -1 ? count - 1 : (currentIndex - 1 + count) % count)
257
258
  return
258
259
  }
259
260
 
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * VueStackTabs 全局运行时上下文管理。
3
3
  *
4
- * 负责创建、注册、解析全局唯一的 StackTabsRuntimeContext,
5
- * 支持通过 Vue provide/inject 或模块级单例两种方式访问。
4
+ * 负责创建、注册、解析 StackTabsRuntimeContext。
5
+ * 业务运行时通过 Vue provide/inject 隔离;模块级回退仅保留给没有 App
6
+ * 容器的兼容调用(例如旧的单元测试),绝不能承载某个 Vue App 的状态。
6
7
  * 每个 Vue app 只允许注册一个 <VueStackTabs> 实例。
7
8
  */
8
9
  import type { App, DefineComponent, InjectionKey, Ref, ShallowRef } from 'vue'
@@ -77,7 +78,10 @@ export const createStackTabsRuntimeContext = (
77
78
  }
78
79
  }
79
80
 
80
- /** 获取当前活跃的运行时上下文(模块级单例) */
81
+ /**
82
+ * 获取非 App 容器下注册的兼容上下文。
83
+ * @deprecated App 内的 composable 必须依赖 provide/inject,不应使用这个回退。
84
+ */
81
85
  export const getActiveStackTabsRuntimeContext = (): StackTabsRuntimeContext | null =>
82
86
  activeRuntimeContext
83
87
 
@@ -86,22 +90,21 @@ export const registerStackTabsRuntimeContext = (
86
90
  context: StackTabsRuntimeContext,
87
91
  options: RegisterStackTabsRuntimeContextOptions = {}
88
92
  ): boolean => {
89
- if (!activeRuntimeContext) {
90
- activeRuntimeContext = context
91
- }
92
-
93
93
  if (options.app && !runtimeContextOwnersByApp.has(options.app)) {
94
94
  runtimeContextOwnersByApp.add(options.app)
95
95
  return true
96
96
  }
97
97
 
98
- if (
99
- !options.app &&
100
- activeRuntimeContext === context &&
101
- !isFallbackRuntimeContextOwnerRegistered
102
- ) {
103
- isFallbackRuntimeContextOwnerRegistered = true
104
- return true
98
+ if (!options.app) {
99
+ if (!activeRuntimeContext) {
100
+ activeRuntimeContext = context
101
+ isFallbackRuntimeContextOwnerRegistered = true
102
+ return true
103
+ }
104
+ if (activeRuntimeContext === context && !isFallbackRuntimeContextOwnerRegistered) {
105
+ isFallbackRuntimeContextOwnerRegistered = true
106
+ return true
107
+ }
105
108
  }
106
109
 
107
110
  const isProduction = options.isProduction ?? import.meta.env.PROD
@@ -118,13 +121,15 @@ export const unregisterStackTabsRuntimeContext = (
118
121
  context: StackTabsRuntimeContext,
119
122
  options: UnregisterStackTabsRuntimeContextOptions = {}
120
123
  ): void => {
121
- if (options.app) runtimeContextOwnersByApp.delete(options.app)
122
- if (!options.app && activeRuntimeContext === context)
123
- isFallbackRuntimeContextOwnerRegistered = false
124
+ if (options.app) {
125
+ runtimeContextOwnersByApp.delete(options.app)
126
+ return
127
+ }
128
+ if (activeRuntimeContext === context) isFallbackRuntimeContextOwnerRegistered = false
124
129
  if (activeRuntimeContext === context) activeRuntimeContext = null
125
130
  }
126
131
 
127
- /** 解析当前可用的运行时上下文,优先使用 inject,回退到模块单例 */
132
+ /** 解析当前可用的运行时上下文,优先使用 inject,兼容回退仅限非 App 容器。 */
128
133
  export const resolveStackTabsRuntimeContext = (): StackTabsRuntimeContext => {
129
134
  const injected = hasInjectionContext() ? inject(stackTabsContextKey, null) : null
130
135
  const context = injected ?? activeRuntimeContext
@@ -0,0 +1,142 @@
1
+ /**
2
+ * 页面缓存组件工厂。
3
+ *
4
+ * 将 keep-alive 包装、VNode 注入和页面激活生命周期从 tab 状态编排中隔离,
5
+ * 使 useTabPanel 只负责领域状态与路由协调。
6
+ */
7
+ import {
8
+ cloneVNode,
9
+ defineComponent,
10
+ onActivated,
11
+ onDeactivated,
12
+ onMounted,
13
+ onUnmounted,
14
+ ref,
15
+ shallowRef
16
+ } from 'vue'
17
+ import type { DefineComponent, PropType, VNode } from 'vue'
18
+ import type { ITabBase, ITabItem } from '../../model/TabModel'
19
+ import PageLoading from '../../components/PageLoading.vue'
20
+ import type { StackTabsRuntimeContext } from '../stackTabsContext'
21
+ import { useScrollShadow } from '../useScrollShadow'
22
+
23
+ interface PageComponentFactoryOptions {
24
+ runtimeContext: StackTabsRuntimeContext
25
+ getTab: (id: string) => ITabItem | null
26
+ addCache: (cacheName: string) => void
27
+ evictMarkedCaches: () => void
28
+ addPageScroller: (pageId: string, ...selectors: string[]) => void
29
+ saveScroller: (pageId: string) => void
30
+ restoreScroller: (pageId: string) => void
31
+ removeScroller: (pageId: string) => void
32
+ }
33
+
34
+ export const createPageComponentFactory = (options: PageComponentFactoryOptions) => {
35
+ const {
36
+ runtimeContext,
37
+ getTab,
38
+ addCache,
39
+ evictMarkedCaches,
40
+ addPageScroller,
41
+ saveScroller,
42
+ restoreScroller,
43
+ removeScroller
44
+ } = options
45
+ const { components, tabIdsToEvict } = runtimeContext
46
+
47
+ const resolvePageComponent = (ctx: { cacheName: string; tabInfo: ITabBase }): DefineComponent => {
48
+ const { cacheName, tabInfo } = ctx
49
+ const existing = components.get(cacheName)
50
+ if (existing) {
51
+ addCache(cacheName)
52
+ return existing
53
+ }
54
+
55
+ const cacheComponent = defineComponent<{ vnode?: VNode }>({
56
+ name: cacheName,
57
+ props: {
58
+ vnode: {
59
+ type: Object as PropType<VNode>,
60
+ required: false,
61
+ default: undefined
62
+ }
63
+ },
64
+ emits: ['onLoaded'],
65
+ setup(props, context) {
66
+ const localBackParams = ref<Record<string, unknown> | null>(null)
67
+ const lastVnode = shallowRef<VNode | null | undefined>(null)
68
+ const lastCloned = shallowRef<VNode | null>(null)
69
+ const lastBackParams = shallowRef<Record<string, unknown> | null>(null)
70
+ const pageRoot = ref<HTMLElement | null>(null)
71
+
72
+ const checkAndConsumeBackParams = () => {
73
+ try {
74
+ const tab = getTab(tabInfo.id!)
75
+ const top = tab?.pages.peek()
76
+ if (top && top.id === cacheName && top._backParams) {
77
+ localBackParams.value = { ...top._backParams }
78
+ delete top._backParams
79
+ } else {
80
+ localBackParams.value = null
81
+ }
82
+ } catch {
83
+ localBackParams.value = null
84
+ }
85
+ }
86
+
87
+ onMounted(() => {
88
+ context.emit('onLoaded')
89
+ addPageScroller(cacheName, `#W-${cacheName}`)
90
+ checkAndConsumeBackParams()
91
+ })
92
+ onDeactivated(() => saveScroller(cacheName))
93
+ onActivated(() => {
94
+ context.emit('onLoaded')
95
+ restoreScroller(cacheName)
96
+ evictMarkedCaches()
97
+ tabIdsToEvict.clear()
98
+ checkAndConsumeBackParams()
99
+ })
100
+ onUnmounted(() => removeScroller(cacheName))
101
+
102
+ // 给 .cache-page-wrapper 挂滚动阴影,确保 keep-alive 的 suspended/deactivated 生命周期下表现一致
103
+ useScrollShadow({ target: pageRoot })
104
+
105
+ return () => {
106
+ const vnode = props.vnode
107
+ const backParams = localBackParams.value
108
+ if (vnode !== lastVnode.value || backParams !== lastBackParams.value) {
109
+ lastCloned.value = vnode
110
+ ? cloneVNode(vnode, {
111
+ tId: tabInfo.id,
112
+ pId: cacheName,
113
+ ...(backParams ? { _back: backParams } : {})
114
+ })
115
+ : null
116
+ lastVnode.value = vnode
117
+ lastBackParams.value = backParams
118
+ }
119
+ return (
120
+ <div
121
+ ref={pageRoot}
122
+ class="cache-page-wrapper"
123
+ id={`W-${cacheName}`}
124
+ style={[
125
+ runtimeContext.useGlobalScroll.value ? 'overflow:auto' : 'overflow:hidden',
126
+ 'height: 100%'
127
+ ]}
128
+ >
129
+ {lastCloned.value}
130
+ <PageLoading tabId={tabInfo.id!} />
131
+ </div>
132
+ )
133
+ }
134
+ }
135
+ }) as DefineComponent
136
+ components.set(cacheName, cacheComponent)
137
+ addCache(cacheName)
138
+ return cacheComponent
139
+ }
140
+
141
+ return { resolvePageComponent }
142
+ }