feat: 1.0.3 引入 Dark Reader 通用暗色引擎

This commit is contained in:
2026-08-08 17:21:22 +08:00
parent b36ad30d7a
commit 202ab537d1
29 changed files with 19555 additions and 207 deletions
+60
View File
@@ -0,0 +1,60 @@
# 暗色模式回归扫描工具
用 Playwright 驱动 Edge 对 Halo 后台做全路由深浅色残留扫描,验证每次 CSS 改动。
## 依赖
- Python 3.13 环境(本机:`D:\settings\settings\uv\my_uv_env`
- Playwright(已安装):`pip install playwright``uv pip install playwright`
- 本机 EdgePlaywright 通过 `channel="msedge"` 复用,无需下载浏览器内核)
## 首次使用:登录
```powershell
D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\login-wait.py
```
会弹出 Edge 窗口,登录 `https://blog.liuhangyv.top/console` 后脚本自动退出,
会话保存在 `scripts/.browser-profile/`(已被 gitignore,不会入库)。
已有登录态想复用,可显式指定 profile:
```powershell
D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\scan-dark.py --profile workplace\pw-profile
```
## 常用命令
```powershell
# 全路由深色扫描,注入本地构建产物做预部署验证,存在残留时返回非 0
D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\scan-dark.py --local-css ui\build\dist\style.css --assert-zero
# 只扫指定页面
D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\scan-dark.py --pages /console/posts,/console/users
# 浅色模式回归抽查
D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\scan-dark.py --mode light --pages /console/dashboard,/console/posts
```
## 输出
- `scripts/output/scan-results.json`:按路由分组的结果
- `scripts/output/shots/*.png`:每页截图
- 均已被 gitignore,不入库。
## 切换行为验证
```powershell
D:\settings\settings\uv\my_uv_env\Scripts\python.exe scripts\verify-toggle.py
```
部署新版插件后运行,会检查侧边栏按钮切换、`data-halo-theme` 属性、
localStorage 持久化以及 Monaco 日志查看器主题是否同步。
## 注意事项
- 扫描会忽略 `img/canvas/video/svg/iframe`,避免把文章图片、编辑器画布等内容性
元素误判为界面残留。
- 路由从 Vue Router 动态发现,Halo 或插件升级新增页面后无需维护清单。
- `--local-css``add_style_tag` 追加本地样式(不替换线上 bundle),可安全验证
CSS 修复;JS 改动(如 Monaco 主题同步)需要重新构建并部署后再跑 `verify-toggle.py`
+53
View File
@@ -0,0 +1,53 @@
"""打开带持久化配置的 Edge 窗口,等待用户在 Halo 后台完成登录。
登录成功后会话保存在 profile 目录,供 scan-dark.py 复用。
"""
import argparse
import pathlib
import sys
import time
from playwright.sync_api import sync_playwright
DEFAULT_PROFILE = pathlib.Path(__file__).parent / ".browser-profile"
LOGIN_URL = "https://blog.liuhangyv.top/console/login"
TIMEOUT_S = 280
def main() -> int:
parser = argparse.ArgumentParser(description="登录 Halo 后台并保存会话")
parser.add_argument("--profile", default=str(DEFAULT_PROFILE))
args = parser.parse_args()
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
args.profile,
channel="msedge",
headless=False,
viewport={"width": 1600, "height": 950},
)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto(LOGIN_URL)
print("浏览器窗口已打开,请在其中登录 Halo 后台...", flush=True)
deadline = time.time() + TIMEOUT_S
while time.time() < deadline:
try:
url = page.url
except Exception:
print("检测到窗口被关闭", flush=True)
return 2
if "/console" in url and "/login" not in url:
time.sleep(3)
print(f"检测到登录成功: {url}", flush=True)
ctx.close()
return 0
time.sleep(2)
print("等待超时,未检测到登录", flush=True)
ctx.close()
return 1
if __name__ == "__main__":
sys.exit(main())
+220
View File
@@ -0,0 +1,220 @@
"""Halo 后台暗色模式残留扫描器(正式回归工具)。
用法示例:
python scripts/scan-dark.py --local-css ui/build/dist/style.css --assert-zero
python scripts/scan-dark.py --mode light --pages /console/posts,/console/users
python scripts/scan-dark.py --profile workplace/pw-profile
"""
import argparse
import json
import pathlib
import re
import sys
import time
from playwright.sync_api import sync_playwright
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
BASE = "https://blog.liuhangyv.top"
DEFAULT_PROFILE = pathlib.Path(__file__).parent / ".browser-profile"
DEFAULT_OUTPUT = pathlib.Path(__file__).parent / "output"
SCAN_JS = r"""
() => {
const EXCLUDED_TAGS = new Set(['IMG', 'CANVAS', 'VIDEO', 'SVG', 'IFRAME']);
const results = [];
const seen = new Set();
const isVisible = (el) => {
const cs = getComputedStyle(el);
return cs.display !== 'none' && cs.visibility !== 'hidden' && +cs.opacity > 0.05;
};
const shortPath = (el) => {
const parts = [];
let cur = el;
for (let i = 0; i < 5 && cur && cur !== document.body; i++) {
let p = cur.tagName.toLowerCase();
if (cur.id) p += '#' + cur.id;
else if (typeof cur.className === 'string' && cur.className.trim()) {
p += '.' + cur.className.trim().split(/\s+/).slice(0, 2).join('.');
}
parts.unshift(p);
cur = cur.parentElement;
}
return parts.join(' > ');
};
document.querySelectorAll('body *').forEach(el => {
if (EXCLUDED_TAGS.has(el.tagName)) return;
if (!isVisible(el)) return;
const r = el.getBoundingClientRect();
if (r.width < 50 || r.height < 20) return;
const cs = getComputedStyle(el);
const issues = [];
const bg = cs.backgroundColor.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/);
if (bg && bg[4] !== '0' && +bg[1] > 235 && +bg[2] > 235 && +bg[3] > 235) {
issues.push('light-bg ' + cs.backgroundColor);
}
const hasText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
const c = cs.color.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/);
if (hasText && c && (+c[4] ?? 1) > 0.05 && (+c[1] + +c[2] + +c[3]) / 3 < 95) {
issues.push('dark-text ' + cs.color);
}
if (!issues.length) return;
const cls = (typeof el.className === 'string' ? el.className : '').trim().replace(/\s+/g, ' ').slice(0, 150);
const key = el.tagName + '|' + cls + '|' + issues.join(',');
if (seen.has(key)) return;
seen.add(key);
results.push({
tag: el.tagName.toLowerCase(),
cls,
path: shortPath(el),
issues,
size: Math.round(r.width) + 'x' + Math.round(r.height),
text: (el.textContent || '').trim().slice(0, 40),
});
});
return results;
}
"""
def slug(route: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", route.lower()).strip("-") or "root"
def main() -> int:
parser = argparse.ArgumentParser(description="Halo 后台暗色模式残留扫描")
parser.add_argument("--base", default=BASE)
parser.add_argument("--profile", default=str(DEFAULT_PROFILE))
parser.add_argument("--output-dir", default=str(DEFAULT_OUTPUT))
parser.add_argument("--mode", choices=["dark", "light"], default="dark")
parser.add_argument("--pages", default="", help="逗号分隔的路由子集,默认扫描全部")
parser.add_argument("--local-css", default="", help="注入本地构建的 style.css 做预部署验证")
parser.add_argument("--assert-zero", action="store_true", help="存在残留时以退出码 1 结束")
args = parser.parse_args()
output = pathlib.Path(args.output_dir)
shots = output / "shots"
shots.mkdir(parents=True, exist_ok=True)
if args.mode == "dark":
init_js = """
try { localStorage.setItem('halo-dark-mode-theme', 'dark'); } catch (e) {}
document.documentElement.setAttribute('data-halo-theme', 'dark');
"""
else:
init_js = """
try { localStorage.setItem('halo-dark-mode-theme', 'light'); } catch (e) {}
document.documentElement.removeAttribute('data-halo-theme');
"""
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
args.profile,
channel="msedge",
headless=True,
viewport={"width": 1600, "height": 950},
)
ctx.add_init_script(init_js)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto(args.base + "/console/dashboard", wait_until="domcontentloaded")
try:
page.wait_for_load_state("networkidle", timeout=10000)
except Exception:
pass
try:
page.wait_for_selector("#app > *, .sidebar, .main-content", timeout=20000)
except Exception:
pass
time.sleep(2)
if "/login" in page.url:
print("SESSION_EXPIRED 登录态失效,请先运行 scripts/login-wait.py 登录")
ctx.close()
return 3
routes = []
try:
routes = page.evaluate(
"""() => {
const app = document.querySelector('#app').__vue_app__;
const router = app.config.globalProperties.$router;
return router.getRoutes().map(r => r.path);
}"""
)
except Exception:
routes = page.evaluate(
"""() => [...new Set([...document.querySelectorAll('a[href]')]
.map(a => a.getAttribute('href'))
.filter(h => h && h.startsWith('/console')))]"""
)
routes = sorted(
{
r if r.startswith("/console") else "/console" + r
for r in routes
if r.startswith("/") and ":" not in r and r not in ("/", "/console")
}
)
editor = [r for r in routes if "editor" in r]
routes = [r for r in routes if "editor" not in r] + editor[:1]
if args.pages:
wanted = [r.strip() for r in args.pages.split(",") if r.strip()]
routes = [r for r in wanted if r.startswith("/console")]
print(f"模式={args.mode} 待扫描路由数={len(routes)}", flush=True)
all_results = {}
for route in routes:
try:
last_error = None
for attempt in range(3):
try:
page.goto(args.base + route, wait_until="domcontentloaded")
last_error = None
break
except Exception as e:
last_error = e
print(f"{route}: 第 {attempt + 1} 次导航失败,稍后重试", flush=True)
time.sleep(8)
if last_error:
raise last_error
try:
page.wait_for_load_state("networkidle", timeout=8000)
except Exception:
pass
# 限流时 Halo bundle 可能加载较慢,确保应用渲染完成再扫描
page.wait_for_selector("#app > *, .sidebar, .main-content", timeout=20000)
if args.local_css:
page.add_style_tag(path=args.local_css)
time.sleep(2)
if args.mode == "dark":
page.evaluate("document.documentElement.setAttribute('data-halo-theme','dark')")
else:
page.evaluate("document.documentElement.removeAttribute('data-halo-theme')")
time.sleep(0.3)
items = page.evaluate(SCAN_JS)
all_results[route] = items
page.screenshot(path=str(shots / (slug(route) + ".png")))
print(f"{route}: {len(items)} 处疑似残留", flush=True)
except Exception as e:
all_results[route] = {"error": str(e)[:200]}
print(f"{route}: 扫描失败 {e}", flush=True)
out_json = output / "scan-results.json"
out_json.write_text(json.dumps(all_results, ensure_ascii=False, indent=2), encoding="utf-8")
ctx.close()
total = sum(
len(v) for v in all_results.values() if isinstance(v, list)
)
dirty = sum(1 for v in all_results.values() if isinstance(v, list) and v)
print(f"DONE 残留条目={total} 残留路由={dirty} 结果文件={out_json}", flush=True)
if args.assert_zero and dirty:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+76
View File
@@ -0,0 +1,76 @@
"""验证侧边栏主题切换:点击后检查 data-halo-theme、localStorage 与 Monaco 主题同步。
部署新版插件后运行可获得完整结果;预部署阶段 Monaco 断言会提示跳过。
"""
import argparse
import pathlib
import sys
import time
from playwright.sync_api import sync_playwright
DEFAULT_PROFILE = pathlib.Path(__file__).parent / ".browser-profile"
BASE = "https://blog.liuhangyv.top"
def main() -> int:
parser = argparse.ArgumentParser(description="验证主题切换运行时行为")
parser.add_argument("--base", default=BASE)
parser.add_argument("--profile", default=str(DEFAULT_PROFILE))
args = parser.parse_args()
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
args.profile, channel="msedge", headless=True,
viewport={"width": 1600, "height": 950},
)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto(args.base + "/console/dashboard", wait_until="domcontentloaded")
try:
page.wait_for_load_state("networkidle", timeout=10000)
except Exception:
pass
time.sleep(3)
if "/login" in page.url:
print("SESSION_EXPIRED 请先运行 scripts/login-wait.py")
ctx.close()
return 3
toggle = page.locator(".theme-toggle")
toggle.wait_for(state="visible", timeout=15000)
before = page.evaluate("() => ({ attr: document.documentElement.getAttribute('data-halo-theme'), stored: localStorage.getItem('halo-dark-mode-theme') })")
toggle.click()
time.sleep(0.5)
after = page.evaluate("() => ({ attr: document.documentElement.getAttribute('data-halo-theme'), stored: localStorage.getItem('halo-dark-mode-theme') })")
toggle.click()
time.sleep(0.5)
restored = page.evaluate("() => ({ attr: document.documentElement.getAttribute('data-halo-theme'), stored: localStorage.getItem('halo-dark-mode-theme') })")
attr_flipped = before["attr"] != after["attr"] and before["attr"] == restored["attr"]
stored_flipped = before["stored"] != after["stored"] and before["stored"] == restored["stored"]
print(f"初始: {before}")
print(f"切换: {after}")
print(f"还原: {restored}")
print(f"属性翻转: {'PASS' if attr_flipped else 'FAIL'} 存储翻转: {'PASS' if stored_flipped else 'FAIL'}")
page.goto(args.base + "/console/log-viewer", wait_until="domcontentloaded")
try:
page.wait_for_load_state("networkidle", timeout=6000)
except Exception:
pass
time.sleep(2)
has_monaco = page.locator(".monaco-editor").count() > 0
if has_monaco:
dark_attr = page.evaluate("document.documentElement.getAttribute('data-halo-theme')") == "dark"
theme_cls = page.evaluate("() => { const el = document.querySelector('.monaco-editor'); return el ? el.className : '' }")
monaco_dark = "vs-dark" in theme_cls
print(f"Monaco 实例: 存在 | 属性dark={dark_attr} | 主题类含vs-dark={monaco_dark} | class={theme_cls[:60]}")
print(f"Monaco 主题同步: {'PASS' if (dark_attr and monaco_dark) or (not dark_attr and not monaco_dark) else 'FAIL(可能部署的还是旧版 JS'}")
else:
print("Monaco 实例: 未检测到,跳过主题断言(日志页面可能未加载或路由不同)")
ctx.close()
return 0 if attr_flipped and stored_flipped else 1
if __name__ == "__main__":
sys.exit(main())