23 Commits

Author SHA1 Message Date
Serendipity 4a83d05f27 chore: 移除 docs 原版 logo 跟踪 2026-08-08 21:40:26 +08:00
Serendipity 3e9630fc91 docs: README 增加截图预览并停止跟踪内部文档
- README 新增截图预览:插件设置界面与后台深色模式效果
- docs 下内部审查/计划文档从仓库移除,本地保留并加入 gitignore
- 新增 docs/screenshots 4 张截图
2026-08-08 21:36:59 +08:00
Serendipity c4d99ec041 chore: 更新插件 logo 2026-08-08 21:14:44 +08:00
Serendipity 30b2ae2f83 chore: 提交 gradle wrapper jar 并修复忽略规则 2026-08-08 20:51:16 +08:00
Serendipity cffbe572bf feat: 设置页官方化改造并升级 1.0.8
- 设置页改用 Halo 官方 VPageHeader / VCard / VDescription / VTag 组件
- 精简 variables.css 为设置页实际使用的变量
- 同步 README 更新日志与本地说明文档
2026-08-08 20:36:52 +08:00
Serendipity 96dd1ef4d3 refactor: 移除设置页键盘操作支持并升级 1.0.7
- SettingsView 删除方向键切换、roving tabindex、ARIA 单选组语义与焦点环,仅保留原生按钮点击切换
- README 清理过时描述并补充 v1.0.7 更新日志
- 版本升级 1.0.7
2026-08-08 20:11:28 +08:00
Serendipity df9cd52de6 chore: 更新插件清单 GitHub 仓库地址 2026-08-08 19:32:23 +08:00
Serendipity 394613f516 fix: 深色模式入口移至外观分组并移除侧边栏按钮(1.0.6)
- 设置入口 group 改为官方 interface(外观)分组
- 删除侧边栏 ThemeToggle 注入器及组件,设置页成为唯一入口
- verify-toggle.py 改为直接驱动主题状态并校验翻转
- 同步 README 与脚本文档,版本升级 1.0.6
2026-08-08 19:25:27 +08:00
Serendipity 5cbce86e03 chore: 收口复查遗留项并补 radiogroup 键盘导航 2026-08-08 18:46:43 +08:00
Serendipity 45451c0d49 chore: 提交工作区剩余变更与复查文档 2026-08-08 18:38:42 +08:00
Serendipity d95d17234e fix: 按审查报告修复 P2/P3 问题并升级 1.0.5 2026-08-08 18:15:11 +08:00
Serendipity b6403b7536 docs: 按 Halo 官方插件 README 风格重写 2026-08-08 17:56:47 +08:00
Serendipity 363f30dcd6 chore: 忽略 Python 缓存目录 2026-08-08 17:49:09 +08:00
Serendipity 88a071f673 chore: 调整 Dark Reader 依赖跟踪并修复验证脚本 2026-08-08 17:48:29 +08:00
Serendipity 0bb552b026 chore: 整理 third-party Dark Reader 目录并移除本地说明文件跟踪 2026-08-08 17:38:08 +08:00
Serendipity 202ab537d1 feat: 1.0.3 引入 Dark Reader 通用暗色引擎 2026-08-08 17:21:22 +08:00
Serendipity b36ad30d7a chore: 版本 1.0.1,清理过期文档,加入 Playwright 自动化测试工具
- 版本号从 1.0.0-SNAPSHOT 升级到 1.0.1
- 删除已过期的设计文档和调查文档
- 新增 workplace/ 目录:暗色模式扫描、CSS 冲突探测、登录管理脚本
- 更新 .gitignore 忽略截图纸张和浏览器缓存
- 更新 CLAUDE.md 反映当前仓库状态

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 13:06:28 +08:00
Serendipity 154fc63eef fix: 覆盖 Bytemd Markdown 编辑器与 CodeMirror 暗色样式
文章编辑器页面使用 Bytemd (Markdown) + CodeMirror (代码编辑器),
之前的 editor.css 只覆盖了 ProseMirror/TipTap, 导致编辑区大块白色。

新增覆盖:
- .bytemd 主容器/工具栏/编辑器/预览/状态栏
- .bytemd-dropdown/.bytemd-sidebar/.bytemd-floating-btn
- .CodeMirror 背景/行号/光标/选区/语法高亮
- .cm-strong 等 markdown 语法元素颜色

真实环境验证编辑器页面零残留

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 08:36:59 +08:00
Serendipity fd1cf94a39 fix: 补充更多第三方插件页面暗色覆盖
继续扫描用户服务器 (Halo Pro) 上所有插件页面, 修复残留:
- 存储工具箱: .toolkit-wrapper, .st-card, .col-* 表头
- 已安装应用: card-header/card-body 内 UnoCSS hash 类 (用上下文定位)
- 文章导入导出: .tabbar-wrapper
- 文档示例页: .docs__box
- 通用: main 下 .bg-gray-*/.bg-white 兜底

逐页注入验证 8 个问题页面全部零残留

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 08:35:14 +08:00
Serendipity 8a525a0e5e fix: 覆盖第三方插件页面的内部 UI 残留
用户服务器 (Halo Pro) 上部分第三方插件页面仍有白色残留,
原因是这些插件使用 Vue scoped 样式 ([data-v-xxx]),
特异性与常规覆盖相同但加载顺序靠后。

修复:
- 新增 plugin-pages.css, 针对已装插件的内部类覆盖 (含 !important):
  - 装备: .group, .equipment-*
  - 日程日历: .week-picker, .day-column__body
  - 心愿便签: .ah-body, .wb-s-card
  - 瞬间: .moment-editor, .editor-main, .card
- 修复侧边栏 .user-profile 白色残留 (Halo Pro 特有)
- 基于 blog.liuhangyv.top 真实环境逐页验证, 全部零残留

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 07:43:29 +08:00
Serendipity c1c05d9bd4 docs: 更新 CLAUDE.md 与 README.md 反映最新架构
CLAUDE.md:
- 修正覆盖策略描述(Halo 2.25 用 UnoCSS + BEM 类,非 Tailwind)
- 补充 injector.ts 侧边栏注入方案与 halo-core.css 核心地位
- 新增真实环境验证工作流章节

README.md:
- 补充验证过的页面列表与 JDK 25 构建说明
- 补全项目结构中 styles/overrides/ 目录

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 22:54:24 +08:00
Serendipity 42df299791 fix: 重写暗色模式覆盖,基于真实 DOM 类名
问题: 之前的 CSS 覆盖使用推测的 Tailwind/Vuetify 类名,
但 Halo 2.25 实际使用 UnoCSS + BEM 语义类, 导致大部分
页面元素未切换颜色。

修复:
- 新增 halo-core.css, 覆盖真实类名:
  .page-header, .card-wrapper, .entity-field-title,
  .pagination, .tag-default, .modal-*, .description-item-*,
  .toast-container, .markdown-body, html/body 根背景
- 新增 injector.ts, 通过 MutationObserver 将 ThemeToggle
  挂载到侧边栏 .sidebar__profile 上方
- 基于 demo.halocms.site 真实环境扫描验证 9 个页面
  零白色残留、零不可见文字

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 22:49:03 +08:00
Serendipity 2a37c36894 chore: 修正构建配置以支持本机 JDK 25 构建
- 移除 toolchain 约束,改用 options.release = 21 兼容 Halo API
- 通过 gradle.properties 指定本机 JDK 25 路径
- gradle wrapper 切换至腾讯镜像源解决下载超时

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 22:48:57 +08:00
48 changed files with 19761 additions and 2413 deletions
+30 -1
View File
@@ -1,8 +1,8 @@
.gradle/
build/
!gradle/wrapper/gradle-wrapper.jar
*.class
*.jar
!gradle/wrapper/gradle-wrapper.jar
*.war
*.log
*.tmp
@@ -10,4 +10,33 @@ build/
*.iml
.DS_Store
node_modules/
.pnpm-store/
dist/
# Playwright 测试工具 — 只跟踪脚本和配置,忽略输出/截图/缓存
workplace/*
!workplace/*.py
!workplace/*.yaml
# 暗色扫描输出与会话
scripts/output/
scripts/.browser-profile/
# 本地项目说明文件,不入库
AGENTS.md
CLAUDE.md
# Dark Reader 仅跟踪构建所需文件,完整源码保留在本地
third-party/darkreader/*
!third-party/darkreader/package.json
!third-party/darkreader/darkreader.js
!third-party/darkreader/darkreader.mjs
!third-party/darkreader/index.d.ts
!third-party/darkreader/LICENSE
!third-party/darkreader/SHA256SUMS
__pycache__/
*.pyc
# 内部审查/计划文档不入库,仅保留本地
docs/*.md
docs/**/*.md
docs/原版logo.png
-107
View File
@@ -1,107 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
Halo 暗色模式插件 — 为 Halo 2.25 后台管理面板提供深色/浅色模式切换。不修改 Halo 核心代码,完全通过插件机制实现。
**核心技术栈**:Java 21(后端插件骨架)、Vue 3 + TypeScript(前端 UI)、OKLCH 色彩空间(CSS 变量体系)、Gradle(构建)
## 常用命令
```bash
# 后端 — 启用插件并启动 Halo 开发服务器
./gradlew haloServer
# 后端 — 构建插件 JAR(产物在 build/libs/
./gradlew build
# 前端 — 进入 ui/ 开发
cd ui && pnpm install
# 前端 — 开发模式(watch 构建)
pnpm dev
# 前端 — 生产构建
pnpm build
# 前端 — 类型检查
pnpm type-check
# 前端 — Lintoxlint + eslint
pnpm lint
# 前端 — 格式化
pnpm prettier
# 前端 — 单元测试
pnpm test:unit
# 后端 — 仅运行 Java 测试
./gradlew test
```
## 架构概览
```
halo-dark-mode-plugin/
├── 后端 (Java/Gradle) ← 插件骨架,极简
│ ├── DarkModePlugin.java ← 继承 BasePlugin,仅 start()/stop()
│ └── plugin.yaml ← 插件清单(声明式元数据 + 版本约束)
├── 前端 (Vue 3 / TypeScript) ← 核心实现
│ ├── index.ts ← definePlugin() 入口,注册路由+组件
│ ├── composables/
│ │ ├── useDarkMode.ts ← 单例状态管理(light/dark/auto + localStorage 持久化)
│ │ └── useSystemPreference.ts ← matchMedia 系统偏好监听
│ ├── components/
│ │ └── ThemeToggle.vue ← 侧边栏切换按钮
│ ├── views/
│ │ └── SettingsView.vue ← 设置页面(浅色/深色/跟随系统 三选一)
│ └── styles/
│ ├── variables.css ← 40+ CSS 变量(:root 浅色 + [data-halo-theme="dark"] 深色)
│ └── overrides/ ← 按区域分层覆盖(layout/components/forms/editor/scrollbar/utilities
└── 构建链
└── processUiResources ← Gradle task: ui/dist/ → resources/main/ui/
```
## 关键设计决策
### 主题切换机制
- **触发方式**`document.documentElement` 上设置/移除 `data-halo-theme="dark"` 属性
- **CSS 变量体系**:所有颜色通过 `--halo-*` 前缀的 CSS 自定义属性控制,一个语义变量对应一个视觉属性
- **颜色空间**:全部使用 OKLCH(感知均匀,暗色模式天然适配,Chrome 111+/Firefox 113+/Safari 15.4+
- **暗色配色策略**:用亮度层次区分背景(越"高"的层越亮),中性色含微量蓝色调,强调色略降饱和
### 状态管理
- `useDarkMode()` 是**模块级单例** — 所有组件共享同一份 `theme` ref 和 `isDark` computed
- 三个主题模式:`light` / `dark` / `auto`(跟随系统)
- 持久化:`localStorage` key `halo-dark-mode-theme`,默认 `auto`
- FOUC 防护:插件 bundle 顶部同步执行脚本,DOM 渲染前设置 `data-halo-theme`
### 前端入口
- `definePlugin()``@halo-dev/ui-shared` 导入(非 `@halo-dev/console-shared`,那是旧版 plugin-starter 的源)
- 路由使用 `() => import(...)` 懒加载
- 构建使用 `@halo-dev/ui-plugin-bundler-kit``viteConfig()` 包装器
### 覆盖策略
三级渐进覆盖:
1. **CSS 变量注入**~80% 场景)— 通过 `--halo-*` 变量重定义 Tailwind 颜色语义
2. **选择器覆盖**~17% 场景)— `[data-halo-theme="dark"]` 前缀高特异性选择器
3. **组件穿透**~3% 场景)— FormKit/编辑器等第三方组件的自有 CSS 变量接口
### 后端
后端极简 — `DarkModePlugin extends BasePlugin` 仅含 `start()`/`stop()` 生命周期钩子。所有核心逻辑在前端。插件不依赖后端 Setting API。
## 项目文档
- `设计文档.md` — 完整的技术设计(架构图、CSS 变量清单、调色板、组件覆盖策略、实现阶段划分、测试策略)
- `调查文档.md` — 技术调查(create-halo-plugin vs plugin-starter 差异、dev-skills、Halo 插件机制)
- `README.md` — 用户向 README
+136 -42
View File
@@ -1,63 +1,109 @@
# dark-mode
# Halo 深色模式
Halo 2.25 暗色模式插件 — 为 Halo 后台管理面板提供深色/浅色模式切换,支持跟随系统、手动切换和偏好记忆。
为 Halo 后台管理面板提供深色/浅色模式切换,内置 Dark Reader 通用暗色引擎,支持手动切换、跟随系统与偏好记忆。
## 功能
## 功能特性
- ☀️/🌙 **三种模式**:浅色、深色、跟随系统
- 💾 **偏好持久化**:自动记忆用户选择(localStorage),刷新不丢失
- 🖥️ **系统偏好跟随**:切换系统外观时自动响应
- **瞬间切换**CSS 变量瞬时生效,无可见闪烁
- 🧩 **侧边栏注入**:切换按钮自动出现在侧边栏底部(UserProfileBanner 上方)
- ⚙️ **设置页面**:提供详细的模式选择界面(菜单 → 偏好设置 → 深色模式)
- 🎨 **OKLCH 色彩空间**:感知均匀,暗色模式天然适配,WCAG AA 对比度保证
- 📦 **零后端依赖**:纯前端实现,不需要后端 API
- **三种模式**:浅色、深色、跟随系统
- **偏好持久化**:自动记忆用户选择(localStorage),刷新不丢失
- **系统偏好跟随**:切换系统外观时自动响应
- **Dark Reader 引擎**:自动分析页面 CSS 与 DOM,覆盖 Halo 核心页面和第三方插件页面
- **官方外观分组**:设置入口位于 Halo「外观」分组,与主题、菜单、插件同组
- **设置页面**:在「外观 → 深色模式」中详细选择显示模式
- **零后端依赖**:纯前端实现,不需要额外后端 API
## 安装
1. 从 Releases 下载 `plugin-dark-mode-<version>.jar`
2. 在 Halo 后台的「插件管理」中上传并安装。
3. 启用插件后,进入「外观 → 深色模式」设置页面调整主题。
## 使用
安装并启用后,进入「外观 → 深色模式」设置页面选择显示模式:
| 配置项 | 可选值 | 说明 |
| -------- | --------- | -------------------- |
| 主题模式 | `light` | 始终使用浅色模式 |
| 主题模式 | `dark` | 始终使用深色模式 |
| 主题模式 | `auto` | 跟随系统外观自动切换 |
## 截图预览
以下截图展示插件在 Halo 后台的实际效果。
![插件设置界面](docs/screenshots/插件界面.png)
![后台深色模式效果 1](docs/screenshots/photo1.png)
![后台深色模式效果 2](docs/screenshots/photo2.png)
![后台深色模式效果 3](docs/screenshots/photo3.png)
## 技术原理
- 插件通过 `useDarkMode()` 管理 `light` / `dark` / `auto` 三种状态。
- 深色模式下调用 Dark Reader 的 `enable()`,浅色模式下调用 `disable()`
- Dark Reader 会持续监听页面 DOM 变化,因此第三方插件动态渲染的内容也能自动转换。
- 插件自身只保留设置页所需的最小 UI 变量,不再维护逐页手工 CSS 覆盖。
> 说明:Dark Reader 的样式注入是异步的,刷新瞬间仍可能存在极短闪白;插件通过同步设置 `color-scheme` 缓解,但无法完全消除。
## 第三方依赖
- [Dark Reader](https://github.com/darkreader/darkreader) `4.9.129`[MIT License](https://github.com/darkreader/darkreader/blob/main/LICENSE)。
- 构建所需文件位于 `third-party/darkreader/`,由 `ui/package.json` 通过本地文件依赖引用。
- 升级或替换 Dark Reader 构建文件后,请校验 `third-party/darkreader/SHA256SUMS`
Linux / macOS 使用 `sha256sum -c SHA256SUMS`Windows 使用 `Get-FileHash -Algorithm SHA256` 对比。
## 开发环境
- Java 21+
- Halo `>=2.25.0`
- Java 21+(项目使用 `--release 21` 编译)
- Node.js 18+
- pnpm
- Docker`haloServer` 开发服务器需要)
## 快速开始
## 从源码构建
仓库使用标准 Gradle 结构,并提交了 Gradle Wrapper。
Linux / macOS
```bash
# 启用插件并启动 Halo 开发服务器
./gradlew haloServer
./gradlew clean build
```
# 前端开发(watch 模式)
Windows
```powershell
.\gradlew.bat clean build
```
构建产物位于 `build/libs/plugin-dark-mode-<version>.jar`
## 前端开发
```bash
cd ui
pnpm install
pnpm dev
```
## 构建
常用检查命令:
```bash
# 完整构建(后端 + 前端)
./gradlew build
```
构建完成后,插件 JAR 文件位于 `build/libs/`,可直接在 Halo 后台安装。
## 前端命令
```bash
cd ui
pnpm dev # 开发构建(watch
pnpm build # 生产构建
pnpm type-check # TypeScript 类型检查
pnpm lint # Lintoxlint + eslint
pnpm prettier # 代码格式化
pnpm test:unit # 单元测试
pnpm type-check # TypeScript 类型检查
pnpm lint # Lintoxlint + eslint
pnpm build # 生产构建
```
## 项目结构
```
├── build.gradle # 根构建BOM 2.25.0, DevTools 0.8.0
```text
├── build.gradle # 根构建配置
├── settings.gradle # 包含 :ui 子项目
├── gradle/wrapper/ # Gradle Wrapper
├── src/
│ └── main/
│ ├── java/run/halo/darkmode/
@@ -65,23 +111,71 @@ pnpm test:unit # 单元测试
│ └── resources/
│ └── plugin.yaml # 插件清单
├── third-party/
│ └── darkreader/ # Dark Reader 构建产物(MIT
└── ui/
├── package.json # 前端依赖
├── vite.config.ts # Vite 配置
└── src/
├── index.ts # definePlugin 入口
├── injector.ts # ThemeToggle 侧边栏注入器
├── darkreader-engine.ts # Dark Reader 通用暗色引擎
├── composables/
│ ├── useDarkMode.ts # 核心状态管理(模块级单例)
│ ├── useDarkMode.ts # 主题状态管理(模块级单例)
│ └── useSystemPreference.ts # 系统偏好监听
├── components/
│ └── ThemeToggle.vue # 侧边栏切换按钮
├── views/
│ └── SettingsView.vue # 设置页面
└── styles/
├── index.css # 样式入口
── variables.css # 40+ CSS 变量(浅色 + 深色)
└── overrides/ # 组件覆盖样式
── variables.css # 插件自身 UI 变量
```
## 测试
```bash
./gradlew test
```
运行时验证脚本位于 `scripts/verify-toggle.py`,用于直接驱动主题状态,并检查 data-halo-theme、localStorage、Dark Reader 注入与 color-scheme 的翻转。
## 更新日志
### v1.0.8
- 设置页改用 Halo 官方 VPageHeader / VCard / VDescription / VTag 组件,与官方页面风格统一
- 精简插件自身 CSS 变量,仅保留设置页所需变量
### v1.0.7
- 移除设置页方向键切换、roving tabindex 与 ARIA 单选组语义,保留原生按钮点击切换
- 同步清理 README 中已过时的「设置选项支持键盘操作」描述
### v1.0.6
- 深色模式设置入口移至 Halo 官方「外观」分组
- 移除侧边栏底部注入的切换按钮,设置页成为唯一入口
- 更新运行时验证脚本:直接驱动主题状态,不再依赖侧边栏按钮
### v1.0.5
- 同步设置 `color-scheme`,缓解深色模式刷新闪烁
- 增加多标签页主题同步与 `useDarkMode` 单元测试
- 后端日志改用 Lombok `@Slf4j`
- 裁剪 vendored `package.json`,新增 Dark Reader `SHA256SUMS` 完整性校验
- `plugin.yaml` 改为安装后由用户手动启用
### v1.0.4
- 改为纯 Dark Reader 引擎,移除手工 CSS 覆盖
- 移除 Monaco 手动主题同步,暗色转换统一交给 Dark Reader
- 整理 `third-party/darkreader` 目录,仅保留构建所需文件
### v1.0.3
- 引入 Dark Reader 通用暗色引擎
- 保留原有手工 CSS 作为兼容层,覆盖核心页面与第三方插件页面
- 增加 Monaco 日志查看器主题同步
## 许可证
[GPL-3.0](./LICENSE) © LHY
-6
View File
@@ -23,12 +23,6 @@ test {
useJUnitPlatform()
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = "UTF-8"
options.release = 21
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

+3 -1
View File
@@ -1 +1,3 @@
version=1.0.0-SNAPSHOT
version=1.0.8
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
org.gradle.java.home=D:/settings/Language/Java/jdk-25.0.1
Binary file not shown.
+3 -3
View File
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.4.0-bin.zip
networkTimeout=30000
validateDistributionUrl=false
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+67
View File
@@ -0,0 +1,67 @@
# 暗色模式回归扫描与运行时验证工具
用 Playwright 驱动 Edge 对 Halo 后台做全路由深浅色残留扫描与运行时切换验证。
## 依赖
- Python 3.13 环境(本机:`D:\settings\settings\uv\my_uv_env`
- Playwright(已安装):`pip install playwright``uv pip install playwright`
- 本机 EdgePlaywright 通过 `channel="msedge"` 复用,无需下载浏览器内核)
## 首次使用:登录
```powershell
D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\login-wait.py
```
会弹出 Edge 窗口,登录 `https://blog.liuhangyv.top/console` 后脚本自动退出,
会话保存在 `scripts/.browser-profile/`(已被 gitignore,不会入库)。
已有登录态想复用,可显式指定 profile:
```powershell
D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\scan-dark.py --profile scripts\.browser-profile
```
## 常用命令
```powershell
# 全路由深色扫描(历史 CSS 工作流保留)
D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\scan-dark.py --local-css ui\build\dist\style.css --assert-zero
# 只扫指定页面
D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\scan-dark.py --pages /console/posts,/console/users
# 浅色模式回归抽查
D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\scan-dark.py --mode light --pages /console/dashboard,/console/posts
```
## 切换行为验证
```powershell
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。
## 输出
- `scripts/output/scan-results.json`:按路由分组的结果
- `scripts/output/shots/*.png`:每页截图
- 均已被 gitignore,不入库。
## 注意事项
- 扫描会忽略 `img/canvas/video/svg/iframe`,避免把文章图片、编辑器画布等内容性
元素误判为界面残留。
- 路由从 Vue Router 动态发现,Halo 或插件升级新增页面后无需维护清单。
- `scan-dark.py` 主要为历史手工 CSS 工作流保留;当前暗色转换由 Dark Reader 负责,
运行时行为验证以 `verify-toggle.py` 为准。
+9
View File
@@ -0,0 +1,9 @@
# scripts/legacy
保留的旧版分析工具,来自早期手工 CSS 工作流,当前纯 Dark Reader 架构下不再参与日常流程。
- `aggregate.py` — 聚合多路由扫描结果
- `fetch_bundle.py` — 抓取线上部署 CSS bundle
- `probe_conflict.py` — 探测 CSS 覆盖冲突
如需恢复手工 CSS 扫描工作流,可参考这些脚本;否则可在一段时间后删除。
+30
View File
@@ -0,0 +1,30 @@
# 聚合扫描结果:按 (元素, 类名, 问题) 分组,统计影响页面数
import json
import pathlib
from collections import defaultdict
data = json.loads(
(pathlib.Path(__file__).parent / "scan-results.json").read_text(encoding="utf-8")
)
groups = defaultdict(lambda: {"pages": [], "sample": None})
for route, items in data.items():
if route.startswith("__") or isinstance(items, dict):
continue
for it in items:
key = (it["tag"], it["cls"], tuple(it["issues"]))
groups[key]["pages"].append(route)
if groups[key]["sample"] is None:
groups[key]["sample"] = it
# 按影响页面数降序
ranked = sorted(groups.items(), key=lambda kv: -len(kv[1]["pages"]))
for (tag, cls, issues), g in ranked:
s = g["sample"]
pages = g["pages"]
print(f"[{len(pages)}页] <{tag}> .{cls[:80]}")
print(f" 问题: {', '.join(issues)}")
print(f" 路径: {s['path'][:130]}")
print(f" 文本: {s['text'][:40]} 尺寸: {s['size']}")
print(f" 页面: {', '.join(pages[:12])}{' ...' if len(pages) > 12 else ''}")
print()
+54
View File
@@ -0,0 +1,54 @@
# 抓取服务端实际部署的插件 bundle.css,与本地构建产物对比
import pathlib
import re
import time
from playwright.sync_api import sync_playwright
ROOT = pathlib.Path(__file__).parent
PROFILE = ROOT / "pw-profile"
OUT = ROOT / "deployed-bundle.css"
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
str(PROFILE), channel="msedge", headless=True
)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto("https://blog.liuhangyv.top/console/overview", wait_until="domcontentloaded")
time.sleep(4)
text = page.evaluate(
"""async () => {
const sheet = [...document.styleSheets].find(s => s.href && s.href.includes('bundle.css'));
if (!sheet) return null;
return await (await fetch(sheet.href)).text();
}"""
)
ctx.close()
if not text:
print("未找到 bundle.css")
raise SystemExit(1)
OUT.write_text(text, encoding="utf-8")
local = (ROOT.parent / "ui" / "build" / "dist" / "style.css").read_text(encoding="utf-8")
def dark_selectors(css: str) -> set:
# 提取所有 [data-halo-theme=dark] 规则的选择器(粗略切分)
return set(re.findall(r"(\[data-halo-theme=dark\][^{]+)\{", css))
dep = dark_selectors(text)
loc = dark_selectors(local)
print(f"部署版 dark 规则选择器数: {len(dep)}")
print(f"本地构建 dark 规则选择器数: {len(loc)}")
print(f"本地有而部署没有(未部署的新覆盖): {len(loc - dep)}")
for s in sorted(loc - dep)[:40]:
print(" +", s[:110])
print(f"部署有而本地没有(本地已删除的旧规则): {len(dep - loc)}")
for s in sorted(dep - loc)[:40]:
print(" -", s[:110])
for kw in ["description-item__label", "description-item__content", "empty-title",
"menu-item-title", "alert-wrapper"]:
print(f"关键字 {kw!r}: 部署版={'' if kw in text else ''} 本地={'' if kw in local else ''}")
+49
View File
@@ -0,0 +1,49 @@
# 冲突溯源:找出与插件暗色规则竞争的原生规则及其样式表加载顺序
import pathlib
import time
from playwright.sync_api import sync_playwright
ROOT = pathlib.Path(__file__).parent
PROFILE = ROOT / "pw-profile"
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
str(PROFILE), channel="msedge", headless=True
)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto("https://blog.liuhangyv.top/console/overview", wait_until="domcontentloaded")
try:
page.wait_for_load_state("networkidle", timeout=10000)
except Exception:
pass
time.sleep(3)
result = page.evaluate(
"""() => {
const targets = ['description-item__label', 'menu-item-title', 'empty-title', 'alert-wrapper'];
const out = [];
const sheets = [...document.styleSheets];
sheets.forEach((sheet, si) => {
let rules;
try { rules = sheet.cssRules; } catch (e) { return; }
for (const r of rules) {
const sel = r.selectorText || '';
if (sel.includes('data-halo-theme')) continue;
for (const t of targets) {
if (sel.includes(t)) {
out.push({ sheetIndex: si, href: (sheet.href || '(inline)').slice(-60), selector: sel.slice(0, 120), body: r.style.cssText.slice(0, 120) });
}
}
}
});
// 插件 bundle.css 的位置
const pluginIdx = sheets.findIndex(s => s.href && s.href.includes('bundle.css'));
return { pluginIdx, totalSheets: sheets.length, matches: out };
}"""
)
ctx.close()
print(f"插件 bundle.css 样式表序号: {result['pluginIdx']} / 共 {result['totalSheets']}")
for m in result["matches"]:
print(f"[sheet #{m['sheetIndex']:>2}] {m['selector']}")
print(f" {m['body']} <- {m['href']}")
+53
View File
@@ -0,0 +1,53 @@
"""打开带持久化配置的 Edge 窗口,等待用户在 Halo 后台完成登录。
登录成功后会话保存在 profile 目录,供 scan-dark.py 复用。
"""
import argparse
import pathlib
import sys
import time
from playwright.sync_api import sync_playwright
DEFAULT_PROFILE = pathlib.Path(__file__).parent / ".browser-profile"
LOGIN_URL = "https://blog.liuhangyv.top/console/login"
TIMEOUT_S = 280
def main() -> int:
parser = argparse.ArgumentParser(description="登录 Halo 后台并保存会话")
parser.add_argument("--profile", default=str(DEFAULT_PROFILE))
args = parser.parse_args()
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
args.profile,
channel="msedge",
headless=False,
viewport={"width": 1600, "height": 950},
)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto(LOGIN_URL)
print("浏览器窗口已打开,请在其中登录 Halo 后台...", flush=True)
deadline = time.time() + TIMEOUT_S
while time.time() < deadline:
try:
url = page.url
except Exception:
print("检测到窗口被关闭", flush=True)
return 2
if "/console" in url and "/login" not in url:
time.sleep(3)
print(f"检测到登录成功: {url}", flush=True)
ctx.close()
return 0
time.sleep(2)
print("等待超时,未检测到登录", flush=True)
ctx.close()
return 1
if __name__ == "__main__":
sys.exit(main())
+220
View File
@@ -0,0 +1,220 @@
"""Halo 后台暗色模式残留扫描器(正式回归工具)。
用法示例:
python scripts/scan-dark.py --local-css ui/build/dist/style.css --assert-zero
python scripts/scan-dark.py --mode light --pages /console/posts,/console/users
python scripts/scan-dark.py --profile workplace/pw-profile
"""
import argparse
import json
import pathlib
import re
import sys
import time
from playwright.sync_api import sync_playwright
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
BASE = "https://blog.liuhangyv.top"
DEFAULT_PROFILE = pathlib.Path(__file__).parent / ".browser-profile"
DEFAULT_OUTPUT = pathlib.Path(__file__).parent / "output"
SCAN_JS = r"""
() => {
const EXCLUDED_TAGS = new Set(['IMG', 'CANVAS', 'VIDEO', 'SVG', 'IFRAME']);
const results = [];
const seen = new Set();
const isVisible = (el) => {
const cs = getComputedStyle(el);
return cs.display !== 'none' && cs.visibility !== 'hidden' && +cs.opacity > 0.05;
};
const shortPath = (el) => {
const parts = [];
let cur = el;
for (let i = 0; i < 5 && cur && cur !== document.body; i++) {
let p = cur.tagName.toLowerCase();
if (cur.id) p += '#' + cur.id;
else if (typeof cur.className === 'string' && cur.className.trim()) {
p += '.' + cur.className.trim().split(/\s+/).slice(0, 2).join('.');
}
parts.unshift(p);
cur = cur.parentElement;
}
return parts.join(' > ');
};
document.querySelectorAll('body *').forEach(el => {
if (EXCLUDED_TAGS.has(el.tagName)) return;
if (!isVisible(el)) return;
const r = el.getBoundingClientRect();
if (r.width < 50 || r.height < 20) return;
const cs = getComputedStyle(el);
const issues = [];
const bg = cs.backgroundColor.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/);
if (bg && bg[4] !== '0' && +bg[1] > 235 && +bg[2] > 235 && +bg[3] > 235) {
issues.push('light-bg ' + cs.backgroundColor);
}
const hasText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
const c = cs.color.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/);
if (hasText && c && (+c[4] ?? 1) > 0.05 && (+c[1] + +c[2] + +c[3]) / 3 < 95) {
issues.push('dark-text ' + cs.color);
}
if (!issues.length) return;
const cls = (typeof el.className === 'string' ? el.className : '').trim().replace(/\s+/g, ' ').slice(0, 150);
const key = el.tagName + '|' + cls + '|' + issues.join(',');
if (seen.has(key)) return;
seen.add(key);
results.push({
tag: el.tagName.toLowerCase(),
cls,
path: shortPath(el),
issues,
size: Math.round(r.width) + 'x' + Math.round(r.height),
text: (el.textContent || '').trim().slice(0, 40),
});
});
return results;
}
"""
def slug(route: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", route.lower()).strip("-") or "root"
def main() -> int:
parser = argparse.ArgumentParser(description="Halo 后台暗色模式残留扫描")
parser.add_argument("--base", default=BASE)
parser.add_argument("--profile", default=str(DEFAULT_PROFILE))
parser.add_argument("--output-dir", default=str(DEFAULT_OUTPUT))
parser.add_argument("--mode", choices=["dark", "light"], default="dark")
parser.add_argument("--pages", default="", help="逗号分隔的路由子集,默认扫描全部")
parser.add_argument("--local-css", default="", help="注入本地构建的 style.css 做预部署验证")
parser.add_argument("--assert-zero", action="store_true", help="存在残留时以退出码 1 结束")
args = parser.parse_args()
output = pathlib.Path(args.output_dir)
shots = output / "shots"
shots.mkdir(parents=True, exist_ok=True)
if args.mode == "dark":
init_js = """
try { localStorage.setItem('halo-dark-mode-theme', 'dark'); } catch (e) {}
document.documentElement.setAttribute('data-halo-theme', 'dark');
"""
else:
init_js = """
try { localStorage.setItem('halo-dark-mode-theme', 'light'); } catch (e) {}
document.documentElement.removeAttribute('data-halo-theme');
"""
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
args.profile,
channel="msedge",
headless=True,
viewport={"width": 1600, "height": 950},
)
ctx.add_init_script(init_js)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto(args.base + "/console/dashboard", wait_until="domcontentloaded")
try:
page.wait_for_load_state("networkidle", timeout=10000)
except Exception:
pass
try:
page.wait_for_selector("#app > *, .sidebar, .main-content", timeout=20000)
except Exception:
pass
time.sleep(2)
if "/login" in page.url:
print("SESSION_EXPIRED 登录态失效,请先运行 scripts/login-wait.py 登录")
ctx.close()
return 3
routes = []
try:
routes = page.evaluate(
"""() => {
const app = document.querySelector('#app').__vue_app__;
const router = app.config.globalProperties.$router;
return router.getRoutes().map(r => r.path);
}"""
)
except Exception:
routes = page.evaluate(
"""() => [...new Set([...document.querySelectorAll('a[href]')]
.map(a => a.getAttribute('href'))
.filter(h => h && h.startsWith('/console')))]"""
)
routes = sorted(
{
r if r.startswith("/console") else "/console" + r
for r in routes
if r.startswith("/") and ":" not in r and r not in ("/", "/console")
}
)
editor = [r for r in routes if "editor" in r]
routes = [r for r in routes if "editor" not in r] + editor[:1]
if args.pages:
wanted = [r.strip() for r in args.pages.split(",") if r.strip()]
routes = [r for r in wanted if r.startswith("/console")]
print(f"模式={args.mode} 待扫描路由数={len(routes)}", flush=True)
all_results = {}
for route in routes:
try:
last_error = None
for attempt in range(3):
try:
page.goto(args.base + route, wait_until="domcontentloaded")
last_error = None
break
except Exception as e:
last_error = e
print(f"{route}: 第 {attempt + 1} 次导航失败,稍后重试", flush=True)
time.sleep(8)
if last_error:
raise last_error
try:
page.wait_for_load_state("networkidle", timeout=8000)
except Exception:
pass
# 限流时 Halo bundle 可能加载较慢,确保应用渲染完成再扫描
page.wait_for_selector("#app > *, .sidebar, .main-content", timeout=20000)
if args.local_css:
page.add_style_tag(path=args.local_css)
time.sleep(2)
if args.mode == "dark":
page.evaluate("document.documentElement.setAttribute('data-halo-theme','dark')")
else:
page.evaluate("document.documentElement.removeAttribute('data-halo-theme')")
time.sleep(0.3)
items = page.evaluate(SCAN_JS)
all_results[route] = items
page.screenshot(path=str(shots / (slug(route) + ".png")))
print(f"{route}: {len(items)} 处疑似残留", flush=True)
except Exception as e:
all_results[route] = {"error": str(e)[:200]}
print(f"{route}: 扫描失败 {e}", flush=True)
out_json = output / "scan-results.json"
out_json.write_text(json.dumps(all_results, ensure_ascii=False, indent=2), encoding="utf-8")
ctx.close()
total = sum(
len(v) for v in all_results.values() if isinstance(v, list)
)
dirty = sum(1 for v in all_results.values() if isinstance(v, list) and v)
print(f"DONE 残留条目={total} 残留路由={dirty} 结果文件={out_json}", flush=True)
if args.assert_zero and dirty:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+130
View File
@@ -0,0 +1,130 @@
"""验证主题切换:直接驱动 localStorage,检查 data-halo-theme、Dark Reader 与 color-scheme 翻转。"""
import argparse
import pathlib
import sys
import time
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:
return page.evaluate(
"""() => ({
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)
parser.add_argument("--profile", default=str(DEFAULT_PROFILE))
args = parser.parse_args()
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
args.profile,
channel="msedge",
headless=True,
viewport={"width": 1600, "height": 950},
)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto(args.base + "/console/dashboard", wait_until="domcontentloaded")
try:
page.wait_for_load_state("networkidle", timeout=10000)
except Exception:
pass
time.sleep(3)
if "/login" in page.url:
print("SESSION_EXPIRED 请先运行 scripts/login-wait.py")
ctx.close()
return 3
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)
set_mode(page, "dark")
wait_mode(page, expect_dark=True)
after = read_state(page)
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"]
)
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"color-scheme 翻转: {'PASS' if scheme_flipped else 'FAIL'}"
)
ctx.close()
return (
0
if toggle_absent
and attr_flipped
and stored_flipped
and darkreader_flipped
and scheme_flipped
else 1
)
if __name__ == "__main__":
sys.exit(main())
@@ -1,5 +1,6 @@
package run.halo.darkmode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import run.halo.app.plugin.BasePlugin;
import run.halo.app.plugin.PluginContext;
@@ -10,8 +11,9 @@ import run.halo.app.plugin.PluginContext;
* <p>Only one main class extending {@link BasePlugin} is allowed per plugin.</p>
*
* @author LHY
* @since 1.0.0
* @since 1.0.8
*/
@Slf4j
@Component
public class DarkModePlugin extends BasePlugin {
@@ -21,11 +23,11 @@ public class DarkModePlugin extends BasePlugin {
@Override
public void start() {
System.out.println("插件启动成功!");
log.info("插件启动成功!");
}
@Override
public void stop() {
System.out.println("插件停止!");
log.info("插件停止!");
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 77 KiB

+6 -6
View File
@@ -6,17 +6,17 @@ metadata:
# The name defines how the plugin is invoked, A unique name
name: dark-mode
spec:
enabled: true
enabled: false
requires: ">=2.25.0"
author:
name: LHY
website: https://github.com/LHY
website: https://github.com/LHY0125
logo: logo.png
homepage: https://github.com/LHY/dark-mode#readme
repo: https://github.com/LHY/dark-mode
issues: https://github.com/LHY/dark-mode/issues
homepage: https://github.com/LHY0125/halo-dark-mode-plugin#readme
repo: https://github.com/LHY0125/halo-dark-mode-plugin
issues: https://github.com/LHY0125/halo-dark-mode-plugin/issues
displayName: "深色模式"
description: "为 Halo 后台管理面板提供深色/浅色模式切换,支持跟随系统、手动切换和偏好记忆"
license:
- name: "GPL-3.0"
url: "https://github.com/LHY/dark-mode/blob/main/LICENSE"
url: "https://github.com/LHY0125/halo-dark-mode-plugin/blob/main/LICENSE"
+23
View File
@@ -0,0 +1,23 @@
MIT License
Copyright (c) 2026 Dark Reader Ltd.
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+5
View File
@@ -0,0 +1,5 @@
0b126b96a0d76a1ff1b1c7cc7088c3ee1903dea2119abded2fb3913b340b63b1 darkreader.js
fbacd711bfd26b33b160881b50028a3e22e8f083b62ec727baebc0c4ad14b052 darkreader.mjs
5f7ebb5de2d74e011d87b86c2cc12048d667981599bdc9c527f98de9e0503a26 package.json
ab1ba9694b242323d15ed4839457c14fe04891966568413d5cd5eb78156dae1f index.d.ts
3f018657498dc4a805af22cc872688f1cd699bbe0045c82d29aeb0c8e65843e5 LICENSE
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+173
View File
@@ -0,0 +1,173 @@
declare namespace DarkReader {
/**
* Enables dark mode for current web page.
* @param theme Theme options.
* @param fixes Fixes for the generated theme.
*/
function enable(theme: Partial<Theme>, fixes?: DynamicThemeFix): void;
/**
* Disables dark mode for current web page.
*/
function disable(): void;
/**
* Enables dark mode when system color scheme is dark.
* @param theme Theme options.
* @param fixes Fixes for the generated theme.
*/
function auto(theme: Partial<Theme> | false, fixes?: DynamicThemeFix): void;
/**
* Stops watching for system color scheme.
* @param isEnabled Boolean `false` value.
*/
function auto(isEnabled: false): void;
/**
* Returns if darkreader is enabled.
*/
function isEnabled(): boolean;
/**
* Sets a function for making CORS requests.
* @param fetch A fetch function.
*/
function setFetchMethod(fetch: (url: string) => Promise<Response>): void;
/**
* Returns the generated CSS by Dark Reader as a string.
*/
function exportGeneratedCSS(): Promise<string>;
/**
* Theme options.
*/
interface Theme {
/**
* 1 - dark mode, 0 - dimmed mode.
* Default 1.
*/
mode: 0 | 1;
/**
* Brightness (0 - 100+).
* Default 100.
*/
brightness: number;
/**
* Contrast (0 - 100+).
* Default 100.
*/
contrast: number;
/**
* Grayscale (0 - 100).
* Default 0.
*/
grayscale: number;
/**
* Sepia (0 - 100).
* Default 0.
*/
sepia: number;
/**
* Specifies if custom font should be used.
* Default false.
*/
useFont: boolean;
/**
* Font family to use.
*/
fontFamily: string;
/**
* Makes text look bolder (0 - 1px).
* Default 0.
*/
textStroke: number;
/**
* Background color to use for dark mode.
* Default #181a1b
*/
darkSchemeBackgroundColor: string;
/**
* Text color to use for dark mode.
* Default #e8e6e3
*/
darkSchemeTextColor: string;
/**
* Background color to use for light mode.
* Default #dcdad7
*/
lightSchemeBackgroundColor: string;
/**
* Text color to use for light mode.
* Default #181a1b
*/
lightSchemeTextColor: string;
/**
* Scrollbar color
* Default auto
*/
scrollbarColor: string;
/**
* Selection color
* Default auto
*/
selectionColor: string;
/**
* Specifies if it has to style system controls
* Default true
*/
styleSystemControls: boolean;
}
/**
* Contains fixes for the generated theme.
*/
interface DynamicThemeFix {
/**
* List of CSS selectors that should be inverted.
* Usually icons that are contained in sprites.
*/
invert: string[];
/**
* Additional CSS.
* ${color} template should be used to apply theme options to a color.
* Example:
* ```
* body {
* background-color: ${white} !important;
* background-image: none !important;
* }
* ```
*/
css: string;
/**
* List of CSS selectors where it's inline style should not be analyzed
* Mostly used for color pickers
*/
ignoreInlineStyle: string[];
/**
* List of CSS selectors where it's image should not be analyzed
* Mostly used for wrongly inverted background-images
*/
ignoreImageAnalysis: string[];
/**
* A toggle to disable the proxying of `document.styleSheets`.
* This is a API-Exclusive option, as it can break legitimate websites,
* who are using the Dark Reader API.
*/
disableStyleSheetsProxy: boolean;
/**
* List of stylesheet URL patterns to ignore.
* Stylesheets matching these patterns will not be processed by Dark Reader.
* - Simple string: matches if URL contains the string (e.g., "crisp")
* - Prefix with ^: matches if URL starts with pattern (e.g., "^https://client.crisp")
* - Suffix with $: matches if URL ends with pattern (e.g., ".css$")
*/
ignoreCSSUrl: string[];
}
}
declare module 'darkreader' {
export = DarkReader;
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "darkreader",
"version": "4.9.129",
"description": "Dark mode for every website",
"main": "darkreader.js",
"module": "darkreader.mjs",
"types": "index.d.ts",
"license": "MIT"
}
+5
View File
@@ -5,6 +5,11 @@ plugins {
group 'run.halo.darkmode.ui'
node {
// 使用系统安装的 Node.js,避免 Gradle 下载的本地 Node 目录缺失时失败
download = false
}
tasks.register('pnpmBuild', PnpmTask) {
group = 'build'
description = 'Build the UI project using pnpm'
+4 -2
View File
@@ -8,7 +8,7 @@
"lint:eslint": "eslint . --fix",
"lint": "run-s lint:*",
"prettier": "prettier --write src/",
"test:unit": "vitest --passWithNoTests",
"test:unit": "vitest",
"type-check": "vue-tsc --build"
},
"prettier": {
@@ -23,10 +23,10 @@
"@halo-dev/ui-shared": "^2.25.1",
"axios": "^1.13.5",
"canvas-confetti": "^1.9.3",
"darkreader": "file:../third-party/darkreader",
"vue": "^3.5.28"
},
"devDependencies": {
"vite": "^8.0.16",
"@halo-dev/ui-plugin-bundler-kit": "^2.25.1",
"@iconify-json/ri": "^1.2.10",
"@tsconfig/node20": "^20.1.6",
@@ -41,6 +41,7 @@
"eslint": "^9.29.0",
"eslint-plugin-oxlint": "^0.16.12",
"eslint-plugin-vue": "~10.0.1",
"jiti": "^2.7.0",
"jsdom": "^26.1.0",
"npm-run-all2": "^7.0.2",
"oxlint": "^0.16.12",
@@ -48,6 +49,7 @@
"sass": "^1.89.2",
"typescript": "~5.8.3",
"unplugin-icons": "^23.0.1",
"vite": "^8.0.16",
"vitest": "^4.1.0",
"vue-tsc": "^3.3.3"
},
+98 -78
View File
@@ -13,23 +13,26 @@ importers:
version: 2.25.2(axios@1.19.0)
'@halo-dev/components':
specifier: ^2.25.1
version: 2.25.2(vue-router@5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(vue@3.5.41(typescript@5.8.3))
version: 2.25.2(vue-router@5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(vue@3.5.41(typescript@5.8.3))
'@halo-dev/ui-shared':
specifier: ^2.25.1
version: 2.25.2(@tiptap/pm@3.29.2)(axios@1.19.0)(vue-router@5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(vue@3.5.41(typescript@5.8.3))
version: 2.25.2(@tiptap/pm@3.29.2)(axios@1.19.0)(vue-router@5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(vue@3.5.41(typescript@5.8.3))
axios:
specifier: ^1.13.5
version: 1.19.0
canvas-confetti:
specifier: ^1.9.3
version: 1.9.4
darkreader:
specifier: file:../third-party/darkreader
version: file:../third-party/darkreader
vue:
specifier: ^3.5.28
version: 3.5.41(typescript@5.8.3)
devDependencies:
'@halo-dev/ui-plugin-bundler-kit':
specifier: ^2.25.1
version: 2.25.2(@rsbuild/core@2.1.10)(@rsbuild/plugin-vue@2.0.1(@rsbuild/core@2.1.10)(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(vue@3.5.41(typescript@5.8.3)))(@vitejs/plugin-vue@6.0.8(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(axios@1.19.0)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))
version: 2.25.2(@rsbuild/core@2.1.10)(@rsbuild/plugin-vue@2.0.1(@rsbuild/core@2.1.10)(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(vue@3.5.41(typescript@5.8.3)))(@vitejs/plugin-vue@6.0.8(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(axios@1.19.0)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))
'@iconify-json/ri':
specifier: ^1.2.10
version: 1.2.10
@@ -47,13 +50,13 @@ importers:
version: 24.13.3
'@vitest/eslint-plugin':
specifier: ^1.2.7
version: 1.6.26(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.8.3))(eslint@9.39.5)(typescript@5.8.3))(eslint@9.39.5)(typescript@5.8.3)(vitest@4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)))
version: 1.6.26(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)(vitest@4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)))
'@vue/eslint-config-prettier':
specifier: ^10.2.0
version: 10.2.0(eslint@9.39.5)(prettier@3.9.6)
version: 10.2.0(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.6)
'@vue/eslint-config-typescript':
specifier: ^14.5.1
version: 14.9.0(eslint-plugin-vue@10.0.1(eslint@9.39.5)(vue-eslint-parser@10.4.1(eslint@9.39.5)))(eslint@9.39.5)(typescript@5.8.3)
version: 14.9.0(eslint-plugin-vue@10.0.1(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0))))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
'@vue/test-utils':
specifier: ^2.4.6
version: 2.4.11(@vue/compiler-dom@3.5.41)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@5.8.3))
@@ -62,13 +65,16 @@ importers:
version: 0.7.0(typescript@5.8.3)(vue@3.5.41(typescript@5.8.3))
eslint:
specifier: ^9.29.0
version: 9.39.5
version: 9.39.5(jiti@2.7.0)
eslint-plugin-oxlint:
specifier: ^0.16.12
version: 0.16.12
eslint-plugin-vue:
specifier: ~10.0.1
version: 10.0.1(eslint@9.39.5)(vue-eslint-parser@10.4.1(eslint@9.39.5))
version: 10.0.1(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0)))
jiti:
specifier: ^2.7.0
version: 2.7.0
jsdom:
specifier: ^26.1.0
version: 26.1.0
@@ -92,10 +98,10 @@ importers:
version: 23.0.1(@vue/compiler-sfc@3.5.41)
vite:
specifier: ^8.0.16
version: 8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)
version: 8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)
vitest:
specifier: ^4.1.0
version: 4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))
version: 4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))
vue-tsc:
specifier: ^3.3.3
version: 3.3.9(typescript@5.8.3)
@@ -1079,6 +1085,9 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
darkreader@file:../third-party/darkreader:
resolution: {directory: ../third-party/darkreader, type: directory}
data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
@@ -1454,6 +1463,10 @@ packages:
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
js-beautify@1.15.4:
resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==}
engines: {node: '>=14'}
@@ -2427,9 +2440,9 @@ snapshots:
tslib: 2.8.1
optional: true
'@eslint-community/eslint-utils@4.10.1(eslint@9.39.5)':
'@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))':
dependencies:
eslint: 9.39.5
eslint: 9.39.5(jiti@2.7.0)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
@@ -2488,33 +2501,33 @@ snapshots:
axios: 1.19.0
qs: 6.15.3
'@halo-dev/components@2.25.2(vue-router@5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(vue@3.5.41(typescript@5.8.3))':
'@halo-dev/components@2.25.2(vue-router@5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(vue@3.5.41(typescript@5.8.3))':
dependencies:
floating-vue: 5.2.2(vue@3.5.41(typescript@5.8.3))
vue: 3.5.41(typescript@5.8.3)
vue-router: 5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3))
vue-router: 5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3))
transitivePeerDependencies:
- '@nuxt/kit'
'@halo-dev/ui-plugin-bundler-kit@2.25.2(@rsbuild/core@2.1.10)(@rsbuild/plugin-vue@2.0.1(@rsbuild/core@2.1.10)(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(vue@3.5.41(typescript@5.8.3)))(@vitejs/plugin-vue@6.0.8(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(axios@1.19.0)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))':
'@halo-dev/ui-plugin-bundler-kit@2.25.2(@rsbuild/core@2.1.10)(@rsbuild/plugin-vue@2.0.1(@rsbuild/core@2.1.10)(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(vue@3.5.41(typescript@5.8.3)))(@vitejs/plugin-vue@6.0.8(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(axios@1.19.0)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))':
dependencies:
'@halo-dev/api-client': 2.25.2(axios@1.19.0)
'@rsbuild/core': 2.1.10
'@rsbuild/plugin-vue': 2.0.1(@rsbuild/core@2.1.10)(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(vue@3.5.41(typescript@5.8.3))
'@vitejs/plugin-vue': 6.0.8(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3))
'@vitejs/plugin-vue': 6.0.8(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3))
js-yaml: 4.3.1
semver: 7.8.5
vite: 8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)
vite: 8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)
transitivePeerDependencies:
- axios
'@halo-dev/ui-shared@2.25.2(@tiptap/pm@3.29.2)(axios@1.19.0)(vue-router@5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(vue@3.5.41(typescript@5.8.3))':
'@halo-dev/ui-shared@2.25.2(@tiptap/pm@3.29.2)(axios@1.19.0)(vue-router@5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(vue@3.5.41(typescript@5.8.3))':
dependencies:
'@halo-dev/api-client': 2.25.2(axios@1.19.0)
'@tiptap/core': 3.29.2(@tiptap/pm@3.29.2)
mitt: 3.0.1
vue: 3.5.41(typescript@5.8.3)
vue-router: 5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3))
vue-router: 5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3))
transitivePeerDependencies:
- '@tiptap/pm'
- axios
@@ -2870,15 +2883,15 @@ snapshots:
'@types/tough-cookie@4.0.5': {}
'@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.8.3))(eslint@9.39.5)(typescript@5.8.3)':
'@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.66.0(eslint@9.39.5)(typescript@5.8.3)
'@typescript-eslint/parser': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
'@typescript-eslint/scope-manager': 8.66.0
'@typescript-eslint/type-utils': 8.66.0(eslint@9.39.5)(typescript@5.8.3)
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5)(typescript@5.8.3)
'@typescript-eslint/type-utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
'@typescript-eslint/visitor-keys': 8.66.0
eslint: 9.39.5
eslint: 9.39.5(jiti@2.7.0)
ignore: 7.0.6
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@5.8.3)
@@ -2886,14 +2899,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.8.3)':
'@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.66.0
'@typescript-eslint/types': 8.66.0
'@typescript-eslint/typescript-estree': 8.66.0(typescript@5.8.3)
'@typescript-eslint/visitor-keys': 8.66.0
debug: 4.4.3
eslint: 9.39.5
eslint: 9.39.5(jiti@2.7.0)
typescript: 5.8.3
transitivePeerDependencies:
- supports-color
@@ -2916,13 +2929,13 @@ snapshots:
dependencies:
typescript: 5.8.3
'@typescript-eslint/type-utils@8.66.0(eslint@9.39.5)(typescript@5.8.3)':
'@typescript-eslint/type-utils@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)':
dependencies:
'@typescript-eslint/types': 8.66.0
'@typescript-eslint/typescript-estree': 8.66.0(typescript@5.8.3)
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5)(typescript@5.8.3)
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
debug: 4.4.3
eslint: 9.39.5
eslint: 9.39.5(jiti@2.7.0)
ts-api-utils: 2.5.0(typescript@5.8.3)
typescript: 5.8.3
transitivePeerDependencies:
@@ -2945,13 +2958,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.66.0(eslint@9.39.5)(typescript@5.8.3)':
'@typescript-eslint/utils@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)':
dependencies:
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5)
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0))
'@typescript-eslint/scope-manager': 8.66.0
'@typescript-eslint/types': 8.66.0
'@typescript-eslint/typescript-estree': 8.66.0(typescript@5.8.3)
eslint: 9.39.5
eslint: 9.39.5(jiti@2.7.0)
typescript: 5.8.3
transitivePeerDependencies:
- supports-color
@@ -2961,21 +2974,21 @@ snapshots:
'@typescript-eslint/types': 8.66.0
eslint-visitor-keys: 5.0.1
'@vitejs/plugin-vue@6.0.8(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3))':
'@vitejs/plugin-vue@6.0.8(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)
vite: 8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)
vue: 3.5.41(typescript@5.8.3)
'@vitest/eslint-plugin@1.6.26(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.8.3))(eslint@9.39.5)(typescript@5.8.3))(eslint@9.39.5)(typescript@5.8.3)(vitest@4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)))':
'@vitest/eslint-plugin@1.6.26(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)(vitest@4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)))':
dependencies:
'@typescript-eslint/scope-manager': 8.66.0
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5)(typescript@5.8.3)
eslint: 9.39.5
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
eslint: 9.39.5(jiti@2.7.0)
optionalDependencies:
'@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.8.3))(eslint@9.39.5)(typescript@5.8.3)
'@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
typescript: 5.8.3
vitest: 4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))
vitest: 4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))
transitivePeerDependencies:
- supports-color
@@ -2988,13 +3001,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.1
'@vitest/mocker@4.1.10(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))':
'@vitest/mocker@4.1.10(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.1.10
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)
vite: 8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)
'@vitest/pretty-format@4.1.10':
dependencies:
@@ -3085,23 +3098,23 @@ snapshots:
'@vue/devtools-shared@8.2.1': {}
'@vue/eslint-config-prettier@10.2.0(eslint@9.39.5)(prettier@3.9.6)':
'@vue/eslint-config-prettier@10.2.0(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.6)':
dependencies:
eslint: 9.39.5
eslint-config-prettier: 10.1.8(eslint@9.39.5)
eslint-plugin-prettier: 5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.5))(eslint@9.39.5)(prettier@3.9.6)
eslint: 9.39.5(jiti@2.7.0)
eslint-config-prettier: 10.1.8(eslint@9.39.5(jiti@2.7.0))
eslint-plugin-prettier: 5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.6)
prettier: 3.9.6
transitivePeerDependencies:
- '@types/eslint'
'@vue/eslint-config-typescript@14.9.0(eslint-plugin-vue@10.0.1(eslint@9.39.5)(vue-eslint-parser@10.4.1(eslint@9.39.5)))(eslint@9.39.5)(typescript@5.8.3)':
'@vue/eslint-config-typescript@14.9.0(eslint-plugin-vue@10.0.1(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0))))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)':
dependencies:
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5)(typescript@5.8.3)
eslint: 9.39.5
eslint-plugin-vue: 10.0.1(eslint@9.39.5)(vue-eslint-parser@10.4.1(eslint@9.39.5))
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
eslint: 9.39.5(jiti@2.7.0)
eslint-plugin-vue: 10.0.1(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0)))
fast-glob: 3.3.3
typescript-eslint: 8.66.0(eslint@9.39.5)(typescript@5.8.3)
vue-eslint-parser: 10.4.1(eslint@9.39.5)
typescript-eslint: 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
vue-eslint-parser: 10.4.1(eslint@9.39.5(jiti@2.7.0))
optionalDependencies:
typescript: 5.8.3
transitivePeerDependencies:
@@ -3307,6 +3320,8 @@ snapshots:
csstype@3.2.3: {}
darkreader@file:../third-party/darkreader: {}
data-urls@5.0.0:
dependencies:
whatwg-mimetype: 4.0.0
@@ -3366,32 +3381,32 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-config-prettier@10.1.8(eslint@9.39.5):
eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)):
dependencies:
eslint: 9.39.5
eslint: 9.39.5(jiti@2.7.0)
eslint-plugin-oxlint@0.16.12:
dependencies:
jsonc-parser: 3.3.1
eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.5))(eslint@9.39.5)(prettier@3.9.6):
eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.6):
dependencies:
eslint: 9.39.5
eslint: 9.39.5(jiti@2.7.0)
prettier: 3.9.6
prettier-linter-helpers: 1.0.1
synckit: 0.11.13
optionalDependencies:
eslint-config-prettier: 10.1.8(eslint@9.39.5)
eslint-config-prettier: 10.1.8(eslint@9.39.5(jiti@2.7.0))
eslint-plugin-vue@10.0.1(eslint@9.39.5)(vue-eslint-parser@10.4.1(eslint@9.39.5)):
eslint-plugin-vue@10.0.1(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0))):
dependencies:
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5)
eslint: 9.39.5
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0))
eslint: 9.39.5(jiti@2.7.0)
natural-compare: 1.4.0
nth-check: 2.1.1
postcss-selector-parser: 6.1.4
semver: 7.8.5
vue-eslint-parser: 10.4.1(eslint@9.39.5)
vue-eslint-parser: 10.4.1(eslint@9.39.5(jiti@2.7.0))
xml-name-validator: 4.0.0
eslint-scope@8.4.0:
@@ -3412,9 +3427,9 @@ snapshots:
eslint-visitor-keys@5.0.1: {}
eslint@9.39.5:
eslint@9.39.5(jiti@2.7.0):
dependencies:
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5)
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.2
'@eslint/config-helpers': 0.4.2
@@ -3448,6 +3463,8 @@ snapshots:
minimatch: 3.1.5
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
jiti: 2.7.0
transitivePeerDependencies:
- supports-color
@@ -3676,6 +3693,8 @@ snapshots:
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
jiti@2.7.0: {}
js-beautify@1.15.4:
dependencies:
config-chain: 1.1.13
@@ -4259,13 +4278,13 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
typescript-eslint@8.66.0(eslint@9.39.5)(typescript@5.8.3):
typescript-eslint@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.8.3))(eslint@9.39.5)(typescript@5.8.3)
'@typescript-eslint/parser': 8.66.0(eslint@9.39.5)(typescript@5.8.3)
'@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
'@typescript-eslint/parser': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
'@typescript-eslint/typescript-estree': 8.66.0(typescript@5.8.3)
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5)(typescript@5.8.3)
eslint: 9.39.5
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
eslint: 9.39.5(jiti@2.7.0)
typescript: 5.8.3
transitivePeerDependencies:
- supports-color
@@ -4298,7 +4317,7 @@ snapshots:
picomatch: 4.0.5
webpack-virtual-modules: 0.6.2
unplugin@3.3.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)):
unplugin@3.3.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)):
dependencies:
'@jridgewell/remapping': 2.3.5
picomatch: 4.0.5
@@ -4306,7 +4325,7 @@ snapshots:
optionalDependencies:
'@rspack/core': 2.1.8(@swc/helpers@0.5.23)
rolldown: 1.2.3
vite: 8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)
vite: 8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)
uri-js@4.4.1:
dependencies:
@@ -4314,7 +4333,7 @@ snapshots:
util-deprecate@1.0.2: {}
vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0):
vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.5
@@ -4324,13 +4343,14 @@ snapshots:
optionalDependencies:
'@types/node': 24.13.3
fsevents: 2.3.3
jiti: 2.7.0
sass: 1.102.0
yaml: 2.9.0
vitest@4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)):
vitest@4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.10
'@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))
'@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.10
'@vitest/runner': 4.1.10
'@vitest/snapshot': 4.1.10
@@ -4347,7 +4367,7 @@ snapshots:
tinyexec: 1.3.0
tinyglobby: 0.2.17
tinyrainbow: 3.1.1
vite: 8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)
vite: 8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.13.3
@@ -4359,10 +4379,10 @@ snapshots:
vue-component-type-helpers@3.3.9: {}
vue-eslint-parser@10.4.1(eslint@9.39.5):
vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0)):
dependencies:
debug: 4.4.3
eslint: 9.39.5
eslint: 9.39.5(jiti@2.7.0)
eslint-scope: 9.1.2
eslint-visitor-keys: 5.0.1
espree: 11.2.0
@@ -4375,7 +4395,7 @@ snapshots:
dependencies:
vue: 3.5.41(typescript@5.8.3)
vue-router@5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)):
vue-router@5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)):
dependencies:
'@babel/generator': 8.0.0
'@vue-macros/common': 3.1.4(vue@3.5.41(typescript@5.8.3))
@@ -4392,13 +4412,13 @@ snapshots:
picomatch: 4.0.5
scule: 1.3.0
tinyglobby: 0.2.17
unplugin: 3.3.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))
unplugin: 3.3.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))
unplugin-utils: 0.3.2
vue: 3.5.41(typescript@5.8.3)
yaml: 2.9.0
optionalDependencies:
'@vue/compiler-sfc': 3.5.41
vite: 8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)
vite: 8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)
transitivePeerDependencies:
- '@farmfe/core'
- '@rspack/core'
-46
View File
@@ -1,46 +0,0 @@
<script setup lang="ts">
import { IconMoon, IconSunny } from '@halo-dev/components'
import { useDarkMode } from '../composables/useDarkMode'
const { isDark, toggle } = useDarkMode()
</script>
<template>
<div class="theme-toggle" @click="toggle">
<IconSunny v-if="isDark" class="theme-toggle__icon" />
<IconMoon v-else class="theme-toggle__icon" />
<span class="theme-toggle__label">
{{ isDark ? '浅色模式' : '深色模式' }}
</span>
</div>
</template>
<style scoped>
.theme-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
color: var(--halo-text-secondary);
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__icon {
width: 1.125rem;
height: 1.125rem;
flex-shrink: 0;
}
.theme-toggle__label {
font-size: 0.8125rem;
white-space: nowrap;
}
</style>
@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
const STORAGE_KEY = 'halo-dark-mode-theme'
function mockMatchMedia(matches: boolean): void {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}))
}
describe('useDarkMode', () => {
let useDarkMode: typeof import('../useDarkMode').useDarkMode
async function load(systemDark = false): Promise<void> {
vi.resetModules()
localStorage.clear()
mockMatchMedia(systemDark)
const mod = await import('../useDarkMode')
useDarkMode = mod.useDarkMode
}
beforeEach(async () => {
await load(false)
})
it('默认 auto 且系统为深色时 isDark 为 true', async () => {
await load(true)
const { theme, isDark } = useDarkMode()
expect(theme.value).toBe('auto')
expect(isDark.value).toBe(true)
})
it('auto 模式下点击切换变为显式深色并持久化', async () => {
const { theme, toggle } = useDarkMode()
toggle()
expect(theme.value).toBe('dark')
await nextTick()
expect(localStorage.getItem(STORAGE_KEY)).toBe('dark')
})
it('auto 且系统为深色时点击切换变为显式浅色', async () => {
await load(true)
const { theme, toggle } = useDarkMode()
toggle()
expect(theme.value).toBe('light')
await nextTick()
expect(localStorage.getItem(STORAGE_KEY)).toBe('light')
})
it('dark 与 light 之间往返切换', async () => {
const { theme, toggle } = useDarkMode()
theme.value = 'dark'
toggle()
expect(theme.value).toBe('light')
toggle()
expect(theme.value).toBe('dark')
await nextTick()
expect(localStorage.getItem(STORAGE_KEY)).toBe('dark')
})
it('setTheme 会持久化到 localStorage', async () => {
const { setTheme } = useDarkMode()
setTheme('dark')
await nextTick()
expect(localStorage.getItem(STORAGE_KEY)).toBe('dark')
})
it('localStorage 非法值回退到 auto', async () => {
localStorage.setItem(STORAGE_KEY, 'not-a-theme')
await load(false)
const { theme } = useDarkMode()
expect(theme.value).toBe('auto')
})
it('storage 事件会同步其他标签页的主题', () => {
const { theme, setTheme } = useDarkMode()
setTheme('light')
window.dispatchEvent(
new StorageEvent('storage', {
key: STORAGE_KEY,
newValue: 'dark',
}),
)
expect(theme.value).toBe('dark')
})
it('storage 事件忽略非法值', () => {
const { theme, setTheme } = useDarkMode()
setTheme('light')
window.dispatchEvent(
new StorageEvent('storage', {
key: STORAGE_KEY,
newValue: 'invalid',
}),
)
expect(theme.value).toBe('light')
})
})
+18 -3
View File
@@ -26,13 +26,19 @@ function persistTheme(theme: ThemeMode): void {
}
}
/** 将 data-halo-theme 属性应用到 <html> 元素 */
/**
* 将主题状态同步到 <html>。
* data-halo-theme 为兼容性遗留标记,当前已无 CSS 消费方,保留给外部脚本与验证工具;
* color-scheme 用于在 Dark Reader 异步注入前同步浏览器控件外观,缓解 FOUC。
*/
function applyHtmlAttribute(isDark: boolean): void {
const root = document.documentElement
if (isDark) {
document.documentElement.setAttribute('data-halo-theme', 'dark')
root.setAttribute('data-halo-theme', 'dark')
} else {
document.documentElement.removeAttribute('data-halo-theme')
root.removeAttribute('data-halo-theme')
}
root.style.colorScheme = isDark ? 'dark' : 'light'
}
// 模块级单例 — 所有组件共享同一个状态
@@ -52,6 +58,15 @@ watch(isDark, (dark) => applyHtmlAttribute(dark), { immediate: true })
// 监听 theme 变化 → 持久化
watch(theme, (t) => persistTheme(t))
// 多标签页同步:其他标签页修改主题时跟随更新
window.addEventListener('storage', (event) => {
if (event.key !== STORAGE_KEY) return
const value = event.newValue
if (value === 'light' || value === 'dark' || value === 'auto') {
theme.value = value
}
})
// 监听系统偏好变化 → 仅在 'auto' 模式下响应
onChange((systemDark) => {
if (theme.value === 'auto') {
+49
View File
@@ -0,0 +1,49 @@
import { disable, enable } from 'darkreader'
import { watch } from 'vue'
import { useDarkMode } from './composables/useDarkMode'
/**
* Dark Reader 引擎参数。
* 纯 Dark Reader 策略下不再维护手工 CSS 覆盖;
* 这里只调整通用观感参数,让核心与第三方插件页面转换后风格统一。
* 注意:DR 注入为异步过程,刷新闪烁只能由 useDarkMode 的 color-scheme 同步缓解。
*/
const DARK_READER_THEME = {
brightness: 100,
contrast: 90,
grayscale: 0,
sepia: 0,
darkSchemeBackgroundColor: '#181b20',
darkSchemeTextColor: '#e8e6e3',
scrollbarColor: '#3a3f4a',
selectionColor: '#2f6f7a',
styleSystemControls: true,
} as const
let initialized = false
/**
* 初始化 Dark Reader 通用暗色引擎。
* 监听 useDarkMode 的 isDark 状态,深色时启用,浅色时关闭。
*/
export function initDarkReaderEngine(): void {
if (initialized) return
initialized = true
const { isDark } = useDarkMode()
watch(
isDark,
(dark) => {
try {
if (dark) {
enable(DARK_READER_THEME)
} else {
disable()
}
} catch (error) {
console.error('[dark-mode] Dark Reader 引擎异常', error)
}
},
{ immediate: true },
)
}
+7 -3
View File
@@ -1,7 +1,10 @@
import { definePlugin } from '@halo-dev/ui-shared'
import { IconMoon } from '@halo-dev/components'
import { IconPalette } from '@halo-dev/components'
import { markRaw } from 'vue'
import './styles/index.css'
import { initDarkReaderEngine } from './darkreader-engine'
initDarkReaderEngine()
export default definePlugin({
components: {},
@@ -17,8 +20,9 @@ export default definePlugin({
searchable: true,
menu: {
name: '深色模式',
group: '偏好设置',
icon: markRaw(IconMoon),
// Halo 官方「外观」分组
group: 'interface',
icon: markRaw(IconPalette),
priority: 50,
},
},
+1 -7
View File
@@ -1,12 +1,6 @@
/* ============================================================
Halo Dark Mode — 样式入口
按加载顺序导入所有样式文件
仅保留插件自身 UI 变量;页面暗色转换完全交给 Dark Reader。
============================================================ */
@import './variables.css';
@import './overrides/utilities.css';
@import './overrides/layout.css';
@import './overrides/components.css';
@import './overrides/forms.css';
@import './overrides/editor.css';
@import './overrides/scrollbar.css';
-142
View File
@@ -1,142 +0,0 @@
/* ============================================================
Halo Dark Mode — @halo-dev/components 组件库覆盖
覆盖 Halo 组件库中的 VCard, VModal, VDropdown, VTag 等
============================================================ */
[data-halo-theme="dark"] {
/* ===== 卡片 VCard ===== */
.v-card,
[class*="v-card"] {
background-color: var(--halo-bg-card);
border-color: var(--halo-border-base);
color: var(--halo-text-primary);
}
/* ===== 模态框 VModal ===== */
.v-modal,
.modal-container,
[class*="modal"] {
background-color: var(--halo-bg-card);
color: var(--halo-text-primary);
}
/* ===== 下拉菜单 VDropdown ===== */
.v-dropdown,
.dropdown-menu,
[class*="dropdown"] {
background-color: var(--halo-bg-dropdown);
border-color: var(--halo-border-base);
color: var(--halo-text-primary);
}
.v-dropdown-item:hover,
.dropdown-item:hover {
background-color: var(--halo-bg-hover);
}
/* ===== 提示框 VTooltip ===== */
.v-tooltip,
[class*="tooltip"] {
background-color: var(--halo-bg-tooltip);
color: var(--halo-text-inverse);
}
/* ===== 标签/徽章 VTag, VBadge ===== */
.v-tag,
[class*="tag"],
.v-badge,
[class*="badge"] {
background-color: var(--halo-tag-bg);
color: var(--halo-tag-text);
}
/* ===== 按钮 ===== */
.btn-default,
.btn-secondary,
button:not([class*="btn-primary"]):not([class*="btn-danger"]) {
background-color: var(--halo-bg-card);
color: var(--halo-text-primary);
border-color: var(--halo-border-base);
}
.btn-default:hover,
.btn-secondary:hover {
background-color: var(--halo-bg-hover);
}
/* ===== 表格 VTable ===== */
table,
.v-table,
[class*="table"] {
background-color: var(--halo-bg-card);
color: var(--halo-text-primary);
}
thead,
.table-header {
background-color: var(--halo-table-header-bg);
}
thead th,
.table-header th {
color: var(--halo-text-secondary);
border-color: var(--halo-table-border);
}
tbody td,
.table-body td {
border-color: var(--halo-table-border);
}
tbody tr:hover,
.table-row:hover {
background-color: var(--halo-table-row-hover);
}
/* ===== 分页 ===== */
.pagination,
.v-pagination {
color: var(--halo-text-secondary);
}
.pagination .active,
.v-pagination .active {
background-color: var(--halo-accent-primary);
color: var(--halo-accent-primary-text);
}
/* ===== 面包屑 ===== */
.breadcrumb,
.v-breadcrumb {
color: var(--halo-text-secondary);
}
.breadcrumb a,
.v-breadcrumb a {
color: var(--halo-text-link);
}
/* ===== Toast / 通知 ===== */
.v-toast,
.toast-notification,
[class*="toast"] {
background-color: var(--halo-bg-card);
color: var(--halo-text-primary);
border-color: var(--halo-border-base);
}
/* ===== 步骤条 ===== */
.v-steps,
[class*="steps"] {
color: var(--halo-text-secondary);
}
/* ===== 开关 VSwitch ===== */
.v-switch-track {
background-color: var(--halo-bg-disabled);
}
.v-switch-track[aria-checked="true"] {
background-color: var(--halo-accent-primary);
}
}
-71
View File
@@ -1,71 +0,0 @@
/* ============================================================
Halo Dark Mode — 富文本编辑器覆盖
@halo-dev/richtext-editor + TipTap / ProseMirror
============================================================ */
[data-halo-theme="dark"] {
/* ===== ProseMirror / TipTap 编辑器主体 ===== */
.ProseMirror,
.tiptap,
[class*="editor-content"] {
color: var(--halo-text-primary);
background-color: var(--halo-bg-input);
}
/* ===== 编辑器菜单栏 ===== */
.editor-toolbar,
.editor-menubar,
[class*="editor-toolbar"],
[class*="editor-menubar"] {
background-color: var(--halo-bg-card);
border-color: var(--halo-border-base);
}
.editor-toolbar button,
.editor-menubar button {
color: var(--halo-text-secondary);
}
.editor-toolbar button:hover,
.editor-menubar button:hover,
.editor-toolbar button.is-active,
.editor-menubar button.is-active {
background-color: var(--halo-bg-hover);
color: var(--halo-text-primary);
}
/* ===== 代码块 ===== */
.ProseMirror pre,
.ProseMirror code,
.tiptap pre,
.tiptap code {
background-color: oklch(20% 0.015 250);
color: oklch(85% 0.03 160);
}
/* ===== Bubble menu / floating menu ===== */
.tippy-box,
.tiptap-bubble-menu,
.floating-menu {
background-color: var(--halo-bg-dropdown);
border-color: var(--halo-border-base);
color: var(--halo-text-primary);
}
/* ===== 编辑器占位符 ===== */
.ProseMirror p.is-editor-empty:first-child::before {
color: var(--halo-text-tertiary);
}
/* ===== 链接 ===== */
.ProseMirror a,
.tiptap a {
color: var(--halo-text-link);
}
/* ===== 选中文字 ===== */
.ProseMirror ::selection,
.tiptap ::selection {
background-color: oklch(30% 0.04 160 / 50%);
}
}
-89
View File
@@ -1,89 +0,0 @@
/* ============================================================
Halo Dark Mode — FormKit 表单组件覆盖
覆盖输入框、选择器、开关、复选框等表单元素
============================================================ */
[data-halo-theme="dark"] {
/* ===== 输入框 ===== */
input[type="text"],
input[type="password"],
input[type="email"],
input[type="number"],
input[type="search"],
input[type="url"],
input[type="tel"],
textarea,
select,
.formkit-input,
[data-formkit-input] {
background-color: var(--halo-bg-input);
color: var(--halo-text-primary);
border-color: var(--halo-border-input);
}
input:focus,
textarea:focus,
select:focus,
.formkit-input:focus {
border-color: var(--halo-border-focus);
outline-color: var(--halo-border-focus);
}
input::placeholder,
textarea::placeholder,
.formkit-input::placeholder {
color: var(--halo-text-tertiary);
}
input:disabled,
textarea:disabled,
select:disabled {
background-color: var(--halo-bg-disabled);
color: var(--halo-text-tertiary);
}
/* ===== 复选框/单选框 ===== */
input[type="checkbox"],
input[type="radio"],
.formkit-checkbox,
.formkit-radio {
accent-color: var(--halo-accent-primary);
}
/* ===== FormKit 标签和帮助文本 ===== */
.formkit-label,
.formkit-legend {
color: var(--halo-text-secondary);
}
.formkit-help,
.formkit-message {
color: var(--halo-text-tertiary);
}
/* ===== FormKit 外层 ===== */
.formkit-outer {
color: var(--halo-text-primary);
}
/* ===== FormKit 前缀/后缀 ===== */
.formkit-prefix,
.formkit-suffix {
background-color: var(--halo-bg-hover);
color: var(--halo-text-secondary);
border-color: var(--halo-border-input);
}
/* ===== 选择器下拉 ===== */
select option {
background-color: var(--halo-bg-dropdown);
color: var(--halo-text-primary);
}
/* ===== 代码块 in forms ===== */
.formkit-code {
background-color: var(--halo-bg-hover);
color: var(--halo-text-primary);
border-color: var(--halo-border-base);
}
}
-62
View File
@@ -1,62 +0,0 @@
/* ============================================================
Halo Dark Mode — 核心布局覆盖
覆盖 BasicLayout.vue 的硬编码颜色
============================================================ */
[data-halo-theme="dark"] {
/* ===== 页面整体 ===== */
.layout {
background-color: var(--halo-bg-body);
}
/* ===== 侧边栏 ===== */
.sidebar {
background-color: var(--halo-bg-sidebar);
box-shadow: var(--halo-shadow-sm);
}
.sidebar__search {
background-color: var(--halo-search-bg);
color: var(--halo-search-placeholder);
}
.sidebar__search:hover {
color: var(--halo-search-text);
}
.sidebar__search-icon {
color: inherit;
}
.sidebar__search-shortcut {
color: var(--halo-text-tertiary);
}
.sidebar__logo {
filter: brightness(0.9) invert(0);
}
.sidebar__profile {
border-top-color: var(--halo-border-light);
}
/* ===== 内容区 ===== */
.main-content {
background-color: var(--halo-bg-content);
color: var(--halo-text-primary);
}
.main-content__footer-text {
color: var(--halo-text-tertiary);
}
.main-content__footer-link {
color: var(--halo-text-link);
}
/* ===== 页面标题 ===== */
.page-title,
.v-card-title {
color: var(--halo-text-primary);
}
}
-48
View File
@@ -1,48 +0,0 @@
/* ============================================================
Halo Dark Mode — 滚动条覆盖
OverlayScrollbars + 原生滚动条
============================================================ */
[data-halo-theme="dark"] {
/* ===== OverlayScrollbars ===== */
.os-scrollbar .os-scrollbar-handle {
background-color: var(--halo-scrollbar-thumb);
}
.os-scrollbar .os-scrollbar-track {
background-color: var(--halo-scrollbar-track);
}
.os-scrollbar .os-scrollbar-handle:hover {
background-color: oklch(45% 0.03 250);
}
/* ===== 原生滚动条(Firefox ===== */
* {
scrollbar-color: var(--halo-scrollbar-thumb) var(--halo-scrollbar-track);
scrollbar-width: thin;
}
/* ===== 原生滚动条(WebkitChrome/Edge/Safari ===== */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--halo-scrollbar-track);
}
::-webkit-scrollbar-thumb {
background: var(--halo-scrollbar-thumb);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(45% 0.03 250);
}
::-webkit-scrollbar-corner {
background: var(--halo-scrollbar-track);
}
}
-77
View File
@@ -1,77 +0,0 @@
/* ============================================================
Halo Dark Mode — Tailwind 工具类覆盖
覆盖 Tailwind 的颜色类和常见 UI 原子类
============================================================ */
[data-halo-theme="dark"] {
/* ===== 背景色 ===== */
.bg-white {
background-color: var(--halo-bg-card);
}
.bg-gray-50,
.bg-gray-100 {
background-color: var(--halo-bg-content);
}
.bg-gray-200 {
background-color: var(--halo-bg-hover);
}
/* ===== 文字色 ===== */
.text-gray-900,
.text-gray-800 {
color: var(--halo-text-primary);
}
.text-gray-700,
.text-gray-600,
.text-gray-500 {
color: var(--halo-text-secondary);
}
.text-gray-400,
.text-gray-300 {
color: var(--halo-text-tertiary);
}
/* ===== 边框 ===== */
.border,
.border-gray-200,
.border-gray-300 {
border-color: var(--halo-border-base);
}
.divide-y > :not([hidden]) ~ :not([hidden]),
.divide-x > :not([hidden]) ~ :not([hidden]) {
border-color: var(--halo-border-light);
}
/* ===== 阴影 ===== */
.shadow {
box-shadow: var(--halo-shadow-base);
}
.shadow-sm {
box-shadow: var(--halo-shadow-sm);
}
.shadow-lg,
.shadow-xl {
box-shadow: var(--halo-shadow-lg);
}
/* ===== 链接 ===== */
a:not([class*="text-"]),
.hover\:text-gray-900:hover,
.hover\:text-gray-800:hover,
.hover\:text-gray-700:hover {
color: var(--halo-text-link);
}
/* ===== 通用悬停 ===== */
.hover\:bg-gray-50:hover,
.hover\:bg-gray-100:hover {
background-color: var(--halo-bg-hover);
}
}
+7 -104
View File
@@ -3,114 +3,17 @@
配色策略: OKLCH 颜色空间 | 中性色微蓝着色调 | 亮度层次替代阴影
============================================================ */
/* 说明:data-halo-theme 为兼容性遗留标记,当前已无 CSS 消费方,保留给外部脚本与验证工具。 */
/* ===== 浅色模式(Halo 默认,此处定义为显式回退) ===== */
:root {
--halo-bg-body: oklch(97% 0.005 250);
--halo-bg-sidebar: oklch(100% 0 0);
--halo-bg-content: oklch(97% 0.005 250);
--halo-bg-card: oklch(100% 0 0);
--halo-bg-input: oklch(100% 0 0);
--halo-bg-hover: oklch(95% 0.01 250);
--halo-bg-active: oklch(90% 0.02 160);
--halo-bg-disabled: oklch(95% 0.005 250);
--halo-bg-tooltip: oklch(20% 0.01 250);
--halo-bg-modal: oklch(0% 0 0 / 60%);
--halo-bg-dropdown: oklch(100% 0 0);
--halo-text-primary: oklch(20% 0.01 250);
--halo-text-tertiary: oklch(60% 0.01 250);
--halo-text-primary: oklch(20% 0.01 250);
--halo-text-secondary: oklch(45% 0.01 250);
--halo-text-tertiary: oklch(60% 0.01 250);
--halo-text-link: oklch(45% 0.15 160);
--halo-text-inverse: oklch(100% 0 0);
--halo-bg-hover: oklch(95% 0.01 250);
--halo-border-base: oklch(88% 0.01 250);
--halo-border-light: oklch(93% 0.005 250);
--halo-border-input: oklch(80% 0.01 250);
--halo-border-focus: oklch(55% 0.15 160);
--halo-border-base: oklch(88% 0.01 250);
--halo-accent-primary: oklch(55% 0.14 160);
--halo-accent-primary-hover: oklch(48% 0.15 160);
--halo-accent-primary-text: oklch(100% 0 0);
--halo-accent-danger: oklch(45% 0.18 25);
--halo-accent-danger-hover: oklch(40% 0.19 25);
--halo-accent-success: oklch(50% 0.16 150);
--halo-accent-warning: oklch(60% 0.16 85);
--halo-accent-primary: oklch(55% 0.14 160);
--halo-shadow-sm: 0 1px 2px oklch(0% 0 0 / 6%);
--halo-shadow-base: 0 2px 8px oklch(0% 0 0 / 10%);
--halo-shadow-lg: 0 4px 16px oklch(0% 0 0 / 14%);
--halo-scrollbar-thumb: oklch(80% 0.01 250);
--halo-scrollbar-track: oklch(95% 0.005 250);
--halo-search-bg: oklch(95% 0.01 250);
--halo-search-text: oklch(45% 0.01 250);
--halo-search-placeholder: oklch(60% 0.01 250);
--halo-menu-item-hover: oklch(93% 0.02 160);
--halo-menu-item-active: oklch(88% 0.03 160);
--halo-menu-group-title: oklch(55% 0.01 250);
--halo-table-header-bg: oklch(96% 0.005 250);
--halo-table-row-hover: oklch(93% 0.015 250);
--halo-table-border: oklch(88% 0.01 250);
--halo-tag-bg: oklch(90% 0.04 160);
--halo-tag-text: oklch(40% 0.1 160);
}
/* ===== 深色模式 ===== */
[data-halo-theme="dark"] {
--halo-bg-body: oklch(14% 0.01 250);
--halo-bg-sidebar: oklch(16% 0.015 250);
--halo-bg-content: oklch(14% 0.01 250);
--halo-bg-card: oklch(18% 0.015 250);
--halo-bg-input: oklch(20% 0.015 250);
--halo-bg-hover: oklch(24% 0.02 250);
--halo-bg-active: oklch(28% 0.03 160);
--halo-bg-disabled: oklch(16% 0.005 250);
--halo-bg-tooltip: oklch(25% 0.01 250);
--halo-bg-modal: oklch(0% 0 0 / 60%);
--halo-bg-dropdown: oklch(20% 0.015 250);
--halo-text-primary: oklch(92% 0.005 250);
--halo-text-secondary: oklch(70% 0.01 250);
--halo-text-tertiary: oklch(50% 0.01 250);
--halo-text-link: oklch(72% 0.14 160);
--halo-text-inverse: oklch(14% 0.01 250);
--halo-border-base: oklch(28% 0.015 250);
--halo-border-light: oklch(22% 0.01 250);
--halo-border-input: oklch(30% 0.015 250);
--halo-border-focus: oklch(65% 0.14 160);
--halo-accent-primary: oklch(60% 0.13 160);
--halo-accent-primary-hover: oklch(66% 0.12 160);
--halo-accent-primary-text: oklch(14% 0.02 160);
--halo-accent-danger: oklch(50% 0.18 25);
--halo-accent-danger-hover: oklch(56% 0.17 25);
--halo-accent-success: oklch(58% 0.16 150);
--halo-accent-warning: oklch(65% 0.16 85);
--halo-shadow-sm: 0 1px 2px oklch(0% 0 0 / 30%);
--halo-shadow-base: 0 2px 8px oklch(0% 0 0 / 40%);
--halo-shadow-lg: 0 4px 16px oklch(0% 0 0 / 50%);
--halo-scrollbar-thumb: oklch(35% 0.02 250);
--halo-scrollbar-track: oklch(18% 0.01 250);
--halo-search-bg: oklch(20% 0.015 250);
--halo-search-text: oklch(70% 0.01 250);
--halo-search-placeholder: oklch(50% 0.01 250);
--halo-menu-item-hover: oklch(22% 0.02 160);
--halo-menu-item-active: oklch(28% 0.04 160);
--halo-menu-group-title: oklch(55% 0.01 250);
--halo-table-header-bg: oklch(18% 0.01 250);
--halo-table-row-hover: oklch(22% 0.015 250);
--halo-table-border: oklch(26% 0.015 250);
--halo-tag-bg: oklch(22% 0.03 160);
--halo-tag-text: oklch(80% 0.08 160);
--halo-menu-item-active: oklch(88% 0.03 160);
}
+46 -62
View File
@@ -1,6 +1,14 @@
<script setup lang="ts">
import type { ThemeMode } from '../composables/useDarkMode'
import { useDarkMode } from '../composables/useDarkMode'
import {
IconPalette,
VCard,
VDescription,
VDescriptionItem,
VPageHeader,
VTag,
} from '@halo-dev/components'
import { computed } from 'vue'
const { theme, isDark, setTheme } = useDarkMode()
@@ -20,86 +28,62 @@ const modeOptions: { value: ThemeMode; label: string; description: string }[] =
</script>
<template>
<div class="dark-mode-settings">
<div class="dark-mode-settings__header">
<h1 class="dark-mode-settings__title">深色模式设置</h1>
<p class="dark-mode-settings__desc">选择后台管理面板的显示模式</p>
</div>
<div>
<VPageHeader title="深色模式设置">
<template #icon>
<IconPalette />
</template>
</VPageHeader>
<div class="dark-mode-settings__card">
<div class="dark-mode-settings__current">
当前生效<strong>{{ currentEffectiveMode }}</strong>
</div>
<div class="m-0 md:m-4">
<VCard :body-class="['!p-0']">
<div class="p-4">
<VDescription>
<VDescriptionItem label="当前生效">
<VTag>{{ currentEffectiveMode }}</VTag>
</VDescriptionItem>
</VDescription>
<div class="dark-mode-settings__options">
<div
v-for="option in modeOptions"
:key="option.value"
class="dark-mode-settings__option"
:class="{ 'is-active': theme === option.value }"
@click="setTheme(option.value)"
>
<div class="dark-mode-settings__option-label">{{ option.label }}</div>
<div class="dark-mode-settings__option-desc">{{ option.description }}</div>
<div class="dark-mode-settings__options">
<button
v-for="option in modeOptions"
:key="option.value"
type="button"
class="dark-mode-settings__option"
:class="{ 'is-active': theme === option.value }"
@click="setTheme(option.value)"
>
<div class="dark-mode-settings__option-label">{{ option.label }}</div>
<div class="dark-mode-settings__option-desc">{{ option.description }}</div>
</button>
</div>
</div>
</div>
</VCard>
</div>
</div>
</template>
<style scoped>
.dark-mode-settings {
max-width: 640px;
padding: 1.5rem;
}
.dark-mode-settings__header {
margin-bottom: 1.5rem;
}
.dark-mode-settings__title {
font-size: 1.25rem;
font-weight: 600;
color: var(--halo-text-primary);
margin-bottom: 0.25rem;
}
.dark-mode-settings__desc {
font-size: 0.875rem;
color: var(--halo-text-secondary);
}
.dark-mode-settings__card {
padding: 1.25rem;
background-color: var(--halo-bg-card);
border: 1px solid var(--halo-border-base);
border-radius: 0.25rem;
}
.dark-mode-settings__current {
font-size: 0.875rem;
color: var(--halo-text-secondary);
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--halo-border-light);
}
.dark-mode-settings__current strong {
color: var(--halo-accent-primary);
}
.dark-mode-settings__options {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
}
.dark-mode-settings__option {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--halo-border-base);
background: transparent;
color: inherit;
font: inherit;
text-align: left;
border-radius: 0.375rem;
cursor: pointer;
border: 1px solid var(--halo-border-base);
transition: background-color 0.15s, border-color 0.15s;
transition:
background-color 0.15s,
border-color 0.15s;
}
.dark-mode-settings__option:hover {
View File
-893
View File
@@ -1,893 +0,0 @@
# Halo 黑暗模式插件 — 设计文档
> 版本:v0.2.0-draft(基于 create-halo-plugin + dev-skills 调查更新)
> 日期:2026-08-06
> 状态:待审阅
> 上一步:[调查文档](./调查文档.md)(含 0.4 节补充调查更新)
---
## 目录
1. [设计目标与范围](#1-设计目标与范围)
2. [技术架构](#2-技术架构)
3. [CSS 变量体系设计](#3-css-变量体系设计)
4. [黑暗模式调色板](#4-黑暗模式调色板)
5. [组件覆盖策略](#5-组件覆盖策略)
6. [切换器 UI 设计](#6-切换器-ui-设计)
7. [路由与菜单](#7-路由与菜单)
8. [偏好持久化](#8-偏好持久化)
9. [项目文件结构](#9-项目文件结构)
10. [实现阶段划分](#10-实现阶段划分)
11. [测试策略](#11-测试策略)
12. [兼容性矩阵](#12-兼容性矩阵)
---
## 1. 设计目标与范围
### 1.1 核心目标
将 Halo 后台管理面板(Console)从纯浅色模式改造为支持浅色/黑暗双模式,**不修改 Halo 核心代码**,完全通过插件机制实现。
### 1.2 范围界定
| 范围 | 包含 | 不包含 |
|------|------|--------|
| 页面 | Halo Console(后台管理)全体页面 | 用户中心 (uc-src)、前台主题 |
| 组件 | Halo 核心组件 + `@halo-dev/components` 组件库 | 第三方插件自有 UI |
| 编辑器 | FormKit 表单 + 富文本编辑器 | 编辑器内容区自定义样式 |
| 模式 | 浅色 ↔ 黑暗手动切换 + 跟随系统 | 定时切换、多主题 |
### 1.3 非功能性目标
- **性能**CSS 变量切换应 < 50ms,无可见闪烁(FOUC
- **可访问性**:黑暗模式下所有文本满足 WCAG AA 对比度要求(≥ 4.5:1
- **兼容性**:支持 Halo ≥ 2.23.0(对应 plugin-starter 的版本约束)
- **可维护性**:CSS 变量体系命名清晰,一个语义变量对应一个视觉属性
### 1.4 反目标(明确不做)
- ❌ 不美化 UI(不改变布局、圆角、间距、字体等)
- ❌ 不添加任何视觉装饰效果
- ❌ 不修改 Halo 组件库源码
- ❌ 不支持前台主题的暗色化
---
## 2. 技术架构
### 2.1 整体架构图
```
┌──────────────────────────────────────────────────────────┐
│ 插件边界 │
│ │
│ ┌─────────────┐ ┌──────────────────────────────────┐ │
│ │ Java 后端 │ │ 前端 (ui/) │ │
│ │ │ │ │ │
│ │ BasePlugin │ │ ┌────────────────────────────┐ │ │
│ │ ├ start() │ │ │ index.ts (definePlugin) │ │ │
│ │ └ stop() │ │ │ ├ components: { │ │ │
│ │ │ │ │ │ ThemeToggle │ │ │
│ │ (极简骨架) │ │ │ │ } │ │ │
│ └─────────────┘ │ │ ├ routes: [设置页面] │ │ │
│ │ │ └ extensionPoints: {} │ │ │
│ │ └────────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────┐ │ │
│ │ │ composables/ │ │ │
│ │ │ ├ useDarkMode.ts │ │ │
│ │ │ └ useSystemPreference.ts │ │ │
│ │ └────────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────┐ │ │
│ │ │ styles/ │ │ │
│ │ │ ├ variables.css │ │ │
│ │ │ ├ dark-theme.css │ │ │
│ │ │ ├ overrides/ │ │ │
│ │ │ │ ├ layout.css │ │ │
│ │ │ │ ├ components.css │ │ │
│ │ │ │ ├ formkit.css │ │ │
│ │ │ │ ├ editor.css │ │ │
│ │ │ │ └ scrollbar.css │ │ │
│ │ │ └ index.css │ │ │
│ │ └────────────────────────────┘ │ │
│ └──────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 注入方式(Halo 插件加载机制自动处理) │ │
│ │ CSS → /apis/.../ui-plugins/-/bundle.css │ │
│ │ JS → /apis/.../ui-plugins/-/bundle.js │ │
│ └──────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
```
### 2.2 运行时数据流
```
┌──────────────┐
│ App 启动 │
└──────┬───────┘
┌──────▼───────┐
│ 读取持久化偏好 │
│ localStorage │
│ (默认: system)│
└──────┬───────┘
┌────────────┼────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 浅色 │ │ 黑暗 │ │ 跟随系统 │
│ theme= │ │ theme= │ │ theme= │
│ "light" │ │ "dark" │ │ "auto" │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
│ │ ┌─────▼──────┐
│ │ │ 监听 match │
│ │ │ Media query│
│ │ └─────┬──────┘
│ │ │
└────────────┼────────────┘
┌──────▼───────┐
│ 设置 │
│ document │
│ .documentEl │
│ 的 data attr │
│ data-halo- │
│ theme="dark" │
│ 或移除该属性 │
└──────┬───────┘
┌──────▼───────┐
│ CSS 变量切换 │
│ :root vs │
│ [data-halo- │
│ theme="dark"]│
└──────────────┘
```
### 2.3 关键技术决策
| 决策点 | 选择 | 理由 |
|--------|------|------|
| 主题切换方式 | `data-halo-theme` 属性 | 前缀避免冲突,属性选择器高效 |
| 颜色系统 | CSS Variables + OKLCH 颜色空间 | 感知均匀,暗色模式天然适配 |
| 切换状态管理 | Vue composable (`useDarkMode`) | 轻量,无 Pinia 依赖,方便跨组件复用 |
| 持久化存储 | `localStorage` | 极简,零后端依赖,立即可用 |
| 系统偏好监听 | `matchMedia('prefers-color-scheme: dark')` | 标准 API,所有现代浏览器支持 |
| 初始加载防闪烁 | `<script>` 阻塞渲染提前设置属性 | 避免 FOUC |
| 样式注入方式 | 依赖 Halo 插件 CSS bundle 自动加载 | 无需额外代码 |
| 脚手架工具 | `pnpm create halo-plugin` | 官方推荐,替代 plugin-starter |
| `definePlugin` 导入 | `@halo-dev/ui-shared` | create-halo-plugin 模板使用的正确源 |
| BOM 版本 | `2.25.0` | 最新稳定版(非 SNAPSHOT |
| DevTools | `0.8.0` | create-halo-plugin 模板版本 |
| UI 产物路径 | `resources/main/ui/` | create-halo-plugin 模板约定 |
| 路由加载 | `() => import(...)` 懒加载 | 代码分割,提升首屏性能 |
| 开发参考 | `halo-plugin-dev` skill | 官方 AI Agent 开发文档 |
---
## 3. CSS 变量体系设计
### 3.1 设计原则
1. **语义优先**:变量名表达意图(`--halo-bg-sidebar`),而非颜色值(`--halo-gray-900`
2. **完全覆盖**:每个被 Halo 使用的视觉属性都应有对应变量
3. **按区域分层**:布局 → 组件 → 表单 → 编辑器 → 滚动条
4. **OKLCH 颜色空间**:所有颜色值使用 `oklch(L C H)` 格式,保证感知均匀
### 3.2 变量命名规范
```
--halo-{category}-{property}
category:
bg 背景色
text 文字色
border 边框色
accent 强调色(primary/danger/warning/success
shadow 阴影
scroll 滚动条
property:
primary / secondary / tertiary 主要/次要/三级
hover / active / disabled 交互状态
sidebar / header / content 区域限定
```
### 3.3 完整变量清单
```css
/* ============================================================
Halo Dark Mode — CSS Variables Definition
============================================================ */
/* ---------- 基础背景 ---------- */
--halo-bg-body /* 页面底色 */
--halo-bg-sidebar /* 侧边栏背景 */
--halo-bg-content /* 内容区背景 */
--halo-bg-card /* 卡片/面板背景 */
--halo-bg-input /* 输入框背景 */
--halo-bg-hover /* 通用悬停背景 */
--halo-bg-active /* 通用激活背景 */
--halo-bg-disabled /* 禁用态背景 */
--halo-bg-tooltip /* 提示框背景 */
--halo-bg-modal /* 弹窗遮罩 */
--halo-bg-dropdown /* 下拉菜单背景 */
/* ---------- 文字颜色 ---------- */
--halo-text-primary /* 主要文字 */
--halo-text-secondary /* 次要文字(描述、元信息) */
--halo-text-tertiary /* 三级文字(占位符、禁用文字) */
--halo-text-link /* 链接文字 */
--halo-text-inverse /* 反色文字(深色背景上) */
/* ---------- 边框 ---------- */
--halo-border-base /* 默认边框 */
--halo-border-light /* 浅边框(分割线) */
--halo-border-input /* 输入框边框 */
--halo-border-focus /* 聚焦边框 */
/* ---------- 强调色 ---------- */
--halo-accent-primary /* 主色 */
--halo-accent-primary-hover /* 主色悬停 */
--halo-accent-primary-text /* 主色上的文字 */
--halo-accent-danger /* 危险色 */
--halo-accent-danger-hover /* 危险色悬停 */
--halo-accent-success /* 成功色 */
--halo-accent-warning /* 警告色 */
/* ---------- 阴影 ---------- */
--halo-shadow-sm /* 小阴影 */
--halo-shadow-base /* 默认阴影 */
--halo-shadow-lg /* 大阴影 */
/* ---------- 滚动条 ---------- */
--halo-scrollbar-thumb /* 滚动条滑块 */
--halo-scrollbar-track /* 滚动条轨道 */
/* ---------- 搜索框(独立区域) ---------- */
--halo-search-bg /* 搜索框背景 */
--halo-search-text /* 搜索框文字 */
--halo-search-placeholder /* 搜索框占位符 */
/* ---------- 菜单 ---------- */
--halo-menu-item-hover /* 菜单项悬停 */
--halo-menu-item-active /* 菜单项激活 */
--halo-menu-group-title /* 菜单组标题 */
/* ---------- 表格 ---------- */
--halo-table-header-bg /* 表头背景 */
--halo-table-row-hover /* 行悬停 */
--halo-table-border /* 表格边框 */
/* ---------- 标签/徽章 ---------- */
--halo-tag-bg /* 标签背景 */
--halo-tag-text /* 标签文字 */
/* 总计: ~40 个语义变量 */
```
---
## 4. 黑暗模式调色板
### 4.1 色彩策略
采用 OKLCH 颜色空间,遵循以下原则:
- **背景层次**:越"高"的层(卡片 > 内容区 > 页面)越亮,用亮度区分层次替代阴影
- **文字层次**:通过亮度和不透明度区分主/次/三级文字
- **强调色去饱和**:暗色下饱和度略降,保持视觉舒适
- **中性色着色**:所有中性色含微量蓝色调(Halo 主色的补色方向)
### 4.2 完整调色板(OKLCH 值)
```css
[data-halo-theme="dark"] {
/* ===== 背景层次 ===== */
--halo-bg-body: oklch(14% 0.01 250);
--halo-bg-sidebar: oklch(16% 0.015 250);
--halo-bg-content: oklch(14% 0.01 250);
--halo-bg-card: oklch(18% 0.015 250);
--halo-bg-input: oklch(20% 0.015 250);
--halo-bg-hover: oklch(24% 0.02 250);
--halo-bg-active: oklch(28% 0.025 250);
--halo-bg-disabled: oklch(16% 0.005 250);
--halo-bg-tooltip: oklch(25% 0.01 250);
--halo-bg-modal: oklch(0% 0 0 / 60%); /* 遮罩 */
--halo-bg-dropdown: oklch(20% 0.015 250);
/* ===== 文字颜色 ===== */
--halo-text-primary: oklch(92% 0.005 250);
--halo-text-secondary: oklch(70% 0.01 250);
--halo-text-tertiary: oklch(50% 0.01 250);
--halo-text-link: oklch(72% 0.14 160); /* 绿色链接,保持与主色关联 */
--halo-text-inverse: oklch(14% 0.01 250);
/* ===== 边框 ===== */
--halo-border-base: oklch(28% 0.015 250);
--halo-border-light: oklch(22% 0.01 250);
--halo-border-input: oklch(30% 0.015 250);
--halo-border-focus: oklch(65% 0.14 160);
/* ===== 强调色 ===== */
--halo-accent-primary: oklch(60% 0.13 160);
--halo-accent-primary-hover: oklch(66% 0.12 160);
--halo-accent-primary-text: oklch(14% 0.02 160);
--halo-accent-danger: oklch(50% 0.18 25);
--halo-accent-danger-hover: oklch(56% 0.17 25);
--halo-accent-success: oklch(58% 0.16 150);
--halo-accent-warning: oklch(65% 0.16 85);
/* ===== 阴影(暗色模式下很微妙) ===== */
--halo-shadow-sm: 0 1px 2px oklch(0% 0 0 / 30%);
--halo-shadow-base: 0 2px 8px oklch(0% 0 0 / 40%);
--halo-shadow-lg: 0 4px 16px oklch(0% 0 0 / 50%);
/* ===== 滚动条 ===== */
--halo-scrollbar-thumb: oklch(35% 0.02 250);
--halo-scrollbar-track: oklch(18% 0.01 250);
/* ===== 搜索框 ===== */
--halo-search-bg: oklch(20% 0.015 250);
--halo-search-text: oklch(70% 0.01 250);
--halo-search-placeholder: oklch(50% 0.01 250);
/* ===== 菜单 ===== */
--halo-menu-item-hover: oklch(22% 0.02 160);
--halo-menu-item-active: oklch(28% 0.03 160);
--halo-menu-group-title: oklch(55% 0.01 250);
/* ===== 表格 ===== */
--halo-table-header-bg: oklch(18% 0.01 250);
--halo-table-row-hover: oklch(22% 0.015 250);
--halo-table-border: oklch(26% 0.015 250);
/* ===== 标签/徽章 ===== */
--halo-tag-bg: oklch(22% 0.02 160);
--halo-tag-text: oklch(80% 0.08 160);
}
```
### 4.3 浅色模式参考值(不注入,仅作对照)
以下为 Halo 当前浅色模式对应的大致 OKLCH 值,用于设计阶段对照,**不会作为 CSS 注入**(浅色模式是 Halo 默认行为):
```
变量 浅色 ≈ oklch 值 暗色 ≈ oklch 值 对比度
--halo-bg-body oklch(97% 0.01 250) oklch(14% 0.01 250) ✓
--halo-bg-sidebar oklch(100% 0 0) oklch(16% 0.015 250) ✓
--halo-bg-card oklch(100% 0 0) oklch(18% 0.015 250) ✓
--halo-text-primary oklch(20% 0.01 250) oklch(92% 0.005 250) ✓
--halo-text-secondary oklch(45% 0.01 250) oklch(70% 0.01 250) ✓
```
### 4.4 WCAG 对比度保证
| 文字类型 | 暗色模式组合 | 预估对比度 | WCAG AA |
|----------|-------------|-----------|---------|
| 主要文字 | `oklch(92%)` on `oklch(14%)` | ~15:1 | ✅ AAA |
| 次要文字 | `oklch(70%)` on `oklch(14%)` | ~8:1 | ✅ AAA |
| 三级文字 | `oklch(50%)` on `oklch(14%)` | ~4.8:1 | ✅ AA |
| 主色按钮文字 | `oklch(14%)` on `oklch(60% 0.13 160)` | ~5:1 | ✅ AA |
---
## 5. 组件覆盖策略
### 5.1 覆盖层级
```
优先级(低 → 高):
1. TailwindCSS 默认样式 ← Halo 核心
2. @halo-dev/components ← Halo 组件库
3. Halo console-src 样式 ← 后台自定义样式
4. 插件 CSS 变量定义 ← 本插件注入
5. 插件 CSS 覆盖规则 ← 本插件注入(最高优先级)
```
### 5.2 覆盖方法
采用**三阶段渐进覆盖**
```
阶段 A:CSS 变量注入(覆盖 ~80% 场景)
↓ 适用:使用了 Tailwind 颜色类的元素
↓ 方法:用 CSS 变量重新定义 Tailwind 的语义类
阶段 B:选择器覆盖(覆盖 ~17% 场景)
↓ 适用:有硬编码颜色的 Halo 自定义样式
↓ 方法:高特异性 [data-halo-theme="dark"] 前缀选择器
阶段 C:组件穿透(覆盖 ~3% 场景)
↓ 适用:FormKit / 编辑器等第三方组件
↓ 方法:利用其自身的 CSS 变量接口
```
### 5.3 具体覆盖清单
#### 阶段 A — CSS 变量注入
```css
/* 重新定义 Tailwind 颜色变量的语义映射 */
[data-halo-theme="dark"] .layout {
/* 侧边栏 */
.sidebar {
background-color: var(--halo-bg-sidebar);
}
}
[data-halo-theme="dark"] .main-content {
background-color: var(--halo-bg-content);
}
/* 覆盖 Tailwind gray 色系的使用(通过变量值覆盖) */
[data-halo-theme="dark"] {
/* 凡是用 bg-gray-* 的地方,统一改为暗色变量 */
/* 凡是用 text-gray-* 的地方,统一改为暗色文字变量 */
}
```
#### 阶段 B — 关键选择器覆盖
| 目标 | 选择器 | 覆盖属性 |
|------|--------|---------|
| 侧边栏搜索框 | `.sidebar__search` | `background-color`, `color` |
| 侧边栏 Logo | `.sidebar__logo` | `filter`(亮度调整) |
| 菜单项 | `.routes-menu__item` | `background-color`, `color` |
| 页面卡片 | `.card`, `[class*="card"]` | `background-color`, `border-color`, `box-shadow` |
| 模态框 | `.modal`, `.dialog` | `background-color` |
| 页脚 | `.main-content__footer` | `color` |
| 表格 | `table`, `.table` | `background-color`, `border-color` |
| 输入框 | `input`, `textarea`, `select` | `background-color`, `color`, `border-color` |
| 下拉菜单 | `.dropdown`, `.v-dropdown` | `background-color`, `border-color` |
#### 阶段 C — 第三方组件穿透
```css
/* FormKit — 使用 FormKit 自身的 CSS 变量接口 */
[data-halo-theme="dark"] {
--fk-bg-input: var(--halo-bg-input);
--fk-color-text: var(--halo-text-primary);
--fk-border-color: var(--halo-border-input);
/* ... */
}
/* OverlayScrollbars */
[data-halo-theme="dark"] .os-scrollbar {
--os-handle-bg: var(--halo-scrollbar-thumb);
--os-track-bg: var(--halo-scrollbar-track);
}
/* 富文本编辑器(@halo-dev/richtext-editor */
[data-halo-theme="dark"] .ProseMirror {
color: var(--halo-text-primary);
background: var(--halo-bg-input);
}
```
---
## 6. 切换器 UI 设计
### 6.1 放置位置
选择**侧边栏底部 `UserProfileBanner` 上方**作为切换器位置:
```
┌──────────────────┐
│ │
│ Logo │
│ │
│ ┌────────────┐ │
│ │ 🔍 搜索... │ │
│ └────────────┘ │
│ │
│ 菜单项... │
│ │
│ │
├──────────────────┤
│ ☀️ 浅色 | 🌙 深色│ ← 主题切换器
├──────────────────┤
│ 👤 用户头像 │ ← UserProfileBanner(现有)
└──────────────────┘
```
选择此位置的理由:
- 与用户偏好设置相邻,符合认知模型
- 不干扰主导航菜单
- 始终可见(侧边栏固定)
- 不需要额外开辟路由
### 6.2 切换器设计
**类型**:双态图标按钮(非 Toggle Switch
```
浅色模式时显示: 🌙 深色模式 (点击切换到深色)
深色模式时显示: ☀️ 浅色模式 (点击切换到浅色)
```
采用图标按钮而非 Toggle Switch,因为:
1. 只有两个状态,不需要 Switch 的"开/关"隐喻
2. 图标 + 文字 = 当前点击将去往的状态(而非当前状态),更直观
3. 占用空间小,符合侧边栏底部的紧凑布局
### 6.3 交互规格
| 属性 | 值 |
|------|-----|
| 触发方式 | 点击 |
| 过渡效果 | 无(瞬间切换,暗色模式切换不宜有过渡动画) |
| 图标切换 | 点击后立即切换图标 |
| 快捷键 | 无(不设置全局快捷键,避免与其他插件冲突) |
| 状态反馈 | 图标 + 文字变化即为反馈 |
| 首次加载 | 默认跟随系统(`prefers-color-scheme`),无偏好时使用浅色 |
### 6.4 组件接口
```typescript
// ThemeToggle.vue 的公开接口
interface ThemeToggleProps {
// 当前无 props — 组件自主从 useDarkMode() 读取状态
}
// useDarkMode composable 的接口
interface UseDarkModeReturn {
theme: Ref<'light' | 'dark' | 'auto'>;
isDark: ComputedRef<boolean>; // 当前是否实际为深色
setTheme: (t: 'light' | 'dark' | 'auto') => void;
toggle: () => void; // 在 light/dark 间切换
}
```
---
## 7. 路由与菜单
### 7.1 路由注册
插件需要在 Halo Console 中注册一个设置页面用于主题偏好的详细配置:
```typescript
// index.ts
routes: [
{
parentName: 'Root',
route: {
path: '/dark-mode-settings',
name: 'DarkModeSettings',
component: SettingsView,
meta: {
title: '深色模式设置',
menu: {
name: '深色模式',
group: '偏好设置',
icon: markRaw(IconMoon),
priority: 100, // 较低优先级,排在菜单靠后位置
},
},
},
},
],
```
### 7.2 设置页面功能
设置页面提供比侧边栏切换器更细粒度的控制:
- **模式选择**:浅色 / 深色 / 跟随系统(Radio Group
- **当前生效模式**:只读显示
- **关于**:插件版本、GitHub 链接
---
## 8. 偏好持久化
### 8.1 存储方案
```
localStorage key: "halo-dark-mode-theme"
value: "light" | "dark" | "auto"
默认值: "auto"
```
### 8.2 初始化时序(防闪烁 FOUC 策略)
`console.html` 中无法注入代码(插件仅在 Console 加载后才激活),因此采用以下防闪烁策略:
```html
<!-- 在插件 JS 入口的最顶部,同步执行 -->
<script>
// 这段代码会在插件的 bundle.js 最顶部执行
// 此时 DOM 尚未渲染,设置属性不会引起闪烁
(function() {
var theme = localStorage.getItem('halo-dark-mode-theme') || 'auto';
if (theme === 'dark') {
document.documentElement.setAttribute('data-halo-theme', 'dark');
} else if (theme === 'auto') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-halo-theme', 'dark');
}
}
})();
</script>
```
**注意**:由于插件 bundle 是异步加载的(`useScriptTag`),FOUC 风险需要通过以下方式缓解:
- CSS 变量切换是瞬时的(< 1 帧),即使有短暂浅色闪现,用户体感为"页面加载完成"
- 后续可通过向 Halo 提交 PR 在 `console.html` 添加 `<script>` 插槽来彻底解决
### 8.3 系统偏好监听
```typescript
// useSystemPreference.ts
export function useSystemPreference() {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
function onChange(callback: (isDark: boolean) => void) {
mediaQuery.addEventListener('change', (e) => {
callback(e.matches);
});
}
return {
isSystemDark: () => mediaQuery.matches,
onChange,
};
}
```
---
## 9. 项目文件结构
```
halo-dark-mode-plugin/
├── build.gradle # Gradle 构建(BOM 2.25.0, DevTools 0.8.0
├── settings.gradle # 包含 :ui 子项目
├── gradle.properties # Gradle 属性
├── gradle/ # Gradle wrapper
├── gradlew / gradlew.bat
├── src/
│ └── main/
│ ├── java/run/halo/darkmode/
│ │ └── DarkModePlugin.java # 插件主类(极简骨架)
│ │
│ └── resources/
│ ├── plugin.yaml # 插件清单
│ └── logo.png # 插件图标
├── ui/
│ ├── package.json # 前端依赖
│ ├── tsconfig.json
│ ├── vite.config.ts # Vite 配置(ui-plugin-bundler-kit
│ ├── build.gradle # 前端构建 Gradle 任务
│ │
│ └── src/
│ ├── index.ts # definePlugin 入口(从 @halo-dev/ui-shared 导入)
│ │
│ ├── composables/
│ │ ├── useDarkMode.ts # 核心状态管理
│ │ └── useSystemPreference.ts # 系统偏好匹配
│ │
│ ├── components/
│ │ └── ThemeToggle.vue # 侧边栏切换器组件
│ │
│ ├── views/
│ │ └── SettingsView.vue # 设置页面
│ │
│ ├── styles/
│ │ ├── index.css # 样式入口
│ │ ├── variables.css # CSS 变量定义(浅色 + 深色)
│ │ └── overrides/
│ │ ├── layout.css # BasicLayout 覆盖
│ │ ├── components.css # @halo-dev/components 覆盖
│ │ ├── forms.css # FormKit 输入组件覆盖
│ │ ├── editor.css # 富文本编辑器覆盖
│ │ ├── scrollbar.css # 滚动条覆盖
│ │ └── utilities.css # Tailwind 工具类覆盖
│ │
│ └── assets/
│ └── icons/ # 月/日 图标 SVG
└── README.md
```
### 9.1 关键配置速查
```groovy
// build.gradle (根)
plugins {
id "run.halo.plugin.devtools" version "0.8.0"
}
dependencies {
implementation platform('run.halo.tools.platform:plugin:2.25.0')
compileOnly 'run.halo.app:api'
}
halo { version = '2.25' }
// settings.gradle
rootProject.name = 'dark-mode'
include ':ui'
// 关键:UI 产物复制到 resources/main/ui/
tasks.register('processUiResources', Copy) {
from project(':ui').layout.buildDirectory.dir('dist')
into layout.buildDirectory.dir('resources/main/ui')
}
```
```typescript
// ui/src/index.ts
import { definePlugin } from '@halo-dev/ui-shared' // ← 注意导入源
export default definePlugin({
components: {},
routes: [
{
parentName: 'Root',
route: {
path: '/dark-mode-settings',
component: () => import('./views/SettingsView.vue'), // 懒加载
meta: { /* ... */ },
},
},
],
extensionPoints: {},
})
```
---
## 10. 实现阶段划分
### Phase 1:骨架搭建(约 150 行)
- [ ] 使用 `create-halo-plugin` 或从 `plugin-starter` 创建项目
- [ ] Java `DarkModePlugin extends BasePlugin` 基本骨架
- [ ] `plugin.yaml` 元数据填写
- [ ] 前端 `index.ts` 最小 `definePlugin`(空路由 + 空组件)
- [ ] 验证插件能成功加载
### Phase 2:CSS 变量体系 + 核心布局暗色化(约 400 行 CSS)
- [ ] `variables.css`:定义全部 40+ CSS 变量(:root 浅色 + [data-halo-theme="dark"] 深色)
- [ ] `overrides/layout.css`BasicLayout 覆盖(侧边栏 + 内容区 + 页脚)
- [ ] `overrides/scrollbar.css`:滚动条覆盖
- [ ] `overrides/utilities.css`Tailwind 通用类覆盖
- [ ] 视觉验收:侧边栏、主内容区、菜单在暗色下正常
### Phase 3:切换器 + 状态管理(约 200 行 TS/Vue
- [ ] `useDarkMode.ts` composable
- [ ] `useSystemPreference.ts` composable
- [ ] `ThemeToggle.vue` 组件
- [ ] FOUC 防护脚本(bundle 顶部内联)
- [ ] 设置页面 `SettingsView.vue` + 路由注册
### Phase 4:组件 + 表单暗色化(约 300 行 CSS)
- [ ] `overrides/components.css`Halo UI 组件库覆盖
- [ ] `overrides/forms.css`FormKit 输入组件覆盖
- [ ] 覆盖表格、标签、徽章、下拉菜单、模态框、提示框
- [ ] 视觉验收:所有表单控件、弹窗、提示在暗色下正常
### Phase 5:编辑器 + 打磨(约 150 行 CSS + 测试)
- [ ] `overrides/editor.css`:富文本编辑器 + Markdown 编辑器覆盖
- [ ] WCAG 对比度验证
- [ ] 图片/Logo 暗色模式适配(亮度降低)
- [ ] 完整的视觉回归检查
### Phase 6:文档 + 发布
- [ ] README.md(安装说明 + 使用指南 + 截图)
- [ ] 版本号确定(v0.1.0
- [ ] 构建产物验证
### 预估总规模
| 类型 | 预估行数 |
|------|---------|
| Java | ~30 行 |
| TypeScript/Vue | ~250 行 |
| CSS | ~850 行 |
| 配置(Gradle/JSON | ~150 行 |
| **总计** | **~1,280 行** |
---
## 11. 测试策略
### 11.1 自动化测试
| 层级 | 工具 | 覆盖目标 | 示例 |
|------|------|---------|------|
| composable 单元测试 | Vitest | `useDarkMode` 状态转换逻辑 | 切换主题 → `isDark` 变化正确 |
| composable 单元测试 | Vitest | `useSystemPreference` matchMedia mock | 系统偏好变化 → 回调触发 |
| 组件测试 | Vitest + vue-test-utils | `ThemeToggle` 渲染与事件 | 点击 → `setTheme` 被调用 |
### 11.2 手动视觉验收清单
```
□ 浅色模式:所有页面与未安装插件时一致(无回归)
□ 深色模式:侧边栏背景为深色
□ 深色模式:菜单项清晰可读
□ 深色模式:卡片/面板背景与页面有明确分层
□ 深色模式:输入框背景与文字对比度足够
□ 深色模式:表格行可区分(斑马纹或边框)
□ 深色模式:按钮主色/危险色/默认色分明
□ 深色模式:模态框遮罩 + 内容清晰
□ 深色模式:下拉菜单不"漂浮"
□ 深色模式:Toast 通知可读
□ 深色模式:富文本编辑器内容可编辑
□ 深色模式:代码块有合适的暗色主题
□ 切换器:点击即时切换,无闪烁
□ 持久化:刷新页面后偏好保持
□ 跟随系统:切换系统暗色 → 页面跟随
□ 设置页面:三种模式可切换
□ 所有页面无可见的浅色残余
```
---
## 12. 兼容性矩阵
### 12.1 Halo 版本兼容
| Halo 版本 | 支持状态 | 说明 |
|-----------|---------|------|
| 2.23.x ~ 2.25.x | ✅ 主要支持 | 对应 plugin-starter 的 BOM 版本 |
| 2.26+ | ⚠️ 预期兼容 | 插件 API 向后兼容;新版本发布后验证 |
| < 2.23 | ❌ 不支持 | `requires: ">=2.23.0"` |
### 12.2 浏览器兼容
| 浏览器 | 最低版本 | 备注 |
|--------|---------|------|
| Chrome | 111+ | OKLCH 支持 |
| Firefox | 113+ | OKLCH 支持 |
| Safari | 15.4+ | OKLCH 支持 |
| Edge | 111+ | 与 Chrome 内核相同 |
**关键依赖**`oklch()` CSS 颜色函数。所有目标浏览器均原生支持(2023 年后发布版本)。
### 12.3 与其他插件的兼容性
- **主题插件**:如果有其他插件也定义了暗色模式,后加载的 CSS 可能产生冲突。通过 `data-halo-theme` 属性选择器命名空间隔离。
- **UI 自定义插件**:不冲突,CSS 变量覆盖不影响自定义插件的样式。
---
## 附录 A:插件 ID 与命名
| 属性 | 值 |
|------|-----|
| Plugin Name (ID) | `dark-mode` |
| Display Name | 深色模式 |
| Description (zh) | 为 Halo 后台管理面板提供深色/浅色模式切换功能 |
| Description (en) | Dark mode toggle for Halo admin console |
| Author | (用户自定义) |
| License | GPL-3.0 |
---
## 附录 B:待确认事项
以下是需要在**开始实现前**与用户确认的决策点:
1. ~~插件名称确认:`dark-mode` 是否合适?~~
2. ~~是否需要国际化(中文为主 + 英文备选)?~~ → 需求仅中文,可选加英文
3. **切换器图标风格**Material Icons(与 Halo 一致)还是自定义 SVG?
4. **是否需要后端 Setting API 持久化**作为 Phase 2 特性?
5. 插件 Logo 设计方向
---
*本文档基于 [调查文档](./调查文档.md) 的发现,面向首次实现。*
-556
View File
@@ -1,556 +0,0 @@
# Halo 黑暗模式插件 — 调查文档
> 调查日期:2026-08-06
> 调查范围:Halo 核心仓库、plugin-starter、create-halo-plugin、dev-skills、theme-vite-starter、插件生态系统
> 目的:为开发 Halo 后台管理面板黑暗模式插件提供技术基础
---
## 0. 补充调查:create-halo-plugin / dev-skills / theme-vite-starter
> 本节为第二轮调查补充(2026-08-06),基于对三个额外仓库的分析。**本节内容优先于第 1-6 节中过时的信息。**
### 0.1 create-halo-plugin — 官方推荐的脚手架工具
**仓库**https://github.com/halo-dev/create-halo-plugin
这是 Halo 官方推荐的插件创建方式,取代了旧的 `plugin-starter`
```bash
pnpm create halo-plugin
pnpm create halo-plugin my-plugin
```
**交互式 CLI 特性**
- 插件名称(遵循 `[a-z0-9][a-z0-9.-]*[a-z0-9]` 规则)
- 域名 → 自动生成 Java 包名
- 作者姓名
- 是否包含 UI 项目
- **UI 构建工具选择:Vite 或 Rsbuild**
**与 plugin-starter 的关键差异**
| 项目 | plugin-starter (旧) | create-halo-plugin (新) |
|------|---------------------|------------------------|
| `definePlugin` 导入源 | `@halo-dev/console-shared` | `@halo-dev/ui-shared` |
| UI 构建产物目录 | `resources/main/console/` | `resources/main/ui/` |
| BOM 版本 | `2.23.0-SNAPSHOT` | `2.25.0` |
| DevTools 版本 | `0.6.2` | `0.8.0` |
| `halo.version` | `2.23.0-beta.2` | `2.25` |
| Maven 仓库 | 含 `Central Portal Snapshots` | 仅 `mavenCentral()` |
| 路由导入 | 静态 import | 懒加载 `() => import(...)` |
| 代码格式化 | Prettier | ESLint |
| SNAPSHOT 依赖 | 是 | **否(仅稳定版)** |
### 0.2 dev-skills — AI Agent 开发技能库
**仓库**https://github.com/halo-dev/dev-skills
这是 Halo 官方的 Agent Skills 集合,为 AI Agent 提供结构化的领域知识。
**`halo-plugin-dev` 技能** — 涵盖 20+ 参考文档:
| 参考文档 | 用途 |
|----------|------|
| `plugin-structure.md` | 目录结构、前后端布局 |
| `plugin-manifest.md` | plugin.yaml 字段、版本约束、依赖 |
| `server-lifecycle.md` | BasePlugin start/stop/delete 生命周期 |
| `server-extension.md` | 自定义 GVK 扩展、CRUD API |
| `server-api.md` | CustomEndpoint、Controller |
| `server-shared-beans.md` | 注入 Halo 核心服务 |
| `server-security.md` | RBAC 角色模板 |
| `ui-entry.md` | definePlugin、routes、菜单配置 |
| `ui-build.md` | bundler-kit (Vite/Rsbuild)、产物目录 |
| `ui-shared.md` | stores (currentUser, globalInfo)、utils |
| `ui-extension-points.md` | Console UI 扩展点(编辑器、附件选择器等) |
| `ui-components.md` | @halo-dev/components 组件库 |
| `ui-forms.md` | FormKit schema、自定义输入组件 |
| `ui-api-request.md` | API client 调用、OpenAPI 客户端生成 |
| `ui-tooling.md` | unplugin-icons、UnoCSS 工具链 |
| `devtools.md` | haloServer、reload、watch、调试 |
| `api-changelog.md` | 各版本 API 变更记录 |
| `theme-head-processor.md` | 注入脚本/样式到主题 `<head>` |
| `theme-content-handler.md` | 修改渲染后的 HTML |
| `theme-integration.md` | 主题 Finder API、反向代理 |
> **实施时**:开发过程中应加载 `halo-plugin-dev` 技能以获取精确的 API 参考。
### 0.3 theme-vite-starter — 主题开发参考
**仓库**https://github.com/halo-dev/theme-vite-starter
这是 Halo 主题脚手架,与插件开发不直接相关,但展示了 Halo 生态的以下约定:
- 使用 `vite-plus`Vite 超集,集成 format/lint
- 使用 `@halo-dev/vite-plugin-halo-theme` 构建主题
- `theme.yaml` manifest 格式(`apiVersion: theme.halo.run/v1alpha1`
- `settings.yaml` 用于控制台表单配置
- Agent Skills 嵌入在 `.agents/skills/` 目录
### 0.4 对设计文档的影响
基于以上新发现,设计文档需要做以下修正:
1. **脚手架工具**:使用 `pnpm create halo-plugin` 而非手动克隆 plugin-starter
2. **`definePlugin` 导入**:从 `@halo-dev/ui-shared` 导入(非 `@halo-dev/console-shared`
3. **版本目标**BOM `2.25.0`DevTools `0.8.0`Halo `2.25`
4. **UI 产物路径**`resources/main/ui/`(非 `resources/main/console/`
5. **路由懒加载**:使用 `() => import(...)` 动态导入
6. **不需要 SNAPSHOT 仓库**:仅 `mavenCentral()` 即可
7. **开发时参考**:善用 `halo-plugin-dev` skill 的参考文档
---
## 1. Halo 项目概览
### 1.1 基本信息
| 属性 | 值 |
|------|-----|
| 仓库 | https://github.com/halo-dev/halo |
| 许可 | GPL-3.0 |
| 类型 | 全栈 monorepo |
| 最新稳定版 | 2.25 |
| 当前开发版 | 2.23.0-beta.2plugin-starter 依赖版本) |
### 1.2 技术栈总览
```
┌─────────────────────────────────────────────────────┐
│ HALO 架构图 │
├─────────────────────────────────────────────────────┤
│ 前端 (ui/) │
│ ├─ console-src/ → 后台管理面板 (Vue 3 + TS) │
│ ├─ uc-src/ → 用户中心 (Vue 3 + TS) │
│ ├─ src/ → 共享代码 (locales/styles/setup) │
│ └─ packages/ → 共享工作空间包 │
│ ├─ api-client → 后端 API 客户端 (生成) │
│ ├─ components → UI 组件库 (Storybook) │
│ ├─ console-shared → definePlugin API │
│ ├─ editor → 富文本编辑器 │
│ ├─ shared → 共享工具 │
│ └─ ui-plugin-bundler-kit → 插件打包工具链 │
├─────────────────────────────────────────────────────┤
│ 后端 │
│ ├─ api/ → 扩展模型/合约/安全 API │
│ ├─ application/ → 服务/路由/数据迁移 │
│ └─ platform/ → BOM (依赖版本约束) │
│ ├─ application/ → 应用 BOM │
│ └─ plugin/ → 插件 BOM (插件可用的依赖) │
└─────────────────────────────────────────────────────┘
```
**核心依赖版本:**
| 层 | 技术 | 版本 |
|----|------|------|
| 后端 | Java | 21 |
| 后端 | Spring Boot (WebFlux) | 3.x |
| 后端 | Gradle | 8.x |
| 前端 | Vue | 3.x |
| 前端 | TypeScript | 5.x |
| 前端 | TailwindCSS | 3.x (含 tailwindcss-themer) |
| 前端 | Vite | 6.x |
| 前端 | pnpm | 9.x |
| 前端 | Pinia | 2.x (状态管理) |
| 前端 | Vue Router | 4.x |
| 前端 | FormKit | 1.x (表单) |
---
## 2. 插件系统深度分析
### 2.1 插件生命周期
```java
// 每个插件只有一个主类继承 BasePlugin
@Component
public class PluginMain extends BasePlugin {
public PluginMain(PluginContext pluginContext) {
super(pluginContext);
}
@Override
public void start() {
// 插件启动 → 注册扩展点、创建资源
}
@Override
public void stop() {
// 插件停止 → 清理资源、取消注册
}
}
```
**关键点:**
- 插件以 Spring `@Component` 的形式存在
- 通过 `PluginContext` 可以访问 Halo 运行时上下文
- `start()` / `stop()` 是主要的生命周期钩子
- 只允许一个类继承 `BasePlugin`
### 2.2 plugin.yaml 清单
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: Plugin
metadata:
name: PluginName # 唯一标识符,后续无法修改
spec:
enabled: true
requires: ">=2.23.0" # Halo 版本要求
author:
name: 作者名
website: https://example.com
logo: logo.png
homepage: https://github.com/...
repo: https://github.com/...
issues: https://github.com/.../issues
displayName: "插件显示名称"
description: "插件描述"
license:
- name: "GPL-3.0"
```
**关键约束:**
- `metadata.name` 设置后不能修改(作为标识符绑定到 API 路由和资源路径)
- `spec.requires` 定义最低 Halo 版本
- 版本约束由 `platform/plugin/` BOM 管理
### 2.3 插件 Gradle 构建系统
```groovy
plugins {
id 'java'
id "io.freefair.lombok" version "9.2.0"
id "run.halo.plugin.devtools" version "0.6.2" // 热重载开发工具
}
dependencies {
implementation platform('run.halo.tools.platform:plugin:2.23.0-SNAPSHOT')
compileOnly 'run.halo.app:api' // 编译时 API
testImplementation 'run.halo.app:api' // 测试时可用
}
// 前端资源复制到后端 classpath
tasks.register('processUiResources', Copy) {
from project(':ui').layout.buildDirectory.dir('dist')
into layout.buildDirectory.dir('resources/main/console')
dependsOn project(':ui').tasks.named('assemble')
}
```
**关键点:**
- `run.halo.app:api``compileOnly` — 运行时由 Halo 提供
- 前端构建产物(JS/CSS)被复制到 `resources/main/console/`
- DevTools 插件支持 Docker 热重载开发
### 2.4 插件前端入口系统
```typescript
// ui/src/index.ts — 插件前端唯一入口
import { definePlugin } from '@halo-dev/console-shared'
import HomeView from './views/HomeView.vue'
import { IconPlug } from '@halo-dev/components'
import { markRaw } from 'vue'
export default definePlugin({
components: {}, // { [name: string]: Component } — 全局注册组件
routes: [...], // 路由定义(可追加到父路由)
extensionPoints: {}, // 扩展点(当前生态尚不成熟)
ucRoutes: [...], // 用户中心路由(可选)
})
```
**`definePlugin` 的本质:** 源码显示它是一个**身份函数**identity function),即 `(x) => x`。这意味着插件配置对象直接传递给 Halo 运行时,没有额外的转换层。这简化了调试但也意味着没有类型强制。
### 2.5 插件资源加载机制(核心发现)
```
浏览器加载流程:
1. Halo 后端生成 bundle URL
/apis/api.console.halo.run/v1alpha1/ui-plugins/-/bundle.js?t=<timestamp>
/apis/api.console.halo.run/v1alpha1/ui-plugins/-/bundle.css?t=<timestamp>
2. setupModules.ts 执行流程:
├─ loadEnabledUiPluginModules()
│ ├─ loadUiPluginBundle() → 加载 JS bundle
│ └─ 读取 window["enabledUiPlugins"] → 获取已启用插件列表
├─ setupComponents() → 注册 FormKit 输入组件
├─ setupPluginModules() → 注册路由 + 全局组件
└─ setupPluginStyles() → 加载插件 CSS bundle
└─ loadStyle(url) → 动态创建 <link> 标签
```
**关键发现:**
- **插件 CSS 会自动加载**`setupPluginStyles()` 函数通过 `loadStyle()` 动态注入 `<link>` 标签到 `<head>`
- **CSS 在所有插件 JS 初始化完成后加载**:这确保了样式加载有正确的优先级
- **无需 Java 端干预 CSS 注入**:只要插件包含前端资源,Halo 会自动处理
- `window["enabledUiPlugins"]` 由后端填充,包含所有已启用插件的元数据
---
## 3. 当前 UI 主题系统分析
### 3.1 TailwindCSS 配置
```typescript
// ui/tailwind.config.ts
plugins: [
themer({
defaultTheme: {
extend: {
colors: {
primary: "#4CCBA0", // 绿色主色调
secondary: "#0E1731", // 深蓝色辅助色
danger: "#D71D1D", // 红色危险色
},
borderRadius: {
base: "4px",
},
},
},
// 注意:只有 defaultTheme,没有 dark 主题
}),
]
```
**核心问题:**
- `tailwindcss-themer` 支持多主题但只配置了一个 `defaultTheme`
- **没有定义 dark 主题变量** — 这是黑暗模式缺失的根本原因
- 所有颜色值硬编码为浅色模式值
### 3.2 BasicLayout 颜色扫描
通过分析 `BasicLayout.vue`(后台主布局),发现以下颜色使用模式:
| 元素 | 当前颜色 | 来源 |
|------|---------|------|
| 侧边栏背景 | `white` | `theme("colors.white")` |
| 搜索框背景 | `gray.100` | `theme("colors.gray.100")` |
| 搜索框图标色 | `gray.400` | `theme("colors.gray.400")` |
| 页脚文字 | `gray.600` | `theme("colors.gray.600")` |
| 悬停链接色 | `gray.600` | `theme("colors.gray.600")` |
| 搜索框悬停文字 | `gray.900` | `theme("colors.gray.900")` |
这些全部是 Tailwind 默认的浅色模式 gray 色调。
### 3.3 主题 Store 分析
```typescript
// ui/console-src/stores/theme.ts
export const useThemeStore = defineStore("theme", () => {
const activatedTheme = ref<Theme>();
async function fetchActivatedTheme() {
// 注意:这里获取的是站点前台主题(博客主题),不是 UI 主题
const { data } = await consoleApiClient.theme.theme.fetchActivatedTheme(...);
activatedTheme.value = data;
}
return { activatedTheme, fetchActivatedTheme };
});
```
**关键发现:**
- `useThemeStore` 管理的是**站点前台主题**(用于访客页面渲染)
- **Halo 后台完全没有 UI 主题/黑暗模式的概念**
- 不存在 `uiTheme``darkMode` 相关的 store、API 或配置项
### 3.4 样式加载架构
```
setupStyles.ts 加载顺序:
1. @halo-dev/richtext-editor/dist/style.css → 编辑器样式
2. @halo-dev/components/dist/style.css → 组件库样式
3. @/styles/tailwind.css → Tailwind 基础 + 组件样式
4. @/styles/index.css → 全局样式覆盖
5. overlayscrollbars/overlayscrollbars.css → 滚动条样式
6. 插件 CSS (最后加载) → setupPluginStyles()
```
**重要发现:插件 CSS 最后加载**,这意味着插件可以通过更高特异性的选择器覆盖前面的样式。这为黑暗模式插件的 CSS 覆盖策略提供了天然的优先级优势。
---
## 4. 黑暗模式实现可行性分析
### 4.1 Halo 现有的"主题"相关 API
| API / Store | 用途 | 是否可用于 UI 暗色模式 |
|-------------|------|----------------------|
| `useThemeStore` | 站点前台主题管理 | ❌ 不相关 |
| `tailwindcss-themer` | Tailwind 多主题支持 | ✅ 基础设施存在但未使用 |
| 插件 CSS bundle | 自动加载插件样式 | ✅ 天然支持 |
| 插件 JS bundle | 自动加载插件 JS | ✅ 天然支持 |
### 4.2 实现黑暗模式的可行路径
#### 方案 A:纯 CSS 覆盖(推荐 — 最简可行方案)
**原理:** 利用插件 CSS 最后加载的优先级,用高特异性 CSS 规则覆盖所有浅色模式样式。
**优点:**
- 无需修改 Halo 核心代码
- 开发成本最低
- 不依赖不成熟的 extensionPoints API
- 兼容所有 Halo 2.x 版本
**缺点:**
- 需要精准定位所有颜色元素
- Halo 版本升级可能导致选择器失效
- 无法利用 Tailwind 的 `dark:` 前缀体系
**技术要点:**
```css
/* 通过属性选择器 + CSS 变量方案 */
[data-color-scheme="dark"] {
--color-bg: #1a1a2e;
--color-text: #e0e0e0;
/* ... 覆盖所有相关的 CSS 变量/类 */
}
```
#### 方案 B:注入 Tailwind dark 主题(中等复杂度)
**原理:** 通过插件在运行时修改 `tailwindcss-themer` 的配置,注入 dark 主题。
**优点:**
- 可以复用 Tailwind 的 `dark:` 类名前缀
- 更系统化的颜色管理
**缺点:**
- 需要访问 Tailwind 运行时配置(不确定是否可行)
- `tailwindcss-themer` 的主题在构建时确定,运行时修改不标准
- 需要修改 Halo 前端构建流程,超出插件范畴
**可行性评估:不可行** — Tailwind 主题是编译时确定的,插件无法在运行时修改。
#### 方案 C:自定义 CSS Properties 体系(推荐 — 稳健方案)
**原理:** 在插件 CSS 中定义一套完整的 CSS 自定义属性(CSS Variables),覆盖 Tailwind 和 Halo 组件的颜色。
**优点:**
- CSS Variables 是标准 Web 技术,浏览器原生支持
- 切换时只需修改 `:root``[data-theme]` 属性
- 渐进式,可以分批覆盖
- 易于维护和扩展
- 可以使用 `prefers-color-scheme` 媒体查询自动跟随系统
**缺点:**
- 需要全面覆盖 Tailwind 颜色类
- 需要为 Halo 自定义组件定义变量名
**技术要点:**
```css
:root {
--halo-bg-primary: #ffffff;
--halo-bg-secondary: #f3f4f6;
--halo-text-primary: #111827;
--halo-text-secondary: #6b7280;
--halo-border: #e5e7eb;
/* ... */
}
[data-halo-theme="dark"] {
--halo-bg-primary: #1a1a2e;
--halo-bg-secondary: #16213e;
--halo-text-primary: #e0e0e0;
--halo-text-secondary: #a0a0a0;
--halo-border: #2a2a4a;
/* ... */
}
```
### 4.3 推荐方案:方案 CCSS Variables+ 方案 A 作为补充
采用 **CSS 自定义属性** 作为主体策略,对少量无法通过变量覆盖的硬编码颜色采用 **选择器覆盖** 作为补充。
---
## 5. 插件架构设计关键决策点
### 5.1 需要改动的内容
| 层级 | 内容 | 必要性 |
|------|------|--------|
| 后端 Java | 插件入口类(基本骨架) | ✅ 必须 |
| 后端 Java | 可选的 Setting API 端点 | ⬜ 可选(存储用户偏好) |
| plugin.yaml | 插件元数据 | ✅ 必须 |
| 前端 JS | 开关按钮 UI 组件 | ✅ 必须 |
| 前端 JS | 路由注册(设置页) | ✅ 必须 |
| 前端 JS | 主题切换逻辑 | ✅ 必须 |
| 前端 CSS | 暗色变量定义 (~200-500 变量) | ✅ 必须 |
| 前端 CSS | 组件覆盖样式 | ✅ 必须 |
### 5.2 不需要改动的内容
- ❌ Halo 核心代码 — 插件机制完全支持
- ❌ Tailwind 配置 — 通过 CSS 变量覆盖
- ❌ Halo 组件库源码 — 通过 CSS 变量穿透
- ❌ 后端路由 — 使用现有 API
### 5.3 开关按钮放置位置
分析 `BasicLayout.vue` 后发现可选位置:
1. **侧边栏底部(推荐)**`sidebar__profile` 区域,与用户头像并列
2. **顶部导航栏** — 但目前 Halo 没有顶部导航栏
3. **插件自有设置页面** — 可添加一个设置页面
### 5.4 偏好持久化策略
| 方案 | 复杂度 | 跨设备 | 持久性 |
|------|--------|--------|--------|
| `localStorage` | 极低 | ❌ | 浏览器级 |
| 后端 Plugin Setting API | 中 | ✅ | 服务器级 |
| Cookie | 低 | ❌ | 浏览器级 |
**推荐:`localStorage` + `prefers-color-scheme` 作为初始版本,后续可选升级到后端持久化。**
---
## 6. 现有参考资源
### 6.1 Halo 生态相关仓库
| 仓库 | 用途 | 关键信息 |
|------|------|---------|
| halo-dev/halo | 核心仓库 | 前端 Vue 3 + Tailwind,后端 Spring Boot |
| halo-dev/plugin-starter | 插件模板 | 完整 Gradle + Vue 3 插件骨架 |
| halo-dev/create-halo-plugin | 交互式创建工具 | 推荐替代 plugin-starter |
### 6.2 关键发现总结
1. **Halo 后台完全没有黑暗模式支持** — 这既是挑战也是机会,意味着插件是第一无二的解决方案
2. **插件 CSS 最后加载** — 为样式覆盖提供了天然优势
3. **`definePlugin` 是纯透传函数** — 简单但缺少类型安全,需要自行保证配置正确性
4. **`tailwindcss-themer` 基础设施存在** — 但只配置了 light 主题且运行时无法修改
5. **`extensionPoints` 机制尚不成熟** — 不推荐作为主要实现手段
6. **Halo 使用 `pnpm` workspace** — 插件前端开发需匹配此工具链
### 6.3 技术风险
| 风险 | 严重程度 | 缓解措施 |
|------|---------|---------|
| Halo 升级导致选择器失效 | 中 | 使用语义化 CSS 变量名;定期跟进 Halo changelog |
| `@halo-dev/components` 组件内部样式无法覆盖 | 中 | 利用 CSS 变量穿透(该组件库可能使用 CSS 变量或 Tailwind 类) |
| 第三方插件不受暗色主题影响 | 低 | 在设计文档中说明插件的暗色化范围 |
| FormKit/编辑器样式复杂 | 中 | 优先保证核心布局暗色化,表单和编辑器作为第二阶段 |
---
## 7. 下一步建议
1. **审阅此调查文档**,确认技术方向
2. **编写设计文档**,详细定义:
- CSS 变量命名体系
- 组件覆盖策略
- 开关 UI 设计
- 路由/菜单结构
- 版本兼容性承诺
3. **使用 `create-halo-plugin` 创建插件骨架**
4. **TDD 开发流程**(先写测试,后实现)
---
*本文档基于 2026-08-06 时 Halo v2.25 的代码分析。*