From d95d17234e2b44c137144f1d3a3d07de707310ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E8=88=AA=E5=AE=87?= <3364451258@qq.com> Date: Sat, 8 Aug 2026 18:15:11 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8C=89=E5=AE=A1=E6=9F=A5=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E4=BF=AE=E5=A4=8D=20P2/P3=20=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E5=B9=B6=E5=8D=87=E7=BA=A7=201.0.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + README.md | 25 +- docs/fix-plan-2026-08-08.md | 151 ++++++++++++ docs/review-2026-08-08.md | 81 +++++++ gradle.properties | 2 +- scripts/README.md | 33 +-- .../run/halo/darkmode/DarkModePlugin.java | 10 +- src/main/resources/plugin.yaml | 2 +- third-party/darkreader/SHA256SUMS | 5 + third-party/darkreader/package.json | 114 +--------- ui/package.json | 2 +- ui/pnpm-lock.yaml | 29 +-- ui/src/components/ThemeToggle.vue | 23 +- .../composables/__tests__/useDarkMode.spec.ts | 106 +++++++++ ui/src/composables/useDarkMode.ts | 23 +- ui/src/darkreader-engine.ts | 1 + ui/src/styles/variables.css | 1 + ui/src/views/SettingsView.vue | 27 ++- workplace/login_wait.py | 46 ---- workplace/scan_dark.py | 214 ------------------ 20 files changed, 457 insertions(+), 439 deletions(-) create mode 100644 docs/fix-plan-2026-08-08.md create mode 100644 docs/review-2026-08-08.md create mode 100644 third-party/darkreader/SHA256SUMS create mode 100644 ui/src/composables/__tests__/useDarkMode.spec.ts delete mode 100644 workplace/login_wait.py delete mode 100644 workplace/scan_dark.py diff --git a/.gitignore b/.gitignore index 552b0a5..2249a9b 100644 --- a/.gitignore +++ b/.gitignore @@ -32,5 +32,6 @@ third-party/darkreader/* !third-party/darkreader/darkreader.mjs !third-party/darkreader/index.d.ts !third-party/darkreader/LICENSE +!third-party/darkreader/SHA256SUMS __pycache__/ *.pyc diff --git a/README.md b/README.md index 33d1e02..1ee4544 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,11 @@ 安装并启用后,可以直接点击侧边栏按钮在深色和浅色模式之间切换,也可以进入设置页面选择固定模式: -| 配置项 | 可选值 | 说明 | -| --- | --- | --- | -| 主题模式 | `light` | 始终使用浅色模式 | -| 主题模式 | `dark` | 始终使用深色模式 | -| 主题模式 | `auto` | 跟随系统外观自动切换 | +| 配置项 | 可选值 | 说明 | +| -------- | --------- | -------------------- | +| 主题模式 | `light` | 始终使用浅色模式 | +| 主题模式 | `dark` | 始终使用深色模式 | +| 主题模式 | `auto` | 跟随系统外观自动切换 | ## 技术原理 @@ -35,10 +35,14 @@ - 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` 对比。 ## 开发环境 @@ -127,6 +131,15 @@ pnpm build # 生产构建 ## 更新日志 +### v1.0.5 + +- 同步设置 `color-scheme`,缓解深色模式刷新闪烁 +- 增加多标签页主题同步与 `useDarkMode` 单元测试 +- 侧边栏切换按钮与设置选项支持键盘操作 +- 后端日志改用 Lombok `@Slf4j` +- 裁剪 vendored `package.json`,新增 Dark Reader `SHA256SUMS` 完整性校验 +- `plugin.yaml` 改为安装后由用户手动启用 + ### v1.0.4 - 改为纯 Dark Reader 引擎,移除手工 CSS 覆盖 @@ -141,4 +154,4 @@ pnpm build # 生产构建 ## 许可证 -[GPL-3.0](./LICENSE) © LHY \ No newline at end of file +[GPL-3.0](./LICENSE) © LHY diff --git a/docs/fix-plan-2026-08-08.md b/docs/fix-plan-2026-08-08.md new file mode 100644 index 0000000..d0a1d81 --- /dev/null +++ b/docs/fix-plan-2026-08-08.md @@ -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` 改为 ` @@ -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); } - + \ No newline at end of file diff --git a/workplace/login_wait.py b/workplace/login_wait.py deleted file mode 100644 index 30f428d..0000000 --- a/workplace/login_wait.py +++ /dev/null @@ -1,46 +0,0 @@ -# 启动带持久化配置的 Edge 窗口,等待用户登录 Halo 后台。 -# 登录成功后脚本自动退出,会话会保留在 pw-profile 目录里供后续扫描使用。 -import pathlib -import sys -import time - -from playwright.sync_api import sync_playwright - -PROFILE = pathlib.Path(__file__).parent / "pw-profile" -LOGIN_URL = "https://blog.liuhangyv.top/console/login" -TIMEOUT_S = 280 - - -def main() -> int: - with sync_playwright() as p: - ctx = p.chromium.launch_persistent_context( - str(PROFILE), - channel="msedge", - headless=False, - viewport={"width": 1600, "height": 950}, - ) - page = ctx.pages[0] if ctx.pages else ctx.new_page() - page.goto(LOGIN_URL) - print("浏览器窗口已打开,请在其中登录 Halo 后台...", flush=True) - - deadline = time.time() + TIMEOUT_S - while time.time() < deadline: - try: - url = page.url - except Exception: - print("检测到窗口被关闭", flush=True) - return 2 - if "/console" in url and "/login" not in url: - time.sleep(3) # 等待会话 cookie 写入磁盘 - print(f"检测到登录成功: {url}", flush=True) - ctx.close() - return 0 - time.sleep(2) - - print("等待超时,未检测到登录", flush=True) - ctx.close() - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/workplace/scan_dark.py b/workplace/scan_dark.py deleted file mode 100644 index d2ff552..0000000 --- a/workplace/scan_dark.py +++ /dev/null @@ -1,214 +0,0 @@ -# Halo 后台暗色模式残留扫描器 -# 自动发现侧边栏全部 /console 路由,逐页扫描浅色背景 / 深色文字残留并截图。 -import json -import pathlib -import re -import sys -import time - -from playwright.sync_api import sync_playwright - -BASE = "https://blog.liuhangyv.top" -ROOT = pathlib.Path(__file__).parent -PROFILE = ROOT / "pw-profile" -SHOTS = ROOT / "shots" -OUT = ROOT / "scan-results.json" - -# 在每个页面加载前强制插件进入深色模式 -INIT_JS = """ -try { localStorage.setItem('halo-dark-mode-theme', 'dark'); } catch(e) {} -document.documentElement.setAttribute('data-halo-theme', 'dark'); -""" - -# 页面内扫描:浅色背景(RGB 均 >235 且不透明)、深色文字(RGB 均 <70) -SCAN_JS = r""" -() => { - const results = []; - const seen = new Set(); - const isVisible = (el) => { - const cs = getComputedStyle(el); - return cs.display !== 'none' && cs.visibility !== 'hidden' && +cs.opacity > 0.05; - }; - const shortPath = (el) => { - const parts = []; - let cur = el; - for (let i = 0; i < 5 && cur && cur !== document.body; i++) { - let p = cur.tagName.toLowerCase(); - if (cur.id) p += '#' + cur.id; - else if (typeof cur.className === 'string' && cur.className.trim()) { - p += '.' + cur.className.trim().split(/\s+/).slice(0, 2).join('.'); - } - parts.unshift(p); - cur = cur.parentElement; - } - return parts.join(' > '); - }; - document.querySelectorAll('body *').forEach(el => { - if (!isVisible(el)) return; - const r = el.getBoundingClientRect(); - if (r.width < 50 || r.height < 20) return; - const cs = getComputedStyle(el); - const issues = []; - const bg = cs.backgroundColor.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/); - if (bg && bg[4] !== '0' && +bg[1] > 235 && +bg[2] > 235 && +bg[3] > 235) { - issues.push('light-bg ' + cs.backgroundColor); - } - const hasText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim()); - const c = cs.color.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)/); - if (hasText && c && +c[1] < 70 && +c[2] < 70 && +c[3] < 70) { - issues.push('dark-text ' + cs.color); - } - if (!issues.length) return; - const cls = (typeof el.className === 'string' ? el.className : '').trim().replace(/\s+/g, ' ').slice(0, 150); - const key = el.tagName + '|' + cls + '|' + issues.join(','); - if (seen.has(key)) return; - seen.add(key); - results.push({ - tag: el.tagName.toLowerCase(), - cls, - path: shortPath(el), - issues, - size: Math.round(r.width) + 'x' + Math.round(r.height), - text: (el.textContent || '').trim().slice(0, 40), - }); - }); - return results; -} -""" - - -def slug(route: str) -> str: - return re.sub(r"[^a-z0-9]+", "-", route.lower()).strip("-") or "root" - - -def main() -> int: - SHOTS.mkdir(exist_ok=True) - with sync_playwright() as p: - ctx = p.chromium.launch_persistent_context( - str(PROFILE), - channel="msedge", - headless=True, - viewport={"width": 1600, "height": 950}, - ) - ctx.add_init_script(INIT_JS) - page = ctx.pages[0] if ctx.pages else ctx.new_page() - page.goto(BASE + "/console/dashboard", wait_until="domcontentloaded") - try: - page.wait_for_load_state("networkidle", timeout=10000) - except Exception: - pass - time.sleep(3) - if "/login" in page.url: - print("SESSION_EXPIRED 登录态失效,需要重新登录", flush=True) - ctx.close() - return 3 - - dark = page.evaluate("document.documentElement.getAttribute('data-halo-theme')") - print(f"data-halo-theme = {dark}", flush=True) - - # 普查样式表:确认服务端实际部署的插件 CSS 覆盖了哪些内容 - census = page.evaluate( - """() => { - const out = []; - for (const sheet of document.styleSheets) { - let rules; - try { rules = sheet.cssRules; } catch (e) { continue; } - let darkRules = 0; - let samples = []; - for (const r of rules) { - const t = r.cssText || ''; - if (t.includes('data-halo-theme')) { - darkRules++; - if (samples.length < 3) samples.push(t.slice(0, 100)); - } - } - if (darkRules > 0) { - out.push({ href: sheet.href || '(inline)', total: rules.length, darkRules, samples }); - } - } - return out; - }""" - ) - print("=== 包含暗色规则的样式表 ===", flush=True) - for c in census: - print(f" {c['href']} dark规则数={c['darkRules']}", flush=True) - - # 关键字探针:确认部署的 CSS 是否包含关键覆盖(判断部署版本新旧) - keywords = page.evaluate( - """() => { - const kws = ['description-item', 'bytemd', 'week-picker', 'menu-item-title', - 'alert-wrapper', 'entity-field-title', 'sidebar__profile', 'card-wrapper']; - const found = {}; - for (const kw of kws) found[kw] = false; - for (const sheet of document.styleSheets) { - let rules; - try { rules = sheet.cssRules; } catch (e) { continue; } - for (const r of rules) { - const t = r.cssText || ''; - if (!t.includes('data-halo-theme')) continue; - for (const kw of kws) if (t.includes(kw)) found[kw] = true; - } - } - return found; - }""" - ) - print(f"=== 部署 CSS 关键字探针 === {keywords}", flush=True) - - # 从 Vue Router 读取全部已注册路由(含插件注册的菜单页) - try: - routes = page.evaluate( - """() => { - const app = document.querySelector('#app').__vue_app__; - const router = app.config.globalProperties.$router; - return router.getRoutes().map(r => r.path); - }""" - ) - except Exception as e: - print(f"Router 读取失败,回退到锚点抓取: {e}", flush=True) - routes = page.evaluate( - """() => [...new Set([...document.querySelectorAll('a[href]')] - .map(a => a.getAttribute('href')) - .filter(h => h && h.startsWith('/console')))]""" - ) - # Halo Console 的 router base 是 /console/,getRoutes() 返回的路径不带 base - routes = sorted( - { - r if r.startswith("/console") else "/console" + r - for r in routes - if r.startswith("/") and ":" not in r and r not in ("/", "/console") - } - ) - # 编辑器路由只保留一个样本,避免重复扫描 - editor = [r for r in routes if "editor" in r] - routes = [r for r in routes if "editor" not in r] + editor[:1] - print(f"发现 {len(routes)} 个后台路由: {routes}", flush=True) - - all_results = {} - all_results["__stylesheet_census__"] = census - for route in routes: - try: - page.goto(BASE + route, wait_until="domcontentloaded") - try: - page.wait_for_load_state("networkidle", timeout=6000) - except Exception: - pass - time.sleep(1.5) - # 兜底:再设一次暗色属性,防止插件脚本时序问题 - page.evaluate("document.documentElement.setAttribute('data-halo-theme','dark')") - time.sleep(0.3) - items = page.evaluate(SCAN_JS) - all_results[route] = items - page.screenshot(path=str(SHOTS / (slug(route) + ".png"))) - print(f"{route}: {len(items)} 处疑似残留", flush=True) - except Exception as e: # noqa: BLE001 - all_results[route] = {"error": str(e)[:200]} - print(f"{route}: 扫描失败 {e}", flush=True) - - OUT.write_text(json.dumps(all_results, ensure_ascii=False, indent=2), encoding="utf-8") - ctx.close() - print(f"DONE -> {OUT}", flush=True) - return 0 - - -if __name__ == "__main__": - sys.exit(main())