mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-04 23:18:44 +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;
|
inline-size: $ps-hover-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
// fix bug
|
// 滚动条只反馈实际滚动状态,鼠标经过容器或键盘聚焦不应让轨道常驻。
|
||||||
@media(hover: none) {
|
.ps:hover > .ps__rail-x,
|
||||||
.ps > .ps__rail-x,
|
.ps:hover > .ps__rail-y,
|
||||||
.ps > .ps__rail-y {
|
.ps--focus > .ps__rail-x,
|
||||||
opacity: 0.6;
|
.ps--focus > .ps__rail-y {
|
||||||
}
|
opacity: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ps.ps--scrolling-x > .ps__rail-x,
|
||||||
|
.ps.ps--scrolling-y > .ps__rail-y {
|
||||||
|
opacity: 0.6 !important;
|
||||||
|
}
|
||||||
|
|||||||
+127
-25
@@ -11,7 +11,7 @@ import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
|||||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||||
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
||||||
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
|
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
|
||||||
import { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer'
|
import { applyStoredThemeCustomizerAppearance, useEffectiveGlassSettings } from '@/composables/useThemeCustomizer'
|
||||||
import {
|
import {
|
||||||
applyStoredTransparencySettings,
|
applyStoredTransparencySettings,
|
||||||
TRANSPARENCY_SETTINGS_CHANGED_EVENT,
|
TRANSPARENCY_SETTINGS_CHANGED_EVENT,
|
||||||
@@ -22,15 +22,14 @@ import { completeLaunchLoading } from '@/composables/useLaunchLoading'
|
|||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { themeManager } from '@/utils/themeManager'
|
import { themeManager } from '@/utils/themeManager'
|
||||||
import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette'
|
import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette'
|
||||||
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||||
import {
|
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
|
||||||
useGlobalOfflineStatus,
|
|
||||||
type ConnectionFailureReason,
|
|
||||||
} from '@/composables/useOfflineStatus'
|
|
||||||
|
|
||||||
const LOGIN_WALLPAPER_ROUTE = '/login'
|
const LOGIN_WALLPAPER_ROUTE = '/login'
|
||||||
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
|
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
|
||||||
const WINDOW_BLUR_RENDER_THROTTLE_DELAY_MS = 180_000
|
const WINDOW_BLUR_RENDER_THROTTLE_DELAY_MS = 180_000
|
||||||
|
const MEDIA_DENSE_OPTICAL_DEFER_MS = 1_600
|
||||||
|
|
||||||
// 生效主题
|
// 生效主题
|
||||||
const vuetifyTheme = useTheme()
|
const vuetifyTheme = useTheme()
|
||||||
@@ -59,6 +58,7 @@ setI18nLanguage(localeValue as SupportedLocale)
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const isLogin = computed(() => authStore.token)
|
const isLogin = computed(() => authStore.token)
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const { initializePWA } = usePWA()
|
const { initializePWA } = usePWA()
|
||||||
const offlineStatus = useGlobalOfflineStatus()
|
const offlineStatus = useGlobalOfflineStatus()
|
||||||
|
|
||||||
@@ -72,8 +72,14 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'
|
|||||||
const backgroundImages = ref<string[]>([])
|
const backgroundImages = ref<string[]>([])
|
||||||
const activeImageIndex = ref(0)
|
const activeImageIndex = ref(0)
|
||||||
const previousImageIndex = ref<number | null>(null)
|
const previousImageIndex = ref<number | null>(null)
|
||||||
|
const isBackgroundCrossfading = ref(false)
|
||||||
|
const isRenderThrottled = ref(document.visibilityState === 'hidden')
|
||||||
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
||||||
const isGlassTheme = computed(() => globalTheme.name.value === 'glass')
|
const isGlassTheme = computed(() => globalTheme.name.value === 'glass')
|
||||||
|
const effectiveGlassSettings = useEffectiveGlassSettings()
|
||||||
|
const isInitialRouteReady = ref(false)
|
||||||
|
const isGlassOpticalLayerDeferred = ref(true)
|
||||||
|
let glassOpticalLayerDeferTimer: number | null = null
|
||||||
const isBackdropTheme = computed(() => isTransparentTheme.value || isGlassTheme.value)
|
const isBackdropTheme = computed(() => isTransparentTheme.value || isGlassTheme.value)
|
||||||
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
|
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
|
||||||
const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value)
|
const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value)
|
||||||
@@ -81,6 +87,23 @@ const shouldUseGlassBackgroundTreatment = computed(() => Boolean(isLogin.value)
|
|||||||
const shouldLoadBackgroundImages = computed(
|
const shouldLoadBackgroundImages = computed(
|
||||||
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isBackdropTheme.value),
|
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isBackdropTheme.value),
|
||||||
)
|
)
|
||||||
|
const activeBackgroundImage = computed(() => backgroundImages.value[activeImageIndex.value] ?? '')
|
||||||
|
// 登录页的光学层只绘制程序化焦散,不会读取该跨域壁纸;登录后才通过同源缓存采样纹理。
|
||||||
|
const activeOpticalBackgroundImage = computed(() =>
|
||||||
|
getDisplayImageUrl(activeBackgroundImage.value, Boolean(isLogin.value)),
|
||||||
|
)
|
||||||
|
const appWrapperStyle = computed(() => ({
|
||||||
|
'--login-wallpaper-image': activeBackgroundImage.value ? `url("${activeBackgroundImage.value}")` : 'none',
|
||||||
|
}))
|
||||||
|
const shouldRenderGlassOpticalLayer = computed(
|
||||||
|
() =>
|
||||||
|
isGlassTheme.value &&
|
||||||
|
effectiveGlassSettings.value.glassQuality !== 'css' &&
|
||||||
|
!isGlassOpticalLayerDeferred.value &&
|
||||||
|
!isRenderThrottled.value &&
|
||||||
|
Boolean(activeBackgroundImage.value),
|
||||||
|
)
|
||||||
|
const GlassOpticalLayer = defineAsyncComponent(() => import('@/components/theme/GlassOpticalLayer.vue'))
|
||||||
const transparentBackgroundBlur = ref(16)
|
const transparentBackgroundBlur = ref(16)
|
||||||
const transparencyGlassQuality = ref<TransparencyGlassQuality>(
|
const transparencyGlassQuality = ref<TransparencyGlassQuality>(
|
||||||
localStorage.getItem('transparency-glass-quality') === 'realtime' ? 'realtime' : 'lightweight',
|
localStorage.getItem('transparency-glass-quality') === 'realtime' ? 'realtime' : 'lightweight',
|
||||||
@@ -91,7 +114,6 @@ const shouldRenderGlobalBlurLayer = computed(
|
|||||||
transparentBackgroundBlur.value > 0 &&
|
transparentBackgroundBlur.value > 0 &&
|
||||||
transparencyGlassQuality.value === 'realtime',
|
transparencyGlassQuality.value === 'realtime',
|
||||||
)
|
)
|
||||||
const isRenderThrottled = ref(document.visibilityState === 'hidden')
|
|
||||||
let backgroundRetryTimer: number | null = null
|
let backgroundRetryTimer: number | null = null
|
||||||
let backgroundRequestController: AbortController | null = null
|
let backgroundRequestController: AbortController | null = null
|
||||||
let backgroundCrossfadeTimer: number | null = null
|
let backgroundCrossfadeTimer: number | null = null
|
||||||
@@ -116,6 +138,39 @@ function handleTransparencySettingsChanged(event: Event) {
|
|||||||
|
|
||||||
applyTransparentBackgroundSettings()
|
applyTransparentBackgroundSettings()
|
||||||
|
|
||||||
|
/** 推荐页高质量光学层让首屏海报优先完成解码与合成。 */
|
||||||
|
watch(
|
||||||
|
() => [route.fullPath, effectiveGlassSettings.value.glassQuality, isInitialRouteReady.value] as const,
|
||||||
|
([, quality, routeReady]) => {
|
||||||
|
if (glassOpticalLayerDeferTimer !== null) {
|
||||||
|
window.clearTimeout(glassOpticalLayerDeferTimer)
|
||||||
|
glassOpticalLayerDeferTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首次导航完成前 route 仍可能是重定向来源,禁止 renderer 按错误页面短暂挂载。
|
||||||
|
if (!routeReady) {
|
||||||
|
isGlassOpticalLayerDeferred.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.path !== '/recommend' || quality !== 'high') {
|
||||||
|
isGlassOpticalLayerDeferred.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isGlassOpticalLayerDeferred.value = true
|
||||||
|
glassOpticalLayerDeferTimer = window.setTimeout(() => {
|
||||||
|
isGlassOpticalLayerDeferred.value = false
|
||||||
|
glassOpticalLayerDeferTimer = null
|
||||||
|
}, MEDIA_DENSE_OPTICAL_DEFER_MS)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
void router.isReady().then(() => {
|
||||||
|
isInitialRouteReady.value = true
|
||||||
|
})
|
||||||
|
|
||||||
function clearWindowBlurRenderThrottleTimer() {
|
function clearWindowBlurRenderThrottleTimer() {
|
||||||
if (windowBlurRenderThrottleTimer) {
|
if (windowBlurRenderThrottleTimer) {
|
||||||
window.clearTimeout(windowBlurRenderThrottleTimer)
|
window.clearTimeout(windowBlurRenderThrottleTimer)
|
||||||
@@ -212,13 +267,10 @@ async function probeServerConnection(showChecking = false): Promise<boolean> {
|
|||||||
const successSequenceAtProbeStart = offlineStatus.serverSuccessSequence.value
|
const successSequenceAtProbeStart = offlineStatus.serverSuccessSequence.value
|
||||||
const probePromise = (async () => {
|
const probePromise = (async () => {
|
||||||
try {
|
try {
|
||||||
await api.get(
|
await api.get('system/ping', {
|
||||||
'system/ping',
|
skipConnectionTracking: true,
|
||||||
{
|
timeout: SERVER_PROBE_TIMEOUT_MS,
|
||||||
skipConnectionTracking: true,
|
} as ConnectionAwareRequestConfig)
|
||||||
timeout: SERVER_PROBE_TIMEOUT_MS,
|
|
||||||
} as ConnectionAwareRequestConfig,
|
|
||||||
)
|
|
||||||
connectionProbeFailures = 0
|
connectionProbeFailures = 0
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -261,9 +313,12 @@ function startHeartbeat() {
|
|||||||
|
|
||||||
void probeServerConnection()
|
void probeServerConnection()
|
||||||
|
|
||||||
heartbeatInterval = window.setInterval(async () => {
|
heartbeatInterval = window.setInterval(
|
||||||
if (isLogin.value) await probeServerConnection()
|
async () => {
|
||||||
}, 5 * 60 * 1000)
|
if (isLogin.value) await probeServerConnection()
|
||||||
|
},
|
||||||
|
5 * 60 * 1000,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 停止心跳和等待中的自动重连任务。 */
|
/** 停止心跳和等待中的自动重连任务。 */
|
||||||
@@ -371,6 +426,7 @@ function clearBackgroundCrossfadeTimer() {
|
|||||||
function resetBackgroundCrossfade() {
|
function resetBackgroundCrossfade() {
|
||||||
clearBackgroundCrossfadeTimer()
|
clearBackgroundCrossfadeTimer()
|
||||||
previousImageIndex.value = null
|
previousImageIndex.value = null
|
||||||
|
isBackgroundCrossfading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换期保留上一张背景的渲染状态,避免图片合成层重建时露出透明底。
|
// 切换期保留上一张背景的渲染状态,避免图片合成层重建时露出透明底。
|
||||||
@@ -379,9 +435,11 @@ function activateBackgroundImage(nextIndex: number) {
|
|||||||
|
|
||||||
clearBackgroundCrossfadeTimer()
|
clearBackgroundCrossfadeTimer()
|
||||||
previousImageIndex.value = activeImageIndex.value
|
previousImageIndex.value = activeImageIndex.value
|
||||||
|
isBackgroundCrossfading.value = true
|
||||||
activeImageIndex.value = nextIndex
|
activeImageIndex.value = nextIndex
|
||||||
backgroundCrossfadeTimer = window.setTimeout(() => {
|
backgroundCrossfadeTimer = window.setTimeout(() => {
|
||||||
previousImageIndex.value = null
|
previousImageIndex.value = null
|
||||||
|
isBackgroundCrossfading.value = false
|
||||||
backgroundCrossfadeTimer = null
|
backgroundCrossfadeTimer = null
|
||||||
}, BACKGROUND_CROSSFADE_DURATION_MS)
|
}, BACKGROUND_CROSSFADE_DURATION_MS)
|
||||||
}
|
}
|
||||||
@@ -633,6 +691,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
if (glassOpticalLayerDeferTimer !== null) window.clearTimeout(glassOpticalLayerDeferTimer)
|
||||||
// 清除背景轮换定时器
|
// 清除背景轮换定时器
|
||||||
stopBackgroundLoading()
|
stopBackgroundLoading()
|
||||||
if (authenticatedStateTimer) {
|
if (authenticatedStateTimer) {
|
||||||
@@ -654,7 +713,14 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="app-wrapper" :class="{ 'app-wrapper--render-throttled': isRenderThrottled }">
|
<div
|
||||||
|
class="app-wrapper"
|
||||||
|
:class="{
|
||||||
|
'app-wrapper--background-transition': isBackgroundCrossfading,
|
||||||
|
'app-wrapper--render-throttled': isRenderThrottled,
|
||||||
|
}"
|
||||||
|
:style="appWrapperStyle"
|
||||||
|
>
|
||||||
<!-- 登录页、透明主题和玻璃主题共用动态壁纸场景。 -->
|
<!-- 登录页、透明主题和玻璃主题共用动态壁纸场景。 -->
|
||||||
<div
|
<div
|
||||||
v-if="backgroundImages.length > 0 && (isBackdropTheme || !isLogin)"
|
v-if="backgroundImages.length > 0 && (isBackdropTheme || !isLogin)"
|
||||||
@@ -676,6 +742,15 @@ onUnmounted(() => {
|
|||||||
<!-- 全局磨砂层 -->
|
<!-- 全局磨砂层 -->
|
||||||
<div v-if="shouldRenderGlobalBlurLayer" class="global-blur-layer"></div>
|
<div v-if="shouldRenderGlobalBlurLayer" class="global-blur-layer"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<GlassOpticalLayer
|
||||||
|
v-if="shouldRenderGlassOpticalLayer"
|
||||||
|
:appearance="effectiveGlassSettings.glassAppearance"
|
||||||
|
:class="{ 'glass-optical-layer--background-transition': isBackgroundCrossfading }"
|
||||||
|
:quality="effectiveGlassSettings.glassQuality === 'high' ? 'high' : 'balanced'"
|
||||||
|
:route-key="route.fullPath"
|
||||||
|
:tint-color="globalTheme.current.value.colors.primary"
|
||||||
|
:wallpaper-url="activeOpticalBackgroundImage"
|
||||||
|
/>
|
||||||
<!-- 页面内容 -->
|
<!-- 页面内容 -->
|
||||||
<VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
|
<VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
@@ -743,19 +818,48 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.background-container.is-glass-theme .background-image.active,
|
.background-container.is-glass-theme .background-image.active,
|
||||||
.background-container.is-glass-theme .background-image.previous {
|
.background-container.is-glass-theme .background-image.previous {
|
||||||
filter: brightness(0.82) saturate(0.9);
|
filter: brightness(0.86) saturate(0.95) contrast(1.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-container.is-glass-theme .background-image.active {
|
.background-container.is-glass-theme .background-image.active {
|
||||||
opacity: 0.92;
|
opacity: 0.94;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 玻璃主题用冷色暗幕压低壁纸对比度,避免轻阴影表面与背景直接冲突。
|
|
||||||
.background-container.is-glass-theme .background-image.active::after,
|
.background-container.is-glass-theme .background-image.active::after,
|
||||||
.background-container.is-glass-theme .background-image.previous::after {
|
.background-container.is-glass-theme .background-image.previous::after {
|
||||||
background:
|
background:
|
||||||
linear-gradient(rgba(6, 10, 19, 30%) 0%, rgba(6, 10, 19, 58%) 100%),
|
radial-gradient(circle at 50% 18%, transparent 24%, rgba(6, 10, 19, 16%) 100%),
|
||||||
rgba(11, 19, 34, 10%);
|
linear-gradient(rgba(6, 10, 19, 14%) 0%, rgba(6, 10, 19, 40%) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.active,
|
||||||
|
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.previous {
|
||||||
|
filter: brightness(0.85) saturate(0.97) contrast(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.active {
|
||||||
|
opacity: 0.93;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.active::after,
|
||||||
|
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.previous::after {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 18%, transparent 22%, rgba(6, 10, 19, 18%) 100%),
|
||||||
|
linear-gradient(rgba(6, 10, 19, 14%) 0%, rgba(6, 10, 19, 42%) 100%), rgba(var(--v-theme-primary), 3%);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.active,
|
||||||
|
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.previous {
|
||||||
|
filter: brightness(0.82) saturate(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.active {
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.active::after,
|
||||||
|
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.previous::after {
|
||||||
|
background: linear-gradient(rgba(6, 10, 19, 30%) 0%, rgba(6, 10, 19, 58%) 100%), rgba(11, 19, 34, 10%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-container.is-transparent-glass-lightweight .background-image.active,
|
.background-container.is-transparent-glass-lightweight .background-image.active,
|
||||||
@@ -766,9 +870,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.background-container.is-transparent-glass-lightweight .background-image.active::after,
|
.background-container.is-transparent-glass-lightweight .background-image.active::after,
|
||||||
.background-container.is-transparent-glass-lightweight .background-image.previous::after {
|
.background-container.is-transparent-glass-lightweight .background-image.previous::after {
|
||||||
background:
|
background: linear-gradient(rgba(0, 0, 0, 30%) 0%, rgba(0, 0, 0, 60%) 100%), rgba(128, 128, 128, 30%);
|
||||||
linear-gradient(rgba(0, 0, 0, 30%) 0%, rgba(0, 0, 0, 60%) 100%),
|
|
||||||
rgba(128, 128, 128, 30%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 全局磨砂层 */
|
/* 全局磨砂层 */
|
||||||
|
|||||||
@@ -548,6 +548,34 @@ describe('MediaCard', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders the AniList source badge after the poster loads', async () => {
|
||||||
|
const media = createMediaInfo({
|
||||||
|
anilist_id: 154588,
|
||||||
|
poster_path: '/original/anilist.jpg',
|
||||||
|
source: 'anilist',
|
||||||
|
tmdb_id: undefined,
|
||||||
|
type: '电视剧',
|
||||||
|
})
|
||||||
|
const VImgStub = defineComponent({
|
||||||
|
name: 'VImg',
|
||||||
|
emits: ['load'],
|
||||||
|
props: { src: String },
|
||||||
|
setup(_props, { emit, slots }) {
|
||||||
|
return () =>
|
||||||
|
h('div', [h('button', { 'aria-label': '图片加载成功', onClick: () => emit('load') }), slots.default?.()])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const { container } = await renderWithProviders(MediaCard, {
|
||||||
|
props: { media, width: '9rem' },
|
||||||
|
initialState: { user: { superUser: true } },
|
||||||
|
global: { stubs: { VImg: VImgStub } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await fireEvent.click(container.querySelector('[aria-label="图片加载成功"]') as HTMLElement)
|
||||||
|
|
||||||
|
await waitFor(() => expect(container.querySelector('.v-avatar .iconify--mdi')).not.toBeNull())
|
||||||
|
})
|
||||||
|
|
||||||
it('hides search and subscribe actions when the user lacks both permissions', async () => {
|
it('hides search and subscribe actions when the user lacks both permissions', async () => {
|
||||||
const { container } = await renderCard(createMediaInfo({ tmdb_id: 9601 }), {
|
const { container } = await renderCard(createMediaInfo({ tmdb_id: 9601 }), {
|
||||||
permissions: {
|
permissions: {
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
cancelGlassPreview,
|
||||||
|
commitGlassPreview,
|
||||||
|
previewGlassSettings,
|
||||||
|
useThemeCustomizer,
|
||||||
|
type ThemeCustomizerGlassAppearance,
|
||||||
|
type ThemeCustomizerGlassQuality,
|
||||||
|
} from '@/composables/useThemeCustomizer'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'close'): void
|
||||||
|
(event: 'update:modelValue', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const display = useDisplay()
|
||||||
|
const { settings } = useThemeCustomizer()
|
||||||
|
const draftAppearance = ref<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance)
|
||||||
|
const draftQuality = ref<ThemeCustomizerGlassQuality>(settings.value.glassQuality)
|
||||||
|
const isSaving = ref(false)
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: value => {
|
||||||
|
if (!value) cancelGlassPreview()
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
if (!value) emit('close')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 父级控制弹窗生命周期时,同步结束旧预览并重建持久化草稿。 */
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(value, previous) => {
|
||||||
|
if (value) {
|
||||||
|
draftAppearance.value = settings.value.glassAppearance
|
||||||
|
draftQuality.value = settings.value.glassQuality
|
||||||
|
} else if (previous) {
|
||||||
|
cancelGlassPreview()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const appearanceOptions: Array<{
|
||||||
|
label: string
|
||||||
|
value: ThemeCustomizerGlassAppearance
|
||||||
|
}> = [
|
||||||
|
{ label: 'theme.glassAppearanceClear', value: 'clear' },
|
||||||
|
{ label: 'theme.glassAppearanceTinted', value: 'tinted' },
|
||||||
|
{ label: 'theme.glassAppearanceFrosted', value: 'frosted' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const qualityOptions: Array<{
|
||||||
|
label: string
|
||||||
|
value: ThemeCustomizerGlassQuality
|
||||||
|
}> = [
|
||||||
|
{ label: 'theme.glassQualityCss', value: 'css' },
|
||||||
|
{ label: 'theme.glassQualityBalanced', value: 'balanced' },
|
||||||
|
{ label: 'theme.glassQualityHigh', value: 'high' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 仅允许已实现的材质进入待保存设置。 */
|
||||||
|
function updateAppearance(value: unknown) {
|
||||||
|
if (value !== 'clear' && value !== 'tinted' && value !== 'frosted') return
|
||||||
|
|
||||||
|
draftAppearance.value = value
|
||||||
|
previewGlassSettings({ glassAppearance: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅允许面板声明的质量档位进入待保存设置。 */
|
||||||
|
function updateQuality(value: unknown) {
|
||||||
|
const option = qualityOptions.find(item => item.value === value)
|
||||||
|
if (!option) return
|
||||||
|
|
||||||
|
draftQuality.value = option.value
|
||||||
|
previewGlassSettings({ glassQuality: option.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一次提交当前预览,持久化后关闭不会发生视觉回跳。 */
|
||||||
|
async function saveSettings() {
|
||||||
|
if (isSaving.value) return
|
||||||
|
|
||||||
|
isSaving.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
previewGlassSettings({
|
||||||
|
glassAppearance: draftAppearance.value,
|
||||||
|
glassQuality: draftQuality.value,
|
||||||
|
})
|
||||||
|
commitGlassPreview()
|
||||||
|
visible.value = false
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹窗的任意未保存销毁路径都应恢复持久化快照。
|
||||||
|
onScopeDispose(cancelGlassPreview)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VDialog v-if="visible" v-model="visible" max-width="28rem" scrollable :fullscreen="display.xs.value">
|
||||||
|
<VCard class="glass-settings-dialog">
|
||||||
|
<VCardItem class="glass-settings-dialog__header py-3">
|
||||||
|
<template #prepend>
|
||||||
|
<VAvatar color="primary" variant="tonal" rounded size="40" class="me-2">
|
||||||
|
<VIcon icon="mdi-blur-radial" size="22" />
|
||||||
|
</VAvatar>
|
||||||
|
</template>
|
||||||
|
<VCardTitle>
|
||||||
|
{{ t('theme.glassSettings') }}
|
||||||
|
</VCardTitle>
|
||||||
|
<VDialogCloseBtn v-model="visible" />
|
||||||
|
</VCardItem>
|
||||||
|
|
||||||
|
<VCardText class="glass-settings-dialog__body">
|
||||||
|
<section>
|
||||||
|
<h3 class="glass-settings-dialog__label">{{ t('theme.glassAppearance') }}</h3>
|
||||||
|
<VBtnToggle
|
||||||
|
:model-value="draftAppearance"
|
||||||
|
mandatory
|
||||||
|
color="primary"
|
||||||
|
variant="text"
|
||||||
|
class="glass-settings-dialog__appearance"
|
||||||
|
@update:model-value="updateAppearance"
|
||||||
|
>
|
||||||
|
<VBtn
|
||||||
|
v-for="option in appearanceOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
class="glass-settings-dialog__appearance-option"
|
||||||
|
>
|
||||||
|
{{ t(option.label) }}
|
||||||
|
</VBtn>
|
||||||
|
</VBtnToggle>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 class="glass-settings-dialog__label">{{ t('theme.glassQuality') }}</h3>
|
||||||
|
<VBtnToggle
|
||||||
|
:model-value="draftQuality"
|
||||||
|
mandatory
|
||||||
|
color="primary"
|
||||||
|
variant="text"
|
||||||
|
class="glass-settings-dialog__quality"
|
||||||
|
@update:model-value="updateQuality"
|
||||||
|
>
|
||||||
|
<VBtn
|
||||||
|
v-for="option in qualityOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
class="glass-settings-dialog__quality-option"
|
||||||
|
>
|
||||||
|
{{ t(option.label) }}
|
||||||
|
</VBtn>
|
||||||
|
</VBtnToggle>
|
||||||
|
</section>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VCardActions class="app-dialog-actions glass-settings-dialog__actions">
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
prepend-icon="mdi-content-save"
|
||||||
|
class="px-5"
|
||||||
|
:loading="isSaving"
|
||||||
|
@click="saveSettings"
|
||||||
|
>
|
||||||
|
{{ t('common.save') }}
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.glass-settings-dialog {
|
||||||
|
overflow: hidden;
|
||||||
|
max-block-size: calc(100dvh - 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-settings-dialog__header {
|
||||||
|
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-settings-dialog__body {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 24px;
|
||||||
|
grid-auto-rows: max-content;
|
||||||
|
padding: 20px 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-settings-dialog__label {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-settings-dialog__appearance,
|
||||||
|
.glass-settings-dialog__quality {
|
||||||
|
display: grid;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.035);
|
||||||
|
gap: 4px;
|
||||||
|
inline-size: 100%;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-settings-dialog__appearance {
|
||||||
|
block-size: 42px !important;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-settings-dialog__quality {
|
||||||
|
block-size: 42px !important;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-settings-dialog__appearance-option {
|
||||||
|
block-size: 32px !important;
|
||||||
|
inline-size: 100%;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-settings-dialog__appearance-option,
|
||||||
|
.glass-settings-dialog__quality-option {
|
||||||
|
border: 0 !important;
|
||||||
|
border-radius: 7px !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
min-inline-size: 0;
|
||||||
|
inline-size: 100%;
|
||||||
|
letter-spacing: 0;
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-settings-dialog__quality-option {
|
||||||
|
block-size: 32px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-settings-dialog__appearance-option:deep(.v-btn--active),
|
||||||
|
.glass-settings-dialog__quality-option:deep(.v-btn--active) {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.14) !important;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(var(--v-theme-primary), 0.38) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-settings-dialog__actions {
|
||||||
|
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
padding-block: 0.875rem;
|
||||||
|
padding-inline: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 600px) {
|
||||||
|
.glass-settings-dialog {
|
||||||
|
block-size: 100dvh;
|
||||||
|
max-block-size: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-settings-dialog__body {
|
||||||
|
padding-inline: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-settings-dialog__actions {
|
||||||
|
padding-block-end: max(0.875rem, calc(env(safe-area-inset-bottom) + 0.75rem));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { shallowMount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import GlassSettingsDialog from '@/components/dialog/GlassSettingsDialog.vue'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
cancelGlassPreview: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useThemeCustomizer', () => ({
|
||||||
|
cancelGlassPreview: mocks.cancelGlassPreview,
|
||||||
|
commitGlassPreview: vi.fn(),
|
||||||
|
previewGlassSettings: vi.fn(),
|
||||||
|
useThemeCustomizer: () => ({
|
||||||
|
settings: {
|
||||||
|
value: {
|
||||||
|
glassAppearance: 'clear',
|
||||||
|
glassQuality: 'css',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({ t: (key: string) => key }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vuetify', () => ({
|
||||||
|
useDisplay: () => ({ xs: { value: false } }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('GlassSettingsDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.cancelGlassPreview.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels an active preview when the parent closes the dialog', async () => {
|
||||||
|
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||||
|
global: {
|
||||||
|
stubs: { VDialogCloseBtn: true },
|
||||||
|
},
|
||||||
|
props: { modelValue: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.setProps({ modelValue: false })
|
||||||
|
|
||||||
|
expect(mocks.cancelGlassPreview).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
|
||||||
|
import { useGlassOpticalRenderer } from '@/composables/useGlassOpticalRenderer'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 当前玻璃材质,用于选择透明、色调或磨砂的光学参数。 */
|
||||||
|
appearance: ThemeCustomizerGlassAppearance
|
||||||
|
/** 当前光学质量;标准档不会挂载该组件。 */
|
||||||
|
quality: Exclude<ThemeCustomizerGlassQuality, 'css'>
|
||||||
|
/** 路由变化标识,用于在页面内容稳定后重新发现高价值表面。 */
|
||||||
|
routeKey: string
|
||||||
|
/** 当前主题主色,用于同步色调材质的光学高光。 */
|
||||||
|
tintColor: string
|
||||||
|
/** 与 CSS 背景保持一致的活动壁纸。 */
|
||||||
|
wallpaperUrl: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const { state } = useGlassOpticalRenderer({
|
||||||
|
active: true,
|
||||||
|
appearance: () => props.appearance,
|
||||||
|
canvas,
|
||||||
|
quality: () => props.quality,
|
||||||
|
routeKey: () => props.routeKey,
|
||||||
|
tintColor: () => props.tintColor,
|
||||||
|
wallpaperUrl: () => props.wallpaperUrl,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<canvas ref="canvas" class="glass-optical-layer" aria-hidden="true" :data-state="state" />
|
||||||
|
</template>
|
||||||
@@ -22,6 +22,8 @@ const customColorInput = ref<HTMLInputElement | null>(null)
|
|||||||
const {
|
const {
|
||||||
isCustomized,
|
isCustomized,
|
||||||
resetSettings,
|
resetSettings,
|
||||||
|
setGlassAppearance,
|
||||||
|
setGlassQuality,
|
||||||
setLayout,
|
setLayout,
|
||||||
setPrimaryColor,
|
setPrimaryColor,
|
||||||
setRadius,
|
setRadius,
|
||||||
@@ -137,6 +139,8 @@ const showShadowSection = computed(() => globalTheme.name.value !== 'glass')
|
|||||||
const hasAppModeCustomization = computed(() => {
|
const hasAppModeCustomization = computed(() => {
|
||||||
return (
|
return (
|
||||||
settings.value.primaryColor !== defaultPrimaryColor ||
|
settings.value.primaryColor !== defaultPrimaryColor ||
|
||||||
|
settings.value.glassAppearance !== 'clear' ||
|
||||||
|
settings.value.glassQuality !== 'css' ||
|
||||||
settings.value.radius !== 'default' ||
|
settings.value.radius !== 'default' ||
|
||||||
settings.value.shadow !== '0' ||
|
settings.value.shadow !== '0' ||
|
||||||
settings.value.skin !== 'default' ||
|
settings.value.skin !== 'default' ||
|
||||||
@@ -197,6 +201,8 @@ async function handleResetSettings() {
|
|||||||
|
|
||||||
// App 模式共享定制器,但保留桌面导航相关偏好,只重置 App 侧可调整的外观设置。
|
// App 模式共享定制器,但保留桌面导航相关偏好,只重置 App 侧可调整的外观设置。
|
||||||
await setPrimaryColor(defaultPrimaryColor)
|
await setPrimaryColor(defaultPrimaryColor)
|
||||||
|
await setGlassAppearance('clear')
|
||||||
|
await setGlassQuality('css')
|
||||||
await setRadius('default')
|
await setRadius('default')
|
||||||
await setShadow('0')
|
await setShadow('0')
|
||||||
await setSkin('default')
|
await setSkin('default')
|
||||||
@@ -205,11 +211,7 @@ async function handleResetSettings() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside
|
<aside class="theme-customizer-panel-host" role="dialog" :aria-label="t('theme.customizer.title')">
|
||||||
class="theme-customizer-panel-host"
|
|
||||||
role="dialog"
|
|
||||||
:aria-label="t('theme.customizer.title')"
|
|
||||||
>
|
|
||||||
<div class="theme-customizer-panel" :class="{ 'theme-customizer-panel--dialog': appMode, 'app-surface': appMode }">
|
<div class="theme-customizer-panel" :class="{ 'theme-customizer-panel--dialog': appMode, 'app-surface': appMode }">
|
||||||
<div class="theme-customizer-header py-5 px-4">
|
<div class="theme-customizer-header py-5 px-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -320,10 +322,7 @@ async function handleResetSettings() {
|
|||||||
:class="{ 'is-active': settings.radius === radius.value }"
|
:class="{ 'is-active': settings.radius === radius.value }"
|
||||||
@click="setRadius(radius.value)"
|
@click="setRadius(radius.value)"
|
||||||
>
|
>
|
||||||
<span
|
<span class="theme-customizer-radius-scene" :class="`theme-customizer-radius-scene--${radius.value}`">
|
||||||
class="theme-customizer-radius-scene"
|
|
||||||
:class="`theme-customizer-radius-scene--${radius.value}`"
|
|
||||||
>
|
|
||||||
<span class="theme-customizer-radius-scene__card">
|
<span class="theme-customizer-radius-scene__card">
|
||||||
<span class="theme-customizer-radius-scene__badge" />
|
<span class="theme-customizer-radius-scene__badge" />
|
||||||
<span class="theme-customizer-radius-scene__line" />
|
<span class="theme-customizer-radius-scene__line" />
|
||||||
@@ -350,7 +349,9 @@ async function handleResetSettings() {
|
|||||||
>
|
>
|
||||||
<span class="theme-customizer-shadow-slider__sample-accent" />
|
<span class="theme-customizer-shadow-slider__sample-accent" />
|
||||||
<span class="theme-customizer-shadow-slider__sample-line" />
|
<span class="theme-customizer-shadow-slider__sample-line" />
|
||||||
<span class="theme-customizer-shadow-slider__sample-line theme-customizer-shadow-slider__sample-line--short" />
|
<span
|
||||||
|
class="theme-customizer-shadow-slider__sample-line theme-customizer-shadow-slider__sample-line--short"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<VSlider
|
<VSlider
|
||||||
:model-value="shadowSliderValue"
|
:model-value="shadowSliderValue"
|
||||||
@@ -367,10 +368,7 @@ async function handleResetSettings() {
|
|||||||
@update:model-value="handleShadowSliderChange"
|
@update:model-value="handleShadowSliderChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="theme-customizer-shadow-slider__scale" aria-hidden="true">
|
||||||
class="theme-customizer-shadow-slider__scale"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<span>0</span>
|
<span>0</span>
|
||||||
<span>24</span>
|
<span>24</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
containsGlassOpticalSurface,
|
||||||
|
setGlassRendererState,
|
||||||
|
useGlassOpticalRenderer,
|
||||||
|
type GlassRendererState,
|
||||||
|
} from '@/composables/useGlassOpticalRenderer'
|
||||||
|
import { effectScope, nextTick, ref } from 'vue'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('three', async importOriginal => {
|
||||||
|
const actual = await importOriginal<typeof import('three')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
TextureLoader: class {
|
||||||
|
loadAsync() {
|
||||||
|
const texture = new actual.Texture()
|
||||||
|
texture.image = { height: 1, naturalHeight: 1, naturalWidth: 1, width: 1 }
|
||||||
|
return Promise.resolve(texture)
|
||||||
|
}
|
||||||
|
|
||||||
|
setCrossOrigin() {}
|
||||||
|
},
|
||||||
|
WebGLRenderer: class {
|
||||||
|
compileAsync() {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {}
|
||||||
|
forceContextLoss() {}
|
||||||
|
render() {}
|
||||||
|
setClearColor() {}
|
||||||
|
setPixelRatio() {}
|
||||||
|
setSize() {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
class ResizeObserverMock {
|
||||||
|
disconnect() {}
|
||||||
|
observe() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
||||||
|
vi.stubGlobal('WebGLRenderingContext', class {})
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
delete document.documentElement.dataset.glassRendererState
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('glass optical surface discovery', () => {
|
||||||
|
it('detects a target surface added directly', () => {
|
||||||
|
const surface = document.createElement('section')
|
||||||
|
surface.className = 'dashboard-grid-item-content'
|
||||||
|
|
||||||
|
expect(containsGlassOpticalSurface(surface)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects a target surface inside an asynchronously added subtree', () => {
|
||||||
|
const wrapper = document.createElement('div')
|
||||||
|
wrapper.innerHTML = '<div><section class="dashboard-grid-item-content"></section></div>'
|
||||||
|
|
||||||
|
expect(containsGlassOpticalSurface(wrapper)).toBe(true)
|
||||||
|
expect(containsGlassOpticalSurface(document.createElement('div'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects an optical surface when the overlay root is created lazily', () => {
|
||||||
|
const overlayRoot = document.createElement('div')
|
||||||
|
overlayRoot.className = 'v-overlay-container'
|
||||||
|
overlayRoot.innerHTML = `
|
||||||
|
<div class="v-overlay v-overlay--active">
|
||||||
|
<div class="v-overlay__content"><div class="v-card"></div></div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
expect(containsGlassOpticalSurface(overlayRoot)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the renderer state used by components and global CSS in sync', () => {
|
||||||
|
const state = ref<GlassRendererState>('ready')
|
||||||
|
|
||||||
|
setGlassRendererState(state, 'fallback')
|
||||||
|
|
||||||
|
expect(state.value).toBe('fallback')
|
||||||
|
expect(document.documentElement.dataset.glassRendererState).toBe('fallback')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recovers after consecutive WebGL context loss cycles', async () => {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const scope = effectScope()
|
||||||
|
const renderer = scope.run(() =>
|
||||||
|
useGlassOpticalRenderer({
|
||||||
|
active: ref(true),
|
||||||
|
appearance: ref('clear'),
|
||||||
|
canvas: ref(canvas),
|
||||||
|
quality: ref('balanced'),
|
||||||
|
routeKey: ref('/dashboard'),
|
||||||
|
tintColor: ref('#8D51F9'),
|
||||||
|
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||||
|
|
||||||
|
for (let cycle = 0; cycle < 2; cycle += 1) {
|
||||||
|
canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }))
|
||||||
|
expect(renderer?.state.value).toBe('fallback')
|
||||||
|
|
||||||
|
canvas.dispatchEvent(new Event('webglcontextrestored'))
|
||||||
|
await nextTick()
|
||||||
|
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import {
|
||||||
|
applyThemeCustomizerRootSettings,
|
||||||
|
cancelGlassPreview,
|
||||||
|
commitGlassPreview,
|
||||||
|
persistPartialThemeCustomizerSettings,
|
||||||
|
previewGlassSettings,
|
||||||
|
readThemeCustomizerSettings,
|
||||||
|
THEME_CUSTOMIZER_STORAGE_KEY,
|
||||||
|
} from '@/composables/useThemeCustomizer'
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
describe('useThemeCustomizer glass settings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cancelGlassPreview()
|
||||||
|
localStorage.clear()
|
||||||
|
persistPartialThemeCustomizerSettings({ glassAppearance: 'clear', glassQuality: 'css' })
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses clear appearance and CSS quality by default', () => {
|
||||||
|
const settings = readThemeCustomizerSettings()
|
||||||
|
|
||||||
|
expect(settings.glassAppearance).toBe('clear')
|
||||||
|
expect(settings.glassQuality).toBe('css')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['balanced', 'high'] as const)('preserves the %s quality contract', quality => {
|
||||||
|
localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify({ glassQuality: quality }))
|
||||||
|
|
||||||
|
expect(readThemeCustomizerSettings().glassQuality).toBe(quality)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['clear', 'tinted', 'frosted'] as const)('preserves the %s material contract', glassAppearance => {
|
||||||
|
localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify({ glassAppearance }))
|
||||||
|
|
||||||
|
expect(readThemeCustomizerSettings().glassAppearance).toBe(glassAppearance)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back when stored glass settings are invalid', () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
THEME_CUSTOMIZER_STORAGE_KEY,
|
||||||
|
JSON.stringify({ glassAppearance: 'opaque', glassQuality: 'ultra' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const settings = readThemeCustomizerSettings()
|
||||||
|
|
||||||
|
expect(settings.glassAppearance).toBe('clear')
|
||||||
|
expect(settings.glassQuality).toBe('css')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncs glass settings to the document roots', () => {
|
||||||
|
const settings = readThemeCustomizerSettings()
|
||||||
|
|
||||||
|
applyThemeCustomizerRootSettings({
|
||||||
|
...settings,
|
||||||
|
glassAppearance: 'tinted',
|
||||||
|
glassQuality: 'high',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(document.documentElement.dataset.glassAppearance).toBe('tinted')
|
||||||
|
expect(document.documentElement.dataset.glassQuality).toBe('high')
|
||||||
|
expect(document.body.dataset.glassAppearance).toBe('tinted')
|
||||||
|
expect(document.body.dataset.glassQuality).toBe('high')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('previews glass settings without persisting them', () => {
|
||||||
|
const storedBeforePreview = localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)
|
||||||
|
|
||||||
|
previewGlassSettings({ glassAppearance: 'tinted' })
|
||||||
|
|
||||||
|
expect(document.documentElement.dataset.glassAppearance).toBe('tinted')
|
||||||
|
expect(readThemeCustomizerSettings().glassAppearance).toBe('clear')
|
||||||
|
expect(localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)).toBe(storedBeforePreview)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('commits the latest glass preview as one persisted state', () => {
|
||||||
|
previewGlassSettings({ glassAppearance: 'tinted' })
|
||||||
|
previewGlassSettings({ glassAppearance: 'clear' })
|
||||||
|
previewGlassSettings({ glassAppearance: 'tinted', glassQuality: 'css' })
|
||||||
|
|
||||||
|
commitGlassPreview()
|
||||||
|
|
||||||
|
expect(readThemeCustomizerSettings()).toMatchObject({ glassAppearance: 'tinted', glassQuality: 'css' })
|
||||||
|
expect(document.documentElement.dataset.glassAppearance).toBe('tinted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('previews and commits frosted material without changing quality', () => {
|
||||||
|
previewGlassSettings({ glassAppearance: 'frosted' })
|
||||||
|
|
||||||
|
expect(document.documentElement.dataset.glassAppearance).toBe('frosted')
|
||||||
|
expect(readThemeCustomizerSettings()).toMatchObject({ glassAppearance: 'clear', glassQuality: 'css' })
|
||||||
|
|
||||||
|
commitGlassPreview()
|
||||||
|
|
||||||
|
expect(readThemeCustomizerSettings()).toMatchObject({ glassAppearance: 'frosted', glassQuality: 'css' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores persisted glass settings when a preview is cancelled', () => {
|
||||||
|
persistPartialThemeCustomizerSettings({ glassAppearance: 'tinted' })
|
||||||
|
previewGlassSettings({ glassAppearance: 'clear' })
|
||||||
|
|
||||||
|
cancelGlassPreview()
|
||||||
|
|
||||||
|
expect(document.documentElement.dataset.glassAppearance).toBe('tinted')
|
||||||
|
expect(readThemeCustomizerSettings().glassAppearance).toBe('tinted')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
] as const
|
||||||
|
|
||||||
export type ThemeCustomizerLayout = 'collapsed' | 'horizontal' | 'vertical'
|
export type ThemeCustomizerLayout = 'collapsed' | 'horizontal' | 'vertical'
|
||||||
|
export type ThemeCustomizerGlassAppearance = 'clear' | 'frosted' | 'tinted'
|
||||||
|
export type ThemeCustomizerGlassQuality = 'balanced' | 'css' | 'high'
|
||||||
export type ThemeCustomizerRadius = 'default' | 'extra' | 'large' | 'none' | 'small'
|
export type ThemeCustomizerRadius = 'default' | 'extra' | 'large' | 'none' | 'small'
|
||||||
export type ThemeCustomizerShadow = (typeof themeCustomizerShadowLevels)[number]
|
export type ThemeCustomizerShadow = (typeof themeCustomizerShadowLevels)[number]
|
||||||
export type ThemeCustomizerSkin = 'bordered' | 'default'
|
export type ThemeCustomizerSkin = 'bordered' | 'default'
|
||||||
export type ThemeCustomizerTheme = 'auto' | 'dark' | 'glass' | 'light' | 'purple' | 'transparent'
|
export type ThemeCustomizerTheme = 'auto' | 'dark' | 'glass' | 'light' | 'purple' | 'transparent'
|
||||||
|
|
||||||
export interface ThemeCustomizerSettings {
|
export interface ThemeCustomizerSettings {
|
||||||
|
/** 玻璃主题的材质语义,与渲染质量保持独立。 */
|
||||||
|
glassAppearance: ThemeCustomizerGlassAppearance
|
||||||
|
/** 玻璃主题的渲染质量,决定使用标准 CSS 或共享光学渲染器。 */
|
||||||
|
glassQuality: ThemeCustomizerGlassQuality
|
||||||
|
/** 桌面导航布局。 */
|
||||||
layout: ThemeCustomizerLayout
|
layout: ThemeCustomizerLayout
|
||||||
|
/** 主题强调色,也是色调玻璃的颜色来源。 */
|
||||||
primaryColor: string
|
primaryColor: string
|
||||||
|
/** 全局表面圆角档位。 */
|
||||||
radius: ThemeCustomizerRadius
|
radius: ThemeCustomizerRadius
|
||||||
|
/** 浅色主题是否使用半深色侧栏。 */
|
||||||
semiDarkMenu: boolean
|
semiDarkMenu: boolean
|
||||||
|
/** 非玻璃主题的表面阴影档位。 */
|
||||||
shadow: ThemeCustomizerShadow
|
shadow: ThemeCustomizerShadow
|
||||||
|
/** 非玻璃主题的边框皮肤。 */
|
||||||
skin: ThemeCustomizerSkin
|
skin: ThemeCustomizerSkin
|
||||||
|
/** 用户选择的主题偏好。 */
|
||||||
theme: ThemeCustomizerTheme
|
theme: ThemeCustomizerTheme
|
||||||
}
|
}
|
||||||
|
|
||||||
type VuetifyThemeApi = ReturnType<typeof useTheme>
|
type VuetifyThemeApi = ReturnType<typeof useTheme>
|
||||||
|
|
||||||
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
|
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
|
||||||
|
const validGlassAppearances: ThemeCustomizerGlassAppearance[] = ['clear', 'tinted', 'frosted']
|
||||||
|
const validGlassQualities: ThemeCustomizerGlassQuality[] = ['css', 'balanced', 'high']
|
||||||
const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal']
|
const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal']
|
||||||
const validRadii: ThemeCustomizerRadius[] = ['none', 'small', 'default', 'large', 'extra']
|
const validRadii: ThemeCustomizerRadius[] = ['none', 'small', 'default', 'large', 'extra']
|
||||||
const validShadows: readonly ThemeCustomizerShadow[] = themeCustomizerShadowLevels
|
const validShadows: readonly ThemeCustomizerShadow[] = themeCustomizerShadowLevels
|
||||||
@@ -108,6 +123,8 @@ function readStoredThemePreference(): ThemeCustomizerTheme {
|
|||||||
/** 生成与当前主题偏好一致的定制器默认设置。 */
|
/** 生成与当前主题偏好一致的定制器默认设置。 */
|
||||||
function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
|
function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
|
||||||
return {
|
return {
|
||||||
|
glassAppearance: 'clear',
|
||||||
|
glassQuality: 'css',
|
||||||
layout: 'vertical',
|
layout: 'vertical',
|
||||||
primaryColor: defaultPrimaryColor,
|
primaryColor: defaultPrimaryColor,
|
||||||
radius: 'default',
|
radius: 'default',
|
||||||
@@ -134,13 +151,17 @@ function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSetti
|
|||||||
const primaryColor = isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor
|
const primaryColor = isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
glassAppearance: validGlassAppearances.includes(settings.glassAppearance as ThemeCustomizerGlassAppearance)
|
||||||
|
? (settings.glassAppearance as ThemeCustomizerGlassAppearance)
|
||||||
|
: fallback.glassAppearance,
|
||||||
|
glassQuality: validGlassQualities.includes(settings.glassQuality as ThemeCustomizerGlassQuality)
|
||||||
|
? (settings.glassQuality as ThemeCustomizerGlassQuality)
|
||||||
|
: fallback.glassQuality,
|
||||||
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
|
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
|
||||||
? (settings.layout as ThemeCustomizerLayout)
|
? (settings.layout as ThemeCustomizerLayout)
|
||||||
: fallback.layout,
|
: fallback.layout,
|
||||||
primaryColor,
|
primaryColor,
|
||||||
radius: validRadii.includes(radius as ThemeCustomizerRadius)
|
radius: validRadii.includes(radius as ThemeCustomizerRadius) ? (radius as ThemeCustomizerRadius) : fallback.radius,
|
||||||
? (radius as ThemeCustomizerRadius)
|
|
||||||
: fallback.radius,
|
|
||||||
semiDarkMenu: typeof settings.semiDarkMenu === 'boolean' ? settings.semiDarkMenu : fallback.semiDarkMenu,
|
semiDarkMenu: typeof settings.semiDarkMenu === 'boolean' ? settings.semiDarkMenu : fallback.semiDarkMenu,
|
||||||
shadow: normalizeThemeCustomizerShadow(settings.shadow),
|
shadow: normalizeThemeCustomizerShadow(settings.shadow),
|
||||||
skin: validSkins.includes(settings.skin as ThemeCustomizerSkin)
|
skin: validSkins.includes(settings.skin as ThemeCustomizerSkin)
|
||||||
@@ -175,6 +196,16 @@ export function readThemeCustomizerSettings(): ThemeCustomizerSettings {
|
|||||||
|
|
||||||
// 生产构建会改写导出函数的声明形式,状态初始化必须放在读取函数定义之后,避免首屏执行时引用未完成赋值的函数。
|
// 生产构建会改写导出函数的声明形式,状态初始化必须放在读取函数定义之后,避免首屏执行时引用未完成赋值的函数。
|
||||||
const settingsState = ref<ThemeCustomizerSettings>(readThemeCustomizerSettings())
|
const settingsState = ref<ThemeCustomizerSettings>(readThemeCustomizerSettings())
|
||||||
|
const glassPreviewState = ref<Pick<ThemeCustomizerSettings, 'glassAppearance' | 'glassQuality'> | null>(null)
|
||||||
|
const effectiveGlassSettings = computed(() => ({
|
||||||
|
glassAppearance: glassPreviewState.value?.glassAppearance ?? settingsState.value.glassAppearance,
|
||||||
|
glassQuality: glassPreviewState.value?.glassQuality ?? settingsState.value.glassQuality,
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** 提供当前实际生效的玻璃外观;临时预览优先于已持久化设置。 */
|
||||||
|
export function useEffectiveGlassSettings() {
|
||||||
|
return readonly(effectiveGlassSettings)
|
||||||
|
}
|
||||||
|
|
||||||
/** 将完整主题定制设置写入本地存储。 */
|
/** 将完整主题定制设置写入本地存储。 */
|
||||||
function persistThemeCustomizerSettings(settings: ThemeCustomizerSettings) {
|
function persistThemeCustomizerSettings(settings: ThemeCustomizerSettings) {
|
||||||
@@ -223,17 +254,24 @@ export function applyPrimaryColorToVuetify(color: string, themeApi: VuetifyTheme
|
|||||||
syncThemeFavicon(activePrimaryColor)
|
syncThemeFavicon(activePrimaryColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 布局、圆角、阴影、皮肤和局部菜单风格只依赖根节点属性,CSS 可以在不刷新页面的情况下即时响应。 */
|
/** 将外观设置同步为根节点属性,使主题 CSS 无需刷新即可响应。 */
|
||||||
export function applyThemeCustomizerRootSettings(
|
export function applyThemeCustomizerRootSettings(
|
||||||
settings: Pick<ThemeCustomizerSettings, 'layout' | 'radius' | 'semiDarkMenu' | 'shadow' | 'skin'>,
|
settings: Pick<
|
||||||
|
ThemeCustomizerSettings,
|
||||||
|
'glassAppearance' | 'glassQuality' | 'layout' | 'radius' | 'semiDarkMenu' | 'shadow' | 'skin'
|
||||||
|
>,
|
||||||
) {
|
) {
|
||||||
if (!isBrowser()) return
|
if (!isBrowser()) return
|
||||||
|
|
||||||
|
document.documentElement.setAttribute('data-glass-appearance', settings.glassAppearance)
|
||||||
|
document.documentElement.setAttribute('data-glass-quality', settings.glassQuality)
|
||||||
document.documentElement.setAttribute('data-theme-layout', settings.layout)
|
document.documentElement.setAttribute('data-theme-layout', settings.layout)
|
||||||
document.documentElement.setAttribute('data-theme-radius', settings.radius)
|
document.documentElement.setAttribute('data-theme-radius', settings.radius)
|
||||||
document.documentElement.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
|
document.documentElement.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
|
||||||
document.documentElement.setAttribute('data-theme-shadow', settings.shadow)
|
document.documentElement.setAttribute('data-theme-shadow', settings.shadow)
|
||||||
document.documentElement.setAttribute('data-theme-skin', settings.skin)
|
document.documentElement.setAttribute('data-theme-skin', settings.skin)
|
||||||
|
document.body.setAttribute('data-glass-appearance', settings.glassAppearance)
|
||||||
|
document.body.setAttribute('data-glass-quality', settings.glassQuality)
|
||||||
document.body.setAttribute('data-theme-layout', settings.layout)
|
document.body.setAttribute('data-theme-layout', settings.layout)
|
||||||
document.body.setAttribute('data-theme-radius', settings.radius)
|
document.body.setAttribute('data-theme-radius', settings.radius)
|
||||||
document.body.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
|
document.body.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
|
||||||
@@ -276,6 +314,7 @@ async function applyThemePreference(themePreference: ThemeCustomizerTheme, theme
|
|||||||
export function applyStoredThemeCustomizerAppearance(themeApi: VuetifyThemeApi) {
|
export function applyStoredThemeCustomizerAppearance(themeApi: VuetifyThemeApi) {
|
||||||
const settings = readThemeCustomizerSettings()
|
const settings = readThemeCustomizerSettings()
|
||||||
|
|
||||||
|
glassPreviewState.value = null
|
||||||
settingsState.value = settings
|
settingsState.value = settings
|
||||||
applyPrimaryColorToVuetify(settings.primaryColor, themeApi)
|
applyPrimaryColorToVuetify(settings.primaryColor, themeApi)
|
||||||
applyThemeCustomizerRootSettings(settings)
|
applyThemeCustomizerRootSettings(settings)
|
||||||
@@ -290,6 +329,7 @@ export function persistPartialThemeCustomizerSettings(patch: Partial<ThemeCustom
|
|||||||
...patch,
|
...patch,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
glassPreviewState.value = null
|
||||||
settingsState.value = nextSettings
|
settingsState.value = nextSettings
|
||||||
persistThemeCustomizerSettings(nextSettings)
|
persistThemeCustomizerSettings(nextSettings)
|
||||||
applyPrimaryColorToVuetify(nextSettings.primaryColor, vuetify.theme)
|
applyPrimaryColorToVuetify(nextSettings.primaryColor, vuetify.theme)
|
||||||
@@ -299,9 +339,54 @@ export function persistPartialThemeCustomizerSettings(patch: Partial<ThemeCustom
|
|||||||
return nextSettings
|
return nextSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 临时应用玻璃外观,不写入存储或广播持久化变更。 */
|
||||||
|
export function previewGlassSettings(
|
||||||
|
patch: Partial<Pick<ThemeCustomizerSettings, 'glassAppearance' | 'glassQuality'>>,
|
||||||
|
) {
|
||||||
|
const previewSettings = normalizeThemeCustomizerSettings({
|
||||||
|
...settingsState.value,
|
||||||
|
...glassPreviewState.value,
|
||||||
|
...patch,
|
||||||
|
})
|
||||||
|
|
||||||
|
glassPreviewState.value = {
|
||||||
|
glassAppearance: previewSettings.glassAppearance,
|
||||||
|
glassQuality: previewSettings.glassQuality,
|
||||||
|
}
|
||||||
|
applyThemeCustomizerRootSettings({
|
||||||
|
...settingsState.value,
|
||||||
|
...glassPreviewState.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
return glassPreviewState.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将当前玻璃预览作为一个设置事务持久化,避免外观与质量分步提交。 */
|
||||||
|
export function commitGlassPreview() {
|
||||||
|
const previewSettings = glassPreviewState.value
|
||||||
|
|
||||||
|
if (!previewSettings) return settingsState.value
|
||||||
|
|
||||||
|
glassPreviewState.value = null
|
||||||
|
|
||||||
|
return persistPartialThemeCustomizerSettings(previewSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 丢弃临时玻璃预览并恢复已保存设置。 */
|
||||||
|
export function cancelGlassPreview() {
|
||||||
|
if (!glassPreviewState.value) return settingsState.value
|
||||||
|
|
||||||
|
glassPreviewState.value = null
|
||||||
|
applyThemeCustomizerRootSettings(settingsState.value)
|
||||||
|
|
||||||
|
return settingsState.value
|
||||||
|
}
|
||||||
|
|
||||||
/** 判断当前主题定制设置是否仍为默认值。 */
|
/** 判断当前主题定制设置是否仍为默认值。 */
|
||||||
export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettings) {
|
export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettings) {
|
||||||
const defaults = normalizeThemeCustomizerSettings({
|
const defaults = normalizeThemeCustomizerSettings({
|
||||||
|
glassAppearance: 'clear',
|
||||||
|
glassQuality: 'css',
|
||||||
layout: 'vertical',
|
layout: 'vertical',
|
||||||
primaryColor: defaultPrimaryColor,
|
primaryColor: defaultPrimaryColor,
|
||||||
radius: 'default',
|
radius: 'default',
|
||||||
@@ -312,6 +397,8 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
settings.glassAppearance === defaults.glassAppearance &&
|
||||||
|
settings.glassQuality === defaults.glassQuality &&
|
||||||
settings.layout === defaults.layout &&
|
settings.layout === defaults.layout &&
|
||||||
settings.primaryColor === defaults.primaryColor &&
|
settings.primaryColor === defaults.primaryColor &&
|
||||||
settings.radius === defaults.radius &&
|
settings.radius === defaults.radius &&
|
||||||
@@ -335,6 +422,7 @@ export function useThemeCustomizer() {
|
|||||||
...patch,
|
...patch,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
glassPreviewState.value = null
|
||||||
settings.value = nextSettings
|
settings.value = nextSettings
|
||||||
persistThemeCustomizerSettings(nextSettings)
|
persistThemeCustomizerSettings(nextSettings)
|
||||||
applyPrimaryColorToVuetify(nextSettings.primaryColor, themeApi)
|
applyPrimaryColorToVuetify(nextSettings.primaryColor, themeApi)
|
||||||
@@ -355,6 +443,16 @@ export function useThemeCustomizer() {
|
|||||||
return updateSettings({ primaryColor: color })
|
return updateSettings({ primaryColor: color })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 更新玻璃主题材质,不隐式改变渲染质量。 */
|
||||||
|
function setGlassAppearance(glassAppearance: ThemeCustomizerGlassAppearance) {
|
||||||
|
return updateSettings({ glassAppearance })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新玻璃主题渲染质量档位。 */
|
||||||
|
function setGlassQuality(glassQuality: ThemeCustomizerGlassQuality) {
|
||||||
|
return updateSettings({ glassQuality })
|
||||||
|
}
|
||||||
|
|
||||||
/** 更新全局圆角档位。 */
|
/** 更新全局圆角档位。 */
|
||||||
function setRadius(radius: ThemeCustomizerRadius) {
|
function setRadius(radius: ThemeCustomizerRadius) {
|
||||||
return updateSettings({ radius })
|
return updateSettings({ radius })
|
||||||
@@ -388,6 +486,8 @@ export function useThemeCustomizer() {
|
|||||||
/** 将主题定制器恢复到默认设置。 */
|
/** 将主题定制器恢复到默认设置。 */
|
||||||
async function resetSettings() {
|
async function resetSettings() {
|
||||||
await updateSettings({
|
await updateSettings({
|
||||||
|
glassAppearance: 'clear',
|
||||||
|
glassQuality: 'css',
|
||||||
layout: 'vertical',
|
layout: 'vertical',
|
||||||
primaryColor: defaultPrimaryColor,
|
primaryColor: defaultPrimaryColor,
|
||||||
radius: 'default',
|
radius: 'default',
|
||||||
@@ -423,6 +523,8 @@ export function useThemeCustomizer() {
|
|||||||
return {
|
return {
|
||||||
isCustomized: computed(() => !isDefaultThemeCustomizerSettings(settings.value)),
|
isCustomized: computed(() => !isDefaultThemeCustomizerSettings(settings.value)),
|
||||||
resetSettings,
|
resetSettings,
|
||||||
|
setGlassAppearance,
|
||||||
|
setGlassQuality,
|
||||||
setLayout,
|
setLayout,
|
||||||
setPrimaryColor,
|
setPrimaryColor,
|
||||||
setRadius,
|
setRadius,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
|||||||
|
|
||||||
const AboutDialog = defineAsyncComponent(() => import('@/components/dialog/AboutDialog.vue'))
|
const AboutDialog = defineAsyncComponent(() => import('@/components/dialog/AboutDialog.vue'))
|
||||||
const CustomCssDialog = defineAsyncComponent(() => import('@/components/dialog/CustomCssDialog.vue'))
|
const CustomCssDialog = defineAsyncComponent(() => import('@/components/dialog/CustomCssDialog.vue'))
|
||||||
|
const GlassSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/GlassSettingsDialog.vue'))
|
||||||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||||
const TransparencySettingsDialog = defineAsyncComponent(
|
const TransparencySettingsDialog = defineAsyncComponent(
|
||||||
() => import('@/components/dialog/TransparencySettingsDialog.vue'),
|
() => import('@/components/dialog/TransparencySettingsDialog.vue'),
|
||||||
@@ -63,6 +64,7 @@ const showLanguageMenu = ref(false)
|
|||||||
const customCSS = ref('')
|
const customCSS = ref('')
|
||||||
|
|
||||||
const isTransparentTheme = computed(() => currentThemeName.value === 'transparent')
|
const isTransparentTheme = computed(() => currentThemeName.value === 'transparent')
|
||||||
|
const isGlassTheme = computed(() => currentThemeName.value === 'glass')
|
||||||
|
|
||||||
// 重启轮询控制标识
|
// 重启轮询控制标识
|
||||||
const restartPollingId = ref<number | null>(null)
|
const restartPollingId = ref<number | null>(null)
|
||||||
@@ -445,6 +447,11 @@ function showTransparencySettingsDialog() {
|
|||||||
openSharedDialog(TransparencySettingsDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
openSharedDialog(TransparencySettingsDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 打开玻璃主题的外观与质量设置共享弹窗。 */
|
||||||
|
function showGlassSettingsDialog() {
|
||||||
|
openSharedDialog(GlassSettingsDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
||||||
|
}
|
||||||
|
|
||||||
/** 从用户菜单打开主题定制器,App 模式会在面板内部隐藏布局设置。 */
|
/** 从用户菜单打开主题定制器,App 模式会在面板内部隐藏布局设置。 */
|
||||||
function showThemeCustomizerDrawer() {
|
function showThemeCustomizerDrawer() {
|
||||||
showUserMenu.value = false
|
showUserMenu.value = false
|
||||||
@@ -700,6 +707,16 @@ onUnmounted(() => {
|
|||||||
</VListItem>
|
</VListItem>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<VListItem v-else-if="isGlassTheme" @click="showGlassSettingsDialog">
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon icon="mdi-blur-radial" />
|
||||||
|
</template>
|
||||||
|
<VListItemTitle>{{ t('theme.glassSettings') }}</VListItemTitle>
|
||||||
|
<template #append>
|
||||||
|
<VIcon icon="mdi-chevron-right" size="small" />
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
|
||||||
<VListItem v-if="canAdmin" @click="showCustomCssDialog">
|
<VListItem v-if="canAdmin" @click="showCustomCssDialog">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-palette" />
|
<VIcon icon="mdi-palette" />
|
||||||
|
|||||||
@@ -147,6 +147,15 @@ export default {
|
|||||||
autoUI: 'Auto',
|
autoUI: 'Auto',
|
||||||
transparent: 'Transparent',
|
transparent: 'Transparent',
|
||||||
glass: 'Glass',
|
glass: 'Glass',
|
||||||
|
glassSettings: 'Glass Settings',
|
||||||
|
glassAppearance: 'Material',
|
||||||
|
glassAppearanceClear: 'Clear',
|
||||||
|
glassAppearanceTinted: 'Tinted',
|
||||||
|
glassAppearanceFrosted: 'Frosted',
|
||||||
|
glassQuality: 'Quality',
|
||||||
|
glassQualityCss: 'Standard',
|
||||||
|
glassQualityBalanced: 'Balanced',
|
||||||
|
glassQualityHigh: 'High',
|
||||||
purple: 'Purple',
|
purple: 'Purple',
|
||||||
custom: 'Custom Style',
|
custom: 'Custom Style',
|
||||||
transparency: 'Transparency',
|
transparency: 'Transparency',
|
||||||
|
|||||||
@@ -145,6 +145,15 @@ export default {
|
|||||||
autoUI: '自动',
|
autoUI: '自动',
|
||||||
transparent: '透明',
|
transparent: '透明',
|
||||||
glass: '玻璃',
|
glass: '玻璃',
|
||||||
|
glassSettings: '玻璃设置',
|
||||||
|
glassAppearance: '材质',
|
||||||
|
glassAppearanceClear: '透明',
|
||||||
|
glassAppearanceTinted: '色调',
|
||||||
|
glassAppearanceFrosted: '磨砂',
|
||||||
|
glassQuality: '质量',
|
||||||
|
glassQualityCss: '标准',
|
||||||
|
glassQualityBalanced: '均衡',
|
||||||
|
glassQualityHigh: '高质量',
|
||||||
purple: '幻紫',
|
purple: '幻紫',
|
||||||
custom: '附加样式',
|
custom: '附加样式',
|
||||||
transparency: '透明度',
|
transparency: '透明度',
|
||||||
|
|||||||
@@ -145,6 +145,15 @@ export default {
|
|||||||
autoUI: '自動',
|
autoUI: '自動',
|
||||||
transparent: '透明',
|
transparent: '透明',
|
||||||
glass: '玻璃',
|
glass: '玻璃',
|
||||||
|
glassSettings: '玻璃設定',
|
||||||
|
glassAppearance: '材質',
|
||||||
|
glassAppearanceClear: '透明',
|
||||||
|
glassAppearanceTinted: '色調',
|
||||||
|
glassAppearanceFrosted: '磨砂',
|
||||||
|
glassQuality: '品質',
|
||||||
|
glassQualityCss: '標準',
|
||||||
|
glassQualityBalanced: '均衡',
|
||||||
|
glassQualityHigh: '高品質',
|
||||||
purple: '幻紫',
|
purple: '幻紫',
|
||||||
custom: '附加樣式',
|
custom: '附加樣式',
|
||||||
transparency: '透明度',
|
transparency: '透明度',
|
||||||
|
|||||||
+56
-23
@@ -62,6 +62,8 @@ function renderCardLight() {
|
|||||||
root?.style.setProperty('--login-card-highlight-alpha', (0.035 + pendingCardLightEnergy * 0.08).toFixed(3))
|
root?.style.setProperty('--login-card-highlight-alpha', (0.035 + pendingCardLightEnergy * 0.08).toFixed(3))
|
||||||
root?.style.setProperty('--login-card-primary-alpha', (0.018 + pendingCardLightEnergy * 0.04).toFixed(3))
|
root?.style.setProperty('--login-card-primary-alpha', (0.018 + pendingCardLightEnergy * 0.04).toFixed(3))
|
||||||
root?.style.setProperty('--login-card-top-prism-alpha', (0.2 + pendingCardLightEnergy * 0.32).toFixed(3))
|
root?.style.setProperty('--login-card-top-prism-alpha', (0.2 + pendingCardLightEnergy * 0.32).toFixed(3))
|
||||||
|
root?.style.setProperty('--login-refraction-shift-x', `${((0.5 - pendingCardLightX) * 8).toFixed(2)}px`)
|
||||||
|
root?.style.setProperty('--login-refraction-shift-y', `${((0.5 - pendingCardLightY) * 6).toFixed(2)}px`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 根据指针在登录卡片中的位置更新光照目标。 */
|
/** 根据指针在登录卡片中的位置更新光照目标。 */
|
||||||
@@ -762,12 +764,25 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- 登录页面容器 -->
|
<!-- 登录页面容器 -->
|
||||||
<div
|
<div ref="loginRootRef" class="login-root" @pointermove="handlePointerLight" @pointerleave="resetPointerLight">
|
||||||
ref="loginRootRef"
|
<!-- SVG 位移只处理卡片内重复绘制的壁纸,不读取跨域图片像素。 -->
|
||||||
class="login-root"
|
<svg class="login-liquid-filter-defs" aria-hidden="true" focusable="false">
|
||||||
@pointermove="handlePointerLight"
|
<defs>
|
||||||
@pointerleave="resetPointerLight"
|
<filter
|
||||||
>
|
id="login-liquid-refraction-clear"
|
||||||
|
x="-8%"
|
||||||
|
y="-8%"
|
||||||
|
width="116%"
|
||||||
|
height="116%"
|
||||||
|
color-interpolation-filters="sRGB"
|
||||||
|
>
|
||||||
|
<feTurbulence type="fractalNoise" baseFrequency="0.009 0.014" numOctaves="2" seed="7" result="noise" />
|
||||||
|
<feGaussianBlur in="noise" stdDeviation="1.2" result="soft-noise" />
|
||||||
|
<feDisplacementMap in="SourceGraphic" in2="soft-noise" scale="24" xChannelSelector="R" yChannelSelector="G" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
<!-- 装饰性背景光晕 -->
|
<!-- 装饰性背景光晕 -->
|
||||||
<div class="login-bg-decor" aria-hidden="true">
|
<div class="login-bg-decor" aria-hidden="true">
|
||||||
<div class="login-orb login-orb--1" />
|
<div class="login-orb login-orb--1" />
|
||||||
@@ -810,16 +825,13 @@ onUnmounted(() => {
|
|||||||
flat
|
flat
|
||||||
>
|
>
|
||||||
<div class="login-card__glass" aria-hidden="true">
|
<div class="login-card__glass" aria-hidden="true">
|
||||||
|
<span class="login-card__wallpaper-refraction" />
|
||||||
<span class="login-card__glass-caustic" />
|
<span class="login-card__glass-caustic" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 卡片头部:Logo + 标题 + 欢迎语 -->
|
<!-- 卡片头部:Logo + 标题 + 欢迎语 -->
|
||||||
<div class="login-head">
|
<div class="login-head">
|
||||||
<OpticalLogoLab
|
<OpticalLogoLab class="login-logo" :locale="currentLocale" @logo-click="handleLabTap('logo')">
|
||||||
class="login-logo"
|
|
||||||
:locale="currentLocale"
|
|
||||||
@logo-click="handleLabTap('logo')"
|
|
||||||
>
|
|
||||||
<h1 class="login-title" @click="handleLabTap('title')">MoviePilot</h1>
|
<h1 class="login-title" @click="handleLabTap('title')">MoviePilot</h1>
|
||||||
<p class="login-subtitle">{{ t('login.welcomeBack') || 'Welcome Back' }}</p>
|
<p class="login-subtitle">{{ t('login.welcomeBack') || 'Welcome Back' }}</p>
|
||||||
</OpticalLogoLab>
|
</OpticalLogoLab>
|
||||||
@@ -988,6 +1000,8 @@ onUnmounted(() => {
|
|||||||
--login-card-highlight-alpha: 0.035;
|
--login-card-highlight-alpha: 0.035;
|
||||||
--login-card-primary-alpha: 0.018;
|
--login-card-primary-alpha: 0.018;
|
||||||
--login-card-top-prism-alpha: 0.2;
|
--login-card-top-prism-alpha: 0.2;
|
||||||
|
--login-refraction-shift-x: 0px;
|
||||||
|
--login-refraction-shift-y: 3px;
|
||||||
--optical-glass-x: 50%;
|
--optical-glass-x: 50%;
|
||||||
--optical-glass-y: 22%;
|
--optical-glass-y: 22%;
|
||||||
--optical-glass-blur: 24px;
|
--optical-glass-blur: 24px;
|
||||||
@@ -1018,6 +1032,13 @@ onUnmounted(() => {
|
|||||||
padding-block: calc(env(safe-area-inset-top, 0px) + 24px) calc(env(safe-area-inset-bottom, 0px) + 24px);
|
padding-block: calc(env(safe-area-inset-top, 0px) + 24px) calc(env(safe-area-inset-bottom, 0px) + 24px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-liquid-filter-defs {
|
||||||
|
position: absolute;
|
||||||
|
block-size: 0;
|
||||||
|
inline-size: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===================== 装饰性背景光晕 ===================== */
|
/* ===================== 装饰性背景光晕 ===================== */
|
||||||
.login-bg-decor {
|
.login-bg-decor {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1142,12 +1163,7 @@ onUnmounted(() => {
|
|||||||
z-index: 4;
|
z-index: 4;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background:
|
background:
|
||||||
radial-gradient(
|
radial-gradient(ellipse at center, rgba(255, 255, 255, 0.92), rgba(232, 219, 255, 0.48) 28%, transparent 72%),
|
||||||
ellipse at center,
|
|
||||||
rgba(255, 255, 255, 0.92),
|
|
||||||
rgba(232, 219, 255, 0.48) 28%,
|
|
||||||
transparent 72%
|
|
||||||
),
|
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
transparent,
|
transparent,
|
||||||
@@ -1189,8 +1205,7 @@ onUnmounted(() => {
|
|||||||
rgba(var(--v-theme-primary), var(--login-card-primary-alpha)) 44%,
|
rgba(var(--v-theme-primary), var(--login-card-primary-alpha)) 44%,
|
||||||
transparent 76%
|
transparent 76%
|
||||||
),
|
),
|
||||||
linear-gradient(145deg, rgba(255, 255, 255, 0.075), transparent 34%),
|
linear-gradient(145deg, rgba(255, 255, 255, 0.075), transparent 34%), rgba(var(--v-theme-surface), 0.7);
|
||||||
rgba(var(--v-theme-surface), 0.7);
|
|
||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform: translateZ(0);
|
transform: translateZ(0);
|
||||||
@@ -1198,6 +1213,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.login-card__glass::before {
|
.login-card__glass::before {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
border-radius: 44% 56% 61% 39% / 42% 39% 61% 58%;
|
border-radius: 44% 56% 61% 39% / 42% 39% 61% 58%;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 36% 32%, rgba(255, 255, 255, 0.2), transparent 24%),
|
radial-gradient(circle at 36% 32%, rgba(255, 255, 255, 0.2), transparent 24%),
|
||||||
@@ -1208,18 +1224,27 @@ onUnmounted(() => {
|
|||||||
transparent 38%,
|
transparent 38%,
|
||||||
rgba(255, 255, 255, 0.09),
|
rgba(255, 255, 255, 0.09),
|
||||||
transparent 72%
|
transparent 72%
|
||||||
);
|
);
|
||||||
content: '';
|
content: '';
|
||||||
filter: blur(var(--optical-glass-pulse-blur));
|
filter: blur(var(--optical-glass-pulse-blur));
|
||||||
inset: -28%;
|
inset: -28%;
|
||||||
opacity: var(--optical-glass-glow-opacity);
|
opacity: var(--optical-glass-glow-opacity);
|
||||||
transform: translate(var(--optical-glass-shift-x), var(--optical-glass-shift-y))
|
transform: translate(var(--optical-glass-shift-x), var(--optical-glass-shift-y)) rotate(var(--optical-glass-rotation))
|
||||||
rotate(var(--optical-glass-rotation)) scale(var(--optical-glass-scale));
|
scale(var(--optical-glass-scale));
|
||||||
transition: opacity 220ms ease;
|
transition: opacity 220ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-card__wallpaper-refraction {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
display: none;
|
||||||
|
inset: -2%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.login-card__glass-caustic {
|
.login-card__glass-caustic {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: radial-gradient(
|
background: radial-gradient(
|
||||||
@@ -1633,6 +1658,10 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-transparency: reduce) {
|
@media (prefers-reduced-transparency: reduce) {
|
||||||
|
.login-card__wallpaper-refraction {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.login-card__glass,
|
.login-card__glass,
|
||||||
.native-login-field {
|
.native-login-field {
|
||||||
backdrop-filter: none !important;
|
backdrop-filter: none !important;
|
||||||
@@ -1650,7 +1679,11 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@supports not ((backdrop-filter: blur(1px))) {
|
@supports not (backdrop-filter: blur(1px)) {
|
||||||
|
.login-card__wallpaper-refraction {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.login-card__glass,
|
.login-card__glass,
|
||||||
.native-login-field {
|
.native-login-field {
|
||||||
background: rgba(var(--v-theme-surface), 0.96) !important;
|
background: rgba(var(--v-theme-surface), 0.96) !important;
|
||||||
|
|||||||
+522
-164
@@ -1,35 +1,72 @@
|
|||||||
/* stylelint-disable no-descending-specificity */
|
/* stylelint-disable no-descending-specificity */
|
||||||
/* stylelint-disable scss/at-rule-no-unknown */
|
/* stylelint-disable scss/at-rule-no-unknown */
|
||||||
|
|
||||||
// 玻璃主题通过 Vuetify 全局表面类统一塑造材质,业务页面无需逐个接入。
|
// 材质只覆盖统一表面 token;质量档只替换光学层,不改变业务组件契约。
|
||||||
html[data-theme='glass'] {
|
html[data-theme='glass'] {
|
||||||
--glass-surface: rgba(255, 255, 255, 8%);
|
--glass-surface: rgba(11, 19, 34, 22%);
|
||||||
--glass-surface-soft: rgba(255, 255, 255, 5%);
|
--glass-surface-soft: rgba(11, 19, 34, 28%);
|
||||||
--glass-surface-raised: rgba(255, 255, 255, 12%);
|
--glass-surface-raised: rgba(11, 19, 34, 36%);
|
||||||
--glass-border: rgba(255, 255, 255, 15%);
|
--glass-control: rgba(11, 19, 34, 52%);
|
||||||
--glass-border-hover: rgba(255, 255, 255, 28%);
|
--glass-control-prominent: rgba(255, 255, 255, 7%);
|
||||||
--glass-highlight: rgba(255, 255, 255, 22%);
|
--glass-control-prominent-focus: color-mix(in srgb, rgba(255, 255, 255, 10%) 84%, rgba(var(--v-theme-primary), 24%));
|
||||||
|
--glass-border: rgba(255, 255, 255, 10%);
|
||||||
|
--glass-border-raised: rgba(255, 255, 255, 14%);
|
||||||
|
--glass-border-hover: rgba(255, 255, 255, 20%);
|
||||||
|
--glass-control-prominent-border: rgba(255, 255, 255, 14%);
|
||||||
|
--glass-highlight: rgba(255, 255, 255, 14%);
|
||||||
|
--glass-sheen: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(255, 255, 255, 9%),
|
||||||
|
transparent 28%,
|
||||||
|
rgba(255, 255, 255, 3%) 72%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
--glass-shadow:
|
--glass-shadow:
|
||||||
0 6px 18px rgba(3, 7, 18, 28%), inset 0 1px 0 rgba(255, 255, 255, 18%), inset 0 -1px 0 rgba(2, 6, 16, 22%);
|
0 10px 28px rgba(3, 7, 18, 24%), inset 0 1px 0 var(--glass-highlight), inset 0 -1px 0 rgba(2, 6, 16, 14%);
|
||||||
--glass-shadow-raised:
|
--glass-shadow-raised:
|
||||||
0 10px 28px rgba(3, 7, 18, 34%), 0 0 0 1px rgba(255, 255, 255, 4%), inset 0 1px 0 rgba(255, 255, 255, 22%),
|
0 16px 40px rgba(3, 7, 18, 32%), inset 0 1px 0 rgba(255, 255, 255, 24%), inset 0 -1px 0 rgba(2, 6, 16, 18%);
|
||||||
inset 0 -1px 0 rgba(2, 6, 16, 26%);
|
|
||||||
--glass-shadow-hover:
|
--glass-shadow-hover:
|
||||||
0 12px 32px rgba(3, 7, 18, 38%), 0 0 0 1px rgba(255, 255, 255, 6%), inset 0 1px 0 rgba(255, 255, 255, 27%),
|
0 18px 44px rgba(3, 7, 18, 36%), inset 0 1px 0 rgba(255, 255, 255, 28%), inset 0 -1px 0 rgba(2, 6, 16, 18%);
|
||||||
inset 0 -1px 0 rgba(2, 6, 16, 28%);
|
|
||||||
--glass-navbar-shadow:
|
--glass-navbar-shadow:
|
||||||
0 3px 12px rgba(3, 7, 18, 18%), inset 0 1px 0 rgba(255, 255, 255, 10%), inset 0 -1px 0 rgba(2, 6, 16, 12%);
|
0 3px 12px rgba(3, 7, 18, 18%), inset 0 1px 0 rgba(255, 255, 255, 10%), inset 0 -1px 0 rgba(2, 6, 16, 12%);
|
||||||
--glass-blur: 24px;
|
--glass-control-shadow: inset 0 1px 0 rgba(255, 255, 255, 12%), inset 0 -1px 0 rgba(2, 6, 16, 14%);
|
||||||
|
--glass-control-shadow-hover: inset 0 1px 0 rgba(255, 255, 255, 18%), inset 0 -1px 0 rgba(2, 6, 16, 12%);
|
||||||
|
--glass-control-shadow-active: inset 0 1px 0 rgba(255, 255, 255, 7%), inset 0 -1px 0 rgba(2, 6, 16, 20%);
|
||||||
|
--glass-control-prominent-shadow: inset 0 1px 0 rgba(255, 255, 255, 18%), inset 0 -1px 0 rgba(2, 6, 16, 18%);
|
||||||
|
--glass-control-prominent-focus-shadow:
|
||||||
|
0 0 0 2px rgba(var(--v-theme-primary), 16%), inset 0 1px 0 rgba(255, 255, 255, 24%),
|
||||||
|
inset 0 -1px 0 rgba(2, 6, 16, 16%);
|
||||||
|
--glass-surface-backdrop-filter: none;
|
||||||
|
--glass-raised-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
|
||||||
|
--glass-control-backdrop-filter: none;
|
||||||
|
--glass-control-prominent-backdrop-filter: blur(20px) saturate(var(--glass-saturate));
|
||||||
|
--glass-control-icon-color: rgba(242, 245, 250, 70%);
|
||||||
|
--glass-control-placeholder-color: rgba(242, 245, 250, 54%);
|
||||||
|
--glass-control-shortcut-background: rgba(255, 255, 255, 8%);
|
||||||
|
--glass-control-shortcut-border: rgba(255, 255, 255, 12%);
|
||||||
|
--glass-control-shortcut-color: rgba(242, 245, 250, 68%);
|
||||||
|
--glass-button-surface: rgba(255, 255, 255, 8%);
|
||||||
|
--glass-button-surface-hover: rgba(255, 255, 255, 12%);
|
||||||
|
--glass-nav-active-background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(var(--v-theme-primary), 22%),
|
||||||
|
rgba(var(--v-theme-primary), 48%)
|
||||||
|
);
|
||||||
|
--glass-nav-active-border: 1px solid rgba(var(--v-theme-primary), 36%);
|
||||||
|
--glass-nav-active-shadow: inset 0 1px 0 rgba(255, 255, 255, 14%);
|
||||||
|
--glass-dashboard-backdrop-filter: none;
|
||||||
|
--glass-blur-surface: 16px;
|
||||||
|
--glass-blur: 22px;
|
||||||
--glass-blur-raised: 32px;
|
--glass-blur-raised: 32px;
|
||||||
--glass-saturate: 150%;
|
--glass-saturate: 145%;
|
||||||
--glass-motion: 500ms cubic-bezier(0.16, 1, 0.3, 1);
|
--glass-motion: 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
--app-card-rest-shadow: var(--glass-shadow);
|
--app-card-rest-shadow: var(--glass-shadow);
|
||||||
--app-card-hover-shadow: var(--glass-shadow-hover);
|
--app-card-hover-shadow: var(--glass-shadow-hover);
|
||||||
--app-fab-shadow: var(--glass-shadow);
|
--app-fab-shadow: var(--glass-control-shadow);
|
||||||
--app-fab-shadow-strong: var(--glass-shadow-raised);
|
--app-fab-shadow-strong: var(--glass-control-shadow-hover);
|
||||||
--app-fab-shadow-hover: var(--glass-shadow-hover);
|
--app-fab-shadow-hover: var(--glass-control-shadow-hover);
|
||||||
--app-fab-shadow-strong-hover: var(--glass-shadow-hover);
|
--app-fab-shadow-strong-hover: var(--glass-control-shadow-hover);
|
||||||
--app-fab-shadow-active: 0 3px 10px rgba(3, 7, 18, 24%), inset 0 1px 0 rgba(255, 255, 255, 16%);
|
--app-fab-shadow-active: var(--glass-control-shadow-active);
|
||||||
--app-overlay-shadow: var(--glass-shadow-raised);
|
--app-overlay-shadow: var(--glass-shadow-raised);
|
||||||
--app-surface-shadow: var(--glass-shadow);
|
--app-surface-shadow: var(--glass-shadow);
|
||||||
--app-surface-hover-shadow: var(--glass-shadow-hover);
|
--app-surface-hover-shadow: var(--glass-shadow-hover);
|
||||||
@@ -41,16 +78,16 @@ html[data-theme='glass'] {
|
|||||||
--app-card-light-border: 1px solid var(--glass-border);
|
--app-card-light-border: 1px solid var(--glass-border);
|
||||||
--app-overlay-border: 1px solid var(--glass-border);
|
--app-overlay-border: 1px solid var(--glass-border);
|
||||||
--app-grouped-list-background: var(--glass-surface);
|
--app-grouped-list-background: var(--glass-surface);
|
||||||
--app-grouped-list-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
--app-grouped-list-backdrop-filter: none;
|
||||||
--app-grouped-list-border: 1px solid var(--glass-border);
|
--app-grouped-list-border: 1px solid var(--glass-border);
|
||||||
--app-grouped-list-separator-color: rgba(255, 255, 255, 10%);
|
--app-grouped-list-separator-color: rgba(255, 255, 255, 10%);
|
||||||
--app-grouped-list-header-color: rgba(242, 245, 250, 68%);
|
--app-grouped-list-header-color: rgba(242, 245, 250, 68%);
|
||||||
--app-grouped-list-chevron-color: rgba(242, 245, 250, 56%);
|
--app-grouped-list-chevron-color: rgba(242, 245, 250, 56%);
|
||||||
--app-grouped-list-hover-background: rgba(255, 255, 255, 8%);
|
--app-grouped-list-hover-background: rgba(255, 255, 255, 8%);
|
||||||
--app-grouped-list-active-background: rgba(var(--v-theme-primary), 16%);
|
--app-grouped-list-active-background: rgba(var(--v-theme-primary), 16%);
|
||||||
--mp-motion-duration-page: 500ms;
|
--mp-motion-duration-page: 180ms;
|
||||||
--mp-motion-duration-overlay: 500ms;
|
--mp-motion-duration-overlay: 180ms;
|
||||||
--mp-motion-ease-standard: cubic-bezier(0.16, 1, 0.3, 1);
|
--mp-motion-ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
|
||||||
background-color: #0b1322;
|
background-color: #0b1322;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
@@ -59,6 +96,77 @@ html[data-theme='glass'] {
|
|||||||
background-color: #0b1322;
|
background-color: #0b1322;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 色调保持与透明材质接近的透光度,只叠加主色语义。
|
||||||
|
&[data-glass-appearance='tinted'] {
|
||||||
|
--glass-surface: color-mix(in srgb, rgba(11, 19, 34, 24%) 88%, rgba(var(--v-theme-primary), 28%));
|
||||||
|
--glass-surface-soft: color-mix(in srgb, rgba(11, 19, 34, 30%) 89%, rgba(var(--v-theme-primary), 26%));
|
||||||
|
--glass-surface-raised: color-mix(in srgb, rgba(11, 19, 34, 38%) 84%, rgba(var(--v-theme-primary), 32%));
|
||||||
|
--glass-control: color-mix(in srgb, rgba(11, 19, 34, 52%) 88%, rgba(var(--v-theme-primary), 28%));
|
||||||
|
--glass-control-prominent: color-mix(in srgb, rgba(255, 255, 255, 7%) 82%, rgba(var(--v-theme-primary), 24%));
|
||||||
|
--glass-control-prominent-focus: color-mix(
|
||||||
|
in srgb,
|
||||||
|
rgba(255, 255, 255, 10%) 76%,
|
||||||
|
rgba(var(--v-theme-primary), 32%)
|
||||||
|
);
|
||||||
|
--glass-border: color-mix(in srgb, rgba(255, 255, 255, 10%) 82%, rgba(var(--v-theme-primary), 26%));
|
||||||
|
--glass-border-raised: color-mix(in srgb, rgba(255, 255, 255, 14%) 80%, rgba(var(--v-theme-primary), 30%));
|
||||||
|
--glass-border-hover: color-mix(in srgb, rgba(255, 255, 255, 20%) 78%, rgba(var(--v-theme-primary), 36%));
|
||||||
|
--glass-control-prominent-border: color-mix(
|
||||||
|
in srgb,
|
||||||
|
rgba(255, 255, 255, 14%) 78%,
|
||||||
|
rgba(var(--v-theme-primary), 32%)
|
||||||
|
);
|
||||||
|
--glass-highlight: color-mix(in srgb, rgba(255, 255, 255, 14%) 86%, rgba(var(--v-theme-primary), 18%));
|
||||||
|
--glass-sheen: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(255, 255, 255, 9%),
|
||||||
|
transparent 28%,
|
||||||
|
rgba(var(--v-theme-primary), 6%) 72%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 磨砂材质使用更亮的散射表面,但仍只让高价值层执行实时背景采样。
|
||||||
|
&[data-glass-appearance='frosted'] {
|
||||||
|
--glass-surface: rgba(255, 255, 255, 8%);
|
||||||
|
--glass-surface-soft: rgba(255, 255, 255, 7%);
|
||||||
|
--glass-surface-raised: rgba(255, 255, 255, 12%);
|
||||||
|
--glass-control: rgba(255, 255, 255, 9%);
|
||||||
|
--glass-control-prominent: rgba(255, 255, 255, 10%);
|
||||||
|
--glass-control-prominent-focus: rgba(255, 255, 255, 13%);
|
||||||
|
--glass-border: rgba(255, 255, 255, 15%);
|
||||||
|
--glass-border-raised: rgba(255, 255, 255, 18%);
|
||||||
|
--glass-border-hover: rgba(255, 255, 255, 28%);
|
||||||
|
--glass-control-prominent-border: rgba(255, 255, 255, 18%);
|
||||||
|
--glass-highlight: rgba(255, 255, 255, 22%);
|
||||||
|
--glass-sheen: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(255, 255, 255, 7%),
|
||||||
|
transparent 24%,
|
||||||
|
rgba(255, 255, 255, 2%) 72%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
--glass-control-prominent-shadow: inset 0 1px 0 rgba(255, 255, 255, 28%), inset 0 -1px 0 rgba(2, 6, 16, 20%);
|
||||||
|
--glass-control-prominent-focus-shadow:
|
||||||
|
0 0 0 2px rgba(var(--v-theme-primary), 17%), inset 0 1px 0 rgba(255, 255, 255, 34%),
|
||||||
|
inset 0 -1px 0 rgba(2, 6, 16, 18%);
|
||||||
|
--glass-control-prominent-backdrop-filter: blur(24px) saturate(150%);
|
||||||
|
--glass-blur-surface: 28px;
|
||||||
|
--glass-saturate: 150%;
|
||||||
|
--glass-control-icon-color: rgba(242, 245, 250, 78%);
|
||||||
|
--glass-control-placeholder-color: rgba(242, 245, 250, 62%);
|
||||||
|
--glass-control-shortcut-background: rgba(255, 255, 255, 11%);
|
||||||
|
--glass-control-shortcut-border: rgba(255, 255, 255, 16%);
|
||||||
|
--glass-control-shortcut-color: rgba(242, 245, 250, 76%);
|
||||||
|
--glass-button-surface: rgba(255, 255, 255, 10%);
|
||||||
|
--glass-button-surface-hover: rgba(255, 255, 255, 14%);
|
||||||
|
--glass-nav-active-background: linear-gradient(270deg, rgb(var(--v-theme-primary)) 0%, rgb(255, 255, 255) 300%);
|
||||||
|
--glass-nav-active-border: 0 solid transparent;
|
||||||
|
--glass-nav-active-shadow:
|
||||||
|
0 4px 14px -4px rgba(3, 7, 18, 34%), 0 4px 8px -4px rgba(3, 7, 18, 24%), 0 4px 8px -4px rgba(3, 7, 18, 16%);
|
||||||
|
--glass-dashboard-backdrop-filter: blur(var(--glass-blur-surface)) saturate(var(--glass-saturate));
|
||||||
|
}
|
||||||
|
|
||||||
&[data-theme-radius='none'] {
|
&[data-theme-radius='none'] {
|
||||||
--app-theme-surface-radius: 8px;
|
--app-theme-surface-radius: 8px;
|
||||||
--app-field-radius: 8px;
|
--app-field-radius: 8px;
|
||||||
@@ -110,98 +218,54 @@ html[data-theme='glass'] {
|
|||||||
.v-layout,
|
.v-layout,
|
||||||
.v-main,
|
.v-main,
|
||||||
.layout-wrapper,
|
.layout-wrapper,
|
||||||
.layout-page-content,
|
|
||||||
.layout-content-wrapper,
|
.layout-content-wrapper,
|
||||||
.page-content-container {
|
.page-content-container {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layout-page-content {
|
||||||
|
background-color: rgba(255, 255, 255, 2.5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-glass-appearance='frosted'] .layout-page-content {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.v-application {
|
.v-application {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 玻璃表面依赖稳定的背景采样层;入场透明度、位移或滤镜会让浏览器在动画结束后才重建材质。
|
// 页面路由只使用短距离透明度与位移反馈,避免触发整页滤镜重采样。
|
||||||
:where(
|
.mp-page-route--entering {
|
||||||
.mp-page-route--entering,
|
animation: glass-page-route-enter var(--mp-motion-duration-page) var(--mp-motion-ease-standard) both;
|
||||||
.mp-page-enter-active,
|
will-change: opacity, transform;
|
||||||
.mp-page-leave-active,
|
|
||||||
.fade-enter-active,
|
|
||||||
.fade-leave-active,
|
|
||||||
.fade-slide-enter-active,
|
|
||||||
.fade-slide-leave-active,
|
|
||||||
.fade-transition-enter-active,
|
|
||||||
.fade-transition-leave-active,
|
|
||||||
.module-item-enter-active
|
|
||||||
) {
|
|
||||||
animation: none !important;
|
|
||||||
transition: none !important;
|
|
||||||
will-change: auto !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:where(
|
@keyframes glass-page-route-enter {
|
||||||
.mp-page-enter-from,
|
from {
|
||||||
.mp-page-leave-to,
|
opacity: 0.82;
|
||||||
.fade-enter-from,
|
transform: translate3d(0, 2px, 0);
|
||||||
.fade-leave-to,
|
}
|
||||||
.fade-slide-enter-from,
|
|
||||||
.fade-slide-leave-to,
|
to {
|
||||||
.fade-transition-enter-from,
|
opacity: 1;
|
||||||
.fade-transition-leave-to,
|
transform: none;
|
||||||
.module-item-enter-from,
|
}
|
||||||
.module-item-enter-to
|
}
|
||||||
) {
|
|
||||||
|
.mp-page-enter-active,
|
||||||
|
.mp-page-leave-active {
|
||||||
filter: none !important;
|
filter: none !important;
|
||||||
opacity: 1 !important;
|
transition:
|
||||||
transform: none !important;
|
opacity var(--mp-motion-duration-page) var(--mp-motion-ease-standard),
|
||||||
|
transform var(--mp-motion-duration-page) var(--mp-motion-ease-standard);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 异步卡片批次不再通过父层透明度或位移动画入场,避免整组玻璃表面重新合成。
|
.mp-page-enter-from {
|
||||||
.dashboard-grid,
|
|
||||||
.stream-result-item {
|
|
||||||
animation: none !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
transform: none !important;
|
|
||||||
transition: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overlay 的过渡类挂在玻璃表面的父层上,统一保持最终合成状态可覆盖菜单、选择器和弹窗。
|
|
||||||
.v-overlay__content[class*='-transition-enter-'],
|
|
||||||
.v-overlay__content[class*='-transition-leave-'] {
|
|
||||||
animation: none !important;
|
|
||||||
filter: none !important;
|
filter: none !important;
|
||||||
opacity: 1 !important;
|
opacity: 0.82;
|
||||||
transform: none !important;
|
transform: translate3d(0, 2px, 0);
|
||||||
transition: none !important;
|
|
||||||
will-change: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提前为实际使用背景滤镜的表面建立合成提示,减少动态插入后的首帧材质延迟。
|
|
||||||
:where(
|
|
||||||
.app-surface:not(.no-blur),
|
|
||||||
.v-card:not(.no-blur, .bg-primary, .bg-success, .bg-info, .bg-warning, .bg-error),
|
|
||||||
.v-sheet:not(.no-blur, .bg-primary, .bg-success, .bg-info, .bg-warning, .bg-error),
|
|
||||||
.v-list,
|
|
||||||
.v-toolbar,
|
|
||||||
.v-footer,
|
|
||||||
.v-expansion-panel,
|
|
||||||
.v-stepper,
|
|
||||||
.v-table,
|
|
||||||
.layout-vertical-nav,
|
|
||||||
.layout-navbar,
|
|
||||||
.footer-nav-card,
|
|
||||||
.theme-customizer-panel-host,
|
|
||||||
.agent-assistant-panel,
|
|
||||||
.v-tooltip > .v-overlay__content,
|
|
||||||
.v-snackbar__wrapper,
|
|
||||||
.transfer-history-mobile-record,
|
|
||||||
.mobile-calendar-filter-card,
|
|
||||||
.mobile-calendar-event-card,
|
|
||||||
.mobile-scheduler-card,
|
|
||||||
.logging-shell,
|
|
||||||
.ai-agent-settings-card
|
|
||||||
) {
|
|
||||||
will-change: backdrop-filter;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:where(
|
:where(
|
||||||
@@ -213,31 +277,46 @@ html[data-theme='glass'] {
|
|||||||
.v-table
|
.v-table
|
||||||
) {
|
) {
|
||||||
border-color: var(--glass-border) !important;
|
border-color: var(--glass-border) !important;
|
||||||
-webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
|
-webkit-backdrop-filter: var(--glass-surface-backdrop-filter);
|
||||||
backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
|
backdrop-filter: var(--glass-surface-backdrop-filter);
|
||||||
background-color: var(--glass-surface) !important;
|
background-color: var(--glass-surface) !important;
|
||||||
|
background-image: var(--glass-sheen) !important;
|
||||||
box-shadow: var(--glass-shadow) !important;
|
box-shadow: var(--glass-shadow) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:where(.v-list, .v-toolbar, .v-footer) {
|
:where(.v-list, .v-footer) {
|
||||||
border-color: var(--glass-border);
|
border-color: var(--glass-border);
|
||||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
-webkit-backdrop-filter: var(--glass-surface-backdrop-filter);
|
||||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
backdrop-filter: var(--glass-surface-backdrop-filter);
|
||||||
background-color: var(--glass-surface) !important;
|
background-color: var(--glass-surface) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.v-toolbar {
|
||||||
|
border-color: var(--glass-border);
|
||||||
|
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||||
|
backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||||
|
background-color: var(--glass-surface) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard 顶层工作卡片允许一层业务布局容器,内部图表、列表与海报只使用静态光学表面。
|
||||||
|
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card,
|
||||||
|
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > :first-child > .v-card {
|
||||||
|
-webkit-backdrop-filter: var(--glass-dashboard-backdrop-filter);
|
||||||
|
backdrop-filter: var(--glass-dashboard-backdrop-filter);
|
||||||
|
}
|
||||||
|
|
||||||
:where(
|
:where(
|
||||||
.layout-vertical-nav,
|
|
||||||
.layout-navbar,
|
.layout-navbar,
|
||||||
.footer-nav-card,
|
.footer-nav-card,
|
||||||
.theme-customizer-panel-host,
|
.theme-customizer-panel-host,
|
||||||
.agent-assistant-panel,
|
.agent-assistant-panel,
|
||||||
.Vue-Toastification__toast
|
.Vue-Toastification__toast
|
||||||
) {
|
) {
|
||||||
border-color: var(--glass-border) !important;
|
border-color: var(--glass-border-raised) !important;
|
||||||
-webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
|
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||||
backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
|
backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||||
background-color: var(--glass-surface-raised) !important;
|
background-color: var(--glass-surface-raised) !important;
|
||||||
|
background-image: var(--glass-sheen) !important;
|
||||||
box-shadow: var(--glass-shadow-raised) !important;
|
box-shadow: var(--glass-shadow-raised) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +326,52 @@ html[data-theme='glass'] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.layout-vertical-nav {
|
.layout-vertical-nav {
|
||||||
border-inline-end: 1px solid var(--glass-border);
|
isolation: isolate;
|
||||||
|
border-inline-end: 0;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
backdrop-filter: none;
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
|
||||||
|
// 导航材质与可变菜单内容分层,路由激活态切换不应重栅格化整块全高背板。
|
||||||
|
&::before {
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
border-inline-end: 1px solid var(--glass-border-raised);
|
||||||
|
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||||
|
backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||||
|
background-color: var(--glass-surface-raised);
|
||||||
|
background-image: var(--glass-sheen);
|
||||||
|
box-shadow: var(--glass-shadow-raised);
|
||||||
|
content: '';
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动条与导航材质边界保持间距,避免窄轨道参与全高背板的合成。
|
||||||
|
.layout-vertical-nav .ps__rail-y {
|
||||||
|
inset-inline-end: 0.5rem !important;
|
||||||
|
transition: opacity 120ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-vertical-nav .ps__thumb-y,
|
||||||
|
.layout-vertical-nav .ps__rail-y:focus > .ps__thumb-y,
|
||||||
|
.layout-vertical-nav .ps__rail-y:hover > .ps__thumb-y,
|
||||||
|
.layout-vertical-nav .ps__rail-y.ps--clicking .ps__thumb-y {
|
||||||
|
background-color: rgba(255, 255, 255, 28%) !important;
|
||||||
|
inline-size: 2px !important;
|
||||||
|
inset-inline-end: 0.1875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-vertical-nav .nav-items.ps--scrolling-y > .ps__rail-y {
|
||||||
|
opacity: 0.42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-vertical-nav .nav-link > .router-link-exact-active {
|
||||||
|
border: var(--glass-nav-active-border);
|
||||||
|
background: var(--glass-nav-active-background) !important;
|
||||||
|
box-shadow: var(--glass-nav-active-shadow) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-wrapper.window-scrolled.layout-navbar-fixed .layout-navbar,
|
.layout-wrapper.window-scrolled.layout-navbar-fixed .layout-navbar,
|
||||||
@@ -255,7 +379,8 @@ html[data-theme='glass'] {
|
|||||||
border-color: var(--glass-border) !important;
|
border-color: var(--glass-border) !important;
|
||||||
-webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate)) !important;
|
-webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate)) !important;
|
||||||
backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate)) !important;
|
backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate)) !important;
|
||||||
background: var(--glass-surface-raised) !important;
|
background-color: var(--glass-surface-raised) !important;
|
||||||
|
background-image: var(--glass-sheen) !important;
|
||||||
box-shadow: var(--glass-navbar-shadow) !important;
|
box-shadow: var(--glass-navbar-shadow) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,21 +394,32 @@ html[data-theme='glass'] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:where(.search-desktop-activator, .search-header) .search-input-wrapper {
|
:where(.search-desktop-activator, .search-header) .search-input-wrapper {
|
||||||
border-color: var(--glass-border) !important;
|
border: 1px solid var(--glass-control-prominent-border) !important;
|
||||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
-webkit-backdrop-filter: var(--glass-control-prominent-backdrop-filter);
|
||||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
backdrop-filter: var(--glass-control-prominent-backdrop-filter);
|
||||||
background: rgba(255, 255, 255, 9%) !important;
|
background-color: var(--glass-control-prominent) !important;
|
||||||
box-shadow:
|
background-image: var(--glass-sheen) !important;
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 22%),
|
box-shadow: var(--glass-control-prominent-shadow);
|
||||||
inset 0 -1px 0 rgba(2, 6, 16, 22%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:where(.search-desktop-activator, .search-header) .search-input-wrapper:focus-within {
|
:where(.search-desktop-activator, .search-header) .search-input-wrapper:focus-within {
|
||||||
border-color: rgba(var(--v-theme-primary), 70%) !important;
|
border-color: rgba(var(--v-theme-primary), 70%) !important;
|
||||||
background: rgba(255, 255, 255, 12%) !important;
|
background: var(--glass-control-prominent-focus) !important;
|
||||||
box-shadow:
|
box-shadow: var(--glass-control-prominent-focus-shadow);
|
||||||
0 0 0 3px rgba(var(--v-theme-primary), 15%),
|
}
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 28%);
|
|
||||||
|
:where(.search-desktop-activator, .search-header) .search-input-icon {
|
||||||
|
color: var(--glass-control-icon-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.search-desktop-activator, .search-header) .search-native-input::placeholder {
|
||||||
|
color: var(--glass-control-placeholder-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.search-desktop-activator, .search-header) .search-shortcut-badge {
|
||||||
|
border: 1px solid var(--glass-control-shortcut-border);
|
||||||
|
background: var(--glass-control-shortcut-background);
|
||||||
|
color: var(--glass-control-shortcut-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-customizer-panel,
|
.theme-customizer-panel,
|
||||||
@@ -329,16 +465,17 @@ html[data-theme='glass'] {
|
|||||||
.compact-fab .v-btn,
|
.compact-fab .v-btn,
|
||||||
.global-action-buttons .v-btn {
|
.global-action-buttons .v-btn {
|
||||||
border: 1px solid var(--glass-border) !important;
|
border: 1px solid var(--glass-border) !important;
|
||||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||||
background-color: var(--glass-surface-raised) !important;
|
background-color: var(--glass-surface-raised) !important;
|
||||||
box-shadow: var(--glass-shadow) !important;
|
background-image: var(--glass-sheen) !important;
|
||||||
|
box-shadow: var(--glass-control-shadow) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-overlay__scrim {
|
.v-overlay__scrim {
|
||||||
-webkit-backdrop-filter: blur(12px) saturate(120%);
|
-webkit-backdrop-filter: blur(12px) saturate(120%);
|
||||||
backdrop-filter: blur(12px) saturate(120%);
|
backdrop-filter: blur(12px) saturate(120%);
|
||||||
background: rgba(3, 7, 18, 62%);
|
background: rgba(3, 7, 18, 48%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-dialog--fullscreen {
|
.v-dialog--fullscreen {
|
||||||
@@ -349,9 +486,10 @@ html[data-theme='glass'] {
|
|||||||
border-radius: var(--app-overlay-radius);
|
border-radius: var(--app-overlay-radius);
|
||||||
|
|
||||||
> :where(.v-card, .v-sheet, .v-list) {
|
> :where(.v-card, .v-sheet, .v-list) {
|
||||||
-webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
|
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||||
backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
|
backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||||
background-color: var(--glass-surface-raised) !important;
|
background-color: var(--glass-surface-raised) !important;
|
||||||
|
background-image: var(--glass-sheen) !important;
|
||||||
box-shadow: var(--glass-shadow-raised) !important;
|
box-shadow: var(--glass-shadow-raised) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,28 +504,27 @@ html[data-theme='glass'] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.v-field {
|
.v-field {
|
||||||
--v-field-border-opacity: 0.18;
|
--v-field-border-opacity: 0.14;
|
||||||
--v-field-border-width: 1px;
|
--v-field-border-width: 1px;
|
||||||
|
|
||||||
border-radius: var(--app-field-radius);
|
border-radius: var(--app-field-radius);
|
||||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
-webkit-backdrop-filter: var(--glass-control-backdrop-filter);
|
||||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
backdrop-filter: var(--glass-control-backdrop-filter);
|
||||||
background-color: var(--glass-surface-soft) !important;
|
background-color: var(--glass-control) !important;
|
||||||
box-shadow:
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 8%);
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 15%),
|
|
||||||
inset 0 -1px 0 rgba(2, 6, 16, 30%);
|
|
||||||
transition:
|
transition:
|
||||||
background-color var(--glass-motion),
|
background-color var(--glass-motion),
|
||||||
box-shadow var(--glass-motion);
|
box-shadow var(--glass-motion);
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-field--focused {
|
.v-field--focused {
|
||||||
--v-field-border-opacity: 0.36;
|
--v-field-border-opacity: 0.32;
|
||||||
|
|
||||||
background-color: rgba(255, 255, 255, 10%) !important;
|
background-color: color-mix(in srgb, var(--glass-control) 84%, rgba(var(--v-theme-primary), 32%)) !important;
|
||||||
|
background-image: none !important;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 3px rgba(var(--v-theme-primary), 15%),
|
0 0 0 3px rgba(var(--v-theme-primary), 14%),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 25%);
|
inset 0 1px 0 rgba(255, 255, 255, 10%);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Outlined 描边自身会为浮动标签留出缺口,移除贯穿整个顶部的 field 阴影线。
|
// Outlined 描边自身会为浮动标签留出缺口,移除贯穿整个顶部的 field 阴影线。
|
||||||
@@ -421,19 +558,16 @@ html[data-theme='glass'] {
|
|||||||
transition:
|
transition:
|
||||||
background-color var(--glass-motion),
|
background-color var(--glass-motion),
|
||||||
border-color var(--glass-motion),
|
border-color var(--glass-motion),
|
||||||
box-shadow var(--glass-motion),
|
box-shadow var(--glass-motion);
|
||||||
transform var(--glass-motion);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-btn:not(.v-btn--variant-text, .v-btn--variant-plain) {
|
.v-btn:not(.v-btn--variant-text, .v-btn--variant-plain) {
|
||||||
border: 1px solid rgba(255, 255, 255, 20%);
|
border: 1px solid var(--glass-border);
|
||||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
-webkit-backdrop-filter: var(--glass-control-backdrop-filter);
|
||||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
backdrop-filter: var(--glass-control-backdrop-filter);
|
||||||
background-color: rgba(255, 255, 255, 10%) !important;
|
background-color: var(--glass-button-surface) !important;
|
||||||
box-shadow:
|
background-image: none !important;
|
||||||
0 2px 8px rgba(3, 7, 18, 24%),
|
box-shadow: var(--glass-control-shadow) !important;
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 20%),
|
|
||||||
inset 0 -1px 0 rgba(2, 6, 16, 18%) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-btn.bg-primary {
|
.v-btn.bg-primary {
|
||||||
@@ -465,7 +599,7 @@ html[data-theme='glass'] {
|
|||||||
outline: 0;
|
outline: 0;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 3px rgba(var(--v-theme-primary), 18%),
|
0 0 0 3px rgba(var(--v-theme-primary), 18%),
|
||||||
var(--glass-shadow) !important;
|
var(--glass-control-shadow) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-chip {
|
.v-chip {
|
||||||
@@ -495,8 +629,8 @@ html[data-theme='glass'] {
|
|||||||
--app-card-stripe-opacity: 0.12;
|
--app-card-stripe-opacity: 0.12;
|
||||||
--app-card-surface-opacity: 0.08;
|
--app-card-surface-opacity: 0.08;
|
||||||
|
|
||||||
-webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
|
-webkit-backdrop-filter: none;
|
||||||
backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
|
backdrop-filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移动端文件整理和日历使用自定义容器,需要显式接入玻璃表面材质。
|
// 移动端文件整理和日历使用自定义容器,需要显式接入玻璃表面材质。
|
||||||
@@ -504,15 +638,15 @@ html[data-theme='glass'] {
|
|||||||
--transfer-history-mobile-search-bg: var(--glass-surface-soft);
|
--transfer-history-mobile-search-bg: var(--glass-surface-soft);
|
||||||
--transfer-history-mobile-muted-bg: var(--glass-surface-soft);
|
--transfer-history-mobile-muted-bg: var(--glass-surface-soft);
|
||||||
--transfer-history-mobile-border: var(--glass-border);
|
--transfer-history-mobile-border: var(--glass-border);
|
||||||
--transfer-history-mobile-surface-blur: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
--transfer-history-mobile-surface-blur: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transfer-history-mobile-record,
|
.transfer-history-mobile-record,
|
||||||
.mobile-calendar-filter-card,
|
.mobile-calendar-filter-card,
|
||||||
.mobile-calendar-event-card {
|
.mobile-calendar-event-card {
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
-webkit-backdrop-filter: none;
|
||||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
backdrop-filter: none;
|
||||||
background: var(--glass-surface);
|
background: var(--glass-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,7 +662,7 @@ html[data-theme='glass'] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mobile-scheduler-view {
|
.mobile-scheduler-view {
|
||||||
--mobile-scheduler-card-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
--mobile-scheduler-card-backdrop-filter: none;
|
||||||
--mobile-scheduler-card-background: var(--glass-surface);
|
--mobile-scheduler-card-background: var(--glass-surface);
|
||||||
--mobile-scheduler-card-border: 1px solid var(--glass-border);
|
--mobile-scheduler-card-border: 1px solid var(--glass-border);
|
||||||
--mobile-scheduler-card-shadow: var(--glass-shadow);
|
--mobile-scheduler-card-shadow: var(--glass-shadow);
|
||||||
@@ -537,7 +671,7 @@ html[data-theme='glass'] {
|
|||||||
.mobile-subscribe-calendar {
|
.mobile-subscribe-calendar {
|
||||||
--mobile-calendar-control-bg: var(--glass-surface-soft);
|
--mobile-calendar-control-bg: var(--glass-surface-soft);
|
||||||
--mobile-calendar-placeholder-bg: var(--glass-surface-soft);
|
--mobile-calendar-placeholder-bg: var(--glass-surface-soft);
|
||||||
--mobile-calendar-surface-blur: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
--mobile-calendar-surface-blur: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户权限编辑器由普通容器构成,通过主题变量接入玻璃表面材质。
|
// 用户权限编辑器由普通容器构成,通过主题变量接入玻璃表面材质。
|
||||||
@@ -548,7 +682,7 @@ html[data-theme='glass'] {
|
|||||||
--permission-editor-hover-bg: rgba(255, 255, 255, 8%);
|
--permission-editor-hover-bg: rgba(255, 255, 255, 8%);
|
||||||
--permission-editor-active-bg: rgba(var(--v-theme-primary), 16%);
|
--permission-editor-active-bg: rgba(var(--v-theme-primary), 16%);
|
||||||
--permission-editor-selected-bg: rgba(var(--v-theme-primary), 12%);
|
--permission-editor-selected-bg: rgba(var(--v-theme-primary), 12%);
|
||||||
--permission-editor-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
--permission-editor-backdrop-filter: none;
|
||||||
--permission-editor-shadow: inset 0 1px 0 rgba(255, 255, 255, 8%);
|
--permission-editor-shadow: inset 0 1px 0 rgba(255, 255, 255, 8%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,10 +726,8 @@ html[data-theme='glass'] {
|
|||||||
@media (hover: hover) and (pointer: fine) {
|
@media (hover: hover) and (pointer: fine) {
|
||||||
.v-btn:not(.v-btn--variant-text, .v-btn--variant-plain):hover {
|
.v-btn:not(.v-btn--variant-text, .v-btn--variant-plain):hover {
|
||||||
border-color: var(--glass-border-hover);
|
border-color: var(--glass-border-hover);
|
||||||
background-color: rgba(255, 255, 255, 15%) !important;
|
background-color: var(--glass-button-surface-hover) !important;
|
||||||
box-shadow:
|
box-shadow: var(--glass-control-shadow-hover) !important;
|
||||||
0 5px 16px rgba(3, 7, 18, 28%),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 26%) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-btn.bg-primary:hover {
|
.v-btn.bg-primary:hover {
|
||||||
@@ -629,8 +761,8 @@ html[data-theme='glass'] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-btn:active {
|
.v-btn:not(.v-btn--variant-text, .v-btn--variant-plain):active {
|
||||||
transform: scale(0.97);
|
box-shadow: var(--glass-control-shadow-active) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width <= 960px) {
|
@media (width <= 960px) {
|
||||||
@@ -665,6 +797,23 @@ html[data-theme='glass'] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-transparency: reduce) {
|
||||||
|
:where(
|
||||||
|
.app-surface,
|
||||||
|
.v-card,
|
||||||
|
.v-sheet,
|
||||||
|
.v-list,
|
||||||
|
.v-toolbar,
|
||||||
|
.layout-vertical-nav,
|
||||||
|
.layout-navbar,
|
||||||
|
.v-overlay__content
|
||||||
|
) {
|
||||||
|
-webkit-backdrop-filter: none !important;
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
background-color: rgba(11, 19, 34, 94%) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.v-btn,
|
.v-btn,
|
||||||
.v-card,
|
.v-card,
|
||||||
@@ -674,3 +823,212 @@ html[data-theme='glass'] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 光学档位只在 renderer ready 后接管背景采样,加载与失败状态继续使用标准 CSS 材质。
|
||||||
|
.glass-optical-layer {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 0;
|
||||||
|
block-size: 100%;
|
||||||
|
inline-size: 100%;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 180ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 壁纸交叉淡入期间停用折射输出,避免新纹理采样仍在混合的背景。
|
||||||
|
.glass-optical-layer--background-transition {
|
||||||
|
opacity: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录卡片的壁纸副本与外层背景共享切换时序,避免卡片内部提前显示下一张图片。
|
||||||
|
html[data-theme='glass'] .app-wrapper--background-transition .login-card__wallpaper-refraction {
|
||||||
|
opacity: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:is([data-glass-quality='balanced'], [data-glass-quality='high'])[data-glass-renderer-state='ready']
|
||||||
|
.glass-optical-layer {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:is([data-glass-quality='balanced'], [data-glass-quality='high'])[data-glass-renderer-state='ready']
|
||||||
|
body[data-theme='glass'] {
|
||||||
|
.layout-vertical-nav::before,
|
||||||
|
.layout-navbar {
|
||||||
|
-webkit-backdrop-filter: blur(10px) saturate(140%) !important;
|
||||||
|
backdrop-filter: blur(10px) saturate(140%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-overlay__content > :where(.v-card, .v-sheet, .v-list) {
|
||||||
|
-webkit-backdrop-filter: blur(12px) saturate(140%) !important;
|
||||||
|
backdrop-filter: blur(12px) saturate(140%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card,
|
||||||
|
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > :first-child > .v-card {
|
||||||
|
-webkit-backdrop-filter: none !important;
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 磨砂材质的散射由 renderer 与有限的局部扩散共同构成。
|
||||||
|
html[data-glass-appearance='frosted']:is(
|
||||||
|
[data-glass-quality='balanced'],
|
||||||
|
[data-glass-quality='high']
|
||||||
|
)[data-glass-renderer-state='ready']
|
||||||
|
body[data-theme='glass'] {
|
||||||
|
.layout-vertical-nav::before,
|
||||||
|
.layout-navbar,
|
||||||
|
.v-overlay__content > :where(.v-card, .v-sheet, .v-list) {
|
||||||
|
-webkit-backdrop-filter: blur(18px) saturate(145%) !important;
|
||||||
|
backdrop-filter: blur(18px) saturate(145%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card,
|
||||||
|
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > :first-child > .v-card {
|
||||||
|
-webkit-backdrop-filter: blur(24px) saturate(150%) !important;
|
||||||
|
backdrop-filter: blur(24px) saturate(150%) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录页只有一个高价值表面,三档质量共用局部实时材质,避免整屏 renderer 重复采样跨域壁纸。
|
||||||
|
html[data-theme='glass'] body[data-theme='glass'] {
|
||||||
|
.login-card {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.16),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.2),
|
||||||
|
0 24px 64px rgba(1, 6, 15, 0.22) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__glass {
|
||||||
|
-webkit-backdrop-filter: blur(14px) saturate(162%) contrast(108%) !important;
|
||||||
|
backdrop-filter: blur(14px) saturate(162%) contrast(108%) !important;
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
220px 190px at var(--login-card-light-x) var(--login-card-light-y),
|
||||||
|
rgba(255, 255, 255, 0.13),
|
||||||
|
rgba(var(--v-theme-primary), 0.045) 42%,
|
||||||
|
transparent 76%
|
||||||
|
),
|
||||||
|
linear-gradient(145deg, rgba(255, 255, 255, 0.1), transparent 36%), rgba(var(--v-theme-surface), 0.28) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__wallpaper-refraction {
|
||||||
|
display: block;
|
||||||
|
background-attachment: fixed;
|
||||||
|
background-image: var(--login-wallpaper-image);
|
||||||
|
background-position: calc(50% + var(--login-refraction-shift-x)) calc(50% + var(--login-refraction-shift-y));
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
filter: url('#login-liquid-refraction-clear') saturate(1.22) contrast(1.06) brightness(1.04);
|
||||||
|
opacity: 0.48;
|
||||||
|
transform: scale(1.025);
|
||||||
|
transition:
|
||||||
|
background-position 90ms linear,
|
||||||
|
opacity 180ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__glass-caustic {
|
||||||
|
inset-block-start: calc(var(--login-card-light-y) - 88px);
|
||||||
|
inset-inline-start: calc(var(--login-card-light-x) - 112px);
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
opacity: calc(var(--optical-glass-caustic-opacity) + 0.12);
|
||||||
|
transform: scale(calc(var(--optical-glass-caustic-scale) + 0.08));
|
||||||
|
transition:
|
||||||
|
inset-block-start 90ms linear,
|
||||||
|
inset-inline-start 90ms linear,
|
||||||
|
opacity 160ms ease-out,
|
||||||
|
transform 180ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.native-login-field {
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
background: rgba(7, 16, 28, 0.2);
|
||||||
|
-webkit-backdrop-filter: blur(10px) saturate(132%);
|
||||||
|
backdrop-filter: blur(10px) saturate(132%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='glass'][data-glass-appearance='tinted'] body[data-theme='glass'] {
|
||||||
|
.login-card__glass {
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
220px 190px at var(--login-card-light-x) var(--login-card-light-y),
|
||||||
|
rgba(255, 255, 255, 0.13),
|
||||||
|
rgba(var(--v-theme-primary), 0.1) 44%,
|
||||||
|
transparent 76%
|
||||||
|
),
|
||||||
|
linear-gradient(145deg, rgba(255, 255, 255, 0.1), transparent 36%),
|
||||||
|
linear-gradient(rgba(var(--v-theme-primary), 0.1), rgba(var(--v-theme-primary), 0.04)),
|
||||||
|
rgba(var(--v-theme-surface), 0.28) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__wallpaper-refraction {
|
||||||
|
filter: url('#login-liquid-refraction-clear') saturate(1.22) contrast(1.06) brightness(1.04);
|
||||||
|
opacity: 0.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.native-login-field {
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
background:
|
||||||
|
linear-gradient(rgba(var(--v-theme-primary), 0.07), rgba(var(--v-theme-primary), 0.07)), rgba(7, 16, 28, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='glass'][data-glass-appearance='frosted'] body[data-theme='glass'] {
|
||||||
|
.login-card {
|
||||||
|
box-shadow: var(--glass-shadow-raised) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__glass {
|
||||||
|
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter) !important;
|
||||||
|
backdrop-filter: var(--glass-raised-backdrop-filter) !important;
|
||||||
|
background-color: var(--glass-surface-raised) !important;
|
||||||
|
background-image: var(--glass-sheen) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__glass::after {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
background-image: var(--glass-sheen);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.22),
|
||||||
|
inset 0 -1px 0 rgba(2, 6, 16, 0.16);
|
||||||
|
content: '';
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录页位于独立合成边界,壁纸副本用于恢复导航表面的背景采样语义。
|
||||||
|
.login-card__wallpaper-refraction {
|
||||||
|
display: block;
|
||||||
|
background-image: linear-gradient(rgba(6, 10, 19, 0.3), rgba(6, 10, 19, 0.58)), var(--login-wallpaper-image);
|
||||||
|
background-attachment: fixed, fixed;
|
||||||
|
background-position:
|
||||||
|
center,
|
||||||
|
calc(50% + var(--login-refraction-shift-x)) calc(50% + var(--login-refraction-shift-y));
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
filter: blur(52px) brightness(0.76) saturate(0.72);
|
||||||
|
opacity: 0.94;
|
||||||
|
transform: scale(1.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__glass-caustic {
|
||||||
|
opacity: calc(var(--optical-glass-caustic-opacity) + 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.native-login-field {
|
||||||
|
border-color: var(--glass-control-prominent-border);
|
||||||
|
background: var(--glass-control-prominent);
|
||||||
|
-webkit-backdrop-filter: var(--glass-control-prominent-backdrop-filter);
|
||||||
|
backdrop-filter: var(--glass-control-prominent-backdrop-filter);
|
||||||
|
box-shadow: var(--glass-control-prominent-shadow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.glass-optical-layer {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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