feat(theme): refine glass and transparency settings (#578)

This commit is contained in:
InfinityPacer
2026-07-23 14:06:46 +08:00
committed by GitHub
parent f7ef9dd116
commit 590755169f
8 changed files with 368 additions and 72 deletions

View File

@@ -28,7 +28,7 @@ import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composab
const LOGIN_WALLPAPER_ROUTE = '/login'
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
const WINDOW_BLUR_RENDER_THROTTLE_DELAY_MS = 180_000
const WINDOW_BLUR_RENDER_THROTTLE_DELAY_MS = 60_000
const MEDIA_DENSE_OPTICAL_DEFER_MS = 1_600
// 生效主题

View File

@@ -88,6 +88,16 @@ function updateQuality(value: unknown) {
previewGlassSettings({ glassQuality: option.value })
}
/** 将当前草稿恢复为玻璃主题默认值并立即预览。 */
function resetSettings() {
draftAppearance.value = 'clear'
draftQuality.value = 'css'
previewGlassSettings({
glassAppearance: draftAppearance.value,
glassQuality: draftQuality.value,
})
}
/** 一次提交当前预览,持久化后关闭不会发生视觉回跳。 */
async function saveSettings() {
if (isSaving.value) return
@@ -111,19 +121,16 @@ onScopeDispose(cancelGlassPreview)
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="28rem" scrollable :fullscreen="display.xs.value">
<VCard class="glass-settings-dialog">
<VCardItem class="glass-settings-dialog__header py-3">
<template #prepend>
<VAvatar color="primary" variant="tonal" rounded size="40" class="me-2">
<VIcon icon="mdi-blur-radial" size="22" />
</VAvatar>
</template>
<VDialog v-if="visible" v-model="visible" max-width="30rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-blur-radial" class="me-2" />
{{ t('theme.glassSettings') }}
</VCardTitle>
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<VDivider />
<VCardText class="glass-settings-dialog__body">
<section>
@@ -169,33 +176,20 @@ onScopeDispose(cancelGlassPreview)
</section>
</VCardText>
<VCardActions class="app-dialog-actions glass-settings-dialog__actions">
<VSpacer />
<VBtn
color="primary"
variant="flat"
prepend-icon="mdi-content-save"
class="px-5"
:loading="isSaving"
@click="saveSettings"
>
<VDivider />
<VCardText class="text-center">
<VBtn variant="outlined" prepend-icon="mdi-refresh" class="me-2" @click="resetSettings">
{{ t('common.reset') }}
</VBtn>
<VBtn color="primary" prepend-icon="mdi-content-save" :loading="isSaving" @click="saveSettings">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCardText>
</VCard>
</VDialog>
</template>
<style scoped lang="scss">
.glass-settings-dialog {
overflow: hidden;
max-block-size: calc(100dvh - 2rem);
}
.glass-settings-dialog__header {
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.glass-settings-dialog__body {
display: grid;
align-content: start;
@@ -260,25 +254,4 @@ onScopeDispose(cancelGlassPreview)
background-color: rgba(var(--v-theme-primary), 0.14) !important;
box-shadow: inset 0 0 0 1px rgba(var(--v-theme-primary), 0.38) !important;
}
.glass-settings-dialog__actions {
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
padding-block: 0.875rem;
padding-inline: 1rem;
}
@media (width <= 600px) {
.glass-settings-dialog {
block-size: 100dvh;
max-block-size: 100dvh;
}
.glass-settings-dialog__body {
padding-inline: 18px;
}
.glass-settings-dialog__actions {
padding-block-end: max(0.875rem, calc(env(safe-area-inset-bottom) + 0.75rem));
}
}
</style>

View File

@@ -29,6 +29,7 @@ const emit = defineEmits<{
const visible = computed({
get: () => props.modelValue,
set: value => {
if (!value) cancelTransparencySettings()
emit('update:modelValue', value)
if (!value) emit('close')
},
@@ -38,6 +39,7 @@ const {
adjustTransparency,
backgroundBlur,
backgroundPosterOpacity,
cancelTransparencySettings,
currentPresetLevel,
onBackgroundBlurChange,
onBackgroundPosterOpacityChange,
@@ -45,10 +47,28 @@ const {
onGlassQualityChange,
onOpacityChange,
resetTransparencySettings,
saveTransparencySettings,
transparencyBlur,
transparencyGlassQuality,
transparencyOpacity,
} = useTransparencySettings()
/** 父级控制弹窗生命周期时,未保存关闭仍需恢复持久化设置。 */
watch(
() => props.modelValue,
(value, previous) => {
if (!value && previous) cancelTransparencySettings()
},
)
/** 保存当前透明度预览,随后关闭面板。 */
function saveSettings() {
saveTransparencySettings()
visible.value = false
}
// 弹窗的任意未保存销毁路径都应恢复持久化快照。
onScopeDispose(cancelTransparencySettings)
</script>
<template>
@@ -185,14 +205,11 @@ const {
</VCardText>
<VDivider />
<VCardText class="text-center">
<VBtn @click="resetTransparencySettings" variant="outlined" class="me-2">
<template #prepend>
<VIcon icon="mdi-refresh" />
</template>
{{ t('theme.transparencyReset') }}
<VBtn variant="outlined" prepend-icon="mdi-refresh" class="me-2" @click="resetTransparencySettings">
{{ t('common.reset') }}
</VBtn>
<VBtn @click="visible = false" color="primary">
{{ t('common.confirm') }}
<VBtn color="primary" prepend-icon="mdi-content-save" @click="saveSettings">
{{ t('common.save') }}
</VBtn>
</VCardText>
</VCard>

View File

@@ -2,14 +2,18 @@ import { shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import GlassSettingsDialog from '@/components/dialog/GlassSettingsDialog.vue'
const slotStub = { template: '<div><slot /></div>' }
const mocks = vi.hoisted(() => ({
cancelGlassPreview: vi.fn(),
commitGlassPreview: vi.fn(),
previewGlassSettings: vi.fn(),
}))
vi.mock('@/composables/useThemeCustomizer', () => ({
cancelGlassPreview: mocks.cancelGlassPreview,
commitGlassPreview: vi.fn(),
previewGlassSettings: vi.fn(),
commitGlassPreview: mocks.commitGlassPreview,
previewGlassSettings: mocks.previewGlassSettings,
useThemeCustomizer: () => ({
settings: {
value: {
@@ -25,12 +29,14 @@ vi.mock('vue-i18n', () => ({
}))
vi.mock('vuetify', () => ({
useDisplay: () => ({ xs: { value: false } }),
useDisplay: () => ({ mdAndUp: { value: true } }),
}))
describe('GlassSettingsDialog', () => {
beforeEach(() => {
mocks.cancelGlassPreview.mockClear()
mocks.commitGlassPreview.mockClear()
mocks.previewGlassSettings.mockClear()
})
it('cancels an active preview when the parent closes the dialog', async () => {
@@ -45,4 +51,33 @@ describe('GlassSettingsDialog', () => {
expect(mocks.cancelGlassPreview).toHaveBeenCalledOnce()
})
it('resets the draft to the default glass settings without committing', async () => {
const wrapper = shallowMount(GlassSettingsDialog, {
global: {
stubs: {
VCard: slotStub,
VCardText: slotStub,
VBtn: {
emits: ['click'],
props: ['prependIcon'],
template: '<button :data-icon="prependIcon" @click="$emit(\'click\')"><slot /></button>',
},
VDialog: slotStub,
VDialogCloseBtn: true,
VDivider: true,
},
},
props: { modelValue: true },
})
const resetButton = wrapper.find('[data-icon="mdi-refresh"]')
await resetButton.trigger('click')
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
glassAppearance: 'clear',
glassQuality: 'css',
})
expect(mocks.commitGlassPreview).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,87 @@
import { shallowMount } from '@vue/test-utils'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TransparencySettingsDialog from '@/components/dialog/TransparencySettingsDialog.vue'
const slotStub = { template: '<div><slot /></div>' }
const mocks = vi.hoisted(() => ({
cancelTransparencySettings: vi.fn(),
saveTransparencySettings: vi.fn(),
}))
vi.mock('@/composables/useTransparencySettings', () => ({
useTransparencySettings: () => ({
adjustTransparency: vi.fn(),
backgroundBlur: ref(16),
backgroundPosterOpacity: ref(0),
cancelTransparencySettings: mocks.cancelTransparencySettings,
currentPresetLevel: ref('medium'),
onBackgroundBlurChange: vi.fn(),
onBackgroundPosterOpacityChange: vi.fn(),
onBlurChange: vi.fn(),
onGlassQualityChange: vi.fn(),
onOpacityChange: vi.fn(),
resetTransparencySettings: vi.fn(),
saveTransparencySettings: mocks.saveTransparencySettings,
transparencyBlur: ref(10),
transparencyGlassQuality: ref('lightweight'),
transparencyOpacity: ref(0.3),
}),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('vuetify', () => ({
useDisplay: () => ({ mdAndUp: { value: true } }),
}))
describe('TransparencySettingsDialog', () => {
beforeEach(() => {
mocks.cancelTransparencySettings.mockClear()
mocks.saveTransparencySettings.mockClear()
})
it('cancels an active preview when the parent closes the dialog', async () => {
const wrapper = shallowMount(TransparencySettingsDialog, {
global: {
stubs: { VDialogCloseBtn: true, VSlider: true },
},
props: { modelValue: true },
})
await wrapper.setProps({ modelValue: false })
expect(mocks.cancelTransparencySettings).toHaveBeenCalledOnce()
})
it('persists the preview only from the save action', async () => {
const wrapper = shallowMount(TransparencySettingsDialog, {
global: {
stubs: {
VCard: slotStub,
VCardText: slotStub,
VBtn: {
emits: ['click'],
props: ['prependIcon'],
template: '<button :data-icon="prependIcon" @click="$emit(\'click\')"><slot /></button>',
},
VDialog: slotStub,
VDialogCloseBtn: true,
VDivider: true,
VSlider: true,
},
},
props: { modelValue: true },
})
const saveButton = wrapper.find('[data-icon="mdi-content-save"]')
expect(mocks.saveTransparencySettings).not.toHaveBeenCalled()
await saveButton.trigger('click')
expect(mocks.saveTransparencySettings).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,98 @@
import {
applyTransparencySettings,
cancelTransparencyPreview,
commitTransparencyPreview,
previewTransparencySettings,
readTransparencySettings,
useTransparencySettings,
type TransparencySettings,
} from '@/composables/useTransparencySettings'
import { beforeEach, describe, expect, it } from 'vitest'
const storedSettings: TransparencySettings = {
backgroundBlur: 16,
backgroundPosterOpacity: 0.2,
blur: 10,
glassQuality: 'lightweight',
level: 'medium',
opacity: 0.3,
}
describe('useTransparencySettings preview transaction', () => {
beforeEach(() => {
cancelTransparencyPreview()
localStorage.clear()
applyTransparencySettings(storedSettings)
})
it('previews changes without writing them to local storage', () => {
previewTransparencySettings({
backgroundBlur: 24,
glassQuality: 'realtime',
opacity: 0.7,
})
expect(document.documentElement.style.getPropertyValue('--transparent-opacity')).toBe('0.7')
expect(document.documentElement.style.getPropertyValue('--transparent-background-blur')).toBe('24px')
expect(document.documentElement.classList.contains('transparent-glass-realtime')).toBe(true)
expect(readTransparencySettings()).toEqual(storedSettings)
})
it('commits the latest preview as one persisted state', () => {
previewTransparencySettings({ opacity: 0.7 })
previewTransparencySettings({ backgroundBlur: 24, glassQuality: 'realtime' })
commitTransparencyPreview()
expect(readTransparencySettings()).toEqual({
...storedSettings,
backgroundBlur: 24,
glassQuality: 'realtime',
opacity: 0.7,
})
})
it('restores the persisted appearance when the preview is cancelled', () => {
previewTransparencySettings({
backgroundBlur: 24,
glassQuality: 'realtime',
opacity: 0.7,
})
cancelTransparencyPreview()
expect(document.documentElement.style.getPropertyValue('--transparent-opacity')).toBe('0.3')
expect(document.documentElement.style.getPropertyValue('--transparent-background-blur')).toBe('16px')
expect(document.documentElement.classList.contains('transparent-glass-lightweight')).toBe(true)
expect(readTransparencySettings()).toEqual(storedSettings)
})
it('keeps reset as a preview until the dialog saves it', () => {
applyTransparencySettings({
backgroundBlur: 25,
backgroundPosterOpacity: 0.6,
blur: 20,
glassQuality: 'realtime',
level: '',
opacity: 0.8,
})
const settings = useTransparencySettings()
settings.resetTransparencySettings()
expect(readTransparencySettings()).toMatchObject({
backgroundBlur: 25,
backgroundPosterOpacity: 0.6,
blur: 20,
glassQuality: 'realtime',
opacity: 0.8,
})
settings.saveTransparencySettings()
expect(readTransparencySettings()).toEqual({
...storedSettings,
backgroundPosterOpacity: 0,
})
})
})

View File

@@ -19,6 +19,9 @@ export const transparencyPresets = {
export const TRANSPARENCY_SETTINGS_CHANGED_EVENT = 'transparency-settings-changed'
let transparencyPreviewSnapshot: TransparencySettings | null = null
let transparencyPreviewState: TransparencySettings | null = null
/** 将数值限制在指定范围内。 */
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
@@ -43,9 +46,9 @@ export function readTransparencySettings(): TransparencySettings {
}
}
/** 应用透明主题设置并写入本地存储。 */
export function applyTransparencySettings(settings: TransparencySettings) {
const normalized: TransparencySettings = {
/** 校验透明主题设置并限制数值范围。 */
function normalizeTransparencySettings(settings: TransparencySettings): TransparencySettings {
return {
opacity: Number.isFinite(settings.opacity) ? clamp(settings.opacity, 0, 1) : 0.3,
blur: Number.isFinite(settings.blur) ? clamp(settings.blur, 0, 30) : 10,
backgroundPosterOpacity: Number.isFinite(settings.backgroundPosterOpacity)
@@ -55,6 +58,11 @@ export function applyTransparencySettings(settings: TransparencySettings) {
glassQuality: settings.glassQuality === 'realtime' ? 'realtime' : 'lightweight',
level: settings.level,
}
}
/** 将透明主题设置应用到当前页面,不改变持久化状态。 */
function applyTransparencyAppearance(settings: TransparencySettings) {
const normalized = normalizeTransparencySettings(settings)
const root = document.documentElement
root.style.setProperty('--transparent-opacity', normalized.opacity.toString())
@@ -70,6 +78,17 @@ export function applyTransparencySettings(settings: TransparencySettings) {
root.classList.toggle('transparent-glass-lightweight', normalized.glassQuality === 'lightweight')
root.classList.toggle('transparent-glass-realtime', normalized.glassQuality === 'realtime')
window.dispatchEvent(
new CustomEvent<TransparencySettings>(TRANSPARENCY_SETTINGS_CHANGED_EVENT, { detail: normalized }),
)
return normalized
}
/** 将透明主题设置写入本地存储。 */
function persistTransparencySettings(settings: TransparencySettings) {
const normalized = normalizeTransparencySettings(settings)
localStorage.setItem('transparency-opacity', normalized.opacity.toString())
localStorage.setItem('transparency-blur', normalized.blur.toString())
localStorage.setItem('transparency-background-poster-opacity', normalized.backgroundPosterOpacity.toString())
@@ -77,19 +96,64 @@ export function applyTransparencySettings(settings: TransparencySettings) {
localStorage.setItem('transparency-glass-quality', normalized.glassQuality)
localStorage.setItem('transparency-level', normalized.level)
window.dispatchEvent(new CustomEvent<TransparencySettings>(TRANSPARENCY_SETTINGS_CHANGED_EVENT, { detail: normalized }))
return normalized
}
/** 应用透明主题设置并写入本地存储。 */
export function applyTransparencySettings(settings: TransparencySettings) {
const normalized = persistTransparencySettings(settings)
return applyTransparencyAppearance(normalized)
}
/** 按本地存储中的最新值应用透明主题设置。 */
export function applyStoredTransparencySettings() {
transparencyPreviewSnapshot = null
transparencyPreviewState = null
return applyTransparencySettings(readTransparencySettings())
}
/** 临时预览透明主题设置,关闭设置面板时可恢复已保存快照。 */
export function previewTransparencySettings(patch: Partial<TransparencySettings>) {
if (!transparencyPreviewSnapshot) {
transparencyPreviewSnapshot = normalizeTransparencySettings(readTransparencySettings())
}
transparencyPreviewState = applyTransparencyAppearance(
normalizeTransparencySettings({
...transparencyPreviewSnapshot,
...transparencyPreviewState,
...patch,
}),
)
return transparencyPreviewState
}
/** 将当前透明主题预览作为一次设置事务持久化。 */
export function commitTransparencyPreview() {
const settings = transparencyPreviewState
transparencyPreviewSnapshot = null
transparencyPreviewState = null
return settings ? applyTransparencySettings(settings) : normalizeTransparencySettings(readTransparencySettings())
}
/** 丢弃透明主题预览并恢复打开设置面板前的持久化快照。 */
export function cancelTransparencyPreview() {
const snapshot = transparencyPreviewSnapshot
transparencyPreviewSnapshot = null
transparencyPreviewState = null
return snapshot ? applyTransparencyAppearance(snapshot) : normalizeTransparencySettings(readTransparencySettings())
}
/** 提供透明主题设置的响应式状态和操作方法。 */
export function useTransparencySettings() {
const storedSettings = readTransparencySettings()
const storedSettings = normalizeTransparencySettings(readTransparencySettings())
const transparencyOpacity = ref(storedSettings.opacity)
const transparencyBlur = ref(storedSettings.blur)
const backgroundPosterOpacity = ref(storedSettings.backgroundPosterOpacity)
@@ -110,9 +174,9 @@ export function useTransparencySettings() {
return null
})
/** 同步当前响应式状态到 CSS 变量和本地存储。 */
/** 当前响应式状态同步为未持久化预览。 */
function syncTransparencySettings() {
const normalized = applyTransparencySettings({
const normalized = previewTransparencySettings({
opacity: transparencyOpacity.value,
blur: transparencyBlur.value,
backgroundPosterOpacity: backgroundPosterOpacity.value,
@@ -129,6 +193,30 @@ export function useTransparencySettings() {
transparencyLevel.value = normalized.level
}
/** 将当前预览写入本地存储。 */
function saveTransparencySettings() {
const normalized = commitTransparencyPreview()
syncReactiveState(normalized)
}
/** 丢弃当前预览并恢复持久化设置。 */
function cancelTransparencySettings() {
const normalized = cancelTransparencyPreview()
syncReactiveState(normalized)
}
/** 使用指定设置刷新面板草稿。 */
function syncReactiveState(settings: TransparencySettings) {
transparencyOpacity.value = settings.opacity
transparencyBlur.value = settings.blur
backgroundPosterOpacity.value = settings.backgroundPosterOpacity
backgroundBlur.value = settings.backgroundBlur
transparencyGlassQuality.value = settings.glassQuality
transparencyLevel.value = settings.level
}
/** 按预设级别调整透明度和模糊度。 */
function adjustTransparency(level: string) {
transparencyLevel.value = level
@@ -193,6 +281,7 @@ export function useTransparencySettings() {
adjustTransparency,
backgroundBlur,
backgroundPosterOpacity,
cancelTransparencySettings,
currentPresetLevel,
onBackgroundBlurChange,
onBackgroundPosterOpacityChange,
@@ -200,6 +289,7 @@ export function useTransparencySettings() {
onGlassQualityChange,
onOpacityChange,
resetTransparencySettings,
saveTransparencySettings,
syncTransparencySettings,
transparencyBlur,
transparencyGlassQuality,

View File

@@ -170,10 +170,6 @@ 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-nav-active-background: linear-gradient(270deg, rgb(var(--v-theme-primary)) 0%, rgb(255, 255, 255) 300%);
--glass-nav-active-border: 0 solid transparent;
--glass-nav-active-shadow:
0 4px 14px -4px rgba(3, 7, 18, 34%), 0 4px 8px -4px rgba(3, 7, 18, 24%), 0 4px 8px -4px rgba(3, 7, 18, 16%);
--glass-dashboard-backdrop-filter: blur(var(--glass-blur-surface)) saturate(var(--glass-saturate));
}