diff --git a/src/App.vue b/src/App.vue
index 3506e848..8b8c558d 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -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
// 生效主题
diff --git a/src/components/dialog/GlassSettingsDialog.vue b/src/components/dialog/GlassSettingsDialog.vue
index 4f252f26..09ee8546 100644
--- a/src/components/dialog/GlassSettingsDialog.vue
+++ b/src/components/dialog/GlassSettingsDialog.vue
@@ -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)
-
-
-
diff --git a/src/components/dialog/TransparencySettingsDialog.vue b/src/components/dialog/TransparencySettingsDialog.vue
index d1a9bcd9..61278a02 100644
--- a/src/components/dialog/TransparencySettingsDialog.vue
+++ b/src/components/dialog/TransparencySettingsDialog.vue
@@ -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)
@@ -185,14 +205,11 @@ const {
-
-
-
-
- {{ t('theme.transparencyReset') }}
+
+ {{ t('common.reset') }}
-
- {{ t('common.confirm') }}
+
+ {{ t('common.save') }}
diff --git a/src/components/dialog/__tests__/GlassSettingsDialog.spec.ts b/src/components/dialog/__tests__/GlassSettingsDialog.spec.ts
index 3ff6c04e..fcc78e42 100644
--- a/src/components/dialog/__tests__/GlassSettingsDialog.spec.ts
+++ b/src/components/dialog/__tests__/GlassSettingsDialog.spec.ts
@@ -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: '
' }
+
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: '',
+ },
+ 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()
+ })
})
diff --git a/src/components/dialog/__tests__/TransparencySettingsDialog.spec.ts b/src/components/dialog/__tests__/TransparencySettingsDialog.spec.ts
new file mode 100644
index 00000000..18877f66
--- /dev/null
+++ b/src/components/dialog/__tests__/TransparencySettingsDialog.spec.ts
@@ -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: '
' }
+
+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: '',
+ },
+ 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()
+ })
+})
diff --git a/src/composables/__tests__/useTransparencySettings.spec.ts b/src/composables/__tests__/useTransparencySettings.spec.ts
new file mode 100644
index 00000000..d1413bf7
--- /dev/null
+++ b/src/composables/__tests__/useTransparencySettings.spec.ts
@@ -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,
+ })
+ })
+})
diff --git a/src/composables/useTransparencySettings.ts b/src/composables/useTransparencySettings.ts
index 562dcb0d..852e335c 100644
--- a/src/composables/useTransparencySettings.ts
+++ b/src/composables/useTransparencySettings.ts
@@ -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(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(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) {
+ 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,
diff --git a/src/styles/themes/glass.scss b/src/styles/themes/glass.scss
index 59b86cd9..f1052d5d 100644
--- a/src/styles/themes/glass.scss
+++ b/src/styles/themes/glass.scss
@@ -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));
}