feat(glass): unify login and app optical presentation (#591)

This commit is contained in:
InfinityPacer
2026-07-27 10:26:46 +08:00
committed by GitHub
parent 0f8a817413
commit 3886909e9e
21 changed files with 1561 additions and 628 deletions
+212 -49
View File
@@ -27,8 +27,23 @@ import { getDisplayImageUrl } from '@/utils/imageUtils'
import { configureApexChartsTheme } from '@/utils/apexCharts'
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
import { commitPreloadedBackgroundRotation, preloadBackgroundRotationImages } from '@/utils/backgroundRotation'
import { GLASS_OPTICAL_STRENGTH_DEFAULT } from '@/utils/glassOptics'
import {
BACKGROUND_ROTATION_GRACE_MS,
commitPreloadedBackgroundRotation,
preloadBackgroundRotationImages,
preloadBackgroundSequence,
shouldAllowBackgroundRotation,
} from '@/utils/backgroundRotation'
import {
activateLoginBackgroundLayer,
createLoginBackgroundLayers,
getLoginGlassOpticalSettings,
getLoginVisualProfile,
getLoginWallpaperRequestMode,
prepareLoginBackgroundLayer,
settleLoginBackgroundLayers,
type LoginWallpaperRequestMode,
} from '@/utils/loginPresentation'
const LOGIN_WALLPAPER_ROUTE = '/login'
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
@@ -67,49 +82,88 @@ const offlineStatus = useGlobalOfflineStatus()
// 全局设置store
const globalSettingsStore = useGlobalSettingsStore()
// 生成背景图片key
const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'))
// 背景图片
const backgroundImages = ref<string[]>([])
const backgroundLayers = ref(createLoginBackgroundLayers())
const activeImageIndex = ref(0)
const previousImageIndex = ref<number | null>(null)
const isBackgroundCrossfading = ref(false)
const backgroundCrossfadeStartedAt = ref(0)
const pendingOpticalBackgroundImage = ref('')
const { allowsDecorativeMotion, isSuspended: isRenderThrottled, state: appActivityState } = useAppActivityLifecycle()
const preferredMotion = usePreferredReducedMotion()
// 壁纸轮播同时服从应用活动状态与系统动态效果偏好。
const allowsBackgroundRotation = computed(() => allowsDecorativeMotion.value && preferredMotion.value !== 'reduce')
const backgroundRotationGraceActive = ref(false)
let backgroundRotationGraceTimer: number | null = null
// 壁纸时钟允许短时后台续跑;指针、滚动和流场仍服从更严格的应用活动状态。
const allowsBackgroundRotation = computed(() =>
shouldAllowBackgroundRotation(
appActivityState.value,
backgroundRotationGraceActive.value,
preferredMotion.value === 'reduce',
),
)
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
const isGlassTheme = computed(() => globalTheme.name.value === 'glass')
const effectiveGlassSettings = useEffectiveGlassSettings()
const isInitialRouteReady = ref(false)
const isBackdropTheme = computed(() => isTransparentTheme.value || isGlassTheme.value)
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
// 登录专题保持固定光学参数,避免全局用户偏好改变其独立视觉合成。
const loginVisualProfile = computed(() => getLoginVisualProfile(globalTheme.name.value))
const loginGlassSettings = computed(() =>
getLoginGlassOpticalSettings({
appearance: effectiveGlassSettings.value.glassAppearance,
deformationStrength: effectiveGlassSettings.value.glassDeformationStrength,
flowStrength: effectiveGlassSettings.value.glassFlowStrength,
preset: effectiveGlassSettings.value.glassPreset,
reflectionStrength: effectiveGlassSettings.value.glassReflectionStrength,
transmissionStrength: effectiveGlassSettings.value.glassTransmissionStrength,
translationStrength: effectiveGlassSettings.value.glassTranslationStrength,
transparencyStrength: effectiveGlassSettings.value.glassTransparencyStrength,
}),
)
const opticalDeformationStrength = computed(() =>
isLoginWallpaperRoute.value ? GLASS_OPTICAL_STRENGTH_DEFAULT : effectiveGlassSettings.value.glassDeformationStrength,
isLoginWallpaperRoute.value
? loginGlassSettings.value.deformationStrength
: effectiveGlassSettings.value.glassDeformationStrength,
)
const opticalFlowStrength = computed(() =>
isLoginWallpaperRoute.value ? GLASS_OPTICAL_STRENGTH_DEFAULT : effectiveGlassSettings.value.glassFlowStrength,
isLoginWallpaperRoute.value ? loginGlassSettings.value.flowStrength : effectiveGlassSettings.value.glassFlowStrength,
)
const opticalQuality = computed(() =>
isLoginWallpaperRoute.value ? loginGlassSettings.value.quality : effectiveGlassSettings.value.glassQuality,
)
const opticalReflectionStrength = computed(() =>
isLoginWallpaperRoute.value ? GLASS_OPTICAL_STRENGTH_DEFAULT : effectiveGlassSettings.value.glassReflectionStrength,
isLoginWallpaperRoute.value
? loginGlassSettings.value.reflectionStrength
: effectiveGlassSettings.value.glassReflectionStrength,
)
const opticalTransparencyStrength = computed(() =>
isLoginWallpaperRoute.value ? GLASS_OPTICAL_STRENGTH_DEFAULT : effectiveGlassSettings.value.glassTransparencyStrength,
isLoginWallpaperRoute.value
? loginGlassSettings.value.transparencyStrength
: effectiveGlassSettings.value.glassTransparencyStrength,
)
const opticalTranslationStrength = computed(() =>
isLoginWallpaperRoute.value ? GLASS_OPTICAL_STRENGTH_DEFAULT : effectiveGlassSettings.value.glassTranslationStrength,
isLoginWallpaperRoute.value
? loginGlassSettings.value.translationStrength
: effectiveGlassSettings.value.glassTranslationStrength,
)
const opticalTransmissionStrength = computed(() =>
isLoginWallpaperRoute.value
? loginGlassSettings.value.transmissionStrength
: effectiveGlassSettings.value.glassTransmissionStrength,
)
const shouldUseTransparentBackgroundTreatment = computed(() => isTransparentTheme.value && Boolean(isLogin.value))
const shouldUseGlassBackgroundTreatment = computed(
() => isGlassTheme.value && (Boolean(isLogin.value) || isLoginWallpaperRoute.value),
)
const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value)
const shouldUseGlassBackgroundTreatment = computed(() => Boolean(isLogin.value) && isGlassTheme.value)
const shouldLoadBackgroundImages = computed(
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isBackdropTheme.value),
)
const wallpaperRequestMode = computed(() => getLoginWallpaperRequestMode(loginVisualProfile.value))
const activeBackgroundImage = computed(() => backgroundImages.value[activeImageIndex.value] ?? '')
const renderedBackgroundLayers = computed(() => backgroundLayers.value)
const getOpticalBackgroundImage = (imageUrl: string) => getDisplayImageUrl(imageUrl, Boolean(isLogin.value))
// 登录页的光学层只绘制程序化焦散,不会读取该跨域壁纸;登录后才通过同源缓存采样纹理
// 玻璃 profile 使用同源 catalog;旧后端返回外链时仍由 renderer 的纹理失败路径安全回退
const activeOpticalBackgroundImage = computed(() => getOpticalBackgroundImage(activeBackgroundImage.value))
const previousOpticalBackgroundImage = computed(() => {
const previousIndex = previousImageIndex.value
@@ -117,13 +171,10 @@ const previousOpticalBackgroundImage = computed(() => {
return getOpticalBackgroundImage(backgroundImages.value[previousIndex] ?? '')
})
const appWrapperStyle = computed(() => ({
'--login-wallpaper-image': activeBackgroundImage.value ? `url("${activeBackgroundImage.value}")` : 'none',
}))
const shouldRenderGlassOpticalLayer = computed(
() =>
isGlassTheme.value &&
effectiveGlassSettings.value.glassQuality !== 'css' &&
opticalQuality.value !== 'css' &&
isInitialRouteReady.value &&
Boolean(activeBackgroundImage.value),
)
@@ -141,8 +192,11 @@ const shouldRenderGlobalBlurLayer = computed(
let backgroundRetryTimer: number | null = null
let backgroundRequestController: AbortController | null = null
let backgroundCrossfadeTimer: number | null = null
let pendingOpticalWallpaperTimer: number | null = null
let pendingOpticalWallpaperResolve: ((ready: boolean) => void) | null = null
let authenticatedStateTimer: number | null = null
let backgroundRotationVersion = 0
let backgroundPreloadVersion = 0
// 读取并同步透明主题背景设置到根组件响应式状态。
function applyTransparentBackgroundSettings() {
@@ -367,12 +421,44 @@ function clearBackgroundCrossfadeTimer() {
}
}
/** 结束当前 GPU 纹理预备等待,旧请求不得继续提交壁纸切换。 */
function settlePendingOpticalWallpaper(ready: boolean) {
if (pendingOpticalWallpaperTimer !== null) {
window.clearTimeout(pendingOpticalWallpaperTimer)
pendingOpticalWallpaperTimer = null
}
pendingOpticalBackgroundImage.value = ''
pendingOpticalWallpaperResolve?.(ready)
pendingOpticalWallpaperResolve = null
}
/** 等待两个 WebGL 呈现 context 完成下一张纹理上传。 */
function prepareOpticalWallpaper(url: string) {
if (!shouldRenderGlassOpticalLayer.value || !url || url === activeOpticalBackgroundImage.value) {
return Promise.resolve(true)
}
settlePendingOpticalWallpaper(false)
pendingOpticalBackgroundImage.value = url
return new Promise<boolean>(resolve => {
pendingOpticalWallpaperResolve = resolve
pendingOpticalWallpaperTimer = window.setTimeout(() => settlePendingOpticalWallpaper(false), 10000)
})
}
/** 只接受当前待切换 URL 的 renderer 就绪回执。 */
function handleOpticalWallpaperPrepared(url: string) {
if (url === pendingOpticalBackgroundImage.value) settlePendingOpticalWallpaper(true)
}
// 重置背景图交叉淡入淡出状态。
function resetBackgroundCrossfade() {
clearBackgroundCrossfadeTimer()
previousImageIndex.value = null
isBackgroundCrossfading.value = false
backgroundCrossfadeStartedAt.value = 0
backgroundLayers.value = createLoginBackgroundLayers(activeBackgroundImage.value)
}
// 切换期保留上一张背景的渲染状态,避免图片合成层重建时露出透明底。
@@ -380,27 +466,31 @@ function activateBackgroundImage(nextIndex: number) {
if (nextIndex === activeImageIndex.value) return
clearBackgroundCrossfadeTimer()
backgroundLayers.value = prepareLoginBackgroundLayer(backgroundLayers.value, backgroundImages.value[nextIndex] ?? '')
previousImageIndex.value = activeImageIndex.value
isBackgroundCrossfading.value = true
backgroundCrossfadeStartedAt.value = performance.now()
activeImageIndex.value = nextIndex
backgroundLayers.value = activateLoginBackgroundLayer(backgroundLayers.value)
backgroundCrossfadeTimer = window.setTimeout(() => {
previousImageIndex.value = null
isBackgroundCrossfading.value = false
backgroundLayers.value = settleLoginBackgroundLayers(backgroundLayers.value)
backgroundCrossfadeTimer = null
}, BACKGROUND_CROSSFADE_DURATION_MS)
}
// 获取背景图片
async function fetchBackgroundImages() {
async function fetchBackgroundImages(requestMode: LoginWallpaperRequestMode) {
try {
backgroundRequestController?.abort()
backgroundRequestController = new AbortController()
backgroundImages.value = await api.get(`/login/wallpapers`, {
params: requestMode === 'same-origin' ? { same_origin: true } : undefined,
signal: backgroundRequestController.signal,
})
resetBackgroundCrossfade()
activeImageIndex.value = 0
resetBackgroundCrossfade()
} catch (e) {
throw e
}
@@ -415,10 +505,7 @@ function rotateBackgroundImage() {
const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length
const requestVersion = ++backgroundRotationVersion
const nextImage = backgroundImages.value[nextIndex]
const opticalImage =
shouldRenderGlassOpticalLayer.value && !isLoginWallpaperRoute.value
? getOpticalBackgroundImage(nextImage)
: undefined
const opticalImage = shouldRenderGlassOpticalLayer.value ? getOpticalBackgroundImage(nextImage) : undefined
void commitPreloadedBackgroundRotation({
canCommit: () => allowsBackgroundRotation.value && requestVersion === backgroundRotationVersion,
@@ -428,15 +515,51 @@ function rotateBackgroundImage() {
displayUrl: nextImage,
opticalUrl: opticalImage,
preload: preloadImage,
}),
}).then(imagesReady => (imagesReady && opticalImage ? prepareOpticalWallpaper(opticalImage) : imagesReady)),
})
}
}
/** 当前图稳定后按轮播顺序串行预加载其余壁纸,队列失效时不再发起新请求。 */
async function preloadRemainingBackgroundImages() {
if (backgroundImages.value.length <= 1) return
const version = ++backgroundPreloadVersion
const orderedImages = backgroundImages.value
.slice(activeImageIndex.value + 1)
.concat(backgroundImages.value.slice(0, activeImageIndex.value))
await preloadBackgroundSequence({
canContinue: () => version === backgroundPreloadVersion && shouldLoadBackgroundImages.value,
preload: preloadImage,
urls: orderedImages,
})
}
// 停止轮询并使已经发起的壁纸预加载失效,避免非活动状态收到迟到提交。
function stopBackgroundRotation() {
backgroundRotationVersion += 1
backgroundPreloadVersion += 1
removeBackgroundTimer('background-rotation')
settlePendingOpticalWallpaper(false)
}
function clearBackgroundRotationGrace() {
if (backgroundRotationGraceTimer !== null) {
window.clearTimeout(backgroundRotationGraceTimer)
backgroundRotationGraceTimer = null
}
}
function startBackgroundRotationGrace() {
if (backgroundRotationGraceActive.value) return
backgroundRotationGraceActive.value = true
clearBackgroundRotationGrace()
backgroundRotationGraceTimer = window.setTimeout(() => {
backgroundRotationGraceTimer = null
backgroundRotationGraceActive.value = false
}, BACKGROUND_ROTATION_GRACE_MS)
}
// 开始背景图片轮换
@@ -444,19 +567,41 @@ function startBackgroundRotation() {
stopBackgroundRotation()
if (allowsBackgroundRotation.value && backgroundImages.value.length > 1) {
// 使用优化的定时器管理器,后台时自动暂停
// 宽限期结束会使预载队列失效;恢复活动状态时从当前图继续补齐剩余壁纸。
void preloadRemainingBackgroundImages()
// 隐藏页面也允许在有界宽限期内轮换,回调自身会再次核对生命周期。
addBackgroundTimer(
'background-rotation',
rotateBackgroundImage,
10000, // 每10秒切换一次
{
runInBackground: false, // 后台时不运行
runInBackground: true,
skipInitialRun: true, // 不需要立即执行
},
)
}
}
watch(
appActivityState,
(state, previousState) => {
if (state === 'active') {
clearBackgroundRotationGrace()
backgroundRotationGraceActive.value = false
return
}
if (state === 'idle') {
clearBackgroundRotationGrace()
backgroundRotationGraceActive.value = false
return
}
if (previousState === 'active') startBackgroundRotationGrace()
},
{ flush: 'sync' },
)
watch(allowsBackgroundRotation, allowsRotation => {
resetBackgroundCrossfade()
@@ -565,10 +710,12 @@ async function removeLoadingWithStateCheck() {
}
// 加载背景图片
async function loadBackgroundImages(retryCount = 0) {
async function loadBackgroundImages(requestMode: LoginWallpaperRequestMode, retryCount = 0) {
const maxRetries = 3
try {
await fetchBackgroundImages()
await fetchBackgroundImages(requestMode)
const activeImage = activeBackgroundImage.value
if (activeImage && !(await preloadImage(activeImage))) throw new Error('登录壁纸首图预加载失败')
startBackgroundRotation()
} catch (error: any) {
const isAbortError = error.name === 'AbortError' || error.code === 'ERR_CANCELED'
@@ -577,7 +724,7 @@ async function loadBackgroundImages(retryCount = 0) {
const retryDelay = Math.min(baseDelay * Math.pow(2, retryCount), 10000)
backgroundRetryTimer = window.setTimeout(() => {
backgroundRetryTimer = null
loadBackgroundImages(retryCount + 1)
loadBackgroundImages(requestMode, retryCount + 1)
}, retryDelay)
}
}
@@ -621,13 +768,13 @@ onMounted(async () => {
window.addEventListener('focus', handlePageShowThemeSync)
window.addEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged)
// 登录页壁纸仅在未登录登录页需要,避免其他首屏额外发起图片列表请求
// 背景范围或玻璃同源能力变化时重新加载;登录前后玻璃主题保持同一请求模式和活动壁纸
watch(
shouldLoadBackgroundImages,
shouldLoad => {
() => [shouldLoadBackgroundImages.value, wallpaperRequestMode.value] as const,
([shouldLoad, requestMode]) => {
stopBackgroundLoading()
if (shouldLoad) {
loadBackgroundImages()
loadBackgroundImages(requestMode)
} else if (!isBackdropTheme.value) {
backgroundImages.value = []
}
@@ -663,6 +810,7 @@ onMounted(async () => {
onUnmounted(() => {
// 清除背景轮换定时器
stopBackgroundLoading()
clearBackgroundRotationGrace()
if (authenticatedStateTimer) {
window.clearTimeout(authenticatedStateTimer)
authenticatedStateTimer = null
@@ -684,10 +832,11 @@ onUnmounted(() => {
:class="{
'app-wrapper--background-transition': isBackgroundCrossfading,
'app-wrapper--decorative-motion-paused': !allowsDecorativeMotion,
'app-wrapper--login-glass-high': isLoginWallpaperRoute && loginVisualProfile === 'glass',
'app-wrapper--login-wallpaper': isLoginWallpaperRoute,
'app-wrapper--render-throttled': isRenderThrottled,
}"
:data-app-activity-state="appActivityState"
:style="appWrapperStyle"
>
<!-- 登录页透明主题和玻璃主题共用动态壁纸场景 -->
<div
@@ -701,11 +850,11 @@ onUnmounted(() => {
}"
>
<div
v-for="(imageUrl, index) in backgroundImages"
:key="`bg-${index}-${loginStateKey}`"
v-for="layer in renderedBackgroundLayers"
:key="layer.key"
class="background-image"
:class="{ 'active': index === activeImageIndex, 'previous': index === previousImageIndex }"
:style="{ 'backgroundImage': `url(${imageUrl})` }"
:class="layer.role"
:style="{ 'backgroundImage': layer.url ? `url(${layer.url})` : undefined }"
/>
<!-- 全局磨砂层 -->
<div v-if="shouldRenderGlobalBlurLayer" class="global-blur-layer"></div>
@@ -715,9 +864,10 @@ onUnmounted(() => {
:appearance="effectiveGlassSettings.glassAppearance"
:deformation-strength="opticalDeformationStrength"
:flow-strength="opticalFlowStrength"
:quality="effectiveGlassSettings.glassQuality === 'high' ? 'high' : 'balanced'"
:quality="opticalQuality === 'high' ? 'high' : 'balanced'"
:reflection-strength="opticalReflectionStrength"
:transparency-strength="opticalTransparencyStrength"
:transmission-strength="opticalTransmissionStrength"
:translation-strength="opticalTranslationStrength"
:route-key="route.fullPath"
:tint-color="globalTheme.current.value.colors.primary"
@@ -725,9 +875,14 @@ onUnmounted(() => {
:transition-started-at="backgroundCrossfadeStartedAt"
:wallpaper-url="activeOpticalBackgroundImage"
:previous-wallpaper-url="previousOpticalBackgroundImage"
:pending-wallpaper-url="pendingOpticalBackgroundImage"
@wallpaper-prepared="handleOpticalWallpaperPrepared"
/>
<!-- 页面内容 -->
<VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
<VApp
:class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }"
:data-login-visual-profile="isLoginWallpaperRoute ? loginVisualProfile : undefined"
>
<RouterView />
<!-- 全局共享弹窗入口列表与卡片按需在这里挂载业务弹窗 -->
<SharedDialogHost />
@@ -755,6 +910,14 @@ onUnmounted(() => {
inset-inline-start: 0;
}
// 登录内容与壁纸进入同一文档弹性合成上下文;sticky 仍保持普通滚动时的 viewport 锁定。
.app-wrapper--login-wallpaper .background-container {
position: sticky;
block-size: 100dvh;
margin-block-end: -100dvh;
inset-block-start: 0;
}
.background-image {
position: absolute;
background-position: center;
@@ -803,8 +966,8 @@ onUnmounted(() => {
.background-container.is-glass-theme .background-image.active::after,
.background-container.is-glass-theme .background-image.previous::after {
background:
radial-gradient(circle at 50% 18%, transparent 24%, rgba(6, 10, 19, 16%) 100%),
linear-gradient(rgba(6, 10, 19, 14%) 0%, rgba(6, 10, 19, 40%) 100%);
radial-gradient(circle at 50% 18%, transparent 24%, rgba(6, 10, 19, 12%) 100%),
linear-gradient(rgba(6, 10, 19, 10%) 0%, rgba(6, 10, 19, 30%) 100%);
}
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.active,
@@ -819,8 +982,8 @@ html[data-glass-appearance='tinted'] .background-container.is-glass-theme .backg
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%);
radial-gradient(circle at 50% 18%, transparent 22%, rgba(6, 10, 19, 14%) 100%),
linear-gradient(rgba(6, 10, 19, 10%) 0%, rgba(6, 10, 19, 32%) 100%), rgba(var(--v-theme-primary), 3%);
}
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.active,
@@ -834,7 +997,7 @@ html[data-glass-appearance='frosted'] .background-container.is-glass-theme .back
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: linear-gradient(rgba(6, 10, 19, 24%) 0%, rgba(6, 10, 19, 48%) 100%), rgba(11, 19, 34, 8%);
}
.background-container.is-transparent-glass-lightweight .background-image.active,
@@ -867,7 +1030,7 @@ html[data-glass-appearance='frosted'] .background-container.is-glass-theme .back
}
.app-wrapper--decorative-motion-paused {
.login-bg-decor *,
.login-ambient-light *,
.login-logo,
.login-logo-wrapper,
.login-logo-wrapper::before,
+42 -2
View File
@@ -39,6 +39,7 @@ const draftFlowStrength = ref(settings.value.glassFlowStrength)
const draftPreset = ref<GlassOpticalPreset>(settings.value.glassPreset)
const draftQuality = ref<ThemeCustomizerGlassQuality>(settings.value.glassQuality)
const draftReflectionStrength = ref(settings.value.glassReflectionStrength)
const draftTransmissionStrength = ref(settings.value.glassTransmissionStrength)
const draftTranslationStrength = ref(settings.value.glassTranslationStrength)
const draftTransparencyStrength = ref(settings.value.glassTransparencyStrength)
const isSaving = ref(false)
@@ -69,6 +70,7 @@ watch(
draftPreset.value = settings.value.glassPreset
draftQuality.value = settings.value.glassQuality
draftReflectionStrength.value = settings.value.glassReflectionStrength
draftTransmissionStrength.value = settings.value.glassTransmissionStrength
draftTranslationStrength.value = settings.value.glassTranslationStrength
draftTransparencyStrength.value = settings.value.glassTransparencyStrength
} else if (previous) {
@@ -122,13 +124,14 @@ function updateQuality(value: unknown) {
previewGlassSettings({ glassQuality: option.value })
}
/** 将个具体参数作为一个预览事务同步,预置只负责生成这些值。 */
/** 将个具体参数作为一个预览事务同步,预置只负责生成这些值。 */
function previewDraftParameters() {
previewGlassSettings({
glassDeformationStrength: draftDeformationStrength.value,
glassFlowStrength: draftFlowStrength.value,
glassPreset: draftPreset.value,
glassReflectionStrength: draftReflectionStrength.value,
glassTransmissionStrength: draftTransmissionStrength.value,
glassTranslationStrength: draftTranslationStrength.value,
glassTransparencyStrength: draftTransparencyStrength.value,
})
@@ -144,6 +147,7 @@ function applyPreset(value: unknown) {
draftDeformationStrength.value = parameters.deformation
draftFlowStrength.value = parameters.flow
draftReflectionStrength.value = parameters.reflection
draftTransmissionStrength.value = parameters.transmission
draftTranslationStrength.value = parameters.translation
draftTransparencyStrength.value = parameters.transparency
previewDraftParameters()
@@ -173,6 +177,12 @@ function updateReflectionStrength(value: unknown) {
previewGlassSettings({ glassReflectionStrength: draftReflectionStrength.value })
}
/** 将透射亮度限制为稳定范围并即时调整卡片内部壁纸的明暗。 */
function updateTransmissionStrength(value: unknown) {
draftTransmissionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassTransmissionStrength: draftTransmissionStrength.value })
}
/** 将通透度限制为稳定范围并即时调整材质与真实壁纸的占比。 */
function updateTransparencyStrength(value: unknown) {
draftTransparencyStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
@@ -198,6 +208,7 @@ async function saveSettings() {
glassPreset: draftPreset.value,
glassQuality: draftQuality.value,
glassReflectionStrength: draftReflectionStrength.value,
glassTransmissionStrength: draftTransmissionStrength.value,
glassTranslationStrength: draftTranslationStrength.value,
glassTransparencyStrength: draftTransparencyStrength.value,
})
@@ -292,6 +303,7 @@ onScopeDispose(cancelGlassPreview)
</section>
<section class="glass-settings-dialog__tuning">
<h3 class="glass-settings-dialog__group-label">{{ t('theme.glassMaterialTuning') }}</h3>
<div class="glass-settings-dialog__slider-header">
<h3 class="glass-settings-dialog__label">{{ t('theme.glassTransparencyStrength') }}</h3>
<output>{{ draftTransparencyStrength }}%</output>
@@ -309,6 +321,23 @@ onScopeDispose(cancelGlassPreview)
@update:model-value="updateTransparencyStrength"
/>
<div class="glass-settings-dialog__slider-header glass-settings-dialog__slider-header--spaced">
<h3 class="glass-settings-dialog__label">{{ t('theme.glassTransmissionStrength') }}</h3>
<output>{{ draftTransmissionStrength }}%</output>
</div>
<VSlider
:model-value="draftTransmissionStrength"
:aria-label="t('theme.glassTransmissionStrength')"
:min="GLASS_OPTICAL_STRENGTH_MIN"
:max="GLASS_OPTICAL_STRENGTH_MAX"
:step="1"
color="primary"
density="comfortable"
hide-details
thumb-label
@update:model-value="updateTransmissionStrength"
/>
<div class="glass-settings-dialog__slider-header glass-settings-dialog__slider-header--spaced">
<h3 class="glass-settings-dialog__label">{{ t('theme.glassReflectionStrength') }}</h3>
<output>{{ draftReflectionStrength }}%</output>
@@ -327,6 +356,7 @@ onScopeDispose(cancelGlassPreview)
/>
<div v-if="showsDynamicTuning" class="glass-settings-dialog__live-controls">
<h3 class="glass-settings-dialog__group-label">{{ t('theme.glassDynamicTuning') }}</h3>
<div class="glass-settings-dialog__slider-header">
<h3 class="glass-settings-dialog__label">{{ t('theme.glassTranslationStrength') }}</h3>
<output>{{ draftTranslationStrength }}%</output>
@@ -421,6 +451,14 @@ onScopeDispose(cancelGlassPreview)
line-height: 1.45;
}
.glass-settings-dialog__group-label {
margin: 0 0 14px;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
font-weight: 600;
line-height: 1.4;
}
.glass-settings-dialog__slider-header {
display: flex;
align-items: center;
@@ -444,7 +482,9 @@ onScopeDispose(cancelGlassPreview)
}
.glass-settings-dialog__live-controls {
margin-block-start: 18px;
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.1);
margin-block-start: 24px;
padding-block-start: 20px;
:deep(.v-slider) {
margin-block-start: 4px;
@@ -27,6 +27,7 @@ const mocks = vi.hoisted(() => ({
glassPreset: 'natural',
glassQuality: 'css',
glassReflectionStrength: 50,
glassTransmissionStrength: 50,
glassTranslationStrength: 50,
glassTransparencyStrength: 50,
},
@@ -57,6 +58,7 @@ describe('GlassSettingsDialog', () => {
mocks.settings.value.glassPreset = 'natural'
mocks.settings.value.glassQuality = 'css'
mocks.settings.value.glassReflectionStrength = 50
mocks.settings.value.glassTransmissionStrength = 50
mocks.settings.value.glassTranslationStrength = 50
mocks.settings.value.glassTransparencyStrength = 50
})
@@ -76,7 +78,7 @@ describe('GlassSettingsDialog', () => {
})
const sliders = wrapper.findAll('.slider-stub')
expect(sliders).toHaveLength(2)
expect(sliders).toHaveLength(3)
expect(sliders[0].attributes('data-disabled')).toBe('undefined')
expect(sliders[1].attributes('data-disabled')).toBe('undefined')
await wrapper.setProps({ modelValue: false })
@@ -91,6 +93,7 @@ describe('GlassSettingsDialog', () => {
mocks.settings.value.glassDeformationStrength = 65
mocks.settings.value.glassFlowStrength = 61
mocks.settings.value.glassReflectionStrength = 58
mocks.settings.value.glassTransmissionStrength = 57
mocks.settings.value.glassTranslationStrength = 57
mocks.settings.value.glassTransparencyStrength = 55
const wrapper = shallowMount(GlassSettingsDialog, {
@@ -119,7 +122,8 @@ describe('GlassSettingsDialog', () => {
glassDeformationStrength: 82,
glassFlowStrength: 80,
glassPreset: 'liquid',
glassReflectionStrength: 44,
glassReflectionStrength: 31,
glassTransmissionStrength: 58,
glassTranslationStrength: 54,
glassTransparencyStrength: 46,
})
@@ -141,9 +145,9 @@ describe('GlassSettingsDialog', () => {
})
const sliders = wrapper.findAll('.slider-stub')
expect(sliders).toHaveLength(2)
expect(sliders[1].attributes('data-disabled')).toBe('undefined')
await sliders[1].setValue('86')
expect(sliders).toHaveLength(3)
expect(sliders[2].attributes('data-disabled')).toBe('undefined')
await sliders[2].setValue('86')
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({ glassReflectionStrength: 86 })
})
@@ -197,7 +201,7 @@ describe('GlassSettingsDialog', () => {
expect(preset.attributes('data-model-value')).toBe('glide')
})
it('normalizes and previews all five independent slider values', async () => {
it('normalizes and previews all six independent slider values', async () => {
mocks.settings.value.glassQuality = 'balanced'
const wrapper = shallowMount(GlassSettingsDialog, {
global: {
@@ -213,20 +217,22 @@ describe('GlassSettingsDialog', () => {
})
const sliders = wrapper.findAll('.slider-stub')
expect(sliders).toHaveLength(5)
expect(sliders).toHaveLength(6)
expect(sliders.every(slider => slider.attributes('data-disabled') !== 'true')).toBe(true)
await sliders[0].setValue('91.4')
await sliders[1].setValue('73.6')
await sliders[2].setValue('84.2')
await sliders[3].setValue('68.7')
await sliders[4].setValue('62.2')
await sliders[1].setValue('77.5')
await sliders[2].setValue('73.6')
await sliders[3].setValue('84.2')
await sliders[4].setValue('68.7')
await sliders[5].setValue('62.2')
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(1, { glassTransparencyStrength: 91 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(2, { glassReflectionStrength: 74 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(3, { glassTranslationStrength: 84 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(4, { glassDeformationStrength: 69 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(5, { glassFlowStrength: 62 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(2, { glassTransmissionStrength: 78 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(3, { glassReflectionStrength: 74 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(4, { glassTranslationStrength: 84 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(5, { glassDeformationStrength: 69 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(6, { glassFlowStrength: 62 })
expect(mocks.commitGlassPreview).not.toHaveBeenCalled()
})
@@ -246,9 +252,10 @@ describe('GlassSettingsDialog', () => {
})
const sliders = wrapper.findAll('.slider-stub')
expect(sliders).toHaveLength(5)
expect(sliders).toHaveLength(6)
expect(sliders.map(slider => slider.attributes('aria-label'))).toEqual([
'theme.glassTransparencyStrength',
'theme.glassTransmissionStrength',
'theme.glassReflectionStrength',
'theme.glassTranslationStrength',
'theme.glassDeformationStrength',
@@ -20,6 +20,8 @@ const props = defineProps<{
reflectionStrength: number
/** 用户选择的真实壁纸可见度。 */
transparencyStrength: number
/** 玻璃内部壁纸采样的透射亮度;不会改变外层壁纸的曝光合同。 */
transmissionStrength: number
/** 用户选择的共享壁纸采样平移强度。 */
translationStrength: number
/** 路由变化标识,用于在页面内容稳定后重新发现高价值表面。 */
@@ -34,6 +36,12 @@ const props = defineProps<{
wallpaperUrl: string
/** 切换期保留的上一张壁纸;空值表示当前没有交叉淡化。 */
previousWallpaperUrl: string
/** 下一张同源壁纸;两个 context 均完成上传后才允许外层提交切换。 */
pendingWallpaperUrl?: string
}>()
const emit = defineEmits<{
/** fixed 与 scroll renderer 均已准备同一张待切换纹理。 */
wallpaperPrepared: [url: string]
}>()
const fixedCanvas = ref<HTMLCanvasElement | null>(null)
@@ -49,6 +57,7 @@ const fixedRenderer = useGlassOpticalRenderer({
quality: () => props.quality,
reflectionStrength: () => props.reflectionStrength,
transparencyStrength: () => props.transparencyStrength,
transmissionStrength: () => props.transmissionStrength,
translationStrength: () => props.translationStrength,
routeKey: () => props.routeKey,
tintColor: () => props.tintColor,
@@ -56,6 +65,7 @@ const fixedRenderer = useGlassOpticalRenderer({
transitionStartedAt: () => props.transitionStartedAt,
wallpaperUrl: () => props.wallpaperUrl,
previousWallpaperUrl: () => props.previousWallpaperUrl,
pendingWallpaperUrl: () => props.pendingWallpaperUrl ?? '',
surfaceSpace: 'fixed',
syncDocumentState: false,
})
@@ -69,6 +79,7 @@ const scrollRenderer = useGlassOpticalRenderer({
quality: () => props.quality,
reflectionStrength: () => props.reflectionStrength,
transparencyStrength: () => props.transparencyStrength,
transmissionStrength: () => props.transmissionStrength,
translationStrength: () => props.translationStrength,
routeKey: () => props.routeKey,
tintColor: () => props.tintColor,
@@ -76,6 +87,7 @@ const scrollRenderer = useGlassOpticalRenderer({
transitionStartedAt: () => props.transitionStartedAt,
wallpaperUrl: () => props.wallpaperUrl,
previousWallpaperUrl: () => props.previousWallpaperUrl,
pendingWallpaperUrl: () => props.pendingWallpaperUrl ?? '',
surfaceSpace: 'scroll',
syncDocumentState: false,
})
@@ -94,6 +106,13 @@ watchEffect(() => {
setGlassRendererState(rendererState, state)
})
watchEffect(() => {
const url = props.pendingWallpaperUrl
if (url && fixedRenderer.preparedWallpaperUrl.value === url && scrollRenderer.preparedWallpaperUrl.value === url) {
emit('wallpaperPrepared', url)
}
})
onScopeDispose(() => {
setGlassRendererState(rendererState, 'fallback')
})
+3 -1
View File
@@ -12,7 +12,7 @@ import {
import { usePWA } from '@/composables/usePWA'
import { useI18n } from 'vue-i18n'
import { useTheme } from 'vuetify'
import { GLASS_OPTICAL_STRENGTH_DEFAULT } from '@/utils/glassOptics'
import { GLASS_OPTICAL_STRENGTH_DEFAULT, getGlassOpticalPresetParameters } from '@/utils/glassOptics'
const emit = defineEmits<{
'close': []
@@ -38,6 +38,7 @@ const { appMode } = usePWA()
const { t } = useI18n()
const { global: globalTheme } = useTheme()
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
const defaultGlassTransmissionStrength = getGlassOpticalPresetParameters('clear', 'balanced', 'natural').transmission
// 将主题定制器打开状态同步到根节点,供全局悬浮按钮避让右侧面板。
function syncThemeCustomizerOpenState(isOpen: boolean) {
@@ -145,6 +146,7 @@ const hasAppModeCustomization = computed(() => {
settings.value.glassFlowStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
settings.value.glassQuality !== 'css' ||
settings.value.glassReflectionStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
settings.value.glassTransmissionStrength !== defaultGlassTransmissionStrength ||
settings.value.glassTranslationStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
settings.value.glassTransparencyStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
settings.value.radius !== 'default' ||
@@ -40,6 +40,7 @@ describe('GlassOpticalLayer', () => {
tintColor: '#8D51F9',
transitionDuration: 1500,
transitionStartedAt: 0,
transmissionStrength: 84,
translationStrength: 50,
transparencyStrength: 50,
wallpaperUrl: '/wallpaper.jpg',
@@ -2,6 +2,7 @@ import {
collectGlassOpticalRects,
containsGlassOpticalSurface,
setGlassRendererState,
useGlassOpticalInteractionSource,
useGlassOpticalRenderer,
type GlassRendererState,
} from '@/composables/useGlassOpticalRenderer'
@@ -33,6 +34,7 @@ vi.mock('three', async importOriginal => {
render() {}
setClearColor() {}
setPixelRatio() {}
initTexture() {}
setRenderTarget() {}
setScissor() {}
setScissorTest() {}
@@ -136,6 +138,50 @@ afterEach(() => {
})
describe('glass optical surface discovery', () => {
it('gives an active overlay exclusive ownership of shared pointer input', () => {
const overlay = document.createElement('div')
overlay.className = 'v-overlay v-overlay--active'
document.body.append(overlay)
const fixedListener = vi.fn()
const scrollListener = vi.fn()
const scope = effectScope()
scope.run(() => {
const source = useGlassOpticalInteractionSource()
source.subscribe('fixed', fixedListener)
source.subscribe('scroll', scrollListener)
})
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 400, clientY: 400 }))
expect(fixedListener).toHaveBeenCalledOnce()
expect(scrollListener).not.toHaveBeenCalled()
scope.stop()
})
it('routes login-card input to the scroll presentation context', () => {
const loginRoot = document.createElement('div')
loginRoot.className = 'login-root'
const loginCard = document.createElement('section')
loginCard.className = 'login-card'
setOpticalSurfaceBounds(loginCard, { height: 600, width: 420, x: 200, y: 100 })
loginRoot.append(loginCard)
document.body.append(loginRoot)
const fixedListener = vi.fn()
const scrollListener = vi.fn()
const scope = effectScope()
scope.run(() => {
const source = useGlassOpticalInteractionSource()
source.subscribe('fixed', fixedListener)
source.subscribe('scroll', scrollListener)
})
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 400, clientY: 400 }))
expect(scrollListener).toHaveBeenCalledOnce()
expect(fixedListener).not.toHaveBeenCalled()
scope.stop()
})
it('detects a target surface added directly', () => {
const surface = document.createElement('section')
surface.dataset.glassOpticalSurface = ''
@@ -254,6 +300,20 @@ describe('glass optical surface discovery', () => {
])
})
it('discovers top-level business cards without route-specific selectors', () => {
const main = document.createElement('main')
main.className = 'layout-page-content'
const surface = document.createElement('section')
surface.className = 'v-card'
setOpticalSurfaceBounds(surface, { height: 360, width: 720, x: 120, y: 140 })
main.append(surface)
document.body.append(main)
expect(collectGlassOpticalRects(1200, 900, 'clear')).toEqual([
expect.objectContaining({ height: 360, width: 720, x: 120, y: 140 }),
])
})
it('recovers after consecutive WebGL context loss cycles', async () => {
const canvas = document.createElement('canvas')
const scope = effectScope()
@@ -321,7 +381,7 @@ describe('glass optical surface discovery', () => {
expect(countListenerAdds(addWindowListener.mock.calls, 'touchend')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'touchcancel')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'scroll')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'scroll')).toBe(0)
expect(countListenerAdds(addWindowListener.mock.calls, 'focus')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'pageshow')).toBe(1)
expect(countListenerAdds(addDocumentListener.mock.calls, 'visibilitychange')).toBe(1)
@@ -353,7 +413,7 @@ describe('glass optical surface discovery', () => {
expect(countListenerAdds(addWindowListener.mock.calls, 'touchend')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'touchcancel')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'scroll')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'scroll')).toBe(0)
expect(countListenerAdds(addWindowListener.mock.calls, 'focus')).toBe(0)
expect(countListenerAdds(addWindowListener.mock.calls, 'pageshow')).toBe(0)
expect(countListenerAdds(addDocumentListener.mock.calls, 'visibilitychange')).toBe(0)
@@ -415,6 +475,116 @@ describe('glass optical surface discovery', () => {
scope.stop()
})
it('keeps the loaded same-origin texture while leaving the login route', async () => {
const three = await import('three')
const canvas = document.createElement('canvas')
const routeKey = ref('/login')
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(canvas),
quality: ref('high'),
routeKey,
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('/api/v1/login/wallpapers/opaque-id'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
const textureLoad = vi.spyOn(three.TextureLoader.prototype, 'loadAsync')
const rendererDispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
routeKey.value = '/dashboard'
await nextTick()
expect(renderer?.state.value).toBe('ready')
expect(textureLoad).not.toHaveBeenCalled()
expect(rendererDispose).not.toHaveBeenCalled()
scope.stop()
})
it('clears a removed fixed surface during a route transition without waiting for pointer input', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1800)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(900)
const three = await import('three')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const setRenderTarget = vi.spyOn(three.WebGLRenderer.prototype, 'setRenderTarget')
const renderedRectCounts: number[] = []
render.mockImplementation(scene => {
const uniforms = (
scene as unknown as {
children: Array<{
material?: {
uniforms?: {
uRectCount?: { value: number }
}
}
}>
}
).children[0]?.material?.uniforms
if (uniforms?.uRectCount) renderedRectCounts.push(uniforms.uRectCount.value)
})
const callbacks = new Map<number, FrameRequestCallback>()
let frameId = 0
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
frameId += 1
callbacks.set(frameId, callback)
return frameId
})
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => callbacks.delete(id))
const wrapper = document.createElement('div')
wrapper.className = 'app-wrapper'
const nav = document.createElement('aside')
nav.className = 'layout-vertical-nav'
setOpticalSurfaceBounds(nav, { height: 900, width: 260, x: 0, y: 0 })
wrapper.append(nav)
document.body.append(wrapper)
const routeKey = ref('/dashboard')
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
quality: ref('high'),
routeKey,
surfaceSpace: 'fixed',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
for (let pass = 0; pass < 5 && callbacks.size > 0; pass += 1) {
const scheduledCallbacks = [...callbacks.values()]
callbacks.clear()
scheduledCallbacks.forEach(callback => callback(performance.now() + pass * 16))
}
render.mockClear()
setRenderTarget.mockClear()
renderedRectCounts.length = 0
nav.remove()
routeKey.value = '/login'
await nextTick()
await vi.waitFor(() => expect(callbacks.size).toBeGreaterThan(0))
for (let pass = 0; pass < 5 && callbacks.size > 0; pass += 1) {
const scheduledCallbacks = [...callbacks.values()]
callbacks.clear()
scheduledCallbacks.forEach(callback => callback(performance.now() + 100 + pass * 16))
}
expect(render).toHaveBeenCalled()
expect(renderedRectCounts.at(-1)).toBe(0)
expect(setRenderTarget).not.toHaveBeenCalled()
expect(callbacks.size).toBe(0)
scope.stop()
})
it('keeps old and new wallpaper textures in the same renderer during the shared transition', async () => {
const three = await import('three')
const canvas = document.createElement('canvas')
@@ -572,12 +742,55 @@ describe('glass optical surface discovery', () => {
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
const dispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
const contextLoss = vi.spyOn(three.WebGLRenderer.prototype, 'forceContextLoss')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
vi.useFakeTimers()
visibilityState = 'hidden'
document.dispatchEvent(new Event('visibilitychange'))
await vi.advanceTimersByTimeAsync(APP_ACTIVITY_SUSPEND_DELAY_MS)
expect(dispose).toHaveBeenCalledTimes(1)
expect(contextLoss).not.toHaveBeenCalled()
render.mockClear()
visibilityState = 'visible'
document.dispatchEvent(new Event('visibilitychange'))
await nextTick()
await vi.waitFor(() => expect(render).toHaveBeenCalled())
expect(renderer?.state.value).toBe('ready')
expect(dispose).toHaveBeenCalledTimes(1)
expect(contextLoss).not.toHaveBeenCalled()
scope.stop()
})
it('releases a paused renderer after the visible window remains unfocused', async () => {
const three = await import('three')
const canvas = document.createElement('canvas')
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
vi.spyOn(document, 'hasFocus').mockReturnValue(false)
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(canvas),
quality: ref('high'),
routeKey: ref('/dashboard'),
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
const dispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
const contextLoss = vi.spyOn(three.WebGLRenderer.prototype, 'forceContextLoss')
vi.useFakeTimers()
window.dispatchEvent(new Event('blur'))
await vi.advanceTimersByTimeAsync(APP_ACTIVITY_SUSPEND_DELAY_MS)
expect(dispose).toHaveBeenCalledTimes(1)
expect(contextLoss).not.toHaveBeenCalled()
scope.stop()
})
@@ -844,6 +1057,114 @@ describe('glass optical surface discovery', () => {
expect(callbacks.size).toBe(0)
})
it('redraws a high-quality scroll layer without advancing its flow targets', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
const three = await import('three')
const setRenderTarget = vi.spyOn(three.WebGLRenderer.prototype, 'setRenderTarget')
const callbacks = new Map<number, FrameRequestCallback>()
let frameId = 0
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
frameId += 1
callbacks.set(frameId, callback)
return frameId
})
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => callbacks.delete(id))
const surface = appendOpticalSurface('app-hover-lift-card', { height: 300, width: 400, x: 40, y: 120 })
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
quality: ref('high'),
routeKey: ref('/dashboard'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
for (let pass = 0; pass < 4 && callbacks.size > 0; pass += 1) {
const scheduledCallbacks = [...callbacks.values()]
callbacks.clear()
scheduledCallbacks.forEach(callback => callback(performance.now() + pass * 16))
}
setRenderTarget.mockClear()
window.dispatchEvent(new Event('scroll'))
for (let pass = 0; pass < 3 && callbacks.size > 0; pass += 1) {
const scheduledCallbacks = [...callbacks.values()]
callbacks.clear()
scheduledCallbacks.forEach(callback => callback(performance.now() + 100 + pass * 16))
}
expect(setRenderTarget).not.toHaveBeenCalled()
scope.stop()
surface.remove()
})
it('tracks transformed card geometry during a bounded hover transition without advancing flow', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
const three = await import('three')
const setRenderTarget = vi.spyOn(three.WebGLRenderer.prototype, 'setRenderTarget')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const callbacks = new Map<number, FrameRequestCallback>()
let frameId = 0
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
frameId += 1
callbacks.set(frameId, callback)
return frameId
})
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => callbacks.delete(id))
const surface = appendOpticalSurface('app-hover-lift-card', { height: 300, width: 400, x: 40, y: 120 })
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
quality: ref('high'),
routeKey: ref('/plugins'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
for (let pass = 0; pass < 6 && callbacks.size > 0; pass += 1) {
const scheduledCallbacks = [...callbacks.values()]
callbacks.clear()
scheduledCallbacks.forEach(callback => callback(performance.now() + pass * 16))
}
render.mockClear()
setRenderTarget.mockClear()
setOpticalSurfaceBounds(surface, { height: 300, width: 400, x: 40, y: 116 })
const transitionRun = new Event('transitionrun', { bubbles: true }) as TransitionEvent
Object.defineProperty(transitionRun, 'propertyName', { value: 'transform' })
surface.dispatchEvent(transitionRun)
expect(callbacks.size).toBeGreaterThan(0)
const scheduledCallbacks = [...callbacks.values()]
callbacks.clear()
scheduledCallbacks.forEach(callback => callback(performance.now() + 100))
expect(render).toHaveBeenCalled()
expect(setRenderTarget).not.toHaveBeenCalled()
const transitionEnd = new Event('transitionend', { bubbles: true }) as TransitionEvent
Object.defineProperty(transitionEnd, 'propertyName', { value: 'transform' })
surface.dispatchEvent(transitionEnd)
scope.stop()
expect(callbacks.size).toBe(0)
surface.remove()
})
it('refreshes visible surface slots after scrolling settles', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
@@ -963,6 +1284,7 @@ describe('glass optical surface discovery', () => {
const deformationStrength = ref(50)
const flowStrength = ref(50)
const reflectionStrength = ref(50)
const transmissionStrength = ref(50)
const translationStrength = ref(50)
const scope = effectScope()
const renderer = scope.run(() =>
@@ -977,6 +1299,7 @@ describe('glass optical surface discovery', () => {
routeKey: ref('/dashboard'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
transmissionStrength,
translationStrength,
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
@@ -1005,6 +1328,7 @@ describe('glass optical surface discovery', () => {
uRects: { value: Array<{ x: number }> }
uReflectionStrength: { value: number }
uTransparency: { value: number }
uTransmissionStrength: { value: number }
uTranslationStrength: { value: number }
uSurfaceWeights: { value: number[] }
}
@@ -1034,11 +1358,13 @@ describe('glass optical surface discovery', () => {
expect(uniforms.uMaxRefractionPixels.value).toBe(6)
expect(uniforms.uReflectionStrength.value).toBe(1)
expect(uniforms.uTransparency.value).toBeGreaterThan(0.6)
expect(uniforms.uTransmissionStrength.value).toBeCloseTo(5 / 7)
translationStrength.value = 100
deformationStrength.value = 100
flowStrength.value = 100
reflectionStrength.value = 80
transmissionStrength.value = 100
await nextTick()
expect(uniforms.uTranslationStrength.value).toBeCloseTo(1.7)
expect(uniforms.uDeformationStrength.value).toBeCloseTo(1.55)
@@ -1046,6 +1372,7 @@ describe('glass optical surface discovery', () => {
expect(uniforms.uMotionExpansion.value).toBe(1)
expect(uniforms.uMaxRefractionPixels.value).toBe(6)
expect(uniforms.uReflectionStrength.value).toBeGreaterThan(1)
expect(uniforms.uTransmissionStrength.value).toBe(1.3)
expect(scene.children[0].material.fragmentShader).not.toContain('clamp(uMotion +')
expect(scene.children[0].material.fragmentShader).toContain(
@@ -1067,6 +1394,14 @@ describe('glass optical surface discovery', () => {
expect(scene.children[0].material.fragmentShader).toContain('uniform float uFlowStrength')
expect(scene.children[0].material.fragmentShader).toContain('uniform float uReflectionStrength')
expect(scene.children[0].material.fragmentShader).toContain('uniform float uTransparency')
expect(scene.children[0].material.fragmentShader).toContain('uniform float uTransmissionStrength')
expect(scene.children[0].material.fragmentShader).toContain('float transmissionResponse')
expect(scene.children[0].material.fragmentShader).toContain('vec3 transmissionReference')
expect(scene.children[0].material.fragmentShader).toContain('float shadowLiftGate')
expect(scene.children[0].material.fragmentShader).toContain('float shadowColorRetention')
expect(scene.children[0].material.fragmentShader).toContain('float highlightProtection')
expect(scene.children[0].material.fragmentShader).toContain('protectedHighlightReference')
expect(scene.children[0].material.fragmentShader).toContain('mix(0.58, 0.92, uQuality)')
expect(scene.children[0].material.fragmentShader).toContain('uMotion *')
expect(scene.children[0].material.fragmentShader).toContain('uTranslationStrength')
expect(scene.children[0].material.fragmentShader).toContain('vec2 lightDirection = normalize(vec2(-0.68, 0.74))')
@@ -26,9 +26,10 @@ describe('useThemeCustomizer glass settings', () => {
expect(settings.glassFlowStrength).toBe(50)
expect(settings.glassPreset).toBe('natural')
expect(settings.glassQuality).toBe('balanced')
expect(settings.glassReflectionStrength).toBe(50)
expect(settings.glassReflectionStrength).toBe(35)
expect(settings.glassTransmissionStrength).toBe(70)
expect(settings.glassTranslationStrength).toBe(50)
expect(settings.glassTransparencyStrength).toBe(50)
expect(settings.glassTransparencyStrength).toBe(70)
})
it.each(['balanced', 'high'] as const)('preserves the %s quality contract', quality => {
@@ -63,6 +64,7 @@ describe('useThemeCustomizer glass settings', () => {
glassDeformationStrength: -12,
glassFlowStrength: 42.6,
glassReflectionStrength: 140.6,
glassTransmissionStrength: 78.4,
glassTranslationStrength: 101,
glassTransparencyStrength: 83.7,
}),
@@ -72,6 +74,7 @@ describe('useThemeCustomizer glass settings', () => {
glassDeformationStrength: 0,
glassFlowStrength: 43,
glassReflectionStrength: 100,
glassTransmissionStrength: 78,
glassTranslationStrength: 100,
glassTransparencyStrength: 84,
})
@@ -87,6 +90,12 @@ describe('useThemeCustomizer glass settings', () => {
})
})
it('keeps neutral transmission for stored settings created before the field existed', () => {
localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify({ glassAppearance: 'clear' }))
expect(readThemeCustomizerSettings().glassTransmissionStrength).toBe(50)
})
it('syncs glass settings to the document roots', () => {
const settings = readThemeCustomizerSettings()
@@ -100,10 +109,12 @@ describe('useThemeCustomizer glass settings', () => {
expect(document.documentElement.dataset.glassQuality).toBe('high')
expect(document.body.dataset.glassAppearance).toBe('tinted')
expect(document.body.dataset.glassQuality).toBe('high')
expect(document.documentElement.style.getPropertyValue('--glass-reflection')).toBe('0.5')
expect(document.body.style.getPropertyValue('--glass-reflection')).toBe('0.5')
expect(document.documentElement.style.getPropertyValue('--glass-transparency')).toBe('0.5')
expect(document.body.style.getPropertyValue('--glass-transparency')).toBe('0.5')
expect(document.documentElement.style.getPropertyValue('--glass-reflection')).toBe('0.35')
expect(document.body.style.getPropertyValue('--glass-reflection')).toBe('0.35')
expect(Number(document.documentElement.style.getPropertyValue('--glass-transmission'))).toBe(1)
expect(document.body.style.getPropertyValue('--glass-transmission-brightness')).not.toBe('')
expect(Number(document.documentElement.style.getPropertyValue('--glass-transparency'))).toBeCloseTo(0.96, 2)
expect(Number(document.body.style.getPropertyValue('--glass-transparency'))).toBeCloseTo(0.96, 2)
})
it('previews glass settings without persisting them', () => {
@@ -126,6 +137,7 @@ describe('useThemeCustomizer glass settings', () => {
glassPreset: 'glide',
glassQuality: 'css',
glassReflectionStrength: 81,
glassTransmissionStrength: 76,
glassTranslationStrength: 69,
glassTransparencyStrength: 80,
})
@@ -139,6 +151,7 @@ describe('useThemeCustomizer glass settings', () => {
glassPreset: 'glide',
glassQuality: 'css',
glassReflectionStrength: 81,
glassTransmissionStrength: 76,
glassTranslationStrength: 69,
glassTransparencyStrength: 80,
})
@@ -163,6 +176,7 @@ describe('useThemeCustomizer glass settings', () => {
glassDeformationStrength: 42,
glassFlowStrength: 44,
glassReflectionStrength: 66,
glassTransmissionStrength: 64,
glassTranslationStrength: 46,
glassTransparencyStrength: 72,
})
@@ -171,6 +185,7 @@ describe('useThemeCustomizer glass settings', () => {
glassDeformationStrength: 90,
glassFlowStrength: 88,
glassReflectionStrength: 12,
glassTransmissionStrength: 92,
glassTranslationStrength: 86,
glassTransparencyStrength: 94,
})
@@ -179,6 +194,7 @@ describe('useThemeCustomizer glass settings', () => {
glassDeformationStrength: 90,
glassFlowStrength: 88,
glassReflectionStrength: 12,
glassTransmissionStrength: 92,
glassTranslationStrength: 86,
glassTransparencyStrength: 94,
})
@@ -191,6 +207,7 @@ describe('useThemeCustomizer glass settings', () => {
glassDeformationStrength: 42,
glassFlowStrength: 44,
glassReflectionStrength: 66,
glassTransmissionStrength: 64,
glassTranslationStrength: 46,
glassTransparencyStrength: 72,
})
+398 -81
View File
@@ -28,6 +28,7 @@ import {
getGlassOpticalMotionEnergy,
getGlassOpticalReflectionStrengthScale,
getGlassOpticalRenderProfile,
getGlassOpticalTransmissionStrength,
getGlassScrollBufferSize,
getGlassOpticalTransparency,
getGlassOpticalTranslationStrengthScale,
@@ -52,8 +53,8 @@ export type GlassRendererState = 'fallback' | 'loading' | 'ready'
export type GlassPresentationSpace = 'fixed' | 'scroll'
export interface GlassOpticalInteractionSource {
/** 订阅同一组原始指针输入;各呈现层仅负责转换自己的坐标空间。 */
subscribe(listener: (event: PointerEvent | TouchEvent) => void): () => void
/** 按呈现空间订阅共享输入;同一事件只会交给一个空间。 */
subscribe(space: GlassPresentationSpace, listener: (event: PointerEvent | TouchEvent) => void): () => void
}
/** 同步组件状态和根节点属性,确保 CSS 回退与 renderer 生命周期一致。 */
@@ -64,9 +65,58 @@ export function setGlassRendererState(state: Ref<GlassRendererState>, value: Gla
/** 为有界的多个呈现 context 建立唯一的全局指针与触摸事件源。 */
export function useGlassOpticalInteractionSource(): GlassOpticalInteractionSource {
const listeners = new Set<(event: PointerEvent | TouchEvent) => void>()
const listeners: Record<GlassPresentationSpace, Set<(event: PointerEvent | TouchEvent) => void>> = {
fixed: new Set(),
scroll: new Set(),
}
const touchOwners = new Map<number, GlassPresentationSpace>()
const resolvePointOwner = (clientX: number, clientY: number): GlassPresentationSpace => {
if (document.querySelector('.v-overlay--active')) return 'fixed'
const loginCard = document.querySelector<HTMLElement>('.login-card')
if (loginCard) {
const rect = loginCard.getBoundingClientRect()
if (clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom) {
// 登录卡片与文档弹性滚动共用 scroll-space,输入必须落到同一呈现 context。
return document.querySelector('.login-root') ? 'scroll' : 'fixed'
}
}
const fixedSurface = document.querySelectorAll<HTMLElement>(
'.agent-assistant-panel, .layout-navbar, .layout-vertical-nav',
)
for (const surface of fixedSurface) {
const rect = surface.getBoundingClientRect()
if (clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom) return 'fixed'
}
return 'scroll'
}
const resolveTouchOwner = (event: TouchEvent) => {
const changedTouch = event.changedTouches.item(0)
if (!changedTouch) return null
if (event.type === 'touchstart') {
const owner = resolvePointOwner(changedTouch.clientX, changedTouch.clientY)
touchOwners.set(changedTouch.identifier, owner)
return owner
}
return touchOwners.get(changedTouch.identifier) ?? null
}
const dispatch = (event: PointerEvent | TouchEvent) => {
for (const listener of listeners) listener(event)
const owner =
event instanceof TouchEvent
? resolveTouchOwner(event)
: resolvePointOwner((event as PointerEvent).clientX, (event as PointerEvent).clientY)
if (!owner) return
for (const listener of listeners[owner]) listener(event)
if (event instanceof TouchEvent && (event.type === 'touchend' || event.type === 'touchcancel')) {
for (const touch of Array.from(event.changedTouches)) touchOwners.delete(touch.identifier)
}
}
window.addEventListener('pointermove', dispatch, { passive: true })
@@ -76,7 +126,9 @@ export function useGlassOpticalInteractionSource(): GlassOpticalInteractionSourc
window.addEventListener('touchcancel', dispatch, { passive: true })
onScopeDispose(() => {
listeners.clear()
listeners.fixed.clear()
listeners.scroll.clear()
touchOwners.clear()
window.removeEventListener('pointermove', dispatch)
window.removeEventListener('touchstart', dispatch)
window.removeEventListener('touchmove', dispatch)
@@ -85,10 +137,10 @@ export function useGlassOpticalInteractionSource(): GlassOpticalInteractionSourc
})
return {
subscribe(listener) {
listeners.add(listener)
subscribe(space, listener) {
listeners[space].add(listener)
return () => listeners.delete(listener)
return () => listeners[space].delete(listener)
},
}
}
@@ -119,6 +171,7 @@ interface GlassRendererUniforms extends Record<string, IUniform> {
uTextureMix: IUniform<number>
uTintColor: IUniform<Color>
uTransparency: IUniform<number>
uTransmissionStrength: IUniform<number>
uTranslationStrength: IUniform<number>
uTrail: IUniform<Vector4[]>
uTrailCount: IUniform<number>
@@ -169,6 +222,8 @@ interface UseGlassOpticalRendererOptions {
quality: MaybeRefOrGetter<GlassOpticalQuality>
reflectionStrength?: MaybeRefOrGetter<number>
previousWallpaperUrl?: MaybeRefOrGetter<string>
/** 下一张壁纸可在提交切换前完成解码和 GPU 上传。 */
pendingWallpaperUrl?: MaybeRefOrGetter<string>
transparencyStrength?: MaybeRefOrGetter<number>
routeKey: MaybeRefOrGetter<string>
syncDocumentState?: boolean
@@ -176,12 +231,25 @@ interface UseGlassOpticalRendererOptions {
tintColor: MaybeRefOrGetter<string>
transitionDuration?: MaybeRefOrGetter<number>
transitionStartedAt?: MaybeRefOrGetter<number>
/** 玻璃内部壁纸采样的透射亮度。 */
transmissionStrength?: MaybeRefOrGetter<number>
translationStrength?: MaybeRefOrGetter<number>
wallpaperUrl: MaybeRefOrGetter<string>
}
type GlassOpticalSurfaceDescriptor = GlassOpticalSurfaceCandidate<HTMLElement>
interface PreparedWallpaperTexture {
/** 是否包含可采样的真实壁纸,而非程序化回退纹理。 */
hasWallpaperTexture: boolean
/** 纹理像素高度。 */
height: number
/** 已完成当前 WebGL context 上传的纹理。 */
texture: Texture
/** 纹理像素宽度。 */
width: number
}
const SURFACE_SELECTORS = [
{ rank: 1, selector: '.v-overlay--active .v-overlay__content > .v-card', space: 'fixed' },
{ rank: 1, selector: '.v-overlay--active .v-overlay__content > .v-sheet', space: 'fixed' },
@@ -203,9 +271,19 @@ const SURFACE_SELECTORS = [
{ rank: 3, selector: '[data-glass-optical-surface]', space: 'scroll' },
// 推荐、订阅、媒体详情与设置页共用该交互卡片契约,不按业务路由维护 renderer 白名单。
{ rank: 4, selector: '.app-hover-lift-card', space: 'scroll' },
// 顶层业务卡片共享玻璃表面语义;嵌套卡片由表面收集阶段折叠,避免按页面维护白名单。
{ rank: 5, selector: '.layout-page-content .v-card', space: 'scroll' },
] as const
const SURFACE_SELECTOR_QUERY = SURFACE_SELECTORS.map(({ selector }) => selector).join(',')
/** 登录卡片随文档弹性合成,其余固定表面继续使用 viewport 坐标。 */
function getSurfacePresentationSpace(
selector: (typeof SURFACE_SELECTORS)[number]['selector'],
defaultSpace: GlassPresentationSpace,
) {
return selector === '.login-card' && document.querySelector('.login-root') ? 'scroll' : defaultSpace
}
/** 判断新增或移除的 DOM 子树是否会改变光学表面集合。 */
export function containsGlassOpticalSurface(node: Node) {
return (
@@ -293,6 +371,7 @@ uniform int uRectCount;
uniform float uAppearance;
uniform vec3 uTintColor;
uniform float uTransparency;
uniform float uTransmissionStrength;
uniform float uTranslationStrength;
uniform float uTextureMix;
uniform vec4 uTrail[4];
@@ -338,14 +417,49 @@ vec3 toneMapWallpaper(vec3 color, vec2 uv) {
mapped = clamp((mapped - vec3(0.5)) * contrast + vec3(0.5), 0.0, 1.0) * exposure;
float top = 1.0 - uv.y;
float linearStart = mix(0.14, 0.3, frosted);
float linearEnd = mix(mix(0.4, 0.42, tinted), 0.58, frosted);
float linearStart = mix(0.1, 0.24, frosted);
float linearEnd = mix(mix(0.3, 0.32, tinted), 0.48, frosted);
float linearAbsorption = mix(linearStart, linearEnd, top);
vec2 radialDelta = (uv - vec2(0.5, 0.82)) / vec2(0.78, 1.0);
float radialAbsorption = smoothstep(0.24, 0.92, length(radialDelta)) * mix(0.16, 0.18, tinted);
float radialAbsorption = smoothstep(0.24, 0.92, length(radialDelta)) * mix(0.12, 0.14, tinted);
radialAbsorption *= 1.0 - frosted;
vec3 absorbed = mapped * (1.0 - linearAbsorption) * (1.0 - radialAbsorption);
float transmissionExpansion = max(0.0, (uTransmissionStrength - 0.5) * 2.0);
float transmissionResponse = pow(transmissionExpansion, mix(0.8, 0.65, uQuality));
float transmissionMaterialScale = mix(1.0, 0.78, tinted);
transmissionMaterialScale = mix(transmissionMaterialScale, 0.42, frosted);
float shadowGamma =
1.0 -
transmissionResponse *
transmissionMaterialScale *
mix(0.28, 0.5, uQuality);
float shadowLiftGate = smoothstep(0.035, 0.2, luminance);
float highlightProtection = smoothstep(0.72, 0.96, luminance);
float protectedGamma = mix(max(shadowGamma, 0.72), shadowGamma, shadowLiftGate);
protectedGamma = mix(protectedGamma, 1.0, highlightProtection);
vec3 expandedSource = pow(max(color, vec3(0.0)), vec3(protectedGamma));
float sourceLuminance = dot(expandedSource, vec3(0.2126, 0.7152, 0.0722));
float shadowColorRetention = mix(0.42, 1.0, smoothstep(0.025, 0.18, luminance));
expandedSource = mix(vec3(sourceLuminance), expandedSource, shadowColorRetention);
sourceLuminance = dot(expandedSource, vec3(0.2126, 0.7152, 0.0722));
vec3 transmissionReference = mix(vec3(sourceLuminance), expandedSource, mix(1.16, 1.08, frosted));
transmissionReference =
clamp((transmissionReference - vec3(0.5)) * 1.06 + vec3(0.5), 0.0, 1.0) *
1.04;
vec3 protectedHighlightReference = min(color * 1.02, vec3(1.0));
transmissionReference = mix(
transmissionReference,
protectedHighlightReference,
highlightProtection * 0.72
);
float transmissionMix = min(
transmissionResponse *
transmissionMaterialScale *
mix(0.58, 0.92, uQuality),
0.94
);
return mapped * (1.0 - linearAbsorption) * (1.0 - radialAbsorption);
return mix(absorbed, min(transmissionReference, vec3(1.0)), transmissionMix);
}
vec3 sampleWallpaper(vec2 uv) {
@@ -603,6 +717,13 @@ void main() {
}
refracted = mix(refracted, diffused, frosted);
float refractedLuminance = dot(refracted, vec3(0.2126, 0.7152, 0.0722));
float tinted = step(0.5, uAppearance) * (1.0 - step(1.5, uAppearance));
float transmissionOffset = (uTransmissionStrength - 0.5) * 2.0;
if (transmissionOffset < 0.0) {
float dimming = mix(0.18, 0.22, uQuality) * mix(1.0, 0.65, frosted);
refracted *= 1.0 + transmissionOffset * dimming;
}
refractedLuminance = dot(refracted, vec3(0.2126, 0.7152, 0.0722));
float highlightBudget = mix(1.0, 0.34, smoothstep(0.48, 0.9, refractedLuminance));
float frostedBrightCompression = smoothstep(0.58, 0.94, refractedLuminance) * frosted;
refracted *= 1.0 - frostedBrightCompression * mix(0.16, 0.22, uQuality);
@@ -686,10 +807,12 @@ void main() {
}
`
const SCROLL_SURFACE_UPDATE_INTERVAL_MS = 32
const SCROLL_STABLE_TAIL_FRAMES = 2
const SURFACE_STABILITY_MAX_FRAMES = 6
const SURFACE_STABILITY_REQUIRED_FRAMES = 2
const FLOW_BUFFER_SCALE = 0.25
const SURFACE_TRANSITION_DURATION_MS = 96
const SURFACE_TRANSFORM_TRACKING_MAX_MS = 1000
/** 按 shader 协议读取视觉表面的四角圆角。 */
function readBorderRadii(element: HTMLElement) {
@@ -732,7 +855,8 @@ function collectGlassOpticalSurfaceDescriptors(
const seen = new Set<HTMLElement>()
for (const { rank, selector, space } of SURFACE_SELECTORS) {
if (surfaceSpace !== 'all' && space !== surfaceSpace) continue
const resolvedSpace = getSurfacePresentationSpace(selector, space)
if (surfaceSpace !== 'all' && resolvedSpace !== surfaceSpace) continue
// 移动端透明顶栏只使用稳定的 CSS 表面,避免滚动重扫时再次叠加壁纸折射。
if (viewportWidth <= 600 && appearance === 'clear' && selector === '.layout-navbar') continue
@@ -750,8 +874,8 @@ function collectGlassOpticalSurfaceDescriptors(
const visibleHeight = Math.max(0, bottom - top)
const visibleWidth = Math.max(0, right - left)
if (visibleWidth < 24 || visibleHeight < 24) continue
const coordinateOffsetX = surfaceSpace === 'scroll' ? window.scrollX : 0
const coordinateOffsetY = surfaceSpace === 'scroll' ? window.scrollY : 0
const coordinateOffsetX = resolvedSpace === 'scroll' ? window.scrollX : 0
const coordinateOffsetY = resolvedSpace === 'scroll' ? window.scrollY : 0
candidates.push({
key: element,
@@ -817,6 +941,7 @@ function getGlassAppearanceUniformValue(appearance: ThemeCustomizerGlassAppearan
export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) {
const state = ref<GlassRendererState>('loading')
const renderedFrames = ref(0)
const preparedWallpaperUrl = ref('')
let three: ThreeModule | null = null
let resources: GlassRendererResources | null = null
let flowResources: GlassFlowResources | null = null
@@ -827,12 +952,18 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
let previousTextureHeight = 1
let previousTextureWidth = 1
let loadVersion = 0
let prepareVersion = 0
let preparedWallpaper: PreparedWallpaperTexture | null = null
let animationFrame: number | null = null
let wallpaperTransitionFrame: number | null = null
let backgroundDisposeTimer: number | null = null
let presentationBufferHeight = 1
let presentationBufferWidth = 1
let surfaceUpdateFrame: number | null = null
let surfaceStabilityFrame: number | null = null
let surfaceStabilityPass = 0
let surfaceStableFrameCount = 0
let lastSurfaceGeometrySignature = ''
let lastInteractionAt = 0
let lastInteractionFrameAt = 0
let lastPointerAt = 0
@@ -855,9 +986,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
let scrollStableFrameCount = 0
let lastRenderedScrollX = window.scrollX
let lastRenderedScrollY = window.scrollY
let scrollSurfaceTimer: number | null = null
let unsubscribeInteractionSource: (() => void) | null = null
let lastScrollSurfaceUpdateAt = 0
let resizeObserver: ResizeObserver | null = null
let surfaceMutationObserver: MutationObserver | null = null
let observedSurfaces: HTMLElement[] = []
@@ -866,8 +995,10 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
let activeSurface: HTMLElement | null = null
let outgoingSurface: HTMLElement | null = null
let surfaceTransitionStartedAt = 0
let surfaceTransformFrame: number | null = null
let surfaceTransformTrackingDeadline = 0
const transformingSurfaces = new Set<HTMLElement>()
let wakeDirection = { x: 0, y: -1 }
let tracksScrollingSurfaces = false
let contextRecoveryPending = false
const presentationSpace = options.surfaceSpace ?? 'fixed'
@@ -921,6 +1052,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
wallpaperTransitionFrame = null
}
function cancelSurfaceTransformFrame() {
if (surfaceTransformFrame !== null) cancelAnimationFrame(surfaceTransformFrame)
surfaceTransformFrame = null
surfaceTransformTrackingDeadline = 0
transformingSurfaces.clear()
}
function clearBackgroundDisposeTimer() {
if (backgroundDisposeTimer === null) return
@@ -1037,11 +1175,11 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
resources.uniforms.uHasFlowTexture.value = 1
}
function renderFrame(timestamp = performance.now()) {
function renderFrame(timestamp = performance.now(), advanceFlow = true) {
if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') return
updateWallpaperTransition(timestamp)
if (flowResources) {
if (flowResources && advanceFlow) {
resources.renderer.setScissorTest(false)
flowResources.uniforms.uPrevious.value = flowResources.readTarget.texture
resources.renderer.setRenderTarget(flowResources.writeTarget)
@@ -1148,9 +1286,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
const nextObservedSurfaces = Array.from(
new Set(
SURFACE_SELECTORS.filter(({ space }) => space === presentationSpace).flatMap(({ selector }) =>
Array.from(document.querySelectorAll<HTMLElement>(selector)),
),
SURFACE_SELECTORS.filter(
({ selector, space }) => getSurfacePresentationSpace(selector, space) === presentationSpace,
).flatMap(({ selector }) => Array.from(document.querySelectorAll<HTMLElement>(selector))),
),
)
const observedSurfacesChanged =
@@ -1162,19 +1300,55 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
observedSurfaces = nextObservedSurfaces
for (const element of observedSurfaces) resizeObserver?.observe(element)
}
tracksScrollingSurfaces = observedSurfaces.length > 0
if (scheduleRender) scheduleFrame()
}
function scheduleSurfaceUpdate() {
if (surfaceUpdateFrame !== null || !resources) return
surfaceUpdateFrame = requestAnimationFrame(() => {
surfaceUpdateFrame = requestAnimationFrame(timestamp => {
surfaceUpdateFrame = null
updateSurfaceUniforms()
updateSurfaceUniforms(timestamp, false)
// 表面失效必须在同一有界帧内清除旧像素,不能等待下一次指针或壁纸事件。
renderFrame(timestamp, false)
})
}
/** DOM 重排后连续采样少量帧,避免把虚拟列表的中间几何误认为最终表面。 */
function scheduleSurfaceStabilityUpdate() {
surfaceStabilityPass = 0
surfaceStableFrameCount = 0
lastSurfaceGeometrySignature = ''
if (surfaceStabilityFrame !== null || !resources) return
const sample = (timestamp: number) => {
surfaceStabilityFrame = null
if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') return
updateSurfaceUniforms(timestamp, false)
const signature = surfaceSlots
.map(slot => {
const { height, width, x, y } = slot.rect
return `${x.toFixed(2)},${y.toFixed(2)},${width.toFixed(2)},${height.toFixed(2)}`
})
.join('|')
surfaceStableFrameCount = signature === lastSurfaceGeometrySignature ? surfaceStableFrameCount + 1 : 0
lastSurfaceGeometrySignature = signature
surfaceStabilityPass += 1
// DOM 删除和虚拟列表重排只刷新合成几何,不推进已有液态流场。
renderFrame(timestamp, false)
if (
surfaceStableFrameCount < SURFACE_STABILITY_REQUIRED_FRAMES &&
surfaceStabilityPass < SURFACE_STABILITY_MAX_FRAMES
) {
surfaceStabilityFrame = requestAnimationFrame(sample)
}
}
surfaceStabilityFrame = requestAnimationFrame(sample)
}
function scheduleSurfaceResizeUpdate() {
if (surfaceResizeTimer !== null) window.clearTimeout(surfaceResizeTimer)
@@ -1185,6 +1359,65 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
}, 160)
}
/** CSS transform 不改变布局尺寸,过渡期间用有界帧同步真实几何并清除旧蒙版。 */
function scheduleSurfaceTransformFrame() {
if (surfaceTransformFrame !== null || !resources) return
surfaceTransformFrame = requestAnimationFrame(timestamp => {
surfaceTransformFrame = null
if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') {
cancelSurfaceTransformFrame()
return
}
updateSurfaceUniforms(timestamp, false)
renderFrame(timestamp, false)
if (transformingSurfaces.size > 0 && timestamp < surfaceTransformTrackingDeadline) {
scheduleSurfaceTransformFrame()
} else {
transformingSurfaces.clear()
scheduleSurfaceStabilityUpdate()
}
})
}
function resolveTransitionSurface(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return null
const surface = target.matches(SURFACE_SELECTOR_QUERY)
? target
: target.closest<HTMLElement>(SURFACE_SELECTOR_QUERY)
if (!surface) return null
return SURFACE_SELECTORS.some(
({ selector, space }) =>
surface.matches(selector) && getSurfacePresentationSpace(selector, space) === presentationSpace,
)
? surface
: null
}
function handleSurfaceTransitionRun(event: TransitionEvent) {
if (event.propertyName !== 'transform') return
const surface = resolveTransitionSurface(event.target)
if (!surface) return
transformingSurfaces.add(surface)
surfaceTransformTrackingDeadline = performance.now() + SURFACE_TRANSFORM_TRACKING_MAX_MS
scheduleSurfaceTransformFrame()
}
function handleSurfaceTransitionEnd(event: TransitionEvent) {
if (event.propertyName !== 'transform') return
const surface = resolveTransitionSurface(event.target)
if (!surface) return
transformingSurfaces.delete(surface)
if (transformingSurfaces.size === 0) scheduleSurfaceStabilityUpdate()
}
function syncCoverScale(viewportWidth = window.innerWidth, viewportHeight = window.innerHeight) {
if (!resources) return
@@ -1280,9 +1513,17 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
previousProfile: ReturnType<typeof getGlassOpticalRenderProfile>,
nextProfile: ReturnType<typeof getGlassOpticalRenderProfile>,
) {
const wallpaperUrl = toValue(options.wallpaperUrl)
const resolveTextureSource = (profile: ReturnType<typeof getGlassOpticalRenderProfile>) =>
profile.textureSource === 'auto'
? canUseGlassWallpaperTexture(wallpaperUrl, window.location.href)
? 'wallpaper'
: 'procedural'
: profile.textureSource
return (
previousProfile.textureLimit !== nextProfile.textureLimit ||
previousProfile.textureSource !== nextProfile.textureSource
resolveTextureSource(previousProfile) !== resolveTextureSource(nextProfile)
)
}
@@ -1661,7 +1902,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
scrollSurfaceRefreshPending = false
updateSurfaceUniforms(timestamp, false)
}
if (!interactionAnimating) renderFrame(timestamp)
if (!interactionAnimating) renderFrame(timestamp, false)
if (scrollStableFrameCount < SCROLL_STABLE_TAIL_FRAMES) {
scrollAnimationFrame = requestAnimationFrame(renderScrollFrame)
@@ -1682,17 +1923,6 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
scheduleScrollFrame()
return
}
const timestamp = performance.now()
if (!tracksScrollingSurfaces || scrollSurfaceTimer !== null) return
const elapsed = timestamp - lastScrollSurfaceUpdateAt
const delay = Math.max(0, SCROLL_SURFACE_UPDATE_INTERVAL_MS - elapsed)
scrollSurfaceTimer = window.setTimeout(() => {
scrollSurfaceTimer = null
lastScrollSurfaceUpdateAt = performance.now()
scheduleSurfaceUpdate()
}, delay)
}
function handleScrollEnd() {
@@ -1709,6 +1939,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
cancelScheduledFrame()
cancelScrollFrame()
cancelWallpaperTransitionFrame()
cancelSurfaceTransformFrame()
interactionAnimating = false
}
@@ -1730,16 +1961,22 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
scheduleWallpaperTransition()
}
/** 持续非活动才释放 GPU 资源,短时切换应用继续保留纹理与稳定画面。 */
function scheduleInactiveRendererDisposal(isStillInactive: () => boolean) {
clearBackgroundDisposeTimer()
backgroundDisposeTimer = window.setTimeout(() => {
backgroundDisposeTimer = null
if (!isStillInactive()) return
// 生命周期暂停后仍会复用同一 canvas;只释放 renderer 资源,不主动丢失其 WebGL context。
disposeRenderer(false)
}, APP_ACTIVITY_SUSPEND_DELAY_MS)
}
function handleVisibilityChange() {
if (document.visibilityState === 'hidden') {
pauseRenderer()
clearBackgroundDisposeTimer()
backgroundDisposeTimer = window.setTimeout(() => {
backgroundDisposeTimer = null
if (document.visibilityState !== 'hidden') return
disposeRenderer()
}, APP_ACTIVITY_SUSPEND_DELAY_MS)
scheduleInactiveRendererDisposal(() => document.visibilityState === 'hidden')
return
}
@@ -1747,7 +1984,10 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
}
function handleWindowBlur() {
if (document.visibilityState === 'visible') pauseRenderer()
if (document.visibilityState !== 'visible') return
pauseRenderer()
scheduleInactiveRendererDisposal(() => document.visibilityState === 'visible' && !document.hasFocus())
}
function handleWindowResume() {
@@ -1791,7 +2031,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
surfaceMutationObserver = new MutationObserver(mutations => {
// Vuetify 可能在首个弹层打开时才创建容器,后续变更需要纳入同一个表面生命周期。
observeMutationRoot(document.querySelector('.v-overlay-container'), true)
if (mutationTouchesOpticalSurface(mutations)) scheduleSurfaceUpdate()
if (mutationTouchesOpticalSurface(mutations)) scheduleSurfaceStabilityUpdate()
})
observeMutationRoot(document.querySelector('.app-wrapper'), true)
observeMutationRoot(document.querySelector('.v-overlay-container'), true)
@@ -1804,7 +2044,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
function setupEvents() {
if (options.interactionSource) {
unsubscribeInteractionSource = options.interactionSource.subscribe(handleInteractionEvent)
unsubscribeInteractionSource = options.interactionSource.subscribe(presentationSpace, handleInteractionEvent)
} else {
window.addEventListener('pointermove', handlePointerMove, { passive: true })
window.addEventListener('touchstart', handleTouchStart, { passive: true })
@@ -1813,8 +2053,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
window.addEventListener('touchcancel', handleTouchEnd, { passive: true })
}
window.addEventListener('resize', resizeRenderer, { passive: true })
window.addEventListener('scroll', handleScroll, { capture: true, passive: true })
if (presentationSpace === 'scroll') window.addEventListener('scrollend', handleScrollEnd, { passive: true })
window.addEventListener('transitionrun', handleSurfaceTransitionRun, { capture: true, passive: true })
window.addEventListener('transitionend', handleSurfaceTransitionEnd, { capture: true, passive: true })
window.addEventListener('transitioncancel', handleSurfaceTransitionEnd, { capture: true, passive: true })
if (presentationSpace === 'scroll') {
window.addEventListener('scroll', handleScroll, { capture: true, passive: true })
window.addEventListener('scrollend', handleScrollEnd, { passive: true })
}
options.canvas.value?.addEventListener('webglcontextlost', handleContextLost)
options.canvas.value?.addEventListener('webglcontextrestored', handleContextRestored)
}
@@ -1830,32 +2075,39 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
window.removeEventListener('touchcancel', handleTouchEnd)
}
window.removeEventListener('resize', resizeRenderer)
window.removeEventListener('scroll', handleScroll, true)
if (presentationSpace === 'scroll') window.removeEventListener('scrollend', handleScrollEnd)
window.removeEventListener('transitionrun', handleSurfaceTransitionRun, true)
window.removeEventListener('transitionend', handleSurfaceTransitionEnd, true)
window.removeEventListener('transitioncancel', handleSurfaceTransitionEnd, true)
if (presentationSpace === 'scroll') {
window.removeEventListener('scroll', handleScroll, true)
window.removeEventListener('scrollend', handleScrollEnd)
}
options.canvas.value?.removeEventListener('webglcontextlost', handleContextLost)
options.canvas.value?.removeEventListener('webglcontextrestored', handleContextRestored)
}
function disposeRenderer(releaseContext = true) {
loadVersion += 1
prepareVersion += 1
contextRecoveryPending = false
interactionAnimating = false
cancelScheduledFrame()
cancelScrollFrame()
cancelWallpaperTransitionFrame()
cancelSurfaceTransformFrame()
clearBackgroundDisposeTimer()
if (surfaceUpdateFrame !== null) {
cancelAnimationFrame(surfaceUpdateFrame)
surfaceUpdateFrame = null
}
if (surfaceStabilityFrame !== null) {
cancelAnimationFrame(surfaceStabilityFrame)
surfaceStabilityFrame = null
}
if (surfaceResizeTimer !== null) {
window.clearTimeout(surfaceResizeTimer)
surfaceResizeTimer = null
}
if (scrollSurfaceTimer !== null) {
window.clearTimeout(scrollSurfaceTimer)
scrollSurfaceTimer = null
}
removeEvents()
resizeObserver?.disconnect()
resizeObserver = null
@@ -1868,7 +2120,6 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
outgoingSurface = null
surfaceTransitionStartedAt = 0
wakeDirection = { x: 0, y: -1 }
tracksScrollingSurfaces = false
document.documentElement.removeAttribute('data-glass-wallpaper-loading')
activeTouchIdentifier = null
lastPointerX = window.innerWidth * 0.5
@@ -1889,6 +2140,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
activeTexture = null
activeTextureHeight = 1
activeTextureWidth = 1
preparedWallpaper?.texture.dispose()
preparedWallpaper = null
preparedWallpaperUrl.value = ''
disposeFlowResources()
if (resources) {
@@ -1981,9 +2235,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
resources.uniforms.uHasWallpaperTexture.value = hasWallpaperTexture ? 1 : 0
}
async function loadWallpaper(url: string, version: number) {
if (!resources || !three || !url) return
/** 解码并按当前质量预算缩放壁纸,不改变当前可见纹理。 */
async function createWallpaperTexture(url: string): Promise<PreparedWallpaperTexture | null> {
if (!resources || !three || !url) return null
const profile = getRenderProfile()
const shouldUseProceduralTexture =
profile.textureSource === 'procedural' ||
@@ -1997,22 +2251,12 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
texture.generateMipmaps = false
texture.minFilter = three.LinearFilter
texture.magFilter = three.LinearFilter
activateLoadedTexture(texture, 1, 1, false)
await resources.renderer.compileAsync(resources.scene, resources.camera)
if (version !== loadVersion || !resources) return
updateRendererState('ready')
scheduleFrame()
return
return { hasWallpaperTexture: false, height: 1, texture, width: 1 }
}
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
@@ -2041,7 +2285,57 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
texture.generateMipmaps = false
texture.minFilter = three.LinearFilter
texture.magFilter = three.LinearFilter
activateLoadedTexture(texture, textureWidth, textureHeight, true)
return {
hasWallpaperTexture: true,
height: textureHeight,
texture,
width: textureWidth,
}
}
/** 提前准备下一张纹理;失败不会影响当前活动纹理。 */
async function prepareWallpaper(url: string) {
const version = ++prepareVersion
preparedWallpaper?.texture.dispose()
preparedWallpaper = null
preparedWallpaperUrl.value = ''
if (!url || !resources || url === toValue(options.wallpaperUrl)) {
if (url && activeTexture) preparedWallpaperUrl.value = url
return
}
try {
const prepared = await createWallpaperTexture(url)
if (!prepared) return
if (version !== prepareVersion || !resources) {
prepared.texture.dispose()
return
}
resources.renderer.initTexture(prepared.texture)
preparedWallpaper = prepared
preparedWallpaperUrl.value = url
} catch (error) {
if (version === prepareVersion) console.warn('玻璃光学壁纸预备失败,继续使用当前纹理:', error)
}
}
async function loadWallpaper(url: string, version: number) {
if (!resources || !three || !url) return
const prepared =
preparedWallpaper && preparedWallpaperUrl.value === url ? preparedWallpaper : await createWallpaperTexture(url)
if (!prepared) return
if (preparedWallpaper === prepared) {
preparedWallpaper = null
preparedWallpaperUrl.value = ''
}
if (version !== loadVersion || !resources) {
prepared.texture.dispose()
return
}
activateLoadedTexture(prepared.texture, prepared.width, prepared.height, prepared.hasWallpaperTexture)
await resources.renderer.compileAsync(resources.scene, resources.camera)
if (version !== loadVersion || !resources) return
@@ -2104,6 +2398,11 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
uTextureMix: { value: 1 },
uTintColor: { value: new three.Color(toValue(options.tintColor)) },
uTransparency: { value: getTransparency() },
uTransmissionStrength: {
value: getGlassOpticalTransmissionStrength(
toValue(options.transmissionStrength ?? GLASS_OPTICAL_STRENGTH_DEFAULT),
),
},
uTranslationStrength: { value: getTranslationStrengthScale() },
uTrail: { value: Array.from({ length: 4 }, () => new Vector4Class(0.5, 0.5, 0, 0)) },
uTrailCount: { value: getRenderProfile().trailCount },
@@ -2165,26 +2464,34 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
return
}
await nextTick()
if (!resources) {
await initializeRenderer()
if (resources && wallpaperUrl !== previous?.[1]) {
await refreshWallpaper('玻璃光学壁纸纹理加载失败,已保留当前材质:')
return
}
if (wallpaperUrl !== previous?.[1]) {
await refreshWallpaper('玻璃光学壁纸纹理加载失败,已保留当前材质:')
if (!resources) {
await nextTick()
await initializeRenderer()
}
},
{ immediate: true },
)
watch(
() => toValue(options.pendingWallpaperUrl ?? ''),
pendingWallpaperUrl => {
void prepareWallpaper(pendingWallpaperUrl)
},
{ immediate: true },
)
watch(
() => toValue(options.appearance),
appearance => {
if (!resources) return
resources.uniforms.uAppearance.value = getGlassAppearanceUniformValue(appearance)
scheduleSurfaceUpdate()
scheduleSurfaceStabilityUpdate()
},
)
@@ -2234,8 +2541,16 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
toValue(options.flowStrength ?? getLegacyDynamicStrength()),
toValue(options.reflectionStrength ?? GLASS_OPTICAL_STRENGTH_DEFAULT),
toValue(options.transparencyStrength ?? GLASS_OPTICAL_STRENGTH_DEFAULT),
toValue(options.transmissionStrength ?? GLASS_OPTICAL_STRENGTH_DEFAULT),
] as const,
([translationStrength, deformationStrength, flowStrength, reflectionStrength, transparencyStrength]) => {
([
translationStrength,
deformationStrength,
flowStrength,
reflectionStrength,
transparencyStrength,
transmissionStrength,
]) => {
if (!resources) return
resources.uniforms.uTranslationStrength.value = getGlassOpticalTranslationStrengthScale(translationStrength)
@@ -2248,6 +2563,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
)
resources.uniforms.uReflectionStrength.value = getGlassOpticalReflectionStrengthScale(reflectionStrength)
resources.uniforms.uTransparency.value = getGlassOpticalTransparency(transparencyStrength)
resources.uniforms.uTransmissionStrength.value = getGlassOpticalTransmissionStrength(transmissionStrength)
if (resources.uniforms.uFlowStrength.value <= 0 && interactionAnimating) {
interactionAnimating = false
cancelScheduledFrame()
@@ -2286,6 +2602,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
})
return {
preparedWallpaperUrl,
renderedFrames,
state,
}
+67 -13
View File
@@ -6,7 +6,10 @@ import vuetify from '@/plugins/vuetify'
import {
GLASS_OPTICAL_STRENGTH_DEFAULT,
GLASS_OPTICAL_STRENGTH_MAX,
getGlassOpticalCssTransmissionBrightness,
getGlassOpticalPresetParameters,
getGlassOpticalTransmissionStrength,
getGlassOpticalTransparency,
normalizeGlassOpticalStrength,
type GlassOpticalPreset,
} from '@/utils/glassOptics'
@@ -81,6 +84,8 @@ export interface ThemeCustomizerSettings {
glassQuality: ThemeCustomizerGlassQuality
/** 玻璃亮边、镜面高光与焦散光照强度,范围 0 到 100。 */
glassReflectionStrength: number
/** 玻璃内部壁纸采样的亮度与暗部展开强度,范围 0 到 100。 */
glassTransmissionStrength: number
/** 共享壁纸在表面内的统一采样平移强度,范围 0 到 100。 */
glassTranslationStrength: number
/** 玻璃材质释放真实壁纸的程度,范围 0 到 100。 */
@@ -152,6 +157,7 @@ function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
glassPreset: 'natural',
glassQuality: defaultGlassQuality,
glassReflectionStrength: glassParameters.reflection,
glassTransmissionStrength: glassParameters.transmission,
glassTranslationStrength: glassParameters.translation,
glassTransparencyStrength: glassParameters.transparency,
layout: 'vertical',
@@ -186,7 +192,10 @@ function normalizeThemeCustomizerShadow(shadow: unknown): ThemeCustomizerShadow
}
/** 规范化持久化的主题定制设置并迁移旧值。 */
function normalizeThemeCustomizerSettings(settings: NormalizableThemeCustomizerSettings): ThemeCustomizerSettings {
function normalizeThemeCustomizerSettings(
settings: NormalizableThemeCustomizerSettings,
preserveLegacyTransmission = false,
): ThemeCustomizerSettings {
const fallback = getDefaultThemeCustomizerSettings()
const storedRadius = settings.radius as string | undefined
const radius = storedRadius === 'huge' ? 'extra' : storedRadius
@@ -212,13 +221,25 @@ function normalizeThemeCustomizerSettings(settings: NormalizableThemeCustomizerS
glassQuality: validGlassQualities.includes(settings.glassQuality as ThemeCustomizerGlassQuality)
? (settings.glassQuality as ThemeCustomizerGlassQuality)
: fallback.glassQuality,
glassReflectionStrength: normalizeGlassOpticalStrength(settings.glassReflectionStrength),
glassReflectionStrength:
settings.glassReflectionStrength === undefined
? fallback.glassReflectionStrength
: normalizeGlassOpticalStrength(settings.glassReflectionStrength),
glassTransmissionStrength:
settings.glassTransmissionStrength === undefined
? preserveLegacyTransmission
? GLASS_OPTICAL_STRENGTH_DEFAULT
: fallback.glassTransmissionStrength
: normalizeGlassOpticalStrength(settings.glassTransmissionStrength),
glassTranslationStrength: normalizeMigratedGlassStrength(
settings.glassTranslationStrength,
settings.glassMotionStrength,
fallback.glassTranslationStrength,
),
glassTransparencyStrength: normalizeGlassOpticalStrength(settings.glassTransparencyStrength),
glassTransparencyStrength:
settings.glassTransparencyStrength === undefined
? fallback.glassTransparencyStrength
: normalizeGlassOpticalStrength(settings.glassTransparencyStrength),
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
? (settings.layout as ThemeCustomizerLayout)
: fallback.layout,
@@ -244,7 +265,7 @@ export function readThemeCustomizerSettings(): ThemeCustomizerSettings {
try {
const stored = localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)
const parsed = stored ? JSON.parse(stored) : {}
return normalizeThemeCustomizerSettings({ ...parsed, theme: readStoredThemePreference() })
return normalizeThemeCustomizerSettings({ ...parsed, theme: readStoredThemePreference() }, Boolean(stored))
} catch (error) {
console.warn('读取主题定制设置失败,已使用默认设置:', error)
@@ -262,6 +283,7 @@ type ThemeCustomizerGlassSettings = Pick<
| 'glassPreset'
| 'glassQuality'
| 'glassReflectionStrength'
| 'glassTransmissionStrength'
| 'glassTranslationStrength'
| 'glassTransparencyStrength'
>
@@ -275,6 +297,8 @@ const effectiveGlassSettings = computed(() => ({
glassQuality: glassPreviewState.value?.glassQuality ?? settingsState.value.glassQuality,
glassReflectionStrength:
glassPreviewState.value?.glassReflectionStrength ?? settingsState.value.glassReflectionStrength,
glassTransmissionStrength:
glassPreviewState.value?.glassTransmissionStrength ?? settingsState.value.glassTransmissionStrength,
glassTranslationStrength:
glassPreviewState.value?.glassTranslationStrength ?? settingsState.value.glassTranslationStrength,
glassTransparencyStrength:
@@ -340,6 +364,7 @@ export function applyThemeCustomizerRootSettings(
| 'glassAppearance'
| 'glassQuality'
| 'glassReflectionStrength'
| 'glassTransmissionStrength'
| 'glassTransparencyStrength'
| 'layout'
| 'radius'
@@ -356,9 +381,17 @@ export function applyThemeCustomizerRootSettings(
'--glass-reflection',
String(normalizeGlassOpticalStrength(settings.glassReflectionStrength) / GLASS_OPTICAL_STRENGTH_MAX),
)
document.documentElement.style.setProperty(
'--glass-transmission',
String(getGlassOpticalTransmissionStrength(settings.glassTransmissionStrength)),
)
document.documentElement.style.setProperty(
'--glass-transmission-brightness',
String(getGlassOpticalCssTransmissionBrightness(settings.glassTransmissionStrength)),
)
document.documentElement.style.setProperty(
'--glass-transparency',
String(normalizeGlassOpticalStrength(settings.glassTransparencyStrength) / GLASS_OPTICAL_STRENGTH_MAX),
String(getGlassOpticalTransparency(settings.glassTransparencyStrength)),
)
document.documentElement.setAttribute('data-theme-layout', settings.layout)
document.documentElement.setAttribute('data-theme-radius', settings.radius)
@@ -371,9 +404,17 @@ export function applyThemeCustomizerRootSettings(
'--glass-reflection',
String(normalizeGlassOpticalStrength(settings.glassReflectionStrength) / GLASS_OPTICAL_STRENGTH_MAX),
)
document.body.style.setProperty(
'--glass-transmission',
String(getGlassOpticalTransmissionStrength(settings.glassTransmissionStrength)),
)
document.body.style.setProperty(
'--glass-transmission-brightness',
String(getGlassOpticalCssTransmissionBrightness(settings.glassTransmissionStrength)),
)
document.body.style.setProperty(
'--glass-transparency',
String(normalizeGlassOpticalStrength(settings.glassTransparencyStrength) / GLASS_OPTICAL_STRENGTH_MAX),
String(getGlassOpticalTransparency(settings.glassTransparencyStrength)),
)
document.body.setAttribute('data-theme-layout', settings.layout)
document.body.setAttribute('data-theme-radius', settings.radius)
@@ -457,6 +498,7 @@ export function previewGlassSettings(patch: Partial<ThemeCustomizerGlassSettings
glassPreset: previewSettings.glassPreset,
glassQuality: previewSettings.glassQuality,
glassReflectionStrength: previewSettings.glassReflectionStrength,
glassTransmissionStrength: previewSettings.glassTransmissionStrength,
glassTranslationStrength: previewSettings.glassTranslationStrength,
glassTransparencyStrength: previewSettings.glassTransparencyStrength,
}
@@ -491,15 +533,17 @@ export function cancelGlassPreview() {
/** 判断当前主题定制设置是否仍为默认值。 */
export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettings) {
const glassParameters = getGlassOpticalPresetParameters('clear', defaultGlassQuality, 'natural')
const defaults = normalizeThemeCustomizerSettings({
glassAppearance: 'clear',
glassDeformationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassFlowStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassPreset: 'natural',
glassQuality: defaultGlassQuality,
glassReflectionStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassReflectionStrength: glassParameters.reflection,
glassTransmissionStrength: glassParameters.transmission,
glassTranslationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassTransparencyStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassTransparencyStrength: glassParameters.transparency,
layout: 'vertical',
primaryColor: defaultPrimaryColor,
radius: 'default',
@@ -516,6 +560,7 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
settings.glassPreset === defaults.glassPreset &&
settings.glassQuality === defaults.glassQuality &&
settings.glassReflectionStrength === defaults.glassReflectionStrength &&
settings.glassTransmissionStrength === defaults.glassTransmissionStrength &&
settings.glassTranslationStrength === defaults.glassTranslationStrength &&
settings.glassTransparencyStrength === defaults.glassTransparencyStrength &&
settings.layout === defaults.layout &&
@@ -592,6 +637,11 @@ export function useThemeCustomizer() {
return updateSettings({ glassReflectionStrength })
}
/** 更新玻璃内部壁纸采样的透射亮度。 */
function setGlassTransmissionStrength(glassTransmissionStrength: number) {
return updateSettings({ glassTransmissionStrength })
}
/** 更新玻璃统一采样平移强度。 */
function setGlassTranslationStrength(glassTranslationStrength: number) {
return updateSettings({ glassTranslationStrength })
@@ -634,15 +684,18 @@ export function useThemeCustomizer() {
/** 将主题定制器恢复到默认设置。 */
async function resetSettings() {
const glassParameters = getGlassOpticalPresetParameters('clear', defaultGlassQuality, 'natural')
await updateSettings({
glassAppearance: 'clear',
glassDeformationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassFlowStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassDeformationStrength: glassParameters.deformation,
glassFlowStrength: glassParameters.flow,
glassPreset: 'natural',
glassQuality: defaultGlassQuality,
glassReflectionStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassTranslationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassTransparencyStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassReflectionStrength: glassParameters.reflection,
glassTransmissionStrength: glassParameters.transmission,
glassTranslationStrength: glassParameters.translation,
glassTransparencyStrength: glassParameters.transparency,
layout: 'vertical',
primaryColor: defaultPrimaryColor,
radius: 'default',
@@ -684,6 +737,7 @@ export function useThemeCustomizer() {
setGlassPreset,
setGlassQuality,
setGlassReflectionStrength,
setGlassTransmissionStrength,
setGlassTranslationStrength,
setGlassTransparencyStrength,
setLayout,
+6 -2
View File
@@ -156,22 +156,26 @@ export default {
glassQualityCss: 'Standard',
glassQualityBalanced: 'Balanced',
glassQualityHigh: 'High',
glassQualityCssHint: 'Static CSS material with the lowest resource use; clarity and reflection remain available.',
glassQualityCssHint:
'Static CSS material with the lowest resource use; clarity, transmission, and reflection remain available.',
glassQualityBalancedHint: 'Shared live refraction that balances liquid feedback and GPU use.',
glassQualityHighHint: 'Full temporal flow, diffusion detail, and content protection at a higher GPU cost.',
glassPreset: 'Preset',
glassPresetNatural: 'Natural',
glassPresetGlide: 'Glide',
glassPresetLiquid: 'Liquid',
glassMaterialTuning: 'Material',
glassDynamicTuning: 'Motion',
glassTranslationStrength: 'Sample Translation',
glassDeformationStrength: 'Deformation',
glassFlowStrength: 'Flow Strength',
glassReflectionStrength: 'Reflection Brightness',
glassTransmissionStrength: 'Transmission Brightness',
glassTransparencyStrength: 'Clarity',
glassOpticalStrengthHint:
'Sample Translation moves the shared wallpaper. Deformation controls local bending. Flow Strength controls trail range and inertia.',
glassOpticalStrengthUnavailableHint:
'Standard quality provides clarity and static reflection only. Switch to Balanced or High to adjust sample translation, deformation, and flow.',
'Standard quality keeps all three material controls. Switch to Balanced or High to adjust sample translation, deformation, and flow.',
purple: 'Purple',
custom: 'Custom Style',
transparency: 'Transparency',
+5 -3
View File
@@ -154,21 +154,23 @@ export default {
glassQualityCss: '标准',
glassQualityBalanced: '均衡',
glassQualityHigh: '高质量',
glassQualityCssHint: '静态 CSS 材质,资源占用最低,保留通透与反射,不启用实时流动。',
glassQualityCssHint: '静态 CSS 材质,资源占用最低,保留通透、透射与反射,不启用实时流动。',
glassQualityBalancedHint: '共享实时折射,优先平衡流动反馈与 GPU 占用。',
glassQualityHighHint: '完整时序流场、扩散细节与内容保护,GPU 占用更高。',
glassPreset: '方案',
glassPresetNatural: '自然',
glassPresetGlide: '滑移',
glassPresetLiquid: '液态',
glassMaterialTuning: '材质参数',
glassDynamicTuning: '动态参数',
glassTranslationStrength: '采样平移',
glassDeformationStrength: '形变强度',
glassFlowStrength: '流动强度',
glassReflectionStrength: '反射亮度',
glassTransmissionStrength: '透射亮度',
glassTransparencyStrength: '通透度',
glassOpticalStrengthHint: '采样平移控制整体滑移,形变强度控制局部弯曲,流动强度控制轨迹范围与惯性。',
glassOpticalStrengthUnavailableHint:
'标准质量仅提供通透度和静态反射。切换到均衡或高质量后,可调整采样平移、形变和流动。',
glassOpticalStrengthUnavailableHint: '标准质量保留三项材质参数。切换到均衡或高质量后,可调整采样平移、形变和流动。',
purple: '幻紫',
custom: '附加样式',
transparency: '透明度',
+5 -3
View File
@@ -154,21 +154,23 @@ export default {
glassQualityCss: '標準',
glassQualityBalanced: '均衡',
glassQualityHigh: '高品質',
glassQualityCssHint: '靜態 CSS 材質,資源佔用最低,保留通透與反射,不啟用即時流動。',
glassQualityCssHint: '靜態 CSS 材質,資源佔用最低,保留通透、透射與反射,不啟用即時流動。',
glassQualityBalancedHint: '共享即時折射,優先平衡流動回饋與 GPU 佔用。',
glassQualityHighHint: '完整時序流場、擴散細節與內容保護,GPU 佔用更高。',
glassPreset: '方案',
glassPresetNatural: '自然',
glassPresetGlide: '滑移',
glassPresetLiquid: '液態',
glassMaterialTuning: '材質參數',
glassDynamicTuning: '動態參數',
glassTranslationStrength: '採樣平移',
glassDeformationStrength: '形變強度',
glassFlowStrength: '流動強度',
glassReflectionStrength: '反射亮度',
glassTransmissionStrength: '透射亮度',
glassTransparencyStrength: '通透度',
glassOpticalStrengthHint: '採樣平移控制整體滑移,形變強度控制局部彎曲,流動強度控制軌跡範圍與慣性。',
glassOpticalStrengthUnavailableHint:
'標準品質僅提供通透度和靜態反射。切換到均衡或高品質後,可調整採樣平移、形變和流動。',
glassOpticalStrengthUnavailableHint: '標準品質保留三項材質參數。切換到均衡或高品質後,可調整採樣平移、形變和流動。',
purple: '幻紫',
custom: '附加樣式',
transparency: '透明度',
+64 -307
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { AxiosError } from 'axios'
import type { Component } from 'vue'
import { useTheme } from 'vuetify'
import { useAuthStore, useUserStore } from '@/stores'
import { authState, userState } from '@/stores/types'
import api from '@/api'
@@ -15,8 +16,8 @@ import { buildUserPermissionContext, filterMenusByPermission } from '@/utils/per
import type { ApiResponse } from '@/api/types'
import { loadRemoteComponentFromModule, type RemoteModule } from '@/utils/federationLoader'
import type { MfaMethod } from '@/types/auth'
import { getLoginVisualProfile } from '@/utils/loginPresentation'
const loginRootRef = ref<HTMLElement | null>(null)
type LabTapTarget = 'logo' | 'title'
const LAB_TAP_COUNT = 5
@@ -25,10 +26,8 @@ const labTapSequences: Record<LabTapTarget, { count: number; startedAt: number }
logo: { count: 0, startedAt: 0 },
title: { count: 0, startedAt: 0 },
}
let cardLightFrame: number | null = null
let pendingCardLightX = 0.5
let pendingCardLightY = 0
let pendingCardLightEnergy = 0
const { global: loginTheme } = useTheme()
const loginVisualProfile = computed(() => getLoginVisualProfile(loginTheme.name.value))
/** 在指定区域连续点击五次时进入隐藏的 Logo 实验室。 */
function handleLabTap(target: LabTapTarget) {
@@ -53,40 +52,6 @@ function handleLabTap(target: LabTapTarget) {
})
}
/** 卡片顶部反射与指针共用光源位置,避免通过响应式状态触发页面重渲染。 */
function renderCardLight() {
cardLightFrame = null
const root = loginRootRef.value
root?.style.setProperty('--login-card-light-x', `${(pendingCardLightX * 100).toFixed(2)}%`)
root?.style.setProperty('--login-card-light-y', `${(pendingCardLightY * 100).toFixed(2)}%`)
root?.style.setProperty('--login-card-highlight-alpha', (0.035 + pendingCardLightEnergy * 0.08).toFixed(3))
root?.style.setProperty('--login-card-primary-alpha', (0.018 + pendingCardLightEnergy * 0.04).toFixed(3))
root?.style.setProperty('--login-card-top-prism-alpha', (0.2 + pendingCardLightEnergy * 0.32).toFixed(3))
root?.style.setProperty('--login-refraction-shift-x', `${((0.5 - pendingCardLightX) * 8).toFixed(2)}px`)
root?.style.setProperty('--login-refraction-shift-y', `${((0.5 - pendingCardLightY) * 6).toFixed(2)}px`)
}
/** 根据指针在登录卡片中的位置更新光照目标。 */
function handlePointerLight(event: PointerEvent) {
if (event.pointerType === 'touch') return
const bounds = loginRootRef.value?.querySelector<HTMLElement>('.login-card')?.getBoundingClientRect()
if (!bounds?.width || !bounds.height) return
pendingCardLightX = Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width))
pendingCardLightY = Math.min(1, Math.max(0, (event.clientY - bounds.top) / bounds.height))
const centerDistance = Math.hypot(pendingCardLightX - 0.5, pendingCardLightY - 0.5)
pendingCardLightEnergy = Math.max(0.16, 1 - centerDistance * 1.2)
if (cardLightFrame === null) cardLightFrame = window.requestAnimationFrame(renderCardLight)
}
/** 指针离开登录页后恢复卡片的默认光照位置。 */
function resetPointerLight() {
pendingCardLightX = 0.5
pendingCardLightY = 0
pendingCardLightEnergy = 0
if (cardLightFrame === null) cardLightFrame = window.requestAnimationFrame(renderCardLight)
}
//
const { t, te } = useI18n()
@@ -766,8 +731,6 @@ async function initConditionalPasskey() {
//
onUnmounted(() => {
if (cardLightFrame !== null) window.cancelAnimationFrame(cardLightFrame)
if (conditionalAbortController) {
conditionalAbortController.abort()
conditionalAbortController = null
@@ -781,30 +744,10 @@ onUnmounted(() => {
<template>
<!-- 登录页面容器 -->
<div ref="loginRootRef" class="login-root" @pointermove="handlePointerLight" @pointerleave="resetPointerLight">
<!-- SVG 位移只处理卡片内重复绘制的壁纸不读取跨域图片像素 -->
<svg class="login-liquid-filter-defs" aria-hidden="true" focusable="false">
<defs>
<filter
id="login-liquid-refraction-clear"
x="-8%"
y="-8%"
width="116%"
height="116%"
color-interpolation-filters="sRGB"
>
<feTurbulence type="fractalNoise" baseFrequency="0.009 0.014" numOctaves="2" seed="7" result="noise" />
<feGaussianBlur in="noise" stdDeviation="1.2" result="soft-noise" />
<feDisplacementMap in="SourceGraphic" in2="soft-noise" scale="24" xChannelSelector="R" yChannelSelector="G" />
</filter>
</defs>
</svg>
<!-- 装饰性背景光晕 -->
<div class="login-bg-decor" aria-hidden="true">
<div class="login-orb login-orb--1" />
<div class="login-orb login-orb--2" />
<div class="login-orb login-orb--3" />
<div class="login-root" :data-login-visual-profile="loginVisualProfile">
<!-- 经典主题保留一层低频品牌环境光透明与玻璃 profile 不挂载该装饰 -->
<div v-if="loginVisualProfile === 'classic'" class="login-ambient-light" aria-hidden="true">
<span class="login-ambient-light__wash" />
</div>
<!-- 顶部漂浮语言切换 -->
@@ -841,10 +784,7 @@ onUnmounted(() => {
max-width="24rem"
flat
>
<div class="login-card__glass" aria-hidden="true">
<span class="login-card__wallpaper-refraction" />
<span class="login-card__glass-caustic" />
</div>
<div class="login-card__surface" aria-hidden="true" />
<!-- 卡片头部Logo + 标题 + 欢迎语 -->
<div class="login-head">
@@ -1023,34 +963,10 @@ onUnmounted(() => {
/* ===================== 布局根容器 ===================== */
.login-root {
--login-card-light-x: 50%;
--login-card-light-y: 0%;
--login-card-highlight-alpha: 0.035;
--login-card-primary-alpha: 0.018;
--login-card-top-prism-alpha: 0.2;
--login-refraction-shift-x: 0px;
--login-refraction-shift-y: 3px;
--optical-glass-x: 50%;
--optical-glass-y: 22%;
--optical-glass-blur: 24px;
--optical-glass-saturate: 146%;
--optical-glass-contrast: 104%;
--optical-glass-glow-opacity: 0.36;
--optical-glass-caustic-opacity: 0.28;
--optical-glass-caustic-scale: 0.9;
--optical-glass-pulse-blur: 14px;
--optical-glass-shift-x: 0px;
--optical-glass-shift-y: 0px;
--optical-glass-rotation: -5deg;
--optical-glass-scale: 1;
--optical-glass-caustic-highlight-alpha: 0.16;
--optical-glass-caustic-primary-alpha: 0.12;
position: relative;
display: flex;
box-sizing: border-box;
overflow-x: hidden;
overflow-y: auto;
overflow-x: clip;
flex-direction: column;
align-items: center;
justify-content: center;
@@ -1060,15 +976,8 @@ onUnmounted(() => {
padding-block: calc(env(safe-area-inset-top, 0px) + 24px) calc(env(safe-area-inset-bottom, 0px) + 24px);
}
.login-liquid-filter-defs {
position: absolute;
block-size: 0;
inline-size: 0;
pointer-events: none;
}
/* ===================== 装饰性背景光晕 ===================== */
.login-bg-decor {
/* 经典 profile 只保留一层覆盖视口的低频品牌环境光。 */
.login-ambient-light {
position: absolute;
z-index: 0;
overflow: hidden;
@@ -1076,69 +985,20 @@ onUnmounted(() => {
pointer-events: none;
}
.login-orb {
.login-ambient-light__wash {
position: absolute;
border-radius: 50%;
will-change: transform;
animation: ambient-drift 18s ease-in-out infinite alternate;
background:
radial-gradient(ellipse at 78% 18%, rgba(var(--v-theme-primary), 0.18), transparent 48%),
linear-gradient(145deg, transparent 28%, rgba(var(--v-theme-primary), 0.07) 72%, transparent);
filter: blur(40px);
inset: -12%;
transform: translate3d(1.5%, -1%, 0) scale(1.04);
}
.login-orb--1 {
animation: orb-float-1 12s ease-in-out infinite alternate;
background: rgba(var(--v-theme-primary), 0.35);
block-size: 360px;
filter: blur(60px);
inline-size: 360px;
inset-block-start: -15%;
inset-inline-end: -12%;
}
.login-orb--2 {
animation: orb-float-2 15s ease-in-out infinite alternate;
background: rgba(var(--v-theme-primary), 0.25);
block-size: 300px;
filter: blur(55px);
inline-size: 300px;
inset-block-end: -10%;
inset-inline-start: -15%;
}
.login-orb--3 {
animation: orb-float-3 10s ease-in-out infinite alternate;
background: rgba(var(--v-theme-primary), 0.15);
block-size: 220px;
filter: blur(50px);
inline-size: 220px;
inset-block-start: 50%;
inset-inline-end: 15%;
}
@keyframes orb-float-1 {
0% {
transform: translate(0, 0) scale(1);
}
100% {
transform: translate(-30px, 40px) scale(1.1);
}
}
@keyframes orb-float-2 {
0% {
transform: translate(0, 0) scale(1);
}
100% {
transform: translate(25px, -30px) scale(1.08);
}
}
@keyframes orb-float-3 {
0% {
transform: translate(0, 0) scale(1);
}
100% {
transform: translate(-20px, 20px) scale(0.92);
@keyframes ambient-drift {
to {
transform: translate3d(-1.5%, 1%, 0) scale(1);
}
}
@@ -1181,35 +1041,28 @@ onUnmounted(() => {
border-radius: var(--app-surface-radius, 20px) !important;
box-shadow: 0 20px 54px rgba(var(--app-shadow-rgb, 0, 0, 0), 0.12) !important;
> :not(.login-card__glass) {
> :not(.login-card__surface) {
position: relative;
z-index: 2;
}
/* 顶部迎光棱镜保持单方向,不随指针另建一套光源。 */
&::before {
position: absolute;
z-index: 4;
border-radius: 999px;
background:
radial-gradient(ellipse at center, rgba(255, 255, 255, 0.92), rgba(232, 219, 255, 0.48) 28%, transparent 72%),
linear-gradient(
90deg,
transparent,
rgba(117, 212, 255, 0.16) 30%,
rgba(177, 139, 255, 0.22) 50%,
rgba(255, 105, 210, 0.12) 70%,
transparent
);
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.42) 32%,
rgba(255, 255, 255, 0.7) 50%,
transparent
);
block-size: 1px;
content: '';
filter: drop-shadow(0 1px 3px rgba(var(--v-theme-primary), 0.16));
inset-block-start: 0;
inset-inline-start: clamp(44px, var(--login-card-light-x), calc(100% - 44px));
inline-size: 88px;
mix-blend-mode: screen;
opacity: var(--login-card-top-prism-alpha);
inset-inline: 13% 38%;
opacity: 0.34;
pointer-events: none;
transform: translateX(-50%);
}
}
@@ -1219,76 +1072,38 @@ onUnmounted(() => {
background: transparent !important;
}
.login-card__glass {
.login-card__surface {
position: absolute;
z-index: 0;
overflow: hidden;
border-radius: inherit;
backdrop-filter: blur(var(--optical-glass-blur)) saturate(var(--optical-glass-saturate))
contrast(var(--optical-glass-contrast));
background:
radial-gradient(
180px 150px at var(--login-card-light-x) var(--login-card-light-y),
rgba(255, 255, 255, var(--login-card-highlight-alpha)),
rgba(var(--v-theme-primary), var(--login-card-primary-alpha)) 44%,
transparent 76%
),
linear-gradient(145deg, rgba(255, 255, 255, 0.075), transparent 34%), rgba(var(--v-theme-surface), 0.7);
inset: 0;
pointer-events: none;
transform: translateZ(0);
}
.login-card__glass::before {
position: absolute;
z-index: 2;
border-radius: 44% 56% 61% 39% / 42% 39% 61% 58%;
.login-root[data-login-visual-profile='classic'] .login-card__surface {
backdrop-filter: blur(22px) saturate(118%);
background: rgba(var(--v-theme-surface), 0.76);
}
.login-root[data-login-visual-profile='transparent'] .login-card__surface {
backdrop-filter: blur(var(--optical-glass-blur)) saturate(var(--optical-glass-saturate))
contrast(var(--optical-glass-contrast));
background:
radial-gradient(circle at 36% 32%, rgba(255, 255, 255, 0.2), transparent 24%),
conic-gradient(
from 218deg,
transparent,
rgba(var(--v-theme-primary), 0.13),
transparent 38%,
rgba(255, 255, 255, 0.09),
transparent 72%
);
content: '';
filter: blur(var(--optical-glass-pulse-blur));
inset: -28%;
opacity: var(--optical-glass-glow-opacity);
transform: translate(var(--optical-glass-shift-x), var(--optical-glass-shift-y)) rotate(var(--optical-glass-rotation))
scale(var(--optical-glass-scale));
transition: opacity 220ms ease;
radial-gradient(
180px 150px at 50% 0%,
rgba(255, 255, 255, var(--login-card-highlight-alpha, 0.035)),
rgba(var(--v-theme-primary), var(--login-card-primary-alpha, 0.02)) 44%,
transparent 76%
),
linear-gradient(145deg, rgba(255, 255, 255, 0.075), transparent 34%), rgba(var(--v-theme-surface), 0.7);
}
.login-card__wallpaper-refraction {
position: absolute;
z-index: 0;
display: none;
inset: -2%;
pointer-events: none;
}
.login-card__glass-caustic {
position: absolute;
z-index: 3;
display: block;
border-radius: 50%;
background: radial-gradient(
ellipse,
rgba(255, 255, 255, var(--optical-glass-caustic-highlight-alpha)),
rgba(var(--v-theme-primary), var(--optical-glass-caustic-primary-alpha)) 34%,
transparent 72%
);
filter: blur(var(--optical-glass-pulse-blur));
inset-block-start: calc(var(--optical-glass-y) - 88px);
inset-inline-start: calc(var(--optical-glass-x) - 112px);
block-size: 176px;
inline-size: 224px;
opacity: var(--optical-glass-caustic-opacity);
pointer-events: none;
transform: scale(var(--optical-glass-caustic-scale));
.login-root[data-login-visual-profile='glass'] .login-card__surface {
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.08), transparent 34%),
linear-gradient(rgba(7, 14, 25, 0.04), rgba(7, 14, 25, 0.1));
}
/* ===================== 卡片头部 ===================== */
@@ -1500,11 +1315,9 @@ onUnmounted(() => {
}
}
/* 登录按钮:渐变 + 悬浮抬升 + 光泽 */
/* 登录按钮使用克制的主题色层级,避免在壁纸上形成独立霓虹光源。 */
.login-submit {
position: relative;
overflow: hidden;
box-shadow: 0 8px 24px rgba(var(--v-theme-primary), 0.35);
box-shadow: 0 6px 18px rgba(var(--v-theme-primary), 0.2);
font-weight: 600;
letter-spacing: 0.03em;
transition:
@@ -1512,40 +1325,16 @@ onUnmounted(() => {
box-shadow 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
&:hover {
box-shadow: 0 12px 32px rgba(var(--v-theme-primary), 0.45);
transform: translateY(-2px);
box-shadow: 0 8px 22px rgba(var(--v-theme-primary), 0.26);
transform: translateY(-1px);
}
&:active {
box-shadow: 0 4px 12px rgba(var(--v-theme-primary), 0.3);
transform: translateY(0);
box-shadow: 0 3px 10px rgba(var(--v-theme-primary), 0.18);
transform: scale(0.99);
}
}
/* 登录按钮内部光泽扫描层 */
.login-submit :deep(.v-btn__content)::after {
position: absolute;
z-index: 10;
background: linear-gradient(
105deg,
transparent 35%,
rgba(255, 255, 255, 30%) 43%,
rgba(255, 255, 255, 40%) 50%,
rgba(255, 255, 255, 30%) 57%,
transparent 65%
);
content: '';
inset-block: -50%;
inset-inline: -50%;
pointer-events: none;
transform: translateX(-120%);
transition: transform 700ms cubic-bezier(0.4, 0, 0.2, 1);
}
.login-submit:hover :deep(.v-btn__content)::after {
transform: translateX(120%);
}
/* Passkey 按钮 */
.passkey-btn {
border-radius: 12px;
@@ -1670,27 +1459,13 @@ onUnmounted(() => {
transition: none !important;
}
.login-submit :deep(.v-btn__content)::after {
display: none !important;
}
.login-orb {
.login-ambient-light__wash {
animation: none !important;
}
.login-card__glass::before,
.login-card__glass-caustic {
transform: none !important;
transition: none !important;
}
}
@media (prefers-reduced-transparency: reduce) {
.login-card__wallpaper-refraction {
display: none !important;
}
.login-card__glass,
.login-card__surface,
.native-login-field {
backdrop-filter: none !important;
background: rgb(var(--v-theme-surface)) !important;
@@ -1698,7 +1473,7 @@ onUnmounted(() => {
}
@media (prefers-contrast: more) {
.login-card__glass {
.login-card__surface {
background: rgba(var(--v-theme-surface), 0.94);
}
@@ -1708,11 +1483,7 @@ onUnmounted(() => {
}
@supports not (backdrop-filter: blur(1px)) {
.login-card__wallpaper-refraction {
display: none !important;
}
.login-card__glass,
.login-card__surface,
.native-login-field {
background: rgba(var(--v-theme-surface), 0.96) !important;
}
@@ -1732,20 +1503,6 @@ onUnmounted(() => {
padding: 1.5rem !important;
border-radius: 16px !important;
}
.login-orb--1 {
block-size: 220px;
inline-size: 220px;
}
.login-orb--2 {
block-size: 180px;
inline-size: 180px;
}
.login-orb--3 {
display: none;
}
}
@media (width <= 480px) and (height <= 600px) {
+31 -101
View File
@@ -14,6 +14,7 @@ html[data-theme='glass'] {
--glass-border-hover: rgba(255, 255, 255, calc(0.1 + var(--glass-reflection, 0.5) * 0.2));
--glass-control-prominent-border: rgba(255, 255, 255, 14%);
--glass-highlight: rgba(255, 255, 255, calc(0.06 + var(--glass-reflection, 0.5) * 0.16));
--glass-transmission-brightness: 1;
--glass-sheen: linear-gradient(
135deg,
rgba(255, 255, 255, 9%),
@@ -41,8 +42,8 @@ html[data-theme='glass'] {
--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: none;
--glass-surface-backdrop-filter: brightness(var(--glass-transmission-brightness));
--glass-raised-backdrop-filter: brightness(var(--glass-transmission-brightness));
--glass-control-backdrop-filter: none;
--glass-control-prominent-backdrop-filter: none;
--glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter);
@@ -189,8 +190,10 @@ html[data-theme='glass'] {
--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-surface-backdrop-filter: blur(var(--glass-blur-surface)) saturate(var(--glass-saturate));
--glass-raised-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate));
--glass-surface-backdrop-filter: blur(var(--glass-blur-surface)) saturate(var(--glass-saturate))
brightness(var(--glass-transmission-brightness));
--glass-raised-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate))
brightness(var(--glass-transmission-brightness));
--glass-control-prominent-backdrop-filter: blur(24px) saturate(150%);
--glass-overlay-surface: var(--glass-surface-raised);
--glass-overlay-blur: var(--glass-blur-raised);
@@ -204,7 +207,8 @@ html[data-theme='glass'] {
--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-dashboard-backdrop-filter: blur(var(--glass-blur-surface)) saturate(var(--glass-saturate));
--glass-dashboard-backdrop-filter: blur(var(--glass-blur-surface)) saturate(var(--glass-saturate))
brightness(var(--glass-transmission-brightness));
// 磨砂弹窗只由最外层表面采样背景内部卡片与自定义分组表面保持扁平
.v-overlay__content > :where(.v-card, .v-sheet),
@@ -1011,16 +1015,16 @@ html[data-theme='glass'] {
position: absolute;
}
// 登录卡片的壁纸副本与外层背景共享切换时序避免卡片内部提前显示下一张图片
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[data-glass-renderer-state='ready'] .app-wrapper--login-glass-high .glass-optical-layer {
opacity: 1;
}
// 光学档位下顶栏滚动前后共用同一 filter避免滚动态重新叠加高强度模糊
html[data-theme='glass']:is(
[data-glass-quality='balanced'],
@@ -1079,54 +1083,21 @@ html[data-glass-appearance='frosted']:is(
}
}
// 登录页只有一个高价值表面三档质量共用局部实时材质避免整屏 renderer 重复采样跨域壁纸
// 登录页只保留内容保护表面折射方向反射和唯一动态焦散均由共享 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),
inset 0 0 0 1px rgba(255, 255, 255, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.18),
0 24px 64px rgba(1, 6, 15, 0.22) !important;
}
.login-card__glass {
-webkit-backdrop-filter: var(--glass-surface-backdrop-filter) !important;
backdrop-filter: var(--glass-surface-backdrop-filter) !important;
.login-card__surface {
-webkit-backdrop-filter: brightness(var(--glass-transmission-brightness));
backdrop-filter: brightness(var(--glass-transmission-brightness));
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;
linear-gradient(145deg, rgba(255, 255, 255, 0.08), transparent 34%),
linear-gradient(rgba(7, 14, 25, 0.04), rgba(7, 14, 25, 0.1)) !important;
}
.native-login-field {
@@ -1138,22 +1109,11 @@ html[data-theme='glass'] body[data-theme='glass'] {
}
html[data-theme='glass'][data-glass-appearance='tinted'] body[data-theme='glass'] {
.login-card__glass {
.login-card__surface {
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;
linear-gradient(rgba(var(--v-theme-primary), 0.08), rgba(var(--v-theme-primary), 0.03)),
linear-gradient(rgba(7, 14, 25, 0.16), rgba(7, 14, 25, 0.3)) !important;
}
.native-login-field {
@@ -1168,42 +1128,12 @@ html[data-theme='glass'][data-glass-appearance='frosted'] body[data-theme='glass
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);
.login-card__surface {
-webkit-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate))
brightness(var(--glass-transmission-brightness));
backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate))
brightness(var(--glass-transmission-brightness));
background: var(--glass-sheen), linear-gradient(rgba(8, 15, 27, 0.34), rgba(8, 15, 27, 0.48)) !important;
}
.native-login-field {
+54 -1
View File
@@ -1,4 +1,10 @@
import { commitPreloadedBackgroundRotation, preloadBackgroundRotationImages } from '@/utils/backgroundRotation'
import {
BACKGROUND_ROTATION_GRACE_MS,
commitPreloadedBackgroundRotation,
preloadBackgroundRotationImages,
preloadBackgroundSequence,
shouldAllowBackgroundRotation,
} from '@/utils/backgroundRotation'
import { describe, expect, it, vi } from 'vitest'
function deferred<T>() {
@@ -10,6 +16,18 @@ function deferred<T>() {
return { promise, resolve }
}
describe('background rotation lifecycle', () => {
it('keeps wallpaper rotation independent from paused interaction effects during the bounded grace period', () => {
expect(BACKGROUND_ROTATION_GRACE_MS).toBe(60_000)
expect(shouldAllowBackgroundRotation('active', false, false)).toBe(true)
expect(shouldAllowBackgroundRotation('passive', true, false)).toBe(true)
expect(shouldAllowBackgroundRotation('suspended', true, false)).toBe(true)
expect(shouldAllowBackgroundRotation('passive', false, false)).toBe(false)
expect(shouldAllowBackgroundRotation('idle', false, false)).toBe(false)
expect(shouldAllowBackgroundRotation('active', false, true)).toBe(false)
})
})
describe('commitPreloadedBackgroundRotation', () => {
it('drops a successful preload when the rotation becomes inactive before completion', async () => {
const preload = deferred<boolean>()
@@ -87,3 +105,38 @@ describe('preloadBackgroundRotationImages', () => {
expect(preload).toHaveBeenCalledTimes(2)
})
})
describe('preloadBackgroundSequence', () => {
it('preloads remaining wallpapers sequentially in rotation order', async () => {
const calls: string[] = []
await expect(
preloadBackgroundSequence({
canContinue: () => true,
preload: async url => {
calls.push(url)
return url !== 'two.jpg'
},
urls: ['one.jpg', 'two.jpg', 'three.jpg'],
}),
).resolves.toEqual([true, false, true])
expect(calls).toEqual(['one.jpg', 'two.jpg', 'three.jpg'])
})
it('stops an obsolete queue before starting the next image', async () => {
let active = true
const calls: string[] = []
await preloadBackgroundSequence({
canContinue: () => active,
preload: async url => {
calls.push(url)
active = false
return true
},
urls: ['one.jpg', 'two.jpg'],
})
expect(calls).toEqual(['one.jpg'])
})
})
+16 -7
View File
@@ -4,6 +4,7 @@ import {
GLASS_OPTICAL_REFLECTION_MAX_SCALE,
getAvailableGlassOpticalPresets,
getGlassCoverScale,
getGlassOpticalCssTransmissionBrightness,
getGlassOpticalDecay,
getGlassOpticalBufferSize,
getGlassOpticalMaxRefractionPixels,
@@ -14,6 +15,7 @@ import {
getGlassOpticalReflectionStrengthScale,
getGlassOpticalRenderProfile,
getGlassOpticalTransparency,
getGlassOpticalTransmissionStrength,
getGlassOpticalSurfaceTransitionWeights,
getGlassOpticalWakeDirection,
getGlassOpticalWakeSample,
@@ -66,14 +68,20 @@ describe('glass optics geometry', () => {
expect(getGlassOpticalReflectionStrengthScale(100)).toBeCloseTo(GLASS_OPTICAL_REFLECTION_MAX_SCALE)
expect(getGlassOpticalTransparency(0)).toBeCloseTo(0.28)
expect(getGlassOpticalTransparency(50)).toBeGreaterThan(0.6)
expect(getGlassOpticalTransparency(80)).toBeCloseTo(0.86)
expect(getGlassOpticalTransparency(100)).toBeCloseTo(0.96)
expect(getGlassOpticalTransparency(100) - getGlassOpticalTransparency(80)).toBeLessThan(
getGlassOpticalTransparency(80) - getGlassOpticalTransparency(50),
expect(getGlassOpticalTransparency(70)).toBeCloseTo(0.96)
expect(getGlassOpticalTransparency(100)).toBeCloseTo(1.1)
expect(getGlassOpticalTransparency(100) - getGlassOpticalTransparency(70)).toBeLessThan(
getGlassOpticalTransparency(70) - getGlassOpticalTransparency(50),
)
expect(getGlassOpticalTransmissionStrength(0)).toBe(0)
expect(getGlassOpticalTransmissionStrength(70)).toBe(1)
expect(getGlassOpticalTransmissionStrength(100)).toBe(1.3)
expect(getGlassOpticalCssTransmissionBrightness(0)).toBeCloseTo(0.82)
expect(getGlassOpticalCssTransmissionBrightness(70)).toBeCloseTo(1.28)
expect(getGlassOpticalCssTransmissionBrightness(100)).toBeCloseTo(1.48)
})
it('keeps presets as concrete five-parameter values', () => {
it('keeps presets as concrete six-parameter values', () => {
const natural = getGlassOpticalPresetParameters('clear', 'balanced', 'natural')
const glide = getGlassOpticalPresetParameters('clear', 'balanced', 'glide')
const liquid = getGlassOpticalPresetParameters('frosted', 'high', 'liquid')
@@ -81,9 +89,10 @@ describe('glass optics geometry', () => {
expect(natural).toEqual({
deformation: 50,
flow: 50,
reflection: 50,
reflection: 35,
transmission: 70,
translation: 50,
transparency: 50,
transparency: 70,
})
expect(glide.translation).toBeGreaterThan(glide.deformation)
expect(liquid.deformation).toBeGreaterThan(glide.deformation)
@@ -0,0 +1,74 @@
import {
activateLoginBackgroundLayer,
createLoginBackgroundLayers,
getLoginGlassOpticalSettings,
getLoginVisualProfile,
getLoginWallpaperRequestMode,
prepareLoginBackgroundLayer,
settleLoginBackgroundLayers,
} from '@/utils/loginPresentation'
import { describe, expect, it } from 'vitest'
describe('login presentation', () => {
it('maps resolved themes to mutually exclusive visual profiles', () => {
expect(getLoginVisualProfile('glass')).toBe('glass')
expect(getLoginVisualProfile('transparent')).toBe('transparent')
expect(getLoginVisualProfile('purple')).toBe('classic')
expect(getLoginVisualProfile('light')).toBe('classic')
})
it('forces high-quality capability while preserving all six user strengths', () => {
expect(
getLoginGlassOpticalSettings({
appearance: 'frosted',
deformationStrength: 73,
flowStrength: 68,
preset: 'liquid',
reflectionStrength: 41,
transmissionStrength: 62,
translationStrength: 57,
transparencyStrength: 46,
}),
).toEqual({
appearance: 'frosted',
deformationStrength: 73,
flowStrength: 68,
preset: 'liquid',
quality: 'high',
reflectionStrength: 41,
transmissionStrength: 62,
translationStrength: 57,
transparencyStrength: 46,
})
})
it('requests same-origin wallpapers only for glass', () => {
expect(getLoginWallpaperRequestMode('glass')).toBe('same-origin')
expect(getLoginWallpaperRequestMode('classic')).toBe('default')
expect(getLoginWallpaperRequestMode('transparent')).toBe('default')
})
it('keeps two stable wallpaper slots while their transition roles change', () => {
const initial = createLoginBackgroundLayers('one.jpg')
const prepared = prepareLoginBackgroundLayer(initial, 'two.jpg')
const activated = activateLoginBackgroundLayer(prepared)
const settled = settleLoginBackgroundLayers(activated)
expect(initial).toEqual([
{ key: 'front', role: 'active', url: 'one.jpg' },
{ key: 'back', role: 'standby', url: '' },
])
expect(prepared).toEqual([
{ key: 'front', role: 'active', url: 'one.jpg' },
{ key: 'back', role: 'standby', url: 'two.jpg' },
])
expect(activated).toEqual([
{ key: 'front', role: 'previous', url: 'one.jpg' },
{ key: 'back', role: 'active', url: 'two.jpg' },
])
expect(settled).toEqual([
{ key: 'front', role: 'standby', url: '' },
{ key: 'back', role: 'active', url: 'two.jpg' },
])
})
})
+31
View File
@@ -1,3 +1,13 @@
import type { AppActivityState } from '@/utils/appActivityLifecycle'
/** 壁纸在窗口失焦后继续轮换的最长时间,交互 renderer 仍由应用生命周期独立暂停。 */
export const BACKGROUND_ROTATION_GRACE_MS = 60_000
/** 壁纸轮换只在前台活动或失焦宽限期内运行,系统减少动态效果时始终停止。 */
export function shouldAllowBackgroundRotation(state: AppActivityState, graceActive: boolean, reducedMotion: boolean) {
return !reducedMotion && (state === 'active' || graceActive)
}
interface PreloadedBackgroundRotationOptions {
/** 提交前重新判断当前生命周期和请求版本是否仍允许切换。 */
canCommit: () => boolean
@@ -16,6 +26,15 @@ interface BackgroundRotationImagePreloadOptions {
preload: (url: string) => Promise<boolean>
}
interface BackgroundSequencePreloadOptions {
/** 每张图片完成后重新判断队列是否仍属于当前页面与请求代次。 */
canContinue: () => boolean
/** 按实际轮播顺序排列的待预加载地址。 */
urls: string[]
/** 执行单张图片预加载并返回可用状态。 */
preload: (url: string) => Promise<boolean>
}
/**
*
*/
@@ -40,3 +59,15 @@ export async function preloadBackgroundRotationImages(options: BackgroundRotatio
return results.every(Boolean)
}
/** 当前壁纸稳定后串行预加载剩余轮播项,避免并发争抢首屏带宽。 */
export async function preloadBackgroundSequence(options: BackgroundSequencePreloadOptions) {
const results: boolean[] = []
for (const url of options.urls) {
if (!options.canContinue()) break
results.push(await options.preload(url))
}
return results
}
+63 -34
View File
@@ -8,6 +8,7 @@ export const GLASS_OPTICAL_TRANSLATION_MAX_SCALE = 1.7
export const GLASS_OPTICAL_STRENGTH_DEFAULT = 50
export const GLASS_OPTICAL_STRENGTH_MAX = 100
export const GLASS_OPTICAL_STRENGTH_MIN = 0
export const GLASS_OPTICAL_REFERENCE_STRENGTH = 70
export type GlassAppearance = 'clear' | 'frosted' | 'tinted'
export type GlassOpticalCapability = 'balanced' | 'css' | 'high'
@@ -22,6 +23,8 @@ export interface GlassOpticalParameters {
flow: number
/** 方向高光、迎光棱镜与背光吸收强度。 */
reflection: number
/** 玻璃内部壁纸采样的明暗与暗部展开强度。 */
transmission: number
/** 共享壁纸采样在表面内的统一坐标平移强度。 */
translation: number
/** 壁纸可见度与材质遮罩强度。 */
@@ -127,58 +130,58 @@ const GLASS_OPTICAL_PRESET_MATRIX: Record<
> = {
clear: {
css: {
natural: { deformation: 50, flow: 50, reflection: 50, translation: 50, transparency: 50 },
glide: { deformation: 28, flow: 42, reflection: 42, translation: 72, transparency: 58 },
liquid: { deformation: 72, flow: 78, reflection: 54, translation: 56, transparency: 52 },
natural: { deformation: 50, flow: 50, reflection: 35, transmission: 70, translation: 50, transparency: 70 },
glide: { deformation: 28, flow: 42, reflection: 29, transmission: 72, translation: 72, transparency: 78 },
liquid: { deformation: 72, flow: 78, reflection: 38, transmission: 66, translation: 56, transparency: 74 },
},
balanced: {
natural: { deformation: 50, flow: 50, reflection: 50, translation: 50, transparency: 50 },
glide: { deformation: 30, flow: 44, reflection: 42, translation: 72, transparency: 58 },
liquid: { deformation: 70, flow: 76, reflection: 52, translation: 56, transparency: 52 },
natural: { deformation: 50, flow: 50, reflection: 35, transmission: 70, translation: 50, transparency: 70 },
glide: { deformation: 30, flow: 44, reflection: 29, transmission: 72, translation: 72, transparency: 78 },
liquid: { deformation: 70, flow: 76, reflection: 36, transmission: 66, translation: 56, transparency: 74 },
},
high: {
natural: { deformation: 50, flow: 50, reflection: 46, translation: 50, transparency: 50 },
glide: { deformation: 32, flow: 46, reflection: 40, translation: 74, transparency: 60 },
liquid: { deformation: 74, flow: 80, reflection: 50, translation: 58, transparency: 54 },
natural: { deformation: 50, flow: 50, reflection: 32, transmission: 70, translation: 50, transparency: 70 },
glide: { deformation: 32, flow: 46, reflection: 28, transmission: 74, translation: 74, transparency: 80 },
liquid: { deformation: 74, flow: 80, reflection: 35, transmission: 68, translation: 58, transparency: 76 },
},
},
tinted: {
css: {
natural: { deformation: 50, flow: 50, reflection: 54, translation: 50, transparency: 46 },
glide: { deformation: 30, flow: 42, reflection: 48, translation: 70, transparency: 52 },
liquid: { deformation: 70, flow: 76, reflection: 58, translation: 54, transparency: 48 },
natural: { deformation: 50, flow: 50, reflection: 38, transmission: 68, translation: 50, transparency: 46 },
glide: { deformation: 30, flow: 42, reflection: 34, transmission: 74, translation: 70, transparency: 52 },
liquid: { deformation: 70, flow: 76, reflection: 41, transmission: 64, translation: 54, transparency: 48 },
},
balanced: {
natural: { deformation: 52, flow: 50, reflection: 54, translation: 50, transparency: 46 },
glide: { deformation: 32, flow: 44, reflection: 48, translation: 70, transparency: 52 },
liquid: { deformation: 72, flow: 76, reflection: 56, translation: 56, transparency: 48 },
natural: { deformation: 52, flow: 50, reflection: 38, transmission: 72, translation: 50, transparency: 46 },
glide: { deformation: 32, flow: 44, reflection: 34, transmission: 78, translation: 70, transparency: 52 },
liquid: { deformation: 72, flow: 76, reflection: 39, transmission: 68, translation: 56, transparency: 48 },
},
high: {
natural: { deformation: 52, flow: 50, reflection: 50, translation: 50, transparency: 48 },
glide: { deformation: 34, flow: 46, reflection: 46, translation: 72, transparency: 54 },
liquid: { deformation: 76, flow: 80, reflection: 54, translation: 58, transparency: 50 },
natural: { deformation: 52, flow: 50, reflection: 35, transmission: 76, translation: 50, transparency: 48 },
glide: { deformation: 34, flow: 46, reflection: 32, transmission: 82, translation: 72, transparency: 54 },
liquid: { deformation: 76, flow: 80, reflection: 38, transmission: 72, translation: 58, transparency: 50 },
},
},
frosted: {
css: {
natural: { deformation: 50, flow: 50, reflection: 44, translation: 50, transparency: 42 },
glide: { deformation: 34, flow: 42, reflection: 38, translation: 66, transparency: 46 },
liquid: { deformation: 76, flow: 74, reflection: 48, translation: 50, transparency: 44 },
natural: { deformation: 50, flow: 50, reflection: 31, transmission: 54, translation: 50, transparency: 42 },
glide: { deformation: 34, flow: 42, reflection: 27, transmission: 58, translation: 66, transparency: 46 },
liquid: { deformation: 76, flow: 74, reflection: 34, transmission: 50, translation: 50, transparency: 44 },
},
balanced: {
natural: { deformation: 58, flow: 52, reflection: 44, translation: 48, transparency: 42 },
glide: { deformation: 38, flow: 44, reflection: 38, translation: 68, transparency: 46 },
liquid: { deformation: 78, flow: 76, reflection: 46, translation: 52, transparency: 44 },
natural: { deformation: 58, flow: 52, reflection: 31, transmission: 58, translation: 48, transparency: 42 },
glide: { deformation: 38, flow: 44, reflection: 27, transmission: 62, translation: 68, transparency: 46 },
liquid: { deformation: 78, flow: 76, reflection: 32, transmission: 54, translation: 52, transparency: 44 },
},
high: {
natural: { deformation: 60, flow: 52, reflection: 42, translation: 48, transparency: 44 },
glide: { deformation: 40, flow: 46, reflection: 36, translation: 70, transparency: 48 },
liquid: { deformation: 82, flow: 80, reflection: 44, translation: 54, transparency: 46 },
natural: { deformation: 60, flow: 52, reflection: 29, transmission: 62, translation: 48, transparency: 44 },
glide: { deformation: 40, flow: 46, reflection: 25, transmission: 66, translation: 70, transparency: 48 },
liquid: { deformation: 82, flow: 80, reflection: 31, transmission: 58, translation: 54, transparency: 46 },
},
},
}
/** 返回材质、质量与预置共同确定的个具体参数,调用方可以安全修改返回值。 */
/** 返回材质、质量与预置共同确定的个具体参数,调用方可以安全修改返回值。 */
export function getGlassOpticalPresetParameters(
appearance: GlassAppearance,
quality: GlassOpticalCapability,
@@ -277,18 +280,44 @@ export function getGlassOpticalReflectionStrengthScale(value: unknown) {
return normalizedRatio ** Math.log2(GLASS_OPTICAL_REFLECTION_MAX_SCALE)
}
/** 通透度独立控制真实背景与可读性遮罩的占比,高区间继续增强但逐步收敛。 */
/** 通透度独立控制真实背景与可读性遮罩的占比,高区间仍受 shader 可读性上限保护。 */
export function getGlassOpticalTransparency(value: unknown) {
const normalized = normalizeGlassOpticalStrength(value)
if (normalized <= 80) {
const progress = normalized / 80
if (normalized <= GLASS_OPTICAL_REFERENCE_STRENGTH) {
const progress = normalized / GLASS_OPTICAL_REFERENCE_STRENGTH
return 0.28 + 0.58 * progress ** 1.25
return 0.28 + 0.68 * progress ** 1.25
}
const highRangeProgress = (normalized - 80) / 20
const highRangeProgress =
(normalized - GLASS_OPTICAL_REFERENCE_STRENGTH) / (GLASS_OPTICAL_STRENGTH_MAX - GLASS_OPTICAL_REFERENCE_STRENGTH)
return 0.86 + 0.1 * highRangeProgress ** 1.5
return 0.96 + 0.14 * highRangeProgress ** 1.35
}
/** 标准 CSS 材质使用受控亮度曲线,并在高区间保留有限余量。 */
export function getGlassOpticalCssTransmissionBrightness(value: unknown) {
const transmission = getGlassOpticalTransmissionStrength(value)
if (transmission <= 1) {
return 0.82 + 0.46 * transmission ** 1.1
}
const progress = (transmission - 1) / 0.3
return 1.28 + 0.2 * progress ** 1.2
}
/** 实时 renderer 接收透射响应,并在 shader 中按材质和质量执行高亮保护。 */
export function getGlassOpticalTransmissionStrength(value: unknown) {
const normalized = normalizeGlassOpticalStrength(value)
if (normalized <= GLASS_OPTICAL_REFERENCE_STRENGTH) {
return normalized / GLASS_OPTICAL_REFERENCE_STRENGTH
}
const highRangeProgress =
(normalized - GLASS_OPTICAL_REFERENCE_STRENGTH) / (GLASS_OPTICAL_STRENGTH_MAX - GLASS_OPTICAL_REFERENCE_STRENGTH)
return 1 + 0.3 * highRangeProgress ** 1.2
}
/** 质量决定合成缓冲与纹理上限;路由只切换纹理来源,不改变质量档位。 */
+87
View File
@@ -0,0 +1,87 @@
import type { GlassAppearance, GlassOpticalPreset } from '@/utils/glassOptics'
export type LoginVisualProfile = 'classic' | 'glass' | 'transparent'
export type LoginWallpaperRequestMode = 'default' | 'same-origin'
export interface LoginGlassPreference {
/** 用户选择的玻璃材质。 */
appearance: GlassAppearance
/** 用户保存的局部非均匀形变强度。 */
deformationStrength: number
/** 用户保存的轨迹、尾波与惯性强度。 */
flowStrength: number
/** 用户选择的方案;登录页保持方案身份但只强制高质量能力。 */
preset: GlassOpticalPreset
/** 用户保存的方向反射亮度。 */
reflectionStrength: number
/** 用户保存的玻璃内部透射亮度。 */
transmissionStrength: number
/** 用户保存的统一采样平移强度。 */
translationStrength: number
/** 用户保存的壁纸可见度与材质遮罩强度。 */
transparencyStrength: number
}
export interface LoginBackgroundLayer {
/** 跨登录状态保持稳定的呈现槽位。 */
key: 'back' | 'front'
/** 壁纸在交叉淡化中的职责;standby 槽位可提前准备下一张。 */
role: 'active' | 'previous' | 'standby'
/** 当前槽位显示的壁纸地址。 */
url: string
}
/** 将实际主题解析为互斥的登录视觉 profile。 */
export function getLoginVisualProfile(themeName: string): LoginVisualProfile {
if (themeName === 'glass') return 'glass'
if (themeName === 'transparent') return 'transparent'
return 'classic'
}
/** 玻璃登录页只强制高质量能力,六个用户强度在登录前后保持一致。 */
export function getLoginGlassOpticalSettings(preference: LoginGlassPreference) {
return {
appearance: preference.appearance,
deformationStrength: preference.deformationStrength,
flowStrength: preference.flowStrength,
preset: preference.preset,
quality: 'high' as const,
reflectionStrength: preference.reflectionStrength,
transmissionStrength: preference.transmissionStrength,
translationStrength: preference.translationStrength,
transparencyStrength: preference.transparencyStrength,
}
}
/** 玻璃主题需要同源纹理,其余主题保留现有外链返回行为。 */
export function getLoginWallpaperRequestMode(profile: LoginVisualProfile): LoginWallpaperRequestMode {
return profile === 'glass' ? 'same-origin' : 'default'
}
/** 建立两个始终存在的背景槽位,避免角色变化时复用错误的 DOM 合成层。 */
export function createLoginBackgroundLayers(activeUrl = ''): LoginBackgroundLayer[] {
return [
{ key: 'front', role: 'active', url: activeUrl },
{ key: 'back', role: 'standby', url: '' },
]
}
/** 把下一张壁纸放入隐藏槽位,不改变当前可见层。 */
export function prepareLoginBackgroundLayer(layers: LoginBackgroundLayer[], url: string): LoginBackgroundLayer[] {
return layers.map(layer => (layer.role === 'standby' ? { ...layer, url } : { ...layer }))
}
/** 在壁纸与纹理均就绪后原子交换两个槽位的职责。 */
export function activateLoginBackgroundLayer(layers: LoginBackgroundLayer[]): LoginBackgroundLayer[] {
if (!layers.some(layer => layer.role === 'standby' && layer.url)) return layers
return layers.map(layer => ({
...layer,
role: layer.role === 'active' ? 'previous' : layer.role === 'standby' ? 'active' : layer.role,
}))
}
/** 交叉淡化完成后清空旧图,使该稳定槽位可准备下一次切换。 */
export function settleLoginBackgroundLayers(layers: LoginBackgroundLayer[]): LoginBackgroundLayer[] {
return layers.map(layer => (layer.role === 'previous' ? { ...layer, role: 'standby', url: '' } : { ...layer }))
}