diff --git a/src/@layouts/components/VerticalNavLayout.vue b/src/@layouts/components/VerticalNavLayout.vue
index 4978a315..fbd59f81 100644
--- a/src/@layouts/components/VerticalNavLayout.vue
+++ b/src/@layouts/components/VerticalNavLayout.vue
@@ -1,11 +1,13 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/theme/__tests__/GlassFixedShellBackplate.spec.ts b/src/components/theme/__tests__/GlassFixedShellBackplate.spec.ts
new file mode 100644
index 00000000..07790189
--- /dev/null
+++ b/src/components/theme/__tests__/GlassFixedShellBackplate.spec.ts
@@ -0,0 +1,90 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue'
+import type { GlassFixedShellBackplateLayer } from '@/composables/useGlassFixedShellBackplate'
+
+const initialLayers: readonly GlassFixedShellBackplateLayer[] = [
+ {
+ key: 'front',
+ role: 'active',
+ style: {
+ 'backgroundImage': 'url(/wallpaper-current.jpg)',
+ '--glass-wallpaper-brightness': '0.82',
+ },
+ url: '/wallpaper-current.jpg',
+ },
+ {
+ key: 'back',
+ role: 'standby',
+ style: {
+ 'backgroundImage': 'url(/wallpaper-next.jpg)',
+ '--glass-wallpaper-brightness': '0.76',
+ },
+ url: '/wallpaper-next.jpg',
+ },
+]
+
+describe('GlassFixedShellBackplate', () => {
+ it('renders the App-owned slots once for the shared desktop shell', () => {
+ const wrapper = mount(GlassFixedShellBackplate, {
+ props: {
+ isOverlayNav: false,
+ isOverlayNavActive: false,
+ layers: initialLayers,
+ transitionDurationMs: 1500,
+ },
+ })
+
+ expect(wrapper.findAll('[data-backplate-surface="main"] [data-backplate-slot]')).toHaveLength(2)
+ expect(wrapper.find('[data-backplate-slot="front"]').classes()).toContain('is-active')
+ expect(wrapper.find('[data-backplate-slot="back"]').classes()).toContain('is-standby')
+ expect(
+ (wrapper.find('[data-backplate-slot="front"] > div').element as HTMLElement).style.backgroundImage,
+ ).toContain('/wallpaper-current.jpg')
+ expect(wrapper.find('[data-backplate-surface="main"]').attributes('style')).toContain(
+ '--glass-fixed-shell-transition-duration: 1500ms',
+ )
+ expect(wrapper.find('[data-backplate-surface="overlay-nav"]').exists()).toBe(false)
+ })
+
+ it('preserves slot nodes while their active and previous roles swap', async () => {
+ const wrapper = mount(GlassFixedShellBackplate, {
+ props: {
+ isOverlayNav: false,
+ isOverlayNavActive: false,
+ layers: initialLayers,
+ transitionDurationMs: 1500,
+ },
+ })
+ const frontSlot = wrapper.find('[data-backplate-surface="main"] [data-backplate-slot="front"]').element
+ const backSlot = wrapper.find('[data-backplate-surface="main"] [data-backplate-slot="back"]').element
+
+ await wrapper.setProps({
+ layers: [
+ { ...initialLayers[0], role: 'previous' },
+ { ...initialLayers[1], role: 'active' },
+ ],
+ })
+
+ expect(wrapper.find('[data-backplate-surface="main"] [data-backplate-slot="front"]').element).toBe(frontSlot)
+ expect(wrapper.find('[data-backplate-surface="main"] [data-backplate-slot="back"]').element).toBe(backSlot)
+ expect(wrapper.find('[data-backplate-slot="front"]').classes()).toContain('is-previous')
+ expect(wrapper.find('[data-backplate-slot="back"]').classes()).toContain('is-active')
+ })
+
+ it('adds a separately clipped surface only for mobile overlay navigation', () => {
+ const wrapper = mount(GlassFixedShellBackplate, {
+ props: {
+ isOverlayNav: true,
+ isOverlayNavActive: true,
+ layers: initialLayers,
+ transitionDurationMs: 1500,
+ },
+ })
+
+ const overlay = wrapper.find('[data-backplate-surface="overlay-nav"]')
+ expect(overlay.exists()).toBe(true)
+ expect(overlay.classes()).toContain('is-visible')
+ expect(overlay.findAll('[data-backplate-slot]')).toHaveLength(2)
+ })
+})
diff --git a/src/composables/__tests__/useGlassFixedShellBackplate.spec.ts b/src/composables/__tests__/useGlassFixedShellBackplate.spec.ts
new file mode 100644
index 00000000..5cec98e4
--- /dev/null
+++ b/src/composables/__tests__/useGlassFixedShellBackplate.spec.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest'
+import { shouldUseGlassFixedShellBackplate } from '@/composables/useGlassFixedShellBackplate'
+
+describe('shouldUseGlassFixedShellBackplate', () => {
+ const eligible = {
+ appearance: 'frosted' as const,
+ hasWallpaper: true,
+ isAuthenticated: true,
+ quality: 'css' as const,
+ themeName: 'glass',
+ }
+
+ it('enables the direct wallpaper backplate only for authenticated frosted CSS rendering', () => {
+ expect(shouldUseGlassFixedShellBackplate(eligible)).toBe(true)
+ expect(shouldUseGlassFixedShellBackplate({ ...eligible, appearance: 'clear' })).toBe(false)
+ expect(shouldUseGlassFixedShellBackplate({ ...eligible, appearance: 'tinted' })).toBe(false)
+ expect(shouldUseGlassFixedShellBackplate({ ...eligible, quality: 'balanced' })).toBe(false)
+ expect(shouldUseGlassFixedShellBackplate({ ...eligible, quality: 'high' })).toBe(false)
+ expect(shouldUseGlassFixedShellBackplate({ ...eligible, themeName: 'transparent' })).toBe(false)
+ expect(shouldUseGlassFixedShellBackplate({ ...eligible, isAuthenticated: false })).toBe(false)
+ expect(shouldUseGlassFixedShellBackplate({ ...eligible, hasWallpaper: false })).toBe(false)
+ })
+})
diff --git a/src/composables/useGlassFixedShellBackplate.ts b/src/composables/useGlassFixedShellBackplate.ts
new file mode 100644
index 00000000..76b34946
--- /dev/null
+++ b/src/composables/useGlassFixedShellBackplate.ts
@@ -0,0 +1,57 @@
+import { inject, provide, shallowRef, type InjectionKey, type Ref, type StyleValue } from 'vue'
+import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
+import type { LoginBackgroundLayer } from '@/utils/loginPresentation'
+
+export interface GlassFixedShellBackplateLayer extends LoginBackgroundLayer {
+ /** 当前槽位直接采样壁纸所需的背景图与色调变量。 */
+ style: StyleValue
+}
+
+export interface GlassFixedShellBackplateContext {
+ /** App 持有的稳定双槽位;布局只消费,不建立第二套壁纸生命周期。 */
+ layers: Readonly[>
+ /** 双槽位角色交换时使用的统一交叉淡化时长。 */
+ transitionDurationMs: number
+}
+
+interface GlassFixedShellBackplateEligibility {
+ /** 当前玻璃材质。 */
+ appearance: ThemeCustomizerGlassAppearance
+ /** 稳定双槽位中是否已经存在可显示的壁纸。 */
+ hasWallpaper: boolean
+ /** 当前页面是否处于认证后的主布局。 */
+ isAuthenticated: boolean
+ /** 当前玻璃渲染质量。 */
+ quality: ThemeCustomizerGlassQuality
+ /** Vuetify 当前解析后的主题名。 */
+ themeName: string
+}
+
+const GLASS_FIXED_SHELL_BACKPLATE_KEY: InjectionKey =
+ Symbol('glass-fixed-shell-backplate')
+const EMPTY_FIXED_SHELL_LAYERS = shallowRef([])
+const EMPTY_FIXED_SHELL_CONTEXT: GlassFixedShellBackplateContext = {
+ layers: EMPTY_FIXED_SHELL_LAYERS,
+ transitionDurationMs: 0,
+}
+
+/** 只有磨砂标准使用直接壁纸背板;其他材质和 GPU 质量继续走既有呈现路径。 */
+export function shouldUseGlassFixedShellBackplate(options: GlassFixedShellBackplateEligibility) {
+ return (
+ options.isAuthenticated &&
+ options.themeName === 'glass' &&
+ options.appearance === 'frosted' &&
+ options.quality === 'css' &&
+ options.hasWallpaper
+ )
+}
+
+/** 从 App 向认证后布局提供唯一的 fixed-shell 壁纸双槽位。 */
+export function provideGlassFixedShellBackplate(context: GlassFixedShellBackplateContext) {
+ provide(GLASS_FIXED_SHELL_BACKPLATE_KEY, context)
+}
+
+/** 读取 fixed-shell 双槽位;不在 App 树下时返回稳定空上下文。 */
+export function useGlassFixedShellBackplate() {
+ return inject(GLASS_FIXED_SHELL_BACKPLATE_KEY, EMPTY_FIXED_SHELL_CONTEXT)
+}
diff --git a/src/styles/__tests__/glassOverlayMaterial.spec.ts b/src/styles/__tests__/glassOverlayMaterial.spec.ts
index 84406791..9ae7a39a 100644
--- a/src/styles/__tests__/glassOverlayMaterial.spec.ts
+++ b/src/styles/__tests__/glassOverlayMaterial.spec.ts
@@ -33,13 +33,19 @@ describe('glass overlay material styles', () => {
)
})
- it('keeps the fixed navigation backdrop isolated from route content and its scrollbar', () => {
+ it('keeps frosted CSS fixed shells on the stable wallpaper backplate', () => {
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
+ const backplate = readFileSync(resolve(cwd(), 'src/components/theme/GlassFixedShellBackplate.vue'), 'utf8')
- expect(styles).toContain('--glass-fixed-shell-backdrop-filter: blur(min(var(--glass-blur-raised), 60px))')
+ expect(styles).toContain('--glass-fixed-shell-backplate-filter: blur(min(var(--glass-blur-raised), 60px))')
expect(styles).toMatch(
- /\.layout-vertical-nav\s*\{[\s\S]*?isolation:\s*isolate;[\s\S]*?backdrop-filter:\s*none;[\s\S]*?&::before\s*\{[\s\S]*?backdrop-filter:\s*var\(--glass-fixed-shell-backdrop-filter\);/,
+ /&\[data-glass-appearance='frosted'\]\[data-glass-quality='css'\][\s\S]*?\.layout-vertical-nav::before,[\s\S]*?\.layout-navbar,[\s\S]*?backdrop-filter:\s*none\s*!important;/,
)
+ expect(backplate).toContain('filter: var(--glass-fixed-shell-backplate-filter)')
+ expect(backplate).not.toContain('backdrop-filter')
+ expect(backplate).toContain('var(--glass-fixed-shell-nav-inline-size) 100%')
+ expect(backplate).toContain('.layout-wrapper:is(.layout-horizontal-nav-active, .layout-overlay-nav)')
+ expect(backplate).toContain('.glass-fixed-shell-backplate--overlay-nav')
expect(styles).toMatch(/\.layout-vertical-nav \.ps__rail-y\s*\{[\s\S]*?inset-inline-end:\s*0\.5rem !important;/)
})
diff --git a/src/styles/themes/glass.scss b/src/styles/themes/glass.scss
index 36403b8a..713e713f 100644
--- a/src/styles/themes/glass.scss
+++ b/src/styles/themes/glass.scss
@@ -46,6 +46,7 @@ html[data-theme='glass'] {
--glass-surface-backdrop-filter: var(--glass-native-surface-backdrop-filter);
--glass-raised-backdrop-filter: brightness(var(--glass-transmission-brightness));
--glass-fixed-shell-backdrop-filter: var(--glass-raised-backdrop-filter);
+ --glass-fixed-shell-backplate-filter: brightness(var(--glass-transmission-brightness));
--glass-control-backdrop-filter: none;
--glass-control-prominent-backdrop-filter: none;
--glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter);
@@ -210,6 +211,8 @@ html[data-theme='glass'] {
// 固定全高导航限制采样半径,避免页面重绘扩大其 backdrop 栅格化区域。
--glass-fixed-shell-backdrop-filter: blur(min(var(--glass-blur-raised), 60px)) saturate(var(--glass-saturate))
brightness(var(--glass-transmission-brightness));
+ --glass-fixed-shell-backplate-filter: blur(min(var(--glass-blur-raised), 60px)) saturate(var(--glass-saturate))
+ brightness(var(--glass-transmission-brightness));
--glass-control-prominent-backdrop-filter: blur(24px) saturate(150%);
--glass-overlay-surface: rgba(var(--v-theme-background), calc(0.24 + var(--glass-surface-density, 0.86) * 0.12));
--glass-overlay-blur: min(var(--glass-blur-raised), 36px);
@@ -443,7 +446,7 @@ html[data-theme='glass'] {
background: transparent !important;
box-shadow: none !important;
- // 导航材质与可变菜单内容分层,路由激活态切换不应重栅格化整块全高背板。
+ // 导航色层与可变菜单内容分离,路由激活态切换不应重建整块材质。
&::before {
position: absolute;
z-index: -1;
@@ -459,6 +462,16 @@ html[data-theme='glass'] {
}
}
+ // 磨砂标准直接渲染稳定壁纸背板,固定功能层不得读取随页面重绘的 document backdrop。
+ &[data-glass-appearance='frosted'][data-glass-quality='css'] body[data-theme='glass'] {
+ .layout-vertical-nav::before,
+ .layout-navbar,
+ .layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed .layout-navbar {
+ -webkit-backdrop-filter: none !important;
+ backdrop-filter: none !important;
+ }
+ }
+
// 滚动条与导航材质边界保持间距,避免窄轨道参与全高背板的合成。
.layout-vertical-nav .ps__rail-y {
inset-inline-end: 0.5rem !important;
]