diff --git a/.gitignore b/.gitignore index 9056b00..b7afa48 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ build/ .DS_Store node_modules/ dist/ +# Playwright 测试工具 — 只跟踪脚本和配置,忽略输出/截图/缓存 +workplace/* +!workplace/*.py +!workplace/*.yaml diff --git a/CLAUDE.md b/CLAUDE.md index 23bd163..8b0ea15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,8 +146,16 @@ Halo 扩展点系统**没有侧边栏插槽**。`injector.ts` 用 `MutationObser 第三方插件页面(链接/订阅/瞬间等)在 demo 站已装,可一并验证。 -## 项目文档 +## Playwright 自动化验证(workplace/) -- `设计文档.md` — 完整的技术设计(架构图、CSS 变量清单、调色板、组件覆盖策略、实现阶段划分、测试策略) -- `调查文档.md` — 技术调查(create-halo-plugin vs plugin-starter 差异、dev-skills、Halo 插件机制) -- `README.md` — 用户向 README +`workplace/` 目录包含基于 Playwright 的自动化验证工具(未提交到 git): + +- **`login_wait.py`** — 启动带持久化配置的 Edge 窗口,打开 `https://blog.liuhangyv.top/console/login`,等待用户手动登录后将会话 cookie 保存到 `pw-profile/`。超时 280 秒。 +- **`pw-profile/`** — Edge 浏览器持久化用户数据目录,登录后会话保留供后续 Playwright 脚本复用。 +- **`roleTemplates.yaml`** — 预留的角色模板配置(当前为空)。 + +典型流程:先运行 `login_wait.py` 登录,再编写 Playwright 脚本复用 `pw-profile` 中的会话进行页面扫描。 + +## README.md + +用户向 README,包含功能介绍、快速开始、构建命令和项目结构图。 diff --git a/gradle.properties b/gradle.properties index 570f59a..07899a3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=1.0.0-SNAPSHOT +version=1.0.1 org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 org.gradle.java.home=D:/settings/Language/Java/jdk-25.0.1 \ No newline at end of file diff --git a/src/main/java/run/halo/darkmode/DarkModePlugin.java b/src/main/java/run/halo/darkmode/DarkModePlugin.java index 696440f..9955eda 100644 --- a/src/main/java/run/halo/darkmode/DarkModePlugin.java +++ b/src/main/java/run/halo/darkmode/DarkModePlugin.java @@ -10,7 +10,7 @@ import run.halo.app.plugin.PluginContext; *
Only one main class extending {@link BasePlugin} is allowed per plugin.
* * @author LHY - * @since 1.0.0 + * @since 1.0.1 */ @Component public class DarkModePlugin extends BasePlugin { diff --git a/workplace/aggregate.py b/workplace/aggregate.py new file mode 100644 index 0000000..0384fce --- /dev/null +++ b/workplace/aggregate.py @@ -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() diff --git a/workplace/fetch_bundle.py b/workplace/fetch_bundle.py new file mode 100644 index 0000000..a10ee7d --- /dev/null +++ b/workplace/fetch_bundle.py @@ -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 '无'}") diff --git a/workplace/login_wait.py b/workplace/login_wait.py new file mode 100644 index 0000000..30f428d --- /dev/null +++ b/workplace/login_wait.py @@ -0,0 +1,46 @@ +# 启动带持久化配置的 Edge 窗口,等待用户登录 Halo 后台。 +# 登录成功后脚本自动退出,会话会保留在 pw-profile 目录里供后续扫描使用。 +import pathlib +import sys +import time + +from playwright.sync_api import sync_playwright + +PROFILE = pathlib.Path(__file__).parent / "pw-profile" +LOGIN_URL = "https://blog.liuhangyv.top/console/login" +TIMEOUT_S = 280 + + +def main() -> int: + with sync_playwright() as p: + ctx = p.chromium.launch_persistent_context( + str(PROFILE), + channel="msedge", + headless=False, + viewport={"width": 1600, "height": 950}, + ) + page = ctx.pages[0] if ctx.pages else ctx.new_page() + page.goto(LOGIN_URL) + print("浏览器窗口已打开,请在其中登录 Halo 后台...", flush=True) + + deadline = time.time() + TIMEOUT_S + while time.time() < deadline: + try: + url = page.url + except Exception: + print("检测到窗口被关闭", flush=True) + return 2 + if "/console" in url and "/login" not in url: + time.sleep(3) # 等待会话 cookie 写入磁盘 + print(f"检测到登录成功: {url}", flush=True) + ctx.close() + return 0 + time.sleep(2) + + print("等待超时,未检测到登录", flush=True) + ctx.close() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/workplace/probe_conflict.py b/workplace/probe_conflict.py new file mode 100644 index 0000000..37f33fc --- /dev/null +++ b/workplace/probe_conflict.py @@ -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']}") diff --git a/workplace/roleTemplates.yaml b/workplace/roleTemplates.yaml new file mode 100644 index 0000000..e69de29 diff --git a/workplace/scan_dark.py b/workplace/scan_dark.py new file mode 100644 index 0000000..d2ff552 --- /dev/null +++ b/workplace/scan_dark.py @@ -0,0 +1,214 @@ +# Halo 后台暗色模式残留扫描器 +# 自动发现侧边栏全部 /console 路由,逐页扫描浅色背景 / 深色文字残留并截图。 +import json +import pathlib +import re +import sys +import time + +from playwright.sync_api import sync_playwright + +BASE = "https://blog.liuhangyv.top" +ROOT = pathlib.Path(__file__).parent +PROFILE = ROOT / "pw-profile" +SHOTS = ROOT / "shots" +OUT = ROOT / "scan-results.json" + +# 在每个页面加载前强制插件进入深色模式 +INIT_JS = """ +try { localStorage.setItem('halo-dark-mode-theme', 'dark'); } catch(e) {} +document.documentElement.setAttribute('data-halo-theme', 'dark'); +""" + +# 页面内扫描:浅色背景(RGB 均 >235 且不透明)、深色文字(RGB 均 <70) +SCAN_JS = r""" +() => { + const results = []; + const seen = new Set(); + const isVisible = (el) => { + const cs = getComputedStyle(el); + return cs.display !== 'none' && cs.visibility !== 'hidden' && +cs.opacity > 0.05; + }; + const shortPath = (el) => { + const parts = []; + let cur = el; + for (let i = 0; i < 5 && cur && cur !== document.body; i++) { + let p = cur.tagName.toLowerCase(); + if (cur.id) p += '#' + cur.id; + else if (typeof cur.className === 'string' && cur.className.trim()) { + p += '.' + cur.className.trim().split(/\s+/).slice(0, 2).join('.'); + } + parts.unshift(p); + cur = cur.parentElement; + } + return parts.join(' > '); + }; + document.querySelectorAll('body *').forEach(el => { + if (!isVisible(el)) return; + const r = el.getBoundingClientRect(); + if (r.width < 50 || r.height < 20) return; + const cs = getComputedStyle(el); + const issues = []; + const bg = cs.backgroundColor.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/); + if (bg && bg[4] !== '0' && +bg[1] > 235 && +bg[2] > 235 && +bg[3] > 235) { + issues.push('light-bg ' + cs.backgroundColor); + } + const hasText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim()); + const c = cs.color.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)/); + if (hasText && c && +c[1] < 70 && +c[2] < 70 && +c[3] < 70) { + issues.push('dark-text ' + cs.color); + } + if (!issues.length) return; + const cls = (typeof el.className === 'string' ? el.className : '').trim().replace(/\s+/g, ' ').slice(0, 150); + const key = el.tagName + '|' + cls + '|' + issues.join(','); + if (seen.has(key)) return; + seen.add(key); + results.push({ + tag: el.tagName.toLowerCase(), + cls, + path: shortPath(el), + issues, + size: Math.round(r.width) + 'x' + Math.round(r.height), + text: (el.textContent || '').trim().slice(0, 40), + }); + }); + return results; +} +""" + + +def slug(route: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", route.lower()).strip("-") or "root" + + +def main() -> int: + SHOTS.mkdir(exist_ok=True) + with sync_playwright() as p: + ctx = p.chromium.launch_persistent_context( + str(PROFILE), + channel="msedge", + headless=True, + viewport={"width": 1600, "height": 950}, + ) + ctx.add_init_script(INIT_JS) + page = ctx.pages[0] if ctx.pages else ctx.new_page() + page.goto(BASE + "/console/dashboard", wait_until="domcontentloaded") + try: + page.wait_for_load_state("networkidle", timeout=10000) + except Exception: + pass + time.sleep(3) + if "/login" in page.url: + print("SESSION_EXPIRED 登录态失效,需要重新登录", flush=True) + ctx.close() + return 3 + + dark = page.evaluate("document.documentElement.getAttribute('data-halo-theme')") + print(f"data-halo-theme = {dark}", flush=True) + + # 普查样式表:确认服务端实际部署的插件 CSS 覆盖了哪些内容 + census = page.evaluate( + """() => { + const out = []; + for (const sheet of document.styleSheets) { + let rules; + try { rules = sheet.cssRules; } catch (e) { continue; } + let darkRules = 0; + let samples = []; + for (const r of rules) { + const t = r.cssText || ''; + if (t.includes('data-halo-theme')) { + darkRules++; + if (samples.length < 3) samples.push(t.slice(0, 100)); + } + } + if (darkRules > 0) { + out.push({ href: sheet.href || '(inline)', total: rules.length, darkRules, samples }); + } + } + return out; + }""" + ) + print("=== 包含暗色规则的样式表 ===", flush=True) + for c in census: + print(f" {c['href']} dark规则数={c['darkRules']}", flush=True) + + # 关键字探针:确认部署的 CSS 是否包含关键覆盖(判断部署版本新旧) + keywords = page.evaluate( + """() => { + const kws = ['description-item', 'bytemd', 'week-picker', 'menu-item-title', + 'alert-wrapper', 'entity-field-title', 'sidebar__profile', 'card-wrapper']; + const found = {}; + for (const kw of kws) found[kw] = false; + for (const sheet of document.styleSheets) { + let rules; + try { rules = sheet.cssRules; } catch (e) { continue; } + for (const r of rules) { + const t = r.cssText || ''; + if (!t.includes('data-halo-theme')) continue; + for (const kw of kws) if (t.includes(kw)) found[kw] = true; + } + } + return found; + }""" + ) + print(f"=== 部署 CSS 关键字探针 === {keywords}", flush=True) + + # 从 Vue Router 读取全部已注册路由(含插件注册的菜单页) + try: + routes = page.evaluate( + """() => { + const app = document.querySelector('#app').__vue_app__; + const router = app.config.globalProperties.$router; + return router.getRoutes().map(r => r.path); + }""" + ) + except Exception as e: + print(f"Router 读取失败,回退到锚点抓取: {e}", flush=True) + routes = page.evaluate( + """() => [...new Set([...document.querySelectorAll('a[href]')] + .map(a => a.getAttribute('href')) + .filter(h => h && h.startsWith('/console')))]""" + ) + # Halo Console 的 router base 是 /console/,getRoutes() 返回的路径不带 base + routes = sorted( + { + r if r.startswith("/console") else "/console" + r + for r in routes + if r.startswith("/") and ":" not in r and r not in ("/", "/console") + } + ) + # 编辑器路由只保留一个样本,避免重复扫描 + editor = [r for r in routes if "editor" in r] + routes = [r for r in routes if "editor" not in r] + editor[:1] + print(f"发现 {len(routes)} 个后台路由: {routes}", flush=True) + + all_results = {} + all_results["__stylesheet_census__"] = census + for route in routes: + try: + page.goto(BASE + route, wait_until="domcontentloaded") + try: + page.wait_for_load_state("networkidle", timeout=6000) + except Exception: + pass + time.sleep(1.5) + # 兜底:再设一次暗色属性,防止插件脚本时序问题 + page.evaluate("document.documentElement.setAttribute('data-halo-theme','dark')") + time.sleep(0.3) + items = page.evaluate(SCAN_JS) + all_results[route] = items + page.screenshot(path=str(SHOTS / (slug(route) + ".png"))) + print(f"{route}: {len(items)} 处疑似残留", flush=True) + except Exception as e: # noqa: BLE001 + all_results[route] = {"error": str(e)[:200]} + print(f"{route}: 扫描失败 {e}", flush=True) + + OUT.write_text(json.dumps(all_results, ensure_ascii=False, indent=2), encoding="utf-8") + ctx.close() + print(f"DONE -> {OUT}", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/设计文档.md b/设计文档.md deleted file mode 100644 index ebf24be..0000000 --- a/设计文档.md +++ /dev/null @@ -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,所有现代浏览器支持 | -| 初始加载防闪烁 | ` -``` - -**注意**:由于插件 bundle 是异步加载的(`useScriptTag`),FOUC 风险需要通过以下方式缓解: -- CSS 变量切换是瞬时的(< 1 帧),即使有短暂浅色闪现,用户体感为"页面加载完成" -- 后续可通过向 Halo 提交 PR 在 `console.html` 添加 `