chore: 版本 1.0.1,清理过期文档,加入 Playwright 自动化测试工具

- 版本号从 1.0.0-SNAPSHOT 升级到 1.0.1
- 删除已过期的设计文档和调查文档
- 新增 workplace/ 目录:暗色模式扫描、CSS 冲突探测、登录管理脚本
- 更新 .gitignore 忽略截图纸张和浏览器缓存
- 更新 CLAUDE.md 反映当前仓库状态

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-07 13:06:28 +08:00
parent 154fc63eef
commit b36ad30d7a
12 changed files with 411 additions and 1455 deletions
+4
View File
@@ -11,3 +11,7 @@ build/
.DS_Store
node_modules/
dist/
# Playwright 测试工具 — 只跟踪脚本和配置,忽略输出/截图/缓存
workplace/*
!workplace/*.py
!workplace/*.yaml
+12 -4
View File
@@ -146,8 +146,16 @@ Halo 扩展点系统**没有侧边栏插槽**。`injector.ts` 用 `MutationObser
第三方插件页面(链接/订阅/瞬间等)在 demo 站已装,可一并验证。
## 项目文档
## Playwright 自动化验证(workplace/
- `设计文档.md` — 完整的技术设计(架构图、CSS 变量清单、调色板、组件覆盖策略、实现阶段划分、测试策略)
- `调查文档.md` — 技术调查(create-halo-plugin vs plugin-starter 差异、dev-skills、Halo 插件机制)
- `README.md` — 用户向 README
`workplace/` 目录包含基于 Playwright 的自动化验证工具(未提交到 git):
- **`login_wait.py`** — 启动带持久化配置的 Edge 窗口,打开 `https://blog.liuhangyv.top/console/login`,等待用户手动登录后将会话 cookie 保存到 `pw-profile/`。超时 280 秒。
- **`pw-profile/`** — Edge 浏览器持久化用户数据目录,登录后会话保留供后续 Playwright 脚本复用。
- **`roleTemplates.yaml`** — 预留的角色模板配置(当前为空)。
典型流程:先运行 `login_wait.py` 登录,再编写 Playwright 脚本复用 `pw-profile` 中的会话进行页面扫描。
## README.md
用户向 README,包含功能介绍、快速开始、构建命令和项目结构图。
+1 -1
View File
@@ -1,3 +1,3 @@
version=1.0.0-SNAPSHOT
version=1.0.1
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
org.gradle.java.home=D:/settings/Language/Java/jdk-25.0.1
@@ -10,7 +10,7 @@ import run.halo.app.plugin.PluginContext;
* <p>Only one main class extending {@link BasePlugin} is allowed per plugin.</p>
*
* @author LHY
* @since 1.0.0
* @since 1.0.1
*/
@Component
public class DarkModePlugin extends BasePlugin {
+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 ''}")
+46
View File
@@ -0,0 +1,46 @@
# 启动带持久化配置的 Edge 窗口,等待用户登录 Halo 后台。
# 登录成功后脚本自动退出,会话会保留在 pw-profile 目录里供后续扫描使用。
import pathlib
import sys
import time
from playwright.sync_api import sync_playwright
PROFILE = pathlib.Path(__file__).parent / "pw-profile"
LOGIN_URL = "https://blog.liuhangyv.top/console/login"
TIMEOUT_S = 280
def main() -> int:
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
str(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) # 等待会话 cookie 写入磁盘
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())
+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']}")
View File
+214
View File
@@ -0,0 +1,214 @@
# Halo 后台暗色模式残留扫描器
# 自动发现侧边栏全部 /console 路由,逐页扫描浅色背景 / 深色文字残留并截图。
import json
import pathlib
import re
import sys
import time
from playwright.sync_api import sync_playwright
BASE = "https://blog.liuhangyv.top"
ROOT = pathlib.Path(__file__).parent
PROFILE = ROOT / "pw-profile"
SHOTS = ROOT / "shots"
OUT = ROOT / "scan-results.json"
# 在每个页面加载前强制插件进入深色模式
INIT_JS = """
try { localStorage.setItem('halo-dark-mode-theme', 'dark'); } catch(e) {}
document.documentElement.setAttribute('data-halo-theme', 'dark');
"""
# 页面内扫描:浅色背景(RGB 均 >235 且不透明)、深色文字(RGB 均 <70)
SCAN_JS = r"""
() => {
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 (!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.]+)/);
if (hasText && c && +c[1] < 70 && +c[2] < 70 && +c[3] < 70) {
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:
SHOTS.mkdir(exist_ok=True)
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
str(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(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 登录态失效,需要重新登录", flush=True)
ctx.close()
return 3
dark = page.evaluate("document.documentElement.getAttribute('data-halo-theme')")
print(f"data-halo-theme = {dark}", flush=True)
# 普查样式表:确认服务端实际部署的插件 CSS 覆盖了哪些内容
census = page.evaluate(
"""() => {
const out = [];
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules; } catch (e) { continue; }
let darkRules = 0;
let samples = [];
for (const r of rules) {
const t = r.cssText || '';
if (t.includes('data-halo-theme')) {
darkRules++;
if (samples.length < 3) samples.push(t.slice(0, 100));
}
}
if (darkRules > 0) {
out.push({ href: sheet.href || '(inline)', total: rules.length, darkRules, samples });
}
}
return out;
}"""
)
print("=== 包含暗色规则的样式表 ===", flush=True)
for c in census:
print(f" {c['href']} dark规则数={c['darkRules']}", flush=True)
# 关键字探针:确认部署的 CSS 是否包含关键覆盖(判断部署版本新旧)
keywords = page.evaluate(
"""() => {
const kws = ['description-item', 'bytemd', 'week-picker', 'menu-item-title',
'alert-wrapper', 'entity-field-title', 'sidebar__profile', 'card-wrapper'];
const found = {};
for (const kw of kws) found[kw] = false;
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules; } catch (e) { continue; }
for (const r of rules) {
const t = r.cssText || '';
if (!t.includes('data-halo-theme')) continue;
for (const kw of kws) if (t.includes(kw)) found[kw] = true;
}
}
return found;
}"""
)
print(f"=== 部署 CSS 关键字探针 === {keywords}", flush=True)
# 从 Vue Router 读取全部已注册路由(含插件注册的菜单页)
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 as e:
print(f"Router 读取失败,回退到锚点抓取: {e}", flush=True)
routes = page.evaluate(
"""() => [...new Set([...document.querySelectorAll('a[href]')]
.map(a => a.getAttribute('href'))
.filter(h => h && h.startsWith('/console')))]"""
)
# Halo Console 的 router base 是 /console/getRoutes() 返回的路径不带 base
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]
print(f"发现 {len(routes)} 个后台路由: {routes}", flush=True)
all_results = {}
all_results["__stylesheet_census__"] = census
for route in routes:
try:
page.goto(BASE + route, wait_until="domcontentloaded")
try:
page.wait_for_load_state("networkidle", timeout=6000)
except Exception:
pass
time.sleep(1.5)
# 兜底:再设一次暗色属性,防止插件脚本时序问题
page.evaluate("document.documentElement.setAttribute('data-halo-theme','dark')")
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: # noqa: BLE001
all_results[route] = {"error": str(e)[:200]}
print(f"{route}: 扫描失败 {e}", flush=True)
OUT.write_text(json.dumps(all_results, ensure_ascii=False, indent=2), encoding="utf-8")
ctx.close()
print(f"DONE -> {OUT}", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())
-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 的代码分析。*