8 Commits

Author SHA1 Message Date
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
41 changed files with 20748 additions and 1657 deletions
+12
View File
@@ -10,4 +10,16 @@ build/
*.iml
.DS_Store
node_modules/
.pnpm-store/
dist/
# Playwright 测试工具 — 只跟踪脚本和配置,忽略输出/截图/缓存
workplace/*
!workplace/*.py
!workplace/*.yaml
# 暗色扫描输出与会话
scripts/output/
scripts/.browser-profile/
# Dark Reader 源码下载目录(构建依赖已 vendor 到 ui/vendor/darkreader
docs/darkreader/
+86 -16
View File
@@ -6,12 +6,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Halo 暗色模式插件 — 为 Halo 2.25 后台管理面板提供深色/浅色模式切换。不修改 Halo 核心代码,完全通过插件机制实现。
**核心技术栈**Java 21(后端插件骨架)、Vue 3 + TypeScript(前端 UI)、OKLCH 色彩空间(CSS 变量体系)、Gradle(构建)
**核心技术栈**Java(插件骨架,编译目标 21)、Vue 3 + TypeScript(前端 UI)、OKLCH 色彩空间(CSS 变量体系)、Gradle(构建)
**⚠️ 构建环境**:本机没有 JDK 21,构建必须使用 JDK 25(路径已硬编码在 `gradle.properties``org.gradle.java.home`)。`options.release = 21` 保证字节码兼容 Halo API。不要改回 toolchain 方式——那会因找不到 JDK 21 而失败。
## 常用命令
```bash
# 后端 — 启用插件并启动 Halo 开发服务器
# 后端 — 启用插件并启动 Halo 开发服务器(需要 Docker
./gradlew haloServer
# 后端 — 构建插件 JAR(产物在 build/libs/
@@ -52,6 +54,8 @@ halo-dark-mode-plugin/
├── 前端 (Vue 3 / TypeScript) ← 核心实现
│ ├── index.ts ← definePlugin() 入口,注册路由+组件
│ ├── darkreader-engine.ts ← Dark Reader 通用暗色引擎
│ ├── injector.ts ← ThemeToggle 侧边栏注入器(MutationObserver 方案)
│ ├── composables/
│ │ ├── useDarkMode.ts ← 单例状态管理(light/dark/auto + localStorage 持久化)
│ │ └── useSystemPreference.ts ← matchMedia 系统偏好监听
@@ -60,8 +64,13 @@ halo-dark-mode-plugin/
│ ├── views/
│ │ └── SettingsView.vue ← 设置页面(浅色/深色/跟随系统 三选一)
│ └── styles/
│ ├── index.css ← 样式入口(@import 聚合)
│ ├── variables.css ← 40+ CSS 变量(:root 浅色 + [data-halo-theme="dark"] 深色)
│ └── overrides/ ← 按区域分层覆盖(layout/components/forms/editor/scrollbar/utilities
│ └── overrides/
│ ├── halo-core.css ← ★ 核心:真实 DOM 类名覆盖(最重要)
│ ├── layout.css ← 侧边栏/内容区/页脚
│ ├── components.css ← 旧版组件类覆盖(部分已失效,见下)
│ ├── forms.css / editor.css / scrollbar.css / utilities.css
└── 构建链
└── processUiResources ← Gradle task: ui/dist/ → resources/main/ui/
@@ -72,9 +81,44 @@ halo-dark-mode-plugin/
### 主题切换机制
- **触发方式**`document.documentElement` 上设置/移除 `data-halo-theme="dark"` 属性
- **主引擎**Dark Reader(本地 vendored 于 `ui/vendor/darkreader`API 包由
`npm run api` 构建),监听 `useDarkMode().isDark`,深色时 `enable()`,浅色时
`disable()`,可自动分析 Halo 核心与第三方插件的动态 DOM
- **CSS 变量体系**:所有颜色通过 `--halo-*` 前缀的 CSS 自定义属性控制,一个语义变量对应一个视觉属性
- **颜色空间**:全部使用 OKLCH(感知均匀,暗色模式天然适配Chrome 111+/Firefox 113+/Safari 15.4+
- **颜色空间**:全部使用 OKLCH(感知均匀,暗色模式天然适配)
- **暗色配色策略**:用亮度层次区分背景(越"高"的层越亮),中性色含微量蓝色调,强调色略降饱和
- **CSS 覆盖定位**:现有 `overrides/` 仅作为 Dark Reader 的兼容层与兜底,
不再逐页新增手工转换规则;新页面残留统一交给 Dark Reader 处理
### ⚠️ 最关键的教训:Halo 2.25 用 UnoCSS,不是 Tailwind
Halo 2.25 的 Console 实际使用 **UnoCSS**hash 类如 `uno-*`)加 **BEM 语义aliyun "curl -sI --max-time 5 http://localhost/ 2>&1 |类**。**不要写 `.bg-white``.text-gray-900``.v-card` 这类 Tailwind/Vuetify 原子选择器**——它们在真实 DOM 中不存在,覆盖会静默失效。
真实 DOM 里的容器类名(已被 `halo-core.css` 覆盖):
| 语义 | 真实类名 |
| ------------------- | --------------------------------------------------------------------------------------------- |
| 页面顶栏 | `.page-header` / `.page-header__title-text` |
| 列表卡片容器 | `.card-wrapper` |
| 文章/用户列表项标题 | `.entity-field-title` / `.entity-field-title-body` |
| 分页 | `.pagination` / `.pagination__btn` |
| 标签 | `.tag-default` / `.tag-content` |
| 模态框 | `.modal-content` / `.modal-header` / `.modal-body` / `.modal-footer` |
| 详情页描述项 | `.description-item-wrapper` / `.description-item__label` / `.description-item__content` |
| Toast | `.toast-container .toast-body` |
| 用户头像 | `.avatar-wrapper` / `.avatar-circle` |
新增覆盖时:**先到真实环境确认类名,不要凭经验写**。
### 覆盖策略(重写后)
`halo-core.css` 是主要覆盖文件,按语义类精准覆盖。它包含三类规则:
1. **BEM 语义类**(如 `.card-wrapper`)— 直接 `[data-halo-theme="dark"] .card-wrapper { background-color: var(--halo-bg-card) }`
2. **通用 UnoCSS 工具类**(如 `.bg-gray-50``.hover:text-gray-600`)— 批量覆盖文字/背景/边框
3. **根背景兜底**`html, body { background-color: var(--halo-bg-body) !important }`body 不设暗色会在溢出时露白)
`components.css` 等旧文件里的 Tailwind/Vuetify 选择器部分已失效,保留但优先维护 `halo-core.css`
### 状态管理
@@ -83,25 +127,51 @@ halo-dark-mode-plugin/
- 持久化:`localStorage` key `halo-dark-mode-theme`,默认 `auto`
- FOUC 防护:插件 bundle 顶部同步执行脚本,DOM 渲染前设置 `data-halo-theme`
### ThemeToggle 侧边栏注入
Halo 扩展点系统**没有侧边栏插槽**。`injector.ts``MutationObserver` 监听 `.sidebar__profile` 元素出现,在其上方插入容器并挂载 `ThemeToggle.vue` 组件。这是当前 Halo 插件体系下的合理变通方案。
### 前端入口
- `definePlugin()``@halo-dev/ui-shared` 导入(非 `@halo-dev/console-shared`,那是旧版 plugin-starter 的源
- `definePlugin()``@halo-dev/ui-shared` 导入(非旧版 `@halo-dev/console-shared`
- 路由使用 `() => 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
每次执行 `./gradlew build` 打 JAR 前,**必须先递增版本标签**:
1. 修改 `gradle.properties` 中的 `version`(如 `1.0.2``1.0.3`
2. 同步更新 `src/main/java/run/halo/darkmode/DarkModePlugin.java``@since`
3. 构建完成后核对 `build/libs/plugin-dark-mode-<新版本>.jar` 存在,且 JAR 内的
`ui/style.css``ui/main.js` 包含本次改动(用关键字检查,例如新增类名)
4. 向用户交付时明确给出新 JAR 路径和版本号,避免线上仍加载旧 bundle 的混淆
## 验证工作流(必须)
对 CSS 覆盖的任何改动,都要在**真实 Halo 环境**验证,不能只靠 `vite build` 通过:
1. 打开官方 demo 站 `https://demo.halocms.site/console`,登录 `demo / P@ssw0rd123..`
2. 用 Playwright 的 `page.addStyleTag({ path: 'ui/build/dist/style.css' })` 注入构建产物,并 `document.documentElement.setAttribute('data-halo-theme', 'dark')`
3. 遍历页面,扫描 `main *` 中残留的白色背景(`rgb(255,255,255)` 且尺寸 >100×40)和深色文字(`rgb(17,24,39)` 等)元素,记录其真实类名
4. 新增覆盖后重新构建、重新注入、重新扫描,直到零残留
5. 最终 `./gradlew build` 打 JAR,确认 `ui/style.css` 已更新
第三方插件页面(链接/订阅/瞬间等)在 demo 站已装,可一并验证。
## Playwright 自动化验证(workplace/
`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,包含功能介绍、快速开始、构建命令和项目结构图。
+14 -3
View File
@@ -10,12 +10,15 @@ Halo 2.25 暗色模式插件 — 为 Halo 后台管理面板提供深色/浅色
-**瞬间切换**CSS 变量瞬时生效,无可见闪烁
- 🧩 **侧边栏注入**:切换按钮自动出现在侧边栏底部(UserProfileBanner 上方)
- ⚙️ **设置页面**:提供详细的模式选择界面(菜单 → 偏好设置 → 深色模式)
- 🔍 **Dark Reader 引擎**:内置成熟的开源暗色转换引擎,自动分析页面 CSS 与 DOM,
第三方插件页面的黑字/白底也能自动转换,不再依赖逐页手工覆盖
- 🎨 **OKLCH 色彩空间**:感知均匀,暗色模式天然适配,WCAG AA 对比度保证
- 📦 **零后端依赖**:纯前端实现,不需要后端 API
-**覆盖已验证**:仪表盘、内容管理(文章/页面/评论)、链接、订阅、瞬间、用户、主题、设置、编辑器、模态框等页面已针对 Halo 2.25 真实 DOM 逐一验证
## 开发环境
- Java 21+
- Java 21+(Halo 插件编译要求;本机开发使用 JDK 25 + `--release 21`
- Node.js 18+
- pnpm
@@ -55,7 +58,7 @@ pnpm test:unit # 单元测试
## 项目结构
```
```text
├── build.gradle # 根构建(BOM 2.25.0, DevTools 0.8.0
├── settings.gradle # 包含 :ui 子项目
├── src/
@@ -68,6 +71,7 @@ pnpm test:unit # 单元测试
└── ui/
└── src/
├── index.ts # definePlugin 入口
├── darkreader-engine.ts # Dark Reader 通用暗色引擎
├── injector.ts # ThemeToggle 侧边栏注入器
├── composables/
│ ├── useDarkMode.ts # 核心状态管理(模块级单例)
@@ -79,7 +83,14 @@ pnpm test:unit # 单元测试
└── styles/
├── index.css # 样式入口
├── variables.css # 40+ CSS 变量(浅色 + 深色)
└── overrides/ # 组件覆盖样式
└── overrides/
├── halo-core.css # ★ 核心:真实 DOM 类名覆盖
├── layout.css # 侧边栏/内容区/页脚
├── components.css # 组件类覆盖
├── forms.css # FormKit 表单
├── editor.css # 富文本编辑器
├── scrollbar.css # 滚动条
└── utilities.css # 通用工具类
```
## 许可证
-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
+3 -1
View File
@@ -1 +1,3 @@
version=1.0.0-SNAPSHOT
version=1.0.3
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
org.gradle.java.home=D:/settings/Language/Java/jdk-25.0.1
+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
+60
View File
@@ -0,0 +1,60 @@
# 暗色模式回归扫描工具
用 Playwright 驱动 Edge 对 Halo 后台做全路由深浅色残留扫描,验证每次 CSS 改动。
## 依赖
- 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 workplace\pw-profile
```
## 常用命令
```powershell
# 全路由深色扫描,注入本地构建产物做预部署验证,存在残留时返回非 0
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
```
## 输出
- `scripts/output/scan-results.json`:按路由分组的结果
- `scripts/output/shots/*.png`:每页截图
- 均已被 gitignore,不入库。
## 切换行为验证
```powershell
D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\verify-toggle.py
```
部署新版插件后运行,会检查侧边栏按钮切换、`data-halo-theme` 属性、
localStorage 持久化以及 Monaco 日志查看器主题是否同步。
## 注意事项
- 扫描会忽略 `img/canvas/video/svg/iframe`,避免把文章图片、编辑器画布等内容性
元素误判为界面残留。
- 路由从 Vue Router 动态发现,Halo 或插件升级新增页面后无需维护清单。
- `--local-css``add_style_tag` 追加本地样式(不替换线上 bundle),可安全验证
CSS 修复;JS 改动(如 Monaco 主题同步)需要重新构建并部署后再跑 `verify-toggle.py`
+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())
+76
View File
@@ -0,0 +1,76 @@
"""验证侧边栏主题切换:点击后检查 data-halo-theme、localStorage 与 Monaco 主题同步。
部署新版插件后运行可获得完整结果;预部署阶段 Monaco 断言会提示跳过。
"""
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"
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 = page.locator(".theme-toggle")
toggle.wait_for(state="visible", timeout=15000)
before = page.evaluate("() => ({ attr: document.documentElement.getAttribute('data-halo-theme'), stored: localStorage.getItem('halo-dark-mode-theme') })")
toggle.click()
time.sleep(0.5)
after = page.evaluate("() => ({ attr: document.documentElement.getAttribute('data-halo-theme'), stored: localStorage.getItem('halo-dark-mode-theme') })")
toggle.click()
time.sleep(0.5)
restored = page.evaluate("() => ({ attr: document.documentElement.getAttribute('data-halo-theme'), stored: localStorage.getItem('halo-dark-mode-theme') })")
attr_flipped = before["attr"] != after["attr"] and before["attr"] == restored["attr"]
stored_flipped = before["stored"] != after["stored"] and before["stored"] == restored["stored"]
print(f"初始: {before}")
print(f"切换: {after}")
print(f"还原: {restored}")
print(f"属性翻转: {'PASS' if attr_flipped else 'FAIL'} 存储翻转: {'PASS' if stored_flipped else 'FAIL'}")
page.goto(args.base + "/console/log-viewer", wait_until="domcontentloaded")
try:
page.wait_for_load_state("networkidle", timeout=6000)
except Exception:
pass
time.sleep(2)
has_monaco = page.locator(".monaco-editor").count() > 0
if has_monaco:
dark_attr = page.evaluate("document.documentElement.getAttribute('data-halo-theme')") == "dark"
theme_cls = page.evaluate("() => { const el = document.querySelector('.monaco-editor'); return el ? el.className : '' }")
monaco_dark = "vs-dark" in theme_cls
print(f"Monaco 实例: 存在 | 属性dark={dark_attr} | 主题类含vs-dark={monaco_dark} | class={theme_cls[:60]}")
print(f"Monaco 主题同步: {'PASS' if (dark_attr and monaco_dark) or (not dark_attr and not monaco_dark) else 'FAIL(可能部署的还是旧版 JS'}")
else:
print("Monaco 实例: 未检测到,跳过主题断言(日志页面可能未加载或路由不同)")
ctx.close()
return 0 if attr_flipped and stored_flipped else 1
if __name__ == "__main__":
sys.exit(main())
@@ -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.3
*/
@Component
public class DarkModePlugin extends BasePlugin {
+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'
+3 -1
View File
@@ -23,10 +23,10 @@
"@halo-dev/ui-shared": "^2.25.1",
"axios": "^1.13.5",
"canvas-confetti": "^1.9.3",
"darkreader": "file:./vendor/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:./vendor/darkreader
version: file:vendor/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:vendor/darkreader:
resolution: {directory: vendor/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:vendor/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'
+4 -3
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { IconMoon, IconSunny } from '@halo-dev/components'
import IconSunLine from '~icons/ri/sun-line'
import IconMoonLine from '~icons/ri/moon-line'
import { useDarkMode } from '../composables/useDarkMode'
const { isDark, toggle } = useDarkMode()
@@ -7,8 +8,8 @@ const { isDark, toggle } = useDarkMode()
<template>
<div class="theme-toggle" @click="toggle">
<IconSunny v-if="isDark" class="theme-toggle__icon" />
<IconMoon v-else class="theme-toggle__icon" />
<IconSunLine v-if="isDark" class="theme-toggle__icon" />
<IconMoonLine v-else class="theme-toggle__icon" />
<span class="theme-toggle__label">
{{ isDark ? '浅色模式' : '深色模式' }}
</span>
+48
View File
@@ -0,0 +1,48 @@
import { disable, enable } from 'darkreader'
import { watch } from 'vue'
import { useDarkMode } from './composables/useDarkMode'
/**
* Dark Reader 引擎参数。
* brightness/contrast 保持默认观感,背景色与现有 Halo 暗色变量接近,
* 让 Dark Reader 补全第三方插件页面时不会显得突兀。
*/
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 },
)
}
+10 -2
View File
@@ -1,7 +1,15 @@
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'
import { injectThemeToggle } from './injector'
import { initMonacoThemeSync } from './monaco-theme'
// 在插件加载后将切换器注入到侧边栏,并让 Monaco 跟随主题
injectThemeToggle()
initMonacoThemeSync()
initDarkReaderEngine()
export default definePlugin({
components: {},
@@ -18,7 +26,7 @@ export default definePlugin({
menu: {
name: '深色模式',
group: '偏好设置',
icon: markRaw(IconMoon),
icon: markRaw(IconPalette),
priority: 50,
},
},
+46
View File
@@ -0,0 +1,46 @@
import { createVNode, render } from 'vue'
import ThemeToggle from './components/ThemeToggle.vue'
const CONTAINER_CLASS = 'plugin-dark-mode-toggle'
const TARGET_SELECTOR = '.sidebar__profile'
let mounted = false
/**
* 将 ThemeToggle 注入到侧边栏中 UserProfileBanner 上方。
* 使用 MutationObserver 等待侧边栏 DOM 渲染完成。
*/
export function injectThemeToggle(): void {
if (mounted) return
// 先尝试直接查找(侧边栏可能已经渲染)
tryMount()
// 如果还没渲染,等待 DOM 变化
if (!mounted) {
const observer = new MutationObserver(() => {
tryMount()
if (mounted) observer.disconnect()
})
observer.observe(document.body, { childList: true, subtree: true })
}
}
function tryMount(): void {
// 避免重复挂载
if (document.querySelector(`.${CONTAINER_CLASS}`)) {
mounted = true
return
}
const profileEl = document.querySelector(TARGET_SELECTOR)
if (!profileEl?.parentNode) return
const container = document.createElement('div')
container.className = CONTAINER_CLASS
profileEl.parentNode.insertBefore(container, profileEl)
const vnode = createVNode(ThemeToggle)
render(vnode, container)
mounted = true
}
+43
View File
@@ -0,0 +1,43 @@
/**
* 让 Monaco Editor(日志查看器)跟随 Halo 暗色模式。
* 优先调用 Monaco 官方主题 APICSS 兜底见 styles/overrides/editor.css。
*/
type MonacoLike = {
editor?: {
setTheme: (name: string) => void
}
}
const DARK_THEME = 'vs-dark'
const LIGHT_THEME = 'vs'
function getMonaco(): MonacoLike | undefined {
return (window as unknown as { monaco?: MonacoLike }).monaco
}
function applyMonacoTheme(): void {
const monaco = getMonaco()
if (!monaco?.editor?.setTheme) return
const isDark = document.documentElement.getAttribute('data-halo-theme') === 'dark'
monaco.editor.setTheme(isDark ? DARK_THEME : LIGHT_THEME)
}
/** 初始化 Monaco 主题同步:立即应用,并监听属性变化与实例懒加载。 */
export function initMonacoThemeSync(): void {
applyMonacoTheme()
const attributeObserver = new MutationObserver(() => applyMonacoTheme())
attributeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-halo-theme'],
})
// 日志页面可能懒加载 Monaco,实例渲染后再补一次
const instanceObserver = new MutationObserver(() => {
if (document.querySelector('.monaco-editor')) {
applyMonacoTheme()
}
})
instanceObserver.observe(document.body, { childList: true, subtree: true })
}
+3
View File
@@ -4,6 +4,9 @@
============================================================ */
@import './variables.css';
@import './overrides/halo-core.css';
@import './overrides/plugin-pages.css';
@import './overrides/uno-fallback.css';
@import './overrides/utilities.css';
@import './overrides/layout.css';
@import './overrides/components.css';
+16 -88
View File
@@ -1,73 +1,21 @@
/* ============================================================
Halo Dark Mode — @halo-dev/components 组件库覆盖
覆盖 Halo 组件库中的 VCard, VModal, VDropdown, VTag 等
Halo Dark Mode — Halo 组件库覆盖(已清理)
已删除 Halo 2.25 中不存在的 Vuetify 类(.v-card 等),
真实 DOM 类覆盖请优先维护 halo-core.css。
============================================================ */
[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);
/* ===== 模态框(具体类 + !important,防被核心 scoped 样式覆盖) ===== */
.modal-content,
.modal-header,
.modal-body,
.modal-footer {
background-color: var(--halo-bg-card) !important;
color: var(--halo-text-primary) !important;
}
/* ===== 表格 VTable ===== */
table,
.v-table,
[class*="table"] {
table {
background-color: var(--halo-bg-card);
color: var(--halo-text-primary);
}
@@ -94,49 +42,29 @@
}
/* ===== 分页 ===== */
.pagination,
.v-pagination {
.pagination {
color: var(--halo-text-secondary);
}
.pagination .active,
.v-pagination .active {
.pagination .active {
background-color: var(--halo-accent-primary);
color: var(--halo-accent-primary-text);
}
/* ===== 面包屑 ===== */
.breadcrumb,
.v-breadcrumb {
.breadcrumb {
color: var(--halo-text-secondary);
}
.breadcrumb a,
.v-breadcrumb a {
.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);
}
}
}
+192
View File
@@ -1,9 +1,172 @@
/* ============================================================
Halo Dark Mode — 富文本编辑器覆盖
@halo-dev/richtext-editor + TipTap / ProseMirror
+ Bytemd (Markdown 编辑器) + CodeMirror
============================================================ */
[data-halo-theme="dark"] {
/* ===== Bytemd Markdown 编辑器 ===== */
.bytemd {
background-color: var(--halo-bg-card) !important;
color: var(--halo-text-primary) !important;
border-color: var(--halo-border-base) !important;
}
.bytemd-toolbar {
background-color: var(--halo-bg-content) !important;
color: var(--halo-text-primary) !important;
border-color: var(--halo-border-light) !important;
}
.bytemd-toolbar-icon {
color: var(--halo-text-secondary) !important;
}
.bytemd-toolbar-icon:hover {
background-color: var(--halo-bg-hover) !important;
color: var(--halo-text-primary) !important;
}
.bytemd-toolbar-tab-active,
.bytemd-toolbar-icon-active {
color: var(--halo-accent-primary) !important;
}
.bytemd-editor,
.bytemd-editor .CodeMirror {
background-color: var(--halo-bg-input) !important;
color: var(--halo-text-primary) !important;
}
.bytemd-preview {
background-color: var(--halo-bg-card) !important;
color: var(--halo-text-primary) !important;
}
.bytemd-status,
.bytemd-status-right,
.bytemd-status-left {
background-color: var(--halo-bg-content) !important;
color: var(--halo-text-secondary) !important;
border-color: var(--halo-border-light) !important;
}
.bytemd-dropdown,
.bytemd-dropdown-menu,
[class*="bytemd-dropdown"] {
background-color: var(--halo-bg-dropdown) !important;
color: var(--halo-text-primary) !important;
border-color: var(--halo-border-base) !important;
}
.bytemd-dropdown-title {
color: var(--halo-text-secondary) !important;
}
.bytemd-dropdown-item {
color: var(--halo-text-primary) !important;
}
.bytemd-dropdown-item:hover {
background-color: var(--halo-bg-hover) !important;
}
.bytemd-sidebar,
.bytemd-toc,
.bytemd-toc ul,
.bytemd-toc li {
background-color: var(--halo-bg-content) !important;
color: var(--halo-text-secondary) !important;
}
.bytemd-toc-active {
background-color: var(--halo-bg-hover) !important;
color: var(--halo-accent-primary) !important;
}
.bytemd-floating-btn {
background-color: var(--halo-bg-card) !important;
color: var(--halo-text-secondary) !important;
border-color: var(--halo-border-base) !important;
}
.bytemd-floating-btn:hover {
background-color: var(--halo-bg-hover) !important;
}
.bytemd-floating-btn.active {
background-color: var(--halo-bg-active) !important;
color: var(--halo-accent-primary) !important;
}
.bytemd-code-theme-toggle,
.bytemd-copy-code-btn,
[class*="bytemd"] button {
color: var(--halo-text-secondary) !important;
}
.bytemd-copy-code-btn:hover,
[class*="bytemd"] button:hover {
color: var(--halo-text-primary) !important;
background-color: var(--halo-bg-hover) !important;
}
/* 编辑器内 markdown 正文 */
.bytemd-preview .markdown-body,
.bytemd-editor .markdown-body {
color: var(--halo-text-primary) !important;
}
.bytemd-preview .markdown-body p,
.bytemd-preview .markdown-body li,
.bytemd-preview .markdown-body h1,
.bytemd-preview .markdown-body h2,
.bytemd-preview .markdown-body h3,
.bytemd-preview .markdown-body h4,
.bytemd-preview .markdown-body td,
.bytemd-preview .markdown-body th,
.bytemd-preview .markdown-body blockquote {
color: var(--halo-text-primary) !important;
}
.bytemd-preview .markdown-body code,
.bytemd-preview .markdown-body pre,
.bytemd-preview .markdown-body pre code {
background-color: oklch(22% 0.02 250) !important;
color: oklch(85% 0.05 160) !important;
}
.bytemd-preview .markdown-body table tr,
.bytemd-preview .markdown-body table tr:nth-child(2n) {
background-color: var(--halo-bg-card) !important;
}
.bytemd-preview .markdown-body table td,
.bytemd-preview .markdown-body table th {
border-color: var(--halo-border-base) !important;
}
/* ===== CodeMirror 代码编辑器 ===== */
.CodeMirror {
background-color: var(--halo-bg-input) !important;
color: var(--halo-text-primary) !important;
}
.CodeMirror-gutters {
background-color: var(--halo-bg-content) !important;
border-color: var(--halo-border-light) !important;
}
.CodeMirror-linenumber {
color: var(--halo-text-tertiary) !important;
}
.CodeMirror-cursor {
border-color: var(--halo-text-primary) !important;
}
.CodeMirror-selected {
background-color: oklch(30% 0.04 160 / 50%) !important;
}
.cm-s-default .cm-header,
.cm-s-default .cm-strong,
.cm-s-default .cm-em,
.cm-s-default .cm-link,
.cm-s-default .cm-url {
color: var(--halo-text-primary) !important;
}
.cm-s-default .cm-comment {
color: var(--halo-text-tertiary) !important;
}
.cm-s-default .cm-quote {
color: var(--halo-accent-success) !important;
}
.cm-s-default .cm-tag {
color: var(--halo-accent-primary) !important;
}
.CodeMirror pre.CodeMirror-placeholder {
color: var(--halo-text-tertiary) !important;
}
.CodeMirror-activeline-background {
background-color: var(--halo-bg-hover) !important;
}
.CodeMirror-lines {
color: var(--halo-text-primary) !important;
}
/* ===== ProseMirror / TipTap 编辑器主体 ===== */
.ProseMirror,
.tiptap,
@@ -69,3 +232,32 @@
background-color: oklch(30% 0.04 160 / 50%);
}
}
/* ===== Monaco Editor(日志查看器,JS 主题优先,此处仅兜底) ===== */
html[data-halo-theme="dark"] .monaco-editor.vs,
html[data-halo-theme="dark"] .monaco-editor.vs .margin,
html[data-halo-theme="dark"] .monaco-editor.vs .lines-content,
html[data-halo-theme="dark"] .monaco-editor.vs .monaco-scrollable-element {
background-color: var(--halo-bg-input) !important;
color: var(--halo-text-primary) !important;
}
html[data-halo-theme="dark"] .monaco-editor.vs .line-numbers {
color: var(--halo-text-tertiary) !important;
}
html[data-halo-theme="dark"] .monaco-editor.vs .cursor {
border-color: var(--halo-text-primary) !important;
}
html[data-halo-theme="dark"] .monaco-editor.vs .selected-text,
html[data-halo-theme="dark"] .monaco-editor.vs .view-overlays .selected-text {
background-color: oklch(30% 0.04 160 / 50%) !important;
}
html[data-halo-theme="dark"] .monaco-editor.vs .find-widget,
html[data-halo-theme="dark"] .monaco-editor.vs .monaco-hover {
background-color: var(--halo-bg-dropdown) !important;
color: var(--halo-text-primary) !important;
}
/* ===== 编辑器链接(覆盖通用链接色规则) ===== */
html[data-halo-theme="dark"] .ProseMirror a,
html[data-halo-theme="dark"] .tiptap a {
color: var(--halo-text-link) !important;
}
+13
View File
@@ -87,3 +87,16 @@
border-color: var(--halo-border-base);
}
}
/* ===== 强表单覆盖(scoped 哈希类特异性更高,需 html 前缀 + !important ===== */
html[data-halo-theme="dark"] input:not([type="checkbox"]):not([type="radio"]):not([type="color"]),
html[data-halo-theme="dark"] textarea,
html[data-halo-theme="dark"] select {
background-color: var(--halo-bg-input) !important;
color: var(--halo-text-primary) !important;
border-color: var(--halo-border-input) !important;
}
html[data-halo-theme="dark"] input:not([type="checkbox"]):not([type="radio"]):not([type="color"])::placeholder,
html[data-halo-theme="dark"] textarea::placeholder,
html[data-halo-theme="dark"] select::placeholder {
color: var(--halo-text-tertiary) !important;
}
+308
View File
@@ -0,0 +1,308 @@
/* ===== 根元素背景(html 自身携带属性,需独立选择器) ===== */
html[data-halo-theme="dark"] {
background-color: var(--halo-bg-body) !important;
}
/* ============================================================
Halo Dark Mode — Halo 核心 BEM 类覆盖
基于 Halo 2.25 真实 DOM 扫描(demo.halocms.site)得出的选择器
============================================================ */
[data-halo-theme="dark"] {
/* ===== 页面根背景(body 溢出时兜底,html 见文件顶部独立规则) ===== */
body {
background-color: var(--halo-bg-body) !important;
color: var(--halo-text-primary);
}
/* ===== Toast 通知 ===== */
.toast-container .toast-body {
background-color: var(--halo-bg-card) !important;
color: var(--halo-text-primary) !important;
border-color: var(--halo-border-base);
}
/* ===== Markdown 正文(编辑器预览/瞬间预览) ===== */
.markdown-body {
background-color: transparent;
color: var(--halo-text-primary);
}
.markdown-body p,
.markdown-body li,
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6,
.markdown-body td,
.markdown-body th,
.markdown-body blockquote,
.markdown-body a {
color: var(--halo-text-primary);
}
.markdown-body a {
color: var(--halo-text-link);
}
.markdown-body code,
.markdown-body tt {
background-color: oklch(28% 0.02 250);
color: oklch(85% 0.05 160);
}
.markdown-body pre,
.markdown-body .highlight pre {
background-color: oklch(20% 0.015 250);
color: oklch(85% 0.03 160);
}
.markdown-body table tr,
.markdown-body table tr:nth-child(2n) {
background-color: var(--halo-bg-card);
}
.markdown-body table td,
.markdown-body table th {
border-color: var(--halo-border-base);
}
.markdown-body hr {
background-color: var(--halo-border-base);
}
/* ===== 页面顶栏 ===== */
.page-header {
background-color: var(--halo-bg-card);
}
.page-header__title-text {
color: var(--halo-text-primary);
}
.page-header__actions .btn,
.page-header__actions button {
background-color: var(--halo-bg-card);
color: var(--halo-text-primary);
border-color: var(--halo-border-base);
}
.page-header__actions .btn:hover,
.page-header__actions button:hover {
background-color: var(--halo-bg-hover);
}
.page-header__actions .btn-content {
color: var(--halo-text-primary);
}
/* ===== 卡片容器(列表页、详情页主容器) ===== */
.card-wrapper {
background-color: var(--halo-bg-card);
border-color: var(--halo-border-base);
color: var(--halo-text-primary);
}
/* ===== 实体字段列表(文章/页面/评论列表项) ===== */
.entity-field-title {
color: var(--halo-text-primary);
}
.entity-field-title-body {
color: var(--halo-text-primary);
}
.entity-field-description {
color: var(--halo-text-secondary);
}
/* ===== 分页 ===== */
.pagination {
background-color: var(--halo-bg-card);
color: var(--halo-text-secondary);
}
.pagination__btn {
background-color: var(--halo-bg-hover);
color: var(--halo-text-secondary);
}
.pagination__btn:hover {
background-color: var(--halo-bg-active);
color: var(--halo-text-primary);
}
.pagination__select {
background-color: var(--halo-bg-input);
color: var(--halo-text-primary);
border-color: var(--halo-border-input);
}
/* ===== 标签/徽章(BEM ===== */
.tag-wrapper,
.tag-default,
.tag-pill {
background-color: var(--halo-bg-hover);
color: var(--halo-text-secondary);
}
.tag-content,
.tag-left-icon,
.tag-right-icon {
color: var(--halo-text-secondary);
}
/* ===== 头像 ===== */
.avatar-wrapper,
.avatar-circle {
background-color: var(--halo-bg-hover);
}
/* ===== 按钮(默认态) ===== */
.btn-default,
.btn-secondary,
.btn-sm,
.btn-md,
.btn-lg,
.btn[class*="btn"]:not(.btn-primary):not(.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,
.btn-sm:hover,
.btn-md:hover,
.btn-lg:hover {
background-color: var(--halo-bg-hover);
}
.btn-content {
color: var(--halo-text-primary);
}
/* ===== 表单容器(FormKit ===== */
.formkit-inner {
background-color: var(--halo-bg-input);
}
.formkit-inner input,
.formkit-inner textarea,
.formkit-inner select {
background-color: transparent;
color: var(--halo-text-primary);
}
.formkit-inner .border,
.formkit-inner [class*="border-gray"] {
border-color: var(--halo-border-input);
}
/* ===== 下拉菜单 ===== */
.entity-dropdown {
background-color: var(--halo-bg-dropdown);
color: var(--halo-text-primary);
border-color: var(--halo-border-base);
}
.entity-dropdown-trigger {
background-color: var(--halo-bg-hover);
color: var(--halo-text-secondary);
}
.entity-dropdown-trigger:hover {
background-color: var(--halo-bg-active);
color: var(--halo-text-primary);
}
/* ===== 描述项(详情页列表,如用户详情) ===== */
.description-item-wrapper {
background-color: var(--halo-bg-card);
border-color: var(--halo-border-light);
}
.description-item__label {
color: var(--halo-text-secondary) !important;
}
.description-item__content {
color: var(--halo-text-primary) !important;
}
/* ===== 已选指示器 ===== */
.entity-selected-indicator {
color: var(--halo-accent-primary);
}
/* ===== 表格 ===== */
.table,
[class*="table-"] td,
[class*="table-"] th {
border-color: var(--halo-table-border);
color: var(--halo-text-primary);
}
/* ===== 通用文字(UnoCSS hash 类包含 Tailwind 语义) ===== */
.hover\:text-gray-600:hover,
.hover\:text-gray-700:hover,
.hover\:text-gray-500:hover,
.text-gray-600,
.text-gray-700,
.text-gray-500 {
color: var(--halo-text-secondary);
}
.text-gray-900,
.text-gray-800 {
color: var(--halo-text-primary);
}
.text-gray-400,
.text-gray-300 {
color: var(--halo-text-tertiary);
}
/* ===== 通用背景 ===== */
.bg-gray-50,
.bg-gray-100,
.bg-white {
background-color: var(--halo-bg-card);
}
.bg-gray-200 {
background-color: var(--halo-bg-hover);
}
.hover\:bg-gray-50:hover,
.hover\:bg-gray-100:hover,
.hover\:bg-gray-200:hover {
background-color: var(--halo-bg-hover);
}
/* ===== 边框 ===== */
.border,
.border-gray-200,
.border-gray-300 {
border-color: var(--halo-border-base);
}
}
/* ===== 侧边栏激活菜单(Halo 核心 .menu-item-title.active ===== */
html[data-halo-theme="dark"] .menu-item-title.active,
html[data-halo-theme="dark"] .menu-item-title.active:hover {
background-color: var(--halo-menu-item-active) !important;
color: var(--halo-text-primary) !important;
}
html[data-halo-theme="dark"] .menu-item-title.active::after {
background: var(--halo-accent-primary) !important;
}
/* ===== 空状态标题 ===== */
html[data-halo-theme="dark"] .empty-wrapper .empty-title {
color: var(--halo-text-secondary) !important;
}
/* ===== 提示条(Alert ===== */
html[data-halo-theme="dark"] .alert-wrapper {
background-color: var(--halo-bg-card) !important;
border-color: var(--halo-border-base) !important;
color: var(--halo-text-primary) !important;
}
html[data-halo-theme="dark"] .alert-wrapper .alert-title,
html[data-halo-theme="dark"] .alert-wrapper .alert-description,
html[data-halo-theme="dark"] .alert-wrapper .alert-icon {
color: var(--halo-text-secondary) !important;
}
html[data-halo-theme="dark"] .alert-wrapper.alert-warning {
border-color: oklch(40% 0.12 85) !important;
}
html[data-halo-theme="dark"] .alert-wrapper.alert-error {
border-color: oklch(40% 0.17 25) !important;
}
html[data-halo-theme="dark"] .alert-wrapper.alert-success {
border-color: oklch(40% 0.14 150) !important;
}
/* ===== 实体列表标题链接(覆盖通用链接色,hover 变链接色) ===== */
html[data-halo-theme="dark"] .entity-field-title a,
html[data-halo-theme="dark"] .entity-field-title-body a {
color: var(--halo-text-primary) !important;
}
html[data-halo-theme="dark"] .entity-field-title a:hover,
html[data-halo-theme="dark"] .entity-field-title-body a:hover {
color: var(--halo-text-link) !important;
}
+1 -2
View File
@@ -55,8 +55,7 @@
}
/* ===== 页面标题 ===== */
.page-title,
.v-card-title {
.page-title {
color: var(--halo-text-primary);
}
}
+383
View File
@@ -0,0 +1,383 @@
/* ============================================================
Halo Dark Mode — 第三方插件页面覆盖
针对用户服务器上已安装插件的内部 UI 残留。
⚠️ 这些插件使用 Vue scoped 样式 ([data-v-xxx])
需用 !important 才能稳定覆盖。
============================================================ */
[data-halo-theme="dark"] {
/* ===== 装备管理插件 (halo-plugin-equipments) ===== */
.equipments .group,
.equipment-group .group,
main .group {
background-color: var(--halo-bg-card) !important;
border-color: var(--halo-border-base) !important;
color: var(--halo-text-primary) !important;
}
.equipment-card,
.equipment-card__name,
.equipment-card__desc,
[class*="equipment"] {
color: var(--halo-text-primary) !important;
}
/* ===== 日程日历插件 (plugin-calendar) ===== */
.week-picker {
background-color: var(--halo-bg-card) !important;
border-color: var(--halo-border-base) !important;
}
.week-picker select,
.week-picker input,
.week-picker button {
background-color: var(--halo-bg-input) !important;
color: var(--halo-text-primary) !important;
border-color: var(--halo-border-input) !important;
}
.day-column__body {
background-color: var(--halo-bg-content) !important;
border-color: var(--halo-border-light) !important;
}
.day-column,
.calendar-column,
[class*="day-column"] {
background-color: var(--halo-bg-card) !important;
color: var(--halo-text-primary) !important;
}
[class*="calendar"] th,
[class*="calendar"] td {
color: var(--halo-text-primary) !important;
border-color: var(--halo-border-base) !important;
}
/* ===== 心愿便签插件 (plugin-wishboard) ===== */
.ah-body {
background-color: var(--halo-bg-card) !important;
color: var(--halo-text-primary) !important;
}
.wb-s-card {
background-color: var(--halo-bg-card) !important;
color: var(--halo-text-primary) !important;
border-color: var(--halo-border-base) !important;
}
.wb-s-card-title {
color: var(--halo-text-primary) !important;
}
.wb-s-card-count {
color: var(--halo-accent-primary) !important;
}
.wb-s-list,
.wb-s-list-item,
[class*="wb-s-"] {
color: var(--halo-text-primary) !important;
}
/* ===== 瞬间插件 (plugin-moments) ===== */
.moment-editor,
.editor-main,
.moment-editor .card,
.editor-main .card {
background-color: var(--halo-bg-card) !important;
color: var(--halo-text-primary) !important;
border-color: var(--halo-border-base) !important;
}
.moments-content .card,
.moments-content .preview,
.moment-preview,
.moment-card,
[class*="moment-"] .card {
background-color: var(--halo-bg-card) !important;
color: var(--halo-text-primary) !important;
}
.moment-author-name,
.moment-content,
.moment-time,
[class*="moment"] p,
[class*="moment"] span,
[class*="moment"] div {
color: var(--halo-text-primary) !important;
}
.moment-editor input,
.moment-editor textarea,
.editor-main input,
.editor-main textarea {
background-color: var(--halo-bg-input) !important;
color: var(--halo-text-primary) !important;
border-color: var(--halo-border-input) !important;
}
/* ===== 抽奖插件 (plugin-lottery) ===== */
[class*="lottery"] {
color: var(--halo-text-primary) !important;
}
/* ===== 存储工具箱 (plugin-storage-toolkit) ===== */
.toolkit-wrapper,
.st-scope {
background-color: var(--halo-bg-content) !important;
color: var(--halo-text-primary) !important;
}
.st-pill-tabs,
.stat-card.st-card,
.panel-card.st-card,
.st-card {
background-color: var(--halo-bg-card) !important;
color: var(--halo-text-primary) !important;
border-color: var(--halo-border-base) !important;
}
.st-card h3,
.st-card h4,
.st-card p,
.st-card span,
.st-card li,
[class*="st-"] .title,
[class*="st-"] .name {
color: var(--halo-text-primary) !important;
}
[class*="st-"] {
color: var(--halo-text-primary) !important;
}
/* ===== 应用市场已安装 (Halo 内置 app-store) ===== */
.tabbar-wrapper,
.tabbar-outline,
[class*="tabbar"] {
background-color: var(--halo-bg-hover) !important;
color: var(--halo-text-primary) !important;
border-color: var(--halo-border-base) !important;
}
.tabbar-item,
[class*="tabbar"] a,
[class*="tabbar"] span {
color: var(--halo-text-primary) !important;
}
.tabbar-item:hover,
[class*="tabbar"] a:hover {
background-color: var(--halo-bg-active) !important;
color: var(--halo-text-primary) !important;
}
/* 已安装应用页的筛选和卡片 */
.app-card,
.app-card-header,
.app-card-body,
[class*="app-card"] {
background-color: var(--halo-bg-card) !important;
color: var(--halo-text-primary) !important;
border-color: var(--halo-border-base) !important;
}
.app-card .title,
.app-card .name,
.app-card .desc,
[class*="app-card"] h3,
[class*="app-card"] p {
color: var(--halo-text-primary) !important;
}
/* ===== 文档/示例页面 (docs__box) ===== */
.docs__box {
background-color: var(--halo-bg-card) !important;
color: var(--halo-text-primary) !important;
border-color: var(--halo-border-base) !important;
}
.docs__box h3,
.docs__box h4,
.docs__box p,
.docs__box li,
.docs__box a {
color: var(--halo-text-primary) !important;
}
.docs__box a:hover {
color: var(--halo-text-link) !important;
}
/* ===== 通用:任何插件页面的亮色卡片兜底 ===== */
main .bg-gray-50,
main .bg-gray-100,
main .bg-gray-200 {
background-color: var(--halo-bg-hover) !important;
}
main .bg-white {
background-color: var(--halo-bg-card) !important;
}
/* ===== 应用卡片内筛选 tabUnoCSS hash 类,用上下文定位) ===== */
.card-wrapper .card-header [class*="i-"],
.card-header [class*="i-"],
.card-wrapper .card-body [class*="i-"],
.card-body [class*="i-"],
[class*="app-card"] [class*="i-"] {
background-color: var(--halo-bg-hover) !important;
color: var(--halo-text-primary) !important;
}
.card-wrapper .card-header [class*="i-"]:hover,
.card-header [class*="i-"]:hover,
.card-wrapper .card-body [class*="i-"]:hover,
.card-body [class*="i-"]:hover {
background-color: var(--halo-bg-active) !important;
color: var(--halo-text-primary) !important;
}
/* ===== 存储工具箱表格表头 (col-*) ===== */
.col-name,
.col-count,
.col-size,
.col-percent,
[class*="col-"] {
background-color: var(--halo-table-header-bg) !important;
color: var(--halo-text-secondary) !important;
}
/* ===== 侧边栏用户资料(Halo Pro 特有) ===== */
.sidebar__profile .user-profile {
background-color: transparent !important;
}
.user-profile__name {
color: var(--halo-text-primary) !important;
}
.user-profile__roles .tag-default,
.user-profile__roles .tag-wrapper {
background-color: var(--halo-bg-hover) !important;
color: var(--halo-text-secondary) !important;
}
.user-profile__action-button {
color: var(--halo-text-secondary) !important;
background-color: transparent !important;
}
.user-profile__action-button:hover {
background-color: var(--halo-bg-hover) !important;
color: var(--halo-text-primary) !important;
}
}
/* ===== AstraHub / 心愿便签(共用 ah-* UI ===== */
html[data-halo-theme="dark"] .ah-card,
html[data-halo-theme="dark"] .ah-topbar,
html[data-halo-theme="dark"] .ah-topbar-search,
html[data-halo-theme="dark"] .ah-float-nav {
background-color: var(--halo-bg-card) !important;
border-color: var(--halo-border-base) !important;
color: var(--halo-text-primary) !important;
}
html[data-halo-theme="dark"] .ah-topbar-search {
background-color: var(--halo-bg-input) !important;
}
html[data-halo-theme="dark"] .ah-topbar-brand,
html[data-halo-theme="dark"] .planet-hero-title {
color: var(--halo-text-primary) !important;
}
html[data-halo-theme="dark"] .ah-topbar-search input {
background-color: transparent !important;
color: var(--halo-text-primary) !important;
}
html[data-halo-theme="dark"] .planet-links-more {
background-color: var(--halo-bg-card) !important;
color: var(--halo-text-secondary) !important;
}
/* ===== RSS 订阅(links/rss ===== */
html[data-halo-theme="dark"] .subscription-panel,
html[data-halo-theme="dark"] .feed-main,
html[data-halo-theme="dark"] .feed-toolbar,
html[data-halo-theme="dark"] .feed-stream,
html[data-halo-theme="dark"] .feed-stream__footer {
background-color: var(--halo-bg-card) !important;
border-color: var(--halo-border-base) !important;
color: var(--halo-text-primary) !important;
}
html[data-halo-theme="dark"] .feed-status-tabs {
background-color: var(--halo-bg-hover) !important;
color: var(--halo-text-secondary) !important;
}
html[data-halo-theme="dark"] .feed-brief__title,
html[data-halo-theme="dark"] .feed-stream p,
html[data-halo-theme="dark"] .feed-stream a,
html[data-halo-theme="dark"] .subscription-panel a,
html[data-halo-theme="dark"] .subscription-panel span {
color: var(--halo-text-primary) !important;
}
/* ===== 私密文章插件 ===== */
html[data-halo-theme="dark"] .focus-card,
html[data-halo-theme="dark"] .overview-card,
html[data-halo-theme="dark"] .list-card,
html[data-halo-theme="dark"] .empty-state {
background-color: var(--halo-bg-card) !important;
border-color: var(--halo-border-base) !important;
color: var(--halo-text-primary) !important;
}
html[data-halo-theme="dark"] .overview-card h2,
html[data-halo-theme="dark"] .page-shell h2,
html[data-halo-theme="dark"] .overview-stats dd,
html[data-halo-theme="dark"] .overview-stats dt {
color: var(--halo-text-primary) !important;
}
/* ===== AI 评论自动处理插件 ===== */
html[data-halo-theme="dark"] .settings-container,
html[data-halo-theme="dark"] .setting-panel,
html[data-halo-theme="dark"] .tabs-wrap,
html[data-halo-theme="dark"] .sidebar-card,
html[data-halo-theme="dark"] .list-empty,
html[data-halo-theme="dark"] .reply-card {
background-color: var(--halo-bg-card) !important;
border-color: var(--halo-border-base) !important;
color: var(--halo-text-primary) !important;
}
html[data-halo-theme="dark"] .form-row,
html[data-halo-theme="dark"] .list-col,
html[data-halo-theme="dark"] .panel-header,
html[data-halo-theme="dark"] .card-footer {
background-color: var(--halo-bg-hover) !important;
color: var(--halo-text-primary) !important;
}
html[data-halo-theme="dark"] .section-header__text h3,
html[data-halo-theme="dark"] .sidebar-header h4,
html[data-halo-theme="dark"] .panel-header h3,
html[data-halo-theme="dark"] .reply-card h3 {
color: var(--halo-text-primary) !important;
}
html[data-halo-theme="dark"] .wake-word-tag {
background-color: var(--halo-bg-hover) !important;
color: var(--halo-text-secondary) !important;
}
/* ===== 数据工坊 / 代码注入器等工具页语义容器 ===== */
html[data-halo-theme="dark"] .data-studio-card-body,
html[data-halo-theme="dark"] .injector-view-card-body,
html[data-halo-theme="dark"] .injector-editor-container {
background-color: var(--halo-bg-card) !important;
border-color: var(--halo-border-base) !important;
color: var(--halo-text-primary) !important;
}
/* ===== 示例插件页 ===== */
html[data-halo-theme="dark"] main section#plugin-starter {
background-color: var(--halo-bg-body) !important;
color: var(--halo-text-primary) !important;
}
/* ===== 友链卡片徽章 ===== */
html[data-halo-theme="dark"] .link-badge {
background-color: var(--halo-bg-hover) !important;
color: var(--halo-text-primary) !important;
border-color: var(--halo-border-base) !important;
}
/* ===== 日程日历插件(time-column 布局) ===== */
html[data-halo-theme="dark"] .time-column__header,
html[data-halo-theme="dark"] .time-column__body,
html[data-halo-theme="dark"] [class*="time-column"] {
background-color: var(--halo-bg-card) !important;
color: var(--halo-text-primary) !important;
border-color: var(--halo-border-base) !important;
}
html[data-halo-theme="dark"] .entry-card-header__title {
color: var(--halo-text-primary) !important;
}
/* ===== 存储工具箱补充类 ===== */
html[data-halo-theme="dark"] .stat-value,
html[data-halo-theme="dark"] .stat-card .card-title,
html[data-halo-theme="dark"] .panel-card .card-title {
color: var(--halo-text-primary) !important;
}
html[data-halo-theme="dark"] .bar-track {
background-color: var(--halo-bg-disabled) !important;
}
+34
View File
@@ -0,0 +1,34 @@
/* ============================================================
Halo Dark Mode — UnoCSS 哈希类兜底
Halo 2.25 中 UnoCSS 同时生成 uno-* / i-* 哈希类,
这些类随构建变化,只能按结构定位兜底,不写死哈希值。
注意:本文件规则刻意不加 !important,便于后续语义规则覆盖。
============================================================ */
/* 文字兜底:只修正文本元素,豁免表单控件 */
html[data-halo-theme="dark"] main [class*="uno-"]:not(input):not(select):not(textarea) {
color: var(--halo-text-primary);
}
/* 块级容器背景兜底:豁免交互元素与表单控件 */
html[data-halo-theme="dark"] main [class*="uno-"]:not(button):not(a):not(input):not(select):not(textarea):not(label) {
background-color: var(--halo-bg-card);
border-color: var(--halo-border-base);
}
/* 页面级容器使用内容区背景 */
html[data-halo-theme="dark"] main > [class*="uno-"] {
background-color: var(--halo-bg-content);
}
/* 卡片头/卡片体内的 uno-* 区块使用 hover 级背景 */
html[data-halo-theme="dark"] .card-header [class*="uno-"],
html[data-halo-theme="dark"] .card-body [class*="uno-"] {
background-color: var(--halo-bg-hover);
border-color: var(--halo-border-light);
}
/* 旧版 i-* 哈希:只做文字兜底,避免给图标类设置背景 */
html[data-halo-theme="dark"] main [class*="i-"]:not(input):not(select):not(textarea) {
color: var(--halo-text-primary);
}
+1 -4
View File
@@ -62,10 +62,7 @@
}
/* ===== 链接 ===== */
a:not([class*="text-"]),
.hover\:text-gray-900:hover,
.hover\:text-gray-800:hover,
.hover\:text-gray-700:hover {
html[data-halo-theme="dark"] a:not([class*="text-"]) {
color: var(--halo-text-link);
}
+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.
+9366
View File
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"
}
+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 的代码分析。*