diff --git a/eslint-suppressions.json b/eslint-suppressions.json index b542b114..8cb8c073 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -47,17 +47,6 @@ "count": 3 } }, - "src/App.vue": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - }, - "@typescript-eslint/no-unused-vars": { - "count": 1 - }, - "sonarjs/no-ignored-exceptions": { - "count": 1 - } - }, "src/ace-config.ts": { "@typescript-eslint/no-explicit-any": { "count": 24 diff --git a/src/@layouts/components/VerticalNavLayout.vue b/src/@layouts/components/VerticalNavLayout.vue index 6d327069..eec7ef6f 100644 --- a/src/@layouts/components/VerticalNavLayout.vue +++ b/src/@layouts/components/VerticalNavLayout.vue @@ -2,6 +2,7 @@ import { useDisplay } from 'vuetify' import VerticalNav from '@layouts/components/VerticalNav.vue' import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue' +import GlassNavbarRefractionDefs from '@/components/theme/GlassNavbarRefractionDefs.vue' import { readThemeCustomizerSettings, THEME_CUSTOMIZER_CHANGE_EVENT, @@ -11,6 +12,7 @@ import { useGlassFixedShellBackplate } from '@/composables/useGlassFixedShellBac import { usePWA } from '@/composables/usePWA' import { useShellScrollState } from '@/composables/useShellScrollState' import { useFooterDockHeight } from '@/composables/useFooterDockHeight' +import { supportsGlassNavbarLiveRefraction } from '@/utils/glassNavbarRefraction' const FLOATING_NAVBAR_INSET_PX = 16 @@ -26,7 +28,10 @@ export default defineComponent({ // App Dock 通过 Teleport 挂载到 body,不参与内容流;将实际高度交给布局用于末尾避让。 const { footerDockHeight } = useFooterDockHeight() const fixedShellBackplate = useGlassFixedShellBackplate() - const themeLayout = ref(readThemeCustomizerSettings().layout) + const navbarRefractionMode = supportsGlassNavbarLiveRefraction() ? 'chromium' : 'goal1' + const initialThemeSettings = readThemeCustomizerSettings() + const themeLayout = ref(initialThemeSettings.layout) + const shellTheme = ref(initialThemeSettings.theme) const canUseDesktopLayout = computed(() => !mdAndDown.value && !appMode.value) const isOverlayShell = computed(() => mdAndDown.value && !appMode.value) const isCollapsedLayout = computed(() => canUseDesktopLayout.value && themeLayout.value === 'collapsed') @@ -57,9 +62,22 @@ export default defineComponent({ const isDialogOpen = ref(false) let dialogObserver: MutationObserver | null = null const shellScroll = useShellScrollState({ scrollLocked: isDialogOpen }) + const isGlassFloatingAway = ref(false) + + // 桌面脱离窗口边缘是材质状态;复用滚动坐标,但不等待移动App的64px收起阈值。 + watch( + () => [shellScroll.scrollY.value, isFloatingNavbarEligible.value, shellTheme.value] as const, + ([scrollY, eligible, theme]) => { + if (!eligible || theme !== 'glass' || scrollY <= 4) isGlassFloatingAway.value = false + else if (scrollY >= 12) isGlassFloatingAway.value = true + }, + { immediate: true }, + ) const handleThemeCustomizerChange = (event: Event) => { - themeLayout.value = (event as CustomEvent).detail.layout + const settings = (event as CustomEvent).detail + themeLayout.value = settings.layout + shellTheme.value = settings.theme } // 监听弹窗状态变化 @@ -149,7 +167,10 @@ export default defineComponent({ // 👉 根据路由 meta 决定 footer 高度 const shouldShowFooter = !route.meta.hideFooter - const isNavbarAwayFromTop = shellScroll.state.value !== 'expanded' + const isNavbarAwayFromTop = + isFloatingNavbarEligible.value && shellTheme.value === 'glass' + ? isGlassFloatingAway.value + : shellScroll.state.value !== 'expanded' // compact/revealed 是 App 上下文顶栏的呈现状态;其他 Shell 只消费 away-from-top 材质状态。 const isNavbarCompact = appMode.value && shellScroll.state.value === 'compact' const isNavbarRevealed = appMode.value && shellScroll.state.value === 'revealed' @@ -208,6 +229,8 @@ export default defineComponent({ ? 'theme-qualified' : 'connected', 'data-shell-scroll-direction': shellScroll.direction.value, + 'data-glass-navigation-refraction': navbarRefractionMode, + 'data-glass-navbar-refraction': navbarRefractionMode, style: { '--layout-footer-dock-height': `${footerDockHeight.value ?? 0}px`, '--shell-floating-navbar-scale-x': floatingNavbarScale.value, @@ -215,6 +238,7 @@ export default defineComponent({ }, }, [ + navbarRefractionMode === 'chromium' ? h(GlassNavbarRefractionDefs) : null, fixedShellBackplateNode, verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), diff --git a/src/@layouts/components/__tests__/VerticalNavLayout.spec.ts b/src/@layouts/components/__tests__/VerticalNavLayout.spec.ts index 57da91ea..3c1d5294 100644 --- a/src/@layouts/components/__tests__/VerticalNavLayout.spec.ts +++ b/src/@layouts/components/__tests__/VerticalNavLayout.spec.ts @@ -13,6 +13,8 @@ const mocks = vi.hoisted(() => ({ isStandaloneMode: false, isWindowControlsOverlayMode: false, mdAndDown: false, + navbarRefractionSupported: false, + scrollY: 0, revision: undefined as { value: number } | undefined, state: 'expanded' as 'expanded' | 'compact' | 'revealed', })) @@ -24,6 +26,10 @@ vi.mock('@/composables/useShellScrollState', async () => { useShellScrollState: () => ({ direction: computed(() => mocks.direction), state: computed(() => mocks.state), + scrollY: computed(() => { + void mocks.revision!.value + return mocks.scrollY + }), }), } }) @@ -85,6 +91,10 @@ vi.mock('@/composables/useGlassFixedShellBackplate', async () => { } }) +vi.mock('@/utils/glassNavbarRefraction', () => ({ + supportsGlassNavbarLiveRefraction: () => mocks.navbarRefractionSupported, +})) + vi.mock('@/composables/useThemeCustomizer', () => ({ readThemeCustomizerSettings: () => ({ layout: 'vertical' }), THEME_CUSTOMIZER_CHANGE_EVENT: 'moviepilot:theme-customizer-change', @@ -94,6 +104,10 @@ vi.mock('@/components/theme/GlassFixedShellBackplate.vue', () => ({ default: { template: '
' }, })) +vi.mock('@/components/theme/GlassNavbarRefractionDefs.vue', () => ({ + default: { template: '' }, +})) + vi.mock('@layouts/components/VerticalNav.vue', () => ({ default: { template: '' }, })) @@ -143,6 +157,8 @@ describe('VerticalNavLayout shell states', () => { mocks.isStandaloneMode = false mocks.isWindowControlsOverlayMode = false mocks.mdAndDown = false + mocks.navbarRefractionSupported = false + mocks.scrollY = 0 mocks.state = 'expanded' }) @@ -177,6 +193,20 @@ describe('VerticalNavLayout shell states', () => { expect(revealedWrapper.get('.layout-navbar').attributes('data-shell-navbar-state')).toBe('revealed') }) + it('mounts live backdrop definitions only for the verified Chromium path', () => { + const goal1Wrapper = mountLayout() + + expect(goal1Wrapper.get('.layout-wrapper').attributes('data-glass-navbar-refraction')).toBe('goal1') + expect(goal1Wrapper.find('[data-testid="navbar-refraction-defs"]').exists()).toBe(false) + goal1Wrapper.unmount() + + mocks.navbarRefractionSupported = true + const chromiumWrapper = mountLayout() + + expect(chromiumWrapper.get('.layout-wrapper').attributes('data-glass-navbar-refraction')).toBe('chromium') + expect(chromiumWrapper.find('[data-testid="navbar-refraction-defs"]').exists()).toBe(true) + }) + it('keeps the footer contract stable across App and drawer shells', async () => { mocks.appMode = true mocks.mdAndDown = true @@ -358,6 +388,26 @@ describe('VerticalNavLayout shell states', () => { expect(appWrapper.get('.layout-navbar').attributes()).toHaveProperty('inert') }) + it('responds to glass floating early with hysteresis without compacting App controls', async () => { + const wrapper = mountLayout() + window.dispatchEvent( + new CustomEvent('moviepilot:theme-customizer-change', { detail: { layout: 'horizontal', theme: 'glass' } }), + ) + await nextTick() + const root = wrapper.get('.layout-wrapper') + mocks.scrollY = 12 + await refreshShell() + expect(root.classes()).toContain('layout-navbar-away-from-top') + expect(wrapper.get('.layout-navbar').attributes('data-shell-navbar-state')).toBe('expanded') + mocks.scrollY = 8 + await refreshShell() + expect(root.classes()).toContain('layout-navbar-away-from-top') + mocks.scrollY = 4 + await refreshShell() + expect(root.classes()).not.toContain('layout-navbar-away-from-top') + wrapper.unmount() + }) + it('exposes floating eligibility only for an ordinary desktop horizontal environment', async () => { mocks.state = 'compact' const browserWrapper = mountLayout() diff --git a/src/App.vue b/src/App.vue index 9a5be4f3..0f63d7f1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -286,7 +286,20 @@ const shouldRenderGlassOpticalLayer = computed( isInitialRouteReady.value && Boolean(activeBackgroundImage.value), ) -const GlassOpticalLayer = defineAsyncComponent(() => import('@/components/theme/GlassOpticalLayer.vue')) +const loadGlassOpticalLayer = () => import('@/components/theme/GlassOpticalLayer.vue') +const GlassOpticalLayer = defineAsyncComponent(loadGlassOpticalLayer) + +// 模块下载与壁纸准备并行;实际挂载仍等待路由和壁纸,CSS 档不请求光学组件。 +watch( + () => isGlassTheme.value && opticalQuality.value !== 'css', + enabled => { + if (!enabled) return + void loadGlassOpticalLayer().catch(error => { + console.warn('[Glass] Optical component preload failed', error) + }) + }, + { immediate: true }, +) const transparentBackgroundBlur = ref(16) const transparencyGlassQuality = ref( localStorage.getItem('transparency-glass-quality') === 'realtime' ? 'realtime' : 'lightweight', @@ -864,13 +877,15 @@ async function removeLoadingWithStateCheck() { globalLoadingStateManager.setLoadingState('pwa-state', true) // 静默检查PWA状态恢复,但不能让恢复异常或慢请求挡住应用外壳。 - const pwaController = (window as any).pwaStateController + const pwaController = ( + window as Window & { + /** 宿主可选的状态恢复钩子;启动预算到期后不阻塞外壳。 */ + pwaStateController?: { waitForStateRestore?: () => unknown } + } + ).pwaStateController if (pwaController?.waitForStateRestore) { - await waitForLaunchTask( - Promise.resolve().then(() => pwaController.waitForStateRestore()), - getRemainingLaunchBudget(), - 'PWA state restore', - ) + const restoreState = pwaController.waitForStateRestore.bind(pwaController) + await waitForLaunchTask(Promise.resolve().then(restoreState), getRemainingLaunchBudget(), 'PWA state restore') } globalLoadingStateManager.setLoadingState('pwa-state', false) @@ -893,7 +908,7 @@ async function removeLoadingWithStateCheck() { checkAndEmitUnreadMessages() } } catch (error) { - // 即使出错也要移除加载界面 + console.warn('[Launch] State checks failed; revealing the application shell', error) globalLoadingStateManager.reset() await animateAndRemoveLoader() } @@ -927,9 +942,12 @@ async function loadBackgroundImages(loadVersion: number, retryCount = 0) { resetBackgroundCrossfade() recordGlassLaunchTiming('wallpaper-committed', activeBackgroundImage.value) startBackgroundRotation() - } catch (error: any) { + } catch (error: unknown) { if (loadVersion !== backgroundLoadVersion) return - const isAbortError = error.name === 'AbortError' || error.code === 'ERR_CANCELED' + const isAbortError = + typeof error === 'object' && + error !== null && + (('name' in error && error.name === 'AbortError') || ('code' in error && error.code === 'ERR_CANCELED')) if (retryCount < maxRetries) { const baseDelay = isAbortError ? 1000 : 3000 const retryDelay = Math.min(baseDelay * Math.pow(2, retryCount), 10000) diff --git a/src/components/theme/GlassFixedShellBackplate.vue b/src/components/theme/GlassFixedShellBackplate.vue index 433a3b7f..488fe53b 100644 --- a/src/components/theme/GlassFixedShellBackplate.vue +++ b/src/components/theme/GlassFixedShellBackplate.vue @@ -1,4 +1,5 @@