feat: add transparent glass quality options (#507)

* feat(theme): add lightweight transparent glass mode

* perf(theme): reduce realtime glass backdrop filters

* fix(theme): align transparency presets
This commit is contained in:
InfinityPacer
2026-06-30 13:39:22 +08:00
committed by GitHub
parent 39ce8d93dd
commit effc83b9a1
10 changed files with 287 additions and 17 deletions
+47 -15
View File
@@ -12,6 +12,12 @@ import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundMan
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
import { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer'
import {
applyStoredTransparencySettings,
TRANSPARENCY_SETTINGS_CHANGED_EVENT,
type TransparencyGlassQuality,
type TransparencySettings,
} from '@/composables/useTransparencySettings'
import { completeLaunchLoading } from '@/composables/useLaunchLoading'
import { usePWA } from '@/composables/usePWA'
import { themeManager } from '@/utils/themeManager'
@@ -60,29 +66,38 @@ const backgroundImages = ref<string[]>([])
const activeImageIndex = ref(0)
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
const shouldUseTransparentBackgroundTreatment = computed(
() => (Boolean(isLogin.value) && isTransparentTheme.value) || isLoginWallpaperRoute.value,
)
const shouldLoadBackgroundImages = computed(
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isTransparentTheme.value),
)
const transparentBackgroundBlur = ref(16)
const transparencyGlassQuality = ref<TransparencyGlassQuality>(
localStorage.getItem('transparency-glass-quality') === 'realtime' ? 'realtime' : 'lightweight',
)
const shouldRenderGlobalBlurLayer = computed(
() =>
shouldUseTransparentBackgroundTreatment.value &&
transparentBackgroundBlur.value > 0 &&
transparencyGlassQuality.value === 'realtime',
)
let backgroundRetryTimer: number | null = null
let backgroundRequestController: AbortController | null = null
let authenticatedStateTimer: number | null = null
function getStoredNumber(key: string, fallback: number, min: number, max: number) {
const parsed = Number.parseFloat(localStorage.getItem(key) || '')
if (!Number.isFinite(parsed)) return fallback
function applyTransparentBackgroundSettings() {
const settings = applyStoredTransparencySettings()
return Math.min(max, Math.max(min, parsed))
transparentBackgroundBlur.value = settings.backgroundBlur
transparencyGlassQuality.value = settings.glassQuality
}
function applyTransparentBackgroundSettings() {
document.documentElement.style.setProperty(
'--transparent-background-poster-opacity',
(1 - getStoredNumber('transparency-background-poster-opacity', 0, 0, 1)).toString(),
)
document.documentElement.style.setProperty(
'--transparent-background-blur',
`${getStoredNumber('transparency-background-blur', 16, 0, 30)}px`,
)
function handleTransparencySettingsChanged(event: Event) {
const { backgroundBlur, glassQuality } = (event as CustomEvent<TransparencySettings>).detail
transparentBackgroundBlur.value = backgroundBlur
transparencyGlassQuality.value = glassQuality
}
applyTransparentBackgroundSettings()
@@ -360,6 +375,7 @@ onMounted(async () => {
document.addEventListener('visibilitychange', handleVisibilityThemeSync)
window.addEventListener('pageshow', handlePageShowThemeSync)
window.addEventListener('focus', handlePageShowThemeSync)
window.addEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged)
// 登录页壁纸仅在未登录登录页需要,避免其他首屏额外发起图片列表请求。
watch(
@@ -413,6 +429,7 @@ onUnmounted(() => {
document.removeEventListener('visibilitychange', handleVisibilityThemeSync)
window.removeEventListener('pageshow', handlePageShowThemeSync)
window.removeEventListener('focus', handlePageShowThemeSync)
window.removeEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged)
})
</script>
@@ -422,7 +439,11 @@ onUnmounted(() => {
<div
v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)"
class="background-container"
:class="{ 'is-transparent-theme': isTransparentTheme && isLogin }"
:class="{
'is-transparent-theme': shouldUseTransparentBackgroundTreatment,
'is-transparent-glass-lightweight':
shouldUseTransparentBackgroundTreatment && transparencyGlassQuality === 'lightweight',
}"
>
<div
v-for="(imageUrl, index) in backgroundImages"
@@ -432,7 +453,7 @@ onUnmounted(() => {
:style="{ 'backgroundImage': `url(${imageUrl})` }"
/>
<!-- 全局磨砂层 -->
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
<div v-if="shouldRenderGlobalBlurLayer" class="global-blur-layer"></div>
</div>
<!-- 页面内容 -->
<VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
@@ -494,6 +515,17 @@ onUnmounted(() => {
opacity: var(--transparent-background-poster-opacity, 1);
}
.background-container.is-transparent-glass-lightweight .background-image.active {
filter: blur(var(--transparent-background-blur, 16px));
transform: scale(1.03);
}
.background-container.is-transparent-glass-lightweight .background-image.active::after {
background:
linear-gradient(rgba(0, 0, 0, 30%) 0%, rgba(0, 0, 0, 60%) 100%),
rgba(128, 128, 128, 30%);
}
/* 全局磨砂层 */
.global-blur-layer {
position: absolute;
@@ -38,9 +38,11 @@ const {
onBackgroundBlurChange,
onBackgroundPosterOpacityChange,
onBlurChange,
onGlassQualityChange,
onOpacityChange,
resetTransparencySettings,
transparencyBlur,
transparencyGlassQuality,
transparencyOpacity,
} = useTransparencySettings()
</script>
@@ -118,6 +120,34 @@ const {
/>
</div>
<div>
<span class="text-body-2 d-block mb-2">{{ t('theme.transparencyGlassQuality') }}</span>
<VBtnToggle
v-model="transparencyGlassQuality"
mandatory
divided
density="comfortable"
variant="outlined"
color="primary"
class="w-full"
@update:model-value="onGlassQualityChange"
>
<VBtn value="lightweight" class="flex-1">
{{ t('theme.transparencyGlassQualityLightweight') }}
</VBtn>
<VBtn value="realtime" class="flex-1">
{{ t('theme.transparencyGlassQualityRealtime') }}
</VBtn>
</VBtnToggle>
<p class="text-caption text-medium-emphasis mt-2 mb-0">
{{
transparencyGlassQuality === 'realtime'
? t('theme.transparencyGlassQualityRealtimeHint')
: t('theme.transparencyGlassQualityLightweightHint')
}}
</p>
</div>
<div>
<span class="text-body-2 d-block mb-2">{{ t('common.preset') }}</span>
<VBtnGroup density="compact" variant="outlined" class="w-full">
+34 -2
View File
@@ -1,24 +1,36 @@
import { computed, ref } from 'vue'
export type TransparencyGlassQuality = 'lightweight' | 'realtime'
export interface TransparencySettings {
backgroundBlur: number
backgroundPosterOpacity: number
blur: number
glassQuality: TransparencyGlassQuality
level: string
opacity: number
}
export const transparencyPresets = {
low: { opacity: 0.1, blur: 5 },
low: { opacity: 0.6, blur: 5 },
medium: { opacity: 0.3, blur: 10 },
high: { opacity: 0.6, blur: 15 },
high: { opacity: 0.1, blur: 15 },
}
export const TRANSPARENCY_SETTINGS_CHANGED_EVENT = 'transparency-settings-changed'
/** 将数值限制在指定范围内。 */
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
/** 读取玻璃质量档位,未知值回落到默认的轻量玻璃路径。 */
function readGlassQuality(): TransparencyGlassQuality {
const storedQuality = localStorage.getItem('transparency-glass-quality')
return storedQuality === 'realtime' ? 'realtime' : 'lightweight'
}
/** 从本地存储读取透明主题设置。 */
export function readTransparencySettings(): TransparencySettings {
return {
@@ -26,6 +38,7 @@ export function readTransparencySettings(): TransparencySettings {
blur: parseFloat(localStorage.getItem('transparency-blur') || '10'),
backgroundPosterOpacity: parseFloat(localStorage.getItem('transparency-background-poster-opacity') || '0'),
backgroundBlur: parseFloat(localStorage.getItem('transparency-background-blur') || '16'),
glassQuality: readGlassQuality(),
level: localStorage.getItem('transparency-level') || 'medium',
}
}
@@ -39,6 +52,7 @@ export function applyTransparencySettings(settings: TransparencySettings) {
? clamp(settings.backgroundPosterOpacity, 0, 1)
: 0,
backgroundBlur: Number.isFinite(settings.backgroundBlur) ? clamp(settings.backgroundBlur, 0, 30) : 16,
glassQuality: settings.glassQuality === 'realtime' ? 'realtime' : 'lightweight',
level: settings.level,
}
@@ -51,13 +65,20 @@ export function applyTransparencySettings(settings: TransparencySettings) {
root.style.setProperty('--transparent-blur-heavy', `${normalized.blur * 1.6}px`)
root.style.setProperty('--transparent-background-poster-opacity', (1 - normalized.backgroundPosterOpacity).toString())
root.style.setProperty('--transparent-background-blur', `${normalized.backgroundBlur}px`)
root.classList.toggle('transparent-blur-disabled', normalized.blur <= 0)
root.classList.toggle('transparent-background-blur-disabled', normalized.backgroundBlur <= 0)
root.classList.toggle('transparent-glass-lightweight', normalized.glassQuality === 'lightweight')
root.classList.toggle('transparent-glass-realtime', normalized.glassQuality === 'realtime')
localStorage.setItem('transparency-opacity', normalized.opacity.toString())
localStorage.setItem('transparency-blur', normalized.blur.toString())
localStorage.setItem('transparency-background-poster-opacity', normalized.backgroundPosterOpacity.toString())
localStorage.setItem('transparency-background-blur', normalized.backgroundBlur.toString())
localStorage.setItem('transparency-glass-quality', normalized.glassQuality)
localStorage.setItem('transparency-level', normalized.level)
window.dispatchEvent(new CustomEvent<TransparencySettings>(TRANSPARENCY_SETTINGS_CHANGED_EVENT, { detail: normalized }))
return normalized
}
@@ -73,6 +94,7 @@ export function useTransparencySettings() {
const transparencyBlur = ref(storedSettings.blur)
const backgroundPosterOpacity = ref(storedSettings.backgroundPosterOpacity)
const backgroundBlur = ref(storedSettings.backgroundBlur)
const transparencyGlassQuality = ref<TransparencyGlassQuality>(storedSettings.glassQuality)
const transparencyLevel = ref(storedSettings.level)
const currentPresetLevel = computed(() => {
@@ -95,6 +117,7 @@ export function useTransparencySettings() {
blur: transparencyBlur.value,
backgroundPosterOpacity: backgroundPosterOpacity.value,
backgroundBlur: backgroundBlur.value,
glassQuality: transparencyGlassQuality.value,
level: transparencyLevel.value,
})
@@ -102,6 +125,7 @@ export function useTransparencySettings() {
transparencyBlur.value = normalized.blur
backgroundPosterOpacity.value = normalized.backgroundPosterOpacity
backgroundBlur.value = normalized.backgroundBlur
transparencyGlassQuality.value = normalized.glassQuality
transparencyLevel.value = normalized.level
}
@@ -149,12 +173,18 @@ export function useTransparencySettings() {
syncTransparencySettings()
}
/** 处理玻璃质量档位变化。 */
function onGlassQualityChange() {
syncTransparencySettings()
}
/** 重置透明主题设置为默认值。 */
function resetTransparencySettings() {
transparencyOpacity.value = transparencyPresets.medium.opacity
transparencyBlur.value = transparencyPresets.medium.blur
backgroundPosterOpacity.value = 0
backgroundBlur.value = 16
transparencyGlassQuality.value = 'lightweight'
transparencyLevel.value = 'medium'
syncTransparencySettings()
}
@@ -167,10 +197,12 @@ export function useTransparencySettings() {
onBackgroundBlurChange,
onBackgroundPosterOpacityChange,
onBlurChange,
onGlassQualityChange,
onOpacityChange,
resetTransparencySettings,
syncTransparencySettings,
transparencyBlur,
transparencyGlassQuality,
transparencyOpacity,
transparencyLevel,
}
+7
View File
@@ -154,6 +154,13 @@ export default {
transparencyBlur: 'Blur',
backgroundPosterOpacity: 'Background Opacity',
backgroundBlur: 'Background Frosted Blur',
transparencyGlassQuality: 'Glass Effect Quality',
transparencyGlassQualityLightweight: 'Lightweight',
transparencyGlassQualityRealtime: 'Realtime',
transparencyGlassQualityLightweightHint:
'Keeps a similar translucent glass look while reducing live background sampling on dense pages.',
transparencyGlassQualityRealtimeHint:
'Uses the full live glass rendering path for higher visual quality, but may increase GPU usage.',
transparencyReset: 'Reset',
transparencyLow: 'Low Transparency',
transparencyMedium: 'Medium Transparency',
+5
View File
@@ -152,6 +152,11 @@ export default {
transparencyBlur: '模糊度',
backgroundPosterOpacity: '背景透明度',
backgroundBlur: '背景磨砂效果',
transparencyGlassQuality: '玻璃效果质量',
transparencyGlassQualityLightweight: '轻量',
transparencyGlassQualityRealtime: '实时',
transparencyGlassQualityLightweightHint: '保持接近实时毛玻璃的透明观感,减少密集页面的背景实时采样。',
transparencyGlassQualityRealtimeHint: '使用完整实时毛玻璃路径,视觉质量更高,但可能增加 GPU 占用。',
transparencyReset: '重置',
transparencyLow: '低透明度',
transparencyMedium: '中等透明度',
+5
View File
@@ -152,6 +152,11 @@ export default {
transparencyBlur: '模糊度',
backgroundPosterOpacity: '背景透明度',
backgroundBlur: '背景磨砂效果',
transparencyGlassQuality: '玻璃效果品質',
transparencyGlassQualityLightweight: '輕量',
transparencyGlassQualityRealtime: '即時',
transparencyGlassQualityLightweightHint: '保持接近即時毛玻璃的透明觀感,減少密集頁面的背景即時取樣。',
transparencyGlassQualityRealtimeHint: '使用完整即時毛玻璃路徑,視覺品質更高,但可能增加 GPU 佔用。',
transparencyReset: '重置',
transparencyLow: '低透明度',
transparencyMedium: '中等透明度',
+5
View File
@@ -348,6 +348,11 @@ onActivated(async () => {
transition: opacity 0.3s ease;
}
.content-group :deep(.slider-content-wrapper) {
content-visibility: auto;
contain-intrinsic-block-size: 16rem;
}
.empty-category {
display: flex;
flex-direction: column;
+7
View File
@@ -707,6 +707,13 @@ html[data-theme="transparent"] .app-card-colorful,
--app-surface-border: 0 solid transparent;
}
html[data-theme="transparent"].transparent-glass-lightweight .app-card-colorful,
html[data-theme="transparent"].transparent-glass-lightweight .v-theme--transparent .app-card-colorful,
html[data-theme="transparent"].transparent-glass-realtime .app-card-colorful,
html[data-theme="transparent"].transparent-glass-realtime .v-theme--transparent .app-card-colorful {
backdrop-filter: none;
}
// 保证卡片右上角的浮动操作区始终高于可点击的卡片内容层,避免误触发详情打开。
.app-card-top-action {
z-index: 2;
+119
View File
@@ -165,3 +165,122 @@ html[data-theme="transparent"] {
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy));
}
}
html[data-theme="transparent"].transparent-background-blur-disabled {
.global-blur-layer,
.background-image.active {
backdrop-filter: none;
filter: none;
transform: none;
}
}
html[data-theme="transparent"].transparent-glass-lightweight {
.global-blur-layer {
display: none !important;
backdrop-filter: none !important;
}
.layout-vertical-nav,
.v-toolbar,
.v-footer,
.theme-customizer-panel-host,
.theme-customizer-panel,
.agent-assistant-panel,
.agent-assistant-fab__bubble,
.nav-button,
.layout-navbar {
backdrop-filter: blur(var(--transparent-blur));
}
.layout-vertical-nav,
.theme-customizer-panel-host,
.agent-assistant-panel {
backdrop-filter: blur(var(--transparent-blur-heavy));
}
.v-list,
.v-card:not(.no-blur, .bg-primary),
.v-sheet,
.v-expansion-panel {
backdrop-filter: none;
}
.v-card:not(.no-blur, .bg-primary),
.v-sheet,
.v-expansion-panel,
.v-list {
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));
}
.v-overlay__content,
.v-overlay__content .v-card:not(.bg-primary),
.v-overlay__content .v-list {
backdrop-filter: blur(var(--transparent-blur));
}
.v-overlay__content .v-card:not(.bg-primary),
.v-overlay__content .v-list {
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy));
}
.app-card-colorful {
backdrop-filter: none;
}
}
html[data-theme="transparent"].transparent-glass-realtime {
.background-image.active {
filter: none;
transform: none;
}
.v-list,
.v-card:not(.no-blur, .bg-primary),
.v-sheet,
.v-expansion-panel {
backdrop-filter: none;
}
.layout-vertical-nav,
.v-toolbar,
.v-footer,
.theme-customizer-panel-host,
.theme-customizer-panel,
.agent-assistant-panel,
.agent-assistant-fab__bubble,
.nav-button,
.layout-navbar,
.v-overlay__content,
.v-overlay__content .v-card:not(.bg-primary),
.v-overlay__content .v-list {
backdrop-filter: blur(var(--transparent-blur));
}
.layout-vertical-nav,
.theme-customizer-panel-host,
.agent-assistant-panel {
backdrop-filter: blur(var(--transparent-blur-heavy));
}
}
html[data-theme="transparent"].transparent-blur-disabled {
.layout-vertical-nav,
.v-list,
.v-card:not(.no-blur),
.v-toolbar,
.v-footer,
.v-sheet,
.v-expansion-panel,
.v-overlay__content,
.v-overlay__content .v-card:not(.bg-primary),
.v-overlay__content .v-list,
.theme-customizer-panel-host,
.theme-customizer-panel,
.agent-assistant-panel,
.agent-assistant-fab__bubble,
.nav-button,
.layout-navbar {
backdrop-filter: none !important;
}
}
+28
View File
@@ -26,6 +26,7 @@ provide('rankingPropsKey', reactive({ ...props }))
const componentLoaded = ref(false)
// 是否已尝试加载
const hasTriedLoading = ref(false)
const loadingStarted = ref(false)
// 使用 shallowRef 避免横向卡片区的大数组深层代理
const dataList = shallowRef<MediaInfo[]>([])
@@ -36,6 +37,8 @@ const containerRef = ref<HTMLElement | null>(null)
// 获取订阅列表数据
async function fetchData() {
try {
if (loadingStarted.value) return
loadingStarted.value = true
if (!props.apipath) return
dataList.value = await api.get(props.apipath)
if (dataList.value.length > 0) {
@@ -47,10 +50,29 @@ async function fetchData() {
console.error(error)
componentLoaded.value = true
} finally {
loadingStarted.value = false
hasTriedLoading.value = true
}
}
function isNearViewport(element: HTMLElement, rootMargin = 300) {
const rect = element.getBoundingClientRect()
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
return rect.bottom >= -rootMargin && rect.top <= viewportHeight + rootMargin
}
/** IntersectionObserver 未及时回调时,首屏附近内容仍需主动发起数据请求。 */
function loadIfNearViewport() {
const element = containerRef.value
if (!element || loadingStarted.value || hasTriedLoading.value) return
if (isNearViewport(element)) {
fetchData()
stop()
}
}
// 使用 IntersectionObserver 实现懒加载
const { stop } = useIntersectionObserver(
containerRef,
@@ -65,7 +87,13 @@ const { stop } = useIntersectionObserver(
},
)
onMounted(() => {
requestAnimationFrame(loadIfNearViewport)
window.setTimeout(loadIfNearViewport, 600)
})
onActivated(() => {
loadIfNearViewport()
if (dataList.value.length == 0 && hasTriedLoading.value) {
fetchData()
}