diff --git a/src/@core/scss/libs/perfect-scrollbar.scss b/src/@core/scss/libs/perfect-scrollbar.scss index 27e0f25b..38110726 100644 --- a/src/@core/scss/libs/perfect-scrollbar.scss +++ b/src/@core/scss/libs/perfect-scrollbar.scss @@ -34,10 +34,15 @@ $ps-track-size: 0.5rem; inline-size: $ps-hover-size; } -// fix bug -@media(hover: none) { - .ps > .ps__rail-x, - .ps > .ps__rail-y { - opacity: 0.6; - } -} +// 滚动条只反馈实际滚动状态,鼠标经过容器或键盘聚焦不应让轨道常驻。 +.ps:hover > .ps__rail-x, +.ps:hover > .ps__rail-y, +.ps--focus > .ps__rail-x, +.ps--focus > .ps__rail-y { + opacity: 0 !important; +} + +.ps.ps--scrolling-x > .ps__rail-x, +.ps.ps--scrolling-y > .ps__rail-y { + opacity: 0.6 !important; +} diff --git a/src/App.vue b/src/App.vue index 7f3b8794..3506e848 100644 --- a/src/App.vue +++ b/src/App.vue @@ -11,7 +11,7 @@ import { globalLoadingStateManager } from '@/utils/loadingStateManager' import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager' import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue' import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue' -import { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer' +import { applyStoredThemeCustomizerAppearance, useEffectiveGlassSettings } from '@/composables/useThemeCustomizer' import { applyStoredTransparencySettings, TRANSPARENCY_SETTINGS_CHANGED_EVENT, @@ -22,15 +22,14 @@ import { completeLaunchLoading } from '@/composables/useLaunchLoading' import { usePWA } from '@/composables/usePWA' import { themeManager } from '@/utils/themeManager' import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette' +import { getDisplayImageUrl } from '@/utils/imageUtils' import { configureApexChartsTheme } from '@/utils/apexCharts' -import { - useGlobalOfflineStatus, - type ConnectionFailureReason, -} from '@/composables/useOfflineStatus' +import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus' const LOGIN_WALLPAPER_ROUTE = '/login' const BACKGROUND_CROSSFADE_DURATION_MS = 1500 const WINDOW_BLUR_RENDER_THROTTLE_DELAY_MS = 180_000 +const MEDIA_DENSE_OPTICAL_DEFER_MS = 1_600 // 生效主题 const vuetifyTheme = useTheme() @@ -59,6 +58,7 @@ setI18nLanguage(localeValue as SupportedLocale) const authStore = useAuthStore() const isLogin = computed(() => authStore.token) const route = useRoute() +const router = useRouter() const { initializePWA } = usePWA() const offlineStatus = useGlobalOfflineStatus() @@ -72,8 +72,14 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out' const backgroundImages = ref([]) const activeImageIndex = ref(0) const previousImageIndex = ref(null) +const isBackgroundCrossfading = ref(false) +const isRenderThrottled = ref(document.visibilityState === 'hidden') const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent') const isGlassTheme = computed(() => globalTheme.name.value === 'glass') +const effectiveGlassSettings = useEffectiveGlassSettings() +const isInitialRouteReady = ref(false) +const isGlassOpticalLayerDeferred = ref(true) +let glassOpticalLayerDeferTimer: number | null = null const isBackdropTheme = computed(() => isTransparentTheme.value || isGlassTheme.value) const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE) const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value) @@ -81,6 +87,23 @@ const shouldUseGlassBackgroundTreatment = computed(() => Boolean(isLogin.value) const shouldLoadBackgroundImages = computed( () => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isBackdropTheme.value), ) +const activeBackgroundImage = computed(() => backgroundImages.value[activeImageIndex.value] ?? '') +// 登录页的光学层只绘制程序化焦散,不会读取该跨域壁纸;登录后才通过同源缓存采样纹理。 +const activeOpticalBackgroundImage = computed(() => + getDisplayImageUrl(activeBackgroundImage.value, Boolean(isLogin.value)), +) +const appWrapperStyle = computed(() => ({ + '--login-wallpaper-image': activeBackgroundImage.value ? `url("${activeBackgroundImage.value}")` : 'none', +})) +const shouldRenderGlassOpticalLayer = computed( + () => + isGlassTheme.value && + effectiveGlassSettings.value.glassQuality !== 'css' && + !isGlassOpticalLayerDeferred.value && + !isRenderThrottled.value && + Boolean(activeBackgroundImage.value), +) +const GlassOpticalLayer = defineAsyncComponent(() => import('@/components/theme/GlassOpticalLayer.vue')) const transparentBackgroundBlur = ref(16) const transparencyGlassQuality = ref( localStorage.getItem('transparency-glass-quality') === 'realtime' ? 'realtime' : 'lightweight', @@ -91,7 +114,6 @@ const shouldRenderGlobalBlurLayer = computed( transparentBackgroundBlur.value > 0 && transparencyGlassQuality.value === 'realtime', ) -const isRenderThrottled = ref(document.visibilityState === 'hidden') let backgroundRetryTimer: number | null = null let backgroundRequestController: AbortController | null = null let backgroundCrossfadeTimer: number | null = null @@ -116,6 +138,39 @@ function handleTransparencySettingsChanged(event: Event) { applyTransparentBackgroundSettings() +/** 推荐页高质量光学层让首屏海报优先完成解码与合成。 */ +watch( + () => [route.fullPath, effectiveGlassSettings.value.glassQuality, isInitialRouteReady.value] as const, + ([, quality, routeReady]) => { + if (glassOpticalLayerDeferTimer !== null) { + window.clearTimeout(glassOpticalLayerDeferTimer) + glassOpticalLayerDeferTimer = null + } + + // 首次导航完成前 route 仍可能是重定向来源,禁止 renderer 按错误页面短暂挂载。 + if (!routeReady) { + isGlassOpticalLayerDeferred.value = true + return + } + + if (route.path !== '/recommend' || quality !== 'high') { + isGlassOpticalLayerDeferred.value = false + return + } + + isGlassOpticalLayerDeferred.value = true + glassOpticalLayerDeferTimer = window.setTimeout(() => { + isGlassOpticalLayerDeferred.value = false + glassOpticalLayerDeferTimer = null + }, MEDIA_DENSE_OPTICAL_DEFER_MS) + }, + { immediate: true }, +) + +void router.isReady().then(() => { + isInitialRouteReady.value = true +}) + function clearWindowBlurRenderThrottleTimer() { if (windowBlurRenderThrottleTimer) { window.clearTimeout(windowBlurRenderThrottleTimer) @@ -212,13 +267,10 @@ async function probeServerConnection(showChecking = false): Promise { const successSequenceAtProbeStart = offlineStatus.serverSuccessSequence.value const probePromise = (async () => { try { - await api.get( - 'system/ping', - { - skipConnectionTracking: true, - timeout: SERVER_PROBE_TIMEOUT_MS, - } as ConnectionAwareRequestConfig, - ) + await api.get('system/ping', { + skipConnectionTracking: true, + timeout: SERVER_PROBE_TIMEOUT_MS, + } as ConnectionAwareRequestConfig) connectionProbeFailures = 0 return true } catch (error) { @@ -261,9 +313,12 @@ function startHeartbeat() { void probeServerConnection() - heartbeatInterval = window.setInterval(async () => { - if (isLogin.value) await probeServerConnection() - }, 5 * 60 * 1000) + heartbeatInterval = window.setInterval( + async () => { + if (isLogin.value) await probeServerConnection() + }, + 5 * 60 * 1000, + ) } /** 停止心跳和等待中的自动重连任务。 */ @@ -371,6 +426,7 @@ function clearBackgroundCrossfadeTimer() { function resetBackgroundCrossfade() { clearBackgroundCrossfadeTimer() previousImageIndex.value = null + isBackgroundCrossfading.value = false } // 切换期保留上一张背景的渲染状态,避免图片合成层重建时露出透明底。 @@ -379,9 +435,11 @@ function activateBackgroundImage(nextIndex: number) { clearBackgroundCrossfadeTimer() previousImageIndex.value = activeImageIndex.value + isBackgroundCrossfading.value = true activeImageIndex.value = nextIndex backgroundCrossfadeTimer = window.setTimeout(() => { previousImageIndex.value = null + isBackgroundCrossfading.value = false backgroundCrossfadeTimer = null }, BACKGROUND_CROSSFADE_DURATION_MS) } @@ -633,6 +691,7 @@ onMounted(async () => { }) onUnmounted(() => { + if (glassOpticalLayerDeferTimer !== null) window.clearTimeout(glassOpticalLayerDeferTimer) // 清除背景轮换定时器 stopBackgroundLoading() if (authenticatedStateTimer) { @@ -654,7 +713,14 @@ onUnmounted(() => {