From b3fb7e1de198939db5b0853a57e1dc1b0b599df9 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 5 Jun 2026 09:06:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E7=AE=A1=E7=90=86=EF=BC=8C=E6=94=AF=E6=8C=81=E5=8A=A8=E6=80=81?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E5=88=87=E6=8D=A2=E5=92=8C=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 152 ++++++++++++++++++++++---- src/App.vue | 66 +++++++++-- src/composables/useThemeCustomizer.ts | 2 +- src/utils/themeManager.ts | 13 +-- src/utils/themePalette.ts | 125 +++++++++++++++++++++ 5 files changed, 320 insertions(+), 38 deletions(-) create mode 100644 src/utils/themePalette.ts diff --git a/index.html b/index.html index c8635b4f..40a417ff 100644 --- a/index.html +++ b/index.html @@ -7,8 +7,10 @@ --initial-loader-color: #9155FD; --initial-loader-height: 100svh; --initial-loader-width: 100vw; + --initial-color-scheme: dark; background: var(--initial-loader-bg, #0E1116); background-color: var(--initial-loader-bg, #0E1116); + color-scheme: dark; "> @@ -99,6 +101,7 @@ body { background: var(--initial-loader-bg, #0E1116); background-color: var(--initial-loader-bg, #0E1116); + color-scheme: var(--initial-color-scheme, dark); } html[data-launch-loading="true"], @@ -282,27 +285,113 @@ } } - // 根据当前主题提前确定启动屏色彩,避免 iOS PWA 从原生启动图切到网页时露出默认白底。 - const launchThemeBackgrounds = { - light: '#F4F5FA', - dark: '#0E1116', - purple: '#28243D', - transparent: '#1C1C1C', - default: '#F4F5FA', + function getLocalStorageValue(key) { + try { + return localStorage.getItem(key) + } catch (e) { + return null + } } - const savedTheme = localStorage.getItem('theme') || 'auto' - const resolvedLaunchTheme = savedTheme === 'auto' - ? (checkPrefersColorSchemeIsDark() ? 'dark' : 'light') - : savedTheme + // 根据当前主题提前确定启动屏色彩,避免 iOS PWA 从原生启动图切到网页时露出默认白底。 + const launchThemePalettes = { + light: { + background: '#F4F5FA', + primary: '#9155FD', + }, + dark: { + background: '#0E1116', + primary: '#6E66ED', + }, + purple: { + background: '#28243D', + primary: '#9155FD', + }, + transparent: { + background: '#1C1C1C', + primary: '#A370F7', + }, + } - let loaderColor = localStorage.getItem('materio-initial-loader-bg') - || launchThemeBackgrounds[resolvedLaunchTheme] - || launchThemeBackgrounds.light + function getSavedThemePreference() { + return getLocalStorageValue('theme') || 'auto' + } - let primaryColor = localStorage.getItem('materio-initial-loader-color') - if (!primaryColor) { - primaryColor = '#9155FD' + function resolveLaunchTheme(themePreference) { + if (themePreference === 'auto') { + return checkPrefersColorSchemeIsDark() ? 'dark' : 'light' + } + + if (themePreference === 'default') { + return 'light' + } + + return launchThemePalettes[themePreference] ? themePreference : 'light' + } + + function getLaunchColorScheme(themeName) { + return ['dark', 'purple', 'transparent'].includes(themeName) ? 'dark' : 'light' + } + + function setMetaContent(selector, content) { + document.querySelectorAll(selector).forEach(meta => { + meta.setAttribute('content', content) + }) + } + + function syncThemeColorMeta(themeColor) { + const metas = document.querySelectorAll('meta[name="theme-color"]') + + if (metas.length) { + metas.forEach(meta => { + meta.setAttribute('content', themeColor) + }) + + return + } + + const meta = document.createElement('meta') + meta.name = 'theme-color' + meta.content = themeColor + document.head.appendChild(meta) + } + + function applyLaunchThemeChrome() { + const themePreference = getSavedThemePreference() + const resolvedLaunchTheme = resolveLaunchTheme(themePreference) + const colorScheme = getLaunchColorScheme(resolvedLaunchTheme) + const palette = launchThemePalettes[resolvedLaunchTheme] || launchThemePalettes.light + + // auto 模式下系统明暗可能已变化,不能复用旧的启动背景缓存。 + const storedLoaderColor = themePreference === 'auto' ? null : getLocalStorageValue('materio-initial-loader-bg') + const loaderColor = storedLoaderColor || palette.background + const primaryColor = getLocalStorageValue('materio-initial-loader-color') || palette.primary + + document.documentElement.setAttribute('data-launch-theme', resolvedLaunchTheme) + document.documentElement.setAttribute('data-theme', resolvedLaunchTheme) + document.documentElement.setAttribute('data-theme-preference', themePreference) + document.documentElement.style.setProperty('--initial-loader-bg', loaderColor) + document.documentElement.style.setProperty('--initial-loader-color', primaryColor) + document.documentElement.style.setProperty('--initial-color-scheme', colorScheme) + document.documentElement.style.backgroundColor = loaderColor + document.documentElement.style.colorScheme = colorScheme + + if (document.body) { + document.body.setAttribute('data-theme', resolvedLaunchTheme) + document.body.setAttribute('data-theme-preference', themePreference) + document.body.style.backgroundColor = loaderColor + document.body.style.colorScheme = colorScheme + } + + setMetaContent('meta[name="color-scheme"]', colorScheme === 'dark' ? 'dark light' : 'light dark') + syncThemeColorMeta(loaderColor) + + return { + background: loaderColor, + colorScheme, + resolvedLaunchTheme, + themePreference, + } } // 在应用脚本接管前锁定一次启动层内容高度,避免 iOS 独立模式首次重算 safe area 时把 logo 顶下去。 @@ -328,20 +417,39 @@ } // 应用主题色彩 - document.documentElement.setAttribute('data-launch-theme', resolvedLaunchTheme) - document.documentElement.style.setProperty('--initial-loader-bg', loaderColor) - document.documentElement.style.setProperty('--initial-loader-color', primaryColor) - document.documentElement.style.backgroundColor = loaderColor + applyLaunchThemeChrome() syncInitialViewport(true) document.addEventListener('DOMContentLoaded', () => { - document.body.style.backgroundColor = loaderColor + applyLaunchThemeChrome() + }) + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + applyLaunchThemeChrome() + } + }) + + window.addEventListener('pageshow', () => { + applyLaunchThemeChrome() + }) + + window.addEventListener('focus', () => { + applyLaunchThemeChrome() }) window.addEventListener('orientationchange', () => { window.setTimeout(() => syncInitialViewport(true), 160) }) + try { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + applyLaunchThemeChrome() + }) + } catch (e) { + // 老浏览器不支持监听系统主题变化时,运行时主题管理器仍会继续接管。 + } + // 状态栏适配 if (window.navigator.standalone) { document.documentElement.style.setProperty('--status-bar-height', '20px') diff --git a/src/App.vue b/src/App.vue index 9f252f1c..f9f926e9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,6 +1,5 @@ diff --git a/src/composables/useThemeCustomizer.ts b/src/composables/useThemeCustomizer.ts index 303633e8..bd5432ee 100644 --- a/src/composables/useThemeCustomizer.ts +++ b/src/composables/useThemeCustomizer.ts @@ -198,7 +198,7 @@ async function applyThemePreference(themePreference: ThemeCustomizerTheme, theme await themeManager.setTheme(themePreference) - // auto 模式传给 themeManager 后会写入 data-theme="auto",这里再同步为实际生效主题。 + // 这里再同步一次实际主题,确保自定义主题色应用后根节点底色也保持最新。 if (currentVersion === themeApplyVersion) { syncThemeAttribute(resolvedTheme) saveLocalTheme(themePreference, themeApi.global) diff --git a/src/utils/themeManager.ts b/src/utils/themeManager.ts index 5af3a111..807cff71 100644 --- a/src/utils/themeManager.ts +++ b/src/utils/themeManager.ts @@ -1,3 +1,5 @@ +import { applyDocumentThemeChrome } from '@/utils/themePalette' + // 主题管理器 - 动态加载主题CSS export interface ThemeConfig { name: string @@ -116,18 +118,13 @@ class ThemeManager { * 应用主题到DOM */ private applyTheme(themeName: string): void { - // 移除之前的主题属性 - document.documentElement.removeAttribute('data-theme') - - // 设置新主题(除了default主题) - if (themeName !== 'default') { - document.documentElement.setAttribute('data-theme', themeName) - } + // auto 是用户偏好,DOM 上必须落到实际主题,避免恢复前台时短暂匹配不到深色样式。 + const { resolvedTheme } = applyDocumentThemeChrome(themeName) this.currentTheme = themeName // 触发主题变更事件 - this.dispatchThemeChangeEvent(themeName) + this.dispatchThemeChangeEvent(resolvedTheme) } /** diff --git a/src/utils/themePalette.ts b/src/utils/themePalette.ts new file mode 100644 index 00000000..4640d5ce --- /dev/null +++ b/src/utils/themePalette.ts @@ -0,0 +1,125 @@ +import { checkPrefersColorSchemeIsDark } from '@/@core/utils' + +export type ThemePreference = 'auto' | 'default' | 'light' | 'dark' | 'purple' | 'transparent' +export type ResolvedThemeName = 'light' | 'dark' | 'purple' | 'transparent' +export type ThemeColorScheme = 'light' | 'dark' + +interface ThemeRootPalette { + background: string + primary: string +} + +interface ApplyDocumentThemeChromeOptions { + background?: string + persistLoaderColors?: boolean + primary?: string + resolvedTheme?: string +} + +export const themeRootPalettes: Record = { + light: { + background: '#F4F5FA', + primary: '#9155FD', + }, + dark: { + background: '#0E1116', + primary: '#6E66ED', + }, + purple: { + background: '#28243D', + primary: '#9155FD', + }, + transparent: { + background: '#1C1C1C', + primary: '#A370F7', + }, +} + +const validResolvedThemes = new Set(Object.keys(themeRootPalettes)) + +function normalizeResolvedThemeName(themeName: string | null | undefined): ResolvedThemeName { + return validResolvedThemes.has(themeName || '') ? (themeName as ResolvedThemeName) : 'light' +} + +export function resolveThemeName(themePreference: string | null | undefined): ResolvedThemeName { + if (themePreference === 'auto') { + return checkPrefersColorSchemeIsDark() ? 'dark' : 'light' + } + + if (themePreference === 'default') { + return 'light' + } + + return normalizeResolvedThemeName(themePreference) +} + +export function getThemeColorScheme(themeName: string | null | undefined): ThemeColorScheme { + return ['dark', 'purple', 'transparent'].includes(themeName || '') ? 'dark' : 'light' +} + +function setMetaContent(selector: string, content: string) { + document.querySelectorAll(selector).forEach(meta => { + meta.content = content + }) +} + +function ensureThemeColorMeta(themeColor: string) { + const metas = document.querySelectorAll('meta[name="theme-color"]') + + if (metas.length) { + metas.forEach(meta => { + meta.content = themeColor + }) + + return + } + + const meta = document.createElement('meta') + meta.name = 'theme-color' + meta.content = themeColor + document.head.appendChild(meta) +} + +/** + * 同步浏览器首帧会使用的根节点底色和系统控件配色。 + * iOS PWA 从后台恢复时可能先绘制 WebView 外壳,再等 Vue 响应式主题更新。 + */ +export function applyDocumentThemeChrome( + themePreference: string | null | undefined, + options: ApplyDocumentThemeChromeOptions = {}, +) { + const resolvedTheme = normalizeResolvedThemeName(options.resolvedTheme || resolveThemeName(themePreference)) + const colorScheme = getThemeColorScheme(resolvedTheme) + const palette = themeRootPalettes[resolvedTheme] + const background = options.background || palette.background + const primary = options.primary || palette.primary + + document.documentElement.setAttribute('data-theme', resolvedTheme) + document.documentElement.setAttribute('data-theme-preference', themePreference || resolvedTheme) + document.documentElement.style.setProperty('--initial-loader-bg', background) + document.documentElement.style.setProperty('--initial-loader-color', primary) + document.documentElement.style.backgroundColor = background + document.documentElement.style.colorScheme = colorScheme + + if (document.body) { + document.body.setAttribute('data-theme', resolvedTheme) + document.body.setAttribute('data-theme-preference', themePreference || resolvedTheme) + document.body.style.backgroundColor = background + document.body.style.colorScheme = colorScheme + } + + setMetaContent('meta[name="color-scheme"]', colorScheme === 'dark' ? 'dark light' : 'light dark') + ensureThemeColorMeta(background) + + if (options.persistLoaderColors) { + localStorage.setItem('materio-initial-loader-bg', background) + localStorage.setItem('materio-initial-loader-color', primary) + } + + return { + background, + colorScheme, + primary, + resolvedTheme, + } +}