Files
halo-dark-mode-plugin/设计文档.md
T
Serendipity edbf78f236 feat: 初始化 Halo 暗色模式插件
- Halo Plugin 后端(Java/Gradle),含 DarkModePlugin 主类和测试
- Vue 3 + TypeScript 前端 UI,包含主题切换组件和设置页面
- 暗色模式 CSS 变量和覆盖样式(布局/编辑器/表单/滚动条等)
- 设计文档和调查文档
- Halo 插件/主题开发 Agent Skills

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 21:03:00 +08:00

894 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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) 的发现,面向首次实现。*