fix: 深色模式入口移至外观分组并移除侧边栏按钮(1.0.6)

- 设置入口 group 改为官方 interface(外观)分组
- 删除侧边栏 ThemeToggle 注入器及组件,设置页成为唯一入口
- verify-toggle.py 改为直接驱动主题状态并校验翻转
- 同步 README 与脚本文档,版本升级 1.0.6
This commit is contained in:
2026-08-08 19:25:27 +08:00
parent 5cbce86e03
commit 394613f516
10 changed files with 174 additions and 141 deletions
+12 -9
View File
@@ -8,19 +8,19 @@
- **偏好持久化**:自动记忆用户选择(localStorage),刷新不丢失
- **系统偏好跟随**:切换系统外观时自动响应
- **Dark Reader 引擎**:自动分析页面 CSS 与 DOM,覆盖 Halo 核心页面和第三方插件页面
- **侧边栏一键切换**:切换按钮自动出现在侧边栏 UserProfileBanner 上方
- **设置页面**:在「偏好设置 → 深色模式」中详细选择显示模式
- **官方外观分组**:设置入口位于 Halo「外观」分组,与主题、菜单、插件同组
- **设置页面**:在「外观 → 深色模式」中详细选择显示模式
- **零后端依赖**:纯前端实现,不需要额外后端 API
## 安装
1. 从 Releases 下载 `plugin-dark-mode-<version>.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、localStorageDark Reader 注入与 color-scheme 的翻转
## 更新日志
### v1.0.6
- 深色模式设置入口移至 Halo 官方「外观」分组
- 移除侧边栏底部注入的切换按钮,设置页成为唯一入口
- 更新运行时验证脚本:直接驱动主题状态,不再依赖侧边栏按钮
### v1.0.5
- 同步设置 `color-scheme`,缓解深色模式刷新闪烁
+81
View File
@@ -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`
+1 -1
View File
@@ -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
+5 -3
View File
@@ -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` 为准。
运行时行为验证以 `verify-toggle.py` 为准。
+62 -10
View File
@@ -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__":
@@ -11,7 +11,7 @@ import run.halo.app.plugin.PluginContext;
* <p>Only one main class extending {@link BasePlugin} is allowed per plugin.</p>
*
* @author LHY
* @since 1.0.5
* @since 1.0.6
*/
@Slf4j
@Component
-64
View File
@@ -1,64 +0,0 @@
<script setup lang="ts">
import IconSunLine from '~icons/ri/sun-line'
import IconMoonLine from '~icons/ri/moon-line'
import { useDarkMode } from '../composables/useDarkMode'
const { isDark, toggle } = useDarkMode()
</script>
<template>
<button
type="button"
class="theme-toggle"
:aria-pressed="isDark"
:aria-label="isDark ? '切换到浅色模式' : '切换到深色模式'"
@click="toggle"
>
<IconSunLine v-if="isDark" class="theme-toggle__icon" />
<IconMoonLine v-else class="theme-toggle__icon" />
<span class="theme-toggle__label">
{{ isDark ? '浅色模式' : '深色模式' }}
</span>
</button>
</template>
<style scoped>
.theme-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: 0;
background: transparent;
color: var(--halo-text-secondary);
font: inherit;
cursor: pointer;
border-radius: 0.25rem;
transition:
background-color 0.15s,
color 0.15s;
user-select: none;
}
.theme-toggle:hover {
background-color: var(--halo-bg-hover);
color: var(--halo-text-primary);
}
.theme-toggle:focus-visible {
outline: 2px solid var(--halo-accent-primary);
outline-offset: 2px;
}
.theme-toggle__icon {
width: 1.125rem;
height: 1.125rem;
flex-shrink: 0;
}
.theme-toggle__label {
font-size: 0.8125rem;
white-space: nowrap;
}
</style>
+2 -4
View File
@@ -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,
},
-46
View File
@@ -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
}
+10 -3
View File
@@ -20,7 +20,10 @@ const modeOptions: { value: ThemeMode; label: string; description: string }[] =
const optionEls = ref<HTMLButtonElement[]>([])
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)"
>
<div class="dark-mode-settings__option-label">{{ option.label }}</div>
@@ -177,4 +184,4 @@ function onKeydown(event: KeyboardEvent): void {
font-size: 0.8125rem;
color: var(--halo-text-tertiary);
}
</style>
</style>