From 10495669377bb6dbbf7e207ecd069c6c1db73f68 Mon Sep 17 00:00:00 2001 From: rbetree Date: Sat, 17 Jan 2026 00:59:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(theme):=20=E6=96=B0=E5=A2=9E=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E6=A8=A1=E5=BC=8F=E9=85=8D=E7=BD=AE=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=B7=9F=E9=9A=8F=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 site.theme.mode 配置(dark/light/system) - 支持 prefers-color-scheme 系统主题跟随 - 用户手动切换后停止跟随并持久化 ref #36 --- config/README.md | 13 ++-- config/_default/site.yml | 7 ++ src/runtime/app/ui.js | 125 +++++++++++++++++++++++++++------- templates/layouts/default.hbs | 22 +++++- 4 files changed, 138 insertions(+), 29 deletions(-) diff --git a/config/README.md b/config/README.md index 426766c..8fefc0d 100644 --- a/config/README.md +++ b/config/README.md @@ -137,18 +137,23 @@ MeNav 配置系统采用“完全替换”策略(不合并),按以下优 - 可选 `fonts.preload: true`:用 `preload + onload` 的方式非阻塞加载外链字体 CSS(更利于首屏性能) - 首页副标题(渐变发光样式)使用全站基础字体(跟随 `fonts` 配置) -5. **顶部欢迎信息与社交链接** +5. **主题(默认明暗模式)** + - `theme.mode: dark | light | system` + - `dark/light`:首屏默认主题;用户点击按钮切换后会写入 localStorage 并覆盖该默认值 + - `system`:跟随系统 `prefers-color-scheme`;用户手动切换后同样会写入 localStorage 并停止跟随 + +6. **顶部欢迎信息与社交链接** - `profile`:首页顶部欢迎信息 - `social`:侧边栏底部社交链接 - `profile.title` / `profile.subtitle`:分别对应首页顶部主标题与副标题 -6. **导航** +7. **导航** - `navigation[]`:页面入口列表,`id` 需唯一,并与 `pages/.yml` 对应(例如 `id: common` 对应 `pages/common.yml`) - 默认首页由 `navigation` 数组顺序决定:**第一项即为首页(默认打开页)**,不再使用 `active` 字段 - 图标使用 Font Awesome 类名字符串(例如 `fas fa-home`、`fab fa-github`) - 导航显示顺序与数组顺序一致,可通过调整数组顺序改变导航顺序 -7. **RSS(articles Phase 2)** +8. **RSS(articles Phase 2)** - `rss.*`:仅用于 `npm run sync-articles`(联网抓取 RSS/Atom 并写入缓存) - `npm run build` 默认不联网;无缓存时 `articles` 页面会回退到 Phase 1 的站点入口展示 - articles 页面会按 `articles.yml` 的分类进行聚合展示:某分类下配置的来源站点,其文章会显示在该分类下 @@ -156,7 +161,7 @@ MeNav 配置系统采用“完全替换”策略(不合并),按以下优 - 默认配置已将 `rss.cacheDir` 设为 `dev`(仓库默认 gitignore),避免误提交缓存文件;可按需改为自定义目录 - GitHub Pages 部署工作流会在构建前自动执行 `npm run sync-articles`,并支持定时触发(默认每天 UTC 02:00;可在 `.github/workflows/deploy.yml` 调整) -8. **GitHub(projects 热力图,可选)** +9. **GitHub(projects 热力图,可选)** - `github.username`:你的 GitHub 用户名(用于 projects 页面标题栏右侧贡献热力图) - `github.heatmapColor`:热力图主题色(不带 `#`,例如 `339af0`) - `github.cacheDir`:projects 仓库元信息缓存目录(默认 `dev`,仓库默认 gitignore) diff --git a/config/_default/site.yml b/config/_default/site.yml index cfa0658..e62e3fc 100644 --- a/config/_default/site.yml +++ b/config/_default/site.yml @@ -34,6 +34,13 @@ security: # 示例: # allowedSchemes: [http, https, mailto, tel, obsidian, vscode] +# 主题设置:默认明暗模式(可选) +# - mode: dark | light | system +# - dark/light:首屏默认主题;用户点击按钮切换后会写入 localStorage 并覆盖该默认值 +# - system:跟随系统 prefers-color-scheme;用户手动切换后同样会写入 localStorage 并停止跟随 +theme: + mode: dark # 可选: dark | light | system(默认 dark) + # 字体设置:全站基础字体 # - source: css | google | system # - css: 通过 cssUrl 引入第三方字体 CSS diff --git a/src/runtime/app/ui.js b/src/runtime/app/ui.js index 4aed01a..ffa4dfb 100644 --- a/src/runtime/app/ui.js +++ b/src/runtime/app/ui.js @@ -16,11 +16,72 @@ module.exports = function initUi(state, dom) { // 移除预加载类,允许 CSS 过渡效果 document.documentElement.classList.remove('preload'); - // 应用从 localStorage 读取的主题设置(预加载阶段已写入 class) + let systemThemeMql = null; + let systemThemeListener = null; + + function setTheme(isLight) { + const nextIsLight = Boolean(isLight); + state.isLightTheme = nextIsLight; + document.body.classList.toggle('light-theme', nextIsLight); + + if (nextIsLight) { + themeIcon.classList.remove('fa-moon'); + themeIcon.classList.add('fa-sun'); + } else { + themeIcon.classList.remove('fa-sun'); + themeIcon.classList.add('fa-moon'); + } + } + + function stopSystemThemeFollow() { + if (systemThemeMql && systemThemeListener) { + if (typeof systemThemeMql.removeEventListener === 'function') { + systemThemeMql.removeEventListener('change', systemThemeListener); + } else if (typeof systemThemeMql.removeListener === 'function') { + systemThemeMql.removeListener(systemThemeListener); + } + } + systemThemeMql = null; + systemThemeListener = null; + } + + function startSystemThemeFollow() { + stopSystemThemeFollow(); + + try { + systemThemeMql = window.matchMedia('(prefers-color-scheme: light)'); + } catch (e) { + systemThemeMql = null; + } + if (!systemThemeMql) return; + + systemThemeListener = (event) => { + const savedTheme = localStorage.getItem('theme'); + if (savedTheme === 'light' || savedTheme === 'dark') { + stopSystemThemeFollow(); + return; + } + setTheme(Boolean(event && event.matches)); + }; + + if (typeof systemThemeMql.addEventListener === 'function') { + systemThemeMql.addEventListener('change', systemThemeListener); + } else if (typeof systemThemeMql.addListener === 'function') { + systemThemeMql.addListener(systemThemeListener); + } + } + + function getThemeMode() { + const raw = document.documentElement.getAttribute('data-theme-mode'); + return raw ? String(raw).trim().toLowerCase() : 'dark'; + } + + // 应用预加载阶段确定的主题(localStorage / site.theme.mode) if (document.documentElement.classList.contains('theme-preload')) { document.documentElement.classList.remove('theme-preload'); - document.body.classList.add('light-theme'); - state.isLightTheme = true; + setTheme(true); + } else { + setTheme(false); } // 应用从 localStorage 读取的侧边栏状态(预加载阶段已写入 class) @@ -71,37 +132,53 @@ module.exports = function initUi(state, dom) { // 主题切换功能 function toggleTheme() { - state.isLightTheme = !state.isLightTheme; - document.body.classList.toggle('light-theme', state.isLightTheme); + setTheme(!state.isLightTheme); - // 更新图标 - if (state.isLightTheme) { - themeIcon.classList.remove('fa-moon'); - themeIcon.classList.add('fa-sun'); - } else { - themeIcon.classList.remove('fa-sun'); - themeIcon.classList.add('fa-moon'); - } - - // 保存主题偏好到 localStorage + // 用户手动切换后:写入 localStorage,并停止 system 跟随 localStorage.setItem('theme', state.isLightTheme ? 'light' : 'dark'); + stopSystemThemeFollow(); } // 初始化主题 - 已在页面加载前处理,此处仅完成图标状态初始化等次要任务 function initTheme() { // 从 localStorage 获取主题偏好 const savedTheme = localStorage.getItem('theme'); - - // 更新图标状态以匹配当前主题 if (savedTheme === 'light') { - state.isLightTheme = true; - themeIcon.classList.remove('fa-moon'); - themeIcon.classList.add('fa-sun'); - } else { - state.isLightTheme = false; - themeIcon.classList.remove('fa-sun'); - themeIcon.classList.add('fa-moon'); + stopSystemThemeFollow(); + setTheme(true); + return; } + if (savedTheme === 'dark') { + stopSystemThemeFollow(); + setTheme(false); + return; + } + + // 未写入 localStorage:按 site.theme.mode 决定默认值 + const mode = getThemeMode(); + + if (mode === 'light') { + stopSystemThemeFollow(); + setTheme(true); + return; + } + + if (mode === 'system') { + let shouldUseLight = false; + try { + shouldUseLight = + window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches; + } catch (e) { + shouldUseLight = false; + } + setTheme(shouldUseLight); + startSystemThemeFollow(); + return; + } + + // 默认 dark + stopSystemThemeFollow(); + setTheme(false); } // 移动端菜单切换 diff --git a/templates/layouts/default.hbs b/templates/layouts/default.hbs index b2c40a4..d34f509 100644 --- a/templates/layouts/default.hbs +++ b/templates/layouts/default.hbs @@ -1,5 +1,5 @@ - + @@ -18,7 +18,26 @@ (function() { // 读取并应用主题设置 var savedTheme = localStorage.getItem('theme'); + var defaultThemeMode = document.documentElement.getAttribute('data-theme-mode') || 'dark'; + defaultThemeMode = String(defaultThemeMode).trim().toLowerCase(); + var shouldUseLight = false; + if (savedTheme === 'light') { + shouldUseLight = true; + } else if (savedTheme === 'dark') { + shouldUseLight = false; + } else if (defaultThemeMode === 'light') { + shouldUseLight = true; + } else if (defaultThemeMode === 'system') { + try { + shouldUseLight = + window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches; + } catch (e) { + shouldUseLight = false; + } + } + + if (shouldUseLight) { document.documentElement.classList.add('theme-preload'); } @@ -75,6 +94,7 @@ {{{navigation}}} {{/if}} +