diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73e701e66..016be34ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,5 +57,23 @@ jobs: - name: 📐 Type Check (TypeScript) run: pnpm typecheck - - name: 🏗️ Verify Build + - name: 🧪 Run Tests (Vitest) + run: pnpm test:run + + - name: 🎨 Validate Theme Tokens + run: pnpm check:theme-tokens + + - name: 🌐 Validate Supported Sites + run: pnpm check:site-support + + - name: 🏗️ Verify Chrome Build run: pnpm build + + - name: 🦊 Verify Firefox Build + run: pnpm build:firefox + + - name: 🐒 Verify Userscript Build + run: pnpm build:userscript + + - name: 📦 Enforce Bundle Budgets + run: pnpm check:bundle-budget diff --git a/docs/developer/architecture-ui-optimization-plan.md b/docs/developer/architecture-ui-optimization-plan.md deleted file mode 100644 index 988b98b4f..000000000 --- a/docs/developer/architecture-ui-optimization-plan.md +++ /dev/null @@ -1,528 +0,0 @@ -# 项目架构与 UI 交互优化计划(2026-07) - -本文档记录对当前项目架构、代码设计、UI 与交互实现的审查结论,并给出可拆分执行的优化计划。目标不是一次性大重构,而是用低风险、小步提交的方式降低维护成本、提升可访问性与后续扩展效率。 - -## 审查范围 - -本次审查基于以下内容: - -- 项目架构文档:`docs/developer/architecture.md` -- UI 与主题规范:`DESIGN.md` -- Shadow DOM 与样式注入说明:`docs/developer/css-architecture.md` -- 核心初始化链路:`src/core/modules-init.ts` -- 适配器层:`src/adapters/*` -- 状态与存储:`src/stores/*`、`src/utils/storage.ts`、`src/platform/*` -- 主要 UI 组件:`src/components/App.tsx`、`MainPanel.tsx`、`SettingsModal.tsx`、`PromptsTab.tsx` 等 -- 样式入口与注入链路:`src/contents/ui-entry.tsx`、`src/style.css`、`src/styles/*` - -## 总体结论 - -当前项目的基础分层方向是正确的: - -- `src/adapters/` 承担站点差异。 -- `src/core/` 承担跨站点核心能力。 -- `src/platform/` 抽象扩展与油猴脚本平台差异。 -- `src/stores/` 负责 Zustand 状态与持久化。 -- `src/components/` 承担 Shadow DOM 面板 UI。 - -主要风险来自功能增长后的复杂度累积: - -1. 适配器和 UI 单文件体量过大,修改局部行为时容易牵动大量上下文。 -2. 核心模块生命周期偏全局单例,初始化、热更新、销毁路径不够对称。 -3. 设置与存储相关类型、默认值、迁移、选择器和底层存储工具混在一起,边界偏宽。 -4. UI 可访问性基础存在,但复杂弹层、tab、拖拽排序等交互语义仍可补强。 -5. 样式变量体系和 Shadow DOM 注入链路清晰,但 CSS 文件体量大,直接颜色、`!important` 和 `transition: all` 偏多。 - -## 关键证据 - -### 大文件与高复杂度入口 - -当前体量较大的文件包括: - -| 文件 | 近似行数 | 风险 | -| --- | ---: | --- | -| `src/adapters/gemini.ts` | 4900 | 单站点适配器职责过多 | -| `src/components/PromptsTab.tsx` | 3778 | Prompt 列表、编辑、导入、预览、Chain 编辑耦合 | -| `src/components/App.tsx` | 3618 | 应用壳、全局搜索、弹层、快捷操作、通知等职责集中 | -| `src/adapters/chatgpt.ts` | 3411 | 单站点适配器职责过多 | -| `src/adapters/aistudio.ts` | 3276 | 单站点适配器职责过多 | -| `src/core/outline-manager.ts` | 1747 | 核心能力较重,依赖 DOM 与刷新策略 | -| `src/adapters/base.ts` | 1733 | 基类接口过宽 | - -### 适配器基类偏宽 - -`SiteAdapter` 目前不只是站点识别和基础 DOM 访问,还覆盖了: - -- 输入框查找与 prompt 插入 -- 会话列表与会话导航 -- 大纲提取 -- 导出内容提取 -- 页面宽度、用户问题宽度、Zen Mode、Clean Mode -- 主题同步 -- Mermaid 支持 -- 快捷引用 -- 复制与公式处理 -- 模型锁定与提交按钮识别 - -这让新增站点和维护站点都容易复制大量模板逻辑。 - -### 核心模块生命周期不够统一 - -`src/core/modules-init.ts` 中使用全局 `modules` 单例,并集中处理初始化与设置热更新。URL 变化监听会 patch `history.pushState` / `replaceState`,同时使用 1 秒兜底轮询。当前接口没有统一返回 cleanup/disposer,后续重挂载或模块重启时不利于验证监听器和定时器是否被完整释放。 - -全仓核心与 UI 中存在较多异步与监听机制,包括多处 `setInterval`、`setTimeout`、`MutationObserver`、`addEventListener`。这些能力本身有必要,但需要更清晰的生命周期约束。 - -### 设置与存储边界偏宽 - -`src/utils/storage.ts` 同时包含: - -- `Settings` 类型 -- `DEFAULT_SETTINGS` -- storage key -- 站点设置 selector -- clear/restore flag -- Prompt、Folder、Tag 等业务类型 - -`src/stores/settings-store.ts` 还承担 normalize、preview settings、persisted settings、跨上下文 chrome.storage 同步等职责。 - -此外,`src/utils/storage.ts` 当前 type-only 引用了 `~core/webdav-sync` 的 `WebDAVProvider`。虽然 type-only 引用不会造成运行时代码加载,但从架构边界看,基础 utils 层不应依赖 core 层类型。 - -### UI 可访问性语义有补强空间 - -当前 UI 已有一些 `aria-label`、`focus-visible`、`aria-live` 与键盘处理,但复杂交互仍有缺口: - -- `SettingsModal` 的主要容器缺少完整 `role="dialog"`、`aria-modal`、`aria-labelledby`、焦点陷阱和关闭后恢复焦点。 -- `MainPanel` 的 tab 使用按钮和 active class,但缺少完整 `tablist` / `tab` / `tabpanel` 语义。 -- 面板 tab、Prompt、Chain 等拖拽排序交互需要非鼠标替代路径。 -- 大量操作依赖 toast,重要状态应区分 toast、inline status、`aria-live` 和确认弹窗。 - -### 样式系统可继续收敛 - -样式注入链路整体正确:面板样式通过 `src/contents/ui-entry.tsx` 的 `getStyle()` 合并注入 Shadow DOM,动态主题由 `ThemeManager` 注入。主要问题是维护性: - -- `src/style.css` 约 3815 行。 -- `src/styles/settings.css` 约 3048 行。 -- 直接颜色、`rgba()`、`!important` 和 `transition: all` 数量偏多。 -- `prefers-reduced-motion` 已有覆盖,但还可以更系统地约束强交互动效。 - -## 优化目标 - -1. 降低大文件和宽接口带来的维护成本。 -2. 让核心模块初始化、更新、销毁路径可预测、可验证。 -3. 清理设置、类型、默认值、迁移与存储之间的边界。 -4. 提升复杂弹层、tab、拖拽排序等交互的可访问性。 -5. 保持 Shadow DOM 与主题系统兼容,逐步拆分 CSS,避免样式继续堆叠。 -6. 保持每个改动可独立验证,不做一次性大爆炸重构。 - -## 分阶段执行计划 - -### 第一阶段:低风险、高收益修复 - -#### 1. 补强设置弹窗可访问性 - -建议任务:`ux(settings): add accessible dialog semantics and focus management` - -范围: - -- `src/components/SettingsModal.tsx` -- 必要时补少量样式到 `src/styles/settings.css` - -建议实现: - -- 弹窗容器增加 `role="dialog"`、`aria-modal="true"`。 -- 绑定标题 `aria-labelledby`,必要时绑定说明 `aria-describedby`。 -- 打开时聚焦标题或第一个可操作控件。 -- `Tab` / `Shift+Tab` 焦点限制在弹窗内。 -- 关闭后恢复到打开设置的触发元素。 -- 保留 `Escape` 关闭行为。 - -验收标准: - -- 键盘可完整打开、浏览、关闭设置弹窗。 -- 焦点不会逃出弹窗。 -- 关闭后焦点回到原触发位置。 -- 屏幕阅读器能识别弹窗标题和语义。 - -#### 2. 补强主面板 tab 语义与键盘切换 - -建议任务:`ux(panel): add ARIA tablist semantics and keyboard navigation` - -范围: - -- `src/components/MainPanel.tsx` -- `src/style.css` - -建议实现: - -- tab 容器增加 `role="tablist"`。 -- 每个 tab 按钮增加 `role="tab"`、`aria-selected`、`aria-controls`、稳定 `id`。 -- 内容区增加 `role="tabpanel"`、`aria-labelledby`。 -- 支持方向键、`Home`、`End` 切换 tab。 -- 拖拽排序保留,同时确保键盘用户仍可完成 tab 切换。 - -验收标准: - -- 鼠标、键盘都能切换 tab。 -- 当前 tab 可被屏幕阅读器识别。 -- 不影响现有拖拽排序和 tab 顺序设置。 - -#### 3. 修正设置类型边界 - -建议任务:`refactor(settings): move shared settings types out of storage utilities` - -范围: - -- 新增 `src/types/settings.ts` 或 `src/types/webdav.ts` -- `src/utils/storage.ts` -- `src/core/webdav-sync.ts` - -建议实现: - -- 将 `WebDAVProvider` 等纯类型移到 `src/types`。 -- `src/utils/storage.ts` 改为引用纯类型文件,不再 type-only 引用 `~core/webdav-sync`。 -- 不改变运行时行为。 - -验收标准: - -- `utils` 不再依赖 `core` 类型。 -- `pnpm typecheck` 通过。 - -#### 4. 为核心订阅和 URL 监听增加 cleanup - -建议任务:`refactor(core): return disposers for module subscriptions and url observer` - -范围: - -- `src/core/modules-init.ts` -- 内容脚本初始化入口 - -建议实现: - -- `subscribeModuleUpdates(ctx)` 返回 unsubscribe。 -- `initUrlChangeObserver(ctx)` 返回 cleanup,负责: - - 移除 `popstate` / `hashchange` 监听。 - - 恢复 `history.pushState` / `replaceState`。 - - 清理兜底 interval。 - - 清理未完成的 timeout。 -- 暂不改变模块业务逻辑,只补生命周期闭环。 - -验收标准: - -- 现有行为不变。 -- 初始化入口能保存 disposer。 -- 后续模块重启或卸载时有明确清理路径。 - -#### 5. 替换高风险 `transition: all` - -建议任务:`ux(styles): replace broad transitions with explicit properties` - -范围: - -- `src/style.css` -- `src/styles/*.css` - -建议实现: - -- 将 `transition: all` 改为明确属性,例如 `opacity`、`transform`、`background-color`、`border-color`、`box-shadow`。 -- 不调整视觉风格,只降低无意动画和性能风险。 - -验收标准: - -- 视觉效果保持基本一致。 -- `transition: all` 数量明显下降。 -- 不影响主题切换和 Shadow DOM 注入。 - -### 第二阶段:结构减负 - -#### 6. 从 `App.tsx` 拆出全局搜索控制器 - -建议任务:`refactor(app): extract global search controller from app shell` - -建议拆分: - -- `src/components/global-search/useGlobalSearchController.ts` -- `src/components/global-search/GlobalSearchOverlay.tsx` -- `src/components/global-search/globalSearchConstants.ts` - -目标: - -- `App.tsx` 只负责挂载和传递必要回调。 -- 搜索输入、过滤、键盘导航、预览浮层、shortcut nudge 从应用壳中移出。 - -验收标准: - -- 全局搜索快捷键、设置定位、Prompt 预览、空态、语法提示行为不变。 -- `App.tsx` 行数明显下降。 - -#### 7. 拆分 `PromptsTab.tsx` - -建议任务:`refactor(prompts): split prompt tab into focused components` - -建议拆分: - -- `PromptList` -- `PromptCategorySidebar` -- `PromptEditorDialog` -- `PromptImportDialog` -- `PromptPreviewModal` -- `PromptChainEditor` -- `usePromptDragSort` -- `usePromptImportExport` - -目标: - -- Prompt 列表、分类、编辑、导入导出、Chain 编辑各自维护。 -- 降低拖拽排序和弹窗状态对主组件的耦合。 - -验收标准: - -- Prompt 增删改查、分类、导入导出、Chain 编辑行为不变。 -- 拆分后的组件职责清楚,避免引入新的全局状态。 - -#### 8. 拆分 settings schema、默认值、normalize 和 selector - -建议任务:`refactor(settings): split schema defaults normalization and selectors` - -建议结构: - -- `src/types/settings.ts` -- `src/constants/default-settings.ts` -- `src/utils/settings-normalize.ts` -- `src/utils/settings-selectors.ts` -- `src/utils/storage-flags.ts` - -目标: - -- `src/utils/storage.ts` 不再承载所有设置相关逻辑。 -- 设置迁移、默认值、站点 selector 可分别测试和审查。 - -验收标准: - -- 备份/恢复、设置修改、站点特定设置读取行为不变。 -- `DEFAULT_SETTINGS`、store、UI、备份/恢复兼容逻辑仍同步。 - -#### 9. 建立核心模块生命周期接口 - -建议任务:`refactor(core): introduce module lifecycle contract` - -建议接口: - -```ts -interface CoreModule { - start(): void | Promise - update?(settings: Settings): void | Promise - stop(): void -} -``` - -目标: - -- `modules-init.ts` 从“集中写所有热更新逻辑”转为“模块注册与事件分发”。 -- 模块自己管理自己的 update 和 stop。 -- URL 变化、设置变化、清除数据都走统一事件路径。 - -验收标准: - -- 至少先迁移 1 到 2 个低风险模块作为样板。 -- 后续模块可逐步迁移,不要求一次性改完。 - -### 第三阶段:长期架构优化 - -#### 10. 将适配器改为基础适配器 + capability - -建议任务:`refactor(adapter): split site capabilities from base adapter` - -建议 capability: - -- `OutlineCapability` -- `ConversationListCapability` -- `ExportCapability` -- `ThemeCapability` -- `PromptInputCapability` -- `LayoutCapability` -- `QuickQuoteCapability` - -目标: - -- `SiteAdapter` 只保留站点识别、基础 DOM 和通用查询。 -- 站点按需声明能力。 -- 核心模块通过 capability 判断是否启用,而不是假设所有 adapter 都有所有方法。 - -验收标准: - -- 先选一个小站点适配器试点。 -- 不把站点特定逻辑泄漏到公共基类。 -- 新增站点需要实现的最小接口明显减少。 - -#### 11. 统一轮询和 observer 预算 - -建议任务:`perf(core): centralize polling and observer lifecycle` - -目标: - -- 建立 `PollingTaskRegistry` 或类似调度器。 -- 所有周期任务声明:名称、间隔、启动条件、停止条件、页面隐藏行为。 -- 页面隐藏时统一降频或暂停。 -- 模块销毁时统一清理。 - -验收标准: - -- 新增轮询必须注册到调度器。 -- 能从开发日志或调试面板看出当前活跃轮询任务。 - -#### 12. 渐进提升 TypeScript 约束 - -建议任务:`refactor(types): reduce any usage in platform messaging and shared utilities` - -优先目录: - -- `src/platform` -- `src/utils/messaging.ts` -- `src/types` -- `src/core/network-monitor.ts` - -目标: - -- 为 `window.__ophel*` 全局字段增加 `global.d.ts` 声明。 -- 为 background message 建立 request/response 映射。 -- 将可替换的 `any` 改为 `unknown`、泛型或明确 DOM 类型。 -- 暂不一次性开启 `strict`,避免大范围无关改动。 - -验收标准: - -- `any` 数量逐步下降。 -- 核心消息和平台能力类型更明确。 - -## UI 与交互专项建议 - -### 设置页信息架构 - -建议逐步优化: - -- 设置首页增加“常用设置 / 最近修改 / 当前站点推荐”。 -- 全局设置和当前站点设置在视觉上更明确区分。 -- 对高风险设置增加简短说明和即时预览。 -- 搜索结果点击后滚动到目标设置并高亮 1 到 2 秒。 -- 对扩展版/油猴版能力差异,在帮助区域说明原因,避免用户误解为功能丢失。 - -### 反馈系统 - -建议统一反馈分级: - -| 类型 | 适用场景 | -| --- | --- | -| toast info | 复制成功、轻量完成提示 | -| toast warning | 非阻塞警告,如没有可导出内容 | -| toast error | 操作失败,需要说明原因 | -| inline status | 表单校验、导入失败、同步失败 | -| `aria-live` | 后台任务、导入导出、队列状态 | -| confirm dialog | 删除、清空、覆盖导入等破坏性操作 | - -目标是避免所有反馈都依赖 toast,让重要状态更可见、更可访问。 - -### 拖拽排序的非鼠标替代 - -涉及面板 tab、Prompt、Chain 等排序功能。建议为每类排序至少提供一种替代路径: - -- 上移 / 下移按钮。 -- 更多菜单中的“移到顶部 / 移到底部”。 -- 设置页排序列表。 -- 键盘快捷方式说明。 - -### 动效与 reduced motion - -建议建立 motion token: - -- `--gh-motion-fast` -- `--gh-motion-normal` -- `--gh-motion-slow` -- `--gh-ease-standard` -- `--gh-ease-emphasized` - -并在 `prefers-reduced-motion: reduce` 下统一限制: - -- 强 transform 动画。 -- 循环动画。 -- 自动滚动动画。 -- 拖拽和边缘唤起的强动效。 - -## CSS 拆分建议 - -保持现有 `getStyle()` 合并注入 Shadow DOM 的机制不变,但逐步把大 CSS 文件拆为更明确的域: - -- `src/styles/panel.css` -- `src/styles/panel-tabs.css` -- `src/styles/outline.css` -- `src/styles/prompts.css` -- `src/styles/quick-buttons.css` -- `src/styles/settings-layout.css` -- `src/styles/settings-controls.css` -- `src/styles/dialogs.css` - -拆分原则: - -- 每次只拆一个样式域。 -- 同步更新 `src/contents/ui-entry.tsx` 的 `data-text:` 注入。 -- 不改变动态主题变量由 `ThemeManager` 注入 Shadow Root 末尾的事实。 -- 不把 `::view-transition-*` 等文档根伪元素样式放进 Shadow DOM CSS。 - -## 建议 PR 拆分 - -推荐按以下顺序创建独立 PR: - -1. `ux(settings): add accessible dialog semantics and focus trap` -2. `ux(panel): add ARIA tablist semantics and keyboard navigation` -3. `refactor(settings): move WebDAVProvider to shared types` -4. `refactor(core): return cleanup functions from module observers` -5. `ux(styles): replace transition all with explicit transitions` -6. `refactor(app): extract global search controller` -7. `refactor(prompts): split prompt tab dialogs and drag logic` -8. `refactor(settings): split settings schema defaults normalization selectors` -9. `refactor(core): introduce module lifecycle interface` -10. `refactor(adapter): pilot capability-based adapter on one small site` - -## 验证策略 - -### 文档或纯类型边界调整 - -- `pnpm typecheck` -- 必要时 `pnpm lint:check` - -### UI/样式调整 - -- `pnpm format:check` -- `pnpm lint:check` -- `pnpm typecheck` -- 能实际运行时,在至少一个目标站点做最小冒烟: - - 面板打开/关闭。 - - 设置弹窗打开/关闭。 - - tab 切换。 - - 主题切换。 - - Shadow DOM 样式注入。 - -### 平台或核心初始化调整 - -- `pnpm typecheck` -- `pnpm build` -- 涉及油猴路径时补充 `pnpm build:userscript` - -## 非目标 - -以下内容不建议在同一个阶段混入: - -- 一次性全仓格式化。 -- 一次性开启 TypeScript `strict`。 -- 一次性重写所有适配器。 -- 在架构优化 PR 中顺手调整大量视觉风格。 -- 为绕过类型或 lint 问题升级大版本依赖。 - -## 维护约定 - -后续执行本计划时,建议每个 PR 都在描述中说明: - -- 对应本文档的哪个阶段和任务。 -- 改动涉及的层:adapter / core / platform / store / UI / CSS。 -- 是否涉及扩展和油猴双平台。 -- 是否涉及 Shadow DOM 样式注入或主题变量。 -- 已运行的验证命令。 -- 未覆盖的风险与后续任务。 diff --git a/docs/developer/architecture-ui-optimization-tasks.csv b/docs/developer/architecture-ui-optimization-tasks.csv deleted file mode 100644 index 1c77e1207..000000000 --- a/docs/developer/architecture-ui-optimization-tasks.csv +++ /dev/null @@ -1,24 +0,0 @@ -"task_id","phase","priority","title","source_section","change_type","primary_files","depends_on","scope","non_breaking_constraints","ui_safeguards","validation_commands","manual_regression","acceptance_criteria","pr_size_limit","rollback_notes" -"AUI-000","Phase 0 - Guardrails","P0","建立架构与 UI 优化执行检查清单","维护约定; 验证策略; 非目标","docs/process","docs/developer/architecture-ui-optimization-plan.md; docs/developer/architecture-ui-optimization-tasks.csv","","为后续每个优化 PR 固化执行模板:改动层级、平台影响、Shadow DOM/主题影响、验证命令、手动回归点。","只补文档和流程;不修改运行时代码;不改变构建脚本;不把多个实现任务合并到同一 PR。","所有 UI 任务必须声明目标界面、适用 DESIGN.md 章节、主题与 Shadow DOM 注入链路。","python -c ""import csv; rows=list(csv.DictReader(open('docs/developer/architecture-ui-optimization-tasks.csv', encoding='utf-8-sig'))); assert rows""","确认 CSV 可被表格工具打开;确认每个任务都有验证与回滚说明。","后续任务都能按 CSV 独立开 PR;每项任务具备不破坏功能与 UI 的检查项。","文档新增或小幅更新;不超过 1 个文档 PR。","如流程不适用,直接 revert 文档改动,不影响产品代码。" -"AUI-001","Phase 1 - Low risk fixes","P1","给 SettingsModal 补 dialog 语义","第一阶段/1. 补强设置弹窗可访问性","ui/accessibility","src/components/SettingsModal.tsx; src/styles/settings.css","AUI-000","为设置弹窗容器增加 role=""dialog""、aria-modal、aria-labelledby;补稳定标题 id;不改弹窗布局。","不得改变 onClose 触发条件;不得改变现有设置页路由、activePage、initialSubTab 和 locateRequest 行为;不得重命名现有 class。","视觉保持像素级接近;仅增加语义属性和必要焦点样式;浅色/深色主题下关闭、最大化、侧栏导航保持原样。","pnpm format:check; pnpm lint:check; pnpm typecheck","打开设置弹窗;点击遮罩关闭;点击关闭按钮关闭;按 Escape 关闭;切换设置页面;最大化/还原;浅色和深色主题各检查一次。","屏幕阅读器能识别设置弹窗为 dialog;标题可被 aria-labelledby 关联;现有设置操作无行为变化。","只改 SettingsModal 语义与少量样式;不拆组件。","如发现 UI 或关闭行为异常,revert 本 PR 即可恢复原弹窗行为。" -"AUI-002","Phase 1 - Low risk fixes","P1","给 SettingsModal 增加焦点陷阱与关闭后焦点恢复","第一阶段/1. 补强设置弹窗可访问性","ui/accessibility","src/components/SettingsModal.tsx; src/hooks/useFocusTrap.ts(optional)","AUI-001","打开设置时聚焦标题或第一个可操作控件;Tab/Shift+Tab 限制在弹窗内;关闭后恢复到触发元素。","不得拦截输入框内正常 Tab 以外的编辑行为;不得影响全局快捷键恢复;不得引入第三方依赖。","焦点环使用现有 focus-visible 风格;不新增遮罩动画;焦点恢复失败时只 warn,不阻断关闭。","pnpm format:check; pnpm lint:check; pnpm typecheck","键盘打开设置;连续 Tab 到末尾回到开头;Shift+Tab 反向循环;在输入框内编辑;关闭后焦点回到设置按钮;验证全局搜索快捷键不被永久吞掉。","键盘用户可完整操作设置弹窗且焦点不逃逸;鼠标操作行为不变。","最多新增一个小 hook;不迁移设置页结构。","如焦点循环影响输入或快捷键,先禁用 focus trap hook 并保留 AUI-001 语义改动。" -"AUI-003","Phase 1 - Low risk fixes","P1","给主面板 tabs 补 tablist/tab/tabpanel 语义","第一阶段/2. 补强主面板 tab 语义与键盘切换","ui/accessibility","src/components/MainPanel.tsx; src/style.css","AUI-000","为 gh-panel-tabs 增加 role=""tablist"";为 tab button 增加 role=""tab""、aria-selected、aria-controls、id;内容区增加 role=""tabpanel""。","不得改变 activeTab 状态来源;不得改变 visibleTabs、tabOrder、拖拽排序和点击切换逻辑;不得重命名现有 class。","active 样式保持不变;focus-visible 样式沿用现有 gh-panel-tab-btn;不同面板宽度下不新增换行或溢出。","pnpm format:check; pnpm lint:check; pnpm typecheck","点击切换大纲/会话/提示词 tab;拖拽排序 tab;折叠/展开面板;左右停靠;浅色和深色主题检查 active 与 hover 状态。","当前 tab 可被 aria-selected 表达;tabpanel 与 tab 正确关联;现有点击和拖拽行为不变。","只补语义属性和必要 id;不重构 MainPanel。","如 tab 渲染或拖拽异常,revert 本 PR 可恢复原按钮结构。" -"AUI-004","Phase 1 - Low risk fixes","P1","给主面板 tabs 增加键盘导航","第一阶段/2. 补强主面板 tab 语义与键盘切换","ui/interaction","src/components/MainPanel.tsx","AUI-003","支持 ArrowLeft/ArrowRight、Home、End 在可见 tab 间切换;不改变鼠标点击和拖拽排序。","仅在 tab 按钮获得焦点时处理方向键;不得影响页面滚动、输入框、全局快捷键;不得改变 tabOrder 存储。","键盘切换后焦点留在对应 tab;不触发额外动画;折叠/边缘悬停状态下不强制展开面板。","pnpm format:check; pnpm lint:check; pnpm typecheck","用键盘切换 3 个 tab;自定义 tab 顺序后再切换;隐藏某个 tab 后再切换;确认输入框内方向键仍用于移动光标。","键盘可切换所有可见 tab;不会影响其它快捷键和拖拽排序。","只改 tab 键盘事件;不拆 MainPanel。","如快捷键冲突,移除 onKeyDown 即可回滚到点击切换。" -"AUI-005","Phase 1 - Low risk fixes","P1","将 WebDAVProvider 移到共享类型文件","第一阶段/3. 修正设置类型边界","refactor/types","src/types/settings.ts or src/types/webdav.ts; src/utils/storage.ts; src/core/webdav-sync.ts","AUI-000","移动 WebDAVProvider 等纯类型,消除 utils/storage 对 core/webdav-sync 的 type-only 依赖。","不得改变 WebDAV 运行时代码、备份格式、Settings 字段名、默认值;不得修改存储 key。","不改 WebDAV 设置 UI 文案、布局和表单行为。","pnpm format:check; pnpm lint:check; pnpm typecheck","打开 WebDAV 设置页;确认 provider 选择、保存、显示状态不变;不需要实际连接远端服务。","typecheck 通过;utils 层不再引用 core/webdav-sync;产物行为无变化。","纯类型移动;不做 settings 拆分。","如出现类型或导入问题,恢复原类型导出位置。" -"AUI-006","Phase 1 - Low risk fixes","P1","让 subscribeModuleUpdates 返回 unsubscribe","第一阶段/4. 为核心订阅和 URL 监听增加 cleanup","refactor/core-lifecycle","src/core/modules-init.ts; src/contents/main.ts; src/platform/userscript/entry.tsx","AUI-000","将设置订阅函数返回取消订阅函数;初始化入口保存 disposer;不改变任何模块 update 逻辑。","不得改变 settings 热更新顺序;不得移除现有模块更新分支;不得改变用户设置写入和跨上下文同步。","主题切换、布局设置、面板避让、模型锁定等设置变化后 UI 必须即时更新。","pnpm format:check; pnpm lint:check; pnpm typecheck; pnpm build","修改主题;修改面板宽度;切换 Zen/Clean Mode;切换模型锁定;确认无需刷新即可生效。","现有热更新行为不变;调用方可在卸载时取消订阅。","只改订阅返回值和调用方保存;不拆 modules-init。","如设置热更新异常,恢复 subscribeModuleUpdates 原返回 void 实现。" -"AUI-007","Phase 1 - Low risk fixes","P1","让 initUrlChangeObserver 返回 cleanup","第一阶段/4. 为核心订阅和 URL 监听增加 cleanup","refactor/core-lifecycle","src/core/modules-init.ts; src/contents/main.ts; src/platform/userscript/entry.tsx","AUI-006","URL observer 返回 cleanup,负责移除 popstate/hashchange、恢复 history patch、清理 interval 和 pending timeout。","不得改变 URL 变化事件派发;不得改变阅读历史恢复延迟;不得改变 tabManager、usageCounter、modelLocker 的 URL change 行为。","SPA 切换新对话后大纲、会话标题、使用量计数、阅读历史恢复保持原样。","pnpm format:check; pnpm lint:check; pnpm typecheck; pnpm build; pnpm build:userscript","在 ChatGPT/Gemini/Claude 任一站点切换会话;浏览器后退/前进;新对话;确认大纲刷新、标题更新、阅读历史恢复和计数面板正常。","功能行为不变;初始化入口具备完整 URL observer 清理路径。","只补 cleanup;不改 URL change 业务逻辑。","如 SPA 导航失效,恢复原 patch 和 interval 逻辑。" -"AUI-008","Phase 1 - Low risk fixes","P2","替换高风险 transition: all","第一阶段/5. 替换高风险 transition: all","ui/css","src/style.css; src/styles/*.css","AUI-000","逐个样式域将 transition: all 改为明确属性;每个 PR 只处理一个样式域。","不得改变 class 名、CSS 变量名、主题 fallback;不得引入新的强动画;不得改布局尺寸。","处理前后截图对比;浅色/深色主题、hover、active、focus-visible、disabled 状态保持一致。","pnpm format:check; pnpm lint:check; pnpm typecheck","检查面板按钮、tab、设置控件、快捷按钮、弹窗打开关闭;确认无闪烁、无状态丢失、无布局跳动。","目标样式域 transition: all 清零或显著减少;视觉行为基本一致。","每个 PR 只处理一个 CSS 文件或一个组件域;不混入视觉重设计。","如出现动效或布局回归,按样式域 revert。" -"AUI-009","Phase 1 - Low risk fixes","P2","补充 reduced motion 基础覆盖","UI 与交互专项建议/动效与 reduced motion","ui/css-accessibility","src/style.css; src/styles/settings.css; src/styles/queue-overlay.css","AUI-008","为强 transform、循环动画、自动滚动相关视觉效果补 prefers-reduced-motion: reduce 覆盖。","只在用户启用 reduced motion 时改变动画;默认动效不变;不得删除必要状态反馈。","reduced motion 下保留可见状态变化,例如 active、loading、disabled;不让用户失去操作反馈。","pnpm format:check; pnpm lint:check; pnpm typecheck","浏览器模拟 reduced motion;打开面板、设置、队列、快捷按钮;确认无强动画且状态仍清楚。","reduced motion 用户不再看到主要强动效;普通用户视觉不变。","只补 CSS media query;不改组件逻辑。","如 reduced motion 下状态不可见,恢复对应 media query 并重新设计状态反馈。" -"AUI-010","Phase 2 - Structure reduction","P1","从 App.tsx 抽出全局搜索常量和纯函数","第二阶段/6. 从 App.tsx 拆出全局搜索控制器","refactor/ui","src/components/App.tsx; src/components/global-search/*","AUI-000","先移动全局搜索 category、label、语法、过滤相关纯常量/纯函数;不移动状态和渲染。","不得改变搜索结果排序、过滤语法、快捷键、空态文案;不得新增 i18n key。","搜索浮层视觉、提示、预览位置保持不变。","pnpm format:check; pnpm lint:check; pnpm typecheck","打开全局搜索;搜索设置、Prompt、Tips;使用过滤语法;确认结果和原行为一致。","App.tsx 行数下降;全局搜索结果行为无变化。","只移动纯代码;不改 UI 结构。","如搜索结果变化,恢复常量和纯函数到 App.tsx。" -"AUI-011","Phase 2 - Structure reduction","P1","从 App.tsx 抽出 useGlobalSearchController","第二阶段/6. 从 App.tsx 拆出全局搜索控制器","refactor/ui","src/components/App.tsx; src/components/global-search/useGlobalSearchController.ts","AUI-010","移动全局搜索状态、输入 debounce、键盘导航、预览延迟和 shortcut nudge 控制逻辑到 hook。","不得改变快捷键绑定、打开来源、搜索 debounce、预览延迟、localStorage nudge key;不得吞掉其它快捷键。","浮层打开位置、键盘高亮、Prompt 预览、空态、语法提示视觉不变。","pnpm format:check; pnpm lint:check; pnpm typecheck; pnpm build","快捷键打开搜索;键盘上下选择;Enter 跳转;Esc 关闭;Prompt 预览;设置定位高亮;连续打开关闭。","全局搜索端到端行为不变;App.tsx 只保留挂载与回调。","只抽 hook;不改搜索 UI 样式。","如搜索交互回归,恢复控制逻辑到 App.tsx。" -"AUI-012","Phase 2 - Structure reduction","P1","从 PromptsTab.tsx 抽出 PromptEditorDialog","第二阶段/7. 拆分 PromptsTab.tsx","refactor/ui","src/components/PromptsTab.tsx; src/components/prompts/PromptEditorDialog.tsx","AUI-000","先抽出新增/编辑 Prompt 弹窗,保持 props 显式传入,不改变 store 和保存逻辑。","不得改变 Prompt 字段、保存/取消/删除行为、变量识别、分类选择;不得改变已有 i18n key。","弹窗尺寸、按钮顺序、表单 focus、错误提示、浅深主题保持不变。","pnpm format:check; pnpm lint:check; pnpm typecheck","新增 Prompt;编辑 Prompt;取消编辑;分类修改;变量 Prompt 保存;确认列表刷新和选中状态正常。","编辑弹窗独立组件化;Prompt CRUD 行为无变化。","只抽一个弹窗;不碰导入、预览、Chain。","如保存或表单状态异常,恢复弹窗代码到 PromptsTab。" -"AUI-013","Phase 2 - Structure reduction","P1","从 PromptsTab.tsx 抽出 PromptPreviewModal 和 ImportDialog","第二阶段/7. 拆分 PromptsTab.tsx","refactor/ui","src/components/PromptsTab.tsx; src/components/prompts/PromptPreviewModal.tsx; src/components/prompts/PromptImportDialog.tsx","AUI-012","拆出预览和导入弹窗;导入解析函数保持测试前后结果一致。","不得改变导入格式、错误处理、重复 Prompt 策略、预览 markdown 渲染;不得静默吞掉导入错误。","预览排版、滚动、关闭方式、导入确认流程保持一致。","pnpm format:check; pnpm lint:check; pnpm typecheck","预览普通 Prompt 和变量 Prompt;导入有效 JSON;导入无效 JSON;取消导入;确认错误提示清楚且不写入错误数据。","预览/导入弹窗独立;导入导出兼容旧数据。","只抽预览和导入;不改拖拽或 Chain。","如导入兼容性异常,恢复到原 PromptsTab 实现。" -"AUI-014","Phase 2 - Structure reduction","P1","从 PromptsTab.tsx 抽出 Prompt/Chain 拖拽排序 hook","第二阶段/7. 拆分 PromptsTab.tsx; UI 与交互专项建议/拖拽排序的非鼠标替代","refactor/ui-interaction","src/components/PromptsTab.tsx; src/components/prompts/usePromptDragSort.ts","AUI-013","将 Prompt 与 Chain 拖拽排序状态、drop indicator、清理逻辑抽成 hook;不改变排序算法。","不得改变拖拽数据结构、排序结果、分类内/跨分类移动行为;拖拽结束必须清理状态。","拖拽 hover、drop indicator、dragging 样式保持一致;键盘替代路径另开任务,不混入。","pnpm format:check; pnpm lint:check; pnpm typecheck","Prompt 同分类排序;跨分类移动;Chain 排序;拖拽取消;拖拽后刷新确认顺序持久化。","拖拽行为完全等价;PromptsTab 职责减少。","只抽 hook;不改 UI 文案或布局。","如排序错乱,恢复原拖拽逻辑。" -"AUI-015","Phase 2 - Structure reduction","P1","拆分 Settings 类型与默认值","第二阶段/8. 拆分 settings schema、默认值、normalize 和 selector","refactor/settings","src/types/settings.ts; src/constants/default-settings.ts; src/utils/storage.ts","AUI-005","移动 Settings 相关类型和 DEFAULT_SETTINGS 到独立文件;utils/storage 保持兼容 re-export。","不得改变 DEFAULT_SETTINGS 的任何值;不得改变导出名称;不得改变持久化 key 和备份 schema。","设置页默认值、重置设置、站点特定设置显示保持一致。","pnpm format:check; pnpm lint:check; pnpm typecheck; pnpm build","打开设置页;重置设置;切换主题;检查面板默认位置/宽度;确认无默认值漂移。","类型和默认值独立;旧 import 仍可工作或一次性安全迁移。","只移动类型和默认值;不改 normalize。","如默认值或导入异常,恢复 DEFAULT_SETTINGS 到 utils/storage。" -"AUI-016","Phase 2 - Structure reduction","P1","拆分 settings normalize 和 site selectors","第二阶段/8. 拆分 settings schema、默认值、normalize 和 selector","refactor/settings","src/utils/settings-normalize.ts; src/utils/settings-selectors.ts; src/stores/settings-store.ts; src/utils/storage.ts","AUI-015","将 normalizeSettings 及 getSiteTheme/getSitePageWidth 等 selector 拆出;保持 storage 兼容导出。","不得改变迁移和补默认值逻辑;不得改变 previewSettings 与 persistedSettings 合成行为;不得改变站点设置优先级。","修改任意设置后面板和目标站点即时响应;主题预览和恢复设置正常。","pnpm format:check; pnpm lint:check; pnpm typecheck; pnpm build; pnpm build:userscript","修改站点主题、页面宽度、用户问题宽度、Zen Mode、Clean Mode、模型锁定;备份恢复旧设置样本。","normalize 和 selector 可独立维护;所有设置行为等价。","只拆 settings 工具;不改 UI。","如设置迁移或站点覆盖异常,恢复原 utils/storage 结构。" -"AUI-017","Phase 2 - Structure reduction","P2","为一个低风险核心模块试点 CoreModule 生命周期接口","第二阶段/9. 建立核心模块生命周期接口","refactor/core-lifecycle","src/core/modules-init.ts; one of src/core/model-locker.ts or src/core/scroll-lock-manager.ts","AUI-006; AUI-007","定义 CoreModule 接口并选择一个低风险模块试点 start/update/stop;modules-init 仍兼容旧模块。","不得一次性迁移全部模块;不得改变模块初始化顺序;不得改变设置热更新结果。","试点模块相关 UI 开关和状态提示必须保持原样。","pnpm format:check; pnpm lint:check; pnpm typecheck; pnpm build","按试点模块选择验证:模型锁定开关/重锁定,或滚动锁定开关/提示/页面滚动行为。","生命周期接口可用;旧模块不受影响;试点模块行为等价。","只迁移一个模块;不碰适配器。","如试点模块异常,恢复该模块原 init/update 调用方式。" -"AUI-018","Phase 3 - Long-term architecture","P2","选择一个小站点试点 adapter capability","第三阶段/10. 将适配器改为基础适配器 + capability","refactor/adapter","src/adapters/base.ts; one small adapter such as src/adapters/ima.ts; src/core/* consuming capability","AUI-017","先定义一个最小 capability,例如 ConversationListCapability 或 LayoutCapability,并在一个小站点试点。","不得改所有站点;不得删除 SiteAdapter 现有方法;不得改变站点 match 和注册顺序。","试点站点面板、大纲、会话、设置开关表现保持一致;其它站点不应产生 diff 行为。","pnpm format:check; pnpm lint:check; pnpm typecheck; pnpm build; pnpm build:userscript","试点站点:插入 Prompt、切换会话、打开面板、导出或大纲按 capability 范围验证;其它主站点做最小打开面板冒烟。","capability 可被核心模块安全识别;未实现 capability 的站点行为不变。","只试点一个 capability + 一个站点;不做全量迁移。","如站点能力识别异常,保留类型定义但恢复核心调用到旧方法,或整体 revert。" -"AUI-019","Phase 3 - Long-term architecture","P2","建立轮询任务注册表","第三阶段/11. 统一轮询和 observer 预算","perf/core-lifecycle","src/core/polling-task-registry.ts; selected polling module","AUI-017","新增 PollingTaskRegistry,并选择一个低风险轮询迁入;记录名称、间隔、启动条件、停止条件和 hidden 行为。","不得一次性迁移所有 setInterval;不得改变轮询间隔和触发语义,除非该 PR 明确验证。","迁入模块相关状态更新频率和 UI 提示不能变慢到用户可感知。","pnpm format:check; pnpm lint:check; pnpm typecheck; pnpm build","按迁入模块验证:Mermaid 渲染、复制增强、队列、usage counter 或 tab 状态;切换页面 hidden/visible。","轮询可集中 start/stop;页面隐藏策略明确;原功能等价。","只迁入一个轮询任务。","如更新丢失或频率异常,恢复该模块原 setInterval。" -"AUI-020","Phase 3 - Long-term architecture","P2","为 window.__ophel* 增加全局类型声明","第三阶段/12. 渐进提升 TypeScript 约束","refactor/types","src/types/global.d.ts; src/contents/*.ts; src/hooks/useShortcuts.ts","AUI-000","声明当前使用的 window.__ophel* 字段,减少 main world/content script 中的 window as any。","只补类型;不得改变注入脚本运行时字段名和值;不得改变跨 world 消息协议。","快捷键、滚动锁定、水印处理等依赖全局标记的功能保持原样。","pnpm format:check; pnpm lint:check; pnpm typecheck","打开页面确认 content script 不重复初始化;快捷键定位大纲/会话;滚动锁定开关。","相关 any 数量下降;运行时行为无变化。","只补 global 类型和局部类型替换。","如类型声明影响编译,恢复具体文件的局部 any。" -"AUI-021","Phase 3 - Long-term architecture","P2","为 background messaging 建立 request/response 类型映射","第三阶段/12. 渐进提升 TypeScript 约束","refactor/types","src/utils/messaging.ts; src/background.ts; message callers","AUI-020","为现有消息常量建立类型映射,逐步替换 Promise;先覆盖 2 到 3 个高频消息。","不得改变消息 type 字符串、payload 结构、background 处理流程;不得一次性迁移所有消息。","通知、WebDAV、Claude Session Key、全局 URL 打开等调用方 UI 提示保持一致。","pnpm format:check; pnpm lint:check; pnpm typecheck; pnpm build","按覆盖消息验证对应功能;错误场景仍显示原有错误提示。","覆盖消息调用获得明确 response 类型;未覆盖消息行为不变。","每个 PR 覆盖少量消息;不改协议。","如消息类型影响调用,恢复该消息到旧 sendToBackground 泛型。" -"AUI-022","Phase 2 - UI/Interaction","P2","为拖拽排序提供非鼠标替代路径设计与首个实现","UI 与交互专项建议/拖拽排序的非鼠标替代","ui/accessibility","src/components/MainPanel.tsx or src/components/PromptsTab.tsx; related CSS","AUI-004 or AUI-014","先选择面板 tab 或 Prompt 列表之一,提供上移/下移或移到顶部/底部操作;其它拖拽区域后续跟进。","不得移除现有拖拽;不得改变排序持久化格式;不得改变默认 tab/prompt 顺序。","新增操作入口不能增加视觉噪音;小宽度面板下不挤压主要内容;键盘焦点顺序清晰。","pnpm format:check; pnpm lint:check; pnpm typecheck","鼠标拖拽排序仍可用;键盘或菜单排序可用;刷新后顺序持久化;浅深主题检查新增入口。","至少一个排序场景可不用鼠标完成;原拖拽功能无回归。","只实现一个场景;不全量铺开。","如新增入口造成布局或排序 bug,隐藏入口并保留原拖拽。" diff --git a/docs/developer/architecture.md b/docs/developer/architecture.md index 7c2d4ae7f..7f7479d04 100644 --- a/docs/developer/architecture.md +++ b/docs/developer/architecture.md @@ -1,5 +1,7 @@ # Ophel Atlas - AI 对话结构化与导航工具 +> 最近校验:2026-07-10。本文描述当前 `refactor/project-audit-remediation` 架构;历史实施计划不作为运行时事实来源。 + ## 项目愿景 Ophel 是一款跨平台浏览器扩展(同时支持油猴脚本),将 AI 对话转化为可阅读、可导航、可复用的知识内容。通过实时大纲、会话文件夹与 Prompt 词库,让 AI 对话告别无限滚动,成为可组织、可沉淀的工作流。 @@ -29,10 +31,14 @@ Ophel 是一款跨平台浏览器扩展(同时支持油猴脚本),将 AI 本项目采用 **适配器模式 (Adapter Pattern)** 实现多站点支持: ``` -Content Script (main.ts) +Content Script (site-*.ts + main.ts / userscript entry) + | + v +扩展:site-*.ts 静态注册唯一 SiteAdapter --> main.ts 启动公共模块 +油猴:loadAdapter(location) --> hostname/path 轻量路由 --> 动态 import 当前 SiteAdapter | v -getAdapter() --> SiteAdapter (抽象基类) +adapter-runtime.ts --> main、UI 与 userscript 共享当前 adapter 实例 | | | +-- GeminiAdapter | +-- GeminiEnterpriseAdapter @@ -51,7 +57,7 @@ getAdapter() --> SiteAdapter (抽象基类) | +-- ImaAdapter | v -initCoreModules(ctx) --> 13 个核心模块 +initCoreModules(ctx) --> 核心模块编排与设置热更新 | v App.tsx (Shadow DOM 内的 React 面板) @@ -122,7 +128,7 @@ graph TD | `src/core/` | 核心业务模块(多个管理器),由 `modules-init.ts` 统一编排 | `modules-init.ts`, `outline-manager.ts`, `theme-manager.ts`, `layout-manager.ts`, `conversation/`, `webdav-sync.ts`, `assistant-mermaid-renderer.ts`, `gemini-mystuff-bridge.ts`, `usage-counter-manager.ts` | | `src/components/` | React UI 层,运行在 Shadow DOM 中 | `App.tsx`, `MainPanel.tsx`, `OutlineTab.tsx`, `ConversationsTab.tsx`, `SettingsModal.tsx`, `global-search/` | | `src/stores/` | Zustand 状态管理,persist 到 chrome.storage.local | `settings-store.ts`, `conversations-store.ts`, `prompts-store.ts`, `folders-store.ts`, `tags-store.ts` | -| `src/contents/` | Plasmo Content Script 入口 | `main.ts`(逻辑入口), `ui-entry.tsx`(UI/Shadow DOM), `monitor-entry.ts`(网络监控), `aistudio-preload.ts`, `gemini-watermark-main.ts`, `iframe-scroll-main.ts`, `scroll-lock-main.ts` | +| `src/contents/` | Plasmo Content Script 入口 | `site-*.ts`(站点适配器静态注册), `main.ts`(公共逻辑入口), `ui-entry.tsx`(UI/Shadow DOM), `monitor-entry.ts`(网络监控), `aistudio-preload.ts`, `gemini-watermark-main.ts`, `iframe-scroll-main.ts`, `scroll-lock-main.ts` | | `src/platform/` | 平台抽象层(浏览器扩展 vs 油猴脚本) | `types.ts`, `index.ts`, `extension/`, `userscript/` | | `src/utils/` | 工具函数 | `i18n.ts`, `dom-toolkit.ts`, `exporter.ts`, `markdown.ts`, `themes/`, `scroll-helper.ts` | | `src/constants/` | 常量与默认配置 | `defaults.ts`(SITE_IDS), `ui.ts`, `shortcuts.ts`, `tools-menu.ts` | @@ -185,7 +191,8 @@ pnpm docs:build # 构建文档 | 入口 | 路径 | 说明 | | ------------------------- | ----------------------------------- | ------------------------------------- | -| Content Script 逻辑入口 | `src/contents/main.ts` | 初始化适配器和核心模块 | +| Content Script 适配器入口 | `src/contents/site-*.ts` | 按站点静态注册唯一适配器 | +| Content Script 逻辑入口 | `src/contents/main.ts` | 使用已注册适配器初始化核心模块 | | Content Script UI 入口 | `src/contents/ui-entry.tsx` | Shadow DOM 内挂载 React App | | Background Service Worker | `src/background.ts` | 消息处理、通知、代理请求、Cookie 管理 | | Popup 页面 | `src/popup.tsx` | 扩展弹出窗口 | @@ -282,27 +289,33 @@ import { useSettingsStore } from "~stores/settings-store" ## 测试策略 -**当前状态:项目尚未建立测试体系。** 源码中无 test/spec 文件,`package.json` 中无测试相关命令或依赖(如 Jest、Vitest、Playwright)。 +项目使用 Vitest + jsdom 覆盖纯逻辑、生命周期和关键 UI primitive。测试入口为 `pnpm test` / `pnpm test:run`,当前覆盖: -建议优先引入的测试: -1. **单元测试**:适配器的 `match()` / `getSiteId()` 逻辑、`backup-validator.ts`、`format.ts`、`syntax.ts`(搜索语法解析) -2. **集成测试**:`modules-init.ts` 的模块编排、`webdav-sync.ts` 的同步逻辑 -3. **E2E 测试**:使用 Playwright + 浏览器扩展测试框架验证核心用户流程 +1. 适配器轻量路由与共享 runtime。 +2. 设置归一化、persist write gate、备份 schema 与旧格式兼容。 +3. ModelLocker、OutlineManager、PollingTaskRegistry 生命周期。 +4. i18n 按需加载与 11 种语言 key 一致性。 +5. 全局搜索语法、设置深链、主题预置与 token 完整性。 +6. Dialog、Switch、SelectedPromptBar 等交互回归。 + +生产构建后运行 `pnpm check:bundle-budget`,校验扩展内容脚本和 userscript 的 raw/gzip 预算。UI 响应式和键盘行为使用 Playwright 做最小浏览器冒烟;真实站点 DOM 兼容仍需按 adapter 做手动回归。 ## CSS 架构 ### 样式文件清单 -项目共 **7 个 CSS 文件**(纯原生 CSS,无预处理器),总计 ~5,974 行: +截至 2026-07-10,`src/` 下共有 **9 个 CSS 文件**(纯原生 CSS,无预处理器),总计约 10,484 行: | 文件路径 | 行数 | 用途 | | -------------------------------- | ----- | ----------------------------------------------- | -| `src/style.css` | 1,472 | 主样式(大纲面板、快捷按钮、tooltip 等核心 UI) | -| `src/styles/settings.css` | 2,081 | 设置页面(Options Page)样式 | -| `src/styles/conversations.css` | 1,313 | 会话 Tab 样式(文件夹、标签、搜索、批量操作) | -| `src/styles/queue-overlay.css` | 395 | Prompt Queue 排队叠加层样式 | -| `src/styles/theme-variables.css` | 286 | CSS 变量定义文件(浅色/深色模式默认值) | -| `src/popup.css` | 395 | 浏览器扩展 Popup 页面样式 | +| `src/style.css` | 4,463 | 主样式(面板、大纲、快捷按钮、通用组件) | +| `src/styles/settings.css` | 3,727 | 设置弹窗与 Options 共享样式 | +| `src/styles/conversations.css` | 1,344 | 会话 Tab 样式 | +| `src/styles/queue-overlay.css` | 778 | Prompt Queue 覆盖层样式 | +| `src/styles/release-notes.css` | 421 | 更新日志弹窗样式 | +| `src/styles/overlays.css` | 264 | Dialog、Menu、Tooltip 等统一覆盖层样式 | +| `src/styles/theme-variables.css` | 313 | CSS 变量默认值 | +| `src/popup.css` | 613 | 浏览器扩展 Popup 页面样式 | | `src/tabs/options.css` | 32 | Options 页面入口(仅 @import) | ### 样式技术方案 @@ -316,7 +329,7 @@ import { useSettingsStore } from "~stores/settings-store" | 样式隔离 | Shadow DOM(`:host` 选择器 + 动态 style 注入) | | 命名前缀 | `--gh-*`(项目前身 Gemini Helper 缩写) | | 布局 | Flexbox 为主,Grid 辅助,固定定位用于浮动 UI | -| 响应式 | 极少媒体查询,主要依赖 JS 动态类名(`.is-narrow`) | +| 响应式 | 面板动态状态 + Options 900/720/640 px 明确断点 | | 现代特性 | `color-mix()`、`backdrop-filter`、View Transitions、`scrollbar-width` | ### 主题系统 @@ -367,15 +380,17 @@ src/utils/themes/ **两种 Shadow Host**:``(扩展模式)、`
`(油猴模式)。 -**静态样式**(初始化时):`ui-entry.tsx` 通过 `getStyle()` 将三个 CSS 文件合并注入 Shadow DOM `
{t("segmentedExportTitle")}
diff --git a/src/components/SelectedPromptBar.tsx b/src/components/SelectedPromptBar.tsx index 5009db5c6..25c3dbb2c 100644 --- a/src/components/SelectedPromptBar.tsx +++ b/src/components/SelectedPromptBar.tsx @@ -56,6 +56,7 @@ export const SelectedPromptBar: React.FC = ({ // 如果没有输入框引用或输入框不在 DOM 中,使用默认位置 if (!textarea || !textarea.isConnected) { + const hadLayoutSnapshot = lastLayoutSnapshotRef.current !== null setBottomPosition((current) => current === DEFAULT_BOTTOM_POSITION ? current : DEFAULT_BOTTOM_POSITION, ) @@ -63,7 +64,7 @@ export const SelectedPromptBar: React.FC = ({ current === DEFAULT_LEFT_POSITION ? current : DEFAULT_LEFT_POSITION, ) lastLayoutSnapshotRef.current = null - return + return hadLayoutSnapshot } const observedElement = observedElementRef.current @@ -99,7 +100,7 @@ export const SelectedPromptBar: React.FC = ({ lastLayoutSnapshot.height === layoutSnapshot.height && lastLayoutSnapshot.viewportHeight === layoutSnapshot.viewportHeight ) { - return + return false } lastLayoutSnapshotRef.current = layoutSnapshot @@ -116,6 +117,7 @@ export const SelectedPromptBar: React.FC = ({ // 横向跟随输入容器中心,避免在有侧边栏时按整个页面居中。 const nextLeftPosition = `${Math.round(layoutSnapshot.left + layoutSnapshot.width / 2)}px` setLeftPosition((current) => (current === nextLeftPosition ? current : nextLeftPosition)) + return true }, [adapter, findInputContainer]) useEffect(() => { @@ -124,9 +126,35 @@ export const SelectedPromptBar: React.FC = ({ const textarea = adapter?.getTextareaElement() // 创建 ResizeObserver 监听输入框容器尺寸变化 - resizeObserverRef.current = new ResizeObserver(() => { - updatePosition() - }) + let scheduledFrameId: number | null = null + let trackingFrameId: number | null = null + let trackingDeadline = 0 + + const scheduleUpdate = () => { + if (scheduledFrameId !== null) return + scheduledFrameId = window.requestAnimationFrame(() => { + scheduledFrameId = null + updatePosition() + }) + } + + const startBoundedTracking = (durationMs = 700) => { + trackingDeadline = Math.max(trackingDeadline, performance.now() + durationMs) + if (trackingFrameId !== null) return + + const trackPosition = (timestamp: number) => { + updatePosition() + if (timestamp < trackingDeadline) { + trackingFrameId = window.requestAnimationFrame(trackPosition) + } else { + trackingFrameId = null + } + } + + trackingFrameId = window.requestAnimationFrame(trackPosition) + } + + resizeObserverRef.current = new ResizeObserver(scheduleUpdate) // 如果能找到输入框,开始监听其容器 if (textarea) { @@ -140,25 +168,27 @@ export const SelectedPromptBar: React.FC = ({ // 选中时多次延迟更新(处理输入框容器还未渲染完成的情况) const delays = [50, 200, 400] - const timeoutIds = delays.map((delay) => setTimeout(updatePosition, delay)) - - // 跟随 CSS transition / transform 引起的位置变化,例如站点侧边栏展开收起。 - let animationFrameId: number | null = null - const trackPosition = () => { - updatePosition() - animationFrameId = window.requestAnimationFrame(trackPosition) - } - animationFrameId = window.requestAnimationFrame(trackPosition) + const timeoutIds = delays.map((delay) => setTimeout(scheduleUpdate, delay)) + startBoundedTracking() - // 监听窗口大小变化 - window.addEventListener("resize", updatePosition) + const handleTransitionStart = () => startBoundedTracking() + window.addEventListener("resize", scheduleUpdate) + window.addEventListener("scroll", scheduleUpdate, true) + document.addEventListener("transitionrun", handleTransitionStart, true) + document.addEventListener("animationstart", handleTransitionStart, true) + window.visualViewport?.addEventListener("resize", scheduleUpdate) + window.visualViewport?.addEventListener("scroll", scheduleUpdate) return () => { - window.removeEventListener("resize", updatePosition) + window.removeEventListener("resize", scheduleUpdate) + window.removeEventListener("scroll", scheduleUpdate, true) + document.removeEventListener("transitionrun", handleTransitionStart, true) + document.removeEventListener("animationstart", handleTransitionStart, true) + window.visualViewport?.removeEventListener("resize", scheduleUpdate) + window.visualViewport?.removeEventListener("scroll", scheduleUpdate) timeoutIds.forEach((id) => clearTimeout(id)) - if (animationFrameId !== null) { - window.cancelAnimationFrame(animationFrameId) - } + if (scheduledFrameId !== null) window.cancelAnimationFrame(scheduledFrameId) + if (trackingFrameId !== null) window.cancelAnimationFrame(trackingFrameId) if (resizeObserverRef.current) { resizeObserverRef.current.disconnect() resizeObserverRef.current = null diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index df7745a49..ba23a8015 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -4,24 +4,16 @@ */ import React, { useEffect, useRef, useState } from "react" -import { - AboutIcon, - AppearanceIcon, - BackupIcon, - ClearIcon, - FeaturesIcon, - GeneralIcon, - KeyboardIcon, - MaximizeIcon, - PageContentIcon, - PermissionsIcon, - RestoreIcon, - SearchIcon, -} from "~components/icons" +import { ClearIcon, MaximizeIcon, RestoreIcon } from "~components/icons" import ModelLockSettingsContent from "~components/ModelLockSettingsContent" import { Tooltip } from "~components/ui/Tooltip" import { SidebarCommunityLinks } from "~components/SidebarCommunityLinks" -import { NAV_IDS, resolveSettingsNavigateDetail, type SettingsNavigateDetail } from "~constants" +import { + getSettingsNavigationItems, + NAV_IDS, + resolveSettingsNavigateDetail, + type SettingsNavigateDetail, +} from "~constants" import { platform } from "~platform" import { useSettingsHydrated, useSettingsStore } from "~stores/settings-store" import { SidebarFooter } from "~tabs/options/components/SidebarFooter" @@ -38,11 +30,6 @@ import { APP_DISPLAY_NAME, APP_ICON_URL } from "~utils/config" import { attachEditableKeyboardFocusGuard, OPHEL_INTERACTION_LAYER_PROPS } from "~utils/dom-toolkit" import { setLanguage, t } from "~utils/i18n" -const getLocalizedLabel = (labelKey: string, fallback: string): string => { - const localized = t(labelKey) - return localized === labelKey ? fallback : localized -} - const FOCUSABLE_SETTINGS_MODAL_SELECTOR = [ "a[href]", "button:not([disabled])", @@ -73,44 +60,6 @@ const getFocusableElements = (container: HTMLElement): HTMLElement[] => isFocusableElementVisible, ) -// 导航菜单定义 -const NAV_ITEMS = [ - { - id: NAV_IDS.GENERAL, - Icon: GeneralIcon, - labelKey: "navGeneral", - label: "基本设置", - }, - { - id: NAV_IDS.APPEARANCE, - Icon: AppearanceIcon, - labelKey: "navAppearance", - label: "外观主题", - }, - { id: NAV_IDS.FEATURES, Icon: FeaturesIcon, labelKey: "navFeatures", label: "功能模块" }, - { - id: NAV_IDS.SITE_SETTINGS, - Icon: PageContentIcon, - labelKey: "navSiteSettings", - label: "站点配置", - }, - { - id: NAV_IDS.GLOBAL_SEARCH, - Icon: SearchIcon, - labelKey: "navGlobalSearch", - label: "全局搜索", - }, - { id: NAV_IDS.SHORTCUTS, Icon: KeyboardIcon, labelKey: "navShortcuts", label: "快捷键位" }, - { id: NAV_IDS.BACKUP, Icon: BackupIcon, labelKey: "navBackup", label: "数据管理" }, - { - id: NAV_IDS.PERMISSIONS, - Icon: PermissionsIcon, - labelKey: "navPermissions", - label: "权限管理", - }, - { id: NAV_IDS.ABOUT, Icon: AboutIcon, labelKey: "navAbout", label: "关于" }, -] - interface SettingsModalProps { isOpen: boolean onClose: () => void @@ -144,7 +93,7 @@ export const SettingsModal: React.FC = ({ // 初始化语言 useEffect(() => { if (isHydrated && settings?.language) { - setLanguage(settings.language) + void setLanguage(settings.language) } }, [isHydrated, settings?.language]) @@ -236,7 +185,12 @@ export const SettingsModal: React.FC = ({ const handleNavigate = (e: CustomEvent) => { const resolved = resolveSettingsNavigateDetail(e.detail || {}) - if (resolved.page && NAV_ITEMS.some((item) => item.id === resolved.page)) { + if ( + resolved.page && + getSettingsNavigationItems(platform.hasCapability("permissions")).some( + (item) => item.id === resolved.page, + ) + ) { setActivePage(resolved.page) } @@ -460,12 +414,7 @@ export const SettingsModal: React.FC = ({
{activeFilterChips.length > 0 ? ( -
+
{activeFilterChips.map((chip) => ( + ) + })} +
+ +
+ {activeTab === TAB_IDS.PROMPTS && ( + + )} + {activeTab === TAB_IDS.CONVERSATIONS && ( + + )} + {activeTab === TAB_IDS.OUTLINE && ( + + )} +
+ +) diff --git a/src/components/prompts/ImportDialog.tsx b/src/components/prompts/ImportDialog.tsx index 373b2b0f5..56803a041 100644 --- a/src/components/prompts/ImportDialog.tsx +++ b/src/components/prompts/ImportDialog.tsx @@ -1,8 +1,6 @@ import React from "react" -import { createPortal } from "react-dom" -import { Button } from "~components/ui" -import { OPHEL_HOVER_WIDTH_RETAIN_LAYER_PROPS } from "~utils/dom-toolkit" +import { Button, DialogOverlay } from "~components/ui" import { t } from "~utils/i18n" interface ImportDialogProps { @@ -22,80 +20,46 @@ export const ImportDialog = ({ }: ImportDialogProps) => { if (!isOpen) return null - return createPortal( -
{ - if (e.target === e.currentTarget) { - onClose() - } - }} - style={{ - position: "fixed", - top: 0, - left: 0, - right: 0, - bottom: 0, - background: "var(--gh-overlay-bg, rgba(0, 0, 0, 0.5))", - display: "flex", - alignItems: "center", - justifyContent: "center", - zIndex: 10001, - }}> + return ( + +
{t("promptImportTitle")}
-
- {t("promptImportTitle")} -
-
+
  • {t("promptImportOverwriteDesc")}
  • +
  • {t("promptImportMergeDesc")}
  • + +
    +
    + +
    -
    - - - -
    + {t("promptMerge")} + +
    -
    , - document.body, + ) } diff --git a/src/components/prompts/PromptEditorDialog.tsx b/src/components/prompts/PromptEditorDialog.tsx index c49d68d6e..1aebfa325 100644 --- a/src/components/prompts/PromptEditorDialog.tsx +++ b/src/components/prompts/PromptEditorDialog.tsx @@ -1,8 +1,6 @@ -import React from "react" -import { createPortal } from "react-dom" +import React, { useId } from "react" -import { Button } from "~components/ui" -import { OPHEL_HOVER_WIDTH_RETAIN_LAYER_PROPS } from "~utils/dom-toolkit" +import { Button, DialogOverlay } from "~components/ui" import { getHighlightStyles, renderMarkdown } from "~utils/markdown" import { showCopySuccess } from "~utils/icons" import { t } from "~utils/i18n" @@ -32,248 +30,230 @@ export const PromptEditorDialog: React.FC = ({ onClose, onSave, }) => { + const fieldId = useId().replace(/:/g, "") + const titleInputId = `gh-prompt-title-${fieldId}` + const categoryInputId = `gh-prompt-category-${fieldId}` + const contentInputId = `gh-prompt-content-${fieldId}` if (!isOpen) return null - return createPortal( -
    + return ( +
    e.stopPropagation()} + className="gh-dialog-title" style={{ - background: "var(--gh-bg, white)", - borderRadius: "12px", - width: "90%", - maxWidth: "500px", - padding: "24px", - animation: "slideUp 0.3s", - boxShadow: "var(--gh-shadow, 0 20px 50px rgba(0,0,0,0.3))", + fontSize: "18px", + marginBottom: "20px", }}> -
    + + {/* 标题 */} +
    +
    + {t("title")} + + setEditingPrompt({ ...editingPrompt, title: e.target.value })} + style={{ + width: "100%", + padding: "8px 12px", + border: "1px solid var(--gh-border, #d1d5db)", + borderRadius: "6px", + fontSize: "14px", + boxSizing: "border-box", + background: "var(--gh-bg, #ffffff)", + color: "var(--gh-text, #1f2937)", + }} + /> +
    - {/* 标题 */} -
    -
    + )} +
    - {/* 分类 */} -
    -