Compare commits
13 Commits
393c472b2d
...
1.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
| d95d17234e | |||
| b6403b7536 | |||
| 363f30dcd6 | |||
| 88a071f673 | |||
| 0bb552b026 | |||
| 202ab537d1 | |||
| b36ad30d7a | |||
| 154fc63eef | |||
| fd1cf94a39 | |||
| 8a525a0e5e | |||
| c1c05d9bd4 | |||
| 42df299791 | |||
| 2a37c36894 |
+24
@@ -10,4 +10,28 @@ build/
|
||||
*.iml
|
||||
.DS_Store
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
dist/
|
||||
# Playwright 测试工具 — 只跟踪脚本和配置,忽略输出/截图/缓存
|
||||
workplace/*
|
||||
!workplace/*.py
|
||||
!workplace/*.yaml
|
||||
|
||||
# 暗色扫描输出与会话
|
||||
scripts/output/
|
||||
scripts/.browser-profile/
|
||||
|
||||
# 本地项目说明文件,不入库
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
|
||||
# Dark Reader 仅跟踪构建所需文件,完整源码保留在本地
|
||||
third-party/darkreader/*
|
||||
!third-party/darkreader/package.json
|
||||
!third-party/darkreader/darkreader.js
|
||||
!third-party/darkreader/darkreader.mjs
|
||||
!third-party/darkreader/index.d.ts
|
||||
!third-party/darkreader/LICENSE
|
||||
!third-party/darkreader/SHA256SUMS
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目概述
|
||||
|
||||
Halo 暗色模式插件 — 为 Halo 2.25 后台管理面板提供深色/浅色模式切换。不修改 Halo 核心代码,完全通过插件机制实现。
|
||||
|
||||
**核心技术栈**:Java 21(后端插件骨架)、Vue 3 + TypeScript(前端 UI)、OKLCH 色彩空间(CSS 变量体系)、Gradle(构建)
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 后端 — 启用插件并启动 Halo 开发服务器
|
||||
./gradlew haloServer
|
||||
|
||||
# 后端 — 构建插件 JAR(产物在 build/libs/)
|
||||
./gradlew build
|
||||
|
||||
# 前端 — 进入 ui/ 开发
|
||||
cd ui && pnpm install
|
||||
|
||||
# 前端 — 开发模式(watch 构建)
|
||||
pnpm dev
|
||||
|
||||
# 前端 — 生产构建
|
||||
pnpm build
|
||||
|
||||
# 前端 — 类型检查
|
||||
pnpm type-check
|
||||
|
||||
# 前端 — Lint(oxlint + eslint)
|
||||
pnpm lint
|
||||
|
||||
# 前端 — 格式化
|
||||
pnpm prettier
|
||||
|
||||
# 前端 — 单元测试
|
||||
pnpm test:unit
|
||||
|
||||
# 后端 — 仅运行 Java 测试
|
||||
./gradlew test
|
||||
```
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
halo-dark-mode-plugin/
|
||||
├── 后端 (Java/Gradle) ← 插件骨架,极简
|
||||
│ ├── DarkModePlugin.java ← 继承 BasePlugin,仅 start()/stop()
|
||||
│ └── plugin.yaml ← 插件清单(声明式元数据 + 版本约束)
|
||||
│
|
||||
├── 前端 (Vue 3 / TypeScript) ← 核心实现
|
||||
│ ├── index.ts ← definePlugin() 入口,注册路由+组件
|
||||
│ ├── composables/
|
||||
│ │ ├── useDarkMode.ts ← 单例状态管理(light/dark/auto + localStorage 持久化)
|
||||
│ │ └── useSystemPreference.ts ← matchMedia 系统偏好监听
|
||||
│ ├── components/
|
||||
│ │ └── ThemeToggle.vue ← 侧边栏切换按钮
|
||||
│ ├── views/
|
||||
│ │ └── SettingsView.vue ← 设置页面(浅色/深色/跟随系统 三选一)
|
||||
│ └── styles/
|
||||
│ ├── variables.css ← 40+ CSS 变量(:root 浅色 + [data-halo-theme="dark"] 深色)
|
||||
│ └── overrides/ ← 按区域分层覆盖(layout/components/forms/editor/scrollbar/utilities)
|
||||
│
|
||||
└── 构建链
|
||||
└── processUiResources ← Gradle task: ui/dist/ → resources/main/ui/
|
||||
```
|
||||
|
||||
## 关键设计决策
|
||||
|
||||
### 主题切换机制
|
||||
|
||||
- **触发方式**:`document.documentElement` 上设置/移除 `data-halo-theme="dark"` 属性
|
||||
- **CSS 变量体系**:所有颜色通过 `--halo-*` 前缀的 CSS 自定义属性控制,一个语义变量对应一个视觉属性
|
||||
- **颜色空间**:全部使用 OKLCH(感知均匀,暗色模式天然适配,Chrome 111+/Firefox 113+/Safari 15.4+)
|
||||
- **暗色配色策略**:用亮度层次区分背景(越"高"的层越亮),中性色含微量蓝色调,强调色略降饱和
|
||||
|
||||
### 状态管理
|
||||
|
||||
- `useDarkMode()` 是**模块级单例** — 所有组件共享同一份 `theme` ref 和 `isDark` computed
|
||||
- 三个主题模式:`light` / `dark` / `auto`(跟随系统)
|
||||
- 持久化:`localStorage` key `halo-dark-mode-theme`,默认 `auto`
|
||||
- FOUC 防护:插件 bundle 顶部同步执行脚本,DOM 渲染前设置 `data-halo-theme`
|
||||
|
||||
### 前端入口
|
||||
|
||||
- `definePlugin()` 从 `@halo-dev/ui-shared` 导入(非 `@halo-dev/console-shared`,那是旧版 plugin-starter 的源)
|
||||
- 路由使用 `() => import(...)` 懒加载
|
||||
- 构建使用 `@halo-dev/ui-plugin-bundler-kit` 的 `viteConfig()` 包装器
|
||||
|
||||
### 覆盖策略
|
||||
|
||||
三级渐进覆盖:
|
||||
1. **CSS 变量注入**(~80% 场景)— 通过 `--halo-*` 变量重定义 Tailwind 颜色语义
|
||||
2. **选择器覆盖**(~17% 场景)— `[data-halo-theme="dark"]` 前缀高特异性选择器
|
||||
3. **组件穿透**(~3% 场景)— FormKit/编辑器等第三方组件的自有 CSS 变量接口
|
||||
|
||||
### 后端
|
||||
|
||||
后端极简 — `DarkModePlugin extends BasePlugin` 仅含 `start()`/`stop()` 生命周期钩子。所有核心逻辑在前端。插件不依赖后端 Setting API。
|
||||
|
||||
## 项目文档
|
||||
|
||||
- `设计文档.md` — 完整的技术设计(架构图、CSS 变量清单、调色板、组件覆盖策略、实现阶段划分、测试策略)
|
||||
- `调查文档.md` — 技术调查(create-halo-plugin vs plugin-starter 差异、dev-skills、Halo 插件机制)
|
||||
- `README.md` — 用户向 README
|
||||
@@ -1,63 +1,97 @@
|
||||
# dark-mode
|
||||
# Halo 深色模式
|
||||
|
||||
Halo 2.25 暗色模式插件 — 为 Halo 后台管理面板提供深色/浅色模式切换,支持跟随系统、手动切换和偏好记忆。
|
||||
为 Halo 后台管理面板提供深色/浅色模式切换,内置 Dark Reader 通用暗色引擎,支持手动切换、跟随系统与偏好记忆。
|
||||
|
||||
## 功能
|
||||
## 功能特性
|
||||
|
||||
- ☀️/🌙 **三种模式**:浅色、深色、跟随系统
|
||||
- 💾 **偏好持久化**:自动记忆用户选择(localStorage),刷新不丢失
|
||||
- 🖥️ **系统偏好跟随**:切换系统外观时自动响应
|
||||
- ⚡ **瞬间切换**:CSS 变量瞬时生效,无可见闪烁
|
||||
- 🧩 **侧边栏注入**:切换按钮自动出现在侧边栏底部(UserProfileBanner 上方)
|
||||
- ⚙️ **设置页面**:提供详细的模式选择界面(菜单 → 偏好设置 → 深色模式)
|
||||
- 🎨 **OKLCH 色彩空间**:感知均匀,暗色模式天然适配,WCAG AA 对比度保证
|
||||
- 📦 **零后端依赖**:纯前端实现,不需要后端 API
|
||||
- **三种模式**:浅色、深色、跟随系统
|
||||
- **偏好持久化**:自动记忆用户选择(localStorage),刷新不丢失
|
||||
- **系统偏好跟随**:切换系统外观时自动响应
|
||||
- **Dark Reader 引擎**:自动分析页面 CSS 与 DOM,覆盖 Halo 核心页面和第三方插件页面
|
||||
- **侧边栏一键切换**:切换按钮自动出现在侧边栏 UserProfileBanner 上方
|
||||
- **设置页面**:在「偏好设置 → 深色模式」中详细选择显示模式
|
||||
- **零后端依赖**:纯前端实现,不需要额外后端 API
|
||||
|
||||
## 安装
|
||||
|
||||
1. 从 Releases 下载 `plugin-dark-mode-<version>.jar`。
|
||||
2. 在 Halo 后台的「插件管理」中上传并安装。
|
||||
3. 启用插件后,侧边栏底部会出现深色模式切换按钮。
|
||||
|
||||
## 使用
|
||||
|
||||
安装并启用后,可以直接点击侧边栏按钮在深色和浅色模式之间切换,也可以进入设置页面选择固定模式:
|
||||
|
||||
| 配置项 | 可选值 | 说明 |
|
||||
| -------- | --------- | -------------------- |
|
||||
| 主题模式 | `light` | 始终使用浅色模式 |
|
||||
| 主题模式 | `dark` | 始终使用深色模式 |
|
||||
| 主题模式 | `auto` | 跟随系统外观自动切换 |
|
||||
|
||||
## 技术原理
|
||||
|
||||
- 插件通过 `useDarkMode()` 管理 `light` / `dark` / `auto` 三种状态。
|
||||
- 深色模式下调用 Dark Reader 的 `enable()`,浅色模式下调用 `disable()`。
|
||||
- Dark Reader 会持续监听页面 DOM 变化,因此第三方插件动态渲染的内容也能自动转换。
|
||||
- 插件自身只保留切换器与设置页所需的最小 UI 变量,不再维护逐页手工 CSS 覆盖。
|
||||
|
||||
> 说明:Dark Reader 的样式注入是异步的,刷新瞬间仍可能存在极短闪白;插件通过同步设置 `color-scheme` 缓解,但无法完全消除。
|
||||
|
||||
## 第三方依赖
|
||||
|
||||
- [Dark Reader](https://github.com/darkreader/darkreader) `4.9.129`,[MIT License](https://github.com/darkreader/darkreader/blob/main/LICENSE)。
|
||||
- 构建所需文件位于 `third-party/darkreader/`,由 `ui/package.json` 通过本地文件依赖引用。
|
||||
- 升级或替换 Dark Reader 构建文件后,请校验 `third-party/darkreader/SHA256SUMS`:
|
||||
Linux / macOS 使用 `sha256sum -c SHA256SUMS`,Windows 使用 `Get-FileHash -Algorithm SHA256` 对比。
|
||||
|
||||
## 开发环境
|
||||
|
||||
- Java 21+
|
||||
- Halo `>=2.25.0`
|
||||
- Java 21+(项目使用 `--release 21` 编译)
|
||||
- Node.js 18+
|
||||
- pnpm
|
||||
- Docker(`haloServer` 开发服务器需要)
|
||||
|
||||
## 快速开始
|
||||
## 从源码构建
|
||||
|
||||
仓库使用标准 Gradle 结构,并提交了 Gradle Wrapper。
|
||||
|
||||
Linux / macOS:
|
||||
|
||||
```bash
|
||||
# 启用插件并启动 Halo 开发服务器
|
||||
./gradlew haloServer
|
||||
./gradlew clean build
|
||||
```
|
||||
|
||||
# 前端开发(watch 模式)
|
||||
Windows:
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat clean build
|
||||
```
|
||||
|
||||
构建产物位于 `build/libs/plugin-dark-mode-<version>.jar`。
|
||||
|
||||
## 前端开发
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## 构建
|
||||
常用检查命令:
|
||||
|
||||
```bash
|
||||
# 完整构建(后端 + 前端)
|
||||
./gradlew build
|
||||
```
|
||||
|
||||
构建完成后,插件 JAR 文件位于 `build/libs/`,可直接在 Halo 后台安装。
|
||||
|
||||
## 前端命令
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
|
||||
pnpm dev # 开发构建(watch)
|
||||
pnpm build # 生产构建
|
||||
pnpm type-check # TypeScript 类型检查
|
||||
pnpm lint # Lint(oxlint + eslint)
|
||||
pnpm prettier # 代码格式化
|
||||
pnpm test:unit # 单元测试
|
||||
pnpm type-check # TypeScript 类型检查
|
||||
pnpm lint # Lint(oxlint + eslint)
|
||||
pnpm build # 生产构建
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── build.gradle # 根构建(BOM 2.25.0, DevTools 0.8.0)
|
||||
```text
|
||||
├── build.gradle # 根构建配置
|
||||
├── settings.gradle # 包含 :ui 子项目
|
||||
├── gradle/wrapper/ # Gradle Wrapper
|
||||
├── src/
|
||||
│ └── main/
|
||||
│ ├── java/run/halo/darkmode/
|
||||
@@ -65,12 +99,18 @@ pnpm test:unit # 单元测试
|
||||
│ └── resources/
|
||||
│ └── plugin.yaml # 插件清单
|
||||
│
|
||||
├── third-party/
|
||||
│ └── darkreader/ # Dark Reader 构建产物(MIT)
|
||||
│
|
||||
└── ui/
|
||||
├── package.json # 前端依赖
|
||||
├── vite.config.ts # Vite 配置
|
||||
└── src/
|
||||
├── index.ts # definePlugin 入口
|
||||
├── injector.ts # ThemeToggle 侧边栏注入器
|
||||
├── darkreader-engine.ts # Dark Reader 通用暗色引擎
|
||||
├── injector.ts # 侧边栏切换器注入
|
||||
├── composables/
|
||||
│ ├── useDarkMode.ts # 核心状态管理(模块级单例)
|
||||
│ ├── useDarkMode.ts # 主题状态管理(模块级单例)
|
||||
│ └── useSystemPreference.ts # 系统偏好监听
|
||||
├── components/
|
||||
│ └── ThemeToggle.vue # 侧边栏切换按钮
|
||||
@@ -78,10 +118,40 @@ pnpm test:unit # 单元测试
|
||||
│ └── SettingsView.vue # 设置页面
|
||||
└── styles/
|
||||
├── index.css # 样式入口
|
||||
├── variables.css # 40+ CSS 变量(浅色 + 深色)
|
||||
└── overrides/ # 组件覆盖样式
|
||||
└── variables.css # 插件自身 UI 变量
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
./gradlew test
|
||||
```
|
||||
|
||||
运行时验证脚本位于 `scripts/verify-toggle.py`,用于检查主题切换、localStorage 持久化以及 Dark Reader 注入状态。
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.5
|
||||
|
||||
- 同步设置 `color-scheme`,缓解深色模式刷新闪烁
|
||||
- 增加多标签页主题同步与 `useDarkMode` 单元测试
|
||||
- 侧边栏切换按钮与设置选项支持键盘操作
|
||||
- 后端日志改用 Lombok `@Slf4j`
|
||||
- 裁剪 vendored `package.json`,新增 Dark Reader `SHA256SUMS` 完整性校验
|
||||
- `plugin.yaml` 改为安装后由用户手动启用
|
||||
|
||||
### v1.0.4
|
||||
|
||||
- 改为纯 Dark Reader 引擎,移除手工 CSS 覆盖
|
||||
- 移除 Monaco 手动主题同步,暗色转换统一交给 Dark Reader
|
||||
- 整理 `third-party/darkreader` 目录,仅保留构建所需文件
|
||||
|
||||
### v1.0.3
|
||||
|
||||
- 引入 Dark Reader 通用暗色引擎
|
||||
- 保留原有手工 CSS 作为兼容层,覆盖核心页面与第三方插件页面
|
||||
- 增加 Monaco 日志查看器主题同步
|
||||
|
||||
## 许可证
|
||||
|
||||
[GPL-3.0](./LICENSE) © LHY
|
||||
|
||||
@@ -23,12 +23,6 @@ test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = "UTF-8"
|
||||
options.release = 21
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# 修复计划 — halo-dark-mode-plugin(2026-08-08)
|
||||
|
||||
> 依据:`docs/review-2026-08-08.md`
|
||||
> 状态:待用户审批
|
||||
> 审批通过后按 P2 → P3 顺序执行
|
||||
|
||||
## 一、修复总览
|
||||
|
||||
| ID | 优先级 | 问题 | 修复方式 | 主要涉及文件 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| P2-1 | P2 | 深色用户刷新 console 会浅色闪烁 | 同步设置 `color-scheme`,在 `enable()` 前减少闪烁 | `ui/src/composables/useDarkMode.ts`、`ui/src/darkreader-engine.ts` |
|
||||
| P2-2 | P2 | 本地项目文档仍描述旧的手工 CSS / Monaco 架构 | 同步 AGENTS.md、CLAUDE.md、scripts/README.md 到纯 Dark Reader 现状 | `AGENTS.md`、`CLAUDE.md`、`scripts/README.md` |
|
||||
| P2-3 | P2 | `scripts/` 与 `workplace/` 脚本双份 | 删除重复脚本;专用分析脚本保留在 `workplace/` 或迁移,待确认 | `workplace/login_wait.py`、`workplace/scan_dark.py` 等 |
|
||||
| P2-4 | P2 | 前端无单元测试 | 为 `useDarkMode` 补测试,覆盖 light/dark/auto、持久化与非法回退 | `ui/src/composables/__tests__/useDarkMode.spec.ts` |
|
||||
| P2-5 | P2 | 多标签页主题不同步 | 监听 `storage` 事件同步主题 | `ui/src/composables/useDarkMode.ts` |
|
||||
| P2-6 | P2 | 切换器/设置选项不可键盘操作 | 改为原生 `button` + ARIA,或原生 radio 语义 | `ui/src/components/ThemeToggle.vue`、`ui/src/views/SettingsView.vue` |
|
||||
| P3-1 | P3 | 后端使用 `System.out.println` | 使用 Lombok `@Slf4j` 输出日志 | `src/main/java/run/halo/darkmode/DarkModePlugin.java` |
|
||||
| P3-2 | P3 | vendored package.json 保留完整 devDependencies | 裁剪为最小字段,只保留构建所需元数据 | `third-party/darkreader/package.json` |
|
||||
| P3-3 | P3 | 无 Dark Reader 升级/完整性机制 | 增加 `SHA256SUMS` 并写入 README 校验说明 | `third-party/darkreader/SHA256SUMS`、`README.md`、`.gitignore` |
|
||||
| P3-4 | P3 | `data-halo-theme` 属性为历史遗留 | 保留属性,但在代码注释中说明兼容性遗留 | `ui/src/composables/useDarkMode.ts`、`ui/src/styles/variables.css` |
|
||||
| P3-6 | P3 | `plugin.yaml` 的 `spec.enabled: true` | 建议改为 `false`,安装后由用户启用 | `src/main/resources/plugin.yaml` |
|
||||
|
||||
## 二、详细修复说明
|
||||
|
||||
### P2-1 FOUC 闪烁
|
||||
|
||||
- 在 `useDarkMode.ts` 的 `applyHtmlAttribute()` 中同步设置:
|
||||
`document.documentElement.style.colorScheme = isDark ? 'dark' : 'light'`
|
||||
- `darkreader-engine.ts` 保持现有 `enable()/disable()` 逻辑。
|
||||
- `color-scheme` 只能缓解浏览器控件/滚动条闪烁,无法完全消除 DR 异步注入间隙;
|
||||
该限制会写进注释和 README。
|
||||
|
||||
### P2-2 本地项目文档
|
||||
|
||||
- `AGENTS.md` / `CLAUDE.md`:
|
||||
- 删除 `overrides/`、`halo-core.css`、Monaco 同步、FOUC 同步脚本等旧描述
|
||||
- 统一为“纯 Dark Reader 策略”
|
||||
- `scripts/README.md`:
|
||||
- 把“Monaco 日志查看器主题同步”改为检查 `data-darkreader-mode`
|
||||
- 补充 `verify-toggle.py` 的新三向断言说明
|
||||
|
||||
### P2-3 脚本收口
|
||||
|
||||
- 明确 `scripts/` 为正式工具目录。
|
||||
- 计划删除的重复文件(需审批):
|
||||
- `workplace/login_wait.py`(重复 `scripts/login-wait.py`)
|
||||
- `workplace/scan_dark.py`(重复 `scripts/scan-dark.py`)
|
||||
- `workplace/fetch_bundle.py`、`probe_conflict.py`、`aggregate.py` 属于专用分析工具,
|
||||
默认保留在 `workplace/`;如需一并迁移到 `scripts/`,请在审批时注明。
|
||||
|
||||
### P2-4 单元测试
|
||||
|
||||
新增 `ui/src/composables/__tests__/useDarkMode.spec.ts`,覆盖:
|
||||
|
||||
1. 默认 `auto` + 系统深色 → `isDark = true`
|
||||
2. `auto` 下点击切换 → 变成显式深色/浅色
|
||||
3. `dark ↔ light` 往返切换
|
||||
4. `setTheme()` 持久化到 localStorage
|
||||
5. localStorage 非法值回退到 `auto`
|
||||
6. `storage` 事件跨标签页同步
|
||||
|
||||
同时移除 `pnpm test:unit` 的 `--passWithNoTests`,防止测试静默通过。
|
||||
|
||||
### P2-5 多标签页同步
|
||||
|
||||
- `useDarkMode.ts` 增加:
|
||||
`window.addEventListener('storage', handler)`
|
||||
- 仅当 `newValue` 是合法 `light/dark/auto` 时更新 `theme`。
|
||||
|
||||
### P2-6 可访问性
|
||||
|
||||
- `ThemeToggle.vue`:
|
||||
- `div` 改为 `<button type="button">`
|
||||
- 增加 `:aria-pressed="isDark"`
|
||||
- 保留现有样式并补按钮 reset
|
||||
- `SettingsView.vue`:
|
||||
- 选项容器加 `role="radiogroup"`
|
||||
- 每个选项改为 `<button type="button" role="radio" :aria-checked="...">`
|
||||
- 键盘 Tab / Enter / Space 原生可用
|
||||
|
||||
### P3-1 后端日志
|
||||
|
||||
- `DarkModePlugin.java` 增加 `@Slf4j`
|
||||
- `System.out.println` 改为 `log.info(...)`
|
||||
- 保留 start/stop 语义不变
|
||||
|
||||
### P3-2 vendored package.json
|
||||
|
||||
- 裁剪为:
|
||||
`name / version / description / main / module / types / license`
|
||||
- 删除 scripts、devDependencies、optionalDependencies 等构建无关字段
|
||||
- 更新后执行 `pnpm install` 同步 lockfile
|
||||
|
||||
### P3-3 完整性校验
|
||||
|
||||
- 新增 `third-party/darkreader/SHA256SUMS`,记录:
|
||||
`darkreader.js`、`darkreader.mjs`、`package.json`、`index.d.ts`、`LICENSE`
|
||||
- `.gitignore` 白名单增加 `SHA256SUMS`
|
||||
- README 增加“升级 Dark Reader 后校验 SHA256SUMS”说明
|
||||
|
||||
### P3-4 历史遗留属性
|
||||
|
||||
- 保留 `data-halo-theme`(verify-toggle.py 仍使用)
|
||||
- 在 `useDarkMode.ts` 与 `variables.css` 增加注释:
|
||||
该属性当前无 CSS 消费方,仅为兼容性遗留标记
|
||||
|
||||
### P3-6 plugin.yaml
|
||||
|
||||
- 建议将 `spec.enabled` 改为 `false`
|
||||
- 理由:官方 manifest 文档建议生产环境由用户安装后手动启用
|
||||
|
||||
## 三、暂缓项(本计划不做)
|
||||
|
||||
- P3-5 i18n:目标用户为中文,暂不引入国际化
|
||||
- Dark Reader 完整源码保留在本地但不入库:维持现状
|
||||
- 不新增界面截图:README 已可用,后续上架前再补
|
||||
|
||||
## 四、回归验证
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
pnpm install
|
||||
pnpm type-check
|
||||
pnpm lint
|
||||
pnpm test:unit
|
||||
pnpm build
|
||||
|
||||
cd ..
|
||||
./gradlew test
|
||||
./gradlew build
|
||||
```
|
||||
|
||||
运行时验证:
|
||||
|
||||
```bash
|
||||
D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\verify-toggle.py
|
||||
```
|
||||
|
||||
验收标准:
|
||||
|
||||
- 单测至少覆盖 P2-4 中列出的 6 个场景且全部通过
|
||||
- `pnpm test:unit` 不再 `--passWithNoTests`
|
||||
- 深色刷新时 `color-scheme` 已同步
|
||||
- 键盘可操作侧边栏按钮与设置选项
|
||||
- `SHA256SUMS` 可校验,README 有说明
|
||||
- 后端日志不再输出到 stdout
|
||||
|
||||
## 五、版本与提交
|
||||
|
||||
- 修复内容涉及运行时代码,后续打 JAR 时按项目约定递增版本(1.0.4 → 1.0.5)
|
||||
- 本次审批通过后,先提交代码修复,再按需打包
|
||||
@@ -0,0 +1,81 @@
|
||||
# 代码审查交接单 — halo-dark-mode-plugin(2026-08-08)
|
||||
|
||||
> 由审查窗口产出,供开发窗口执行。结论:**可合入,无 P1 阻断问题**。
|
||||
> 审查基线:`HEAD = 363f30d`(工作区干净,已与 origin/main 同步)。
|
||||
> 审查范围:Dark Reader 迁移相关 4 个提交(202ab53 / 0bb552b / 88a071f / 363f30d)。
|
||||
|
||||
## 一、开发窗口无需重复验证(审查窗口已实测)
|
||||
|
||||
- `vite build` 通过:main.js 109.96KB(gzip 37.79KB)、style.css 3.76KB,Dark Reader 已打入 bundle,`enable/disable` 具名导出解析正常
|
||||
- `vue-tsc --build` 通过(exit 0)
|
||||
- `pnpm-lock.yaml` 与 `file:../third-party/darkreader` 依赖一致(含 malevic 0.20.2)
|
||||
- 构建产物 style.css 已无 `[data-halo-theme=dark]` 规则(属性属历史遗留)
|
||||
- DR 产物头 `v4.9.129` 与 README 一致
|
||||
|
||||
## 二、待办清单(建议按 P2 → P3 顺序执行)
|
||||
|
||||
### P2-1 FOUC:深色用户刷新 console 会浅色闪烁
|
||||
- 位置:`ui/src/darkreader-engine.ts`(watch immediate 块,33-46 行)
|
||||
- 原因:bundle 在 console 渲染后才执行,DR `enable()` 是异步分析+注入,无同步兜底
|
||||
- 建议:`enable()` 前同步设 `document.documentElement.style.colorScheme = 'dark'`;或作为已知限制写进文档
|
||||
|
||||
### P2-2 本地项目文档滞后(与"纯 DR 策略"不符)
|
||||
- `AGENTS.md` / `CLAUDE.md`:仍描述 `halo-core.css`、`overrides/`、Monaco 同步、FOUC 同步脚本——这些已不存在(`ui/src/styles/` 只剩 `index.css` + `variables.css`)
|
||||
- `scripts/README.md` 45-52 行:仍写"Monaco 日志查看器主题同步",实际已改为检查 `data-darkreader-mode`
|
||||
- 建议:同步到纯 DR 现状
|
||||
|
||||
### P2-3 验证工具双份、命名不一致
|
||||
- `scripts/`:login-wait.py、scan-dark.py、verify-toggle.py
|
||||
- `workplace/`:login_wait.py、scan_dark.py、fetch_bundle.py、probe_conflict.py、aggregate.py
|
||||
- 建议:统一收口到 `scripts/`,旧文件删除需用户书面确认
|
||||
|
||||
### P2-4 前端零单元测试
|
||||
- `pnpm test:unit` 为 `vitest --passWithNoTests`,恒绿
|
||||
- 建议:为 `ui/src/composables/useDarkMode.ts` 补 3-4 个用例,覆盖 `toggle()` 的 light/dark/auto 三种迁移与持久化回退
|
||||
|
||||
### P2-5 多标签页主题不同步
|
||||
- 位置:`ui/src/composables/useDarkMode.ts`
|
||||
- 建议:监听 `storage` 事件同步主题
|
||||
|
||||
### P2-6 可访问性
|
||||
- `ui/src/components/ThemeToggle.vue` 10-15 行:div+@click,无 role/tabindex/键盘/aria-pressed
|
||||
- `ui/src/views/SettingsView.vue` 36-41 行:选项 div+@click,同上
|
||||
- 建议:补键盘与 ARIA(或改原生 radio 语义)
|
||||
|
||||
### P3-1 后端日志
|
||||
- 位置:`src/main/java/run/halo/darkmode/DarkModePlugin.java` 24、29 行 `System.out.println`
|
||||
- 建议:build.gradle 已有 Lombok,改 `@Slf4j` + `log.info`
|
||||
|
||||
### P3-2 vendored package.json 保留完整 devDependencies
|
||||
- 位置:`third-party/darkreader/package.json`
|
||||
- 建议:裁剪为 name/version/main/module/types/license 最小字段(`file:` 依赖不会安装 devDeps,但保持干净)
|
||||
|
||||
### P3-3 无 DR 升级/完整性机制
|
||||
- 建议:README 或脚本记录 DR 产物 SHA-256,固定版本
|
||||
|
||||
### P3-4 data-halo-theme 属性 + variables.css 已成历史遗留
|
||||
- 构建产物已无 `[data-halo-theme=dark]` 规则,属性无 CSS 消费方
|
||||
- 建议:保留可以,但注释说明是兼容性遗留
|
||||
|
||||
### P3-5 文案硬编码中文,无 i18n
|
||||
- 低优先级(目标用户中文),可暂缓
|
||||
|
||||
### P3-6 plugin.yaml `spec.enabled: true`
|
||||
- 确认是否有意(Halo 通常安装后由用户启用)
|
||||
|
||||
## 三、完成后回归验证清单
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
pnpm build # 构建通过
|
||||
pnpm type-check # 类型检查通过
|
||||
pnpm lint # 通过
|
||||
# 真实环境验证(AGENTS.md 规定流程)
|
||||
python scripts\verify-toggle.py # 三向断言全 PASS(属性/存储/Dark Reader 翻转)
|
||||
```
|
||||
|
||||
## 四、无需改动的亮点(避免开发窗口误改)
|
||||
|
||||
- third-party/darkreader 只跟踪 5 个构建必需文件 + gitignore 白名单,LICENSE/版权头保留,合规 ✅
|
||||
- style.css 从 ~500KB 降到 3.76KB,纯 DR 策略成立 ✅
|
||||
- verify-toggle.py 的 darkreader 断言与策略一致 ✅
|
||||
@@ -0,0 +1,157 @@
|
||||
# Halo 暗色模式残留排查报告
|
||||
|
||||
> 排查时间:2026-08-07
|
||||
> 排查环境:腾讯云生产站 `blog.liuhangyv.top`(Halo Pro 2.24.2)
|
||||
> 排查方式:Playwright 自动化全路由扫描,共 92 个后台路由
|
||||
|
||||
## 一、结论摘要
|
||||
|
||||
1. **部署版本没有问题**。服务端实际加载的 `bundle.css` 与本仓库 `ui/build/dist/style.css` 的 194 条暗色规则完全一致,不存在"部署了旧包"的情况。
|
||||
2. **73 个后台路由存在深浅色残留**,19 个路由完全干净(主要是 Shop 插件全家桶、快照页、权限页)。
|
||||
3. 残留可归纳为 **3 类根因**:
|
||||
- 插件 CSS 加载顺序在最前,同特异性规则被后加载的 Halo 核心样式覆盖;
|
||||
- UnoCSS 哈希类前缀已从 `i-*` 变为 `uno-*`,现有兜底规则匹配不到;
|
||||
- 第三方插件页面(AstraHub、RSS 订阅、私密文章、AI 评论等)基本没有覆盖。
|
||||
|
||||
## 二、扫描方法与产物
|
||||
|
||||
### 方法
|
||||
|
||||
1. Playwright 启动持久化 Edge 上下文,复用已登录会话;
|
||||
2. 从 Vue Router 读取全部注册路由(92 个),逐页加载;
|
||||
3. 每页强制 `data-halo-theme="dark"`,等待网络空闲后扫描;
|
||||
4. 扫描规则:可见且尺寸大于 50×20 的元素,背景 RGB 均 >235 记为浅色背景残留,直接文本颜色 RGB 均 <70 记为深色文字残留;
|
||||
5. 每页截图存档,结果按页面 + 元素分组去重。
|
||||
|
||||
### 产物(均在 `workplace/`)
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `scan_dark.py` | 全路由扫描脚本 |
|
||||
| `scan-results.json` | 原始扫描结果(92 页 × 元素级) |
|
||||
| `aggregate.txt` | 按元素/类名/问题聚合后的明细 |
|
||||
| `shots/*.png` | 92 页深色模式截图 |
|
||||
| `deployed-bundle.css` | 服务端实际部署的 CSS 快照 |
|
||||
| `probe_conflict.py` / `fetch_bundle.py` | 样式表加载顺序与版本对比脚本 |
|
||||
|
||||
## 三、总体数据
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 扫描路由数 | 92 |
|
||||
| 存在残留的路由 | 73 |
|
||||
| 完全干净的路由 | 19 |
|
||||
| 去重后的残留组 | 134 |
|
||||
| 页面 × 元素残留条目 | 256 |
|
||||
| 影响页面最多的残留 | 侧边栏 `.menu-item-title.active` 高亮背景(68 页) |
|
||||
| 次多 | `.empty-title` 深色文字(16 页) |
|
||||
|
||||
无残留的 19 个路由:`/console/403`、Shop 插件全部 11 个页面、`/console/app-store/privacy-policy`、`/console/posts/snapshots`、`/console/single-pages/snapshots`、`/console/users/auth-providers`、`/console/users/roles`。
|
||||
|
||||
## 四、根因分析
|
||||
|
||||
### 根因 1(P0):插件 CSS 加载顺序在最前,同特异性规则被 Halo 核心样式覆盖
|
||||
|
||||
**证据**(来自 `probe_conflict.py`):
|
||||
|
||||
- 插件 `bundle.css` 是 `document.styleSheets` 的第 0 张表;
|
||||
- Halo 核心样式 `process-bar-DsZ2lvXx.css` 是第 9 张表,加载更晚;
|
||||
- 双方存在同特异性规则,CSS 级联规则让"后加载者"胜出:
|
||||
|
||||
| 规则 | 特异性 | 结果 |
|
||||
|------|--------|------|
|
||||
| Halo:`.description-item-wrapper .description-item__label { color: rgb(17 24 39) }` | (0,2,0) | 胜出 |
|
||||
| 插件:`[data-halo-theme="dark"] .description-item__label { color: var(--halo-text-secondary) }` | (0,2,0) | 被覆盖 |
|
||||
|
||||
**受影响页面**:`/console/overview`、`/console/schedule-calendar`、`/console/theme` 的详情描述项(名称、Halo、站点地址等文字仍是深色)。
|
||||
|
||||
**修复建议**:
|
||||
|
||||
- 对这类核心语义类统一加 `!important`;
|
||||
- 或把前缀从 `[data-halo-theme="dark"]` 提升为 `html[data-halo-theme="dark"]`,把特异性从 (0,2,0) 提高到 (0,2,1)。
|
||||
|
||||
推荐两者并用(提高特异性 + `!important` 双保险),因为 Halo 核心样式后续仍可能继续调整。
|
||||
|
||||
### 根因 2(P0):UnoCSS 哈希类前缀已变化,现有兜底规则失效
|
||||
|
||||
**证据**:
|
||||
|
||||
- 大量残留元素的类名是 `uno-*` 前缀(如 `uno-ga61as`、`uno-529526`、`uno-b60d0h`);
|
||||
- `plugin-pages.css` 中的兜底规则只覆盖 `[class*="i-"]`,这是旧版 UnoCSS 的哈希前缀;
|
||||
- 部署 CSS 探针确认:`menu-item-title`、`empty-title`、`alert-wrapper` 三个关键字在暗色规则中完全不存在。
|
||||
|
||||
**受影响页面**:AI Foundation 全家桶(每页 3–24 处)、数据迁移、装备/关注/照片/时间线/投票的卡片头部"新增"栏、SummaraidGPT 等。
|
||||
|
||||
**修复建议**:
|
||||
|
||||
- 新增 `[class*="uno-"]` 的兜底覆盖(背景、文字、边框三件套),并加 `!important`;
|
||||
- 保留 `i-*` 规则,因为扫描中仍有少量 `i-*` 哈希(如 `.i-p4hnaq`)在真实页面出现,两个前缀并存。
|
||||
|
||||
### 根因 3(P0):核心语义类缺覆盖
|
||||
|
||||
以下类在部署 CSS 中**完全没有暗色规则**,属于新增覆盖而非覆盖失效:
|
||||
|
||||
| 类名 | 残留内容 | 影响页面 |
|
||||
|------|----------|----------|
|
||||
| `.menu-item-title.active` | 高亮背景 `rgb(243,244,246)` | 68 页 |
|
||||
| `.empty-title` | 文字 `rgb(17,24,39)` | 16 页 |
|
||||
| `.alert-wrapper` / `.alert-default` | 背景 `rgb(249,250,251)` | 3 页(概览、许可证、导入) |
|
||||
| `.description-item__label/content` | 文字 `rgb(17,24,39)` | 3 页(同特异性失效,见根因 1) |
|
||||
|
||||
**修复建议**:
|
||||
|
||||
- 侧边栏高亮:覆盖 `.menu-item-title.active`(含 `:hover`),用 `--halo-menu-item-active` 变量;
|
||||
- 空状态:覆盖 `.empty-title`,用 `--halo-text-secondary`;
|
||||
- 提示条:覆盖 `.alert-wrapper` 及各变体的背景/边框/文字。
|
||||
|
||||
### 根因 4(P1):第三方插件页面几乎未覆盖
|
||||
|
||||
| 插件/页面 | 残留类名 | 说明 |
|
||||
|-----------|----------|------|
|
||||
| AstraHub + 心愿便签 | `.ah-card`、`.ah-topbar`、`.ah-float-nav`、`.ah-topbar-brand` | 两插件共用同一套 `ah-*` UI,覆盖一次可同时生效 |
|
||||
| RSS 订阅 | `.subscription-panel`、`.feed-toolbar`、`.feed-status-tabs`、`.feed-stream`、`.feed-brief__title` | 整个页面几乎整片浅色 |
|
||||
| 私密文章 | `.focus-card`、`.overview-card`、`.list-card`、`.empty-state` | 半透明白背景叠在深色上,文字也是深色 |
|
||||
| AI 评论自动处理 | `.setting-panel`、`.form-row`、`.sidebar-card`、`.tabs-wrap`、`.list-col`、`.reply-card` | 设置页和日志页整片浅色 |
|
||||
| 数据工坊 | `.modal-content`、`.uno-*` 卡片 | 弹窗与主内容区均残留 |
|
||||
| 代码注入器 | `.uno-*` 侧栏/编辑区 | 整页浅色 |
|
||||
| Meilisearch 概览 | `.uno-*` | 卡片与标题残留 |
|
||||
| 日志查看器 | `.monaco-editor`、`.margin`、`.lines-content` | **Monaco Editor 自带独立主题系统**,仅靠 CSS 覆盖很难稳定 |
|
||||
| 导入/存储工具 | `.uno-*` | 提示卡片、分页栏残留 |
|
||||
|
||||
**修复建议**:
|
||||
|
||||
- `ah-*`、`feed-*`、`focus-card/overview-card/list-card`、`setting-panel/form-row/sidebar-card` 等语义类按插件页面补 `!important` 规则;
|
||||
- Monaco 不要硬啃 CSS,优先在 JS 里监听主题切换并调用 `monaco.editor.setTheme('vs-dark')`,或加载官方 dark 主题 CSS;
|
||||
- 每个插件补完后再跑一次 `scan_dark.py` 验证零残留。
|
||||
|
||||
### 根因 5(P2):历史遗留规则与自相矛盾的选择器
|
||||
|
||||
- `components.css` 中大量 `.v-*`(Vuetify 类)在 Halo 2.25 的真实 DOM 中不存在,属无效规则(CLAUDE.md 已记录);
|
||||
- `[class*="tag-"]`、`[class*="modal"]` 等宽泛匹配容易误伤,建议逐步收紧;
|
||||
- `utilities.css` 与 `halo-core.css` 对链接色的规则互相覆盖,最终生效结果不明确;
|
||||
- `halo-core.css` 的 `html, body` 编译后为 `[data-halo-theme="dark"] html`,该选择器**永远不匹配**(html 元素不会是其自身的后代),根背景兜底实际只靠 `body` 一条,页面在特殊滚动场景下仍有露白风险。
|
||||
|
||||
## 五、修复优先级清单
|
||||
|
||||
### P0(一次重构后全站收益最大)
|
||||
|
||||
1. 全局暗色规则前缀从 `[data-halo-theme="dark"]` 升级为 `html[data-halo-theme="dark"]`;
|
||||
2. 核心语义类(`.page-header`、`.card-wrapper`、`.description-item-*`、`.table` 等)加 `!important`;
|
||||
3. 新增 `.menu-item-title.active`、`.empty-title`、`.alert-wrapper` 覆盖;
|
||||
4. 新增 `[class*="uno-"]` 兜底三件套;
|
||||
5. 修正根背景规则,让 `html` 本身也参与暗色。
|
||||
|
||||
### P1(按插件逐个补齐并复扫)
|
||||
|
||||
6. `ah-*`、`feed-*`、私密文章、AI 评论插件语义类覆盖;
|
||||
7. Monaco 主题切换接入;
|
||||
8. 数据工坊、代码注入器、Meilisearch、导入/存储工具等 `uno-*` 页面复查。
|
||||
|
||||
### P2(清理)
|
||||
|
||||
9. 删除失效的 Vuetify 类规则,收敛宽泛匹配;
|
||||
10. 统一链接色规则,消除重复/冲突。
|
||||
|
||||
## 六、完整残留明细
|
||||
|
||||
见 `workplace/aggregate.txt`(134 组,含影响页面列表),原始数据见 `workplace/scan-results.json`。
|
||||
+3
-1
@@ -1 +1,3 @@
|
||||
version=1.0.0-SNAPSHOT
|
||||
version=1.0.5
|
||||
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||
org.gradle.java.home=D:/settings/Language/Java/jdk-25.0.1
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# 暗色模式回归扫描与运行时验证工具
|
||||
|
||||
用 Playwright 驱动 Edge 对 Halo 后台做全路由深浅色残留扫描与运行时切换验证。
|
||||
|
||||
## 依赖
|
||||
|
||||
- Python 3.13 环境(本机:`D:\settings\settings\uv\my_uv_env`)
|
||||
- Playwright(已安装):`pip install playwright` 或 `uv pip install playwright`
|
||||
- 本机 Edge(Playwright 通过 `channel="msedge"` 复用,无需下载浏览器内核)
|
||||
|
||||
## 首次使用:登录
|
||||
|
||||
```powershell
|
||||
D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\login-wait.py
|
||||
```
|
||||
|
||||
会弹出 Edge 窗口,登录 `https://blog.liuhangyv.top/console` 后脚本自动退出,
|
||||
会话保存在 `scripts/.browser-profile/`(已被 gitignore,不会入库)。
|
||||
|
||||
已有登录态想复用,可显式指定 profile:
|
||||
|
||||
```powershell
|
||||
D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\scan-dark.py --profile scripts\.browser-profile
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
```powershell
|
||||
# 全路由深色扫描(历史 CSS 工作流保留)
|
||||
D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\scan-dark.py --local-css ui\build\dist\style.css --assert-zero
|
||||
|
||||
# 只扫指定页面
|
||||
D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\scan-dark.py --pages /console/posts,/console/users
|
||||
|
||||
# 浅色模式回归抽查
|
||||
D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\scan-dark.py --mode light --pages /console/dashboard,/console/posts
|
||||
```
|
||||
|
||||
## 切换行为验证
|
||||
|
||||
```powershell
|
||||
D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\verify-toggle.py
|
||||
```
|
||||
|
||||
部署新版插件后运行,会检查:
|
||||
|
||||
- `data-halo-theme` 属性翻转
|
||||
- localStorage 持久化翻转
|
||||
- `data-darkreader-mode`(Dark Reader 注入状态)翻转
|
||||
|
||||
三项全部 PASS 时返回 0。
|
||||
|
||||
## 输出
|
||||
|
||||
- `scripts/output/scan-results.json`:按路由分组的结果
|
||||
- `scripts/output/shots/*.png`:每页截图
|
||||
- 均已被 gitignore,不入库。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 扫描会忽略 `img/canvas/video/svg/iframe`,避免把文章图片、编辑器画布等内容性
|
||||
元素误判为界面残留。
|
||||
- 路由从 Vue Router 动态发现,Halo 或插件升级新增页面后无需维护清单。
|
||||
- `scan-dark.py` 主要为历史手工 CSS 工作流保留;当前暗色转换由 Dark Reader 负责,
|
||||
运行时行为验证以 `verify-toggle.py` 为准。
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -0,0 +1,78 @@
|
||||
"""验证侧边栏主题切换:点击后检查 data-halo-theme、localStorage 与 Dark Reader 模式。"""
|
||||
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 read_state(page) -> dict:
|
||||
return page.evaluate(
|
||||
"""() => ({
|
||||
attr: document.documentElement.getAttribute('data-halo-theme'),
|
||||
darkreader: document.documentElement.getAttribute('data-darkreader-mode'),
|
||||
stored: localStorage.getItem('halo-dark-mode-theme'),
|
||||
})"""
|
||||
)
|
||||
|
||||
|
||||
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 = read_state(page)
|
||||
toggle.click()
|
||||
time.sleep(1)
|
||||
after = read_state(page)
|
||||
toggle.click()
|
||||
time.sleep(1)
|
||||
restored = read_state(page)
|
||||
|
||||
attr_flipped = before["attr"] != after["attr"] and before["attr"] == restored["attr"]
|
||||
stored_flipped = before["stored"] != after["stored"] and before["stored"] == restored["stored"]
|
||||
darkreader_flipped = (
|
||||
before["darkreader"] != after["darkreader"]
|
||||
and before["darkreader"] == restored["darkreader"]
|
||||
)
|
||||
print(f"初始: {before}")
|
||||
print(f"切换: {after}")
|
||||
print(f"还原: {restored}")
|
||||
print(
|
||||
f"属性翻转: {'PASS' if attr_flipped else 'FAIL'} "
|
||||
f"存储翻转: {'PASS' if stored_flipped else 'FAIL'} "
|
||||
f"Dark Reader 翻转: {'PASS' if darkreader_flipped else 'FAIL'}"
|
||||
)
|
||||
|
||||
ctx.close()
|
||||
return 0 if attr_flipped and stored_flipped and darkreader_flipped else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,5 +1,6 @@
|
||||
package run.halo.darkmode;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.plugin.BasePlugin;
|
||||
import run.halo.app.plugin.PluginContext;
|
||||
@@ -10,8 +11,9 @@ import run.halo.app.plugin.PluginContext;
|
||||
* <p>Only one main class extending {@link BasePlugin} is allowed per plugin.</p>
|
||||
*
|
||||
* @author LHY
|
||||
* @since 1.0.0
|
||||
* @since 1.0.5
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class DarkModePlugin extends BasePlugin {
|
||||
|
||||
@@ -21,11 +23,11 @@ public class DarkModePlugin extends BasePlugin {
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
System.out.println("插件启动成功!");
|
||||
log.info("插件启动成功!");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
System.out.println("插件停止!");
|
||||
log.info("插件停止!");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ metadata:
|
||||
# The name defines how the plugin is invoked, A unique name
|
||||
name: dark-mode
|
||||
spec:
|
||||
enabled: true
|
||||
enabled: false
|
||||
requires: ">=2.25.0"
|
||||
author:
|
||||
name: LHY
|
||||
|
||||
Vendored
+23
@@ -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.
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
0b126b96a0d76a1ff1b1c7cc7088c3ee1903dea2119abded2fb3913b340b63b1 darkreader.js
|
||||
fbacd711bfd26b33b160881b50028a3e22e8f083b62ec727baebc0c4ad14b052 darkreader.mjs
|
||||
5f7ebb5de2d74e011d87b86c2cc12048d667981599bdc9c527f98de9e0503a26 package.json
|
||||
ab1ba9694b242323d15ed4839457c14fe04891966568413d5cd5eb78156dae1f index.d.ts
|
||||
3f018657498dc4a805af22cc872688f1cd699bbe0045c82d29aeb0c8e65843e5 LICENSE
|
||||
Vendored
+9366
File diff suppressed because it is too large
Load Diff
+9048
File diff suppressed because it is too large
Load Diff
Vendored
+173
@@ -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;
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "darkreader",
|
||||
"version": "4.9.129",
|
||||
"description": "Dark mode for every website",
|
||||
"main": "darkreader.js",
|
||||
"module": "darkreader.mjs",
|
||||
"types": "index.d.ts",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -5,6 +5,11 @@ plugins {
|
||||
|
||||
group 'run.halo.darkmode.ui'
|
||||
|
||||
node {
|
||||
// 使用系统安装的 Node.js,避免 Gradle 下载的本地 Node 目录缺失时失败
|
||||
download = false
|
||||
}
|
||||
|
||||
tasks.register('pnpmBuild', PnpmTask) {
|
||||
group = 'build'
|
||||
description = 'Build the UI project using pnpm'
|
||||
|
||||
+4
-2
@@ -8,7 +8,7 @@
|
||||
"lint:eslint": "eslint . --fix",
|
||||
"lint": "run-s lint:*",
|
||||
"prettier": "prettier --write src/",
|
||||
"test:unit": "vitest --passWithNoTests",
|
||||
"test:unit": "vitest",
|
||||
"type-check": "vue-tsc --build"
|
||||
},
|
||||
"prettier": {
|
||||
@@ -23,10 +23,10 @@
|
||||
"@halo-dev/ui-shared": "^2.25.1",
|
||||
"axios": "^1.13.5",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"darkreader": "file:../third-party/darkreader",
|
||||
"vue": "^3.5.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^8.0.16",
|
||||
"@halo-dev/ui-plugin-bundler-kit": "^2.25.1",
|
||||
"@iconify-json/ri": "^1.2.10",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
@@ -41,6 +41,7 @@
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-plugin-oxlint": "^0.16.12",
|
||||
"eslint-plugin-vue": "~10.0.1",
|
||||
"jiti": "^2.7.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"npm-run-all2": "^7.0.2",
|
||||
"oxlint": "^0.16.12",
|
||||
@@ -48,6 +49,7 @@
|
||||
"sass": "^1.89.2",
|
||||
"typescript": "~5.8.3",
|
||||
"unplugin-icons": "^23.0.1",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.0",
|
||||
"vue-tsc": "^3.3.3"
|
||||
},
|
||||
|
||||
Generated
+98
-78
@@ -13,23 +13,26 @@ importers:
|
||||
version: 2.25.2(axios@1.19.0)
|
||||
'@halo-dev/components':
|
||||
specifier: ^2.25.1
|
||||
version: 2.25.2(vue-router@5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(vue@3.5.41(typescript@5.8.3))
|
||||
version: 2.25.2(vue-router@5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(vue@3.5.41(typescript@5.8.3))
|
||||
'@halo-dev/ui-shared':
|
||||
specifier: ^2.25.1
|
||||
version: 2.25.2(@tiptap/pm@3.29.2)(axios@1.19.0)(vue-router@5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(vue@3.5.41(typescript@5.8.3))
|
||||
version: 2.25.2(@tiptap/pm@3.29.2)(axios@1.19.0)(vue-router@5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(vue@3.5.41(typescript@5.8.3))
|
||||
axios:
|
||||
specifier: ^1.13.5
|
||||
version: 1.19.0
|
||||
canvas-confetti:
|
||||
specifier: ^1.9.3
|
||||
version: 1.9.4
|
||||
darkreader:
|
||||
specifier: file:../third-party/darkreader
|
||||
version: file:../third-party/darkreader
|
||||
vue:
|
||||
specifier: ^3.5.28
|
||||
version: 3.5.41(typescript@5.8.3)
|
||||
devDependencies:
|
||||
'@halo-dev/ui-plugin-bundler-kit':
|
||||
specifier: ^2.25.1
|
||||
version: 2.25.2(@rsbuild/core@2.1.10)(@rsbuild/plugin-vue@2.0.1(@rsbuild/core@2.1.10)(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(vue@3.5.41(typescript@5.8.3)))(@vitejs/plugin-vue@6.0.8(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(axios@1.19.0)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))
|
||||
version: 2.25.2(@rsbuild/core@2.1.10)(@rsbuild/plugin-vue@2.0.1(@rsbuild/core@2.1.10)(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(vue@3.5.41(typescript@5.8.3)))(@vitejs/plugin-vue@6.0.8(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(axios@1.19.0)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))
|
||||
'@iconify-json/ri':
|
||||
specifier: ^1.2.10
|
||||
version: 1.2.10
|
||||
@@ -47,13 +50,13 @@ importers:
|
||||
version: 24.13.3
|
||||
'@vitest/eslint-plugin':
|
||||
specifier: ^1.2.7
|
||||
version: 1.6.26(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.8.3))(eslint@9.39.5)(typescript@5.8.3))(eslint@9.39.5)(typescript@5.8.3)(vitest@4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)))
|
||||
version: 1.6.26(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)(vitest@4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)))
|
||||
'@vue/eslint-config-prettier':
|
||||
specifier: ^10.2.0
|
||||
version: 10.2.0(eslint@9.39.5)(prettier@3.9.6)
|
||||
version: 10.2.0(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.6)
|
||||
'@vue/eslint-config-typescript':
|
||||
specifier: ^14.5.1
|
||||
version: 14.9.0(eslint-plugin-vue@10.0.1(eslint@9.39.5)(vue-eslint-parser@10.4.1(eslint@9.39.5)))(eslint@9.39.5)(typescript@5.8.3)
|
||||
version: 14.9.0(eslint-plugin-vue@10.0.1(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0))))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
|
||||
'@vue/test-utils':
|
||||
specifier: ^2.4.6
|
||||
version: 2.4.11(@vue/compiler-dom@3.5.41)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@5.8.3))
|
||||
@@ -62,13 +65,16 @@ importers:
|
||||
version: 0.7.0(typescript@5.8.3)(vue@3.5.41(typescript@5.8.3))
|
||||
eslint:
|
||||
specifier: ^9.29.0
|
||||
version: 9.39.5
|
||||
version: 9.39.5(jiti@2.7.0)
|
||||
eslint-plugin-oxlint:
|
||||
specifier: ^0.16.12
|
||||
version: 0.16.12
|
||||
eslint-plugin-vue:
|
||||
specifier: ~10.0.1
|
||||
version: 10.0.1(eslint@9.39.5)(vue-eslint-parser@10.4.1(eslint@9.39.5))
|
||||
version: 10.0.1(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0)))
|
||||
jiti:
|
||||
specifier: ^2.7.0
|
||||
version: 2.7.0
|
||||
jsdom:
|
||||
specifier: ^26.1.0
|
||||
version: 26.1.0
|
||||
@@ -92,10 +98,10 @@ importers:
|
||||
version: 23.0.1(@vue/compiler-sfc@3.5.41)
|
||||
vite:
|
||||
specifier: ^8.0.16
|
||||
version: 8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)
|
||||
version: 8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)
|
||||
vitest:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))
|
||||
version: 4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))
|
||||
vue-tsc:
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.9(typescript@5.8.3)
|
||||
@@ -1079,6 +1085,9 @@ packages:
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
darkreader@file:../third-party/darkreader:
|
||||
resolution: {directory: ../third-party/darkreader, type: directory}
|
||||
|
||||
data-urls@5.0.0:
|
||||
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1454,6 +1463,10 @@ packages:
|
||||
jackspeak@3.4.3:
|
||||
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
||||
|
||||
jiti@2.7.0:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
js-beautify@1.15.4:
|
||||
resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -2427,9 +2440,9 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@eslint-community/eslint-utils@4.10.1(eslint@9.39.5)':
|
||||
'@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))':
|
||||
dependencies:
|
||||
eslint: 9.39.5
|
||||
eslint: 9.39.5(jiti@2.7.0)
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/regexpp@4.12.2': {}
|
||||
@@ -2488,33 +2501,33 @@ snapshots:
|
||||
axios: 1.19.0
|
||||
qs: 6.15.3
|
||||
|
||||
'@halo-dev/components@2.25.2(vue-router@5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(vue@3.5.41(typescript@5.8.3))':
|
||||
'@halo-dev/components@2.25.2(vue-router@5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(vue@3.5.41(typescript@5.8.3))':
|
||||
dependencies:
|
||||
floating-vue: 5.2.2(vue@3.5.41(typescript@5.8.3))
|
||||
vue: 3.5.41(typescript@5.8.3)
|
||||
vue-router: 5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3))
|
||||
vue-router: 5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3))
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
|
||||
'@halo-dev/ui-plugin-bundler-kit@2.25.2(@rsbuild/core@2.1.10)(@rsbuild/plugin-vue@2.0.1(@rsbuild/core@2.1.10)(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(vue@3.5.41(typescript@5.8.3)))(@vitejs/plugin-vue@6.0.8(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(axios@1.19.0)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))':
|
||||
'@halo-dev/ui-plugin-bundler-kit@2.25.2(@rsbuild/core@2.1.10)(@rsbuild/plugin-vue@2.0.1(@rsbuild/core@2.1.10)(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(vue@3.5.41(typescript@5.8.3)))(@vitejs/plugin-vue@6.0.8(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(axios@1.19.0)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@halo-dev/api-client': 2.25.2(axios@1.19.0)
|
||||
'@rsbuild/core': 2.1.10
|
||||
'@rsbuild/plugin-vue': 2.0.1(@rsbuild/core@2.1.10)(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(vue@3.5.41(typescript@5.8.3))
|
||||
'@vitejs/plugin-vue': 6.0.8(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3))
|
||||
'@vitejs/plugin-vue': 6.0.8(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3))
|
||||
js-yaml: 4.3.1
|
||||
semver: 7.8.5
|
||||
vite: 8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)
|
||||
vite: 8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)
|
||||
transitivePeerDependencies:
|
||||
- axios
|
||||
|
||||
'@halo-dev/ui-shared@2.25.2(@tiptap/pm@3.29.2)(axios@1.19.0)(vue-router@5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(vue@3.5.41(typescript@5.8.3))':
|
||||
'@halo-dev/ui-shared@2.25.2(@tiptap/pm@3.29.2)(axios@1.19.0)(vue-router@5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)))(vue@3.5.41(typescript@5.8.3))':
|
||||
dependencies:
|
||||
'@halo-dev/api-client': 2.25.2(axios@1.19.0)
|
||||
'@tiptap/core': 3.29.2(@tiptap/pm@3.29.2)
|
||||
mitt: 3.0.1
|
||||
vue: 3.5.41(typescript@5.8.3)
|
||||
vue-router: 5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3))
|
||||
vue-router: 5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3))
|
||||
transitivePeerDependencies:
|
||||
- '@tiptap/pm'
|
||||
- axios
|
||||
@@ -2870,15 +2883,15 @@ snapshots:
|
||||
|
||||
'@types/tough-cookie@4.0.5': {}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.8.3))(eslint@9.39.5)(typescript@5.8.3)':
|
||||
'@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.66.0(eslint@9.39.5)(typescript@5.8.3)
|
||||
'@typescript-eslint/parser': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
|
||||
'@typescript-eslint/scope-manager': 8.66.0
|
||||
'@typescript-eslint/type-utils': 8.66.0(eslint@9.39.5)(typescript@5.8.3)
|
||||
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5)(typescript@5.8.3)
|
||||
'@typescript-eslint/type-utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
|
||||
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
|
||||
'@typescript-eslint/visitor-keys': 8.66.0
|
||||
eslint: 9.39.5
|
||||
eslint: 9.39.5(jiti@2.7.0)
|
||||
ignore: 7.0.6
|
||||
natural-compare: 1.4.0
|
||||
ts-api-utils: 2.5.0(typescript@5.8.3)
|
||||
@@ -2886,14 +2899,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.8.3)':
|
||||
'@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.66.0
|
||||
'@typescript-eslint/types': 8.66.0
|
||||
'@typescript-eslint/typescript-estree': 8.66.0(typescript@5.8.3)
|
||||
'@typescript-eslint/visitor-keys': 8.66.0
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.5
|
||||
eslint: 9.39.5(jiti@2.7.0)
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -2916,13 +2929,13 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.8.3
|
||||
|
||||
'@typescript-eslint/type-utils@8.66.0(eslint@9.39.5)(typescript@5.8.3)':
|
||||
'@typescript-eslint/type-utils@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.66.0
|
||||
'@typescript-eslint/typescript-estree': 8.66.0(typescript@5.8.3)
|
||||
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5)(typescript@5.8.3)
|
||||
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.5
|
||||
eslint: 9.39.5(jiti@2.7.0)
|
||||
ts-api-utils: 2.5.0(typescript@5.8.3)
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
@@ -2945,13 +2958,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.66.0(eslint@9.39.5)(typescript@5.8.3)':
|
||||
'@typescript-eslint/utils@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5)
|
||||
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0))
|
||||
'@typescript-eslint/scope-manager': 8.66.0
|
||||
'@typescript-eslint/types': 8.66.0
|
||||
'@typescript-eslint/typescript-estree': 8.66.0(typescript@5.8.3)
|
||||
eslint: 9.39.5
|
||||
eslint: 9.39.5(jiti@2.7.0)
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -2961,21 +2974,21 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.66.0
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@vitejs/plugin-vue@6.0.8(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3))':
|
||||
'@vitejs/plugin-vue@6.0.8(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)
|
||||
vite: 8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)
|
||||
vue: 3.5.41(typescript@5.8.3)
|
||||
|
||||
'@vitest/eslint-plugin@1.6.26(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.8.3))(eslint@9.39.5)(typescript@5.8.3))(eslint@9.39.5)(typescript@5.8.3)(vitest@4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)))':
|
||||
'@vitest/eslint-plugin@1.6.26(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)(vitest@4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)))':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.66.0
|
||||
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5)(typescript@5.8.3)
|
||||
eslint: 9.39.5
|
||||
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
|
||||
eslint: 9.39.5(jiti@2.7.0)
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.8.3))(eslint@9.39.5)(typescript@5.8.3)
|
||||
'@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
|
||||
typescript: 5.8.3
|
||||
vitest: 4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))
|
||||
vitest: 4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -2988,13 +3001,13 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.1
|
||||
|
||||
'@vitest/mocker@4.1.10(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))':
|
||||
'@vitest/mocker@4.1.10(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.10
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)
|
||||
vite: 8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)
|
||||
|
||||
'@vitest/pretty-format@4.1.10':
|
||||
dependencies:
|
||||
@@ -3085,23 +3098,23 @@ snapshots:
|
||||
|
||||
'@vue/devtools-shared@8.2.1': {}
|
||||
|
||||
'@vue/eslint-config-prettier@10.2.0(eslint@9.39.5)(prettier@3.9.6)':
|
||||
'@vue/eslint-config-prettier@10.2.0(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.6)':
|
||||
dependencies:
|
||||
eslint: 9.39.5
|
||||
eslint-config-prettier: 10.1.8(eslint@9.39.5)
|
||||
eslint-plugin-prettier: 5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.5))(eslint@9.39.5)(prettier@3.9.6)
|
||||
eslint: 9.39.5(jiti@2.7.0)
|
||||
eslint-config-prettier: 10.1.8(eslint@9.39.5(jiti@2.7.0))
|
||||
eslint-plugin-prettier: 5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.6)
|
||||
prettier: 3.9.6
|
||||
transitivePeerDependencies:
|
||||
- '@types/eslint'
|
||||
|
||||
'@vue/eslint-config-typescript@14.9.0(eslint-plugin-vue@10.0.1(eslint@9.39.5)(vue-eslint-parser@10.4.1(eslint@9.39.5)))(eslint@9.39.5)(typescript@5.8.3)':
|
||||
'@vue/eslint-config-typescript@14.9.0(eslint-plugin-vue@10.0.1(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0))))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5)(typescript@5.8.3)
|
||||
eslint: 9.39.5
|
||||
eslint-plugin-vue: 10.0.1(eslint@9.39.5)(vue-eslint-parser@10.4.1(eslint@9.39.5))
|
||||
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
|
||||
eslint: 9.39.5(jiti@2.7.0)
|
||||
eslint-plugin-vue: 10.0.1(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0)))
|
||||
fast-glob: 3.3.3
|
||||
typescript-eslint: 8.66.0(eslint@9.39.5)(typescript@5.8.3)
|
||||
vue-eslint-parser: 10.4.1(eslint@9.39.5)
|
||||
typescript-eslint: 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
|
||||
vue-eslint-parser: 10.4.1(eslint@9.39.5(jiti@2.7.0))
|
||||
optionalDependencies:
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
@@ -3307,6 +3320,8 @@ snapshots:
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
darkreader@file:../third-party/darkreader: {}
|
||||
|
||||
data-urls@5.0.0:
|
||||
dependencies:
|
||||
whatwg-mimetype: 4.0.0
|
||||
@@ -3366,32 +3381,32 @@ snapshots:
|
||||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
|
||||
eslint-config-prettier@10.1.8(eslint@9.39.5):
|
||||
eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)):
|
||||
dependencies:
|
||||
eslint: 9.39.5
|
||||
eslint: 9.39.5(jiti@2.7.0)
|
||||
|
||||
eslint-plugin-oxlint@0.16.12:
|
||||
dependencies:
|
||||
jsonc-parser: 3.3.1
|
||||
|
||||
eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.5))(eslint@9.39.5)(prettier@3.9.6):
|
||||
eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.6):
|
||||
dependencies:
|
||||
eslint: 9.39.5
|
||||
eslint: 9.39.5(jiti@2.7.0)
|
||||
prettier: 3.9.6
|
||||
prettier-linter-helpers: 1.0.1
|
||||
synckit: 0.11.13
|
||||
optionalDependencies:
|
||||
eslint-config-prettier: 10.1.8(eslint@9.39.5)
|
||||
eslint-config-prettier: 10.1.8(eslint@9.39.5(jiti@2.7.0))
|
||||
|
||||
eslint-plugin-vue@10.0.1(eslint@9.39.5)(vue-eslint-parser@10.4.1(eslint@9.39.5)):
|
||||
eslint-plugin-vue@10.0.1(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0))):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5)
|
||||
eslint: 9.39.5
|
||||
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0))
|
||||
eslint: 9.39.5(jiti@2.7.0)
|
||||
natural-compare: 1.4.0
|
||||
nth-check: 2.1.1
|
||||
postcss-selector-parser: 6.1.4
|
||||
semver: 7.8.5
|
||||
vue-eslint-parser: 10.4.1(eslint@9.39.5)
|
||||
vue-eslint-parser: 10.4.1(eslint@9.39.5(jiti@2.7.0))
|
||||
xml-name-validator: 4.0.0
|
||||
|
||||
eslint-scope@8.4.0:
|
||||
@@ -3412,9 +3427,9 @@ snapshots:
|
||||
|
||||
eslint-visitor-keys@5.0.1: {}
|
||||
|
||||
eslint@9.39.5:
|
||||
eslint@9.39.5(jiti@2.7.0):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5)
|
||||
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0))
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@eslint/config-array': 0.21.2
|
||||
'@eslint/config-helpers': 0.4.2
|
||||
@@ -3448,6 +3463,8 @@ snapshots:
|
||||
minimatch: 3.1.5
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.4
|
||||
optionalDependencies:
|
||||
jiti: 2.7.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -3676,6 +3693,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@pkgjs/parseargs': 0.11.0
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
js-beautify@1.15.4:
|
||||
dependencies:
|
||||
config-chain: 1.1.13
|
||||
@@ -4259,13 +4278,13 @@ snapshots:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
typescript-eslint@8.66.0(eslint@9.39.5)(typescript@5.8.3):
|
||||
typescript-eslint@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.8.3))(eslint@9.39.5)(typescript@5.8.3)
|
||||
'@typescript-eslint/parser': 8.66.0(eslint@9.39.5)(typescript@5.8.3)
|
||||
'@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
|
||||
'@typescript-eslint/parser': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
|
||||
'@typescript-eslint/typescript-estree': 8.66.0(typescript@5.8.3)
|
||||
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5)(typescript@5.8.3)
|
||||
eslint: 9.39.5
|
||||
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.8.3)
|
||||
eslint: 9.39.5(jiti@2.7.0)
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -4298,7 +4317,7 @@ snapshots:
|
||||
picomatch: 4.0.5
|
||||
webpack-virtual-modules: 0.6.2
|
||||
|
||||
unplugin@3.3.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)):
|
||||
unplugin@3.3.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
picomatch: 4.0.5
|
||||
@@ -4306,7 +4325,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@rspack/core': 2.1.8(@swc/helpers@0.5.23)
|
||||
rolldown: 1.2.3
|
||||
vite: 8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)
|
||||
vite: 8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)
|
||||
|
||||
uri-js@4.4.1:
|
||||
dependencies:
|
||||
@@ -4314,7 +4333,7 @@ snapshots:
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0):
|
||||
vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
picomatch: 4.0.5
|
||||
@@ -4324,13 +4343,14 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/node': 24.13.3
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.7.0
|
||||
sass: 1.102.0
|
||||
yaml: 2.9.0
|
||||
|
||||
vitest@4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)):
|
||||
vitest@4.1.10(@types/node@24.13.3)(jsdom@26.1.0)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.10
|
||||
'@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))
|
||||
'@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))
|
||||
'@vitest/pretty-format': 4.1.10
|
||||
'@vitest/runner': 4.1.10
|
||||
'@vitest/snapshot': 4.1.10
|
||||
@@ -4347,7 +4367,7 @@ snapshots:
|
||||
tinyexec: 1.3.0
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.1
|
||||
vite: 8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)
|
||||
vite: 8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 24.13.3
|
||||
@@ -4359,10 +4379,10 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.3.9: {}
|
||||
|
||||
vue-eslint-parser@10.4.1(eslint@9.39.5):
|
||||
vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0)):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.5
|
||||
eslint: 9.39.5(jiti@2.7.0)
|
||||
eslint-scope: 9.1.2
|
||||
eslint-visitor-keys: 5.0.1
|
||||
espree: 11.2.0
|
||||
@@ -4375,7 +4395,7 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.41(typescript@5.8.3)
|
||||
|
||||
vue-router@5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)):
|
||||
vue-router@5.2.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.8.3)):
|
||||
dependencies:
|
||||
'@babel/generator': 8.0.0
|
||||
'@vue-macros/common': 3.1.4(vue@3.5.41(typescript@5.8.3))
|
||||
@@ -4392,13 +4412,13 @@ snapshots:
|
||||
picomatch: 4.0.5
|
||||
scule: 1.3.0
|
||||
tinyglobby: 0.2.17
|
||||
unplugin: 3.3.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0))
|
||||
unplugin: 3.3.0(@rspack/core@2.1.8(@swc/helpers@0.5.23))(rolldown@1.2.3)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0))
|
||||
unplugin-utils: 0.3.2
|
||||
vue: 3.5.41(typescript@5.8.3)
|
||||
yaml: 2.9.0
|
||||
optionalDependencies:
|
||||
'@vue/compiler-sfc': 3.5.41
|
||||
vite: 8.2.0(@types/node@24.13.3)(sass@1.102.0)(yaml@2.9.0)
|
||||
vite: 8.2.0(@types/node@24.13.3)(jiti@2.7.0)(sass@1.102.0)(yaml@2.9.0)
|
||||
transitivePeerDependencies:
|
||||
- '@farmfe/core'
|
||||
- '@rspack/core'
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
<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()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="theme-toggle" @click="toggle">
|
||||
<IconSunny v-if="isDark" class="theme-toggle__icon" />
|
||||
<IconMoon v-else class="theme-toggle__icon" />
|
||||
<button
|
||||
type="button"
|
||||
class="theme-toggle"
|
||||
:aria-pressed="isDark"
|
||||
:aria-label="isDark ? '切换到浅色模式' : '切换到深色模式'"
|
||||
@click="toggle"
|
||||
>
|
||||
<IconSunLine v-if="isDark" class="theme-toggle__icon" />
|
||||
<IconMoonLine v-else class="theme-toggle__icon" />
|
||||
<span class="theme-toggle__label">
|
||||
{{ isDark ? '浅色模式' : '深色模式' }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -20,9 +27,13 @@ const { isDark, toggle } = useDarkMode()
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--halo-text-secondary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
user-select: none;
|
||||
@@ -33,6 +44,11 @@ const { isDark, toggle } = useDarkMode()
|
||||
color: var(--halo-text-primary);
|
||||
}
|
||||
|
||||
.theme-toggle:focus-visible {
|
||||
outline: 2px solid var(--halo-accent-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.theme-toggle__icon {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
@@ -43,4 +59,4 @@ const { isDark, toggle } = useDarkMode()
|
||||
font-size: 0.8125rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'halo-dark-mode-theme'
|
||||
|
||||
function mockMatchMedia(matches: boolean): void {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}))
|
||||
}
|
||||
|
||||
describe('useDarkMode', () => {
|
||||
let useDarkMode: typeof import('../useDarkMode').useDarkMode
|
||||
|
||||
async function load(systemDark = false): Promise<void> {
|
||||
vi.resetModules()
|
||||
localStorage.clear()
|
||||
mockMatchMedia(systemDark)
|
||||
const mod = await import('../useDarkMode')
|
||||
useDarkMode = mod.useDarkMode
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await load(false)
|
||||
})
|
||||
|
||||
it('默认 auto 且系统为深色时 isDark 为 true', async () => {
|
||||
await load(true)
|
||||
const { theme, isDark } = useDarkMode()
|
||||
expect(theme.value).toBe('auto')
|
||||
expect(isDark.value).toBe(true)
|
||||
})
|
||||
|
||||
it('auto 模式下点击切换变为显式深色并持久化', async () => {
|
||||
const { theme, toggle } = useDarkMode()
|
||||
toggle()
|
||||
expect(theme.value).toBe('dark')
|
||||
await nextTick()
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('dark')
|
||||
})
|
||||
|
||||
it('auto 且系统为深色时点击切换变为显式浅色', async () => {
|
||||
await load(true)
|
||||
const { theme, toggle } = useDarkMode()
|
||||
toggle()
|
||||
expect(theme.value).toBe('light')
|
||||
await nextTick()
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('light')
|
||||
})
|
||||
|
||||
it('dark 与 light 之间往返切换', async () => {
|
||||
const { theme, toggle } = useDarkMode()
|
||||
theme.value = 'dark'
|
||||
toggle()
|
||||
expect(theme.value).toBe('light')
|
||||
toggle()
|
||||
expect(theme.value).toBe('dark')
|
||||
await nextTick()
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('dark')
|
||||
})
|
||||
|
||||
it('setTheme 会持久化到 localStorage', async () => {
|
||||
const { setTheme } = useDarkMode()
|
||||
setTheme('dark')
|
||||
await nextTick()
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('dark')
|
||||
})
|
||||
|
||||
it('localStorage 非法值回退到 auto', async () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'not-a-theme')
|
||||
await load(false)
|
||||
const { theme } = useDarkMode()
|
||||
expect(theme.value).toBe('auto')
|
||||
})
|
||||
|
||||
it('storage 事件会同步其他标签页的主题', () => {
|
||||
const { theme, setTheme } = useDarkMode()
|
||||
setTheme('light')
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: STORAGE_KEY,
|
||||
newValue: 'dark',
|
||||
}),
|
||||
)
|
||||
expect(theme.value).toBe('dark')
|
||||
})
|
||||
|
||||
it('storage 事件忽略非法值', () => {
|
||||
const { theme, setTheme } = useDarkMode()
|
||||
setTheme('light')
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: STORAGE_KEY,
|
||||
newValue: 'invalid',
|
||||
}),
|
||||
)
|
||||
expect(theme.value).toBe('light')
|
||||
})
|
||||
})
|
||||
@@ -26,13 +26,19 @@ function persistTheme(theme: ThemeMode): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** 将 data-halo-theme 属性应用到 <html> 元素 */
|
||||
/**
|
||||
* 将主题状态同步到 <html>。
|
||||
* data-halo-theme 为兼容性遗留标记,当前已无 CSS 消费方,保留给外部脚本与验证工具;
|
||||
* color-scheme 用于在 Dark Reader 异步注入前同步浏览器控件外观,缓解 FOUC。
|
||||
*/
|
||||
function applyHtmlAttribute(isDark: boolean): void {
|
||||
const root = document.documentElement
|
||||
if (isDark) {
|
||||
document.documentElement.setAttribute('data-halo-theme', 'dark')
|
||||
root.setAttribute('data-halo-theme', 'dark')
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-halo-theme')
|
||||
root.removeAttribute('data-halo-theme')
|
||||
}
|
||||
root.style.colorScheme = isDark ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
// 模块级单例 — 所有组件共享同一个状态
|
||||
@@ -52,6 +58,15 @@ watch(isDark, (dark) => applyHtmlAttribute(dark), { immediate: true })
|
||||
// 监听 theme 变化 → 持久化
|
||||
watch(theme, (t) => persistTheme(t))
|
||||
|
||||
// 多标签页同步:其他标签页修改主题时跟随更新
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key !== STORAGE_KEY) return
|
||||
const value = event.newValue
|
||||
if (value === 'light' || value === 'dark' || value === 'auto') {
|
||||
theme.value = value
|
||||
}
|
||||
})
|
||||
|
||||
// 监听系统偏好变化 → 仅在 'auto' 模式下响应
|
||||
onChange((systemDark) => {
|
||||
if (theme.value === 'auto') {
|
||||
@@ -83,4 +98,4 @@ export function useDarkMode() {
|
||||
setTheme,
|
||||
toggle,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { disable, enable } from 'darkreader'
|
||||
import { watch } from 'vue'
|
||||
import { useDarkMode } from './composables/useDarkMode'
|
||||
|
||||
/**
|
||||
* Dark Reader 引擎参数。
|
||||
* 纯 Dark Reader 策略下不再维护手工 CSS 覆盖;
|
||||
* 这里只调整通用观感参数,让核心与第三方插件页面转换后风格统一。
|
||||
* 注意:DR 注入为异步过程,刷新闪烁只能由 useDarkMode 的 color-scheme 同步缓解。
|
||||
*/
|
||||
const DARK_READER_THEME = {
|
||||
brightness: 100,
|
||||
contrast: 90,
|
||||
grayscale: 0,
|
||||
sepia: 0,
|
||||
darkSchemeBackgroundColor: '#181b20',
|
||||
darkSchemeTextColor: '#e8e6e3',
|
||||
scrollbarColor: '#3a3f4a',
|
||||
selectionColor: '#2f6f7a',
|
||||
styleSystemControls: true,
|
||||
} as const
|
||||
|
||||
let initialized = false
|
||||
|
||||
/**
|
||||
* 初始化 Dark Reader 通用暗色引擎。
|
||||
* 监听 useDarkMode 的 isDark 状态,深色时启用,浅色时关闭。
|
||||
*/
|
||||
export function initDarkReaderEngine(): void {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
|
||||
const { isDark } = useDarkMode()
|
||||
watch(
|
||||
isDark,
|
||||
(dark) => {
|
||||
try {
|
||||
if (dark) {
|
||||
enable(DARK_READER_THEME)
|
||||
} else {
|
||||
disable()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[dark-mode] Dark Reader 引擎异常', error)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
}
|
||||
+8
-2
@@ -1,7 +1,13 @@
|
||||
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'
|
||||
|
||||
// 在插件加载后将切换器注入到侧边栏,暗色转换完全交给 Dark Reader
|
||||
injectThemeToggle()
|
||||
initDarkReaderEngine()
|
||||
|
||||
export default definePlugin({
|
||||
components: {},
|
||||
@@ -18,7 +24,7 @@ export default definePlugin({
|
||||
menu: {
|
||||
name: '深色模式',
|
||||
group: '偏好设置',
|
||||
icon: markRaw(IconMoon),
|
||||
icon: markRaw(IconPalette),
|
||||
priority: 50,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,12 +1,6 @@
|
||||
/* ============================================================
|
||||
Halo Dark Mode — 样式入口
|
||||
按加载顺序导入所有样式文件
|
||||
仅保留插件自身 UI 变量;页面暗色转换完全交给 Dark Reader。
|
||||
============================================================ */
|
||||
|
||||
@import './variables.css';
|
||||
@import './overrides/utilities.css';
|
||||
@import './overrides/layout.css';
|
||||
@import './overrides/components.css';
|
||||
@import './overrides/forms.css';
|
||||
@import './overrides/editor.css';
|
||||
@import './overrides/scrollbar.css';
|
||||
@import './variables.css';
|
||||
@@ -1,142 +0,0 @@
|
||||
/* ============================================================
|
||||
Halo Dark Mode — @halo-dev/components 组件库覆盖
|
||||
覆盖 Halo 组件库中的 VCard, VModal, VDropdown, VTag 等
|
||||
============================================================ */
|
||||
|
||||
[data-halo-theme="dark"] {
|
||||
/* ===== 卡片 VCard ===== */
|
||||
.v-card,
|
||||
[class*="v-card"] {
|
||||
background-color: var(--halo-bg-card);
|
||||
border-color: var(--halo-border-base);
|
||||
color: var(--halo-text-primary);
|
||||
}
|
||||
|
||||
/* ===== 模态框 VModal ===== */
|
||||
.v-modal,
|
||||
.modal-container,
|
||||
[class*="modal"] {
|
||||
background-color: var(--halo-bg-card);
|
||||
color: var(--halo-text-primary);
|
||||
}
|
||||
|
||||
/* ===== 下拉菜单 VDropdown ===== */
|
||||
.v-dropdown,
|
||||
.dropdown-menu,
|
||||
[class*="dropdown"] {
|
||||
background-color: var(--halo-bg-dropdown);
|
||||
border-color: var(--halo-border-base);
|
||||
color: var(--halo-text-primary);
|
||||
}
|
||||
|
||||
.v-dropdown-item:hover,
|
||||
.dropdown-item:hover {
|
||||
background-color: var(--halo-bg-hover);
|
||||
}
|
||||
|
||||
/* ===== 提示框 VTooltip ===== */
|
||||
.v-tooltip,
|
||||
[class*="tooltip"] {
|
||||
background-color: var(--halo-bg-tooltip);
|
||||
color: var(--halo-text-inverse);
|
||||
}
|
||||
|
||||
/* ===== 标签/徽章 VTag, VBadge ===== */
|
||||
.v-tag,
|
||||
[class*="tag"],
|
||||
.v-badge,
|
||||
[class*="badge"] {
|
||||
background-color: var(--halo-tag-bg);
|
||||
color: var(--halo-tag-text);
|
||||
}
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn-default,
|
||||
.btn-secondary,
|
||||
button:not([class*="btn-primary"]):not([class*="btn-danger"]) {
|
||||
background-color: var(--halo-bg-card);
|
||||
color: var(--halo-text-primary);
|
||||
border-color: var(--halo-border-base);
|
||||
}
|
||||
|
||||
.btn-default:hover,
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--halo-bg-hover);
|
||||
}
|
||||
|
||||
/* ===== 表格 VTable ===== */
|
||||
table,
|
||||
.v-table,
|
||||
[class*="table"] {
|
||||
background-color: var(--halo-bg-card);
|
||||
color: var(--halo-text-primary);
|
||||
}
|
||||
|
||||
thead,
|
||||
.table-header {
|
||||
background-color: var(--halo-table-header-bg);
|
||||
}
|
||||
|
||||
thead th,
|
||||
.table-header th {
|
||||
color: var(--halo-text-secondary);
|
||||
border-color: var(--halo-table-border);
|
||||
}
|
||||
|
||||
tbody td,
|
||||
.table-body td {
|
||||
border-color: var(--halo-table-border);
|
||||
}
|
||||
|
||||
tbody tr:hover,
|
||||
.table-row:hover {
|
||||
background-color: var(--halo-table-row-hover);
|
||||
}
|
||||
|
||||
/* ===== 分页 ===== */
|
||||
.pagination,
|
||||
.v-pagination {
|
||||
color: var(--halo-text-secondary);
|
||||
}
|
||||
|
||||
.pagination .active,
|
||||
.v-pagination .active {
|
||||
background-color: var(--halo-accent-primary);
|
||||
color: var(--halo-accent-primary-text);
|
||||
}
|
||||
|
||||
/* ===== 面包屑 ===== */
|
||||
.breadcrumb,
|
||||
.v-breadcrumb {
|
||||
color: var(--halo-text-secondary);
|
||||
}
|
||||
|
||||
.breadcrumb a,
|
||||
.v-breadcrumb a {
|
||||
color: var(--halo-text-link);
|
||||
}
|
||||
|
||||
/* ===== Toast / 通知 ===== */
|
||||
.v-toast,
|
||||
.toast-notification,
|
||||
[class*="toast"] {
|
||||
background-color: var(--halo-bg-card);
|
||||
color: var(--halo-text-primary);
|
||||
border-color: var(--halo-border-base);
|
||||
}
|
||||
|
||||
/* ===== 步骤条 ===== */
|
||||
.v-steps,
|
||||
[class*="steps"] {
|
||||
color: var(--halo-text-secondary);
|
||||
}
|
||||
|
||||
/* ===== 开关 VSwitch ===== */
|
||||
.v-switch-track {
|
||||
background-color: var(--halo-bg-disabled);
|
||||
}
|
||||
|
||||
.v-switch-track[aria-checked="true"] {
|
||||
background-color: var(--halo-accent-primary);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/* ============================================================
|
||||
Halo Dark Mode — 富文本编辑器覆盖
|
||||
@halo-dev/richtext-editor + TipTap / ProseMirror
|
||||
============================================================ */
|
||||
|
||||
[data-halo-theme="dark"] {
|
||||
/* ===== ProseMirror / TipTap 编辑器主体 ===== */
|
||||
.ProseMirror,
|
||||
.tiptap,
|
||||
[class*="editor-content"] {
|
||||
color: var(--halo-text-primary);
|
||||
background-color: var(--halo-bg-input);
|
||||
}
|
||||
|
||||
/* ===== 编辑器菜单栏 ===== */
|
||||
.editor-toolbar,
|
||||
.editor-menubar,
|
||||
[class*="editor-toolbar"],
|
||||
[class*="editor-menubar"] {
|
||||
background-color: var(--halo-bg-card);
|
||||
border-color: var(--halo-border-base);
|
||||
}
|
||||
|
||||
.editor-toolbar button,
|
||||
.editor-menubar button {
|
||||
color: var(--halo-text-secondary);
|
||||
}
|
||||
|
||||
.editor-toolbar button:hover,
|
||||
.editor-menubar button:hover,
|
||||
.editor-toolbar button.is-active,
|
||||
.editor-menubar button.is-active {
|
||||
background-color: var(--halo-bg-hover);
|
||||
color: var(--halo-text-primary);
|
||||
}
|
||||
|
||||
/* ===== 代码块 ===== */
|
||||
.ProseMirror pre,
|
||||
.ProseMirror code,
|
||||
.tiptap pre,
|
||||
.tiptap code {
|
||||
background-color: oklch(20% 0.015 250);
|
||||
color: oklch(85% 0.03 160);
|
||||
}
|
||||
|
||||
/* ===== Bubble menu / floating menu ===== */
|
||||
.tippy-box,
|
||||
.tiptap-bubble-menu,
|
||||
.floating-menu {
|
||||
background-color: var(--halo-bg-dropdown);
|
||||
border-color: var(--halo-border-base);
|
||||
color: var(--halo-text-primary);
|
||||
}
|
||||
|
||||
/* ===== 编辑器占位符 ===== */
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
color: var(--halo-text-tertiary);
|
||||
}
|
||||
|
||||
/* ===== 链接 ===== */
|
||||
.ProseMirror a,
|
||||
.tiptap a {
|
||||
color: var(--halo-text-link);
|
||||
}
|
||||
|
||||
/* ===== 选中文字 ===== */
|
||||
.ProseMirror ::selection,
|
||||
.tiptap ::selection {
|
||||
background-color: oklch(30% 0.04 160 / 50%);
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
/* ============================================================
|
||||
Halo Dark Mode — FormKit 表单组件覆盖
|
||||
覆盖输入框、选择器、开关、复选框等表单元素
|
||||
============================================================ */
|
||||
|
||||
[data-halo-theme="dark"] {
|
||||
/* ===== 输入框 ===== */
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="email"],
|
||||
input[type="number"],
|
||||
input[type="search"],
|
||||
input[type="url"],
|
||||
input[type="tel"],
|
||||
textarea,
|
||||
select,
|
||||
.formkit-input,
|
||||
[data-formkit-input] {
|
||||
background-color: var(--halo-bg-input);
|
||||
color: var(--halo-text-primary);
|
||||
border-color: var(--halo-border-input);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus,
|
||||
.formkit-input:focus {
|
||||
border-color: var(--halo-border-focus);
|
||||
outline-color: var(--halo-border-focus);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder,
|
||||
.formkit-input::placeholder {
|
||||
color: var(--halo-text-tertiary);
|
||||
}
|
||||
|
||||
input:disabled,
|
||||
textarea:disabled,
|
||||
select:disabled {
|
||||
background-color: var(--halo-bg-disabled);
|
||||
color: var(--halo-text-tertiary);
|
||||
}
|
||||
|
||||
/* ===== 复选框/单选框 ===== */
|
||||
input[type="checkbox"],
|
||||
input[type="radio"],
|
||||
.formkit-checkbox,
|
||||
.formkit-radio {
|
||||
accent-color: var(--halo-accent-primary);
|
||||
}
|
||||
|
||||
/* ===== FormKit 标签和帮助文本 ===== */
|
||||
.formkit-label,
|
||||
.formkit-legend {
|
||||
color: var(--halo-text-secondary);
|
||||
}
|
||||
|
||||
.formkit-help,
|
||||
.formkit-message {
|
||||
color: var(--halo-text-tertiary);
|
||||
}
|
||||
|
||||
/* ===== FormKit 外层 ===== */
|
||||
.formkit-outer {
|
||||
color: var(--halo-text-primary);
|
||||
}
|
||||
|
||||
/* ===== FormKit 前缀/后缀 ===== */
|
||||
.formkit-prefix,
|
||||
.formkit-suffix {
|
||||
background-color: var(--halo-bg-hover);
|
||||
color: var(--halo-text-secondary);
|
||||
border-color: var(--halo-border-input);
|
||||
}
|
||||
|
||||
/* ===== 选择器下拉 ===== */
|
||||
select option {
|
||||
background-color: var(--halo-bg-dropdown);
|
||||
color: var(--halo-text-primary);
|
||||
}
|
||||
|
||||
/* ===== 代码块 in forms ===== */
|
||||
.formkit-code {
|
||||
background-color: var(--halo-bg-hover);
|
||||
color: var(--halo-text-primary);
|
||||
border-color: var(--halo-border-base);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/* ============================================================
|
||||
Halo Dark Mode — 核心布局覆盖
|
||||
覆盖 BasicLayout.vue 的硬编码颜色
|
||||
============================================================ */
|
||||
|
||||
[data-halo-theme="dark"] {
|
||||
/* ===== 页面整体 ===== */
|
||||
.layout {
|
||||
background-color: var(--halo-bg-body);
|
||||
}
|
||||
|
||||
/* ===== 侧边栏 ===== */
|
||||
.sidebar {
|
||||
background-color: var(--halo-bg-sidebar);
|
||||
box-shadow: var(--halo-shadow-sm);
|
||||
}
|
||||
|
||||
.sidebar__search {
|
||||
background-color: var(--halo-search-bg);
|
||||
color: var(--halo-search-placeholder);
|
||||
}
|
||||
|
||||
.sidebar__search:hover {
|
||||
color: var(--halo-search-text);
|
||||
}
|
||||
|
||||
.sidebar__search-icon {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.sidebar__search-shortcut {
|
||||
color: var(--halo-text-tertiary);
|
||||
}
|
||||
|
||||
.sidebar__logo {
|
||||
filter: brightness(0.9) invert(0);
|
||||
}
|
||||
|
||||
.sidebar__profile {
|
||||
border-top-color: var(--halo-border-light);
|
||||
}
|
||||
|
||||
/* ===== 内容区 ===== */
|
||||
.main-content {
|
||||
background-color: var(--halo-bg-content);
|
||||
color: var(--halo-text-primary);
|
||||
}
|
||||
|
||||
.main-content__footer-text {
|
||||
color: var(--halo-text-tertiary);
|
||||
}
|
||||
|
||||
.main-content__footer-link {
|
||||
color: var(--halo-text-link);
|
||||
}
|
||||
|
||||
/* ===== 页面标题 ===== */
|
||||
.page-title,
|
||||
.v-card-title {
|
||||
color: var(--halo-text-primary);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/* ============================================================
|
||||
Halo Dark Mode — 滚动条覆盖
|
||||
OverlayScrollbars + 原生滚动条
|
||||
============================================================ */
|
||||
|
||||
[data-halo-theme="dark"] {
|
||||
/* ===== OverlayScrollbars ===== */
|
||||
.os-scrollbar .os-scrollbar-handle {
|
||||
background-color: var(--halo-scrollbar-thumb);
|
||||
}
|
||||
|
||||
.os-scrollbar .os-scrollbar-track {
|
||||
background-color: var(--halo-scrollbar-track);
|
||||
}
|
||||
|
||||
.os-scrollbar .os-scrollbar-handle:hover {
|
||||
background-color: oklch(45% 0.03 250);
|
||||
}
|
||||
|
||||
/* ===== 原生滚动条(Firefox) ===== */
|
||||
* {
|
||||
scrollbar-color: var(--halo-scrollbar-thumb) var(--halo-scrollbar-track);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* ===== 原生滚动条(Webkit:Chrome/Edge/Safari) ===== */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--halo-scrollbar-track);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--halo-scrollbar-thumb);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(45% 0.03 250);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: var(--halo-scrollbar-track);
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/* ============================================================
|
||||
Halo Dark Mode — Tailwind 工具类覆盖
|
||||
覆盖 Tailwind 的颜色类和常见 UI 原子类
|
||||
============================================================ */
|
||||
|
||||
[data-halo-theme="dark"] {
|
||||
/* ===== 背景色 ===== */
|
||||
.bg-white {
|
||||
background-color: var(--halo-bg-card);
|
||||
}
|
||||
|
||||
.bg-gray-50,
|
||||
.bg-gray-100 {
|
||||
background-color: var(--halo-bg-content);
|
||||
}
|
||||
|
||||
.bg-gray-200 {
|
||||
background-color: var(--halo-bg-hover);
|
||||
}
|
||||
|
||||
/* ===== 文字色 ===== */
|
||||
.text-gray-900,
|
||||
.text-gray-800 {
|
||||
color: var(--halo-text-primary);
|
||||
}
|
||||
|
||||
.text-gray-700,
|
||||
.text-gray-600,
|
||||
.text-gray-500 {
|
||||
color: var(--halo-text-secondary);
|
||||
}
|
||||
|
||||
.text-gray-400,
|
||||
.text-gray-300 {
|
||||
color: var(--halo-text-tertiary);
|
||||
}
|
||||
|
||||
/* ===== 边框 ===== */
|
||||
.border,
|
||||
.border-gray-200,
|
||||
.border-gray-300 {
|
||||
border-color: var(--halo-border-base);
|
||||
}
|
||||
|
||||
.divide-y > :not([hidden]) ~ :not([hidden]),
|
||||
.divide-x > :not([hidden]) ~ :not([hidden]) {
|
||||
border-color: var(--halo-border-light);
|
||||
}
|
||||
|
||||
/* ===== 阴影 ===== */
|
||||
.shadow {
|
||||
box-shadow: var(--halo-shadow-base);
|
||||
}
|
||||
|
||||
.shadow-sm {
|
||||
box-shadow: var(--halo-shadow-sm);
|
||||
}
|
||||
|
||||
.shadow-lg,
|
||||
.shadow-xl {
|
||||
box-shadow: var(--halo-shadow-lg);
|
||||
}
|
||||
|
||||
/* ===== 链接 ===== */
|
||||
a:not([class*="text-"]),
|
||||
.hover\:text-gray-900:hover,
|
||||
.hover\:text-gray-800:hover,
|
||||
.hover\:text-gray-700:hover {
|
||||
color: var(--halo-text-link);
|
||||
}
|
||||
|
||||
/* ===== 通用悬停 ===== */
|
||||
.hover\:bg-gray-50:hover,
|
||||
.hover\:bg-gray-100:hover {
|
||||
background-color: var(--halo-bg-hover);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
配色策略: OKLCH 颜色空间 | 中性色微蓝着色调 | 亮度层次替代阴影
|
||||
============================================================ */
|
||||
|
||||
/* 说明:data-halo-theme 为兼容性遗留标记,当前已无 CSS 消费方,保留给外部脚本与验证工具。 */
|
||||
/* ===== 浅色模式(Halo 默认,此处定义为显式回退) ===== */
|
||||
:root {
|
||||
--halo-bg-body: oklch(97% 0.005 250);
|
||||
@@ -58,59 +59,3 @@
|
||||
--halo-tag-bg: oklch(90% 0.04 160);
|
||||
--halo-tag-text: oklch(40% 0.1 160);
|
||||
}
|
||||
|
||||
/* ===== 深色模式 ===== */
|
||||
[data-halo-theme="dark"] {
|
||||
--halo-bg-body: oklch(14% 0.01 250);
|
||||
--halo-bg-sidebar: oklch(16% 0.015 250);
|
||||
--halo-bg-content: oklch(14% 0.01 250);
|
||||
--halo-bg-card: oklch(18% 0.015 250);
|
||||
--halo-bg-input: oklch(20% 0.015 250);
|
||||
--halo-bg-hover: oklch(24% 0.02 250);
|
||||
--halo-bg-active: oklch(28% 0.03 160);
|
||||
--halo-bg-disabled: oklch(16% 0.005 250);
|
||||
--halo-bg-tooltip: oklch(25% 0.01 250);
|
||||
--halo-bg-modal: oklch(0% 0 0 / 60%);
|
||||
--halo-bg-dropdown: oklch(20% 0.015 250);
|
||||
|
||||
--halo-text-primary: oklch(92% 0.005 250);
|
||||
--halo-text-secondary: oklch(70% 0.01 250);
|
||||
--halo-text-tertiary: oklch(50% 0.01 250);
|
||||
--halo-text-link: oklch(72% 0.14 160);
|
||||
--halo-text-inverse: oklch(14% 0.01 250);
|
||||
|
||||
--halo-border-base: oklch(28% 0.015 250);
|
||||
--halo-border-light: oklch(22% 0.01 250);
|
||||
--halo-border-input: oklch(30% 0.015 250);
|
||||
--halo-border-focus: oklch(65% 0.14 160);
|
||||
|
||||
--halo-accent-primary: oklch(60% 0.13 160);
|
||||
--halo-accent-primary-hover: oklch(66% 0.12 160);
|
||||
--halo-accent-primary-text: oklch(14% 0.02 160);
|
||||
--halo-accent-danger: oklch(50% 0.18 25);
|
||||
--halo-accent-danger-hover: oklch(56% 0.17 25);
|
||||
--halo-accent-success: oklch(58% 0.16 150);
|
||||
--halo-accent-warning: oklch(65% 0.16 85);
|
||||
|
||||
--halo-shadow-sm: 0 1px 2px oklch(0% 0 0 / 30%);
|
||||
--halo-shadow-base: 0 2px 8px oklch(0% 0 0 / 40%);
|
||||
--halo-shadow-lg: 0 4px 16px oklch(0% 0 0 / 50%);
|
||||
|
||||
--halo-scrollbar-thumb: oklch(35% 0.02 250);
|
||||
--halo-scrollbar-track: oklch(18% 0.01 250);
|
||||
|
||||
--halo-search-bg: oklch(20% 0.015 250);
|
||||
--halo-search-text: oklch(70% 0.01 250);
|
||||
--halo-search-placeholder: oklch(50% 0.01 250);
|
||||
|
||||
--halo-menu-item-hover: oklch(22% 0.02 160);
|
||||
--halo-menu-item-active: oklch(28% 0.04 160);
|
||||
--halo-menu-group-title: oklch(55% 0.01 250);
|
||||
|
||||
--halo-table-header-bg: oklch(18% 0.01 250);
|
||||
--halo-table-row-hover: oklch(22% 0.015 250);
|
||||
--halo-table-border: oklch(26% 0.015 250);
|
||||
|
||||
--halo-tag-bg: oklch(22% 0.03 160);
|
||||
--halo-tag-text: oklch(80% 0.08 160);
|
||||
}
|
||||
|
||||
@@ -31,17 +31,24 @@ const modeOptions: { value: ThemeMode; label: string; description: string }[] =
|
||||
当前生效:<strong>{{ currentEffectiveMode }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="dark-mode-settings__options">
|
||||
<div
|
||||
<div
|
||||
class="dark-mode-settings__options"
|
||||
role="radiogroup"
|
||||
aria-label="主题模式"
|
||||
>
|
||||
<button
|
||||
v-for="option in modeOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
role="radio"
|
||||
class="dark-mode-settings__option"
|
||||
:class="{ 'is-active': theme === option.value }"
|
||||
:aria-checked="theme === option.value"
|
||||
@click="setTheme(option.value)"
|
||||
>
|
||||
<div class="dark-mode-settings__option-label">{{ option.label }}</div>
|
||||
<div class="dark-mode-settings__option-desc">{{ option.description }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,10 +102,15 @@ const modeOptions: { value: ThemeMode; label: string; description: string }[] =
|
||||
}
|
||||
|
||||
.dark-mode-settings__option {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--halo-border-base);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--halo-border-base);
|
||||
transition: background-color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
@@ -106,6 +118,11 @@ const modeOptions: { value: ThemeMode; label: string; description: string }[] =
|
||||
background-color: var(--halo-bg-hover);
|
||||
}
|
||||
|
||||
.dark-mode-settings__option:focus-visible {
|
||||
outline: 2px solid var(--halo-accent-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dark-mode-settings__option.is-active {
|
||||
background-color: var(--halo-menu-item-active);
|
||||
border-color: var(--halo-accent-primary);
|
||||
@@ -122,4 +139,4 @@ const modeOptions: { value: ThemeMode; label: string; description: string }[] =
|
||||
font-size: 0.8125rem;
|
||||
color: var(--halo-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -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()
|
||||
@@ -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 '无'}")
|
||||
@@ -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']}")
|
||||
@@ -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) 的发现,面向首次实现。*
|
||||
@@ -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.2(plugin-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 推荐方案:方案 C(CSS 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 的代码分析。*
|
||||
Reference in New Issue
Block a user