diff --git a/README.md b/README.md index 1ee4544..9e00548 100644 --- a/README.md +++ b/README.md @@ -8,19 +8,19 @@ - **偏好持久化**:自动记忆用户选择(localStorage),刷新不丢失 - **系统偏好跟随**:切换系统外观时自动响应 - **Dark Reader 引擎**:自动分析页面 CSS 与 DOM,覆盖 Halo 核心页面和第三方插件页面 -- **侧边栏一键切换**:切换按钮自动出现在侧边栏 UserProfileBanner 上方 -- **设置页面**:在「偏好设置 → 深色模式」中详细选择显示模式 +- **官方外观分组**:设置入口位于 Halo「外观」分组,与主题、菜单、插件同组 +- **设置页面**:在「外观 → 深色模式」中详细选择显示模式 - **零后端依赖**:纯前端实现,不需要额外后端 API ## 安装 1. 从 Releases 下载 `plugin-dark-mode-.jar`。 2. 在 Halo 后台的「插件管理」中上传并安装。 -3. 启用插件后,侧边栏底部会出现深色模式切换按钮。 +3. 启用插件后,进入「外观 → 深色模式」设置页面调整主题。 ## 使用 -安装并启用后,可以直接点击侧边栏按钮在深色和浅色模式之间切换,也可以进入设置页面选择固定模式: +安装并启用后,进入「外观 → 深色模式」设置页面选择显示模式: | 配置项 | 可选值 | 说明 | | -------- | --------- | -------------------- | @@ -33,7 +33,7 @@ - 插件通过 `useDarkMode()` 管理 `light` / `dark` / `auto` 三种状态。 - 深色模式下调用 Dark Reader 的 `enable()`,浅色模式下调用 `disable()`。 - Dark Reader 会持续监听页面 DOM 变化,因此第三方插件动态渲染的内容也能自动转换。 -- 插件自身只保留切换器与设置页所需的最小 UI 变量,不再维护逐页手工 CSS 覆盖。 +- 插件自身只保留设置页所需的最小 UI 变量,不再维护逐页手工 CSS 覆盖。 > 说明:Dark Reader 的样式注入是异步的,刷新瞬间仍可能存在极短闪白;插件通过同步设置 `color-scheme` 缓解,但无法完全消除。 @@ -108,12 +108,9 @@ pnpm build # 生产构建 └── src/ ├── index.ts # definePlugin 入口 ├── darkreader-engine.ts # Dark Reader 通用暗色引擎 - ├── injector.ts # 侧边栏切换器注入 ├── composables/ │ ├── useDarkMode.ts # 主题状态管理(模块级单例) │ └── useSystemPreference.ts # 系统偏好监听 - ├── components/ - │ └── ThemeToggle.vue # 侧边栏切换按钮 ├── views/ │ └── SettingsView.vue # 设置页面 └── styles/ @@ -127,10 +124,16 @@ pnpm build # 生产构建 ./gradlew test ``` -运行时验证脚本位于 `scripts/verify-toggle.py`,用于检查主题切换、localStorage 持久化以及 Dark Reader 注入状态。 +运行时验证脚本位于 `scripts/verify-toggle.py`,用于直接驱动主题状态,并检查 data-halo-theme、localStorage、Dark Reader 注入与 color-scheme 的翻转。 ## 更新日志 +### v1.0.6 + +- 深色模式设置入口移至 Halo 官方「外观」分组 +- 移除侧边栏底部注入的切换按钮,设置页成为唯一入口 +- 更新运行时验证脚本:直接驱动主题状态,不再依赖侧边栏按钮 + ### v1.0.5 - 同步设置 `color-scheme`,缓解深色模式刷新闪烁 diff --git a/docs/fix-sidebar-entry-2026-08-08.md b/docs/fix-sidebar-entry-2026-08-08.md new file mode 100644 index 0000000..7663a32 --- /dev/null +++ b/docs/fix-sidebar-entry-2026-08-08.md @@ -0,0 +1,81 @@ +# 问题交接单:深色模式入口去重 + 菜单分组调整(2026-08-08) + +> 审查窗口产出,**交付开发窗口执行**;审查窗口不修改代码。 +> 用户需求(已确认):① 深色模式设置入口从"偏好设置"移到 Halo 官方"外观"分组;② 移除侧边栏底部注入的切换按钮(与菜单入口重复,用户判定多余)。 +> 前置说明:真实环境取证于 `blog.liuhangyv.top/console`(已登录),见"问题描述"。 + +## 一、问题描述 + +### 问题 1:深色模式存在两个入口,功能重复 + +当前 Halo 后台对"深色模式"有两个入口: + +- **入口 A(侧边栏底部切换按钮)**:插件注入的 `ThemeToggle` 按钮(显示"浅色模式 / 深色模式",点击一键切换)。 +- **入口 B(侧边栏菜单项)**:「偏好设置 → 深色模式」设置页(浅色 / 深色 / 跟随系统三选一)。 + +用户观察到侧边栏底部有两个"切换"类入口,底部注入的按钮闲置不用,判定没有必要,要求移除。 + +**真实环境取证结果**(消除歧义): +- 侧边栏底部**只有 1 个**主题切换按钮:插件注入的 `plugin-dark-mode-toggle`(位于 `sidebar__profile` 上方),工作正常。 +- 用户信息区(`user-profile__actions`)另外两个图标按钮是 Halo 官方「个人资料(跳 /uc)」和「退出登录」——已核对官方源码 `ui/src/layouts/UserProfileBanner.vue`,**不是**主题切换。 +- 因此用户所说的"两个切换按钮" = 入口 A(底部注入按钮)+ 入口 B(菜单项),二者功能重复,保留其一即可。 + +### 问题 2:设置页入口挂在自建分组"偏好设置"下 + +- 现状:菜单 `group` 为字符串 `'偏好设置'`。这不是 Halo 标准分组 key,Halo 找不到对应 i18n 翻译,直接将该字符串作为分组标题显示,于是在侧边栏多出"偏好设置"分组(其中只有"深色模式"一项)。 +- 期望:放入 Halo 官方**「外观」**分组(主题 / 菜单 / 插件所在)。 +- 背景:Halo 2.25 官方分组 key(`halo-dev/halo` `ui/console-src/router/constant.ts`):`dashboard`、`content`、`interface`、`system`、`tool`。其中 **`interface` 即"外观"**(实测该分组内已有主题 / 菜单 / 插件)。 + +## 二、问题在代码中的具体体现 + +| # | 文件 | 位置 | 说明 | +| --- | --- | --- | --- | +| 1 | `ui/src/index.ts` | L5 | `import { injectThemeToggle } from './injector'` | +| 2 | `ui/src/index.ts` | L7-L8 | 模块加载时执行 `injectThemeToggle()` → 注入入口 A | +| 3 | `ui/src/index.ts` | L26 | `group: '偏好设置'` → 自建字面量分组(入口 B 位置错误) | +| 4 | `ui/src/injector.ts` | 全文 | 入口 A 的注入器:`CONTAINER_CLASS = 'plugin-dark-mode-toggle'`(L4)、`TARGET_SELECTOR = '.sidebar__profile'`(L5)、`injectThemeToggle()` 用 `MutationObserver` 等待侧边栏渲染(L13-L31)、`tryMount()` 将容器插入 profile 上方并 `render(ThemeToggle)`(L33-L48) | +| 5 | `ui/src/components/ThemeToggle.vue` | 全文 | 入口 A 的按钮本体(class `theme-toggle`,文案"浅色模式 / 深色模式") | + +**连带影响(改动入口 A 时必须同步处理):** + +| # | 文件 | 位置 | 影响 | +| --- | --- | --- | --- | +| 6 | `scripts/verify-toggle.py` | L40-L43 | 依赖 `.theme-toggle` 定位点击验证,移除按钮后脚本失效 | +| 7 | `README.md` | L11 / L19 / L23 / L111 / L116 / L130 / L138 | 多处描述"侧边栏一键切换 / UserProfileBanner / 注入器" | +| 8 | `CLAUDE.md` / `AGENTS.md` | L54 / L59 / L86-L88 | 架构树与"ThemeToggle 侧边栏注入"章节(gitignored,本地维护) | + +**不涉及**:`useDarkMode.ts`、`darkreader-engine.ts`、`SettingsView.vue` 均无需改动(入口 B 及其状态管理完全独立)。 + +## 三、修改建议(供开发窗口执行) + +### 推荐方案:移分组 + 移除侧边栏注入按钮 + +1. **`ui/src/index.ts`** + - L26:`group: '偏好设置'` → `group: 'interface'`(进入"外观"分组) + - L5:删除 `import { injectThemeToggle } from './injector'` + - L7-L8:删除调用与注释 +2. **删除文件(需用户书面确认,AGENTS.md 约定)** + - `ui/src/injector.ts` + - `ui/src/components/ThemeToggle.vue` +3. **`scripts/verify-toggle.py`** + - 移除 L40-L43 对 `.theme-toggle` 的点击依赖 + - 改为直接驱动状态 + 断言,例如:`localStorage.setItem('halo-dark-mode-theme', 'dark'/'light')` 后断言 `data-halo-theme`、`data-darkreader-mode`、`color-scheme` 翻转;保留现有三向 PASS 判定逻辑 +4. **文档同步** + - `README.md`:功能特性去掉"侧边栏一键切换"、使用说明改为"进入「外观 → 深色模式」"、项目结构删除 injector/ThemeToggle 两行、验证脚本描述更新 + - `CLAUDE.md` / `AGENTS.md`:删除"ThemeToggle 侧边栏注入"章节,更新架构树 +5. **构建与回归** + - `cd ui && pnpm build`、`pnpm type-check`、`pnpm prettier`、`pnpm lint`、`pnpm test:unit` + - 根目录 `gradlew build` + - 部署后跑 `scripts/verify-toggle.py`(改版后) + +### 备选方案:仅移分组(保留侧边栏按钮) + +- 只改 `ui/src/index.ts` L26 为 `'interface'`;验证脚本与文档均不用动。 +- 适合希望保留"一键切换"快捷方式的场景;但无法满足用户"移除多余按钮"的诉求。 + +## 四、注意事项 + +- **删除文件必须先获得用户书面同意**(项目 AGENTS.md 明确"未经书面同意不得删除任何文件");用户本意是"没必要保留入口 A",可据此向用户确认后执行。 +- 移除入口 A 后,设置页(`SettingsView.vue`)成为唯一入口,属预期结果,保留即可。 +- `useDarkMode` 的 localStorage 持久化、storage 跨标签同步、Dark Reader 引擎均与入口 A 无关,不受影响。 +- 版本号建议随本次改动递增(如 1.0.6),并同步 `plugin.yaml`(如需)与 `@since`。 \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 8f3b543..e1a860a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=1.0.5 +version=1.0.6 org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 org.gradle.java.home=D:/settings/Language/Java/jdk-25.0.1 diff --git a/scripts/README.md b/scripts/README.md index b988419..333fa67 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -42,13 +42,15 @@ D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\scan-dark.py --mode D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\verify-toggle.py ``` -部署新版插件后运行,会检查: +部署新版插件后运行,会直接驱动 localStorage 中的主题状态,并检查: - `data-halo-theme` 属性翻转 - localStorage 持久化翻转 - `data-darkreader-mode`(Dark Reader 注入状态)翻转 +- `color-scheme` 翻转 +- 侧边栏已不再注入 `.theme-toggle` / `.plugin-dark-mode-toggle` 按钮 -三项全部 PASS 时返回 0。 +状态翻转全部 PASS 且按钮已移除时返回 0。 ## 输出 @@ -62,4 +64,4 @@ D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\verify-toggle.py 元素误判为界面残留。 - 路由从 Vue Router 动态发现,Halo 或插件升级新增页面后无需维护清单。 - `scan-dark.py` 主要为历史手工 CSS 工作流保留;当前暗色转换由 Dark Reader 负责, - 运行时行为验证以 `verify-toggle.py` 为准。 \ No newline at end of file + 运行时行为验证以 `verify-toggle.py` 为准。 diff --git a/scripts/verify-toggle.py b/scripts/verify-toggle.py index 2035af4..e6217ca 100644 --- a/scripts/verify-toggle.py +++ b/scripts/verify-toggle.py @@ -1,4 +1,4 @@ -"""验证侧边栏主题切换:点击后检查 data-halo-theme、localStorage 与 Dark Reader 模式。""" +"""验证主题切换:直接驱动 localStorage,检查 data-halo-theme、Dark Reader 与 color-scheme 翻转。""" import argparse import pathlib import sys @@ -8,6 +8,7 @@ from playwright.sync_api import sync_playwright DEFAULT_PROFILE = pathlib.Path(__file__).parent / ".browser-profile" BASE = "https://blog.liuhangyv.top" +STORAGE_KEY = "halo-dark-mode-theme" def read_state(page) -> dict: @@ -16,10 +17,40 @@ def read_state(page) -> dict: attr: document.documentElement.getAttribute('data-halo-theme'), darkreader: document.documentElement.getAttribute('data-darkreader-mode'), stored: localStorage.getItem('halo-dark-mode-theme'), + colorScheme: document.documentElement.style.colorScheme, })""" ) +def set_mode(page, mode: str) -> None: + """写入持久化偏好并派发 storage 事件,让当前页面 useDarkMode 单例即时响应。""" + page.evaluate( + """([key, mode]) => { + localStorage.setItem(key, mode) + window.dispatchEvent( + new StorageEvent('storage', { key: key, newValue: mode }) + ) + }""", + [STORAGE_KEY, mode], + ) + + +def wait_mode(page, expect_dark: bool) -> None: + page.wait_for_function( + """(expectDark) => { + const attr = document.documentElement.getAttribute('data-halo-theme') + const darkreader = document.documentElement.getAttribute('data-darkreader-mode') + const scheme = document.documentElement.style.colorScheme + if (expectDark) { + return attr === 'dark' && darkreader === 'dynamic' && scheme === 'dark' + } + return attr === null && darkreader === null && scheme !== 'dark' + }""", + arg=expect_dark, + timeout=15000, + ) + + def main() -> int: parser = argparse.ArgumentParser(description="验证主题切换运行时行为") parser.add_argument("--base", default=BASE) @@ -45,33 +76,54 @@ def main() -> int: ctx.close() return 3 - toggle = page.locator(".theme-toggle") - toggle.wait_for(state="visible", timeout=15000) + toggle_absent = page.locator(".theme-toggle, .plugin-dark-mode-toggle").count() == 0 + print(f"侧边栏注入按钮已移除: {'PASS' if toggle_absent else 'FAIL'}") + + # 先归一化到浅色,保证后续翻转判定与当前会话状态无关 + set_mode(page, "light") + wait_mode(page, expect_dark=False) before = read_state(page) - toggle.click() - time.sleep(1) + + set_mode(page, "dark") + wait_mode(page, expect_dark=True) after = read_state(page) - toggle.click() - time.sleep(1) + + set_mode(page, "light") + wait_mode(page, expect_dark=False) restored = read_state(page) attr_flipped = before["attr"] != after["attr"] and before["attr"] == restored["attr"] - stored_flipped = before["stored"] != after["stored"] and before["stored"] == restored["stored"] + stored_flipped = ( + before["stored"] != after["stored"] and before["stored"] == restored["stored"] + ) darkreader_flipped = ( before["darkreader"] != after["darkreader"] and before["darkreader"] == restored["darkreader"] ) + scheme_flipped = ( + before["colorScheme"] != after["colorScheme"] + and before["colorScheme"] == restored["colorScheme"] + ) print(f"初始: {before}") print(f"切换: {after}") print(f"还原: {restored}") print( f"属性翻转: {'PASS' if attr_flipped else 'FAIL'} " f"存储翻转: {'PASS' if stored_flipped else 'FAIL'} " - f"Dark Reader 翻转: {'PASS' if darkreader_flipped else 'FAIL'}" + f"Dark Reader 翻转: {'PASS' if darkreader_flipped else 'FAIL'} " + f"color-scheme 翻转: {'PASS' if scheme_flipped else 'FAIL'}" ) ctx.close() - return 0 if attr_flipped and stored_flipped and darkreader_flipped else 1 + return ( + 0 + if toggle_absent + and attr_flipped + and stored_flipped + and darkreader_flipped + and scheme_flipped + else 1 + ) if __name__ == "__main__": diff --git a/src/main/java/run/halo/darkmode/DarkModePlugin.java b/src/main/java/run/halo/darkmode/DarkModePlugin.java index b50972a..00bf8f8 100644 --- a/src/main/java/run/halo/darkmode/DarkModePlugin.java +++ b/src/main/java/run/halo/darkmode/DarkModePlugin.java @@ -11,7 +11,7 @@ import run.halo.app.plugin.PluginContext; *

Only one main class extending {@link BasePlugin} is allowed per plugin.

* * @author LHY - * @since 1.0.5 + * @since 1.0.6 */ @Slf4j @Component diff --git a/ui/src/components/ThemeToggle.vue b/ui/src/components/ThemeToggle.vue deleted file mode 100644 index 7a67016..0000000 --- a/ui/src/components/ThemeToggle.vue +++ /dev/null @@ -1,64 +0,0 @@ - - - - - diff --git a/ui/src/index.ts b/ui/src/index.ts index e3f23af..4790559 100644 --- a/ui/src/index.ts +++ b/ui/src/index.ts @@ -3,10 +3,7 @@ import { IconPalette } from '@halo-dev/components' import { markRaw } from 'vue' import './styles/index.css' import { initDarkReaderEngine } from './darkreader-engine' -import { injectThemeToggle } from './injector' -// 在插件加载后将切换器注入到侧边栏,暗色转换完全交给 Dark Reader -injectThemeToggle() initDarkReaderEngine() export default definePlugin({ @@ -23,7 +20,8 @@ export default definePlugin({ searchable: true, menu: { name: '深色模式', - group: '偏好设置', + // Halo 官方「外观」分组 + group: 'interface', icon: markRaw(IconPalette), priority: 50, }, diff --git a/ui/src/injector.ts b/ui/src/injector.ts deleted file mode 100644 index b64b8ef..0000000 --- a/ui/src/injector.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { createVNode, render } from 'vue' -import ThemeToggle from './components/ThemeToggle.vue' - -const CONTAINER_CLASS = 'plugin-dark-mode-toggle' -const TARGET_SELECTOR = '.sidebar__profile' - -let mounted = false - -/** - * 将 ThemeToggle 注入到侧边栏中 UserProfileBanner 上方。 - * 使用 MutationObserver 等待侧边栏 DOM 渲染完成。 - */ -export function injectThemeToggle(): void { - if (mounted) return - - // 先尝试直接查找(侧边栏可能已经渲染) - tryMount() - - // 如果还没渲染,等待 DOM 变化 - if (!mounted) { - const observer = new MutationObserver(() => { - tryMount() - if (mounted) observer.disconnect() - }) - observer.observe(document.body, { childList: true, subtree: true }) - } -} - -function tryMount(): void { - // 避免重复挂载 - if (document.querySelector(`.${CONTAINER_CLASS}`)) { - mounted = true - return - } - - const profileEl = document.querySelector(TARGET_SELECTOR) - if (!profileEl?.parentNode) return - - const container = document.createElement('div') - container.className = CONTAINER_CLASS - profileEl.parentNode.insertBefore(container, profileEl) - - const vnode = createVNode(ThemeToggle) - render(vnode, container) - mounted = true -} diff --git a/ui/src/views/SettingsView.vue b/ui/src/views/SettingsView.vue index dc896fb..8d648a4 100644 --- a/ui/src/views/SettingsView.vue +++ b/ui/src/views/SettingsView.vue @@ -20,7 +20,10 @@ const modeOptions: { value: ThemeMode; label: string; description: string }[] = const optionEls = ref([]) const activeIndex = computed(() => - Math.max(0, modeOptions.findIndex((option) => option.value === theme.value)), + Math.max( + 0, + modeOptions.findIndex((option) => option.value === theme.value), + ), ) function setOptionRef(el: unknown, index: number): void { @@ -79,7 +82,11 @@ function onKeydown(event: KeyboardEvent): void { :class="{ 'is-active': theme === option.value }" :aria-checked="theme === option.value" :tabindex="theme === option.value ? 0 : -1" - :ref="(el: unknown) => { setOptionRef(el, index) }" + :ref=" + (el: unknown) => { + setOptionRef(el, index) + } + " @click="setTheme(option.value)" >
{{ option.label }}
@@ -177,4 +184,4 @@ function onKeydown(event: KeyboardEvent): void { font-size: 0.8125rem; color: var(--halo-text-tertiary); } - \ No newline at end of file +