mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 01:06:41 +08:00
feat: add glass materials and quality profiles (#577)
This commit is contained in:
@@ -34,10 +34,15 @@ $ps-track-size: 0.5rem;
|
||||
inline-size: $ps-hover-size;
|
||||
}
|
||||
|
||||
// fix bug
|
||||
@media(hover: none) {
|
||||
.ps > .ps__rail-x,
|
||||
.ps > .ps__rail-y {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
// 滚动条只反馈实际滚动状态,鼠标经过容器或键盘聚焦不应让轨道常驻。
|
||||
.ps:hover > .ps__rail-x,
|
||||
.ps:hover > .ps__rail-y,
|
||||
.ps--focus > .ps__rail-x,
|
||||
.ps--focus > .ps__rail-y {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
.ps.ps--scrolling-x > .ps__rail-x,
|
||||
.ps.ps--scrolling-y > .ps__rail-y {
|
||||
opacity: 0.6 !important;
|
||||
}
|
||||
|
||||
+127
-25
@@ -11,7 +11,7 @@ import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
||||
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
|
||||
import { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer'
|
||||
import { applyStoredThemeCustomizerAppearance, useEffectiveGlassSettings } from '@/composables/useThemeCustomizer'
|
||||
import {
|
||||
applyStoredTransparencySettings,
|
||||
TRANSPARENCY_SETTINGS_CHANGED_EVENT,
|
||||
@@ -22,15 +22,14 @@ import { completeLaunchLoading } from '@/composables/useLaunchLoading'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
import {
|
||||
useGlobalOfflineStatus,
|
||||
type ConnectionFailureReason,
|
||||
} from '@/composables/useOfflineStatus'
|
||||
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
|
||||
|
||||
const LOGIN_WALLPAPER_ROUTE = '/login'
|
||||
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
|
||||
const WINDOW_BLUR_RENDER_THROTTLE_DELAY_MS = 180_000
|
||||
const MEDIA_DENSE_OPTICAL_DEFER_MS = 1_600
|
||||
|
||||
// 生效主题
|
||||
const vuetifyTheme = useTheme()
|
||||
@@ -59,6 +58,7 @@ setI18nLanguage(localeValue as SupportedLocale)
|
||||
const authStore = useAuthStore()
|
||||
const isLogin = computed(() => authStore.token)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { initializePWA } = usePWA()
|
||||
const offlineStatus = useGlobalOfflineStatus()
|
||||
|
||||
@@ -72,8 +72,14 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'
|
||||
const backgroundImages = ref<string[]>([])
|
||||
const activeImageIndex = ref(0)
|
||||
const previousImageIndex = ref<number | null>(null)
|
||||
const isBackgroundCrossfading = ref(false)
|
||||
const isRenderThrottled = ref(document.visibilityState === 'hidden')
|
||||
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
||||
const isGlassTheme = computed(() => globalTheme.name.value === 'glass')
|
||||
const effectiveGlassSettings = useEffectiveGlassSettings()
|
||||
const isInitialRouteReady = ref(false)
|
||||
const isGlassOpticalLayerDeferred = ref(true)
|
||||
let glassOpticalLayerDeferTimer: number | null = null
|
||||
const isBackdropTheme = computed(() => isTransparentTheme.value || isGlassTheme.value)
|
||||
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
|
||||
const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value)
|
||||
@@ -81,6 +87,23 @@ const shouldUseGlassBackgroundTreatment = computed(() => Boolean(isLogin.value)
|
||||
const shouldLoadBackgroundImages = computed(
|
||||
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isBackdropTheme.value),
|
||||
)
|
||||
const activeBackgroundImage = computed(() => backgroundImages.value[activeImageIndex.value] ?? '')
|
||||
// 登录页的光学层只绘制程序化焦散,不会读取该跨域壁纸;登录后才通过同源缓存采样纹理。
|
||||
const activeOpticalBackgroundImage = computed(() =>
|
||||
getDisplayImageUrl(activeBackgroundImage.value, Boolean(isLogin.value)),
|
||||
)
|
||||
const appWrapperStyle = computed(() => ({
|
||||
'--login-wallpaper-image': activeBackgroundImage.value ? `url("${activeBackgroundImage.value}")` : 'none',
|
||||
}))
|
||||
const shouldRenderGlassOpticalLayer = computed(
|
||||
() =>
|
||||
isGlassTheme.value &&
|
||||
effectiveGlassSettings.value.glassQuality !== 'css' &&
|
||||
!isGlassOpticalLayerDeferred.value &&
|
||||
!isRenderThrottled.value &&
|
||||
Boolean(activeBackgroundImage.value),
|
||||
)
|
||||
const GlassOpticalLayer = defineAsyncComponent(() => import('@/components/theme/GlassOpticalLayer.vue'))
|
||||
const transparentBackgroundBlur = ref(16)
|
||||
const transparencyGlassQuality = ref<TransparencyGlassQuality>(
|
||||
localStorage.getItem('transparency-glass-quality') === 'realtime' ? 'realtime' : 'lightweight',
|
||||
@@ -91,7 +114,6 @@ const shouldRenderGlobalBlurLayer = computed(
|
||||
transparentBackgroundBlur.value > 0 &&
|
||||
transparencyGlassQuality.value === 'realtime',
|
||||
)
|
||||
const isRenderThrottled = ref(document.visibilityState === 'hidden')
|
||||
let backgroundRetryTimer: number | null = null
|
||||
let backgroundRequestController: AbortController | null = null
|
||||
let backgroundCrossfadeTimer: number | null = null
|
||||
@@ -116,6 +138,39 @@ function handleTransparencySettingsChanged(event: Event) {
|
||||
|
||||
applyTransparentBackgroundSettings()
|
||||
|
||||
/** 推荐页高质量光学层让首屏海报优先完成解码与合成。 */
|
||||
watch(
|
||||
() => [route.fullPath, effectiveGlassSettings.value.glassQuality, isInitialRouteReady.value] as const,
|
||||
([, quality, routeReady]) => {
|
||||
if (glassOpticalLayerDeferTimer !== null) {
|
||||
window.clearTimeout(glassOpticalLayerDeferTimer)
|
||||
glassOpticalLayerDeferTimer = null
|
||||
}
|
||||
|
||||
// 首次导航完成前 route 仍可能是重定向来源,禁止 renderer 按错误页面短暂挂载。
|
||||
if (!routeReady) {
|
||||
isGlassOpticalLayerDeferred.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (route.path !== '/recommend' || quality !== 'high') {
|
||||
isGlassOpticalLayerDeferred.value = false
|
||||
return
|
||||
}
|
||||
|
||||
isGlassOpticalLayerDeferred.value = true
|
||||
glassOpticalLayerDeferTimer = window.setTimeout(() => {
|
||||
isGlassOpticalLayerDeferred.value = false
|
||||
glassOpticalLayerDeferTimer = null
|
||||
}, MEDIA_DENSE_OPTICAL_DEFER_MS)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
void router.isReady().then(() => {
|
||||
isInitialRouteReady.value = true
|
||||
})
|
||||
|
||||
function clearWindowBlurRenderThrottleTimer() {
|
||||
if (windowBlurRenderThrottleTimer) {
|
||||
window.clearTimeout(windowBlurRenderThrottleTimer)
|
||||
@@ -212,13 +267,10 @@ async function probeServerConnection(showChecking = false): Promise<boolean> {
|
||||
const successSequenceAtProbeStart = offlineStatus.serverSuccessSequence.value
|
||||
const probePromise = (async () => {
|
||||
try {
|
||||
await api.get(
|
||||
'system/ping',
|
||||
{
|
||||
skipConnectionTracking: true,
|
||||
timeout: SERVER_PROBE_TIMEOUT_MS,
|
||||
} as ConnectionAwareRequestConfig,
|
||||
)
|
||||
await api.get('system/ping', {
|
||||
skipConnectionTracking: true,
|
||||
timeout: SERVER_PROBE_TIMEOUT_MS,
|
||||
} as ConnectionAwareRequestConfig)
|
||||
connectionProbeFailures = 0
|
||||
return true
|
||||
} catch (error) {
|
||||
@@ -261,9 +313,12 @@ function startHeartbeat() {
|
||||
|
||||
void probeServerConnection()
|
||||
|
||||
heartbeatInterval = window.setInterval(async () => {
|
||||
if (isLogin.value) await probeServerConnection()
|
||||
}, 5 * 60 * 1000)
|
||||
heartbeatInterval = window.setInterval(
|
||||
async () => {
|
||||
if (isLogin.value) await probeServerConnection()
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
)
|
||||
}
|
||||
|
||||
/** 停止心跳和等待中的自动重连任务。 */
|
||||
@@ -371,6 +426,7 @@ function clearBackgroundCrossfadeTimer() {
|
||||
function resetBackgroundCrossfade() {
|
||||
clearBackgroundCrossfadeTimer()
|
||||
previousImageIndex.value = null
|
||||
isBackgroundCrossfading.value = false
|
||||
}
|
||||
|
||||
// 切换期保留上一张背景的渲染状态,避免图片合成层重建时露出透明底。
|
||||
@@ -379,9 +435,11 @@ function activateBackgroundImage(nextIndex: number) {
|
||||
|
||||
clearBackgroundCrossfadeTimer()
|
||||
previousImageIndex.value = activeImageIndex.value
|
||||
isBackgroundCrossfading.value = true
|
||||
activeImageIndex.value = nextIndex
|
||||
backgroundCrossfadeTimer = window.setTimeout(() => {
|
||||
previousImageIndex.value = null
|
||||
isBackgroundCrossfading.value = false
|
||||
backgroundCrossfadeTimer = null
|
||||
}, BACKGROUND_CROSSFADE_DURATION_MS)
|
||||
}
|
||||
@@ -633,6 +691,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (glassOpticalLayerDeferTimer !== null) window.clearTimeout(glassOpticalLayerDeferTimer)
|
||||
// 清除背景轮换定时器
|
||||
stopBackgroundLoading()
|
||||
if (authenticatedStateTimer) {
|
||||
@@ -654,7 +713,14 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<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
|
||||
v-if="backgroundImages.length > 0 && (isBackdropTheme || !isLogin)"
|
||||
@@ -676,6 +742,15 @@ onUnmounted(() => {
|
||||
<!-- 全局磨砂层 -->
|
||||
<div v-if="shouldRenderGlobalBlurLayer" class="global-blur-layer"></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 }">
|
||||
<RouterView />
|
||||
@@ -743,19 +818,48 @@ onUnmounted(() => {
|
||||
|
||||
.background-container.is-glass-theme .background-image.active,
|
||||
.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 {
|
||||
opacity: 0.92;
|
||||
opacity: 0.94;
|
||||
}
|
||||
|
||||
// 玻璃主题用冷色暗幕压低壁纸对比度,避免轻阴影表面与背景直接冲突。
|
||||
.background-container.is-glass-theme .background-image.active::after,
|
||||
.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%);
|
||||
radial-gradient(circle at 50% 18%, transparent 24%, rgba(6, 10, 19, 16%) 100%),
|
||||
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,
|
||||
@@ -766,9 +870,7 @@ onUnmounted(() => {
|
||||
|
||||
.background-container.is-transparent-glass-lightweight .background-image.active::after,
|
||||
.background-container.is-transparent-glass-lightweight .background-image.previous::after {
|
||||
background:
|
||||
linear-gradient(rgba(0, 0, 0, 30%) 0%, rgba(0, 0, 0, 60%) 100%),
|
||||
rgba(128, 128, 128, 30%);
|
||||
background: 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 () => {
|
||||
const { container } = await renderCard(createMediaInfo({ tmdb_id: 9601 }), {
|
||||
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>
|
||||
@@ -22,6 +22,8 @@ const customColorInput = ref<HTMLInputElement | null>(null)
|
||||
const {
|
||||
isCustomized,
|
||||
resetSettings,
|
||||
setGlassAppearance,
|
||||
setGlassQuality,
|
||||
setLayout,
|
||||
setPrimaryColor,
|
||||
setRadius,
|
||||
@@ -137,6 +139,8 @@ const showShadowSection = computed(() => globalTheme.name.value !== 'glass')
|
||||
const hasAppModeCustomization = computed(() => {
|
||||
return (
|
||||
settings.value.primaryColor !== defaultPrimaryColor ||
|
||||
settings.value.glassAppearance !== 'clear' ||
|
||||
settings.value.glassQuality !== 'css' ||
|
||||
settings.value.radius !== 'default' ||
|
||||
settings.value.shadow !== '0' ||
|
||||
settings.value.skin !== 'default' ||
|
||||
@@ -197,6 +201,8 @@ async function handleResetSettings() {
|
||||
|
||||
// App 模式共享定制器,但保留桌面导航相关偏好,只重置 App 侧可调整的外观设置。
|
||||
await setPrimaryColor(defaultPrimaryColor)
|
||||
await setGlassAppearance('clear')
|
||||
await setGlassQuality('css')
|
||||
await setRadius('default')
|
||||
await setShadow('0')
|
||||
await setSkin('default')
|
||||
@@ -205,11 +211,7 @@ async function handleResetSettings() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="theme-customizer-panel-host"
|
||||
role="dialog"
|
||||
:aria-label="t('theme.customizer.title')"
|
||||
>
|
||||
<aside 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-header py-5 px-4">
|
||||
<div>
|
||||
@@ -320,10 +322,7 @@ async function handleResetSettings() {
|
||||
:class="{ 'is-active': settings.radius === radius.value }"
|
||||
@click="setRadius(radius.value)"
|
||||
>
|
||||
<span
|
||||
class="theme-customizer-radius-scene"
|
||||
:class="`theme-customizer-radius-scene--${radius.value}`"
|
||||
>
|
||||
<span 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__badge" />
|
||||
<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-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>
|
||||
<VSlider
|
||||
:model-value="shadowSliderValue"
|
||||
@@ -367,10 +368,7 @@ async function handleResetSettings() {
|
||||
@update:model-value="handleShadowSliderChange"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="theme-customizer-shadow-slider__scale"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="theme-customizer-shadow-slider__scale" aria-hidden="true">
|
||||
<span>0</span>
|
||||
<span>24</span>
|
||||
</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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -54,24 +54,39 @@ export const themeCustomizerShadowLevels = [
|
||||
] as const
|
||||
|
||||
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 ThemeCustomizerShadow = (typeof themeCustomizerShadowLevels)[number]
|
||||
export type ThemeCustomizerSkin = 'bordered' | 'default'
|
||||
export type ThemeCustomizerTheme = 'auto' | 'dark' | 'glass' | 'light' | 'purple' | 'transparent'
|
||||
|
||||
export interface ThemeCustomizerSettings {
|
||||
/** 玻璃主题的材质语义,与渲染质量保持独立。 */
|
||||
glassAppearance: ThemeCustomizerGlassAppearance
|
||||
/** 玻璃主题的渲染质量,决定使用标准 CSS 或共享光学渲染器。 */
|
||||
glassQuality: ThemeCustomizerGlassQuality
|
||||
/** 桌面导航布局。 */
|
||||
layout: ThemeCustomizerLayout
|
||||
/** 主题强调色,也是色调玻璃的颜色来源。 */
|
||||
primaryColor: string
|
||||
/** 全局表面圆角档位。 */
|
||||
radius: ThemeCustomizerRadius
|
||||
/** 浅色主题是否使用半深色侧栏。 */
|
||||
semiDarkMenu: boolean
|
||||
/** 非玻璃主题的表面阴影档位。 */
|
||||
shadow: ThemeCustomizerShadow
|
||||
/** 非玻璃主题的边框皮肤。 */
|
||||
skin: ThemeCustomizerSkin
|
||||
/** 用户选择的主题偏好。 */
|
||||
theme: ThemeCustomizerTheme
|
||||
}
|
||||
|
||||
type VuetifyThemeApi = ReturnType<typeof useTheme>
|
||||
|
||||
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
|
||||
const validGlassAppearances: ThemeCustomizerGlassAppearance[] = ['clear', 'tinted', 'frosted']
|
||||
const validGlassQualities: ThemeCustomizerGlassQuality[] = ['css', 'balanced', 'high']
|
||||
const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal']
|
||||
const validRadii: ThemeCustomizerRadius[] = ['none', 'small', 'default', 'large', 'extra']
|
||||
const validShadows: readonly ThemeCustomizerShadow[] = themeCustomizerShadowLevels
|
||||
@@ -108,6 +123,8 @@ function readStoredThemePreference(): ThemeCustomizerTheme {
|
||||
/** 生成与当前主题偏好一致的定制器默认设置。 */
|
||||
function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
|
||||
return {
|
||||
glassAppearance: 'clear',
|
||||
glassQuality: 'css',
|
||||
layout: 'vertical',
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
@@ -134,13 +151,17 @@ function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSetti
|
||||
const primaryColor = isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor
|
||||
|
||||
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)
|
||||
? (settings.layout as ThemeCustomizerLayout)
|
||||
: fallback.layout,
|
||||
primaryColor,
|
||||
radius: validRadii.includes(radius as ThemeCustomizerRadius)
|
||||
? (radius as ThemeCustomizerRadius)
|
||||
: fallback.radius,
|
||||
radius: validRadii.includes(radius as ThemeCustomizerRadius) ? (radius as ThemeCustomizerRadius) : fallback.radius,
|
||||
semiDarkMenu: typeof settings.semiDarkMenu === 'boolean' ? settings.semiDarkMenu : fallback.semiDarkMenu,
|
||||
shadow: normalizeThemeCustomizerShadow(settings.shadow),
|
||||
skin: validSkins.includes(settings.skin as ThemeCustomizerSkin)
|
||||
@@ -175,6 +196,16 @@ export function readThemeCustomizerSettings(): ThemeCustomizerSettings {
|
||||
|
||||
// 生产构建会改写导出函数的声明形式,状态初始化必须放在读取函数定义之后,避免首屏执行时引用未完成赋值的函数。
|
||||
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) {
|
||||
@@ -223,17 +254,24 @@ export function applyPrimaryColorToVuetify(color: string, themeApi: VuetifyTheme
|
||||
syncThemeFavicon(activePrimaryColor)
|
||||
}
|
||||
|
||||
/** 布局、圆角、阴影、皮肤和局部菜单风格只依赖根节点属性,CSS 可以在不刷新页面的情况下即时响应。 */
|
||||
/** 将外观设置同步为根节点属性,使主题 CSS 无需刷新即可响应。 */
|
||||
export function applyThemeCustomizerRootSettings(
|
||||
settings: Pick<ThemeCustomizerSettings, 'layout' | 'radius' | 'semiDarkMenu' | 'shadow' | 'skin'>,
|
||||
settings: Pick<
|
||||
ThemeCustomizerSettings,
|
||||
'glassAppearance' | 'glassQuality' | 'layout' | 'radius' | 'semiDarkMenu' | 'shadow' | 'skin'
|
||||
>,
|
||||
) {
|
||||
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-radius', settings.radius)
|
||||
document.documentElement.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
|
||||
document.documentElement.setAttribute('data-theme-shadow', settings.shadow)
|
||||
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-radius', settings.radius)
|
||||
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) {
|
||||
const settings = readThemeCustomizerSettings()
|
||||
|
||||
glassPreviewState.value = null
|
||||
settingsState.value = settings
|
||||
applyPrimaryColorToVuetify(settings.primaryColor, themeApi)
|
||||
applyThemeCustomizerRootSettings(settings)
|
||||
@@ -290,6 +329,7 @@ export function persistPartialThemeCustomizerSettings(patch: Partial<ThemeCustom
|
||||
...patch,
|
||||
})
|
||||
|
||||
glassPreviewState.value = null
|
||||
settingsState.value = nextSettings
|
||||
persistThemeCustomizerSettings(nextSettings)
|
||||
applyPrimaryColorToVuetify(nextSettings.primaryColor, vuetify.theme)
|
||||
@@ -299,9 +339,54 @@ export function persistPartialThemeCustomizerSettings(patch: Partial<ThemeCustom
|
||||
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) {
|
||||
const defaults = normalizeThemeCustomizerSettings({
|
||||
glassAppearance: 'clear',
|
||||
glassQuality: 'css',
|
||||
layout: 'vertical',
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
@@ -312,6 +397,8 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
|
||||
})
|
||||
|
||||
return (
|
||||
settings.glassAppearance === defaults.glassAppearance &&
|
||||
settings.glassQuality === defaults.glassQuality &&
|
||||
settings.layout === defaults.layout &&
|
||||
settings.primaryColor === defaults.primaryColor &&
|
||||
settings.radius === defaults.radius &&
|
||||
@@ -335,6 +422,7 @@ export function useThemeCustomizer() {
|
||||
...patch,
|
||||
})
|
||||
|
||||
glassPreviewState.value = null
|
||||
settings.value = nextSettings
|
||||
persistThemeCustomizerSettings(nextSettings)
|
||||
applyPrimaryColorToVuetify(nextSettings.primaryColor, themeApi)
|
||||
@@ -355,6 +443,16 @@ export function useThemeCustomizer() {
|
||||
return updateSettings({ primaryColor: color })
|
||||
}
|
||||
|
||||
/** 更新玻璃主题材质,不隐式改变渲染质量。 */
|
||||
function setGlassAppearance(glassAppearance: ThemeCustomizerGlassAppearance) {
|
||||
return updateSettings({ glassAppearance })
|
||||
}
|
||||
|
||||
/** 更新玻璃主题渲染质量档位。 */
|
||||
function setGlassQuality(glassQuality: ThemeCustomizerGlassQuality) {
|
||||
return updateSettings({ glassQuality })
|
||||
}
|
||||
|
||||
/** 更新全局圆角档位。 */
|
||||
function setRadius(radius: ThemeCustomizerRadius) {
|
||||
return updateSettings({ radius })
|
||||
@@ -388,6 +486,8 @@ export function useThemeCustomizer() {
|
||||
/** 将主题定制器恢复到默认设置。 */
|
||||
async function resetSettings() {
|
||||
await updateSettings({
|
||||
glassAppearance: 'clear',
|
||||
glassQuality: 'css',
|
||||
layout: 'vertical',
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
@@ -423,6 +523,8 @@ export function useThemeCustomizer() {
|
||||
return {
|
||||
isCustomized: computed(() => !isDefaultThemeCustomizerSettings(settings.value)),
|
||||
resetSettings,
|
||||
setGlassAppearance,
|
||||
setGlassQuality,
|
||||
setLayout,
|
||||
setPrimaryColor,
|
||||
setRadius,
|
||||
|
||||
@@ -27,6 +27,7 @@ import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
|
||||
const AboutDialog = defineAsyncComponent(() => import('@/components/dialog/AboutDialog.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 TransparencySettingsDialog = defineAsyncComponent(
|
||||
() => import('@/components/dialog/TransparencySettingsDialog.vue'),
|
||||
@@ -63,6 +64,7 @@ const showLanguageMenu = ref(false)
|
||||
const customCSS = ref('')
|
||||
|
||||
const isTransparentTheme = computed(() => currentThemeName.value === 'transparent')
|
||||
const isGlassTheme = computed(() => currentThemeName.value === 'glass')
|
||||
|
||||
// 重启轮询控制标识
|
||||
const restartPollingId = ref<number | null>(null)
|
||||
@@ -445,6 +447,11 @@ function showTransparencySettingsDialog() {
|
||||
openSharedDialog(TransparencySettingsDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
||||
}
|
||||
|
||||
/** 打开玻璃主题的外观与质量设置共享弹窗。 */
|
||||
function showGlassSettingsDialog() {
|
||||
openSharedDialog(GlassSettingsDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
||||
}
|
||||
|
||||
/** 从用户菜单打开主题定制器,App 模式会在面板内部隐藏布局设置。 */
|
||||
function showThemeCustomizerDrawer() {
|
||||
showUserMenu.value = false
|
||||
@@ -700,6 +707,16 @@ onUnmounted(() => {
|
||||
</VListItem>
|
||||
</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">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-palette" />
|
||||
|
||||
@@ -147,6 +147,15 @@ export default {
|
||||
autoUI: 'Auto',
|
||||
transparent: 'Transparent',
|
||||
glass: 'Glass',
|
||||
glassSettings: 'Glass Settings',
|
||||
glassAppearance: 'Material',
|
||||
glassAppearanceClear: 'Clear',
|
||||
glassAppearanceTinted: 'Tinted',
|
||||
glassAppearanceFrosted: 'Frosted',
|
||||
glassQuality: 'Quality',
|
||||
glassQualityCss: 'Standard',
|
||||
glassQualityBalanced: 'Balanced',
|
||||
glassQualityHigh: 'High',
|
||||
purple: 'Purple',
|
||||
custom: 'Custom Style',
|
||||
transparency: 'Transparency',
|
||||
|
||||
@@ -145,6 +145,15 @@ export default {
|
||||
autoUI: '自动',
|
||||
transparent: '透明',
|
||||
glass: '玻璃',
|
||||
glassSettings: '玻璃设置',
|
||||
glassAppearance: '材质',
|
||||
glassAppearanceClear: '透明',
|
||||
glassAppearanceTinted: '色调',
|
||||
glassAppearanceFrosted: '磨砂',
|
||||
glassQuality: '质量',
|
||||
glassQualityCss: '标准',
|
||||
glassQualityBalanced: '均衡',
|
||||
glassQualityHigh: '高质量',
|
||||
purple: '幻紫',
|
||||
custom: '附加样式',
|
||||
transparency: '透明度',
|
||||
|
||||
@@ -145,6 +145,15 @@ export default {
|
||||
autoUI: '自動',
|
||||
transparent: '透明',
|
||||
glass: '玻璃',
|
||||
glassSettings: '玻璃設定',
|
||||
glassAppearance: '材質',
|
||||
glassAppearanceClear: '透明',
|
||||
glassAppearanceTinted: '色調',
|
||||
glassAppearanceFrosted: '磨砂',
|
||||
glassQuality: '品質',
|
||||
glassQualityCss: '標準',
|
||||
glassQualityBalanced: '均衡',
|
||||
glassQualityHigh: '高品質',
|
||||
purple: '幻紫',
|
||||
custom: '附加樣式',
|
||||
transparency: '透明度',
|
||||
|
||||
+56
-23
@@ -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-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-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>
|
||||
<!-- 登录页面容器 -->
|
||||
<div
|
||||
ref="loginRootRef"
|
||||
class="login-root"
|
||||
@pointermove="handlePointerLight"
|
||||
@pointerleave="resetPointerLight"
|
||||
>
|
||||
<div ref="loginRootRef" class="login-root" @pointermove="handlePointerLight" @pointerleave="resetPointerLight">
|
||||
<!-- SVG 位移只处理卡片内重复绘制的壁纸,不读取跨域图片像素。 -->
|
||||
<svg class="login-liquid-filter-defs" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<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-orb login-orb--1" />
|
||||
@@ -810,16 +825,13 @@ onUnmounted(() => {
|
||||
flat
|
||||
>
|
||||
<div class="login-card__glass" aria-hidden="true">
|
||||
<span class="login-card__wallpaper-refraction" />
|
||||
<span class="login-card__glass-caustic" />
|
||||
</div>
|
||||
|
||||
<!-- 卡片头部:Logo + 标题 + 欢迎语 -->
|
||||
<div class="login-head">
|
||||
<OpticalLogoLab
|
||||
class="login-logo"
|
||||
:locale="currentLocale"
|
||||
@logo-click="handleLabTap('logo')"
|
||||
>
|
||||
<OpticalLogoLab class="login-logo" :locale="currentLocale" @logo-click="handleLabTap('logo')">
|
||||
<h1 class="login-title" @click="handleLabTap('title')">MoviePilot</h1>
|
||||
<p class="login-subtitle">{{ t('login.welcomeBack') || 'Welcome Back' }}</p>
|
||||
</OpticalLogoLab>
|
||||
@@ -988,6 +1000,8 @@ onUnmounted(() => {
|
||||
--login-card-highlight-alpha: 0.035;
|
||||
--login-card-primary-alpha: 0.018;
|
||||
--login-card-top-prism-alpha: 0.2;
|
||||
--login-refraction-shift-x: 0px;
|
||||
--login-refraction-shift-y: 3px;
|
||||
--optical-glass-x: 50%;
|
||||
--optical-glass-y: 22%;
|
||||
--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);
|
||||
}
|
||||
|
||||
.login-liquid-filter-defs {
|
||||
position: absolute;
|
||||
block-size: 0;
|
||||
inline-size: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===================== 装饰性背景光晕 ===================== */
|
||||
.login-bg-decor {
|
||||
position: absolute;
|
||||
@@ -1142,12 +1163,7 @@ onUnmounted(() => {
|
||||
z-index: 4;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(255, 255, 255, 0.92),
|
||||
rgba(232, 219, 255, 0.48) 28%,
|
||||
transparent 72%
|
||||
),
|
||||
radial-gradient(ellipse at center, rgba(255, 255, 255, 0.92), rgba(232, 219, 255, 0.48) 28%, transparent 72%),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
@@ -1189,8 +1205,7 @@ onUnmounted(() => {
|
||||
rgba(var(--v-theme-primary), var(--login-card-primary-alpha)) 44%,
|
||||
transparent 76%
|
||||
),
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.075), transparent 34%),
|
||||
rgba(var(--v-theme-surface), 0.7);
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.075), transparent 34%), rgba(var(--v-theme-surface), 0.7);
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
transform: translateZ(0);
|
||||
@@ -1198,6 +1213,7 @@ onUnmounted(() => {
|
||||
|
||||
.login-card__glass::before {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
border-radius: 44% 56% 61% 39% / 42% 39% 61% 58%;
|
||||
background:
|
||||
radial-gradient(circle at 36% 32%, rgba(255, 255, 255, 0.2), transparent 24%),
|
||||
@@ -1208,18 +1224,27 @@ onUnmounted(() => {
|
||||
transparent 38%,
|
||||
rgba(255, 255, 255, 0.09),
|
||||
transparent 72%
|
||||
);
|
||||
);
|
||||
content: '';
|
||||
filter: blur(var(--optical-glass-pulse-blur));
|
||||
inset: -28%;
|
||||
opacity: var(--optical-glass-glow-opacity);
|
||||
transform: translate(var(--optical-glass-shift-x), var(--optical-glass-shift-y))
|
||||
rotate(var(--optical-glass-rotation)) scale(var(--optical-glass-scale));
|
||||
transform: translate(var(--optical-glass-shift-x), var(--optical-glass-shift-y)) rotate(var(--optical-glass-rotation))
|
||||
scale(var(--optical-glass-scale));
|
||||
transition: opacity 220ms ease;
|
||||
}
|
||||
|
||||
.login-card__wallpaper-refraction {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
display: none;
|
||||
inset: -2%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-card__glass-caustic {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
@@ -1633,6 +1658,10 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.login-card__wallpaper-refraction {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.login-card__glass,
|
||||
.native-login-field {
|
||||
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,
|
||||
.native-login-field {
|
||||
background: rgba(var(--v-theme-surface), 0.96) !important;
|
||||
|
||||
+522
-164
@@ -1,35 +1,72 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable scss/at-rule-no-unknown */
|
||||
|
||||
// 玻璃主题通过 Vuetify 全局表面类统一塑造材质,业务页面无需逐个接入。
|
||||
// 材质只覆盖统一表面 token;质量档只替换光学层,不改变业务组件契约。
|
||||
html[data-theme='glass'] {
|
||||
--glass-surface: rgba(255, 255, 255, 8%);
|
||||
--glass-surface-soft: rgba(255, 255, 255, 5%);
|
||||
--glass-surface-raised: rgba(255, 255, 255, 12%);
|
||||
--glass-border: rgba(255, 255, 255, 15%);
|
||||
--glass-border-hover: rgba(255, 255, 255, 28%);
|
||||
--glass-highlight: rgba(255, 255, 255, 22%);
|
||||
--glass-surface: rgba(11, 19, 34, 22%);
|
||||
--glass-surface-soft: rgba(11, 19, 34, 28%);
|
||||
--glass-surface-raised: rgba(11, 19, 34, 36%);
|
||||
--glass-control: rgba(11, 19, 34, 52%);
|
||||
--glass-control-prominent: rgba(255, 255, 255, 7%);
|
||||
--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:
|
||||
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:
|
||||
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%),
|
||||
inset 0 -1px 0 rgba(2, 6, 16, 26%);
|
||||
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%);
|
||||
--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%),
|
||||
inset 0 -1px 0 rgba(2, 6, 16, 28%);
|
||||
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%);
|
||||
--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%);
|
||||
--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-saturate: 150%;
|
||||
--glass-motion: 500ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--glass-saturate: 145%;
|
||||
--glass-motion: 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
--app-card-rest-shadow: var(--glass-shadow);
|
||||
--app-card-hover-shadow: var(--glass-shadow-hover);
|
||||
--app-fab-shadow: var(--glass-shadow);
|
||||
--app-fab-shadow-strong: var(--glass-shadow-raised);
|
||||
--app-fab-shadow-hover: var(--glass-shadow-hover);
|
||||
--app-fab-shadow-strong-hover: var(--glass-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: var(--glass-control-shadow);
|
||||
--app-fab-shadow-strong: var(--glass-control-shadow-hover);
|
||||
--app-fab-shadow-hover: var(--glass-control-shadow-hover);
|
||||
--app-fab-shadow-strong-hover: var(--glass-control-shadow-hover);
|
||||
--app-fab-shadow-active: var(--glass-control-shadow-active);
|
||||
--app-overlay-shadow: var(--glass-shadow-raised);
|
||||
--app-surface-shadow: var(--glass-shadow);
|
||||
--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-overlay-border: 1px solid var(--glass-border);
|
||||
--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-separator-color: rgba(255, 255, 255, 10%);
|
||||
--app-grouped-list-header-color: rgba(242, 245, 250, 68%);
|
||||
--app-grouped-list-chevron-color: rgba(242, 245, 250, 56%);
|
||||
--app-grouped-list-hover-background: rgba(255, 255, 255, 8%);
|
||||
--app-grouped-list-active-background: rgba(var(--v-theme-primary), 16%);
|
||||
--mp-motion-duration-page: 500ms;
|
||||
--mp-motion-duration-overlay: 500ms;
|
||||
--mp-motion-ease-standard: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--mp-motion-duration-page: 180ms;
|
||||
--mp-motion-duration-overlay: 180ms;
|
||||
--mp-motion-ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
|
||||
background-color: #0b1322;
|
||||
color-scheme: dark;
|
||||
@@ -59,6 +96,77 @@ html[data-theme='glass'] {
|
||||
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'] {
|
||||
--app-theme-surface-radius: 8px;
|
||||
--app-field-radius: 8px;
|
||||
@@ -110,98 +218,54 @@ html[data-theme='glass'] {
|
||||
.v-layout,
|
||||
.v-main,
|
||||
.layout-wrapper,
|
||||
.layout-page-content,
|
||||
.layout-content-wrapper,
|
||||
.page-content-container {
|
||||
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 {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// 玻璃表面依赖稳定的背景采样层;入场透明度、位移或滤镜会让浏览器在动画结束后才重建材质。
|
||||
:where(
|
||||
.mp-page-route--entering,
|
||||
.mp-page-enter-active,
|
||||
.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;
|
||||
// 页面路由只使用短距离透明度与位移反馈,避免触发整页滤镜重采样。
|
||||
.mp-page-route--entering {
|
||||
animation: glass-page-route-enter var(--mp-motion-duration-page) var(--mp-motion-ease-standard) both;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
:where(
|
||||
.mp-page-enter-from,
|
||||
.mp-page-leave-to,
|
||||
.fade-enter-from,
|
||||
.fade-leave-to,
|
||||
.fade-slide-enter-from,
|
||||
.fade-slide-leave-to,
|
||||
.fade-transition-enter-from,
|
||||
.fade-transition-leave-to,
|
||||
.module-item-enter-from,
|
||||
.module-item-enter-to
|
||||
) {
|
||||
@keyframes glass-page-route-enter {
|
||||
from {
|
||||
opacity: 0.82;
|
||||
transform: translate3d(0, 2px, 0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mp-page-enter-active,
|
||||
.mp-page-leave-active {
|
||||
filter: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
transition:
|
||||
opacity var(--mp-motion-duration-page) var(--mp-motion-ease-standard),
|
||||
transform var(--mp-motion-duration-page) var(--mp-motion-ease-standard);
|
||||
}
|
||||
|
||||
// 异步卡片批次不再通过父层透明度或位移动画入场,避免整组玻璃表面重新合成。
|
||||
.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;
|
||||
.mp-page-enter-from {
|
||||
filter: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
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;
|
||||
opacity: 0.82;
|
||||
transform: translate3d(0, 2px, 0);
|
||||
}
|
||||
|
||||
:where(
|
||||
@@ -213,31 +277,46 @@ html[data-theme='glass'] {
|
||||
.v-table
|
||||
) {
|
||||
border-color: var(--glass-border) !important;
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
|
||||
backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
|
||||
-webkit-backdrop-filter: var(--glass-surface-backdrop-filter);
|
||||
backdrop-filter: var(--glass-surface-backdrop-filter);
|
||||
background-color: var(--glass-surface) !important;
|
||||
background-image: var(--glass-sheen) !important;
|
||||
box-shadow: var(--glass-shadow) !important;
|
||||
}
|
||||
|
||||
:where(.v-list, .v-toolbar, .v-footer) {
|
||||
:where(.v-list, .v-footer) {
|
||||
border-color: var(--glass-border);
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
-webkit-backdrop-filter: var(--glass-surface-backdrop-filter);
|
||||
backdrop-filter: var(--glass-surface-backdrop-filter);
|
||||
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(
|
||||
.layout-vertical-nav,
|
||||
.layout-navbar,
|
||||
.footer-nav-card,
|
||||
.theme-customizer-panel-host,
|
||||
.agent-assistant-panel,
|
||||
.Vue-Toastification__toast
|
||||
) {
|
||||
border-color: var(--glass-border) !important;
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
|
||||
backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
|
||||
border-color: var(--glass-border-raised) !important;
|
||||
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||
backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||
background-color: var(--glass-surface-raised) !important;
|
||||
background-image: var(--glass-sheen) !important;
|
||||
box-shadow: var(--glass-shadow-raised) !important;
|
||||
}
|
||||
|
||||
@@ -247,7 +326,52 @@ html[data-theme='glass'] {
|
||||
}
|
||||
|
||||
.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,
|
||||
@@ -255,7 +379,8 @@ html[data-theme='glass'] {
|
||||
border-color: var(--glass-border) !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;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -269,21 +394,32 @@ html[data-theme='glass'] {
|
||||
}
|
||||
|
||||
:where(.search-desktop-activator, .search-header) .search-input-wrapper {
|
||||
border-color: var(--glass-border) !important;
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
background: rgba(255, 255, 255, 9%) !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 22%),
|
||||
inset 0 -1px 0 rgba(2, 6, 16, 22%);
|
||||
border: 1px solid var(--glass-control-prominent-border) !important;
|
||||
-webkit-backdrop-filter: var(--glass-control-prominent-backdrop-filter);
|
||||
backdrop-filter: var(--glass-control-prominent-backdrop-filter);
|
||||
background-color: var(--glass-control-prominent) !important;
|
||||
background-image: var(--glass-sheen) !important;
|
||||
box-shadow: var(--glass-control-prominent-shadow);
|
||||
}
|
||||
|
||||
:where(.search-desktop-activator, .search-header) .search-input-wrapper:focus-within {
|
||||
border-color: rgba(var(--v-theme-primary), 70%) !important;
|
||||
background: rgba(255, 255, 255, 12%) !important;
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(var(--v-theme-primary), 15%),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 28%);
|
||||
background: var(--glass-control-prominent-focus) !important;
|
||||
box-shadow: var(--glass-control-prominent-focus-shadow);
|
||||
}
|
||||
|
||||
: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,
|
||||
@@ -329,16 +465,17 @@ html[data-theme='glass'] {
|
||||
.compact-fab .v-btn,
|
||||
.global-action-buttons .v-btn {
|
||||
border: 1px solid var(--glass-border) !important;
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||
backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||
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 {
|
||||
-webkit-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 {
|
||||
@@ -349,9 +486,10 @@ html[data-theme='glass'] {
|
||||
border-radius: var(--app-overlay-radius);
|
||||
|
||||
> :where(.v-card, .v-sheet, .v-list) {
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
|
||||
backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
|
||||
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||
backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||
background-color: var(--glass-surface-raised) !important;
|
||||
background-image: var(--glass-sheen) !important;
|
||||
box-shadow: var(--glass-shadow-raised) !important;
|
||||
}
|
||||
}
|
||||
@@ -366,28 +504,27 @@ html[data-theme='glass'] {
|
||||
}
|
||||
|
||||
.v-field {
|
||||
--v-field-border-opacity: 0.18;
|
||||
--v-field-border-opacity: 0.14;
|
||||
--v-field-border-width: 1px;
|
||||
|
||||
border-radius: var(--app-field-radius);
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
background-color: var(--glass-surface-soft) !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 15%),
|
||||
inset 0 -1px 0 rgba(2, 6, 16, 30%);
|
||||
-webkit-backdrop-filter: var(--glass-control-backdrop-filter);
|
||||
backdrop-filter: var(--glass-control-backdrop-filter);
|
||||
background-color: var(--glass-control) !important;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 8%);
|
||||
transition:
|
||||
background-color var(--glass-motion),
|
||||
box-shadow var(--glass-motion);
|
||||
}
|
||||
|
||||
.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:
|
||||
0 0 0 3px rgba(var(--v-theme-primary), 15%),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 25%);
|
||||
0 0 0 3px rgba(var(--v-theme-primary), 14%),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 10%);
|
||||
}
|
||||
|
||||
// Outlined 描边自身会为浮动标签留出缺口,移除贯穿整个顶部的 field 阴影线。
|
||||
@@ -421,19 +558,16 @@ html[data-theme='glass'] {
|
||||
transition:
|
||||
background-color var(--glass-motion),
|
||||
border-color var(--glass-motion),
|
||||
box-shadow var(--glass-motion),
|
||||
transform var(--glass-motion);
|
||||
box-shadow var(--glass-motion);
|
||||
}
|
||||
|
||||
.v-btn:not(.v-btn--variant-text, .v-btn--variant-plain) {
|
||||
border: 1px solid rgba(255, 255, 255, 20%);
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
background-color: rgba(255, 255, 255, 10%) !important;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(3, 7, 18, 24%),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 20%),
|
||||
inset 0 -1px 0 rgba(2, 6, 16, 18%) !important;
|
||||
border: 1px solid var(--glass-border);
|
||||
-webkit-backdrop-filter: var(--glass-control-backdrop-filter);
|
||||
backdrop-filter: var(--glass-control-backdrop-filter);
|
||||
background-color: var(--glass-button-surface) !important;
|
||||
background-image: none !important;
|
||||
box-shadow: var(--glass-control-shadow) !important;
|
||||
}
|
||||
|
||||
.v-btn.bg-primary {
|
||||
@@ -465,7 +599,7 @@ html[data-theme='glass'] {
|
||||
outline: 0;
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(var(--v-theme-primary), 18%),
|
||||
var(--glass-shadow) !important;
|
||||
var(--glass-control-shadow) !important;
|
||||
}
|
||||
|
||||
.v-chip {
|
||||
@@ -495,8 +629,8 @@ html[data-theme='glass'] {
|
||||
--app-card-stripe-opacity: 0.12;
|
||||
--app-card-surface-opacity: 0.08;
|
||||
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
|
||||
backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
// 移动端文件整理和日历使用自定义容器,需要显式接入玻璃表面材质。
|
||||
@@ -504,15 +638,15 @@ html[data-theme='glass'] {
|
||||
--transfer-history-mobile-search-bg: var(--glass-surface-soft);
|
||||
--transfer-history-mobile-muted-bg: var(--glass-surface-soft);
|
||||
--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,
|
||||
.mobile-calendar-filter-card,
|
||||
.mobile-calendar-event-card {
|
||||
border: 1px solid var(--glass-border);
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
background: var(--glass-surface);
|
||||
}
|
||||
|
||||
@@ -528,7 +662,7 @@ html[data-theme='glass'] {
|
||||
}
|
||||
|
||||
.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-border: 1px solid var(--glass-border);
|
||||
--mobile-scheduler-card-shadow: var(--glass-shadow);
|
||||
@@ -537,7 +671,7 @@ html[data-theme='glass'] {
|
||||
.mobile-subscribe-calendar {
|
||||
--mobile-calendar-control-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-active-bg: rgba(var(--v-theme-primary), 16%);
|
||||
--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%);
|
||||
}
|
||||
|
||||
@@ -592,10 +726,8 @@ html[data-theme='glass'] {
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.v-btn:not(.v-btn--variant-text, .v-btn--variant-plain):hover {
|
||||
border-color: var(--glass-border-hover);
|
||||
background-color: rgba(255, 255, 255, 15%) !important;
|
||||
box-shadow:
|
||||
0 5px 16px rgba(3, 7, 18, 28%),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 26%) !important;
|
||||
background-color: var(--glass-button-surface-hover) !important;
|
||||
box-shadow: var(--glass-control-shadow-hover) !important;
|
||||
}
|
||||
|
||||
.v-btn.bg-primary:hover {
|
||||
@@ -629,8 +761,8 @@ html[data-theme='glass'] {
|
||||
}
|
||||
}
|
||||
|
||||
.v-btn:active {
|
||||
transform: scale(0.97);
|
||||
.v-btn:not(.v-btn--variant-text, .v-btn--variant-plain):active {
|
||||
box-shadow: var(--glass-control-shadow-active) !important;
|
||||
}
|
||||
|
||||
@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) {
|
||||
.v-btn,
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user