万字复盘:AI 协作开发一个 Halo 插件,并把它送上官方应用市场

副标题:一个大学生开发者的 Halo 暗色模式插件开发、迭代与上架全记录
作者:刘航宇(GitHub: LHY0125)| 项目:halo-dark-mode-plugin


第 0 章 引言:从「后台太亮了」开始

凌晨一点半,我关掉房间的大灯,打开 Halo 后台准备写一篇新文章。屏幕上大片的白瞬间糊了我一脸——页面背景、侧边栏、表单、弹窗,全是近乎纯白的底。

这就是我当时的日常。我的博客跑在 Halo 上,白天没什么感觉,一到晚上就遭罪:后台没有暗色模式,夜里写文章或者改配置时,屏幕亮度高得眼睛发酸。我试过浏览器扩展的深色模式,效果是生硬的"硬翻转",表格和代码高亮乱成一团;也翻过社区讨论,暗色模式是老生常谈的需求,却一直没下文。

既然等不到,那就自己动手。我是学计算机的,写代码是日常,给 Halo 写一个"换皮肤"的插件,听起来并不难——无非是判断一下当前是白天还是晚上,把背景色换深一点。当时我甚至没怎么查文档,脑子里已经把实现想好了:注入几条 CSS 覆盖,完事。

现在回头看,这个判断错得离谱。这个"看起来很简单"的插件,从 v1.0.1 一路迭代到 v1.0.9,前后 8 个版本、26 次提交;最后一版 1.0.9,是替换成最终自定义 Logo 之后提交上架的正式版。期间我推倒过一次完整的技术方案,删掉过自己亲手做的功能,把设置入口从侧边栏挪进官方「外观」分组,最后在准备上架官方应用商店时,还撞上了一个关于 AI 生成素材版权的坑。

这篇文章就是这段过程的完整记录。我会尽量把每一步的"为什么"讲清楚:为什么最终选了 Dark Reader 而不是手工写 CSS,为什么这个插件的前端要比后端复杂得多,为什么有些功能做得越多反而越不对。如果你也在考虑给 Halo 写插件,或者只是好奇一个小插件背后到底有多少决策,希望这篇复盘能给你一些参考。

第 1 章 认识 Halo 与插件机制

Halo 是什么

Halo 是一个开源的现代化博客系统,Java 技术栈(Spring Boot),我的博客就部署在它上面。和 WordPress 那种"主题、插件直接改前端模板"的模式不同,Halo 2.x 的前台与后台是分离的:后台管理面板(console)是一个 Vue 3 的单页应用,前台则是独立的主题渲染。我要做的"后台暗色模式",改的正是这个 console 界面。

这里有一个绕不开的约束:插件不能修改 Halo 核心代码。所有功能都必须通过官方提供的插件机制挂载进去。这是 Halo 插件体系最核心的设计:核心保持封闭,插件对外开放,两者通过「扩展点」对接。

一个插件由什么组成

一个 Halo 插件本质上是一个 JAR 包,里面至少包含三部分:

  1. plugin.yaml —— 插件清单,声明插件的元数据;
  2. Java 主类 —— 继承 BasePlugin,管理插件生命周期;
  3. 可选的前端资源 —— 打包进 JAR 的 js/css,由 console 动态加载。

先看 plugin.yaml 的关键字段:

apiVersion: plugin.halo.run/v1alpha1
kind: Plugin
metadata:
  name: dark-mode
spec:
  enabled: false
  requires: ">=2.25.0"
  author:
    name: LHY
    website: https://github.com/LHY0125
  logo: logo.png
  homepage: https://github.com/LHY0125/halo-dark-mode-plugin#readme
  repo: https://github.com/LHY0125/halo-dark-mode-plugin
  issues: https://github.com/LHY0125/halo-dark-mode-plugin/issues
  displayName: "深色模式"
  description: "为 Halo 后台管理面板提供深色/浅色模式切换,支持跟随系统、手动切换和偏好记忆"
  license:
    - name: "GPL-3.0"
      url: "https://github.com/LHY0125/halo-dark-mode-plugin/blob/main/LICENSE"

几个值得说明的字段:metadata.name 是插件的唯一标识,安装、调用都靠它;requires: ">=2.25.0" 声明兼容的 Halo 版本范围,我基于 Halo 2.25 开发;enabled: false 表示安装后不自动启用,由用户在插件管理里手动开启——这是我后来特意改的,避免装上插件就"强制变黑";displayNamedescription 是展示给用户的中文名称与描述;issues 指向 Issue 反馈入口,这个字段我一开始没配,上架前才补齐(见第 6 章);license 声明项目以 GPL-3.0 开源。

BasePlugin 生命周期

Java 主类只需要继承 run.halo.app.plugin.BasePlugin,并重写 start() / stop()。插件框架会在插件启用、停用时调用这两个方法,相当于插件的"开关"。我的 DarkModePlugin.java 去掉注释就这么多——类注释、@author@since 标记都省略了,为的是让核心逻辑一眼看全(顺带一提,当前源码里 @since 已标到 1.0.9,对应第 9 章提交上架的正式版):

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;

@Slf4j
@Component
public class DarkModePlugin extends BasePlugin {

    public DarkModePlugin(PluginContext pluginContext) {
        super(pluginContext);
    }

    @Override
    public void start() {
        log.info("插件启动成功!");
    }

    @Override
    public void stop() {
        log.info("插件停止!");
    }
}

你可能会奇怪:这个 Java 类几乎什么都没做,只有两个日志。这不是偷懒,而是一个刻意的架构决策——这个插件的核心逻辑全部放在前端。Halo 的 console 是 Vue 3 写的,前端插件通过 definePlugin() 注册路由和菜单,构建产物随 JAR 一起分发,由 console 在运行时加载。也就是说,"换肤"这种纯 UI 的事根本不需要后端参与:后端只负责让插件能被安装、启用、停用,真正干活的是前端代码。

这种"Java 极简骨架 + Vue 3/TypeScript 前端"的形态,是 Halo UI 类插件很典型的写法,也决定了后面几乎所有的迭代都发生在前端目录里。

第 2 章 技术选型:为什么是 Dark Reader

天真的开始:手工写 CSS 覆盖

第一版方案简单直接:手工写 CSS 覆盖。当时的想法是"后台就那几十个页面,逐个覆盖也不难"。结果很快被打脸。

第一阶段,我甚至没打开开发者工具确认真实类名,而是"推测"Halo 用的是 Tailwind / Vuetify 那套类名去写选择器。上线后发现大部分页面根本没变——Halo 2.25 的 console 实际用的是 UnoCSS(既有按语义命名的 BEM 类,也有工具生成的哈希类),和我猜的完全是两套东西。后来我老老实实基于真实 DOM 类名重写,新增 halo-core.css,覆盖 .page-header.card-wrapper.entity-field-title.pagination.tag-default.modal-*.description-item-*.toast-container.markdown-body 这些真实存在的类,才勉强把核心页面压下去。

第二阶段是第三方插件页面。我的博客装了不少插件,它们的设置页是各插件自己用 Vue scoped 样式写的(类名带 [data-v-xxx] 特征),特异性冲突加上加载顺序靠后,常规覆盖根本不生效,只能逐个加 !important。我为此专门写过 plugin-pages.css,里面是 .group.equipment-*.week-picker.ah-body.wb-s-card 这种"各家各户"的类名。

第三阶段是编辑器。文章编辑器用的是 Bytemd(Markdown)+ CodeMirror(代码编辑),又是一大坨 .bytemd.CodeMirror 的样式。

到这一步我彻底明白了:手工覆盖是一条打地鼠的路。Halo 核心页面还算稳定,但第三方插件是无限增长的——每装一个新插件,就多一批需要我"补课"的类名;而且我根本不知道用户会装什么插件。继续走下去,维护成本会线性膨胀,永无止境。

换个思路:通用暗色引擎

于是我把目光投向 Dark Reader——一个非常成熟的浏览器暗色扩展,MIT 协议开源,允许自由使用和分发。

Dark Reader 与"手工写覆盖"的思路完全不同:它不针对某个页面写死选择器,而是一个动态主题引擎——读取页面所有样式表,分析其中的颜色,在运行时重新计算并注入一套转换后的样式;页面 DOM 变化时它还会继续监听、继续处理。这意味着第三方插件动态渲染的内容也能被自动转换,我完全不需要知道那些类名叫什么。

用起来也极简,核心只有两个 API:

  • enable(theme) —— 传入主题参数,开始暗色转换;
  • disable() —— 关闭转换。

我在 darkreader-engine.ts 里定义了一套针对 Halo 后台调优过的参数:

const DARK_READER_THEME = {
  brightness: 100,
  contrast: 90,
  grayscale: 0,
  sepia: 0,
  darkSchemeBackgroundColor: '#181b20',
  darkSchemeTextColor: '#e8e6e3',
  scrollbarColor: '#3a3f4a',
  selectionColor: '#2f6f7a',
  styleSystemControls: true,
} as const

brightness / contrast 控制整体明暗与对比度——Halo 后台是浅色 UI,默认参数转换出来的深色偏灰,我把对比度调到 90 让观感更柔和;darkSchemeBackgroundColordarkSchemeTextColor 指定深色模式的底色与文字色,让页面不是生硬的黑白,而是 #181b20 这种偏蓝的暗底;scrollbarColor / selectionColor 处理滚动条与选中态;styleSystemControls: true 让表单控件这类"系统组件"也一起转换。

然后监听主题状态,深色时 enable、浅色时 disable:

watch(
  isDark,
  (dark) => {
    try {
      if (dark) {
        enable(DARK_READER_THEME)
      } else {
        disable()
      }
    } catch (error) {
      console.error('[dark-mode] Dark Reader 引擎异常', error)
    }
  },
  { immediate: true },
)

当然,Dark Reader 不是免费的午餐:它的样式注入是异步的,刷新瞬间存在极短的闪白窗口(FOUC);运行时重算也会比静态 CSS 多一点开销。这两点我在后面的章节会专门讲怎么缓解。但对后台这种页面规模,这点代价换来的收益是决定性的。

两条路线的对比

维度手工 CSS 覆盖Dark Reader 通用引擎
工作量每个页面逐个写选择器配置一次参数即可
覆盖范围只覆盖写过的类名自动处理全部样式表
第三方插件每装一个新插件都要补样式无需感知插件存在
动态内容需要自己维护 DOM 监听引擎内置监听
维护成本随插件数量线性增长基本固定
可控性完全可控只能调参数,细节不可控
性能零额外开销注入样式较多,少量开销

这张表基本就是我当初决策的全部依据。手工方案在"可控性"上占优,但"维护成本线性增长"这一条,对插件这种要交给别人用的东西来说是致命的——我不会知道用户装了哪些插件,也负担不起跟着每个第三方插件更新的工作量。选 Dark Reader,本质上是用一点性能和可控性,换取几乎为零的长期维护成本。这个选择后来被验证是对的:在 v1.0.4 彻底移除手工 CSS 覆盖之后,我再没为任何第三方插件页面补过一条样式。


第 3 章 第一版:把 Dark Reader 塞进 Halo

选定 Dark Reader 之后,第一个要拍板的问题是怎么把它装进项目。常规做法是在 ui/package.json 里写 "darkreader": "^4.9.129",让 pnpm 从 npm registry 拉取。我最终没走这条路,而是把它的构建产物直接拷进仓库,用本地文件依赖引用:

{
  "dependencies": {
    "darkreader": "file:../third-party/darkreader"
  }
}

原因有三个。一是版本可控:^4.9.129 这类范围版本会在某次 install 时悄悄升上去,而把产物入库之后,4.9.129 就变成了仓库的一部分,升级是一次显式的、看得见 diff 的操作。二是可审查:Dark Reader 要往用户浏览器里注入样式,属于"在别人机器上跑第三方代码",把它摊在仓库里,至少能 diff 出自己到底在跑什么。三是可复现:pnpm install 不再依赖 registry 的可用性和网络,克隆下来就能构建。这个选择后来被证明值回票价——做上架合规自查时,审查窗口直接 diff 一遍 third-party/darkreader/ 就能确认产物版本和 LICENSE 都在,省了很多口舌。

完整的上游包远不止这些——src/tests/tasks/、CHANGELOG,加起来接近 5 MB。我只需要"能 import、能构建"的最小集。third-party/darkreader/.gitignore 里走白名单模式:git 只跟踪构建真正需要的文件,多出来的上游源码留在本地做参考,不会污染提交历史。最终仓库里跟踪的是这 6 个文件(注:清单是 v1.0.5 收口后的形态——SHA256SUMSpackage.json 的裁剪都是那轮补上的,见 4.1):

文件用途
darkreader.jsCommonJS 主入口,单文件产物 300 多 KB
darkreader.mjsESM 入口,Vite 打包时实际用到
index.d.ts类型声明
package.json精简版,只留 name/version/main/module/types/license 等 7 个字段
LICENSEMIT 许可证,合规必须带
SHA256SUMS上面 5 个文件的哈希清单

原版 package.json 有一百多行,scripts、devDependencies、optionalDependencies 全是上游构建用的,对 file: 依赖毫无意义——裁成最小元数据后一眼能看全,lockfile 也跟着干净。目录瘦身从 v1.0.4 就开始了(那时先把完整上游包整理成"只留构建文件"),package.json 的裁剪和 SHA256SUMS 是 v1.0.5 按审查清单补上的,这一段到 4.1 还会再提。

版本固定了,还得防止"换了文件忘了更新"。v1.0.5 这轮补上了 SHA256SUMS,记录 5 个构建文件的 SHA-256:

0b126b96a0d76a1ff1b1c7cc7088c3ee1903dea2119abded2fb3913b340b63b1  darkreader.js
fbacd711bfd26b33b160881b50028a3e22e8f083b62ec727baebc0c4ad14b052  darkreader.mjs
5f7ebb5de2d74e011d87b86c2cc12048d667981599bdc9c527f98de9e0503a26  package.json
ab1ba9694b242323d15ed4839457c14fe04891966568413d5cd5eb78156dae1f  index.d.ts
3f018657498dc4a805af22cc872688f1cd699bbe0045c82d29aeb0c8e65843e5  LICENSE

Linux / macOS 一条命令校验:sha256sum -c SHA256SUMS;Windows 用 PowerShell 的 Get-FileHash -Algorithm SHA256 逐文件对比。校验说明写进 README,约定"升级 Dark Reader 构建文件后必须重新生成并校验"。哈希清单把"文件名 + 内容"绑在一起——file: 依赖是目录引用,目录里放了什么它就打包什么,如果哪天有人塞进一个同名但内容不同的文件,光看文件名根本发现不了,升级必须是显式动作。

第一版跑起来之后,陆续暴露了三个运行时体验问题:FOUC 闪白、刷新后偏好丢失、多标签页不同步。其中"刷新丢偏好"在状态层搭好 localStorage 持久化之后就解决了;FOUC 的缓解(同步 color-scheme)和多标签页同步(监听 storage 事件)则不是第一版解决的——它们连同 useDarkMode 的单元测试,一起在 v1.0.5 集中落地,细节见 4.1。

第一版真正定下来的是状态层的骨架:一份模块级单例状态、light/dark/auto 三种模式、localStorage 持久化——读取只认三个合法值、其余一律回退 auto,读写都包了 try/catch(localStorage 在隐私模式等场景下可能被禁用,抛异常也不能影响页面渲染),刷新后第一帧就是上次的选择。

状态层到这里闭环:一份单例状态、三种模式、持久化。跨标签同步和 color-scheme 兜底这两处稳定性细节,是 v1.0.5 补齐的,见 4.1。接下来四轮迭代,做的全是"收敛",不是"加功能"。

第 4 章 四轮迭代:细节打磨

v1.0.3 引入引擎、v1.0.4 收口成纯 Dark Reader 策略之后,我没有急着进入"加功能"模式。后面四个版本——v1.0.5 到 v1.0.8——主线其实是同一件事:让这个插件看起来不像外来户,而像 Halo 后台原生的一部分。再往后的 v1.0.9 是上架前的收尾版(替换最终 Logo、README 补截图预览、清理内部文档),第 9 章会交代。

4.1 v1.0.5 稳定性与可访问性

v1.0.4 之后我把代码交给审查窗口过了一遍,拿回一张 P2/P3 待办清单,v1.0.5 就是按这份清单修的。审查窗口和开发窗口是分开的:审查只读不改,把结论写成交接单,开发照着执行——这个双窗口流程第 5 章会专门讲,这里先看它产出的具体修复。

最影响体验的是 FOUC:深色用户刷新控制台先闪白。Dark Reader 的 enable() 是异步的,要先分析页面全部样式、再注入自己的 CSS,中间那段窗口,页面保持浅色。彻底消除做不到——注入间隙是引擎机制决定的——但可以先把浏览器能控制的部分同步翻过来:useDarkMode 把状态同步到 <html> 时同时写 root.style.colorScheme,让滚动条、表单控件这些原生外观先变深色,把闪烁窗口尽量压短。这个属性只影响浏览器自身的渲染外观,不改变页面布局;代价是注入间隙依然存在,只是从"整页白屏"缩小到"极短一段"——README 里如实写了这个限制,没有夸大。落地的代码就这几行,写在 applyHtmlAttribute 里:

function applyHtmlAttribute(isDark: boolean): void {
  const root = document.documentElement
  if (isDark) {
    root.setAttribute('data-halo-theme', 'dark')
  } else {
    root.removeAttribute('data-halo-theme')
  }
  root.style.colorScheme = isDark ? 'dark' : 'light'
}

data-halo-themecolor-scheme 同步写:前者是给外部脚本和验证工具读的标记,后者才是真正缓解闪烁的那一手。

顺手把多标签页同步也补了:后台开两个标签,一个切到深色,另一个还是浅色,体验很割裂。storage 事件天然就是干这个的——同源其他标签页改 localStorage 时会触发,监听它、把 newValue 合进单例状态即可:

// 模块级单例 — 所有组件共享同一份状态
const theme = ref<ThemeMode>(loadPersistedTheme())

// 监听 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
  }
})

这里有个顺带的设计:<html> 上的 data-halo-theme 属性在纯 Dark Reader 策略下已经没有 CSS 消费方了,但我保留了它——它是兼容性遗留标记,留给外部脚本和验证工具用,代码注释里写明了这一点,避免后来的人误以为它是渲染依赖。

单测是这轮从零补上的。此前 pnpm test:unit 跑的是 vitest --passWithNoTests,恒绿,等于没测。我给 useDarkMode 写了 8 个用例,覆盖默认 auto 跟随系统、toggle() 在三种模式间的迁移、setTheme() 持久化、非法 localStorage 回退、storage 事件同步与忽略非法值,同时摘掉 --passWithNoTests——从这以后测试再也不能静默通过。每个用例开头用 vi.resetModules() 重新加载模块并清空 localStorage,保证模块级单例不跨用例泄漏。跨标签同步这条长这样:

it('storage 事件会同步其他标签页的主题', () => {
  const { theme, setTheme } = useDarkMode()
  setTheme('light')
  window.dispatchEvent(
    new StorageEvent('storage', { key: STORAGE_KEY, newValue: 'dark' }),
  )
  expect(theme.value).toBe('dark')
})

可访问性两处:侧边栏切换按钮从 div + @click 改成原生 <button type="button"> 并加 aria-pressed;设置页三个选项补上 role="radiogroup" + role="radio" 语义和键盘导航。后端 System.out.println 换成 Lombok @Slf4jlog.info,日志不再裸打 stdout。plugin.yamlspec.enabledtrue 改成 false——Halo 的惯例是安装后由用户手动启用,插件不该默认自己开。vendored 的 package.json 也是这轮裁掉一百多行,并补上 SHA256SUMS——第 3 章说过,目录瘦身从 v1.0.4 开始,到这里才彻底收口。

回归跑到全绿:vitest 8/8、vue-tsc、prettier、vite build、Gradle build,运行时验证脚本 verify-toggle.py 的断言也全部通过。

4.2 v1.0.6 产品决策:入口放在哪

v1.0.5 之后后台出现了两个"深色模式"入口:侧边栏底部注入的一键切换按钮,和「偏好设置 → 深色模式」设置页。功能重复,而且我日常用下来,底部那个按钮几乎没点过——要精细控制还是得进设置页。

真正让我决定动手的是菜单分组。当时设置页的 group 写的是字符串 '偏好设置',这不是 Halo 的标准分组 key,Halo 找不到对应的 i18n 翻译,就直接把这个字符串当分组标题渲染出来,侧边栏凭空多了一个只有一项的分组。翻了 Halo 源码,官方分组 key 一共五个:dashboardcontentinterfacesystemtool,其中 interface 就是「外观」——主题、菜单、插件都挂在这里。菜单本身是静态生成的:use-route-menu-generator 启动时从 router.getRoutes()meta.menu 一次性生成,菜单名是静态字符串,官方 RoutesMenu 里就是 title={t(item.name, item.name)},没有动态文案机制。菜单是入口,不是状态开关。

结论就清楚了:把 group 改成 'interface',同时删掉侧边栏按钮——injector.tsThemeToggle.vue 两个文件整个移除,设置页成为唯一入口。连带改了验证脚本:verify-toggle.py 之前依赖点击 .theme-toggle,按钮没了脚本就废了,我把它重写成直接驱动 localStorage 并派发 StorageEvent,再断言 data-halo-theme、localStorage、Dark Reader 注入与 color-scheme 四向翻转,还加了一条"侧边栏注入按钮已移除: PASS"。

移过去之后有个细节要交代:菜单项进入页面后保持高亮(官方菜单用 active={route.matched.includes(item.path)} 判断),这是平台一致行为;菜单名保持静态「深色模式」,不随主题变化,当前模式由设置页内的「当前生效」展示。一开始我也想过让菜单文案跟着状态走,查完源码发现官方机制不支持动态文案,强行 DOM hack 换来的只会是脆弱代码,不值得。

4.3 v1.0.7 少即是多:移除键盘操作

v1.0.5 我在设置页加了一套完整的 radiogroup 键盘导航:方向键切换选项、roving tabindex、Home/End、焦点环,当时觉得这是"可访问性做到位"。v1.0.6 把入口移进官方「外观」分组后,我重新审视了这段代码:Halo 后台全站没有用方向键做特殊交互的惯例,方向键在控制台里只负责滚动,用户不会预期按 ↑↓ 会改变主题选择——这套交互是我自己发明的"惯例"。结论是把它们全删了,回到原生 <button>:Tab 聚焦、Enter/Space 激活是浏览器内置能力,不需要插件维护,也不会失效,可访问性的底线并没有塌。

这轮的教训我记了很久:功能贴不贴合生态习惯,比功能多不多更重要。单独实现一套"更完整"的交互,在这里不是加分项,是跟整个后台不一致的噪音。(这个决定从提出到落地是怎么走完双窗口流程的,第 5 章用同一个例子展开。)

4.4 v1.0.8 官方化:对齐设计语言

入口归位之后,设置页本身还是"自绘"的:顶部自定义 h1 + p,卡片自己画,和官方页面放一起一眼就能看出不是一家人。v1.0.8 我把 @halo-dev/components 的组件库全景翻了一遍(21 类组件),挑出设置页真正用得上的四个:VPageHeader(顶部标题栏)、VCard(内容卡片)、VDescription / VDescriptionItem(键值展示)、VTag(状态标签)。官方标题栏组件结构是 .page-header,带 title 和 icon/actions 两个插槽,插件页直接照抄官方 PluginList.vue 的写法:VPageHeader 包在页面顶部,内容区用 m-0 md:m-4 容器包 VCard

<VPageHeader title="深色模式设置">
  <template #icon>
    <IconPalette />
  </template>
</VPageHeader>

<div class="m-0 md:m-4">
  <VCard :body-class="['!p-0']">
    <div class="p-4">
      <VDescription>
        <VDescriptionItem label="当前生效">
          <VTag>{{ currentEffectiveMode }}</VTag>
        </VDescriptionItem>
      </VDescription>

      <div class="dark-mode-settings__options">
        <button
          v-for="option in modeOptions"
          :key="option.value"
          type="button"
          class="dark-mode-settings__option"
          :class="{ 'is-active': theme === option.value }"
          @click="setTheme(option.value)"
        >
          <div class="dark-mode-settings__option-label">{{ option.label }}</div>
          <div class="dark-mode-settings__option-desc">{{ option.description }}</div>
        </button>
      </div>
    </div>
  </VCard>
</div>

有两点值得记。一是官方组件库没有 radio / radio group 单选组件,三个模式选项只能继续用语义化 <button>,硬套不存在的组件只会更糟。二是顺带把 variables.css 从 43 个变量砍到 6 个——官方组件自带样式,插件只需保留自己那几个选项按钮真正用到的变量,--halo-text-primary--halo-border-base--halo-accent-primary 这些。VCard 上的 :body-class="['!p-0']" 是把卡片默认内边距去掉,间距交给内容区自己控制,和官方列表页的做法一致。「当前生效」由 VDescriptionItem 的键值行 + VTag 渲染,auto 模式下会显示"深色(跟随系统)"或"浅色(跟随系统)",当前到底生效什么一眼就能看清。改造后设置页与官方页面同构,深色模式下标题栏和卡片由 Dark Reader 统一转换,反而不用再为它们单独适配。


第 5 章 AI 双窗口协作:我的开发工作流

这个项目名义上是「我一个人开发」,但真正跑起来的时候,工作区里其实住着两个角色:审查窗口开发窗口。它们不是两个账号,而是同一套仓库里的两种工作模式——审查窗口不碰源码,产出的是调研结论、审查报告和交接文档;开发窗口不重复调研,只按交接单执行改动,改完交给对方复查。

一开始这么分工并不是计划好的。v1.0.3 引入 Dark Reader 之后,我发现自己陷入一个怪圈:改代码时全神贯注,回头审视自己刚写的东西却总觉得"没什么问题",而真正的问题往往藏在我没注意的地方。审查报告里那批 P2 问题(FOUC 闪白、多标签页不同步、前端零单元测试、可访问性缺失)就是这么漏掉的。把「写代码的我」和「审查代码的我」拆开之后,审查才真正有了一双新鲜的眼睛——人很难挑出自己刚写的东西的毛病,但换一个身份、隔一层文档,就能冷静下来。

两个窗口,一条传送带

两个窗口之间靠什么协作?文档docs/ 目录就是传送带:

  • 需求来了,审查窗口先写调研/决策文档;
  • 要动手了,写一份交接单(docs/plan-*.mdfix-*.mdremove-*.md),写明背景、决策依据、现状代码位置、修改方案、验证命令;
  • 开发窗口照单执行,改完提交;
  • 审查窗口再写复查文档(review-*.mdrecheck-*.md)逐项核对。

分工还有一层隐形收益:审查窗口会先自己跑一遍验证,再下结论。比如 v1.0.5 复查时,审查窗口实测了 vitest(8/8 通过)、vue-tsc、vite build,甚至代跑 prettier --write 修格式化问题,最后在复查文档里注明「开发窗口无需重复验证」。开发窗口于是只做它没做完的收尾——提交、处理遗留项,各干各的,不重复劳动。复查也不是凭印象:初审报告列了 12 个 P2/P3 项,复查报告逐项标注 ✅ / ⚠️ / ➖,哪项做了、哪项需要用户决策,一眼看清。

一个典型迭代的闭环

通过
打回
需求
调研报告
交接单
docs/plan-*.md
开发窗口执行改动
构建 + 单测验证
审查窗口复查
用户验收
提交版本

闭环里有个容易被忽略的细节:交接单必须写到能直接执行的程度。行号、涉及文件、推荐方案、备选方案、验证命令,一样都不能少。否则开发窗口执行到一半还要回头重新调研,闭环就断了。

真实例子:移除键盘操作

举一个完整的流转——就用上一节讲过的"移除键盘操作"。功能层面为什么删,4.3 已经说透,这里只看它在双窗口流程里是怎么流转的。

v1.0.5 时,审查报告 P2-6 指出设置页的三个选项是 div + @click,没有键盘语义,开发窗口按建议实现了 radiogroup 键盘导航(方向键切换、roving tabindex、aria-checked、焦点环);到了 v1.0.6 入口移进官方「外观」分组之后,我重新审视这段代码,决定反过来把它删掉。这个决定从提出到落地,全程走文档:审查窗口把决策依据写进 docs/remove-keyboard-support-2026-08-08.md,标到具体行号(SettingsView.vue 里的 onKeydownfocusOptionsetOptionRefactiveIndex),给出推荐方案 A(删掉键盘导航,保留原生 <button>,浏览器内置的 Tab 聚焦 + Enter 激活已够用)和备选方案 B(不推荐),还特别注明"删除不涉及 useDarkMode / darkreader-engine,状态管理与主题切换不受影响"。开发窗口拿到交接单,不需要重新理解状态管理,直接按行号删、精简 import、清掉 README 里"支持键盘操作"的过时描述,跑完 pnpm build / type-check / lint / test:unitgradlew build 提交(96dd1ef),审查窗口再对照交接单逐项确认。

这套流程跑下来,最大的收获是可回溯:每个"当初为什么这么做"的决策都能在 docs/ 里找到依据。版本从 1.0.3 一路迭代到 1.0.9,回头补文档、写这篇长文,靠的全是这些交接单。

第 6 章 上架前夜:调研官方应用市场

v1.0.8 把设置页对齐官方设计语言后,功能侧基本定型,我开始认真考虑上架。在此之前,我对 Halo 应用市场的印象停留在旧流程:写好插件,发 PR 请官方代发。所以第一步不是注册账号,而是先去官方文档把流程搞清楚。

两份核心文档

docs.halo.run 找到两份核心文档:《发布应用》和《应用市场审核指南》(我读到的是 v2026.06.25 版本)。读完第一份就发现认知需要更新:应用市场早就支持开发者自助上架了,链路是「开发者入驻 → 创建应用 → 创建版本 → 提交审核」,不再需要发 PR 等官方处理。审核指南也不是空泛的"请遵守社区规范",而是带条款编号的硬性要求——安全(1.x)、应用完整性(2.x)、收费(3.x)、功能与体验(4.x)、合规与隐私(5.x),开头还有一份「提交前检查」13 项清单。这些条款编号不是摆设:文档明确写了被拒后按审核意见引用的编号(比如「请参考 2.1」)逐项整改再重提,整改和申诉都有明确坐标,不用猜。自助上架的另一个好处是后续版本完全自己掌控:审核通过后,在应用管理页面直接发布新版本,配上 GitHub Release 里的 JAR 制品,发布节奏自己定。

提前准备:开源优先的回报

对照《发布应用》第 1 步「发布前准备」盘点,我发现自己大部分硬条件其实早就具备:

准备项状态
公开代码仓库(GitHub)✅ 项目一开始就是 public
GPL-3.0 LICENSE✅ 仓库根目录已有
plugin.yaml 完整元数据(homepage / issues / license)✅ 上架前补齐并指向真实仓库
GitHub Release + JAR 制品✅ v1.0.5 起每个版本都带 jar(最新到 v1.0.9)
README(安装 / 使用 / 构建 / 更新日志)✅ 按官方插件 README 风格重写过

因为开源优先(选 GPL-3.0、公开仓库、Release 带制品)是我从一开始就坚持的做法,上架前的"准备"反而成了最省心的一步。表格之外,还有两项当时没就绪:自定义 Logo 和截图。审核指南明确要求 Logo 不得使用模板默认图标、截图要反映真实功能——这两项直接引出了后面的版权插曲。另外,我把「提交前检查」13 项和正文条款拉了一张覆盖度矩阵,逐条对着项目打勾找缺口,这就是下一章 12 项清单的由来。

官方入口,读者可以直接收藏:

第 7 章 审核自查:12 项检查清单

拿到审核指南后,我没有直接照着 13 项「提交前检查」逐条打勾——那只是起点。审查窗口把它和正文条款(1.x~5.x)拼在一起,对着项目实际状态做了一次覆盖度审查(docs/review-appstore-coverage-2026-08-08.md),结果发现 7 处缺口:没要求"提交前全流程实测"、版本说明缺「升级说明」、缺安全自查声明、没确认「免费」类型、Logo 素材授权待确认、支持链接没逐个点过、缺权限最小化声明。

把缺的补上、和官方 13 项合并去重之后,最终落到一张 12 项的可勾选清单上(完整版在 docs/checklist-appstore-final-2026-08-09.md,12/12 全部通过):

#检查项本项目情况
1在声明的 Halo 版本中全流程实测:安装→启用→配置→使用→禁用→卸载Halo 2.25.0 实测 6 步全部通过
2自定义 Logo,非模板默认图标自有手绘图标,256×256 PNG
3应用截图(官方建议 1~3 张)实际 4 张:1 张设置界面 + 3 张深色效果
4plugin.yaml 的 homepage / issues / license 指向真实仓库四个字段齐全
5版本号 SemVer、兼容范围合法且无首尾空白1.0.9 / >=2.25.0
6版本说明含功能概述、依赖要求、升级说明已定稿,升级说明明确"偏好自动保留"
7安全与权限声明已定稿:纯前端、无网络请求、无远程代码加载、无权限申请
8按免费应用提交费用类型选「免费」
9Logo 素材来源授权清晰自有创作,无第三方授权问题
10支持链接可访问、能响应反馈GitHub 仓库 public,Issues/主页可访问
11草稿状态,仅一个初始草稿版本提交前只建 1 个草稿版本(1.0.9)
12至少一个上传完成的制品(JAR)plugin-dark-mode-1.0.9.jar 已挂载 Release

其中几项和本项目强相关,展开说:

后台页面风格一致性(4.5)。审核指南要求后台插件页面与 Halo 后台保持风格一致——这正是 v1.0.8「官方化」改造的动机。如果没做这步,上架自查就会卡在"设置页是自定义样式"。改造后设置页用 VPageHeader / VCard / VDescription / VTag 官方组件重写,CSS 变量从 40+ 精简到 6 个,这步从"锦上添花"变成了"上架刚需"。

Dark Reader 的「重复扫描」披露(2.4 / 4.3.5)。审核指南 2.4 关注性能与稳定性,其中就包含"重复扫描"一类问题;4.3.5 要求构建、依赖与制品说明清晰。Dark Reader 是动态样式引擎,会持续扫描 DOM 注入样式,属于"需要主动披露"的实现。我在安全与权限声明里明确写了"内置 Dark Reader(MIT License)动态样式引擎,仅在 Halo 后台管理页面运行",并连同"无网络请求、无远程代码加载"一并声明,把被误判为"重复扫描 / 隐藏执行"的风险降到最低。

AI 辅助生成自查(4.6)。审核指南要求:AI 辅助生成的内容要完成审查、验证、安全自查与生态适配。我的整个开发过程都有 AI 参与——调研、代码起草、审查辅助都是。所以我在自查里如实交代范围:AI 负责调研和起草,最终代码经过人工审查、8 个单元测试、构建验证和真实环境部署验证。不夸大、不隐瞒,这条过了。

权限最小化(4.3.1)。审核指南要求权限保持最小必要。这个插件是纯前端实现:没有后端接口、没有网络请求、没有远程代码加载、没有自定义权限申请,偏好只存在浏览器本地(localStorage)。这些在提交说明里一条条写清楚,同时覆盖了安全条款(提交前 #7 / 1.4 / 1.5)——一句话解决两个检查项。

免费应用(3.1)。审核指南目前只支持免费应用提交。本插件本来就是开源的,创建版本时费用类型选「免费」,无付费依赖,这条毫无压力。

应用截图(4 张)。指南建议 1~3 张,我最终按实际功能准备了 4 张:1 张设置界面 + 3 张后台深色效果。设置界面这张能直接展示插件怎么用,多备的这一张我保留了下来。

12 项全部通过后,剩下的就是账号侧操作:开发者入驻 → 创建应用 → 创建版本 → 提交审核。接下来两章,讲提交时踩到的两个坑——一个是 AI 生成的 Logo 被我自己否决,一个是首次提交的版本管理细节。


第 8 章 版权教训:AI 生成的 Logo 不能用

差点拿 AI 生成的 Logo 直接上架

准备上架素材的时候,应用资料里需要一张 Logo。我的第一反应是:这还不简单?打开豆包,输入"Halo 后台深色模式插件,月亮图标,一半亮一半暗,扁平风格",几秒钟出来四张图,挑一张顺眼的下载,裁剪成 256×256,感觉上架这件事已经完成了大半。

好在把 Logo 填进应用资料之前,我多问了自己一句:AI 生成的图片,能拿去当应用商店的 Logo 吗?这一查,冷汗就下来了。

豆包用户协议:当前生效协议明确禁止商用

我去翻豆包的用户协议(https://www.doubao.com/legal/terms),越看越觉得侥幸。当前生效的豆包用户协议里,对生成内容商用限制的表述很明确:第 2.2、2.11、6.1 条都对生成内容的使用范围做了约束,禁止将生成内容用于商业用途。第 9.1 条虽然写了"生成内容的权益归属于用户",但它只解决所有权问题——生成物归你,不等于你有权拿它去商用。条款号以我当时读到的「当前生效协议」为准,协议文本随时可能更新,引用前建议到上面链接复核一次。

用一句话讲透这个坑:所有权不等于使用授权。9.1 回答的是"这是你的东西",而"能不能拿去宣传、拿去卖"是另一套授权规则管的事,那套规则里明确写着禁止商用。我后来跟朋友聊,发现很多人都默认"AI 生成的就是我的,想怎么用都行",这正是最容易踩雷的地方。

需要说明的是,我说的是"当前生效的豆包协议禁止商用",不是"所有 AI 工具都禁止"。不同平台差别很大,有的协议明确允许商用甚至完全开放。所以这条教训不能推而广之,但"用生成内容之前先查协议"这件事,对任何平台都成立。

免费应用 ≠ 非商业用途

还有一个念头在脑子里盘旋:我的插件是免费的,这算商业用途吗?算。平台判断"商业用途"看的是使用场景,不是收不收费。应用商店的 Logo 属于应用的宣传、推广素材,它处在上架、分发、展示这条链路里,即使应用完全免费,也属于商业使用场景。我在"免费应该没事吧"和"还是查清楚"之间来回犹豫过,查完协议之后彻底死了这条心。

自己动手:手绘图标

最后我花了半天,用绘图工具一笔一笔画了个图标:一轮月亮,一半亮一半暗,跟插件"深色/浅色切换"的定位直接对应,导出 256×256 PNG;发布前我又对着实际图标人工目检核对过图案描述,确认与最终 Logo 一致。效果比 AI 生成的那张更贴题,版权也干干净净——从构图到成稿都是我的原创,不存在授权争议。这个手绘图标后来也成了应用资料里的正式 Logo。

这件事还让我重新读了一遍审核指南里两条相关要求:4.6 规定 AI 辅助生成的内容需要开发者自查,审查、验证、安全与生态适配都要过一遍,不能拿 AI 的输出直接交付;5.2 针对知识产权,要求 Logo、图片、字体、图标等素材的来源与授权清晰。我的 Logo 差点就撞在 5.2 的枪口上。代码可以让 AI 帮忙写,但"帮忙写"之后要由我逐行审查、对结果负责;素材同理,用之前先解决授权问题。

第 9 章 提交上架:从草稿到审核

提交的完整顺序

素材和自查都搞定之后,剩下的就是账号侧的操作,一步步走:

  1. 开发者入驻:在 Halo 官网注册账号并完成邮箱验证,然后到 https://www.halo.run/uc/developer/join 提交入驻申请,填写开发者资料和个人介绍,同意开发者协议。我以个人开发者身份提交,介绍里写了真实身份和项目背景,并确保与支持链接口径一致——审核指南 5.4 要求开发者信息真实、可联系。
  2. 创建应用:入驻通过后,在应用管理页面创建应用:类型选"插件",名称"深色模式",填简介,上传手绘 Logo 和 4 张截图,配 README、许可证(GPL-3.0)和外部链接。
  3. 创建版本:版本号 1.0.9,兼容范围 >=2.25.0,版本说明粘贴定稿文案,上传 plugin-dark-mode-1.0.9.jar。
  4. 提交审核:费用类型选"免费",粘贴安全与权限声明,确认提交。

关于版本号:为什么提交的是 1.0.9

功能迭代讲到 v1.0.8 就停了,这里提交上架的却是 1.0.9,需要交代一句。v1.0.8 把设置页官方化之后,我做了最后一步收尾:替换成最终自定义 Logo、README 补上设置页与应用效果截图预览、移除仓库内的内部审查/计划文档、提交 gradle wrapper jar 让全新克隆可以直接构建,版本号升到 1.0.9——这就是提交上架的正式版,GitHub 上的 Release 制品也同步为 plugin-dark-mode-1.0.9.jar。一句话总结:功能迭代的最后四轮是 v1.0.5~v1.0.8,1.0.9 是替换最终 Logo 后提交上架的收尾版。

两个容易踩的坑

第一个坑:首次提交前只建一个草稿版本。 审核指南 2.1 要求,首次提交审核前应用保持草稿状态,且只保留一个初始草稿版本。我最初的想法是"把 1.0.5 到 1.0.9 全部建上去,显得正式",调研完才发现这是错的——多个草稿版本不仅没用,反而可能拖慢首次审核。老老实实只建 1.0.9 一个。

第二个坑:审核快照机制。 提交审核后,平台会冻结一份审核快照,审核期间不能修改应用资料、版本说明或制品;真要改,只能取消审核,或者等驳回后重新提交。这逼着我在提交前把文案彻底定稿——"先提交,后面再补"这条路是走不通的。

定稿文案(可直接复用)

版本说明:

深色模式 1.0.9

为 Halo 博客后台管理面板提供深色/浅色模式切换,支持浅色、深色、跟随系统三种模式,内置 Dark Reader 通用暗色引擎,覆盖官方及第三方插件页面。

依赖要求:Halo >= 2.25.0

升级说明:从旧版本升级无特殊要求,无需额外操作,已保存的主题偏好会自动保留。

安全与权限声明:

本插件为纯前端实现,无后端接口、无网络请求、无远程代码加载、无自定义权限申请,不收集或上传任何用户数据;偏好仅保存在浏览器本地(localStorage)。内置 Dark Reader(MIT License)动态样式引擎,仅在 Halo 后台管理页面运行,用于暗色样式转换。

点下"提交审核"那一刻,插件正式进入待审核状态。

第 10 章 总结与建议

三条核心经验

回看整个过程,最想留下的经验有三条。

技术选型要看长期维护成本。 当初没有选"手工 CSS 覆盖",而是投入精力引入 Dark Reader 并做好 vendoring,是因为我知道这个插件要长期维护:Halo 后台用 UnoCSS 生成样式类(既有按语义命名的 BEM 类,也有工具生成的哈希类),第三方插件页面又多,逐页覆盖注定是一场打不完的地鼠游戏。短期看起来"轻"的方案,长期往往最重。

功能要贴合生态惯例。 四轮迭代里我砍掉过不少"自认为加分"的东西:侧边栏按钮、键盘方向键导航,单看都是好功能,但不是 Halo 的惯例。插件活在生态里,尊重官方信息架构和交互习惯,比"做得更多"更重要。

合规与版权意识要前置。 AI 生成的 Logo 差点让我带着授权隐患上架,好在提交前查了协议。所有权不等于使用授权,免费不等于非商业用途——这些常识应该在项目启动时就刻进脑子里,而不是等审核被打回来再学。

给 Halo 插件开发者的五条建议

  1. 先把 plugin.yaml 的元数据配全(homepage、issues、license、logo),这是上架的地基。
  2. 引入第三方库时做 vendoring 和完整性校验,别让构建依赖裸奔在公共仓库上。
  3. 设置页优先用官方组件库(VPageHeader、VCard 这类),和后台长得一样才叫"融入"。
  4. 上架前对着审核指南逐条自查,重点看 AI 辅助生成(4.6)和知识产权(5.2)这两条容易被忽略的。
  5. 首次提交只建一个草稿版本,提交后别改,安静等审核结果。

彩蛋

项目已开源:https://github.com/LHY0125/halo-dark-mode-plugin,GPL-3.0 协议,欢迎 Star、提 Issue 或直接提 PR。

关于应用市场的状态,说句实话:截至本文发布,插件已提交审核,正处于"待审核"状态,还没有"已上架"这个结果。等审核通过或者被打回,我会在后续文章里继续记录。这也是我最想分享的一点——把开发过程写下来,最大的价值不是展示一个完美的结果,而是让读者看到每一步真实的取舍与代价。