mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 16:56:40 +08:00
feat(glass): unify login and app optical presentation (#591)
This commit is contained in:
+212
-49
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '透明度',
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
/** 质量决定合成缓冲与纹理上限;路由只切换纹理来源,不改变质量档位。 */
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
Reference in New Issue
Block a user