feat: add glass materials and quality profiles (#577)

This commit is contained in:
InfinityPacer
2026-07-23 06:08:39 +08:00
committed by GitHub
parent 80519e6839
commit 1ed69ccc64
19 changed files with 2587 additions and 238 deletions
+12 -7
View File
@@ -34,10 +34,15 @@ $ps-track-size: 0.5rem;
inline-size: $ps-hover-size; inline-size: $ps-hover-size;
} }
// fix bug // 滚动条只反馈实际滚动状态,鼠标经过容器或键盘聚焦不应让轨道常驻。
@media(hover: none) { .ps:hover > .ps__rail-x,
.ps > .ps__rail-x, .ps:hover > .ps__rail-y,
.ps > .ps__rail-y { .ps--focus > .ps__rail-x,
opacity: 0.6; .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;
}
+127 -25
View File
@@ -11,7 +11,7 @@ import { globalLoadingStateManager } from '@/utils/loadingStateManager'
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager' import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue' import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue' import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
import { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer' import { applyStoredThemeCustomizerAppearance, useEffectiveGlassSettings } from '@/composables/useThemeCustomizer'
import { import {
applyStoredTransparencySettings, applyStoredTransparencySettings,
TRANSPARENCY_SETTINGS_CHANGED_EVENT, TRANSPARENCY_SETTINGS_CHANGED_EVENT,
@@ -22,15 +22,14 @@ import { completeLaunchLoading } from '@/composables/useLaunchLoading'
import { usePWA } from '@/composables/usePWA' import { usePWA } from '@/composables/usePWA'
import { themeManager } from '@/utils/themeManager' import { themeManager } from '@/utils/themeManager'
import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette' import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import { configureApexChartsTheme } from '@/utils/apexCharts' import { configureApexChartsTheme } from '@/utils/apexCharts'
import { import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
useGlobalOfflineStatus,
type ConnectionFailureReason,
} from '@/composables/useOfflineStatus'
const LOGIN_WALLPAPER_ROUTE = '/login' const LOGIN_WALLPAPER_ROUTE = '/login'
const BACKGROUND_CROSSFADE_DURATION_MS = 1500 const BACKGROUND_CROSSFADE_DURATION_MS = 1500
const WINDOW_BLUR_RENDER_THROTTLE_DELAY_MS = 180_000 const WINDOW_BLUR_RENDER_THROTTLE_DELAY_MS = 180_000
const MEDIA_DENSE_OPTICAL_DEFER_MS = 1_600
// 生效主题 // 生效主题
const vuetifyTheme = useTheme() const vuetifyTheme = useTheme()
@@ -59,6 +58,7 @@ setI18nLanguage(localeValue as SupportedLocale)
const authStore = useAuthStore() const authStore = useAuthStore()
const isLogin = computed(() => authStore.token) const isLogin = computed(() => authStore.token)
const route = useRoute() const route = useRoute()
const router = useRouter()
const { initializePWA } = usePWA() const { initializePWA } = usePWA()
const offlineStatus = useGlobalOfflineStatus() const offlineStatus = useGlobalOfflineStatus()
@@ -72,8 +72,14 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'
const backgroundImages = ref<string[]>([]) const backgroundImages = ref<string[]>([])
const activeImageIndex = ref(0) const activeImageIndex = ref(0)
const previousImageIndex = ref<number | null>(null) const previousImageIndex = ref<number | null>(null)
const isBackgroundCrossfading = ref(false)
const isRenderThrottled = ref(document.visibilityState === 'hidden')
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent') const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
const isGlassTheme = computed(() => globalTheme.name.value === 'glass') 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 isBackdropTheme = computed(() => isTransparentTheme.value || isGlassTheme.value)
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE) const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value) const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value)
@@ -81,6 +87,23 @@ const shouldUseGlassBackgroundTreatment = computed(() => Boolean(isLogin.value)
const shouldLoadBackgroundImages = computed( const shouldLoadBackgroundImages = computed(
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isBackdropTheme.value), () => 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 transparentBackgroundBlur = ref(16)
const transparencyGlassQuality = ref<TransparencyGlassQuality>( const transparencyGlassQuality = ref<TransparencyGlassQuality>(
localStorage.getItem('transparency-glass-quality') === 'realtime' ? 'realtime' : 'lightweight', localStorage.getItem('transparency-glass-quality') === 'realtime' ? 'realtime' : 'lightweight',
@@ -91,7 +114,6 @@ const shouldRenderGlobalBlurLayer = computed(
transparentBackgroundBlur.value > 0 && transparentBackgroundBlur.value > 0 &&
transparencyGlassQuality.value === 'realtime', transparencyGlassQuality.value === 'realtime',
) )
const isRenderThrottled = ref(document.visibilityState === 'hidden')
let backgroundRetryTimer: number | null = null let backgroundRetryTimer: number | null = null
let backgroundRequestController: AbortController | null = null let backgroundRequestController: AbortController | null = null
let backgroundCrossfadeTimer: number | null = null let backgroundCrossfadeTimer: number | null = null
@@ -116,6 +138,39 @@ function handleTransparencySettingsChanged(event: Event) {
applyTransparentBackgroundSettings() 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() { function clearWindowBlurRenderThrottleTimer() {
if (windowBlurRenderThrottleTimer) { if (windowBlurRenderThrottleTimer) {
window.clearTimeout(windowBlurRenderThrottleTimer) window.clearTimeout(windowBlurRenderThrottleTimer)
@@ -212,13 +267,10 @@ async function probeServerConnection(showChecking = false): Promise<boolean> {
const successSequenceAtProbeStart = offlineStatus.serverSuccessSequence.value const successSequenceAtProbeStart = offlineStatus.serverSuccessSequence.value
const probePromise = (async () => { const probePromise = (async () => {
try { try {
await api.get( await api.get('system/ping', {
'system/ping', skipConnectionTracking: true,
{ timeout: SERVER_PROBE_TIMEOUT_MS,
skipConnectionTracking: true, } as ConnectionAwareRequestConfig)
timeout: SERVER_PROBE_TIMEOUT_MS,
} as ConnectionAwareRequestConfig,
)
connectionProbeFailures = 0 connectionProbeFailures = 0
return true return true
} catch (error) { } catch (error) {
@@ -261,9 +313,12 @@ function startHeartbeat() {
void probeServerConnection() void probeServerConnection()
heartbeatInterval = window.setInterval(async () => { heartbeatInterval = window.setInterval(
if (isLogin.value) await probeServerConnection() async () => {
}, 5 * 60 * 1000) if (isLogin.value) await probeServerConnection()
},
5 * 60 * 1000,
)
} }
/** 停止心跳和等待中的自动重连任务。 */ /** 停止心跳和等待中的自动重连任务。 */
@@ -371,6 +426,7 @@ function clearBackgroundCrossfadeTimer() {
function resetBackgroundCrossfade() { function resetBackgroundCrossfade() {
clearBackgroundCrossfadeTimer() clearBackgroundCrossfadeTimer()
previousImageIndex.value = null previousImageIndex.value = null
isBackgroundCrossfading.value = false
} }
// 切换期保留上一张背景的渲染状态,避免图片合成层重建时露出透明底。 // 切换期保留上一张背景的渲染状态,避免图片合成层重建时露出透明底。
@@ -379,9 +435,11 @@ function activateBackgroundImage(nextIndex: number) {
clearBackgroundCrossfadeTimer() clearBackgroundCrossfadeTimer()
previousImageIndex.value = activeImageIndex.value previousImageIndex.value = activeImageIndex.value
isBackgroundCrossfading.value = true
activeImageIndex.value = nextIndex activeImageIndex.value = nextIndex
backgroundCrossfadeTimer = window.setTimeout(() => { backgroundCrossfadeTimer = window.setTimeout(() => {
previousImageIndex.value = null previousImageIndex.value = null
isBackgroundCrossfading.value = false
backgroundCrossfadeTimer = null backgroundCrossfadeTimer = null
}, BACKGROUND_CROSSFADE_DURATION_MS) }, BACKGROUND_CROSSFADE_DURATION_MS)
} }
@@ -633,6 +691,7 @@ onMounted(async () => {
}) })
onUnmounted(() => { onUnmounted(() => {
if (glassOpticalLayerDeferTimer !== null) window.clearTimeout(glassOpticalLayerDeferTimer)
// 清除背景轮换定时器 // 清除背景轮换定时器
stopBackgroundLoading() stopBackgroundLoading()
if (authenticatedStateTimer) { if (authenticatedStateTimer) {
@@ -654,7 +713,14 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div class="app-wrapper" :class="{ 'app-wrapper--render-throttled': isRenderThrottled }"> <div
class="app-wrapper"
:class="{
'app-wrapper--background-transition': isBackgroundCrossfading,
'app-wrapper--render-throttled': isRenderThrottled,
}"
:style="appWrapperStyle"
>
<!-- 登录页透明主题和玻璃主题共用动态壁纸场景 --> <!-- 登录页透明主题和玻璃主题共用动态壁纸场景 -->
<div <div
v-if="backgroundImages.length > 0 && (isBackdropTheme || !isLogin)" v-if="backgroundImages.length > 0 && (isBackdropTheme || !isLogin)"
@@ -676,6 +742,15 @@ onUnmounted(() => {
<!-- 全局磨砂层 --> <!-- 全局磨砂层 -->
<div v-if="shouldRenderGlobalBlurLayer" class="global-blur-layer"></div> <div v-if="shouldRenderGlobalBlurLayer" class="global-blur-layer"></div>
</div> </div>
<GlassOpticalLayer
v-if="shouldRenderGlassOpticalLayer"
:appearance="effectiveGlassSettings.glassAppearance"
:class="{ 'glass-optical-layer--background-transition': isBackgroundCrossfading }"
:quality="effectiveGlassSettings.glassQuality === 'high' ? 'high' : 'balanced'"
:route-key="route.fullPath"
:tint-color="globalTheme.current.value.colors.primary"
:wallpaper-url="activeOpticalBackgroundImage"
/>
<!-- 页面内容 --> <!-- 页面内容 -->
<VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }"> <VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
<RouterView /> <RouterView />
@@ -743,19 +818,48 @@ onUnmounted(() => {
.background-container.is-glass-theme .background-image.active, .background-container.is-glass-theme .background-image.active,
.background-container.is-glass-theme .background-image.previous { .background-container.is-glass-theme .background-image.previous {
filter: brightness(0.82) saturate(0.9); filter: brightness(0.86) saturate(0.95) contrast(1.02);
} }
.background-container.is-glass-theme .background-image.active { .background-container.is-glass-theme .background-image.active {
opacity: 0.92; opacity: 0.94;
} }
// 玻璃主题用冷色暗幕压低壁纸对比度,避免轻阴影表面与背景直接冲突。
.background-container.is-glass-theme .background-image.active::after, .background-container.is-glass-theme .background-image.active::after,
.background-container.is-glass-theme .background-image.previous::after { .background-container.is-glass-theme .background-image.previous::after {
background: background:
linear-gradient(rgba(6, 10, 19, 30%) 0%, rgba(6, 10, 19, 58%) 100%), radial-gradient(circle at 50% 18%, transparent 24%, rgba(6, 10, 19, 16%) 100%),
rgba(11, 19, 34, 10%); linear-gradient(rgba(6, 10, 19, 14%) 0%, rgba(6, 10, 19, 40%) 100%);
}
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.active,
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.previous {
filter: brightness(0.85) saturate(0.97) contrast(1.02);
}
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.active {
opacity: 0.93;
}
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.active::after,
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.previous::after {
background:
radial-gradient(circle at 50% 18%, transparent 22%, rgba(6, 10, 19, 18%) 100%),
linear-gradient(rgba(6, 10, 19, 14%) 0%, rgba(6, 10, 19, 42%) 100%), rgba(var(--v-theme-primary), 3%);
}
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.active,
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.previous {
filter: brightness(0.82) saturate(0.9);
}
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.active {
opacity: 0.92;
}
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.active::after,
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.previous::after {
background: linear-gradient(rgba(6, 10, 19, 30%) 0%, rgba(6, 10, 19, 58%) 100%), rgba(11, 19, 34, 10%);
} }
.background-container.is-transparent-glass-lightweight .background-image.active, .background-container.is-transparent-glass-lightweight .background-image.active,
@@ -766,9 +870,7 @@ onUnmounted(() => {
.background-container.is-transparent-glass-lightweight .background-image.active::after, .background-container.is-transparent-glass-lightweight .background-image.active::after,
.background-container.is-transparent-glass-lightweight .background-image.previous::after { .background-container.is-transparent-glass-lightweight .background-image.previous::after {
background: background: linear-gradient(rgba(0, 0, 0, 30%) 0%, rgba(0, 0, 0, 60%) 100%), rgba(128, 128, 128, 30%);
linear-gradient(rgba(0, 0, 0, 30%) 0%, rgba(0, 0, 0, 60%) 100%),
rgba(128, 128, 128, 30%);
} }
/* 全局磨砂层 */ /* 全局磨砂层 */
@@ -548,6 +548,34 @@ describe('MediaCard', () => {
) )
}) })
it('renders the AniList source badge after the poster loads', async () => {
const media = createMediaInfo({
anilist_id: 154588,
poster_path: '/original/anilist.jpg',
source: 'anilist',
tmdb_id: undefined,
type: '电视剧',
})
const VImgStub = defineComponent({
name: 'VImg',
emits: ['load'],
props: { src: String },
setup(_props, { emit, slots }) {
return () =>
h('div', [h('button', { 'aria-label': '图片加载成功', onClick: () => emit('load') }), slots.default?.()])
},
})
const { container } = await renderWithProviders(MediaCard, {
props: { media, width: '9rem' },
initialState: { user: { superUser: true } },
global: { stubs: { VImg: VImgStub } },
})
await fireEvent.click(container.querySelector('[aria-label="图片加载成功"]') as HTMLElement)
await waitFor(() => expect(container.querySelector('.v-avatar .iconify--mdi')).not.toBeNull())
})
it('hides search and subscribe actions when the user lacks both permissions', async () => { it('hides search and subscribe actions when the user lacks both permissions', async () => {
const { container } = await renderCard(createMediaInfo({ tmdb_id: 9601 }), { const { container } = await renderCard(createMediaInfo({ tmdb_id: 9601 }), {
permissions: { permissions: {
@@ -0,0 +1,284 @@
<script setup lang="ts">
import {
cancelGlassPreview,
commitGlassPreview,
previewGlassSettings,
useThemeCustomizer,
type ThemeCustomizerGlassAppearance,
type ThemeCustomizerGlassQuality,
} from '@/composables/useThemeCustomizer'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
const props = withDefaults(
defineProps<{
modelValue?: boolean
}>(),
{
modelValue: true,
},
)
const emit = defineEmits<{
(event: 'close'): void
(event: 'update:modelValue', value: boolean): void
}>()
const { t } = useI18n()
const display = useDisplay()
const { settings } = useThemeCustomizer()
const draftAppearance = ref<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance)
const draftQuality = ref<ThemeCustomizerGlassQuality>(settings.value.glassQuality)
const isSaving = ref(false)
const visible = computed({
get: () => props.modelValue,
set: value => {
if (!value) cancelGlassPreview()
emit('update:modelValue', value)
if (!value) emit('close')
},
})
/** 父级控制弹窗生命周期时,同步结束旧预览并重建持久化草稿。 */
watch(
() => props.modelValue,
(value, previous) => {
if (value) {
draftAppearance.value = settings.value.glassAppearance
draftQuality.value = settings.value.glassQuality
} else if (previous) {
cancelGlassPreview()
}
},
)
const appearanceOptions: Array<{
label: string
value: ThemeCustomizerGlassAppearance
}> = [
{ label: 'theme.glassAppearanceClear', value: 'clear' },
{ label: 'theme.glassAppearanceTinted', value: 'tinted' },
{ label: 'theme.glassAppearanceFrosted', value: 'frosted' },
]
const qualityOptions: Array<{
label: string
value: ThemeCustomizerGlassQuality
}> = [
{ label: 'theme.glassQualityCss', value: 'css' },
{ label: 'theme.glassQualityBalanced', value: 'balanced' },
{ label: 'theme.glassQualityHigh', value: 'high' },
]
/** 仅允许已实现的材质进入待保存设置。 */
function updateAppearance(value: unknown) {
if (value !== 'clear' && value !== 'tinted' && value !== 'frosted') return
draftAppearance.value = value
previewGlassSettings({ glassAppearance: value })
}
/** 仅允许面板声明的质量档位进入待保存设置。 */
function updateQuality(value: unknown) {
const option = qualityOptions.find(item => item.value === value)
if (!option) return
draftQuality.value = option.value
previewGlassSettings({ glassQuality: option.value })
}
/** 一次提交当前预览,持久化后关闭不会发生视觉回跳。 */
async function saveSettings() {
if (isSaving.value) return
isSaving.value = true
try {
previewGlassSettings({
glassAppearance: draftAppearance.value,
glassQuality: draftQuality.value,
})
commitGlassPreview()
visible.value = false
} finally {
isSaving.value = false
}
}
// 弹窗的任意未保存销毁路径都应恢复持久化快照。
onScopeDispose(cancelGlassPreview)
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="28rem" scrollable :fullscreen="display.xs.value">
<VCard class="glass-settings-dialog">
<VCardItem class="glass-settings-dialog__header py-3">
<template #prepend>
<VAvatar color="primary" variant="tonal" rounded size="40" class="me-2">
<VIcon icon="mdi-blur-radial" size="22" />
</VAvatar>
</template>
<VCardTitle>
{{ t('theme.glassSettings') }}
</VCardTitle>
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<VCardText class="glass-settings-dialog__body">
<section>
<h3 class="glass-settings-dialog__label">{{ t('theme.glassAppearance') }}</h3>
<VBtnToggle
:model-value="draftAppearance"
mandatory
color="primary"
variant="text"
class="glass-settings-dialog__appearance"
@update:model-value="updateAppearance"
>
<VBtn
v-for="option in appearanceOptions"
:key="option.value"
:value="option.value"
class="glass-settings-dialog__appearance-option"
>
{{ t(option.label) }}
</VBtn>
</VBtnToggle>
</section>
<section>
<h3 class="glass-settings-dialog__label">{{ t('theme.glassQuality') }}</h3>
<VBtnToggle
:model-value="draftQuality"
mandatory
color="primary"
variant="text"
class="glass-settings-dialog__quality"
@update:model-value="updateQuality"
>
<VBtn
v-for="option in qualityOptions"
:key="option.value"
:value="option.value"
class="glass-settings-dialog__quality-option"
>
{{ t(option.label) }}
</VBtn>
</VBtnToggle>
</section>
</VCardText>
<VCardActions class="app-dialog-actions glass-settings-dialog__actions">
<VSpacer />
<VBtn
color="primary"
variant="flat"
prepend-icon="mdi-content-save"
class="px-5"
:loading="isSaving"
@click="saveSettings"
>
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped lang="scss">
.glass-settings-dialog {
overflow: hidden;
max-block-size: calc(100dvh - 2rem);
}
.glass-settings-dialog__header {
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.glass-settings-dialog__body {
display: grid;
align-content: start;
gap: 24px;
grid-auto-rows: max-content;
padding: 20px 24px 24px;
}
.glass-settings-dialog__label {
margin: 0 0 10px;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.4;
}
.glass-settings-dialog__appearance,
.glass-settings-dialog__quality {
display: grid;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 10px;
background: rgba(var(--v-theme-on-surface), 0.035);
gap: 4px;
inline-size: 100%;
padding: 4px;
}
.glass-settings-dialog__appearance {
block-size: 42px !important;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.glass-settings-dialog__quality {
block-size: 42px !important;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.glass-settings-dialog__appearance-option {
block-size: 32px !important;
inline-size: 100%;
letter-spacing: 0;
}
.glass-settings-dialog__appearance-option,
.glass-settings-dialog__quality-option {
border: 0 !important;
border-radius: 7px !important;
box-shadow: none !important;
min-inline-size: 0;
inline-size: 100%;
letter-spacing: 0;
padding-inline: 8px;
}
.glass-settings-dialog__quality-option {
block-size: 32px !important;
}
.glass-settings-dialog__appearance-option:deep(.v-btn--active),
.glass-settings-dialog__quality-option:deep(.v-btn--active) {
background-color: rgba(var(--v-theme-primary), 0.14) !important;
box-shadow: inset 0 0 0 1px rgba(var(--v-theme-primary), 0.38) !important;
}
.glass-settings-dialog__actions {
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
padding-block: 0.875rem;
padding-inline: 1rem;
}
@media (width <= 600px) {
.glass-settings-dialog {
block-size: 100dvh;
max-block-size: 100dvh;
}
.glass-settings-dialog__body {
padding-inline: 18px;
}
.glass-settings-dialog__actions {
padding-block-end: max(0.875rem, calc(env(safe-area-inset-bottom) + 0.75rem));
}
}
</style>
@@ -0,0 +1,48 @@
import { shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import GlassSettingsDialog from '@/components/dialog/GlassSettingsDialog.vue'
const mocks = vi.hoisted(() => ({
cancelGlassPreview: vi.fn(),
}))
vi.mock('@/composables/useThemeCustomizer', () => ({
cancelGlassPreview: mocks.cancelGlassPreview,
commitGlassPreview: vi.fn(),
previewGlassSettings: vi.fn(),
useThemeCustomizer: () => ({
settings: {
value: {
glassAppearance: 'clear',
glassQuality: 'css',
},
},
}),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('vuetify', () => ({
useDisplay: () => ({ xs: { value: false } }),
}))
describe('GlassSettingsDialog', () => {
beforeEach(() => {
mocks.cancelGlassPreview.mockClear()
})
it('cancels an active preview when the parent closes the dialog', async () => {
const wrapper = shallowMount(GlassSettingsDialog, {
global: {
stubs: { VDialogCloseBtn: true },
},
props: { modelValue: true },
})
await wrapper.setProps({ modelValue: false })
expect(mocks.cancelGlassPreview).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
import { useGlassOpticalRenderer } from '@/composables/useGlassOpticalRenderer'
const props = defineProps<{
/** 当前玻璃材质,用于选择透明、色调或磨砂的光学参数。 */
appearance: ThemeCustomizerGlassAppearance
/** 当前光学质量;标准档不会挂载该组件。 */
quality: Exclude<ThemeCustomizerGlassQuality, 'css'>
/** 路由变化标识,用于在页面内容稳定后重新发现高价值表面。 */
routeKey: string
/** 当前主题主色,用于同步色调材质的光学高光。 */
tintColor: string
/** 与 CSS 背景保持一致的活动壁纸。 */
wallpaperUrl: string
}>()
const canvas = ref<HTMLCanvasElement | null>(null)
const { state } = useGlassOpticalRenderer({
active: true,
appearance: () => props.appearance,
canvas,
quality: () => props.quality,
routeKey: () => props.routeKey,
tintColor: () => props.tintColor,
wallpaperUrl: () => props.wallpaperUrl,
})
</script>
<template>
<canvas ref="canvas" class="glass-optical-layer" aria-hidden="true" :data-state="state" />
</template>
+12 -14
View File
@@ -22,6 +22,8 @@ const customColorInput = ref<HTMLInputElement | null>(null)
const { const {
isCustomized, isCustomized,
resetSettings, resetSettings,
setGlassAppearance,
setGlassQuality,
setLayout, setLayout,
setPrimaryColor, setPrimaryColor,
setRadius, setRadius,
@@ -137,6 +139,8 @@ const showShadowSection = computed(() => globalTheme.name.value !== 'glass')
const hasAppModeCustomization = computed(() => { const hasAppModeCustomization = computed(() => {
return ( return (
settings.value.primaryColor !== defaultPrimaryColor || settings.value.primaryColor !== defaultPrimaryColor ||
settings.value.glassAppearance !== 'clear' ||
settings.value.glassQuality !== 'css' ||
settings.value.radius !== 'default' || settings.value.radius !== 'default' ||
settings.value.shadow !== '0' || settings.value.shadow !== '0' ||
settings.value.skin !== 'default' || settings.value.skin !== 'default' ||
@@ -197,6 +201,8 @@ async function handleResetSettings() {
// App 模式共享定制器,但保留桌面导航相关偏好,只重置 App 侧可调整的外观设置。 // App 模式共享定制器,但保留桌面导航相关偏好,只重置 App 侧可调整的外观设置。
await setPrimaryColor(defaultPrimaryColor) await setPrimaryColor(defaultPrimaryColor)
await setGlassAppearance('clear')
await setGlassQuality('css')
await setRadius('default') await setRadius('default')
await setShadow('0') await setShadow('0')
await setSkin('default') await setSkin('default')
@@ -205,11 +211,7 @@ async function handleResetSettings() {
</script> </script>
<template> <template>
<aside <aside class="theme-customizer-panel-host" role="dialog" :aria-label="t('theme.customizer.title')">
class="theme-customizer-panel-host"
role="dialog"
:aria-label="t('theme.customizer.title')"
>
<div class="theme-customizer-panel" :class="{ 'theme-customizer-panel--dialog': appMode, 'app-surface': appMode }"> <div class="theme-customizer-panel" :class="{ 'theme-customizer-panel--dialog': appMode, 'app-surface': appMode }">
<div class="theme-customizer-header py-5 px-4"> <div class="theme-customizer-header py-5 px-4">
<div> <div>
@@ -320,10 +322,7 @@ async function handleResetSettings() {
:class="{ 'is-active': settings.radius === radius.value }" :class="{ 'is-active': settings.radius === radius.value }"
@click="setRadius(radius.value)" @click="setRadius(radius.value)"
> >
<span <span class="theme-customizer-radius-scene" :class="`theme-customizer-radius-scene--${radius.value}`">
class="theme-customizer-radius-scene"
:class="`theme-customizer-radius-scene--${radius.value}`"
>
<span class="theme-customizer-radius-scene__card"> <span class="theme-customizer-radius-scene__card">
<span class="theme-customizer-radius-scene__badge" /> <span class="theme-customizer-radius-scene__badge" />
<span class="theme-customizer-radius-scene__line" /> <span class="theme-customizer-radius-scene__line" />
@@ -350,7 +349,9 @@ async function handleResetSettings() {
> >
<span class="theme-customizer-shadow-slider__sample-accent" /> <span class="theme-customizer-shadow-slider__sample-accent" />
<span class="theme-customizer-shadow-slider__sample-line" /> <span class="theme-customizer-shadow-slider__sample-line" />
<span class="theme-customizer-shadow-slider__sample-line theme-customizer-shadow-slider__sample-line--short" /> <span
class="theme-customizer-shadow-slider__sample-line theme-customizer-shadow-slider__sample-line--short"
/>
</span> </span>
<VSlider <VSlider
:model-value="shadowSliderValue" :model-value="shadowSliderValue"
@@ -367,10 +368,7 @@ async function handleResetSettings() {
@update:model-value="handleShadowSliderChange" @update:model-value="handleShadowSliderChange"
/> />
</div> </div>
<div <div class="theme-customizer-shadow-slider__scale" aria-hidden="true">
class="theme-customizer-shadow-slider__scale"
aria-hidden="true"
>
<span>0</span> <span>0</span>
<span>24</span> <span>24</span>
</div> </div>
@@ -0,0 +1,120 @@
import {
containsGlassOpticalSurface,
setGlassRendererState,
useGlassOpticalRenderer,
type GlassRendererState,
} from '@/composables/useGlassOpticalRenderer'
import { effectScope, nextTick, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('three', async importOriginal => {
const actual = await importOriginal<typeof import('three')>()
return {
...actual,
TextureLoader: class {
loadAsync() {
const texture = new actual.Texture()
texture.image = { height: 1, naturalHeight: 1, naturalWidth: 1, width: 1 }
return Promise.resolve(texture)
}
setCrossOrigin() {}
},
WebGLRenderer: class {
compileAsync() {
return Promise.resolve()
}
dispose() {}
forceContextLoss() {}
render() {}
setClearColor() {}
setPixelRatio() {}
setSize() {}
},
}
})
class ResizeObserverMock {
disconnect() {}
observe() {}
}
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
vi.stubGlobal('WebGLRenderingContext', class {})
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null)
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
delete document.documentElement.dataset.glassRendererState
})
describe('glass optical surface discovery', () => {
it('detects a target surface added directly', () => {
const surface = document.createElement('section')
surface.className = 'dashboard-grid-item-content'
expect(containsGlassOpticalSurface(surface)).toBe(true)
})
it('detects a target surface inside an asynchronously added subtree', () => {
const wrapper = document.createElement('div')
wrapper.innerHTML = '<div><section class="dashboard-grid-item-content"></section></div>'
expect(containsGlassOpticalSurface(wrapper)).toBe(true)
expect(containsGlassOpticalSurface(document.createElement('div'))).toBe(false)
})
it('detects an optical surface when the overlay root is created lazily', () => {
const overlayRoot = document.createElement('div')
overlayRoot.className = 'v-overlay-container'
overlayRoot.innerHTML = `
<div class="v-overlay v-overlay--active">
<div class="v-overlay__content"><div class="v-card"></div></div>
</div>
`
expect(containsGlassOpticalSurface(overlayRoot)).toBe(true)
})
it('keeps the renderer state used by components and global CSS in sync', () => {
const state = ref<GlassRendererState>('ready')
setGlassRendererState(state, 'fallback')
expect(state.value).toBe('fallback')
expect(document.documentElement.dataset.glassRendererState).toBe('fallback')
})
it('recovers after consecutive WebGL context loss cycles', async () => {
const canvas = document.createElement('canvas')
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(canvas),
quality: ref('balanced'),
routeKey: ref('/dashboard'),
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
for (let cycle = 0; cycle < 2; cycle += 1) {
canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }))
expect(renderer?.state.value).toBe('fallback')
canvas.dispatchEvent(new Event('webglcontextrestored'))
await nextTick()
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
}
scope.stop()
})
})
@@ -0,0 +1,107 @@
import {
applyThemeCustomizerRootSettings,
cancelGlassPreview,
commitGlassPreview,
persistPartialThemeCustomizerSettings,
previewGlassSettings,
readThemeCustomizerSettings,
THEME_CUSTOMIZER_STORAGE_KEY,
} from '@/composables/useThemeCustomizer'
import { beforeEach, describe, expect, it } from 'vitest'
describe('useThemeCustomizer glass settings', () => {
beforeEach(() => {
cancelGlassPreview()
localStorage.clear()
persistPartialThemeCustomizerSettings({ glassAppearance: 'clear', glassQuality: 'css' })
localStorage.clear()
})
it('uses clear appearance and CSS quality by default', () => {
const settings = readThemeCustomizerSettings()
expect(settings.glassAppearance).toBe('clear')
expect(settings.glassQuality).toBe('css')
})
it.each(['balanced', 'high'] as const)('preserves the %s quality contract', quality => {
localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify({ glassQuality: quality }))
expect(readThemeCustomizerSettings().glassQuality).toBe(quality)
})
it.each(['clear', 'tinted', 'frosted'] as const)('preserves the %s material contract', glassAppearance => {
localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify({ glassAppearance }))
expect(readThemeCustomizerSettings().glassAppearance).toBe(glassAppearance)
})
it('falls back when stored glass settings are invalid', () => {
localStorage.setItem(
THEME_CUSTOMIZER_STORAGE_KEY,
JSON.stringify({ glassAppearance: 'opaque', glassQuality: 'ultra' }),
)
const settings = readThemeCustomizerSettings()
expect(settings.glassAppearance).toBe('clear')
expect(settings.glassQuality).toBe('css')
})
it('syncs glass settings to the document roots', () => {
const settings = readThemeCustomizerSettings()
applyThemeCustomizerRootSettings({
...settings,
glassAppearance: 'tinted',
glassQuality: 'high',
})
expect(document.documentElement.dataset.glassAppearance).toBe('tinted')
expect(document.documentElement.dataset.glassQuality).toBe('high')
expect(document.body.dataset.glassAppearance).toBe('tinted')
expect(document.body.dataset.glassQuality).toBe('high')
})
it('previews glass settings without persisting them', () => {
const storedBeforePreview = localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)
previewGlassSettings({ glassAppearance: 'tinted' })
expect(document.documentElement.dataset.glassAppearance).toBe('tinted')
expect(readThemeCustomizerSettings().glassAppearance).toBe('clear')
expect(localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)).toBe(storedBeforePreview)
})
it('commits the latest glass preview as one persisted state', () => {
previewGlassSettings({ glassAppearance: 'tinted' })
previewGlassSettings({ glassAppearance: 'clear' })
previewGlassSettings({ glassAppearance: 'tinted', glassQuality: 'css' })
commitGlassPreview()
expect(readThemeCustomizerSettings()).toMatchObject({ glassAppearance: 'tinted', glassQuality: 'css' })
expect(document.documentElement.dataset.glassAppearance).toBe('tinted')
})
it('previews and commits frosted material without changing quality', () => {
previewGlassSettings({ glassAppearance: 'frosted' })
expect(document.documentElement.dataset.glassAppearance).toBe('frosted')
expect(readThemeCustomizerSettings()).toMatchObject({ glassAppearance: 'clear', glassQuality: 'css' })
commitGlassPreview()
expect(readThemeCustomizerSettings()).toMatchObject({ glassAppearance: 'frosted', glassQuality: 'css' })
})
it('restores persisted glass settings when a preview is cancelled', () => {
persistPartialThemeCustomizerSettings({ glassAppearance: 'tinted' })
previewGlassSettings({ glassAppearance: 'clear' })
cancelGlassPreview()
expect(document.documentElement.dataset.glassAppearance).toBe('tinted')
expect(readThemeCustomizerSettings().glassAppearance).toBe('tinted')
})
})
+815
View File
@@ -0,0 +1,815 @@
import { nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue'
import type {
BufferGeometry,
Color,
IUniform,
Mesh,
OrthographicCamera,
Scene,
ShaderMaterial,
Texture,
Vector2,
Vector4,
WebGLRenderer,
} from 'three'
import {
canUseGlassWallpaperTexture,
getGlassCoverScale,
getGlassOpticalBufferSize,
getGlassOpticalRenderProfile,
normalizeGlassOpticalRect,
selectGlassOpticalRects,
type GlassOpticalQuality,
type GlassOpticalRect,
} from '@/utils/glassOptics'
import type { ThemeCustomizerGlassAppearance } from '@/composables/useThemeCustomizer'
type ThreeModule = typeof import('three')
export type GlassRendererState = 'fallback' | 'loading' | 'ready'
/** 同步组件状态和根节点属性,确保 CSS 回退与 renderer 生命周期一致。 */
export function setGlassRendererState(state: Ref<GlassRendererState>, value: GlassRendererState) {
state.value = value
document.documentElement.dataset.glassRendererState = value
}
interface GlassRendererUniforms extends Record<string, IUniform> {
uAppearance: IUniform<number>
uCoverScale: IUniform<Vector2>
uHasWallpaperTexture: IUniform<number>
uPointer: IUniform<Vector2>
uQuality: IUniform<number>
uRadii: IUniform<number[]>
uRectCount: IUniform<number>
uRects: IUniform<Vector4[]>
uTexture: IUniform<Texture | null>
uTintColor: IUniform<Color>
uTime: IUniform<number>
uViewportSize: IUniform<Vector2>
}
interface GlassRendererResources {
camera: OrthographicCamera
geometry: BufferGeometry
material: ShaderMaterial
mesh: Mesh
renderer: WebGLRenderer
scene: Scene
uniforms: GlassRendererUniforms
}
interface UseGlassOpticalRendererOptions {
active: MaybeRefOrGetter<boolean>
appearance: MaybeRefOrGetter<ThemeCustomizerGlassAppearance>
canvas: Ref<HTMLCanvasElement | null>
quality: MaybeRefOrGetter<GlassOpticalQuality>
routeKey: MaybeRefOrGetter<string>
tintColor: MaybeRefOrGetter<string>
wallpaperUrl: MaybeRefOrGetter<string>
}
const SURFACE_SELECTORS = [
{ rank: 1, selector: '.v-overlay--active .v-overlay__content > .v-card' },
{ rank: 1, selector: '.v-overlay--active .v-overlay__content > .v-sheet' },
{ rank: 1, selector: '.agent-assistant-panel' },
{ rank: 1, selector: '.login-card' },
{ rank: 2, selector: '.layout-vertical-nav' },
{ rank: 2, selector: '.layout-navbar' },
{ rank: 3, selector: '.dashboard-grid-item-content' },
] as const
const SURFACE_SELECTOR_QUERY = SURFACE_SELECTORS.map(({ selector }) => selector).join(',')
/** 判断新增或移除的 DOM 子树是否会改变光学表面集合。 */
export function containsGlassOpticalSurface(node: Node) {
return (
node instanceof Element &&
(node.matches(SURFACE_SELECTOR_QUERY) || Boolean(node.querySelector(SURFACE_SELECTOR_QUERY)))
)
}
const VERTEX_SHADER = `
varying vec2 vUv;
void main() {
vUv = position.xy * 0.5 + 0.5;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`
const FRAGMENT_SHADER = `
precision highp float;
uniform sampler2D uTexture;
uniform vec2 uCoverScale;
uniform float uHasWallpaperTexture;
uniform vec2 uPointer;
uniform float uQuality;
uniform vec4 uRects[8];
uniform float uRadii[8];
uniform int uRectCount;
uniform float uAppearance;
uniform vec3 uTintColor;
uniform float uTime;
uniform vec2 uViewportSize;
varying vec2 vUv;
float roundedRectMask(vec2 local, vec2 rectSize, float radius) {
float safeRadius = min(max(radius, 0.0), min(rectSize.x, rectSize.y) * 0.5);
vec2 centered = abs(local - 0.5) * rectSize - (rectSize * 0.5 - vec2(safeRadius));
float distanceToEdge = length(max(centered, 0.0)) + min(max(centered.x, centered.y), 0.0) - safeRadius;
return 1.0 - smoothstep(-1.0, 1.0, distanceToEdge);
}
vec2 coverUv(vec2 uv) {
return vec2(0.5) + (uv - vec2(0.5)) * uCoverScale;
}
void main() {
float mask = 0.0;
float edge = 0.0;
float caustic = 0.0;
vec2 refraction = vec2(0.0);
for (int i = 0; i < 8; i++) {
if (i >= uRectCount) break;
vec4 rect = uRects[i];
vec2 local = (vUv - rect.xy) / rect.zw;
float rectMask = roundedRectMask(local, rect.zw * uViewportSize, uRadii[i]);
if (rectMask <= 0.0) continue;
float edgeDistance = min(min(local.x, 1.0 - local.x), min(local.y, 1.0 - local.y));
float edgeResponse = 1.0 - smoothstep(0.0, 0.16, edgeDistance);
vec2 lens = local - vec2(0.5);
vec2 pointerDelta = uPointer - vUv;
float pointerEnergy = exp(-dot(pointerDelta, pointerDelta) * 18.0);
float ripple = sin((local.x + local.y) * 10.0 + uTime * 0.004) * mix(0.0008, 0.0014, uQuality);
vec2 pointerLocal = (uPointer - rect.xy) / rect.zw;
float causticDistance = length(local - pointerLocal);
float causticWave = 0.5 + 0.5 * cos(causticDistance * 34.0 - uTime * 0.018);
float localCaustic = exp(-causticDistance * 7.0) * causticWave * rectMask * uQuality;
refraction += (lens * mix(0.006 + edgeResponse * 0.012, 0.009 + edgeResponse * 0.017, uQuality) + pointerDelta * pointerEnergy * mix(0.0024, 0.0042, uQuality) + ripple) * rectMask;
edge = max(edge, edgeResponse * rectMask);
caustic = max(caustic, localCaustic);
mask = max(mask, rectMask);
}
if (mask <= 0.0) discard;
vec2 sourceUv = coverUv(vUv + refraction);
float separation = 0.0012 * edge;
vec3 refracted = vec3(
texture2D(uTexture, sourceUv + vec2(separation, 0.0)).r,
texture2D(uTexture, sourceUv).g,
texture2D(uTexture, sourceUv - vec2(separation, 0.0)).b
);
if (uQuality > 0.5) {
float detailSeparation = separation * 1.8 + 0.0007;
vec3 detailed = vec3(
texture2D(uTexture, sourceUv + vec2(detailSeparation, detailSeparation * 0.35)).r,
texture2D(uTexture, sourceUv).g,
texture2D(uTexture, sourceUv - vec2(detailSeparation, detailSeparation * 0.35)).b
);
refracted = mix(refracted, detailed, 0.42);
}
vec3 highlight = vec3(0.84, 0.92, 1.0);
float edgeHighlightMix = 0.12;
float causticHighlightMix = 0.12;
float materialAlpha = 0.28;
float proceduralEdgeAlpha = 0.14;
float proceduralCausticAlpha = 0.1;
if (uAppearance > 1.5) {
highlight = vec3(0.94, 0.97, 1.0);
edgeHighlightMix = 0.22;
causticHighlightMix = 0.1;
materialAlpha = 0.34;
proceduralEdgeAlpha = 0.18;
proceduralCausticAlpha = 0.08;
} else if (uAppearance > 0.5) {
highlight = mix(vec3(1.0), uTintColor, 0.72);
edgeHighlightMix = 0.17;
causticHighlightMix = 0.16;
materialAlpha = 0.3;
}
if (uHasWallpaperTexture < 0.5) {
vec3 proceduralHighlight = highlight * (edge * 0.46 + caustic * 0.72);
float proceduralAlpha = mask * (edge * proceduralEdgeAlpha + caustic * proceduralCausticAlpha);
gl_FragColor = vec4(proceduralHighlight, proceduralAlpha);
return;
}
refracted = mix(refracted, highlight, edge * edgeHighlightMix);
refracted += highlight * caustic * causticHighlightMix;
gl_FragColor = vec4(refracted, mask * (materialAlpha + edge * 0.25 + caustic * 0.08));
}
`
const SCROLL_SURFACE_UPDATE_INTERVAL_MS = 32
/** 读取 CSS 圆角并折算为像素值。 */
function readBorderRadius(element: HTMLElement) {
const value = Number.parseFloat(getComputedStyle(element).borderTopLeftRadius)
return Number.isFinite(value) ? value : 0
}
/** 判断元素是否在布局和视口中实际可见。 */
function isVisibleSurface(element: HTMLElement, bounds: DOMRect) {
const style = getComputedStyle(element)
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
Number.parseFloat(style.opacity || '1') > 0 &&
bounds.width >= 24 &&
bounds.height >= 24
)
}
/** 将活动界面中的高价值材质面集中转换为 renderer 矩形预算。 */
export function collectGlassOpticalRects(viewportWidth: number, viewportHeight: number) {
const candidates: GlassOpticalRect[] = []
const seen = new Set<HTMLElement>()
for (const { rank, selector } of SURFACE_SELECTORS) {
for (const element of document.querySelectorAll<HTMLElement>(selector)) {
if (seen.has(element)) continue
seen.add(element)
const bounds = element.getBoundingClientRect()
if (!isVisibleSurface(element, bounds)) continue
candidates.push({
height: bounds.height,
radius: readBorderRadius(element),
rank: rank + candidates.length * 0.001,
width: bounds.width,
x: bounds.left,
y: bounds.top,
})
}
}
return selectGlassOpticalRects(candidates, viewportWidth, viewportHeight, viewportWidth <= 600)
}
/** 将材质语义映射为稳定 shader 协议,质量档不参与该映射。 */
function getGlassAppearanceUniformValue(appearance: ThemeCustomizerGlassAppearance) {
if (appearance === 'tinted') return 1
if (appearance === 'frosted') return 2
return 0
}
/** 管理玻璃主题唯一 renderer、事件驱动帧调度与完整 GPU 资源释放。 */
export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) {
const state = ref<GlassRendererState>('loading')
const renderedFrames = ref(0)
let three: ThreeModule | null = null
let resources: GlassRendererResources | null = null
let activeTexture: Texture | null = null
let activeTextureHeight = 1
let activeTextureWidth = 1
let loadVersion = 0
let animationFrame: number | null = null
let surfaceUpdateFrame: number | null = null
let pointerAnimationStartedAt = 0
let surfaceResizeTimer: number | null = null
let scrollSurfaceTimer: number | null = null
let lastScrollSurfaceUpdateAt = 0
let resizeObserver: ResizeObserver | null = null
let surfaceMutationObserver: MutationObserver | null = null
let observedSurfaces: HTMLElement[] = []
let currentRects: GlassOpticalRect[] = []
let tracksScrollingSurfaces = false
let contextRecoveryPending = false
function cancelScheduledFrame() {
if (animationFrame === null) return
cancelAnimationFrame(animationFrame)
animationFrame = null
}
function renderFrame(timestamp = performance.now()) {
animationFrame = null
if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') return
resources.uniforms.uTime.value = timestamp
resources.renderer.render(resources.scene, resources.camera)
renderedFrames.value += 1
}
function scheduleFrame() {
if (animationFrame !== null || !resources) return
animationFrame = requestAnimationFrame(renderFrame)
}
function updateSurfaceUniforms() {
if (!resources || !three) return
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const rects = collectGlassOpticalRects(viewportWidth, viewportHeight)
currentRects = rects
const normalized = rects.map(rect => normalizeGlassOpticalRect(rect, viewportWidth, viewportHeight))
const uniformRects = resources.uniforms.uRects.value
const uniformRadii = resources.uniforms.uRadii.value
for (let index = 0; index < 8; index += 1) {
const surface = normalized[index]
const rect = surface?.rect ?? [0, 0, 0, 0]
uniformRects[index].set(rect[0], rect[1], rect[2], rect[3])
uniformRadii[index] = surface?.radius ?? 0
}
resources.uniforms.uRectCount.value = normalized.length
const nextObservedSurfaces = Array.from(
new Set(
SURFACE_SELECTORS.flatMap(({ selector }) => Array.from(document.querySelectorAll<HTMLElement>(selector))),
),
)
const observedSurfacesChanged =
nextObservedSurfaces.length !== observedSurfaces.length ||
nextObservedSurfaces.some((element, index) => element !== observedSurfaces[index])
if (observedSurfacesChanged) {
resizeObserver?.disconnect()
observedSurfaces = nextObservedSurfaces
for (const element of observedSurfaces) resizeObserver?.observe(element)
}
tracksScrollingSurfaces = observedSurfaces.some(element => element.matches('.dashboard-grid-item-content'))
scheduleFrame()
}
function scheduleSurfaceUpdate() {
if (surfaceUpdateFrame !== null || !resources) return
surfaceUpdateFrame = requestAnimationFrame(() => {
surfaceUpdateFrame = null
updateSurfaceUniforms()
})
}
function scheduleSurfaceResizeUpdate() {
if (surfaceResizeTimer !== null) window.clearTimeout(surfaceResizeTimer)
surfaceResizeTimer = window.setTimeout(() => {
surfaceResizeTimer = null
scheduleSurfaceUpdate()
}, 160)
}
function syncCoverScale(viewportWidth = window.innerWidth, viewportHeight = window.innerHeight) {
if (!resources) return
const cover = getGlassCoverScale(viewportWidth, viewportHeight, activeTextureWidth, activeTextureHeight)
resources.uniforms.uCoverScale.value.set(cover.x, cover.y)
}
function resizeRenderer() {
if (!resources) return
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const profile = getGlassOpticalRenderProfile(toValue(options.quality), toValue(options.routeKey))
const buffer = getGlassOpticalBufferSize(viewportWidth, viewportHeight, viewportWidth <= 600, profile.bufferQuality)
resources.renderer.setSize(buffer.width, buffer.height, false)
resources.uniforms.uViewportSize.value.set(viewportWidth, viewportHeight)
syncCoverScale(viewportWidth, viewportHeight)
scheduleSurfaceUpdate()
}
function handlePointerMove(event: PointerEvent) {
if (!resources || matchMedia('(prefers-reduced-motion: reduce)').matches) return
if (
!currentRects.some(
rect =>
event.clientX >= rect.x &&
event.clientX <= rect.x + rect.width &&
event.clientY >= rect.y &&
event.clientY <= rect.y + rect.height,
)
)
return
resources.uniforms.uPointer.value.set(event.clientX / window.innerWidth, 1 - event.clientY / window.innerHeight)
pointerAnimationStartedAt = performance.now()
const animatePointer = (timestamp: number) => {
renderFrame(timestamp)
const responseDuration = toValue(options.quality) === 'high' ? 520 : 360
if (timestamp - pointerAnimationStartedAt < responseDuration && toValue(options.active)) {
animationFrame = requestAnimationFrame(animatePointer)
}
}
cancelScheduledFrame()
animationFrame = requestAnimationFrame(animatePointer)
}
function handleScroll() {
if (!tracksScrollingSurfaces || scrollSurfaceTimer !== null) return
const elapsed = performance.now() - lastScrollSurfaceUpdateAt
const delay = Math.max(0, SCROLL_SURFACE_UPDATE_INTERVAL_MS - elapsed)
scrollSurfaceTimer = window.setTimeout(() => {
scrollSurfaceTimer = null
lastScrollSurfaceUpdateAt = performance.now()
scheduleSurfaceUpdate()
}, delay)
}
function handleVisibilityChange() {
if (document.visibilityState === 'hidden') cancelScheduledFrame()
else scheduleFrame()
}
function handleContextLost(event: Event) {
event.preventDefault()
cancelScheduledFrame()
contextRecoveryPending = true
setGlassRendererState(state, 'fallback')
}
function handleContextRestored() {
if (!contextRecoveryPending) return
contextRecoveryPending = false
void initializeRenderer(false)
}
/** 只让会改变目标表面集合或圆角几何的 DOM 变更触发重扫。 */
function mutationTouchesOpticalSurface(mutations: MutationRecord[]) {
return mutations.some(
mutation =>
mutation.type === 'attributes' ||
[...mutation.addedNodes, ...mutation.removedNodes].some(containsGlassOpticalSurface),
)
}
function setupObservers() {
resizeObserver = new ResizeObserver(scheduleSurfaceResizeUpdate)
const observedMutationRoots = new Set<Node>()
function observeMutationRoot(root: Node | null, subtree: boolean) {
if (!root || observedMutationRoots.has(root)) return
observedMutationRoots.add(root)
surfaceMutationObserver?.observe(root, { childList: true, subtree })
}
surfaceMutationObserver = new MutationObserver(mutations => {
// Vuetify 可能在首个弹层打开时才创建容器,后续变更需要纳入同一个表面生命周期。
observeMutationRoot(document.querySelector('.v-overlay-container'), true)
if (mutationTouchesOpticalSurface(mutations)) scheduleSurfaceUpdate()
})
observeMutationRoot(document.querySelector('.app-wrapper'), true)
observeMutationRoot(document.querySelector('.v-overlay-container'), true)
observeMutationRoot(document.body, false)
surfaceMutationObserver.observe(document.documentElement, {
attributeFilter: ['data-theme-radius'],
attributes: true,
})
}
function setupEvents() {
window.addEventListener('pointermove', handlePointerMove, { passive: true })
window.addEventListener('resize', resizeRenderer, { passive: true })
window.addEventListener('scroll', handleScroll, { capture: true, passive: true })
document.addEventListener('visibilitychange', handleVisibilityChange)
options.canvas.value?.addEventListener('webglcontextlost', handleContextLost)
options.canvas.value?.addEventListener('webglcontextrestored', handleContextRestored)
}
function removeEvents() {
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('resize', resizeRenderer)
window.removeEventListener('scroll', handleScroll, true)
document.removeEventListener('visibilitychange', handleVisibilityChange)
options.canvas.value?.removeEventListener('webglcontextlost', handleContextLost)
options.canvas.value?.removeEventListener('webglcontextrestored', handleContextRestored)
}
function disposeRenderer(releaseContext = true) {
loadVersion += 1
contextRecoveryPending = false
cancelScheduledFrame()
if (surfaceUpdateFrame !== null) {
cancelAnimationFrame(surfaceUpdateFrame)
surfaceUpdateFrame = null
}
if (surfaceResizeTimer !== null) {
window.clearTimeout(surfaceResizeTimer)
surfaceResizeTimer = null
}
if (scrollSurfaceTimer !== null) {
window.clearTimeout(scrollSurfaceTimer)
scrollSurfaceTimer = null
}
removeEvents()
resizeObserver?.disconnect()
resizeObserver = null
surfaceMutationObserver?.disconnect()
surfaceMutationObserver = null
observedSurfaces = []
currentRects = []
tracksScrollingSurfaces = false
activeTexture?.dispose()
activeTexture = null
activeTextureHeight = 1
activeTextureWidth = 1
if (resources) {
resources.geometry.dispose()
resources.material.dispose()
resources.renderer.dispose()
if (releaseContext) resources.renderer.forceContextLoss()
resources = null
}
delete document.documentElement.dataset.glassRendererState
}
/** 只有当前加载代次可以处置共享 renderer,过期请求的失败不会覆盖新状态。 */
function fallbackFromCurrentLoad(version: number, message: string, error: unknown) {
if (version !== loadVersion) return
console.warn(message, error)
disposeRenderer()
setGlassRendererState(state, 'fallback')
}
async function loadWallpaper(url: string, version: number) {
if (!resources || !three || !url) return
const profile = getGlassOpticalRenderProfile(toValue(options.quality), toValue(options.routeKey))
const shouldUseProceduralTexture =
profile.textureSource === 'procedural' ||
(profile.textureSource === 'auto' && !canUseGlassWallpaperTexture(url, window.location.href))
if (shouldUseProceduralTexture) {
const textureCanvas = document.createElement('canvas')
textureCanvas.width = 1
textureCanvas.height = 1
const texture = new three.CanvasTexture(textureCanvas)
texture.colorSpace = three.SRGBColorSpace
texture.generateMipmaps = false
texture.minFilter = three.LinearFilter
texture.magFilter = three.LinearFilter
activeTexture?.dispose()
activeTexture = texture
activeTextureHeight = 1
activeTextureWidth = 1
resources.uniforms.uTexture.value = texture
resources.uniforms.uHasWallpaperTexture.value = 0
await resources.renderer.compileAsync(resources.scene, resources.camera)
if (version !== loadVersion || !resources) return
setGlassRendererState(state, 'ready')
scheduleFrame()
return
}
const loader = new three.TextureLoader()
loader.setCrossOrigin('anonymous')
const sourceTexture = await loader.loadAsync(url)
if (version !== loadVersion || !resources) {
sourceTexture.dispose()
return
}
const image = sourceTexture.image as HTMLImageElement
const sourceWidth = image.naturalWidth || image.width
const sourceHeight = image.naturalHeight || image.height
const { textureLimit } = profile
const scale = Math.min(1, textureLimit / Math.max(sourceWidth, sourceHeight))
const textureCanvas = document.createElement('canvas')
textureCanvas.width = Math.max(1, Math.round(sourceWidth * scale))
textureCanvas.height = Math.max(1, Math.round(sourceHeight * scale))
const textureContext = textureCanvas.getContext('2d')
let texture: Texture
let textureWidth = sourceWidth
let textureHeight = sourceHeight
if (textureContext) {
textureContext.drawImage(image, 0, 0, textureCanvas.width, textureCanvas.height)
sourceTexture.dispose()
texture = new three.CanvasTexture(textureCanvas)
textureWidth = textureCanvas.width
textureHeight = textureCanvas.height
} else {
texture = sourceTexture
}
texture.colorSpace = three.SRGBColorSpace
texture.generateMipmaps = false
texture.minFilter = three.LinearFilter
texture.magFilter = three.LinearFilter
activeTexture?.dispose()
activeTexture = texture
activeTextureHeight = textureHeight
activeTextureWidth = textureWidth
resources.uniforms.uTexture.value = texture
resources.uniforms.uHasWallpaperTexture.value = 1
syncCoverScale()
await resources.renderer.compileAsync(resources.scene, resources.camera)
if (version !== loadVersion || !resources) return
setGlassRendererState(state, 'ready')
scheduleFrame()
}
async function initializeRenderer(releaseContext = true) {
disposeRenderer(releaseContext)
if (!toValue(options.active) || !options.canvas.value) return
if (!window.WebGLRenderingContext || reducedTransparencyQuery.matches || !toValue(options.wallpaperUrl)) {
setGlassRendererState(state, 'fallback')
return
}
setGlassRendererState(state, 'loading')
const version = ++loadVersion
try {
three = await import('three')
if (version !== loadVersion || !options.canvas.value) return
const Vector4Class = three.Vector4
const renderer = new three.WebGLRenderer({
alpha: true,
antialias: false,
canvas: options.canvas.value,
powerPreference: 'low-power',
premultipliedAlpha: true,
})
renderer.setClearColor(0x000000, 0)
renderer.setPixelRatio(1)
const geometry = new three.BufferGeometry()
geometry.setAttribute('position', new three.Float32BufferAttribute([-1, -1, 0, 3, -1, 0, -1, 3, 0], 3))
const uniforms: GlassRendererUniforms = {
uAppearance: { value: getGlassAppearanceUniformValue(toValue(options.appearance)) },
uCoverScale: { value: new three.Vector2(1, 1) },
uHasWallpaperTexture: { value: 0 },
uPointer: { value: new three.Vector2(0.5, 0.5) },
uQuality: { value: toValue(options.quality) === 'high' ? 1 : 0 },
uRadii: { value: Array.from({ length: 8 }, () => 0) },
uRectCount: { value: 0 },
uRects: { value: Array.from({ length: 8 }, () => new Vector4Class()) },
uTexture: { value: null },
uTintColor: { value: new three.Color(toValue(options.tintColor)) },
uTime: { value: 0 },
uViewportSize: { value: new three.Vector2(window.innerWidth, window.innerHeight) },
}
const material = new three.ShaderMaterial({
depthTest: false,
depthWrite: false,
fragmentShader: FRAGMENT_SHADER,
transparent: true,
uniforms,
vertexShader: VERTEX_SHADER,
})
const scene = new three.Scene()
const camera = new three.OrthographicCamera(-1, 1, 1, -1, 0, 1)
const mesh = new three.Mesh(geometry, material)
mesh.frustumCulled = false
scene.add(mesh)
resources = { camera, geometry, material, mesh, renderer, scene, uniforms }
setupObservers()
setupEvents()
resizeRenderer()
await loadWallpaper(toValue(options.wallpaperUrl), version)
} catch (error) {
fallbackFromCurrentLoad(version, '玻璃光学渲染器初始化失败,已回退标准材质:', error)
}
}
/** 运行中切换减少透明度时立即释放或恢复光学资源。 */
function handleReducedTransparencyChange(event: MediaQueryListEvent) {
if (!toValue(options.active)) return
if (event.matches) {
disposeRenderer()
setGlassRendererState(state, 'fallback')
return
}
void initializeRenderer()
}
const reducedTransparencyQuery = matchMedia('(prefers-reduced-transparency: reduce)')
reducedTransparencyQuery.addEventListener('change', handleReducedTransparencyChange)
watch(
() => [toValue(options.active), toValue(options.wallpaperUrl)] as const,
async ([active, wallpaperUrl], previous) => {
if (!active) {
disposeRenderer()
return
}
await nextTick()
if (!resources) {
await initializeRenderer()
return
}
if (wallpaperUrl !== previous?.[1]) {
const version = ++loadVersion
setGlassRendererState(state, 'loading')
try {
await loadWallpaper(wallpaperUrl, version)
} catch (error) {
fallbackFromCurrentLoad(version, '玻璃光学壁纸纹理加载失败,已回退标准材质:', error)
}
}
},
{ immediate: true },
)
watch(
() => toValue(options.appearance),
appearance => {
if (!resources) return
resources.uniforms.uAppearance.value = getGlassAppearanceUniformValue(appearance)
scheduleFrame()
},
)
watch(
() => toValue(options.quality),
async quality => {
if (!resources) return
resources.uniforms.uQuality.value = quality === 'high' ? 1 : 0
resizeRenderer()
const version = ++loadVersion
setGlassRendererState(state, 'loading')
try {
await loadWallpaper(toValue(options.wallpaperUrl), version)
} catch (error) {
fallbackFromCurrentLoad(version, '玻璃质量纹理切换失败,已回退标准材质:', error)
}
},
)
watch(
() => toValue(options.tintColor),
tintColor => {
if (!resources) return
resources.uniforms.uTintColor.value.set(tintColor)
scheduleFrame()
},
)
watch(
() => toValue(options.routeKey),
async (routeKey, previousRouteKey) => {
await nextTick()
if (resources) {
const previousProfile = getGlassOpticalRenderProfile(toValue(options.quality), previousRouteKey ?? '')
const nextProfile = getGlassOpticalRenderProfile(toValue(options.quality), routeKey)
resizeRenderer()
if (
previousProfile.bufferQuality !== nextProfile.bufferQuality ||
previousProfile.textureLimit !== nextProfile.textureLimit ||
previousProfile.textureSource !== nextProfile.textureSource
) {
const version = ++loadVersion
setGlassRendererState(state, 'loading')
try {
await loadWallpaper(toValue(options.wallpaperUrl), version)
} catch (error) {
fallbackFromCurrentLoad(version, '玻璃场景纹理切换失败,已回退标准材质:', error)
}
return
}
}
scheduleSurfaceUpdate()
},
)
onScopeDispose(() => {
reducedTransparencyQuery.removeEventListener('change', handleReducedTransparencyChange)
disposeRenderer()
})
return {
renderedFrames,
state,
}
}
+107 -5
View File
@@ -54,24 +54,39 @@ export const themeCustomizerShadowLevels = [
] as const ] as const
export type ThemeCustomizerLayout = 'collapsed' | 'horizontal' | 'vertical' export type ThemeCustomizerLayout = 'collapsed' | 'horizontal' | 'vertical'
export type ThemeCustomizerGlassAppearance = 'clear' | 'frosted' | 'tinted'
export type ThemeCustomizerGlassQuality = 'balanced' | 'css' | 'high'
export type ThemeCustomizerRadius = 'default' | 'extra' | 'large' | 'none' | 'small' export type ThemeCustomizerRadius = 'default' | 'extra' | 'large' | 'none' | 'small'
export type ThemeCustomizerShadow = (typeof themeCustomizerShadowLevels)[number] export type ThemeCustomizerShadow = (typeof themeCustomizerShadowLevels)[number]
export type ThemeCustomizerSkin = 'bordered' | 'default' export type ThemeCustomizerSkin = 'bordered' | 'default'
export type ThemeCustomizerTheme = 'auto' | 'dark' | 'glass' | 'light' | 'purple' | 'transparent' export type ThemeCustomizerTheme = 'auto' | 'dark' | 'glass' | 'light' | 'purple' | 'transparent'
export interface ThemeCustomizerSettings { export interface ThemeCustomizerSettings {
/** 玻璃主题的材质语义,与渲染质量保持独立。 */
glassAppearance: ThemeCustomizerGlassAppearance
/** 玻璃主题的渲染质量,决定使用标准 CSS 或共享光学渲染器。 */
glassQuality: ThemeCustomizerGlassQuality
/** 桌面导航布局。 */
layout: ThemeCustomizerLayout layout: ThemeCustomizerLayout
/** 主题强调色,也是色调玻璃的颜色来源。 */
primaryColor: string primaryColor: string
/** 全局表面圆角档位。 */
radius: ThemeCustomizerRadius radius: ThemeCustomizerRadius
/** 浅色主题是否使用半深色侧栏。 */
semiDarkMenu: boolean semiDarkMenu: boolean
/** 非玻璃主题的表面阴影档位。 */
shadow: ThemeCustomizerShadow shadow: ThemeCustomizerShadow
/** 非玻璃主题的边框皮肤。 */
skin: ThemeCustomizerSkin skin: ThemeCustomizerSkin
/** 用户选择的主题偏好。 */
theme: ThemeCustomizerTheme theme: ThemeCustomizerTheme
} }
type VuetifyThemeApi = ReturnType<typeof useTheme> type VuetifyThemeApi = ReturnType<typeof useTheme>
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
const validGlassAppearances: ThemeCustomizerGlassAppearance[] = ['clear', 'tinted', 'frosted']
const validGlassQualities: ThemeCustomizerGlassQuality[] = ['css', 'balanced', 'high']
const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal'] const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal']
const validRadii: ThemeCustomizerRadius[] = ['none', 'small', 'default', 'large', 'extra'] const validRadii: ThemeCustomizerRadius[] = ['none', 'small', 'default', 'large', 'extra']
const validShadows: readonly ThemeCustomizerShadow[] = themeCustomizerShadowLevels const validShadows: readonly ThemeCustomizerShadow[] = themeCustomizerShadowLevels
@@ -108,6 +123,8 @@ function readStoredThemePreference(): ThemeCustomizerTheme {
/** 生成与当前主题偏好一致的定制器默认设置。 */ /** 生成与当前主题偏好一致的定制器默认设置。 */
function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings { function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
return { return {
glassAppearance: 'clear',
glassQuality: 'css',
layout: 'vertical', layout: 'vertical',
primaryColor: defaultPrimaryColor, primaryColor: defaultPrimaryColor,
radius: 'default', radius: 'default',
@@ -134,13 +151,17 @@ function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSetti
const primaryColor = isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor const primaryColor = isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor
return { return {
glassAppearance: validGlassAppearances.includes(settings.glassAppearance as ThemeCustomizerGlassAppearance)
? (settings.glassAppearance as ThemeCustomizerGlassAppearance)
: fallback.glassAppearance,
glassQuality: validGlassQualities.includes(settings.glassQuality as ThemeCustomizerGlassQuality)
? (settings.glassQuality as ThemeCustomizerGlassQuality)
: fallback.glassQuality,
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout) layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
? (settings.layout as ThemeCustomizerLayout) ? (settings.layout as ThemeCustomizerLayout)
: fallback.layout, : fallback.layout,
primaryColor, primaryColor,
radius: validRadii.includes(radius as ThemeCustomizerRadius) radius: validRadii.includes(radius as ThemeCustomizerRadius) ? (radius as ThemeCustomizerRadius) : fallback.radius,
? (radius as ThemeCustomizerRadius)
: fallback.radius,
semiDarkMenu: typeof settings.semiDarkMenu === 'boolean' ? settings.semiDarkMenu : fallback.semiDarkMenu, semiDarkMenu: typeof settings.semiDarkMenu === 'boolean' ? settings.semiDarkMenu : fallback.semiDarkMenu,
shadow: normalizeThemeCustomizerShadow(settings.shadow), shadow: normalizeThemeCustomizerShadow(settings.shadow),
skin: validSkins.includes(settings.skin as ThemeCustomizerSkin) skin: validSkins.includes(settings.skin as ThemeCustomizerSkin)
@@ -175,6 +196,16 @@ export function readThemeCustomizerSettings(): ThemeCustomizerSettings {
// 生产构建会改写导出函数的声明形式,状态初始化必须放在读取函数定义之后,避免首屏执行时引用未完成赋值的函数。 // 生产构建会改写导出函数的声明形式,状态初始化必须放在读取函数定义之后,避免首屏执行时引用未完成赋值的函数。
const settingsState = ref<ThemeCustomizerSettings>(readThemeCustomizerSettings()) const settingsState = ref<ThemeCustomizerSettings>(readThemeCustomizerSettings())
const glassPreviewState = ref<Pick<ThemeCustomizerSettings, 'glassAppearance' | 'glassQuality'> | null>(null)
const effectiveGlassSettings = computed(() => ({
glassAppearance: glassPreviewState.value?.glassAppearance ?? settingsState.value.glassAppearance,
glassQuality: glassPreviewState.value?.glassQuality ?? settingsState.value.glassQuality,
}))
/** 提供当前实际生效的玻璃外观;临时预览优先于已持久化设置。 */
export function useEffectiveGlassSettings() {
return readonly(effectiveGlassSettings)
}
/** 将完整主题定制设置写入本地存储。 */ /** 将完整主题定制设置写入本地存储。 */
function persistThemeCustomizerSettings(settings: ThemeCustomizerSettings) { function persistThemeCustomizerSettings(settings: ThemeCustomizerSettings) {
@@ -223,17 +254,24 @@ export function applyPrimaryColorToVuetify(color: string, themeApi: VuetifyTheme
syncThemeFavicon(activePrimaryColor) syncThemeFavicon(activePrimaryColor)
} }
/** 布局、圆角、阴影、皮肤和局部菜单风格只依赖根节点属性,CSS 可以在不刷新页面的情况下即时响应。 */ /** 将外观设置同步为根节点属性,使主题 CSS 无需刷新即可响应。 */
export function applyThemeCustomizerRootSettings( export function applyThemeCustomizerRootSettings(
settings: Pick<ThemeCustomizerSettings, 'layout' | 'radius' | 'semiDarkMenu' | 'shadow' | 'skin'>, settings: Pick<
ThemeCustomizerSettings,
'glassAppearance' | 'glassQuality' | 'layout' | 'radius' | 'semiDarkMenu' | 'shadow' | 'skin'
>,
) { ) {
if (!isBrowser()) return if (!isBrowser()) return
document.documentElement.setAttribute('data-glass-appearance', settings.glassAppearance)
document.documentElement.setAttribute('data-glass-quality', settings.glassQuality)
document.documentElement.setAttribute('data-theme-layout', settings.layout) document.documentElement.setAttribute('data-theme-layout', settings.layout)
document.documentElement.setAttribute('data-theme-radius', settings.radius) document.documentElement.setAttribute('data-theme-radius', settings.radius)
document.documentElement.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu)) document.documentElement.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
document.documentElement.setAttribute('data-theme-shadow', settings.shadow) document.documentElement.setAttribute('data-theme-shadow', settings.shadow)
document.documentElement.setAttribute('data-theme-skin', settings.skin) document.documentElement.setAttribute('data-theme-skin', settings.skin)
document.body.setAttribute('data-glass-appearance', settings.glassAppearance)
document.body.setAttribute('data-glass-quality', settings.glassQuality)
document.body.setAttribute('data-theme-layout', settings.layout) document.body.setAttribute('data-theme-layout', settings.layout)
document.body.setAttribute('data-theme-radius', settings.radius) document.body.setAttribute('data-theme-radius', settings.radius)
document.body.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu)) document.body.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
@@ -276,6 +314,7 @@ async function applyThemePreference(themePreference: ThemeCustomizerTheme, theme
export function applyStoredThemeCustomizerAppearance(themeApi: VuetifyThemeApi) { export function applyStoredThemeCustomizerAppearance(themeApi: VuetifyThemeApi) {
const settings = readThemeCustomizerSettings() const settings = readThemeCustomizerSettings()
glassPreviewState.value = null
settingsState.value = settings settingsState.value = settings
applyPrimaryColorToVuetify(settings.primaryColor, themeApi) applyPrimaryColorToVuetify(settings.primaryColor, themeApi)
applyThemeCustomizerRootSettings(settings) applyThemeCustomizerRootSettings(settings)
@@ -290,6 +329,7 @@ export function persistPartialThemeCustomizerSettings(patch: Partial<ThemeCustom
...patch, ...patch,
}) })
glassPreviewState.value = null
settingsState.value = nextSettings settingsState.value = nextSettings
persistThemeCustomizerSettings(nextSettings) persistThemeCustomizerSettings(nextSettings)
applyPrimaryColorToVuetify(nextSettings.primaryColor, vuetify.theme) applyPrimaryColorToVuetify(nextSettings.primaryColor, vuetify.theme)
@@ -299,9 +339,54 @@ export function persistPartialThemeCustomizerSettings(patch: Partial<ThemeCustom
return nextSettings return nextSettings
} }
/** 临时应用玻璃外观,不写入存储或广播持久化变更。 */
export function previewGlassSettings(
patch: Partial<Pick<ThemeCustomizerSettings, 'glassAppearance' | 'glassQuality'>>,
) {
const previewSettings = normalizeThemeCustomizerSettings({
...settingsState.value,
...glassPreviewState.value,
...patch,
})
glassPreviewState.value = {
glassAppearance: previewSettings.glassAppearance,
glassQuality: previewSettings.glassQuality,
}
applyThemeCustomizerRootSettings({
...settingsState.value,
...glassPreviewState.value,
})
return glassPreviewState.value
}
/** 将当前玻璃预览作为一个设置事务持久化,避免外观与质量分步提交。 */
export function commitGlassPreview() {
const previewSettings = glassPreviewState.value
if (!previewSettings) return settingsState.value
glassPreviewState.value = null
return persistPartialThemeCustomizerSettings(previewSettings)
}
/** 丢弃临时玻璃预览并恢复已保存设置。 */
export function cancelGlassPreview() {
if (!glassPreviewState.value) return settingsState.value
glassPreviewState.value = null
applyThemeCustomizerRootSettings(settingsState.value)
return settingsState.value
}
/** 判断当前主题定制设置是否仍为默认值。 */ /** 判断当前主题定制设置是否仍为默认值。 */
export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettings) { export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettings) {
const defaults = normalizeThemeCustomizerSettings({ const defaults = normalizeThemeCustomizerSettings({
glassAppearance: 'clear',
glassQuality: 'css',
layout: 'vertical', layout: 'vertical',
primaryColor: defaultPrimaryColor, primaryColor: defaultPrimaryColor,
radius: 'default', radius: 'default',
@@ -312,6 +397,8 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
}) })
return ( return (
settings.glassAppearance === defaults.glassAppearance &&
settings.glassQuality === defaults.glassQuality &&
settings.layout === defaults.layout && settings.layout === defaults.layout &&
settings.primaryColor === defaults.primaryColor && settings.primaryColor === defaults.primaryColor &&
settings.radius === defaults.radius && settings.radius === defaults.radius &&
@@ -335,6 +422,7 @@ export function useThemeCustomizer() {
...patch, ...patch,
}) })
glassPreviewState.value = null
settings.value = nextSettings settings.value = nextSettings
persistThemeCustomizerSettings(nextSettings) persistThemeCustomizerSettings(nextSettings)
applyPrimaryColorToVuetify(nextSettings.primaryColor, themeApi) applyPrimaryColorToVuetify(nextSettings.primaryColor, themeApi)
@@ -355,6 +443,16 @@ export function useThemeCustomizer() {
return updateSettings({ primaryColor: color }) return updateSettings({ primaryColor: color })
} }
/** 更新玻璃主题材质,不隐式改变渲染质量。 */
function setGlassAppearance(glassAppearance: ThemeCustomizerGlassAppearance) {
return updateSettings({ glassAppearance })
}
/** 更新玻璃主题渲染质量档位。 */
function setGlassQuality(glassQuality: ThemeCustomizerGlassQuality) {
return updateSettings({ glassQuality })
}
/** 更新全局圆角档位。 */ /** 更新全局圆角档位。 */
function setRadius(radius: ThemeCustomizerRadius) { function setRadius(radius: ThemeCustomizerRadius) {
return updateSettings({ radius }) return updateSettings({ radius })
@@ -388,6 +486,8 @@ export function useThemeCustomizer() {
/** 将主题定制器恢复到默认设置。 */ /** 将主题定制器恢复到默认设置。 */
async function resetSettings() { async function resetSettings() {
await updateSettings({ await updateSettings({
glassAppearance: 'clear',
glassQuality: 'css',
layout: 'vertical', layout: 'vertical',
primaryColor: defaultPrimaryColor, primaryColor: defaultPrimaryColor,
radius: 'default', radius: 'default',
@@ -423,6 +523,8 @@ export function useThemeCustomizer() {
return { return {
isCustomized: computed(() => !isDefaultThemeCustomizerSettings(settings.value)), isCustomized: computed(() => !isDefaultThemeCustomizerSettings(settings.value)),
resetSettings, resetSettings,
setGlassAppearance,
setGlassQuality,
setLayout, setLayout,
setPrimaryColor, setPrimaryColor,
setRadius, setRadius,
@@ -27,6 +27,7 @@ import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
const AboutDialog = defineAsyncComponent(() => import('@/components/dialog/AboutDialog.vue')) const AboutDialog = defineAsyncComponent(() => import('@/components/dialog/AboutDialog.vue'))
const CustomCssDialog = defineAsyncComponent(() => import('@/components/dialog/CustomCssDialog.vue')) const CustomCssDialog = defineAsyncComponent(() => import('@/components/dialog/CustomCssDialog.vue'))
const GlassSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/GlassSettingsDialog.vue'))
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue')) const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
const TransparencySettingsDialog = defineAsyncComponent( const TransparencySettingsDialog = defineAsyncComponent(
() => import('@/components/dialog/TransparencySettingsDialog.vue'), () => import('@/components/dialog/TransparencySettingsDialog.vue'),
@@ -63,6 +64,7 @@ const showLanguageMenu = ref(false)
const customCSS = ref('') const customCSS = ref('')
const isTransparentTheme = computed(() => currentThemeName.value === 'transparent') const isTransparentTheme = computed(() => currentThemeName.value === 'transparent')
const isGlassTheme = computed(() => currentThemeName.value === 'glass')
// 重启轮询控制标识 // 重启轮询控制标识
const restartPollingId = ref<number | null>(null) const restartPollingId = ref<number | null>(null)
@@ -445,6 +447,11 @@ function showTransparencySettingsDialog() {
openSharedDialog(TransparencySettingsDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] }) openSharedDialog(TransparencySettingsDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
} }
/** 打开玻璃主题的外观与质量设置共享弹窗。 */
function showGlassSettingsDialog() {
openSharedDialog(GlassSettingsDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
}
/** 从用户菜单打开主题定制器,App 模式会在面板内部隐藏布局设置。 */ /** 从用户菜单打开主题定制器,App 模式会在面板内部隐藏布局设置。 */
function showThemeCustomizerDrawer() { function showThemeCustomizerDrawer() {
showUserMenu.value = false showUserMenu.value = false
@@ -700,6 +707,16 @@ onUnmounted(() => {
</VListItem> </VListItem>
</template> </template>
<VListItem v-else-if="isGlassTheme" @click="showGlassSettingsDialog">
<template #prepend>
<VIcon icon="mdi-blur-radial" />
</template>
<VListItemTitle>{{ t('theme.glassSettings') }}</VListItemTitle>
<template #append>
<VIcon icon="mdi-chevron-right" size="small" />
</template>
</VListItem>
<VListItem v-if="canAdmin" @click="showCustomCssDialog"> <VListItem v-if="canAdmin" @click="showCustomCssDialog">
<template #prepend> <template #prepend>
<VIcon icon="mdi-palette" /> <VIcon icon="mdi-palette" />
+9
View File
@@ -147,6 +147,15 @@ export default {
autoUI: 'Auto', autoUI: 'Auto',
transparent: 'Transparent', transparent: 'Transparent',
glass: 'Glass', glass: 'Glass',
glassSettings: 'Glass Settings',
glassAppearance: 'Material',
glassAppearanceClear: 'Clear',
glassAppearanceTinted: 'Tinted',
glassAppearanceFrosted: 'Frosted',
glassQuality: 'Quality',
glassQualityCss: 'Standard',
glassQualityBalanced: 'Balanced',
glassQualityHigh: 'High',
purple: 'Purple', purple: 'Purple',
custom: 'Custom Style', custom: 'Custom Style',
transparency: 'Transparency', transparency: 'Transparency',
+9
View File
@@ -145,6 +145,15 @@ export default {
autoUI: '自动', autoUI: '自动',
transparent: '透明', transparent: '透明',
glass: '玻璃', glass: '玻璃',
glassSettings: '玻璃设置',
glassAppearance: '材质',
glassAppearanceClear: '透明',
glassAppearanceTinted: '色调',
glassAppearanceFrosted: '磨砂',
glassQuality: '质量',
glassQualityCss: '标准',
glassQualityBalanced: '均衡',
glassQualityHigh: '高质量',
purple: '幻紫', purple: '幻紫',
custom: '附加样式', custom: '附加样式',
transparency: '透明度', transparency: '透明度',
+9
View File
@@ -145,6 +145,15 @@ export default {
autoUI: '自動', autoUI: '自動',
transparent: '透明', transparent: '透明',
glass: '玻璃', glass: '玻璃',
glassSettings: '玻璃設定',
glassAppearance: '材質',
glassAppearanceClear: '透明',
glassAppearanceTinted: '色調',
glassAppearanceFrosted: '磨砂',
glassQuality: '品質',
glassQualityCss: '標準',
glassQualityBalanced: '均衡',
glassQualityHigh: '高品質',
purple: '幻紫', purple: '幻紫',
custom: '附加樣式', custom: '附加樣式',
transparency: '透明度', transparency: '透明度',
+56 -23
View File
@@ -62,6 +62,8 @@ function renderCardLight() {
root?.style.setProperty('--login-card-highlight-alpha', (0.035 + pendingCardLightEnergy * 0.08).toFixed(3)) root?.style.setProperty('--login-card-highlight-alpha', (0.035 + pendingCardLightEnergy * 0.08).toFixed(3))
root?.style.setProperty('--login-card-primary-alpha', (0.018 + pendingCardLightEnergy * 0.04).toFixed(3)) root?.style.setProperty('--login-card-primary-alpha', (0.018 + pendingCardLightEnergy * 0.04).toFixed(3))
root?.style.setProperty('--login-card-top-prism-alpha', (0.2 + pendingCardLightEnergy * 0.32).toFixed(3)) root?.style.setProperty('--login-card-top-prism-alpha', (0.2 + pendingCardLightEnergy * 0.32).toFixed(3))
root?.style.setProperty('--login-refraction-shift-x', `${((0.5 - pendingCardLightX) * 8).toFixed(2)}px`)
root?.style.setProperty('--login-refraction-shift-y', `${((0.5 - pendingCardLightY) * 6).toFixed(2)}px`)
} }
/** 根据指针在登录卡片中的位置更新光照目标。 */ /** 根据指针在登录卡片中的位置更新光照目标。 */
@@ -762,12 +764,25 @@ onUnmounted(() => {
<template> <template>
<!-- 登录页面容器 --> <!-- 登录页面容器 -->
<div <div ref="loginRootRef" class="login-root" @pointermove="handlePointerLight" @pointerleave="resetPointerLight">
ref="loginRootRef" <!-- SVG 位移只处理卡片内重复绘制的壁纸不读取跨域图片像素 -->
class="login-root" <svg class="login-liquid-filter-defs" aria-hidden="true" focusable="false">
@pointermove="handlePointerLight" <defs>
@pointerleave="resetPointerLight" <filter
> id="login-liquid-refraction-clear"
x="-8%"
y="-8%"
width="116%"
height="116%"
color-interpolation-filters="sRGB"
>
<feTurbulence type="fractalNoise" baseFrequency="0.009 0.014" numOctaves="2" seed="7" result="noise" />
<feGaussianBlur in="noise" stdDeviation="1.2" result="soft-noise" />
<feDisplacementMap in="SourceGraphic" in2="soft-noise" scale="24" xChannelSelector="R" yChannelSelector="G" />
</filter>
</defs>
</svg>
<!-- 装饰性背景光晕 --> <!-- 装饰性背景光晕 -->
<div class="login-bg-decor" aria-hidden="true"> <div class="login-bg-decor" aria-hidden="true">
<div class="login-orb login-orb--1" /> <div class="login-orb login-orb--1" />
@@ -810,16 +825,13 @@ onUnmounted(() => {
flat flat
> >
<div class="login-card__glass" aria-hidden="true"> <div class="login-card__glass" aria-hidden="true">
<span class="login-card__wallpaper-refraction" />
<span class="login-card__glass-caustic" /> <span class="login-card__glass-caustic" />
</div> </div>
<!-- 卡片头部Logo + 标题 + 欢迎语 --> <!-- 卡片头部Logo + 标题 + 欢迎语 -->
<div class="login-head"> <div class="login-head">
<OpticalLogoLab <OpticalLogoLab class="login-logo" :locale="currentLocale" @logo-click="handleLabTap('logo')">
class="login-logo"
:locale="currentLocale"
@logo-click="handleLabTap('logo')"
>
<h1 class="login-title" @click="handleLabTap('title')">MoviePilot</h1> <h1 class="login-title" @click="handleLabTap('title')">MoviePilot</h1>
<p class="login-subtitle">{{ t('login.welcomeBack') || 'Welcome Back' }}</p> <p class="login-subtitle">{{ t('login.welcomeBack') || 'Welcome Back' }}</p>
</OpticalLogoLab> </OpticalLogoLab>
@@ -988,6 +1000,8 @@ onUnmounted(() => {
--login-card-highlight-alpha: 0.035; --login-card-highlight-alpha: 0.035;
--login-card-primary-alpha: 0.018; --login-card-primary-alpha: 0.018;
--login-card-top-prism-alpha: 0.2; --login-card-top-prism-alpha: 0.2;
--login-refraction-shift-x: 0px;
--login-refraction-shift-y: 3px;
--optical-glass-x: 50%; --optical-glass-x: 50%;
--optical-glass-y: 22%; --optical-glass-y: 22%;
--optical-glass-blur: 24px; --optical-glass-blur: 24px;
@@ -1018,6 +1032,13 @@ onUnmounted(() => {
padding-block: calc(env(safe-area-inset-top, 0px) + 24px) calc(env(safe-area-inset-bottom, 0px) + 24px); padding-block: calc(env(safe-area-inset-top, 0px) + 24px) calc(env(safe-area-inset-bottom, 0px) + 24px);
} }
.login-liquid-filter-defs {
position: absolute;
block-size: 0;
inline-size: 0;
pointer-events: none;
}
/* ===================== 装饰性背景光晕 ===================== */ /* ===================== 装饰性背景光晕 ===================== */
.login-bg-decor { .login-bg-decor {
position: absolute; position: absolute;
@@ -1142,12 +1163,7 @@ onUnmounted(() => {
z-index: 4; z-index: 4;
border-radius: 999px; border-radius: 999px;
background: background:
radial-gradient( radial-gradient(ellipse at center, rgba(255, 255, 255, 0.92), rgba(232, 219, 255, 0.48) 28%, transparent 72%),
ellipse at center,
rgba(255, 255, 255, 0.92),
rgba(232, 219, 255, 0.48) 28%,
transparent 72%
),
linear-gradient( linear-gradient(
90deg, 90deg,
transparent, transparent,
@@ -1189,8 +1205,7 @@ onUnmounted(() => {
rgba(var(--v-theme-primary), var(--login-card-primary-alpha)) 44%, rgba(var(--v-theme-primary), var(--login-card-primary-alpha)) 44%,
transparent 76% transparent 76%
), ),
linear-gradient(145deg, rgba(255, 255, 255, 0.075), transparent 34%), linear-gradient(145deg, rgba(255, 255, 255, 0.075), transparent 34%), rgba(var(--v-theme-surface), 0.7);
rgba(var(--v-theme-surface), 0.7);
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
transform: translateZ(0); transform: translateZ(0);
@@ -1198,6 +1213,7 @@ onUnmounted(() => {
.login-card__glass::before { .login-card__glass::before {
position: absolute; position: absolute;
z-index: 2;
border-radius: 44% 56% 61% 39% / 42% 39% 61% 58%; border-radius: 44% 56% 61% 39% / 42% 39% 61% 58%;
background: background:
radial-gradient(circle at 36% 32%, rgba(255, 255, 255, 0.2), transparent 24%), radial-gradient(circle at 36% 32%, rgba(255, 255, 255, 0.2), transparent 24%),
@@ -1208,18 +1224,27 @@ onUnmounted(() => {
transparent 38%, transparent 38%,
rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.09),
transparent 72% transparent 72%
); );
content: ''; content: '';
filter: blur(var(--optical-glass-pulse-blur)); filter: blur(var(--optical-glass-pulse-blur));
inset: -28%; inset: -28%;
opacity: var(--optical-glass-glow-opacity); opacity: var(--optical-glass-glow-opacity);
transform: translate(var(--optical-glass-shift-x), var(--optical-glass-shift-y)) transform: translate(var(--optical-glass-shift-x), var(--optical-glass-shift-y)) rotate(var(--optical-glass-rotation))
rotate(var(--optical-glass-rotation)) scale(var(--optical-glass-scale)); scale(var(--optical-glass-scale));
transition: opacity 220ms ease; transition: opacity 220ms ease;
} }
.login-card__wallpaper-refraction {
position: absolute;
z-index: 0;
display: none;
inset: -2%;
pointer-events: none;
}
.login-card__glass-caustic { .login-card__glass-caustic {
position: absolute; position: absolute;
z-index: 3;
display: block; display: block;
border-radius: 50%; border-radius: 50%;
background: radial-gradient( background: radial-gradient(
@@ -1633,6 +1658,10 @@ onUnmounted(() => {
} }
@media (prefers-reduced-transparency: reduce) { @media (prefers-reduced-transparency: reduce) {
.login-card__wallpaper-refraction {
display: none !important;
}
.login-card__glass, .login-card__glass,
.native-login-field { .native-login-field {
backdrop-filter: none !important; backdrop-filter: none !important;
@@ -1650,7 +1679,11 @@ onUnmounted(() => {
} }
} }
@supports not ((backdrop-filter: blur(1px))) { @supports not (backdrop-filter: blur(1px)) {
.login-card__wallpaper-refraction {
display: none !important;
}
.login-card__glass, .login-card__glass,
.native-login-field { .native-login-field {
background: rgba(var(--v-theme-surface), 0.96) !important; background: rgba(var(--v-theme-surface), 0.96) !important;
+522 -164
View File
@@ -1,35 +1,72 @@
/* stylelint-disable no-descending-specificity */ /* stylelint-disable no-descending-specificity */
/* stylelint-disable scss/at-rule-no-unknown */ /* stylelint-disable scss/at-rule-no-unknown */
// 玻璃主题通过 Vuetify 全局表面类统一塑造材质业务页面无需逐个接入 // 材质只覆盖统一表面 token质量档只替换光学层不改变业务组件契约
html[data-theme='glass'] { html[data-theme='glass'] {
--glass-surface: rgba(255, 255, 255, 8%); --glass-surface: rgba(11, 19, 34, 22%);
--glass-surface-soft: rgba(255, 255, 255, 5%); --glass-surface-soft: rgba(11, 19, 34, 28%);
--glass-surface-raised: rgba(255, 255, 255, 12%); --glass-surface-raised: rgba(11, 19, 34, 36%);
--glass-border: rgba(255, 255, 255, 15%); --glass-control: rgba(11, 19, 34, 52%);
--glass-border-hover: rgba(255, 255, 255, 28%); --glass-control-prominent: rgba(255, 255, 255, 7%);
--glass-highlight: rgba(255, 255, 255, 22%); --glass-control-prominent-focus: color-mix(in srgb, rgba(255, 255, 255, 10%) 84%, rgba(var(--v-theme-primary), 24%));
--glass-border: rgba(255, 255, 255, 10%);
--glass-border-raised: rgba(255, 255, 255, 14%);
--glass-border-hover: rgba(255, 255, 255, 20%);
--glass-control-prominent-border: rgba(255, 255, 255, 14%);
--glass-highlight: rgba(255, 255, 255, 14%);
--glass-sheen: linear-gradient(
135deg,
rgba(255, 255, 255, 9%),
transparent 28%,
rgba(255, 255, 255, 3%) 72%,
transparent
);
--glass-shadow: --glass-shadow:
0 6px 18px rgba(3, 7, 18, 28%), inset 0 1px 0 rgba(255, 255, 255, 18%), inset 0 -1px 0 rgba(2, 6, 16, 22%); 0 10px 28px rgba(3, 7, 18, 24%), inset 0 1px 0 var(--glass-highlight), inset 0 -1px 0 rgba(2, 6, 16, 14%);
--glass-shadow-raised: --glass-shadow-raised:
0 10px 28px rgba(3, 7, 18, 34%), 0 0 0 1px rgba(255, 255, 255, 4%), inset 0 1px 0 rgba(255, 255, 255, 22%), 0 16px 40px rgba(3, 7, 18, 32%), inset 0 1px 0 rgba(255, 255, 255, 24%), inset 0 -1px 0 rgba(2, 6, 16, 18%);
inset 0 -1px 0 rgba(2, 6, 16, 26%);
--glass-shadow-hover: --glass-shadow-hover:
0 12px 32px rgba(3, 7, 18, 38%), 0 0 0 1px rgba(255, 255, 255, 6%), inset 0 1px 0 rgba(255, 255, 255, 27%), 0 18px 44px rgba(3, 7, 18, 36%), inset 0 1px 0 rgba(255, 255, 255, 28%), inset 0 -1px 0 rgba(2, 6, 16, 18%);
inset 0 -1px 0 rgba(2, 6, 16, 28%);
--glass-navbar-shadow: --glass-navbar-shadow:
0 3px 12px rgba(3, 7, 18, 18%), inset 0 1px 0 rgba(255, 255, 255, 10%), inset 0 -1px 0 rgba(2, 6, 16, 12%); 0 3px 12px rgba(3, 7, 18, 18%), inset 0 1px 0 rgba(255, 255, 255, 10%), inset 0 -1px 0 rgba(2, 6, 16, 12%);
--glass-blur: 24px; --glass-control-shadow: inset 0 1px 0 rgba(255, 255, 255, 12%), inset 0 -1px 0 rgba(2, 6, 16, 14%);
--glass-control-shadow-hover: inset 0 1px 0 rgba(255, 255, 255, 18%), inset 0 -1px 0 rgba(2, 6, 16, 12%);
--glass-control-shadow-active: inset 0 1px 0 rgba(255, 255, 255, 7%), inset 0 -1px 0 rgba(2, 6, 16, 20%);
--glass-control-prominent-shadow: inset 0 1px 0 rgba(255, 255, 255, 18%), inset 0 -1px 0 rgba(2, 6, 16, 18%);
--glass-control-prominent-focus-shadow:
0 0 0 2px rgba(var(--v-theme-primary), 16%), inset 0 1px 0 rgba(255, 255, 255, 24%),
inset 0 -1px 0 rgba(2, 6, 16, 16%);
--glass-surface-backdrop-filter: none;
--glass-raised-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
--glass-control-backdrop-filter: none;
--glass-control-prominent-backdrop-filter: blur(20px) saturate(var(--glass-saturate));
--glass-control-icon-color: rgba(242, 245, 250, 70%);
--glass-control-placeholder-color: rgba(242, 245, 250, 54%);
--glass-control-shortcut-background: rgba(255, 255, 255, 8%);
--glass-control-shortcut-border: rgba(255, 255, 255, 12%);
--glass-control-shortcut-color: rgba(242, 245, 250, 68%);
--glass-button-surface: rgba(255, 255, 255, 8%);
--glass-button-surface-hover: rgba(255, 255, 255, 12%);
--glass-nav-active-background: linear-gradient(
90deg,
rgba(var(--v-theme-primary), 22%),
rgba(var(--v-theme-primary), 48%)
);
--glass-nav-active-border: 1px solid rgba(var(--v-theme-primary), 36%);
--glass-nav-active-shadow: inset 0 1px 0 rgba(255, 255, 255, 14%);
--glass-dashboard-backdrop-filter: none;
--glass-blur-surface: 16px;
--glass-blur: 22px;
--glass-blur-raised: 32px; --glass-blur-raised: 32px;
--glass-saturate: 150%; --glass-saturate: 145%;
--glass-motion: 500ms cubic-bezier(0.16, 1, 0.3, 1); --glass-motion: 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
--app-card-rest-shadow: var(--glass-shadow); --app-card-rest-shadow: var(--glass-shadow);
--app-card-hover-shadow: var(--glass-shadow-hover); --app-card-hover-shadow: var(--glass-shadow-hover);
--app-fab-shadow: var(--glass-shadow); --app-fab-shadow: var(--glass-control-shadow);
--app-fab-shadow-strong: var(--glass-shadow-raised); --app-fab-shadow-strong: var(--glass-control-shadow-hover);
--app-fab-shadow-hover: var(--glass-shadow-hover); --app-fab-shadow-hover: var(--glass-control-shadow-hover);
--app-fab-shadow-strong-hover: var(--glass-shadow-hover); --app-fab-shadow-strong-hover: var(--glass-control-shadow-hover);
--app-fab-shadow-active: 0 3px 10px rgba(3, 7, 18, 24%), inset 0 1px 0 rgba(255, 255, 255, 16%); --app-fab-shadow-active: var(--glass-control-shadow-active);
--app-overlay-shadow: var(--glass-shadow-raised); --app-overlay-shadow: var(--glass-shadow-raised);
--app-surface-shadow: var(--glass-shadow); --app-surface-shadow: var(--glass-shadow);
--app-surface-hover-shadow: var(--glass-shadow-hover); --app-surface-hover-shadow: var(--glass-shadow-hover);
@@ -41,16 +78,16 @@ html[data-theme='glass'] {
--app-card-light-border: 1px solid var(--glass-border); --app-card-light-border: 1px solid var(--glass-border);
--app-overlay-border: 1px solid var(--glass-border); --app-overlay-border: 1px solid var(--glass-border);
--app-grouped-list-background: var(--glass-surface); --app-grouped-list-background: var(--glass-surface);
--app-grouped-list-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); --app-grouped-list-backdrop-filter: none;
--app-grouped-list-border: 1px solid var(--glass-border); --app-grouped-list-border: 1px solid var(--glass-border);
--app-grouped-list-separator-color: rgba(255, 255, 255, 10%); --app-grouped-list-separator-color: rgba(255, 255, 255, 10%);
--app-grouped-list-header-color: rgba(242, 245, 250, 68%); --app-grouped-list-header-color: rgba(242, 245, 250, 68%);
--app-grouped-list-chevron-color: rgba(242, 245, 250, 56%); --app-grouped-list-chevron-color: rgba(242, 245, 250, 56%);
--app-grouped-list-hover-background: rgba(255, 255, 255, 8%); --app-grouped-list-hover-background: rgba(255, 255, 255, 8%);
--app-grouped-list-active-background: rgba(var(--v-theme-primary), 16%); --app-grouped-list-active-background: rgba(var(--v-theme-primary), 16%);
--mp-motion-duration-page: 500ms; --mp-motion-duration-page: 180ms;
--mp-motion-duration-overlay: 500ms; --mp-motion-duration-overlay: 180ms;
--mp-motion-ease-standard: cubic-bezier(0.16, 1, 0.3, 1); --mp-motion-ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
background-color: #0b1322; background-color: #0b1322;
color-scheme: dark; color-scheme: dark;
@@ -59,6 +96,77 @@ html[data-theme='glass'] {
background-color: #0b1322; background-color: #0b1322;
} }
// 色调保持与透明材质接近的透光度只叠加主色语义
&[data-glass-appearance='tinted'] {
--glass-surface: color-mix(in srgb, rgba(11, 19, 34, 24%) 88%, rgba(var(--v-theme-primary), 28%));
--glass-surface-soft: color-mix(in srgb, rgba(11, 19, 34, 30%) 89%, rgba(var(--v-theme-primary), 26%));
--glass-surface-raised: color-mix(in srgb, rgba(11, 19, 34, 38%) 84%, rgba(var(--v-theme-primary), 32%));
--glass-control: color-mix(in srgb, rgba(11, 19, 34, 52%) 88%, rgba(var(--v-theme-primary), 28%));
--glass-control-prominent: color-mix(in srgb, rgba(255, 255, 255, 7%) 82%, rgba(var(--v-theme-primary), 24%));
--glass-control-prominent-focus: color-mix(
in srgb,
rgba(255, 255, 255, 10%) 76%,
rgba(var(--v-theme-primary), 32%)
);
--glass-border: color-mix(in srgb, rgba(255, 255, 255, 10%) 82%, rgba(var(--v-theme-primary), 26%));
--glass-border-raised: color-mix(in srgb, rgba(255, 255, 255, 14%) 80%, rgba(var(--v-theme-primary), 30%));
--glass-border-hover: color-mix(in srgb, rgba(255, 255, 255, 20%) 78%, rgba(var(--v-theme-primary), 36%));
--glass-control-prominent-border: color-mix(
in srgb,
rgba(255, 255, 255, 14%) 78%,
rgba(var(--v-theme-primary), 32%)
);
--glass-highlight: color-mix(in srgb, rgba(255, 255, 255, 14%) 86%, rgba(var(--v-theme-primary), 18%));
--glass-sheen: linear-gradient(
135deg,
rgba(255, 255, 255, 9%),
transparent 28%,
rgba(var(--v-theme-primary), 6%) 72%,
transparent
);
}
// 磨砂材质使用更亮的散射表面但仍只让高价值层执行实时背景采样
&[data-glass-appearance='frosted'] {
--glass-surface: rgba(255, 255, 255, 8%);
--glass-surface-soft: rgba(255, 255, 255, 7%);
--glass-surface-raised: rgba(255, 255, 255, 12%);
--glass-control: rgba(255, 255, 255, 9%);
--glass-control-prominent: rgba(255, 255, 255, 10%);
--glass-control-prominent-focus: rgba(255, 255, 255, 13%);
--glass-border: rgba(255, 255, 255, 15%);
--glass-border-raised: rgba(255, 255, 255, 18%);
--glass-border-hover: rgba(255, 255, 255, 28%);
--glass-control-prominent-border: rgba(255, 255, 255, 18%);
--glass-highlight: rgba(255, 255, 255, 22%);
--glass-sheen: linear-gradient(
135deg,
rgba(255, 255, 255, 7%),
transparent 24%,
rgba(255, 255, 255, 2%) 72%,
transparent
);
--glass-control-prominent-shadow: inset 0 1px 0 rgba(255, 255, 255, 28%), inset 0 -1px 0 rgba(2, 6, 16, 20%);
--glass-control-prominent-focus-shadow:
0 0 0 2px rgba(var(--v-theme-primary), 17%), inset 0 1px 0 rgba(255, 255, 255, 34%),
inset 0 -1px 0 rgba(2, 6, 16, 18%);
--glass-control-prominent-backdrop-filter: blur(24px) saturate(150%);
--glass-blur-surface: 28px;
--glass-saturate: 150%;
--glass-control-icon-color: rgba(242, 245, 250, 78%);
--glass-control-placeholder-color: rgba(242, 245, 250, 62%);
--glass-control-shortcut-background: rgba(255, 255, 255, 11%);
--glass-control-shortcut-border: rgba(255, 255, 255, 16%);
--glass-control-shortcut-color: rgba(242, 245, 250, 76%);
--glass-button-surface: rgba(255, 255, 255, 10%);
--glass-button-surface-hover: rgba(255, 255, 255, 14%);
--glass-nav-active-background: linear-gradient(270deg, rgb(var(--v-theme-primary)) 0%, rgb(255, 255, 255) 300%);
--glass-nav-active-border: 0 solid transparent;
--glass-nav-active-shadow:
0 4px 14px -4px rgba(3, 7, 18, 34%), 0 4px 8px -4px rgba(3, 7, 18, 24%), 0 4px 8px -4px rgba(3, 7, 18, 16%);
--glass-dashboard-backdrop-filter: blur(var(--glass-blur-surface)) saturate(var(--glass-saturate));
}
&[data-theme-radius='none'] { &[data-theme-radius='none'] {
--app-theme-surface-radius: 8px; --app-theme-surface-radius: 8px;
--app-field-radius: 8px; --app-field-radius: 8px;
@@ -110,98 +218,54 @@ html[data-theme='glass'] {
.v-layout, .v-layout,
.v-main, .v-main,
.layout-wrapper, .layout-wrapper,
.layout-page-content,
.layout-content-wrapper, .layout-content-wrapper,
.page-content-container { .page-content-container {
background: transparent; background: transparent;
} }
.layout-page-content {
background-color: rgba(255, 255, 255, 2.5%);
}
&[data-glass-appearance='frosted'] .layout-page-content {
background-color: transparent;
}
.v-application { .v-application {
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
// 玻璃表面依赖稳定的背景采样层入场透明度位移或滤镜会让浏览器在动画结束后才重建材质 // 页面路由只使用短距离透明度与位移反馈避免触发整页滤镜重采样
:where( .mp-page-route--entering {
.mp-page-route--entering, animation: glass-page-route-enter var(--mp-motion-duration-page) var(--mp-motion-ease-standard) both;
.mp-page-enter-active, will-change: opacity, transform;
.mp-page-leave-active,
.fade-enter-active,
.fade-leave-active,
.fade-slide-enter-active,
.fade-slide-leave-active,
.fade-transition-enter-active,
.fade-transition-leave-active,
.module-item-enter-active
) {
animation: none !important;
transition: none !important;
will-change: auto !important;
} }
:where( @keyframes glass-page-route-enter {
.mp-page-enter-from, from {
.mp-page-leave-to, opacity: 0.82;
.fade-enter-from, transform: translate3d(0, 2px, 0);
.fade-leave-to, }
.fade-slide-enter-from,
.fade-slide-leave-to, to {
.fade-transition-enter-from, opacity: 1;
.fade-transition-leave-to, transform: none;
.module-item-enter-from, }
.module-item-enter-to }
) {
.mp-page-enter-active,
.mp-page-leave-active {
filter: none !important; filter: none !important;
opacity: 1 !important; transition:
transform: none !important; opacity var(--mp-motion-duration-page) var(--mp-motion-ease-standard),
transform var(--mp-motion-duration-page) var(--mp-motion-ease-standard);
} }
// 异步卡片批次不再通过父层透明度或位移动画入场避免整组玻璃表面重新合成 .mp-page-enter-from {
.dashboard-grid,
.stream-result-item {
animation: none !important;
opacity: 1 !important;
transform: none !important;
transition: none !important;
}
// Overlay 的过渡类挂在玻璃表面的父层上统一保持最终合成状态可覆盖菜单选择器和弹窗
.v-overlay__content[class*='-transition-enter-'],
.v-overlay__content[class*='-transition-leave-'] {
animation: none !important;
filter: none !important; filter: none !important;
opacity: 1 !important; opacity: 0.82;
transform: none !important; transform: translate3d(0, 2px, 0);
transition: none !important;
will-change: auto !important;
}
// 提前为实际使用背景滤镜的表面建立合成提示减少动态插入后的首帧材质延迟
:where(
.app-surface:not(.no-blur),
.v-card:not(.no-blur, .bg-primary, .bg-success, .bg-info, .bg-warning, .bg-error),
.v-sheet:not(.no-blur, .bg-primary, .bg-success, .bg-info, .bg-warning, .bg-error),
.v-list,
.v-toolbar,
.v-footer,
.v-expansion-panel,
.v-stepper,
.v-table,
.layout-vertical-nav,
.layout-navbar,
.footer-nav-card,
.theme-customizer-panel-host,
.agent-assistant-panel,
.v-tooltip > .v-overlay__content,
.v-snackbar__wrapper,
.transfer-history-mobile-record,
.mobile-calendar-filter-card,
.mobile-calendar-event-card,
.mobile-scheduler-card,
.logging-shell,
.ai-agent-settings-card
) {
will-change: backdrop-filter;
} }
:where( :where(
@@ -213,31 +277,46 @@ html[data-theme='glass'] {
.v-table .v-table
) { ) {
border-color: var(--glass-border) !important; border-color: var(--glass-border) !important;
-webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate)); -webkit-backdrop-filter: var(--glass-surface-backdrop-filter);
backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate)); backdrop-filter: var(--glass-surface-backdrop-filter);
background-color: var(--glass-surface) !important; background-color: var(--glass-surface) !important;
background-image: var(--glass-sheen) !important;
box-shadow: var(--glass-shadow) !important; box-shadow: var(--glass-shadow) !important;
} }
:where(.v-list, .v-toolbar, .v-footer) { :where(.v-list, .v-footer) {
border-color: var(--glass-border); border-color: var(--glass-border);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); -webkit-backdrop-filter: var(--glass-surface-backdrop-filter);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); backdrop-filter: var(--glass-surface-backdrop-filter);
background-color: var(--glass-surface) !important; background-color: var(--glass-surface) !important;
} }
.v-toolbar {
border-color: var(--glass-border);
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
backdrop-filter: var(--glass-raised-backdrop-filter);
background-color: var(--glass-surface) !important;
}
// Dashboard 顶层工作卡片允许一层业务布局容器内部图表列表与海报只使用静态光学表面
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card,
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > :first-child > .v-card {
-webkit-backdrop-filter: var(--glass-dashboard-backdrop-filter);
backdrop-filter: var(--glass-dashboard-backdrop-filter);
}
:where( :where(
.layout-vertical-nav,
.layout-navbar, .layout-navbar,
.footer-nav-card, .footer-nav-card,
.theme-customizer-panel-host, .theme-customizer-panel-host,
.agent-assistant-panel, .agent-assistant-panel,
.Vue-Toastification__toast .Vue-Toastification__toast
) { ) {
border-color: var(--glass-border) !important; border-color: var(--glass-border-raised) !important;
-webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate)); -webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate)); backdrop-filter: var(--glass-raised-backdrop-filter);
background-color: var(--glass-surface-raised) !important; background-color: var(--glass-surface-raised) !important;
background-image: var(--glass-sheen) !important;
box-shadow: var(--glass-shadow-raised) !important; box-shadow: var(--glass-shadow-raised) !important;
} }
@@ -247,7 +326,52 @@ html[data-theme='glass'] {
} }
.layout-vertical-nav { .layout-vertical-nav {
border-inline-end: 1px solid var(--glass-border); isolation: isolate;
border-inline-end: 0;
-webkit-backdrop-filter: none;
backdrop-filter: none;
background: transparent !important;
box-shadow: none !important;
// 导航材质与可变菜单内容分层路由激活态切换不应重栅格化整块全高背板
&::before {
position: absolute;
z-index: -1;
border-inline-end: 1px solid var(--glass-border-raised);
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
backdrop-filter: var(--glass-raised-backdrop-filter);
background-color: var(--glass-surface-raised);
background-image: var(--glass-sheen);
box-shadow: var(--glass-shadow-raised);
content: '';
inset: 0;
pointer-events: none;
}
}
// 滚动条与导航材质边界保持间距避免窄轨道参与全高背板的合成
.layout-vertical-nav .ps__rail-y {
inset-inline-end: 0.5rem !important;
transition: opacity 120ms linear;
}
.layout-vertical-nav .ps__thumb-y,
.layout-vertical-nav .ps__rail-y:focus > .ps__thumb-y,
.layout-vertical-nav .ps__rail-y:hover > .ps__thumb-y,
.layout-vertical-nav .ps__rail-y.ps--clicking .ps__thumb-y {
background-color: rgba(255, 255, 255, 28%) !important;
inline-size: 2px !important;
inset-inline-end: 0.1875rem;
}
.layout-vertical-nav .nav-items.ps--scrolling-y > .ps__rail-y {
opacity: 0.42;
}
.layout-vertical-nav .nav-link > .router-link-exact-active {
border: var(--glass-nav-active-border);
background: var(--glass-nav-active-background) !important;
box-shadow: var(--glass-nav-active-shadow) !important;
} }
.layout-wrapper.window-scrolled.layout-navbar-fixed .layout-navbar, .layout-wrapper.window-scrolled.layout-navbar-fixed .layout-navbar,
@@ -255,7 +379,8 @@ html[data-theme='glass'] {
border-color: var(--glass-border) !important; border-color: var(--glass-border) !important;
-webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate)) !important; -webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate)) !important;
backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate)) !important; backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate)) !important;
background: var(--glass-surface-raised) !important; background-color: var(--glass-surface-raised) !important;
background-image: var(--glass-sheen) !important;
box-shadow: var(--glass-navbar-shadow) !important; box-shadow: var(--glass-navbar-shadow) !important;
} }
@@ -269,21 +394,32 @@ html[data-theme='glass'] {
} }
:where(.search-desktop-activator, .search-header) .search-input-wrapper { :where(.search-desktop-activator, .search-header) .search-input-wrapper {
border-color: var(--glass-border) !important; border: 1px solid var(--glass-control-prominent-border) !important;
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); -webkit-backdrop-filter: var(--glass-control-prominent-backdrop-filter);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); backdrop-filter: var(--glass-control-prominent-backdrop-filter);
background: rgba(255, 255, 255, 9%) !important; background-color: var(--glass-control-prominent) !important;
box-shadow: background-image: var(--glass-sheen) !important;
inset 0 1px 0 rgba(255, 255, 255, 22%), box-shadow: var(--glass-control-prominent-shadow);
inset 0 -1px 0 rgba(2, 6, 16, 22%);
} }
:where(.search-desktop-activator, .search-header) .search-input-wrapper:focus-within { :where(.search-desktop-activator, .search-header) .search-input-wrapper:focus-within {
border-color: rgba(var(--v-theme-primary), 70%) !important; border-color: rgba(var(--v-theme-primary), 70%) !important;
background: rgba(255, 255, 255, 12%) !important; background: var(--glass-control-prominent-focus) !important;
box-shadow: box-shadow: var(--glass-control-prominent-focus-shadow);
0 0 0 3px rgba(var(--v-theme-primary), 15%), }
inset 0 1px 0 rgba(255, 255, 255, 28%);
:where(.search-desktop-activator, .search-header) .search-input-icon {
color: var(--glass-control-icon-color) !important;
}
:where(.search-desktop-activator, .search-header) .search-native-input::placeholder {
color: var(--glass-control-placeholder-color);
}
:where(.search-desktop-activator, .search-header) .search-shortcut-badge {
border: 1px solid var(--glass-control-shortcut-border);
background: var(--glass-control-shortcut-background);
color: var(--glass-control-shortcut-color);
} }
.theme-customizer-panel, .theme-customizer-panel,
@@ -329,16 +465,17 @@ html[data-theme='glass'] {
.compact-fab .v-btn, .compact-fab .v-btn,
.global-action-buttons .v-btn { .global-action-buttons .v-btn {
border: 1px solid var(--glass-border) !important; border: 1px solid var(--glass-border) !important;
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); -webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); backdrop-filter: var(--glass-raised-backdrop-filter);
background-color: var(--glass-surface-raised) !important; background-color: var(--glass-surface-raised) !important;
box-shadow: var(--glass-shadow) !important; background-image: var(--glass-sheen) !important;
box-shadow: var(--glass-control-shadow) !important;
} }
.v-overlay__scrim { .v-overlay__scrim {
-webkit-backdrop-filter: blur(12px) saturate(120%); -webkit-backdrop-filter: blur(12px) saturate(120%);
backdrop-filter: blur(12px) saturate(120%); backdrop-filter: blur(12px) saturate(120%);
background: rgba(3, 7, 18, 62%); background: rgba(3, 7, 18, 48%);
} }
.v-dialog--fullscreen { .v-dialog--fullscreen {
@@ -349,9 +486,10 @@ html[data-theme='glass'] {
border-radius: var(--app-overlay-radius); border-radius: var(--app-overlay-radius);
> :where(.v-card, .v-sheet, .v-list) { > :where(.v-card, .v-sheet, .v-list) {
-webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate)); -webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate)); backdrop-filter: var(--glass-raised-backdrop-filter);
background-color: var(--glass-surface-raised) !important; background-color: var(--glass-surface-raised) !important;
background-image: var(--glass-sheen) !important;
box-shadow: var(--glass-shadow-raised) !important; box-shadow: var(--glass-shadow-raised) !important;
} }
} }
@@ -366,28 +504,27 @@ html[data-theme='glass'] {
} }
.v-field { .v-field {
--v-field-border-opacity: 0.18; --v-field-border-opacity: 0.14;
--v-field-border-width: 1px; --v-field-border-width: 1px;
border-radius: var(--app-field-radius); border-radius: var(--app-field-radius);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); -webkit-backdrop-filter: var(--glass-control-backdrop-filter);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); backdrop-filter: var(--glass-control-backdrop-filter);
background-color: var(--glass-surface-soft) !important; background-color: var(--glass-control) !important;
box-shadow: box-shadow: inset 0 1px 0 rgba(255, 255, 255, 8%);
inset 0 1px 0 rgba(255, 255, 255, 15%),
inset 0 -1px 0 rgba(2, 6, 16, 30%);
transition: transition:
background-color var(--glass-motion), background-color var(--glass-motion),
box-shadow var(--glass-motion); box-shadow var(--glass-motion);
} }
.v-field--focused { .v-field--focused {
--v-field-border-opacity: 0.36; --v-field-border-opacity: 0.32;
background-color: rgba(255, 255, 255, 10%) !important; background-color: color-mix(in srgb, var(--glass-control) 84%, rgba(var(--v-theme-primary), 32%)) !important;
background-image: none !important;
box-shadow: box-shadow:
0 0 0 3px rgba(var(--v-theme-primary), 15%), 0 0 0 3px rgba(var(--v-theme-primary), 14%),
inset 0 1px 0 rgba(255, 255, 255, 25%); inset 0 1px 0 rgba(255, 255, 255, 10%);
} }
// Outlined 描边自身会为浮动标签留出缺口移除贯穿整个顶部的 field 阴影线 // Outlined 描边自身会为浮动标签留出缺口移除贯穿整个顶部的 field 阴影线
@@ -421,19 +558,16 @@ html[data-theme='glass'] {
transition: transition:
background-color var(--glass-motion), background-color var(--glass-motion),
border-color var(--glass-motion), border-color var(--glass-motion),
box-shadow var(--glass-motion), box-shadow var(--glass-motion);
transform var(--glass-motion);
} }
.v-btn:not(.v-btn--variant-text, .v-btn--variant-plain) { .v-btn:not(.v-btn--variant-text, .v-btn--variant-plain) {
border: 1px solid rgba(255, 255, 255, 20%); border: 1px solid var(--glass-border);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); -webkit-backdrop-filter: var(--glass-control-backdrop-filter);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); backdrop-filter: var(--glass-control-backdrop-filter);
background-color: rgba(255, 255, 255, 10%) !important; background-color: var(--glass-button-surface) !important;
box-shadow: background-image: none !important;
0 2px 8px rgba(3, 7, 18, 24%), box-shadow: var(--glass-control-shadow) !important;
inset 0 1px 0 rgba(255, 255, 255, 20%),
inset 0 -1px 0 rgba(2, 6, 16, 18%) !important;
} }
.v-btn.bg-primary { .v-btn.bg-primary {
@@ -465,7 +599,7 @@ html[data-theme='glass'] {
outline: 0; outline: 0;
box-shadow: box-shadow:
0 0 0 3px rgba(var(--v-theme-primary), 18%), 0 0 0 3px rgba(var(--v-theme-primary), 18%),
var(--glass-shadow) !important; var(--glass-control-shadow) !important;
} }
.v-chip { .v-chip {
@@ -495,8 +629,8 @@ html[data-theme='glass'] {
--app-card-stripe-opacity: 0.12; --app-card-stripe-opacity: 0.12;
--app-card-surface-opacity: 0.08; --app-card-surface-opacity: 0.08;
-webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate)); -webkit-backdrop-filter: none;
backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate)); backdrop-filter: none;
} }
// 移动端文件整理和日历使用自定义容器需要显式接入玻璃表面材质 // 移动端文件整理和日历使用自定义容器需要显式接入玻璃表面材质
@@ -504,15 +638,15 @@ html[data-theme='glass'] {
--transfer-history-mobile-search-bg: var(--glass-surface-soft); --transfer-history-mobile-search-bg: var(--glass-surface-soft);
--transfer-history-mobile-muted-bg: var(--glass-surface-soft); --transfer-history-mobile-muted-bg: var(--glass-surface-soft);
--transfer-history-mobile-border: var(--glass-border); --transfer-history-mobile-border: var(--glass-border);
--transfer-history-mobile-surface-blur: blur(var(--glass-blur)) saturate(var(--glass-saturate)); --transfer-history-mobile-surface-blur: none;
} }
.transfer-history-mobile-record, .transfer-history-mobile-record,
.mobile-calendar-filter-card, .mobile-calendar-filter-card,
.mobile-calendar-event-card { .mobile-calendar-event-card {
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); -webkit-backdrop-filter: none;
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); backdrop-filter: none;
background: var(--glass-surface); background: var(--glass-surface);
} }
@@ -528,7 +662,7 @@ html[data-theme='glass'] {
} }
.mobile-scheduler-view { .mobile-scheduler-view {
--mobile-scheduler-card-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); --mobile-scheduler-card-backdrop-filter: none;
--mobile-scheduler-card-background: var(--glass-surface); --mobile-scheduler-card-background: var(--glass-surface);
--mobile-scheduler-card-border: 1px solid var(--glass-border); --mobile-scheduler-card-border: 1px solid var(--glass-border);
--mobile-scheduler-card-shadow: var(--glass-shadow); --mobile-scheduler-card-shadow: var(--glass-shadow);
@@ -537,7 +671,7 @@ html[data-theme='glass'] {
.mobile-subscribe-calendar { .mobile-subscribe-calendar {
--mobile-calendar-control-bg: var(--glass-surface-soft); --mobile-calendar-control-bg: var(--glass-surface-soft);
--mobile-calendar-placeholder-bg: var(--glass-surface-soft); --mobile-calendar-placeholder-bg: var(--glass-surface-soft);
--mobile-calendar-surface-blur: blur(var(--glass-blur)) saturate(var(--glass-saturate)); --mobile-calendar-surface-blur: none;
} }
// 用户权限编辑器由普通容器构成通过主题变量接入玻璃表面材质 // 用户权限编辑器由普通容器构成通过主题变量接入玻璃表面材质
@@ -548,7 +682,7 @@ html[data-theme='glass'] {
--permission-editor-hover-bg: rgba(255, 255, 255, 8%); --permission-editor-hover-bg: rgba(255, 255, 255, 8%);
--permission-editor-active-bg: rgba(var(--v-theme-primary), 16%); --permission-editor-active-bg: rgba(var(--v-theme-primary), 16%);
--permission-editor-selected-bg: rgba(var(--v-theme-primary), 12%); --permission-editor-selected-bg: rgba(var(--v-theme-primary), 12%);
--permission-editor-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); --permission-editor-backdrop-filter: none;
--permission-editor-shadow: inset 0 1px 0 rgba(255, 255, 255, 8%); --permission-editor-shadow: inset 0 1px 0 rgba(255, 255, 255, 8%);
} }
@@ -592,10 +726,8 @@ html[data-theme='glass'] {
@media (hover: hover) and (pointer: fine) { @media (hover: hover) and (pointer: fine) {
.v-btn:not(.v-btn--variant-text, .v-btn--variant-plain):hover { .v-btn:not(.v-btn--variant-text, .v-btn--variant-plain):hover {
border-color: var(--glass-border-hover); border-color: var(--glass-border-hover);
background-color: rgba(255, 255, 255, 15%) !important; background-color: var(--glass-button-surface-hover) !important;
box-shadow: box-shadow: var(--glass-control-shadow-hover) !important;
0 5px 16px rgba(3, 7, 18, 28%),
inset 0 1px 0 rgba(255, 255, 255, 26%) !important;
} }
.v-btn.bg-primary:hover { .v-btn.bg-primary:hover {
@@ -629,8 +761,8 @@ html[data-theme='glass'] {
} }
} }
.v-btn:active { .v-btn:not(.v-btn--variant-text, .v-btn--variant-plain):active {
transform: scale(0.97); box-shadow: var(--glass-control-shadow-active) !important;
} }
@media (width <= 960px) { @media (width <= 960px) {
@@ -665,6 +797,23 @@ html[data-theme='glass'] {
} }
} }
@media (prefers-reduced-transparency: reduce) {
:where(
.app-surface,
.v-card,
.v-sheet,
.v-list,
.v-toolbar,
.layout-vertical-nav,
.layout-navbar,
.v-overlay__content
) {
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
background-color: rgba(11, 19, 34, 94%) !important;
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.v-btn, .v-btn,
.v-card, .v-card,
@@ -674,3 +823,212 @@ html[data-theme='glass'] {
} }
} }
} }
// 光学档位只在 renderer ready 后接管背景采样加载与失败状态继续使用标准 CSS 材质
.glass-optical-layer {
position: fixed;
z-index: 0;
block-size: 100%;
inline-size: 100%;
inset: 0;
opacity: 0;
pointer-events: none;
transition: opacity 180ms ease-out;
}
// 壁纸交叉淡入期间停用折射输出避免新纹理采样仍在混合的背景
.glass-optical-layer--background-transition {
opacity: 0 !important;
}
// 登录卡片的壁纸副本与外层背景共享切换时序避免卡片内部提前显示下一张图片
html[data-theme='glass'] .app-wrapper--background-transition .login-card__wallpaper-refraction {
opacity: 0 !important;
}
html:is([data-glass-quality='balanced'], [data-glass-quality='high'])[data-glass-renderer-state='ready']
.glass-optical-layer {
opacity: 1;
}
html:is([data-glass-quality='balanced'], [data-glass-quality='high'])[data-glass-renderer-state='ready']
body[data-theme='glass'] {
.layout-vertical-nav::before,
.layout-navbar {
-webkit-backdrop-filter: blur(10px) saturate(140%) !important;
backdrop-filter: blur(10px) saturate(140%) !important;
}
.v-overlay__content > :where(.v-card, .v-sheet, .v-list) {
-webkit-backdrop-filter: blur(12px) saturate(140%) !important;
backdrop-filter: blur(12px) saturate(140%) !important;
}
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card,
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > :first-child > .v-card {
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
}
}
// 磨砂材质的散射由 renderer 与有限的局部扩散共同构成
html[data-glass-appearance='frosted']:is(
[data-glass-quality='balanced'],
[data-glass-quality='high']
)[data-glass-renderer-state='ready']
body[data-theme='glass'] {
.layout-vertical-nav::before,
.layout-navbar,
.v-overlay__content > :where(.v-card, .v-sheet, .v-list) {
-webkit-backdrop-filter: blur(18px) saturate(145%) !important;
backdrop-filter: blur(18px) saturate(145%) !important;
}
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card,
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > :first-child > .v-card {
-webkit-backdrop-filter: blur(24px) saturate(150%) !important;
backdrop-filter: blur(24px) saturate(150%) !important;
}
}
// 登录页只有一个高价值表面三档质量共用局部实时材质避免整屏 renderer 重复采样跨域壁纸
html[data-theme='glass'] body[data-theme='glass'] {
.login-card {
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.2),
0 24px 64px rgba(1, 6, 15, 0.22) !important;
}
.login-card__glass {
-webkit-backdrop-filter: blur(14px) saturate(162%) contrast(108%) !important;
backdrop-filter: blur(14px) saturate(162%) contrast(108%) !important;
background:
radial-gradient(
220px 190px at var(--login-card-light-x) var(--login-card-light-y),
rgba(255, 255, 255, 0.13),
rgba(var(--v-theme-primary), 0.045) 42%,
transparent 76%
),
linear-gradient(145deg, rgba(255, 255, 255, 0.1), transparent 36%), rgba(var(--v-theme-surface), 0.28) !important;
}
.login-card__wallpaper-refraction {
display: block;
background-attachment: fixed;
background-image: var(--login-wallpaper-image);
background-position: calc(50% + var(--login-refraction-shift-x)) calc(50% + var(--login-refraction-shift-y));
background-repeat: no-repeat;
background-size: cover;
filter: url('#login-liquid-refraction-clear') saturate(1.22) contrast(1.06) brightness(1.04);
opacity: 0.48;
transform: scale(1.025);
transition:
background-position 90ms linear,
opacity 180ms ease-out;
}
.login-card__glass-caustic {
inset-block-start: calc(var(--login-card-light-y) - 88px);
inset-inline-start: calc(var(--login-card-light-x) - 112px);
mix-blend-mode: screen;
opacity: calc(var(--optical-glass-caustic-opacity) + 0.12);
transform: scale(calc(var(--optical-glass-caustic-scale) + 0.08));
transition:
inset-block-start 90ms linear,
inset-inline-start 90ms linear,
opacity 160ms ease-out,
transform 180ms ease-out;
}
.native-login-field {
border-color: rgba(255, 255, 255, 0.15);
background: rgba(7, 16, 28, 0.2);
-webkit-backdrop-filter: blur(10px) saturate(132%);
backdrop-filter: blur(10px) saturate(132%);
}
}
html[data-theme='glass'][data-glass-appearance='tinted'] body[data-theme='glass'] {
.login-card__glass {
background:
radial-gradient(
220px 190px at var(--login-card-light-x) var(--login-card-light-y),
rgba(255, 255, 255, 0.13),
rgba(var(--v-theme-primary), 0.1) 44%,
transparent 76%
),
linear-gradient(145deg, rgba(255, 255, 255, 0.1), transparent 36%),
linear-gradient(rgba(var(--v-theme-primary), 0.1), rgba(var(--v-theme-primary), 0.04)),
rgba(var(--v-theme-surface), 0.28) !important;
}
.login-card__wallpaper-refraction {
filter: url('#login-liquid-refraction-clear') saturate(1.22) contrast(1.06) brightness(1.04);
opacity: 0.48;
}
.native-login-field {
border-color: rgba(255, 255, 255, 0.15);
background:
linear-gradient(rgba(var(--v-theme-primary), 0.07), rgba(var(--v-theme-primary), 0.07)), rgba(7, 16, 28, 0.2);
}
}
html[data-theme='glass'][data-glass-appearance='frosted'] body[data-theme='glass'] {
.login-card {
box-shadow: var(--glass-shadow-raised) !important;
}
.login-card__glass {
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter) !important;
backdrop-filter: var(--glass-raised-backdrop-filter) !important;
background-color: var(--glass-surface-raised) !important;
background-image: var(--glass-sheen) !important;
}
.login-card__glass::after {
position: absolute;
z-index: 1;
background-color: rgba(255, 255, 255, 0.08);
background-image: var(--glass-sheen);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.22),
inset 0 -1px 0 rgba(2, 6, 16, 0.16);
content: '';
inset: 0;
}
// 登录页位于独立合成边界壁纸副本用于恢复导航表面的背景采样语义
.login-card__wallpaper-refraction {
display: block;
background-image: linear-gradient(rgba(6, 10, 19, 0.3), rgba(6, 10, 19, 0.58)), var(--login-wallpaper-image);
background-attachment: fixed, fixed;
background-position:
center,
calc(50% + var(--login-refraction-shift-x)) calc(50% + var(--login-refraction-shift-y));
background-repeat: no-repeat;
background-size: cover;
filter: blur(52px) brightness(0.76) saturate(0.72);
opacity: 0.94;
transform: scale(1.24);
}
.login-card__glass-caustic {
opacity: calc(var(--optical-glass-caustic-opacity) + 0.08);
}
.native-login-field {
border-color: var(--glass-control-prominent-border);
background: var(--glass-control-prominent);
-webkit-backdrop-filter: var(--glass-control-prominent-backdrop-filter);
backdrop-filter: var(--glass-control-prominent-backdrop-filter);
box-shadow: var(--glass-control-prominent-shadow);
}
}
@media (prefers-reduced-motion: reduce) {
.glass-optical-layer {
transition: none;
}
}
+107
View File
@@ -0,0 +1,107 @@
import {
canUseGlassWallpaperTexture,
getGlassCoverScale,
getGlassOpticalBufferSize,
getGlassOpticalRenderProfile,
normalizeGlassOpticalRect,
selectGlassOpticalRects,
type GlassOpticalRect,
} from '@/utils/glassOptics'
import { describe, expect, it } from 'vitest'
describe('glass optics geometry', () => {
it('caps the renderer buffer independently from device pixel ratio', () => {
expect(getGlassOpticalBufferSize(3456, 2234, false)).toEqual({ height: 621, width: 960 })
expect(getGlassOpticalBufferSize(390, 844, true)).toEqual({ height: 720, width: 333 })
expect(getGlassOpticalBufferSize(3456, 2234, false, 'high')).toEqual({ height: 931, width: 1440 })
expect(getGlassOpticalBufferSize(390, 844, true, 'high')).toEqual({ height: 844, width: 390 })
})
it('matches cover cropping on wide and tall images', () => {
expect(getGlassCoverScale(1600, 900, 2400, 1600)).toEqual({ x: 1, y: 0.84375 })
expect(getGlassCoverScale(900, 1600, 2400, 1600)).toEqual({ x: 0.375, y: 1 })
})
it('keeps high quality optics while reducing the media-dense recommendation budget', () => {
expect(getGlassOpticalRenderProfile('high', '/dashboard')).toEqual({
bufferQuality: 'high',
textureLimit: 3072,
textureSource: 'wallpaper',
})
expect(getGlassOpticalRenderProfile('high', '/recommend?source=tmdb')).toEqual({
bufferQuality: 'balanced',
textureLimit: 2048,
textureSource: 'wallpaper',
})
expect(getGlassOpticalRenderProfile('balanced', '/dashboard')).toEqual({
bufferQuality: 'balanced',
textureLimit: 2048,
textureSource: 'wallpaper',
})
expect(getGlassOpticalRenderProfile('high', '/login')).toEqual({
bufferQuality: 'high',
textureLimit: 3072,
textureSource: 'auto',
})
})
it('only uploads browser-readable login wallpapers to WebGL', () => {
const documentUrl = 'https://moviepilot.example/login'
expect(canUseGlassWallpaperTexture('/api/v1/login/wallpaper/0', documentUrl)).toBe(true)
expect(canUseGlassWallpaperTexture('https://moviepilot.example/assets/login.jpg', documentUrl)).toBe(true)
expect(canUseGlassWallpaperTexture('blob:https://moviepilot.example/texture', documentUrl)).toBe(true)
expect(canUseGlassWallpaperTexture('data:image/png;base64,AA==', documentUrl)).toBe(true)
expect(canUseGlassWallpaperTexture('https://image.tmdb.org/t/p/original/poster.jpg', documentUrl)).toBe(false)
expect(canUseGlassWallpaperTexture('', documentUrl)).toBe(false)
})
it('keeps high-value outer surfaces and removes nested repeats', () => {
const candidates: GlassOpticalRect[] = [
{ height: 900, radius: 0, rank: 2, width: 260, x: 0, y: 0 },
{ height: 300, radius: 16, rank: 4, width: 500, x: 300, y: 100 },
{ height: 100, radius: 12, rank: 5, width: 200, x: 320, y: 120 },
{ height: 120, radius: 12, rank: 1, width: 400, x: 500, y: 700 },
]
const selected = selectGlassOpticalRects(candidates, 1440, 900, false)
expect(selected).toHaveLength(3)
expect(selected.map(rect => rect.rank)).toEqual([1, 2, 4])
})
it('keeps original geometry while using the viewport intersection for visibility', () => {
const selected = selectGlassOpticalRects(
[{ height: 160, radius: 20, rank: 1, width: 180, x: -30, y: -40 }],
320,
240,
false,
)
expect(selected).toEqual([{ height: 160, radius: 20, rank: 1, width: 180, x: -30, y: -40 }])
})
it('budgets partially visible surfaces by their visible pixels', () => {
const selected = selectGlassOpticalRects(
[
{ height: 1000, radius: 20, rank: 1, width: 1000, x: -950, y: -900 },
{ height: 120, radius: 16, rank: 2, width: 200, x: 80, y: 80 },
],
320,
240,
false,
)
expect(selected.map(rect => rect.rank)).toEqual([1, 2])
})
it('converts DOM top-origin rectangles while preserving pixel radius', () => {
expect(
normalizeGlassOpticalRect({ height: 100, radius: 20, rank: 1, width: 200, x: 100, y: 50 }, 1000, 500),
).toEqual({ radius: 20, rect: [0.1, 0.7, 0.2, 0.2] })
expect(
normalizeGlassOpticalRect({ height: 100, radius: 80, rank: 1, width: 200, x: -20, y: 50 }, 1000, 500),
).toEqual({ radius: 50, rect: [-0.02, 0.7, 0.2, 0.2] })
})
})
+166
View File
@@ -0,0 +1,166 @@
export const GLASS_OPTICAL_MAX_SURFACES_DESKTOP = 8
export const GLASS_OPTICAL_MAX_SURFACES_MOBILE = 5
export type GlassOpticalQuality = 'balanced' | 'high'
export interface GlassOpticalRect {
/** 元素的实际高度,元素可部分位于视口外。 */
height: number
/** 元素圆角的 CSS 像素值。 */
radius: number
/** 表面的场景优先级,数值越小越优先。 */
rank: number
/** 元素的实际宽度,元素可部分位于视口外。 */
width: number
/** 元素左边缘相对视口的位置。 */
x: number
/** 元素上边缘相对视口的位置。 */
y: number
}
export interface GlassOpticalBufferSize {
height: number
width: number
}
export interface GlassOpticalRenderProfile {
/** 实际内部缓冲档位;媒体密集场景可保留高质量光学但降低合成分辨率。 */
bufferQuality: GlassOpticalQuality
/** 活动壁纸进入 GPU 前的最长边限制。 */
textureLimit: number
/** 登录页优先使用可读纹理,跨域外链自动退回程序化高光。 */
textureSource: 'auto' | 'procedural' | 'wallpaper'
}
/** 按质量与场景分配合成预算,避免推荐页海报解码与高分辨率光学层争抢资源。 */
export function getGlassOpticalRenderProfile(
quality: GlassOpticalQuality,
routeKey: string,
): GlassOpticalRenderProfile {
const mediaDenseRoute = routeKey.startsWith('/recommend')
return {
bufferQuality: quality === 'high' && !mediaDenseRoute ? 'high' : 'balanced',
textureLimit: quality === 'high' && !mediaDenseRoute ? 3072 : 2048,
textureSource: routeKey.startsWith('/login') ? 'auto' : 'wallpaper',
}
}
/** 只将同源及本地对象交给 WebGL,避免跨域纹理失败污染登录页控制台。 */
export function canUseGlassWallpaperTexture(url: string, documentUrl: string): boolean {
if (!url || !documentUrl) return false
try {
const source = new URL(url, documentUrl)
if (source.protocol === 'blob:' || source.protocol === 'data:') return true
return source.origin === new URL(documentUrl).origin
} catch {
return false
}
}
/** 按固定像素预算计算内部缓冲尺寸,避免高 DPI 屏幕线性放大 GPU 成本。 */
export function getGlassOpticalBufferSize(
viewportWidth: number,
viewportHeight: number,
mobile: boolean,
quality: GlassOpticalQuality = 'balanced',
): GlassOpticalBufferSize {
const safeWidth = Math.max(1, viewportWidth)
const safeHeight = Math.max(1, viewportHeight)
const highQuality = quality === 'high'
const maxWidth = mobile ? (highQuality ? 960 : 720) : highQuality ? 1440 : 960
const maxHeight = highQuality ? 960 : 720
const scale = Math.min(1, maxWidth / safeWidth, maxHeight / safeHeight)
return {
height: Math.max(1, Math.round(safeHeight * scale)),
width: Math.max(1, Math.round(safeWidth * scale)),
}
}
/** 计算与 CSS `background-size: cover` 一致的纹理缩放参数。 */
export function getGlassCoverScale(
viewportWidth: number,
viewportHeight: number,
imageWidth: number,
imageHeight: number,
) {
const viewportAspect = Math.max(1, viewportWidth) / Math.max(1, viewportHeight)
const imageAspect = Math.max(1, imageWidth) / Math.max(1, imageHeight)
return imageAspect > viewportAspect
? { x: viewportAspect / imageAspect, y: 1 }
: { x: 1, y: imageAspect / viewportAspect }
}
/** 用可见交集筛选和计费,但保留原始边界给 shader,避免在视口裁剪边生成伪圆角。 */
export function selectGlassOpticalRects(
candidates: GlassOpticalRect[],
viewportWidth: number,
viewportHeight: number,
mobile: boolean,
): GlassOpticalRect[] {
const viewportArea = Math.max(1, viewportWidth * viewportHeight)
const maxCount = mobile ? GLASS_OPTICAL_MAX_SURFACES_MOBILE : GLASS_OPTICAL_MAX_SURFACES_DESKTOP
const maxArea = viewportArea * (mobile ? 0.68 : 0.82)
const visible = candidates
.map(rect => {
const left = Math.max(0, rect.x)
const top = Math.max(0, rect.y)
const right = Math.min(viewportWidth, rect.x + rect.width)
const bottom = Math.min(viewportHeight, rect.y + rect.height)
const visibleHeight = Math.max(0, bottom - top)
const visibleWidth = Math.max(0, right - left)
return {
rect,
visibleArea: visibleWidth * visibleHeight,
visibleHeight,
visibleWidth,
}
})
.filter(candidate => candidate.visibleWidth >= 24 && candidate.visibleHeight >= 24)
.sort((left, right) => left.rect.rank - right.rect.rank || left.visibleArea - right.visibleArea)
const selected: typeof visible = []
let selectedArea = 0
for (const candidate of visible) {
if (selected.length >= maxCount) break
const { rect } = candidate
const nested = selected.some(
parent =>
rect.x >= parent.rect.x &&
rect.y >= parent.rect.y &&
rect.x + rect.width <= parent.rect.x + parent.rect.width &&
rect.y + rect.height <= parent.rect.y + parent.rect.height,
)
if (nested) continue
if (selected.length > 0 && selectedArea + candidate.visibleArea > maxArea) continue
selected.push(candidate)
selectedArea += candidate.visibleArea
}
return selected.map(candidate => candidate.rect)
}
/** 将视口矩形转换为 WebGL 底部原点的归一化参数。 */
export function normalizeGlassOpticalRect(rect: GlassOpticalRect, viewportWidth: number, viewportHeight: number) {
const safeWidth = Math.max(1, viewportWidth)
const safeHeight = Math.max(1, viewportHeight)
return {
radius: Math.min(Math.max(0, rect.radius), Math.max(0, Math.min(rect.width, rect.height) / 2)),
rect: [
rect.x / safeWidth,
1 - (rect.y + rect.height) / safeHeight,
rect.width / safeWidth,
rect.height / safeHeight,
] as const,
}
}