diff --git a/README.md b/README.md index 84e269ef..c2b13cb1 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ For the full end-user walkthrough, see [USAGE.md](USAGE.md). ## Build from source (developers) -The active workspace lives in `openless-all/app/`. `crates/openless-core` is the framework-independent backend, `src-tauri` hosts macOS/Windows/Android, and `linux-egui` contains the native Linux UI and its platform adapters. The macOS Tauri build links a vendored C ASR engine ([`Open-Less/qwen-asr`](https://github.com/Open-Less/qwen-asr), forked from `antirez/qwen-asr`) under `src-tauri/vendor/qwen-asr/`; initialize submodules for macOS Tauri development. The root core/Linux workspace deliberately excludes `src-tauri`, so Linux core and host checks neither initialize that submodule nor parse the Tauri manifest. +The active workspace lives in `openless-all/app/`. `crates/openless-core` is the framework-independent backend, `src-tauri` hosts macOS/Windows/Android, and `linux-egui` contains the native Linux UI and its platform adapters. Initialize submodules before a Tauri source build: its manifest resolves local path dependencies even when their target-specific code is not compiled. These include macOS ASR engines such as [`Open-Less/qwen-asr`](https://github.com/Open-Less/qwen-asr) under `src-tauri/vendor/`. The root Core/Linux workspace excludes `src-tauri`, so its independent checks do not parse the Tauri manifest or require those submodules. Start with the [documentation index](docs/index.md), [architecture](docs/architecture.md), and [source structure](docs/structure.md). Rust 1.88 is the minimum supported toolchain for source builds; the latest stable Rust is recommended. CI verifies both Rust 1.88 and stable on macOS, Windows, and Linux. @@ -269,7 +269,7 @@ cd "openless-all/app" npm ci # macOS/Windows/Android: Vite at :1420 + Tauri host -# Initialize submodules first when building the macOS local-ASR target. +# Initialize submodules before resolving the Tauri manifest. git submodule update --init --recursive npm run tauri dev @@ -277,7 +277,7 @@ npm run tauri dev ./scripts/build-mac.sh INSTALL=0 ./scripts/build-mac.sh # build only, skip install -# Shared backend and Linux non-UI host (no Tauri/WebKitGTK) +# Shared backend and Linux Host/UI (no Tauri/WebKitGTK) cargo check -p openless-core cargo check -p openless-linux-egui --all-targets @@ -357,7 +357,7 @@ What features does this app still need? …not a list of missing features. -Long-term reference rewrites are stored as `raw → polished → rule` triples and will be retrieved as similar-example references (never as conversation context) once a vector store is wired in. See [docs/polish-reference-corpus.md](docs/polish-reference-corpus.md) and [Examples/polish-reference-examples.sample.jsonl](Examples/polish-reference-examples.sample.jsonl). +Long-term reference rewrites are represented as `raw → polished → rule` triples in [Examples/polish-reference-examples.sample.jsonl](Examples/polish-reference-examples.sample.jsonl). The sample format describes reference data; current module responsibilities are documented in [Architecture](docs/architecture.md). ## Dictionary @@ -395,7 +395,7 @@ The `v-tauri` / `v-Beta.N-tauri` workflows publish the macOS, The dictation pipeline: `hotkey edge → Recorder.start + ASR.openSession → [audio frames] → hotkey edge → Recorder.stop + ASR.sendLastFrame → Polish → Insert → History.save`. -See [CLAUDE.md](CLAUDE.md) for invariants and module-wiring rules. +See [AGENTS.md](AGENTS.md) for repository rules and [Architecture](docs/architecture.md) for module responsibilities and wiring. ## Roadmap @@ -432,7 +432,7 @@ OpenLess ships two release channels. The branch name equals the channel name (se ### Post-release verification (always run) -Run the 5-step checklist in [`CLAUDE.md` → Branch & release-channel workflow → Channel distribution](CLAUDE.md): page status (pre-release flag), asset-filename channel correctness, Stable user flow, Beta opt-in flow, and raw endpoint sanity. +Follow [RELEASING.md](RELEASING.md) and verify the release page's pre-release flag, asset-filename channel correctness, Stable user flow, Beta opt-in flow, and raw update endpoints. ## Acknowledgements diff --git a/README.zh.md b/README.zh.md index 1ce82016..d5062b15 100644 --- a/README.zh.md +++ b/README.zh.md @@ -263,7 +263,7 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提 ## 从源码构建(开发者) -活跃 workspace 位于 `openless-all/app/`:`crates/openless-core` 是框架无关后端,`src-tauri` 承载 macOS/Windows/Android,`linux-egui` 包含 Linux 原生 UI 与平台 Adapter。macOS Tauri 构建会链接 `src-tauri/vendor/qwen-asr/` 下的 vendored C ASR 引擎([`Open-Less/qwen-asr`](https://github.com/Open-Less/qwen-asr),fork 自 `antirez/qwen-asr`),因此开发 macOS Tauri 目标时需要初始化子模块。根 core/Linux workspace 显式排除了 `src-tauri`,Linux 的 core/host 检查既不初始化该子模块,也不解析 Tauri manifest。 +活跃 workspace 位于 `openless-all/app/`:`crates/openless-core` 是框架无关后端,`src-tauri` 承载 macOS/Windows/Android,`linux-egui` 包含 Linux 原生 UI 与平台 Adapter。源码构建 Tauri 前需初始化子模块:即使不编译对应平台代码,Cargo 仍会解析 manifest 中的本地 path 依赖,其中包括 `src-tauri/vendor/` 下的 macOS ASR 引擎,如 [`Open-Less/qwen-asr`](https://github.com/Open-Less/qwen-asr)。根 Core/Linux workspace 排除了 `src-tauri`,其独立检查不解析 Tauri manifest,也不要求这些子模块。阅读入口为 [docs/index.md](docs/index.md)、[架构](docs/architecture.md)和[目录结构](docs/structure.md)。 Rust 1.88 是从源码构建所支持的最低工具链版本;建议使用最新 stable Rust。CI 会在 macOS、Windows 和 Linux 上同时验证 Rust 1.88 与 stable。 @@ -274,7 +274,7 @@ cd "openless-all/app" npm ci # macOS/Windows/Android:Vite 运行于 :1420 + Tauri 宿主 -# 构建 macOS 本地 ASR 目标前先初始化子模块。 +# 解析 Tauri manifest 前先初始化子模块。 git submodule update --init --recursive npm run tauri dev @@ -282,7 +282,7 @@ npm run tauri dev ./scripts/build-mac.sh INSTALL=0 ./scripts/build-mac.sh # 仅构建,跳过安装 -# 共享后端与 Linux 非 UI host(不含 Tauri/WebKitGTK) +# 共享后端与 Linux Host/UI(不含 Tauri/WebKitGTK) cargo check -p openless-core cargo check -p openless-linux-egui --all-targets @@ -362,7 +362,7 @@ OpenLess 的润色模型只重塑文本。它不回答问题、不执行任务 ……而不是一份缺失功能的清单。 -长期参考改写以 `raw → polished → rule` 三元组存储,待向量库接入后,将作为相似示例参考被检索(绝不作为对话上下文)。见 [docs/polish-reference-corpus.md](docs/polish-reference-corpus.md) 与 [Examples/polish-reference-examples.sample.jsonl](Examples/polish-reference-examples.sample.jsonl)。 +长期参考改写的 `raw → polished → rule` 三元组格式见 [Examples/polish-reference-examples.sample.jsonl](Examples/polish-reference-examples.sample.jsonl)。该文件说明参考数据格式;当前模块职责见[架构文档](docs/architecture.md)。 ## 词典 @@ -400,7 +400,7 @@ egui UI ── Linux Adapter(无 Tauri/WebKitGTK)───┘ 听写流水线:`hotkey edge → Recorder.start + ASR.openSession → [audio frames] → hotkey edge → Recorder.stop + ASR.sendLastFrame → Polish → Insert → History.save`。 -不变式与模块接线规则见 [CLAUDE.md](CLAUDE.md)。 +仓库规则见 [AGENTS.md](AGENTS.md),模块职责与接线见[架构文档](docs/architecture.md)。 ## 路线图 @@ -438,7 +438,7 @@ OpenLess 提供两个发布频道。分支名即频道名(见[贡献流程](#贡 ### 发布后验证(始终执行) -执行 [`CLAUDE.md` → Branch & release-channel workflow → Channel distribution](CLAUDE.md) 中的 5 步清单,并额外核对 Android: +按 [RELEASING.md](RELEASING.md) 验证发布页的 pre-release 标记、产物文件名与渠道、Stable 用户流程、Beta 选择流程和更新端点,并额外核对 Android: 1. Release 页面含 `latest-android-aarch64.json`(Stable 无 `-beta` 后缀混用)。 2. Beta pre-release 含 `latest-android-aarch64-beta.json`,URL 指向具体 tag。 diff --git a/docs/2.0-desktop-acceptance.md b/docs/2.0-desktop-acceptance.md index 41d28ab3..069337c5 100644 --- a/docs/2.0-desktop-acceptance.md +++ b/docs/2.0-desktop-acceptance.md @@ -1,24 +1,25 @@ # 桌面验收清单(Windows / macOS / Android) -状态:要求清单,不是通过声明;更新:2026-09-07。每个功能域给出源码入口与必须提供的真实证据;自动化测试通过不等于设备验收通过。证据分级见[范围](2.0-requirements.md)第 4 节。 +状态:要求清单,不是通过声明;更新:2026-09-08。每个功能域给出源码入口与必须提供的真实证据;自动化测试通过不等于设备验收通过。证据分级见[范围](2.0-requirements.md)第 4 节。 ## 1. 功能域 → 源码入口 → 验收要求 | 域 | 源码入口(`openless-all/app/`) | 必须的真实证据 | | --- | --- | --- | | 启动/生命周期 | `src/App.tsx`(`?window=` 分支)、`src/components/CoreStartupScreen.tsx`、`src-tauri/src/lib.rs` 启动合同 | 启动快照与版本校验、错误可读与重查、崩溃后恢复 | -| 听写 | `src-tauri/src/coordinator/dictation.rs` + Core `dictation_engine.rs` | 首帧、热键触发、取消、静默自动停止、落字 reconciliation、错误恢复 | +| 听写 | `src-tauri/src/coordinator/dictation_core.rs` + Core `dictation_engine.rs` | 首帧、热键触发、取消、静默自动停止、落字 reconciliation、错误恢复 | | 快捷键与插入 | `src-tauri/src/lib.rs`(`global_hotkey_runtime`)、`src/components/ShortcutRecorder.tsx`、`windows-ime/` | 全局注册/冲突提示/重绑/重启还原;Windows IME 落字与回退 | | AI 服务与渠道 | `src/pages/settings/{ChannelList,ProvidersSection}.tsx`、`src-tauri/src/commands/{providers,channels,credentials}.rs` | 渠道增删改/启停/验证结果展示/凭据安全存储(明文不落盘) | | 本地模型 | `src/pages/LocalAsr/`、`src-tauri/src/commands/local_asr.rs`、Core `model_store.rs` | 下载/取消/激活原子性;Windows Foundry/Sherpa;macOS Apple Speech/MLX | | 历史 | `src/pages/History.tsx`、`src-tauri/src/commands/history.rs` | 列表/详情切换(窄窗)、重润色结果保留、录音归档与导出 | +| 词库与纠错 | `src/pages/{Vocab,Corrections}.tsx`、`src-tauri/src/commands/dictionary.rs`、Core `vocabulary.rs` / `correction.rs` | 词库管理、纠错记录展示/删除/批量清理、重启后持久化与错误反馈 | | 风格包与市场 | `src/pages/{Style,Marketplace}.tsx`、`src-tauri/src/commands/{style_packs,marketplace}.rs` | 导入/应用/导入导出失败反馈;市场浏览/安装/OAuth | | 划词与 QA | `src/pages/{SelectionAsk,SelectionPolishPreview,SelectionVoiceIntentPicker,QaPanel}.tsx` | 触发→意图→预览→应用/取消/撤回;epoch 防旧事件覆盖 | | Less Computer | `src/pages/LessComputer{Panel,Glow}.tsx`、`src-tauri/src/coding_agent/` | 工具审批/输出/取消;Windows Job 对象与进程清理 | | 手机输入 | `src-tauri/src/remote_server/`、远程输入页面 | 配对/TLS/会话桥接/弱网与陈旧地址处理 | | 胶囊与多屏 | `src/components/Capsule.tsx`、`src-tauri/src/coordinator/capsule_focus.rs` | 跟随光标所在屏、多显示器/多 Space 正确显示 | | 音频提示 | `src/components/AudioCue.tsx`、`src-tauri/src/audio_mute.rs` | 提示音、系统静音/恢复所有终态恢复音量 | -| 设置与更新 | `src/pages/settings/`(7 分类,`navigation.ts`)、`src/components/{AutoUpdate,AutoUpdateGate}.tsx` | 自动保存提示、焦点返回、更新安装失败可见与手动兜底 | +| 设置与更新 | `src/pages/settings/`(`navigation.ts`)、`src/components/{AutoUpdate,AutoUpdateGate}.tsx` | 自动保存提示、焦点返回、更新安装失败可见与手动兜底 | ## 2. 平台特有门槛 diff --git a/docs/architecture.md b/docs/architecture.md index 0452f73e..972ec667 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,27 +1,47 @@ # OpenLess 2.0 架构 -状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。 +状态:canonical,当前实现说明;更新:2026-09-08。平台范围见 [2.0 需求](2.0-requirements.md),文件定位见 [目录结构](structure.md)。 ## 1. 分层与工作区 -应用唯一源是 `openless-all/app/`。Cargo workspace 成员为 `crates/openless-core` + `linux-egui`;`src-tauri`(及其 `backend-tests` 测试 crate)被 exclude,独立构建。 +应用开发与构建源在 `openless-all/app/`。下文源码路径以该目录为基准。根 [Cargo workspace](../openless-all/app/Cargo.toml) 成员为 `crates/openless-core` + `linux-egui`;`src-tauri`(及其 `backend-tests` 测试 crate)被 exclude,独立构建。Core 是与 Host 同进程的业务库。 | 层 | 位置 | 职责 | | --- | --- | --- | -| 界面(桌面/移动) | `src/`(React/TypeScript/i18next,五语言) | 唯一桌面 UI;页面、设置分类、窗口分支;不另写业务规则 | -| Host(Win/mac/Android) | `src-tauri/`(crate `2.0.0-Beta.1`) | IPC 命令面(`src/lib.rs` 两个 `generate_handler!`,共 188 个注册);原生 Adapter:窗口、热键、音频、凭据、插入、IME、coordinator | -| 共享 Core | `crates/openless-core/`(crate `0.1.0`) | 全部业务规则与状态机;平台动作只经 `ports.rs` 接口注入 | -| Linux Host + UI | `linux-egui/`(crate `openless-linux-egui` 0.1.0) | 与 Core 同进程:`backend.rs` 组装 `OpenLessBackend`,`main.rs` 跑 egui 界面,不依赖 Tauri/WebKitGTK | +| 界面(Win/mac/Android) | `src/`(React/TypeScript/i18next,五语言) | 页面、设置、窗口分支;调用 typed IPC,展示快照与事件 | +| Host(Win/mac/Android) | `src-tauri/`(crate `openless`) | `src/lib.rs` 注册命令;适配窗口、热键、音频、凭据、插入、IME 和生命周期 | +| 共享 Core | `crates/openless-core/` | 业务规则、会话、服务调用和数据仓储;通过 trait 接入 Host 能力 | +| Linux Host + UI | `linux-egui/`(crate `openless-linux-egui`) | `backend.rs` 组装 `OpenLessBackend`,`main.rs` 实现 egui/eframe UI,不依赖 Tauri/WebKitGTK | -Android 侧:`src-tauri/src/android/`(JNI/桥接)+ `android/`(aidl、kotlin、manifests、frontend);`android/frontend` 经 vite 别名 `@android` 被 `src/` 引用;manifest 由 `scripts/merge-android-*.mjs` 合成。 +Android 侧:`src-tauri/src/android/`(JNI/桥接)+ `android/`(aidl、kotlin、manifests、frontend);`android/frontend` 经 Vite 别名 `@android` 被 `src/` 引用;manifest 由 `scripts/merge-android-*.mjs` 合成。Linux 已有可复用 Host/UI 起点,剩余能力与产品验收见 [交接目录](linux-egui-handoff/README.md)。 ## 2. 数据流 +```mermaid +flowchart TB + React["React 页面 / 窗口"] --> IPC["src/lib/ipc · typed wrapper"] + IPC --> Tauri["Tauri commands / coordinator / core_adapters"] + Egui["egui UI"] --> Linux["LinuxHost / LinuxBackendBuilder"] + Tauri --> Core["OpenLessBackend · Core"] + Linux --> Core + Core --> Stores["历史 / 设置 / 词库 / 风格包仓储"] + Core --> Network["云端 provider / 风格包 API"] + Core --> Ports["录音 / 插入 / 凭据 / 本地模型等接口"] + Ports --> Native["Host 原生实现"] + Core --> Events["BackendEvent · 语义事件"] + Events --> Tauri + Events --> Linux +``` + - 桌面:React → 类型化 IPC 门面(`src/lib/ipc/`)→ Tauri command → Core;Core 事件由 Host 转发回界面。 - Linux:egui UI → `LinuxHost`(`lib.rs`:`snapshot` / `subscribe` / `save_settings` / `drain_events` 等)→ `OpenLessBackend` → Core,类型化 Rust 接口,不经 IPC。 - 浏览器预览:provider 公开目录由 Core 生成到 `src/lib/ipc/provider-descriptors.generated.json`(`cargo run --locked -p openless-core --example export_provider_descriptors` 重新生成;只含公开元数据,无凭据);原生端走同一受启动合同保护的 IPC。 - 旧 React command/event 名称只保留在 Tauri 兼容 Adapter;跨平台合同以 `contract/backend-2.0.json` 为准。 +启动时,`src/App.tsx` 经 `src/lib/ipc/shared.ts` 请求 `get_startup_snapshot`,校验合同版本和 backend 运行状态后进入业务界面。Core 事件定义在 `events.rs`,Tauri 的转译入口为 `src-tauri/src/tauri_events.rs`,Linux 直接订阅类型化事件。 + +听写主链由 `dictation_engine.rs` 管理:触发会话 → 录音/ASR → 清理与润色 → Host 插入 → 历史与事件。Tauri 在 `coordinator/dictation_core.rs` 接入该链路;本地 ASR 的模型管理归 Core,原生执行实现分别位于 Host。取消、失败和旧会话事件处理也属于该业务链,而不是页面各自实现。 + ## 3. Core 模块地图(按域,见 `src/lib.rs` pub mod 清单) - 听写链路:`dictation_engine` / `dictation_context` / `audio` / `external_audio` / `silence_auto_stop` / `streaming_insert` / `hotkey_interpreter` / `voice_session` @@ -34,12 +54,42 @@ Android 侧:`src-tauri/src/android/`(JNI/桥接)+ `android/`(aidl、kotl ## 4. Host 注入点 -Core 不直接做平台动作;Host 通过 `ports.rs` 接口注入能力。注入清单以 `linux-egui/src/backend.rs` 的 builder 为准:`with_task_spawner` / `with_recorder` / `with_auxiliary_polisher` / `with_text_inserter` / `with_credential_store` / `with_services` / `with_host_actions` / `with_settings_runtime` / `with_local_asr_runtime` / `with_polish_failure_policy`。业务规则缺失时回报 Core 修复,不在 UI/Host 复制规则。 +操作系统集成由 Host 提供。Core 的 `ports.rs`、`config.rs`、`domains.rs` 和 `credentials.rs` 定义录音、插入、任务执行、系统动作、领域运行时及凭据接口;Core 自身仍包含 HTTP 调用和框架无关的文件仓储。 + +Tauri 在 `src-tauri/src/coordinator.rs` 构造 Core,`core_adapters.rs` 组装原生依赖并对接已有 persistence。Linux 在 `linux-egui/src/backend.rs` 使用 `LinuxBackendBuilder`,注入音频、凭据、服务、设置、本地 ASR 和平台动作。`BackendConfig` 由 Host 提供数据、缓存、资源路径与平台能力。 + +业务规则缺失时应修复 Core;平台能力缺失时修复对应 Host。设置值、测试 fixture 或 `Unsupported` 实现不能代表原生能力已就绪。 ## 5. 窗口体系 -`tauri.conf.json` 声明 `main`、`capsule` 两个窗口;其余窗口由运行时按 `?window=` 种类创建(`src/App.tsx` 分支):`qa`、`selection` 追问、划词润色预览、语音意图选择、Less Computer(含 glow 变体)与移动端变体。Linux 单实例由 `linux-egui/src/single_instance.rs` 守护并转发启动意图。 +`src-tauri/tauri.conf.json` 声明 `main`、`capsule` 两个窗口。`src/main.tsx` 读取 `?window=`,`src/App.tsx` 按类型加载胶囊、`qa`、`selection-polish-preview`、`selection-voice-intent`、`less-computer` 和 `less-computer-glow`;未指定类型时进入主界面。各 WebView 共用前端入口,重页面按需加载;移动端再依据平台能力选择布局。Linux 单实例由 `linux-egui/src/single_instance.rs` 守护并转发启动意图。 -## 6. 验证入口 +## 6. 存储与外部服务 -在 `openless-all/app/`:`npm test`(构建 + 前端/合同测试,含 Core 快捷键回归);`cargo fmt --check`(三 crate);`cargo test -p openless-core --locked`;`cargo test -p openless-linux-egui --locked`。`src-tauri` 按平台独立构建,macOS 需 `git submodule update --init --recursive`(vendored qwen-asr C 引擎)。 +| 数据或连接 | 所有者与源码入口 | +| --- | --- | +| 历史、活动、偏好、词库、纠错、风格包 | Core 对应仓储模块;Tauri `src-tauri/src/persistence/` 提供平台路径及兼容存储适配 | +| 模型与录音文件 | Core `model_store.rs`、Host 本地运行时和 `persistence/paths.rs`;录音归档受设置控制 | +| 服务凭据 | Core `CredentialStore` 合同,Tauri keyring/Android Keystore 或 Linux `credentials.rs` 适配 | +| 云端 ASR / LLM | Core provider 目录、选择与传输模块;平台本地引擎位于 `src-tauri/src/asr/local/` 或 Linux Host | +| 风格包市场 | Core `marketplace.rs` 管理 HTTP、GitHub device flow 与本地安装;地址由 `MarketplaceConfig` 注入,内置默认值在该模块 | +| 局域网手机输入 | Core `remote_input_service.rs` 定义共享业务,Tauri `remote_server/` 提供本机网络入口和网页资源 | + +应用不会把普通听写交给风格包市场后端。市场安装完成后使用本地风格包;官网也不参与应用的业务调用。长期参考数据、训练准备和历史快照不是 Core 的在线训练服务。 + +## 7. 验证入口 + +以下命令均在 `openless-all/app/` 执行,按变更范围选择: + +| 范围 | 命令与依据 | +| --- | --- | +| 前端与合同 | `npm test`;`pretest` 先构建,`scripts/frontend-test-runner.mjs` 发现前端测试和脚本合同检查,含 Core 快捷键回归 | +| Core/Linux 格式 | `cargo fmt --all --check`,仅根 workspace | +| Core | `cargo test -p openless-core --locked` | +| Linux Host | `cargo test -p openless-linux-egui --locked`;原生能力在 Linux 目标环境验证 | +| Tauri 格式与编译 | `cargo fmt --manifest-path src-tauri/Cargo.toml --check`、`cargo check --locked --manifest-path src-tauri/Cargo.toml` | +| Tauri 库与独立回归 crate | 依平台选 `cargo test --locked --manifest-path src-tauri/Cargo.toml --lib`、`cargo test --locked --manifest-path src-tauri/backend-tests/Cargo.toml`;适用矩阵见 [CI](../.github/workflows/ci.yml) | + +源码构建 Tauri 前执行 `git submodule update --init --recursive`。其 manifest 含受 target 条件控制的本地 path 依赖,Cargo 解析仍需对应子模块;部分 CI 作业通过专用脚本去除非目标依赖。Core/Linux workspace 排除 Tauri,不需要为这些独立检查初始化 Tauri 子模块。 + +仅 Markdown 变动检查相对链接、源码路径与描述一致性。平台交付按 [桌面验收](2.0-desktop-acceptance.md)、[Linux 验收](linux-egui-handoff/07-acceptance.md)和 [发布规范](../RELEASING.md)完成。 diff --git a/docs/index.md b/docs/index.md index 0122ca08..3dbf7cdf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,12 +1,14 @@ # OpenLess 文档入口 -状态:canonical;更新:2026-09-07。本目录 2026-09-07 以源代码为唯一依据重写(基于本地 `beta` `639c2fbf`);文档与代码冲突时,以代码为准修文档,不保留历史叙述。 +状态:canonical;更新:2026-09-08。实现说明与当前源码保持一致;范围、接口合同和验收要求由各自文档维护。各专项文档的更新时间与状态单独标注。 ## 范围与架构 - [2.0 范围](2.0-requirements.md):平台边界、版本锚点与完成条件。 - [架构](architecture.md):Core / 平台 Host / 界面分层、模块地图与验证入口。 +- [目录与工程结构](structure.md):仓库与应用工作目录、构建清单、源码定位和生成目录。 - [桌面验收清单](2.0-desktop-acceptance.md):功能域 → 源码入口 → 必须的真实证据。 +- [发布规范](../RELEASING.md):分支、渠道、版本同步和平台发布条件。 ## Linux 接入交接(egui,目录内互链) @@ -29,6 +31,6 @@ ## 验证入口(在 `openless-all/app` 执行) - `npm test`:构建 React + 全部前端/合同测试(含 Core 快捷键回归)。 -- `cargo fmt --check`:openless-core、linux-egui、src-tauri 三 crate。 +- `cargo fmt --all --check`:根 workspace 的 openless-core、linux-egui;Tauri 单独执行 `cargo fmt --manifest-path src-tauri/Cargo.toml --check`。 - `cargo test -p openless-core --locked`、`cargo test -p openless-linux-egui --locked`。 -- `src-tauri` 被 workspace exclude,按平台独立构建;macOS 构建需 `git submodule update --init --recursive`。 +- `src-tauri` 及 `backend-tests` 被 workspace exclude,按平台独立构建。源码构建 Tauri 前初始化子模块:`git submodule update --init --recursive`;Core/Linux 独立检查不依赖 Tauri 子模块。 diff --git a/docs/linux-egui-handoff/07-acceptance.md b/docs/linux-egui-handoff/07-acceptance.md index a75977a9..8454f9f5 100644 --- a/docs/linux-egui-handoff/07-acceptance.md +++ b/docs/linux-egui-handoff/07-acceptance.md @@ -1,6 +1,6 @@ # 07:验收与证据 -状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。 +状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-08。 ## 1. 两个不同的完成门 @@ -11,7 +11,7 @@ | 验证 | 命令 | | --- | --- | -| 格式 | `cargo fmt --check`(openless-core / linux-egui / src-tauri) | +| 格式 | 根 workspace:`cargo fmt --all --check`(openless-core / linux-egui);Tauri:`cargo fmt --manifest-path src-tauri/Cargo.toml --check` | | Core | `cargo test -p openless-core --locked` | | Linux Host/合同 | `cargo test -p openless-linux-egui --locked` | | Linux 目标编译 | `cargo check -p openless-linux-egui --all-targets --target x86_64-unknown-linux-gnu --locked`(需配置好带 `core/std` 的目标工具链) | diff --git a/docs/structure.md b/docs/structure.md new file mode 100644 index 00000000..962bd78b --- /dev/null +++ b/docs/structure.md @@ -0,0 +1,66 @@ +# 应用目录与工程结构 + +状态:canonical;更新:2026-09-08。分层与调用链见 [架构](architecture.md)。 + +## 仓库与应用工作目录 + +```text +1-app/ Git 仓库、分支与发布边界 +├── AGENTS.md / docs/ 规则、架构、合同说明和平台交接 +├── README.md / README.zh.md 面向使用者和贡献者的双语介绍 +├── RELEASING.md / USAGE.md 发布规则与使用说明 +├── .github/workflows/ CI、Tauri、Android、Linux 发布 +├── Casks/ Homebrew 分发定义 +├── Examples/ 示例数据 +├── assets/ / video-materials/ 产品展示材料 +├── scripts/ 仓库级辅助脚本 +└── openless-all/ + ├── design_handoff_openless/ 设计交接材料 + └── app/ npm 与 Core/Linux Cargo 工作目录 + ├── src/ React / TypeScript 界面 + ├── crates/openless-core/ 共享业务 Rust crate + ├── src-tauri/ Tauri Host,独立 Cargo manifest + ├── linux-egui/ Linux Host 和 egui UI + ├── android/ Kotlin / AIDL / manifest / 前端片段 + ├── windows-ime/ 原生 TSF/IME 工程 + ├── contract/ 机器可读 backend-2.0 合同 + ├── scripts/ 构建、平台检查与合同测试 + └── public/ Vite 静态资源 +``` + +## 按任务定位源码 + +路径以 `openless-all/app/` 为基准。 + +| 任务 | 入口 | 相关边界 | +| --- | --- | --- | +| 启动与窗口分支 | `src/main.tsx`、`src/App.tsx` | typed IPC 启动快照;Tauri 配置与运行时窗口 | +| 主界面、页面与设置 | `src/components/FloatingShell.tsx`、`src/pages/`、`src/pages/settings/` | `src/state/` 组织界面状态;业务规则归 Core | +| 多语言、主题、组件 | `src/i18n/`、`src/styles/`、`src/components/` | 五语言文案;tokens/global 样式 | +| 新增或调整 IPC | `src/lib/ipc/`、`src-tauri/src/commands/`、`src-tauri/src/lib.rs` | Rust/TypeScript 类型、注册、事件与 `contract/` 同步 | +| 共享业务入口 | `crates/openless-core/src/api.rs` | `events.rs`、`ports.rs`、`domains.rs`、`config.rs` | +| 听写和服务 | Core `dictation_engine.rs`、`provider_*`、`asr/`、`polish.rs` | Host 的录音、插入和本地模型适配 | +| 历史、词库、纠错、风格包 | Core `history.rs`、`vocabulary.rs`、`correction.rs`、`style_pack_store.rs` | Tauri `persistence/` 与对应 command | +| Tauri 组装与系统能力 | `src-tauri/src/coordinator.rs`、`core_adapters.rs`、`tauri_coordinator_host.rs` | 窗口、热键、权限、平台输入与生命周期 | +| Linux 原生接入 | `linux-egui/src/main.rs`、`lib.rs`、`backend.rs` | `audio/credentials/fcitx5/hotkeys/settings` 等 Host 模块;见 [交接](linux-egui-handoff/README.md) | +| Android 集成 | `android/`、`src-tauri/src/android/` | `@android` 别名与 `merge-android-*.mjs` 生成链 | +| Windows 输入法 | `windows-ime/`、`src-tauri/src/windows_ime_*.rs` | 原生工程、IPC 协议、目标应用和安装检查 | + +Core 其余模块按领域列于 [架构模块地图](architecture.md)。平台缺口、事件签名与验收项由专项文档维护,本文件只提供定位。 + +## 构建清单与生成文件 + +| 文件或目录 | 作用与维护方式 | +| --- | --- | +| `package.json` / `package-lock.json` | npm 命令、前端依赖与锁定版本;脚本从应用目录执行 | +| `Cargo.toml` / `Cargo.lock` | Core 与 Linux workspace;不覆盖 `src-tauri` | +| `src-tauri/Cargo.toml` / `Cargo.lock` | Tauri Host 的独立依赖图;本地 path 子模块须在解析前就绪 | +| `src-tauri/backend-tests/Cargo.toml` | 独立 Rust 回归 crate,按 CI 选择平台执行 | +| `vite.config.ts` / `tsconfig.json` | WebView 构建、TypeScript 与 Android 别名 | +| `src-tauri/tauri.conf.json` / `src-tauri/capabilities/` | 应用元数据、初始窗口、打包与 Tauri 能力权限 | +| `src-tauri/vendor/` | 原生 ASR 引擎与子模块;升级按 [qwen-asr 清单](qwen-asr-submodule-upgrade-checklist.md) | +| `src/lib/ipc/provider-descriptors.generated.json` | Core 导出的公开 provider 目录;生成命令见 [架构](architecture.md) | +| `src-tauri/gen/` | Tauri 平台生成目录;Android 手写源与合成脚本保留在 `android/`、`scripts/` | +| `node_modules/`、`dist/`、各 `target/` | 依赖和构建产物,不作为源码或 docs 的事实来源 | + +检查命令集中在 [架构的验证入口](architecture.md),版本与发布流程集中在 [RELEASING.md](../RELEASING.md)。不要在目录说明中复制易变的命令数量、分支领先数或单次测试结果。 diff --git a/openless-all/README.md b/openless-all/README.md index 0a2a0c23..337abfed 100644 --- a/openless-all/README.md +++ b/openless-all/README.md @@ -1,19 +1,20 @@ # OpenLess All-Platform -This is the current cross-platform OpenLess workspace. +This directory contains the cross-platform OpenLess application and design handoff material. Start with the repository [documentation index](../docs/index.md), [architecture](../docs/architecture.md), and [source structure](../docs/structure.md). ## App Directory -The runnable sources live in `app/` with three explicit layers: +The runnable sources live in `app/`: - `app/crates/openless-core`: framework-independent shared backend Interface and business rules; -- `app/src-tauri`: macOS, Windows, and Android Tauri Adapter plus the React frontend; -- `app/linux-egui`: Linux non-UI Adapter consumed by the separately developed egui frontend; it does not depend on Tauri or WebKitGTK. +- `app/src`: React frontend for the Tauri hosts; +- `app/src-tauri`: macOS, Windows, and Android Tauri Host and native adapters; +- `app/linux-egui`: Linux Host and egui/eframe UI; it does not depend on Tauri or WebKitGTK. -The macOS Tauri build links a vendored C ASR engine (`Open-Less/qwen-asr`, forked from `antirez/qwen-asr`) tracked as a git submodule under `app/src-tauri/vendor/qwen-asr/`. The root core/Linux workspace excludes `src-tauri`, so Linux checks do not need that submodule. +The Tauri manifest includes local ASR path dependencies under `app/src-tauri/vendor/`; initialize submodules before resolving it, including for non-macOS source builds. The root Core/Linux workspace excludes `src-tauri`, so its independent checks do not require those submodules. ```bash -# macOS Tauri development only — pull in vendored submodules +# Tauri source development — pull in vendored submodules git submodule update --init --recursive cd app @@ -23,17 +24,17 @@ npm run tauri dev ## Shared backend and Linux host -The egui UI is owned by another team. This repository supplies its typed Rust Interface, semantic events, fixtures, and Linux non-UI Adapters. The checked-in `linux-egui/src/main.rs` remains a stub until that team lands `eframe::App`; do not treat the current binary as a production application. +This repository supplies the shared typed Rust interface, semantic events, fixtures, and Linux adapters. `linux-egui/src/main.rs` now implements an egui/eframe application, with `LinuxHost` and `LinuxBackendBuilder` connecting it to Core. Remaining Host/UI work and product acceptance are tracked in the [Linux handoff](../docs/linux-egui-handoff/README.md). ```bash cd app -cargo test -p openless-core -cargo test -p openless-linux-egui --all-targets +cargo test -p openless-core --locked +cargo test -p openless-linux-egui --all-targets --locked pwsh ./scripts/check-core-deps.ps1 pwsh ./scripts/check-core-deps.ps1 openless-linux-egui ``` -The independent Linux package workflow is `.github/workflows/release-linux-egui.yml`. It builds deb/rpm/AppImage and the fcitx5 plugin without WebKitGTK, but deliberately has no automatic tag trigger until the real egui entry point is present. +The independent [Linux package workflow](../.github/workflows/release-linux-egui.yml) builds deb/rpm/AppImage and the fcitx5 plugin without WebKitGTK. It supports manual and reusable-workflow invocation; automatic tag triggering remains gated on real Ubuntu runtime, input, installation, upgrade, and rollback evidence. ## macOS Build diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index a0baeb3d..67b0b41a 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -3630,6 +3630,34 @@ impl OpenLessBackend { self.preferences.get() } + /// 消费「本大版本首启」开屏 PV 标记:配置里的 `splash_seen_version` 与传入的 + /// 当前主版本一致时返回 false(不再播放);不一致时写回主版本并返回 true, + /// 前端据此播放随包发行的开屏动画(同世代 2.x 升级与重启都不重播)。 + /// 磁盘写入失败时仍返回 true——宁可多播一次,也不静默吞掉首启体验;标记留待 + /// 下次启动重试。成功写回后走 publish_preferences_changed 递增 revision, + /// 让并发中的设置页乐观提交重新对账,不会拿着旧档把标记冲掉。 + pub fn take_splash_playback(&self, current_major: &str) -> bool { + match self.preferences.update(|preferences| { + if preferences.splash_seen_version == current_major { + false + } else { + preferences.splash_seen_version = current_major.to_string(); + true + } + }) { + Ok(should_play) => { + if should_play { + self.publish_preferences_changed(); + } + should_play + } + Err(error) => { + log::warn!("[splash] failed to persist splash marker: {error}"); + true + } + } + } + #[cfg(test)] pub(crate) fn set_preferences( &self, @@ -3713,6 +3741,10 @@ impl OpenLessBackend { let mut previous = self.preferences.get(); crate::sync_dictation_hotkey_legacy_fields(&mut previous); crate::sync_dictation_hotkey_legacy_fields(&mut preferences); + // 开屏标记只能由 take_splash_playback 推进:整档提交的客户端(旧前端或 + // 尚未回读标记的请求)不带此字段时,serde 默认会把空串写回,导致下次 + // 启动重播开屏 PV。这里永远沿用盘上的当前值。 + preferences.splash_seen_version = previous.splash_seen_version.clone(); if options.preserve_current_style { preferences.preserve_style_preferences_from(&previous); } @@ -4352,6 +4384,12 @@ impl OpenLessBackend { Ok(()) } + pub fn update_vocabulary_phrase(&self, id: &str, phrase: String) -> Result<(), BackendError> { + self.vocabulary.update_phrase(id, phrase)?; + self.publish_vocabulary_changed(); + Ok(()) + } + pub fn list_correction_rules(&self) -> Result, BackendError> { self.correction_rules.list() } @@ -10608,6 +10646,36 @@ mod tests { assert_eq!(backend.snapshot().dictation.session_id, Some(active)); } + #[test] + fn take_splash_playback_marks_major_once_and_survives_restart() { + let data_dir = TestDataDir::new("splash-playback"); + let make = || { + OpenLessBackend::new( + BackendConfig { + data_dir: data_dir.path().to_path_buf(), + ..BackendConfig::default() + }, + BackendDependencies::unsupported(), + ) + .unwrap() + }; + + let backend = make(); + // 首启:标记缺失 → 播放一次并写回主版本。 + assert!(backend.take_splash_playback("2")); + assert_eq!(backend.get_preferences().splash_seen_version, "2"); + // 同一世代内再次启动不再播放。 + assert!(!backend.take_splash_playback("2")); + + // 模拟进程重启:标记已从 preferences.json 读回。 + let reopened = make(); + assert!(!reopened.take_splash_playback("2")); + // 新一代大版本:播一次新 PV 后同样收口。 + assert!(reopened.take_splash_playback("3")); + assert!(!reopened.take_splash_playback("3")); + assert_eq!(reopened.get_preferences().splash_seen_version, "3"); + } + #[tokio::test] async fn engine_failure_publishes_failed_state_and_preserves_session_identity() { let host = Arc::new(FakeHost::default()); diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index 6266a809..c80c57c1 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -677,6 +677,12 @@ pub struct UserPreferences { /// Android: floating overlay control diameter in dp. #[serde(default = "default_android_overlay_size_dp")] pub android_overlay_size_dp: u32, + /// 开屏 PV 的主版本世代标记(如 "2")。空串 = 从未播过。启动时 Rust 比较 + /// 此标记与当前应用主版本:不一致则写回并播一次开屏动画,之后同一世代内 + /// (2.x 补丁/小版本升级、重启)不再播放。由 `take_splash_playback` 消费, + /// `update_settings` 保存时永远沿用当前值,防止客户端整档提交把它冲掉。 + #[serde(default)] + pub splash_seen_version: String, } impl UserPreferences { @@ -933,6 +939,8 @@ struct UserPreferencesWire { android_overlay_cancel_swipe_direction: AndroidOverlayCancelSwipeDirection, #[serde(default = "default_android_overlay_size_dp")] android_overlay_size_dp: u32, + #[serde(default)] + splash_seen_version: String, } fn deserialize_selection_polish_hotkey<'de, D>( @@ -1077,6 +1085,7 @@ impl Default for UserPreferencesWire { android_overlay_left_swipe_action: prefs.android_overlay_left_swipe_action, android_overlay_cancel_swipe_direction: prefs.android_overlay_cancel_swipe_direction, android_overlay_size_dp: prefs.android_overlay_size_dp, + splash_seen_version: prefs.splash_seen_version, } } } @@ -1243,6 +1252,7 @@ impl<'de> Deserialize<'de> for UserPreferences { android_overlay_size_dp: normalize_android_overlay_size_dp( wire.android_overlay_size_dp, ), + splash_seen_version: wire.splash_seen_version, }) } } @@ -1580,6 +1590,7 @@ impl Default for UserPreferences { android_overlay_cancel_swipe_direction: default_android_overlay_cancel_swipe_direction( ), android_overlay_size_dp: default_android_overlay_size_dp(), + splash_seen_version: String::new(), } } } diff --git a/openless-all/app/crates/openless-core/src/vocabulary.rs b/openless-all/app/crates/openless-core/src/vocabulary.rs index bf175a47..6c2dcf05 100644 --- a/openless-all/app/crates/openless-core/src/vocabulary.rs +++ b/openless-all/app/crates/openless-core/src/vocabulary.rs @@ -149,6 +149,43 @@ impl DictionaryStore { Ok(()) } + /// Rename an entry in place so its id / hits / enabled state survive the edit. + /// Empty phrases and phrases colliding with another entry are rejected. + pub fn update_phrase(&self, id: &str, phrase: String) -> Result<(), BackendError> { + let phrase = phrase.trim().to_string(); + if phrase.is_empty() { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "dictionary phrase is empty", + )); + } + let _guard = self.lock_store()?; + let mut entries = self.read_locked()?; + if entries + .iter() + .any(|entry| entry.id != id && entry.phrase == phrase) + { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "dictionary phrase already exists", + )); + } + let entry = entries + .iter_mut() + .find(|entry| entry.id == id) + .ok_or_else(|| { + BackendError::new( + BackendErrorCode::InvalidArgument, + "dictionary entry not found", + ) + })?; + if entry.phrase != phrase { + entry.phrase = phrase; + self.write_locked(&entries)?; + } + Ok(()) + } + /// Count case-insensitive, non-overlapping occurrences in final output. pub fn record_hits(&self, text: &str) -> Result { if text.is_empty() { diff --git a/openless-all/app/public/openless-2.0-splash.mp4 b/openless-all/app/public/openless-2.0-splash.mp4 new file mode 100644 index 00000000..acda7483 Binary files /dev/null and b/openless-all/app/public/openless-2.0-splash.mp4 differ diff --git a/openless-all/app/scripts/layout-mode-contract.test.mjs b/openless-all/app/scripts/layout-mode-contract.test.mjs index 9e8437f7..fceb9264 100644 --- a/openless-all/app/scripts/layout-mode-contract.test.mjs +++ b/openless-all/app/scripts/layout-mode-contract.test.mjs @@ -26,7 +26,11 @@ assert.match(css, /\.ol-conservative-stack/); assert.match(stackedLayout, /stackedRowLayout\?: boolean/); assert.doesNotMatch(stackedLayout, /mobile\s*\|\|/); -assert.match(shared, /flex:\s*"0 0 36px"/); +// Toggle 尺寸契约:宽 36 由 width/minWidth/maxWidth 锁定,flex 只声明不伸缩。 +// 旧断言锁的是 flex:"0 0 36px"——该写法在列方向容器里把 flex-basis 作用到高度, +// 开机自启行因此变成 36×36 圆球(2.0 UI 走查修复),故改为锁 "0 0 auto"。 +assert.match(shared, /flex:\s*"0 0 auto"/); +assert.match(shared, /width:\s*36/); assert.match(shared, /minWidth:\s*36/); assert.match(shared, /maxWidth:\s*36/); assert.match(pageAtoms, /preferenceStack\s*\?/); diff --git a/openless-all/app/src-tauri/src/commands/dictionary.rs b/openless-all/app/src-tauri/src/commands/dictionary.rs index c7cbff4c..5fb8e313 100644 --- a/openless-all/app/src-tauri/src/commands/dictionary.rs +++ b/openless-all/app/src-tauri/src/commands/dictionary.rs @@ -25,6 +25,12 @@ pub fn set_vocab_enabled(core: CoreState<'_>, id: String, enabled: bool) -> Resu .map_err(|e| e.to_string()) } +#[tauri::command] +pub fn update_vocab(core: CoreState<'_>, id: String, phrase: String) -> Result<(), String> { + core.update_vocabulary_phrase(&id, phrase) + .map_err(|e| e.to_string()) +} + #[tauri::command] pub fn list_correction_rules(core: CoreState<'_>) -> Result, String> { core.list_correction_rules().map_err(|e| e.to_string()) diff --git a/openless-all/app/src-tauri/src/commands/misc.rs b/openless-all/app/src-tauri/src/commands/misc.rs index ddeddc36..f0e15c41 100644 --- a/openless-all/app/src-tauri/src/commands/misc.rs +++ b/openless-all/app/src-tauri/src/commands/misc.rs @@ -34,6 +34,20 @@ pub async fn check_network() -> NetworkCheckResult { } } +/// 开屏 PV 首启判定:读 preferences.json 里的 `splashSeenVersion` 标记,与当前 +/// 应用主版本(`CARGO_PKG_VERSION` 的第一段,2.0.0-Beta.1 → "2")比对。不一致 +/// (含从未写入)时由 core 写回标记并返回 true,前端播放随包开屏动画一次; +/// 之后同一世代内永远返回 false。 +#[tauri::command] +pub fn take_splash_playback(core: CoreState<'_>) -> bool { + let major = env!("CARGO_PKG_VERSION") + .split('.') + .next() + .unwrap_or("0") + .to_string(); + core.take_splash_playback(&major) +} + #[tauri::command] pub async fn get_hotkey_status(core: CoreState<'_>) -> Result { Ok(core diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 8b450cc7..e32c3e1a 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -189,6 +189,7 @@ macro_rules! app_invoke_handler_desktop { commands::fetch_latest_beta_release, commands::app_check_update_with_channel, commands::check_network, + commands::take_splash_playback, commands::get_hotkey_status, commands::get_hotkey_capability, commands::set_shortcut_recording_active, @@ -235,6 +236,7 @@ macro_rules! app_invoke_handler_desktop { commands::add_vocab, commands::remove_vocab, commands::set_vocab_enabled, + commands::update_vocab, commands::list_correction_rules, commands::add_correction_rule, commands::remove_correction_rule, @@ -414,6 +416,7 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::get_default_style_system_prompts, $crate::commands::set_settings, $crate::commands::check_network, + $crate::commands::take_splash_playback, $crate::commands::get_platform_capabilities, $crate::commands::get_android_overlay_status, $crate::commands::request_android_overlay_permission, @@ -472,6 +475,7 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::add_vocab, $crate::commands::remove_vocab, $crate::commands::set_vocab_enabled, + $crate::commands::update_vocab, $crate::commands::list_correction_rules, $crate::commands::add_correction_rule, $crate::commands::remove_correction_rule, diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index bc56288f..e2e639c6 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -26,7 +26,7 @@ "shadow": true, "hiddenTitle": true, "titleBarStyle": "Overlay", - "trafficLightPosition": { "x": 14, "y": 20 }, + "trafficLightPosition": { "x": 20, "y": 20 }, "visible": false, "acceptFirstMouse": true }, diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 53eb1695..dab16a9b 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -18,6 +18,7 @@ import { Style } from '../pages/Style'; import { Marketplace } from '../pages/Marketplace'; import { Translation } from '../pages/Translation'; import { SelectionAsk } from '../pages/SelectionAsk'; +import { Corrections } from '../pages/Corrections'; // 风格市场(Marketplace)现在是侧栏「风格」展开组下的独立页面(不再是 Style 页面内 modal)。 // LocalAsr 不再作为主 nav tab——本地 ASR 模型管理已合并到 Settings → Services 中 // 通过 渲染。这里之前的 import 与 NAV_BASE 条目都已移除。 @@ -28,6 +29,7 @@ import { shouldShowHotkeyModeMigrationPrompt, } from '../lib/hotkeyMigration'; import { applyFontScale, readFontScale } from '../lib/fontScale'; +import { useExitMount } from '../lib/useExitMount'; import { getCredentials } from '../lib/ipc'; import { PROVIDER_SETUP_PROMPT_DEFERRED_KEY, @@ -43,12 +45,13 @@ import { useMobileLayout, useConservativeLayout } from '../lib/useMobileLayout'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { useAppState, type AppTab } from '../state/useAppState'; -const MORE_TAB_IDS: AppTab[] = ['vocab', 'translation', 'selectionAsk']; +const MORE_TAB_IDS: AppTab[] = ['vocab', 'translation', 'selectionAsk', 'corrections']; const STYLE_TAB_IDS: AppTab[] = ['style', 'marketplace']; -/** macOS 上侧栏顶部需让开原生红绿灯的高度(红绿灯竖直落在 ~6–22px)。 */ -const MAC_TRAFFIC_LIGHT_CLEARANCE = 30; -const SIDEBAR_WIDTH = 188; +/** macOS 上侧栏顶部需让开原生红绿灯的高度。红绿灯在 (20,20),按钮组高约 14px, + * 上下留等距 20px → 避让带总高 54px,红绿灯恰好竖直居中、左缘与导航图标列对齐。 */ +const MAC_TRAFFIC_LIGHT_CLEARANCE = 54; +const SIDEBAR_WIDTH = 282; /** tab → 页面组件映射(渲染主内容用;与侧栏树解耦,含未直接列在树上的页)。 */ const PAGE_CMP: Record, ComponentType> = { @@ -59,12 +62,13 @@ const PAGE_CMP: Record, ComponentType> = { marketplace: Marketplace, translation: Translation, selectionAsk: SelectionAsk, + corrections: Corrections, }; /** 侧栏导航树:扁平项 + 可展开分组(用户拍板的结构)。 * - 概览 / 历史 / 词汇:扁平项,位置不变。 * - 风格:展开组 → 润色模式(=style 页) + 风格市场(=marketplace 页,原为 Style 内 modal)。 - * - 工具:展开组 → 划词追问 + 翻译(原本两个扁平项合并成一个可展开分组)。 */ + * - 工具:展开组 → 划词追问 + 翻译 + 纠正规则(2.0 UI 走查:纠正规则从词典页迁入)。 */ type NavNode = | { kind: 'item'; id: AppTab; icon: string } | { kind: 'group'; key: string; icon: string; children: Array<{ id: AppTab }> }; @@ -74,7 +78,7 @@ const NAV_TREE: NavNode[] = [ { kind: 'item', id: 'history', icon: 'history' }, { kind: 'item', id: 'vocab', icon: 'vocab' }, { kind: 'group', key: 'style', icon: 'style', children: [{ id: 'style' }, { id: 'marketplace' }] }, - { kind: 'group', key: 'tools', icon: 'selectionAsk', children: [{ id: 'selectionAsk' }, { id: 'translation' }] }, + { kind: 'group', key: 'tools', icon: 'selectionAsk', children: [{ id: 'selectionAsk' }, { id: 'translation' }, { id: 'corrections' }] }, ]; interface FloatingShellProps { @@ -101,6 +105,10 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia const [settingsInitialSection, setSettingsInitialSection] = useState(); const [providerPromptOpen, setProviderPromptOpen] = useState(false); const [hotkeyModePromptOpen, setHotkeyModePromptOpen] = useState(false); + // 退出动画门(2.0 UI 走查「从哪来回到哪去」):关闭时先反向播放入场动画再卸载。 + const settingsMount = useExitMount(settingsOpen, 220); + const providerPromptMount = useExitMount(providerPromptOpen); + const hotkeyPromptMount = useExitMount(hotkeyModePromptOpen); const [moreOpen, setMoreOpen] = useState(false); const [styleOpen, setStyleOpen] = useState(false); const shellRef = useRef(null); @@ -229,7 +237,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia return ( // 不再为 macOS 红绿灯预留顶部 28px 空条(用户反馈「块上方多一条丑横条」): // 侧栏与内容块都顶到窗口最上沿,原生红绿灯直接浮在侧栏左上角的块面上。 - // 侧栏内 brand 行在 mac 上加 topClearance 让「OpenLess」避开红绿灯。 + // 侧栏 aside 在 mac 上加 topClearance(红绿灯竖直居中的避让带)让导航避开红绿灯。
{mobile && ( @@ -270,14 +278,38 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia padding: os === 'mac' ? `${MAC_TRAFFIC_LIGHT_CLEARANCE}px 10px 12px` : '10px 10px 12px', }}> - {/* brand */} -
- OpenLess + {/* 版本信息行(2.0 UI 走查):原「OpenLess」品牌位置直接改为显示版本信息, + 填掉导航上方的空档;BETA 徽章与版本号同基线。 */} +
+ {IS_BETA_BUILD && ( + {t('shell.betaTag')} + )} - OpenLess + {t('shell.footer.version', { version: APP_VERSION_LABEL })}
{/* nav — 扁平项 + 可展开分组(用户拍板结构)。扁平项:概览/历史/词汇。 @@ -294,7 +326,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'} style={navBtnStyle} > - + {t(`nav.${node.id}`)} @@ -311,7 +343,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia aria-expanded={expanded} style={navBtnStyle} > - + {t(`nav.group.${node.key}`)} - {/* 底部两行:上行 = 版本 chip(含 BETA 标),下行 = 设置按钮。 - 单行布局在窄 sidebar 下会把「设置」挤成两行竖字 + 版本糊一起; - 翻回两行同时把顺序反过来:设置真正落到最底,版本在它上面。 */} + {/* 底部只剩设置按钮(2.0 UI 走查:版本信息已上移到侧栏顶部原品牌位)。 */}
-
- {IS_BETA_BUILD && ( - {t('shell.betaTag')} - )} - - {t('shell.footer.version', { version: APP_VERSION_LABEL })} -
- @@ -448,12 +448,18 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia // 真挤不下了才 fallback 出细滚动条。 // - 其他 tab 同样走细滚动条。 className="ol-thinscroll ol-scroll-fade" + // 概览单屏固定页(overflow hidden,永不出滚动条)不预留滚动条槽位。 + data-ol-page-fixed={displayTab === 'overview' && !mobile ? 'true' : undefined} style={{ flex: 1, minHeight: 0, - overflow: 'auto', + // 概览页是单屏固定页(2.0 UI 走查):不滚动,所有仪表盘铺在一屏内, + // 由 Overview.tsx 内部 flex 自行分配高度;其余页保持细滚动条。 + overflow: displayTab === 'overview' && !mobile ? 'hidden' : 'auto', padding: mobile ? '16px 16px calc(16px + env(safe-area-inset-bottom, 0px) + 56px)' - : '24px 28px 32px', + : displayTab === 'overview' + ? '24px 28px 24px' + : '24px 28px 32px', // position:relative 让页面里的"已保存"toast 用 absolute top:16 right:16 // 锚到这块控制台卡的右上角,而不是横在页头变成长横幅。 position: 'relative', @@ -528,23 +534,26 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia )} - {/* Settings modal — rendered inside this window */} - {settingsOpen && + {/* Settings modal — rendered inside this window;settingsMount 门控退场动画 */} + {settingsMount.mounted && setSettingsOpen(false)} /> } - {providerPromptOpen ? ( + {providerPromptMount.mounted ? ( - ) : hotkeyModePromptOpen ? ( + ) : hotkeyPromptMount.mounted ? ( @@ -784,15 +793,16 @@ const mobileNavBtnStyle: CSSProperties = { const navBtnStyle: CSSProperties = { display: 'flex', alignItems: 'center', gap: 10, width: '100%', - padding: '7px 10px', + padding: '8px 10px', borderRadius: 8, border: 0, - fontFamily: 'inherit', fontSize: 13, + // 2.0 UI 走查(参考 Codex 侧栏):导航文字 15px、图标 16px,观感更接近参考稿。 + fontFamily: 'inherit', fontSize: 15, cursor: 'default', transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)', textAlign: 'left', }; -function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void; onOpenSettings: () => void }) { +function ProviderSetupPrompt({ closing = false, onLater, onOpenSettings }: { closing?: boolean; onLater: () => void; onOpenSettings: () => void }) { const { t } = useTranslation(); return (
void; background: 'rgba(15,17,22,0.28)', backdropFilter: 'blur(6px) saturate(140%)', WebkitBackdropFilter: 'blur(6px) saturate(140%)', - animation: 'ol-prompt-fade 0.2s var(--ol-motion-soft)', + animation: closing ? 'ol-prompt-fade 0.18s var(--ol-motion-soft) reverse both' : 'ol-prompt-fade 0.2s var(--ol-motion-soft)', }} >
void; border: '0.5px solid rgba(0,0,0,.08)', boxShadow: '0 24px 70px -24px rgba(15,17,22,.38), 0 0 0 0.5px rgba(0,0,0,.06)', padding: 20, - animation: 'ol-prompt-pop 0.26s var(--ol-motion-spring)', + animation: closing ? 'ol-prompt-pop 0.18s var(--ol-motion-soft) reverse both' : 'ol-prompt-pop 0.26s var(--ol-motion-spring)', }} >
@@ -885,7 +895,7 @@ function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void; ); } -function HotkeyModeMigrationPrompt({ onLater, onOpenSettings }: { onLater: () => void; onOpenSettings: () => void }) { +function HotkeyModeMigrationPrompt({ closing = false, onLater, onOpenSettings }: { closing?: boolean; onLater: () => void; onOpenSettings: () => void }) { const { t } = useTranslation(); return (
background: 'rgba(15,17,22,0.28)', backdropFilter: 'blur(6px) saturate(140%)', WebkitBackdropFilter: 'blur(6px) saturate(140%)', - animation: 'ol-prompt-fade 0.2s var(--ol-motion-soft)', + animation: closing ? 'ol-prompt-fade 0.18s var(--ol-motion-soft) reverse both' : 'ol-prompt-fade 0.2s var(--ol-motion-soft)', }} >
border: '0.5px solid rgba(0,0,0,.08)', boxShadow: '0 24px 70px -24px rgba(15,17,22,.38), 0 0 0 0.5px rgba(0,0,0,.06)', padding: 20, - animation: 'ol-prompt-pop 0.26s var(--ol-motion-spring)', + animation: closing ? 'ol-prompt-pop 0.18s var(--ol-motion-soft) reverse both' : 'ol-prompt-pop 0.26s var(--ol-motion-spring)', }} >
diff --git a/openless-all/app/src/components/Heatmap.tsx b/openless-all/app/src/components/Heatmap.tsx index e1813240..029fd501 100644 --- a/openless-all/app/src/components/Heatmap.tsx +++ b/openless-all/app/src/components/Heatmap.tsx @@ -19,6 +19,8 @@ interface HeatmapProps { endDate: Date; cellSize?: number; gap?: number; + /** 格子边长上限:概览单屏固定页需要控制热力图总高度,宽容器下不再无限放大。 */ + maxCellSize?: number; /** 值→颜色的插值曲线:log 适合量级差异大的数据。 */ interpolation?: 'linear' | 'sqrt' | 'log'; /** 最小非零值的颜色(hex)。 */ @@ -60,6 +62,7 @@ export function Heatmap({ endDate, cellSize = 11, gap = 3, + maxCellSize, interpolation = 'sqrt', minColor = '#bfdbfe', maxColor = '#1d4ed8', @@ -141,14 +144,16 @@ export function Heatmap({ const per = usable / weekCount; const g = per >= 13 ? 3 : 2; // step = cell + gap = per ⇒ weekCount × step = usable,格子精确铺满右缘。 - // 只留 5px 下限防窄容器过挤,不设上限——宽容器时格子随之放大仍贴边。 - setFitCell(Math.max(5, per - g)); + // 只留 5px 下限防窄容器过挤;maxCellSize 给概览单屏页封顶(不传则宽容器 + // 时格子随之放大仍贴边)。 + const fitted = Math.max(5, per - g); + setFitCell(maxCellSize != null ? Math.min(fitted, maxCellSize) : fitted); }; measure(); const observer = new ResizeObserver(measure); observer.observe(el); return () => observer.disconnect(); - }, [weekCount, cellSize, daysOfTheWeek]); + }, [weekCount, cellSize, daysOfTheWeek, maxCellSize]); const cell = fitCell ?? cellSize; const cellGap = cell >= 11 ? Math.min(gap, 3) : 2; diff --git a/openless-all/app/src/components/Icon.tsx b/openless-all/app/src/components/Icon.tsx index e0117fde..a5631c7d 100644 --- a/openless-all/app/src/components/Icon.tsx +++ b/openless-all/app/src/components/Icon.tsx @@ -54,6 +54,8 @@ export const ICONS: Record = { more: 'M5 12h.01M12 12h.01M19 12h.01', // More (horizontal dots) play: 'M5 3l14 9-14 9V3z', // Play download: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3', // Download + pencil: 'M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z', // Edit (pencil) + feather: 'M20.24 12.24a6 6 0 0 0-8.49-8.49L5 10.5V19h8.5zM16 8L2 22M17.5 15H9', // Feather (dictionary word) }; export interface IconProps { diff --git a/openless-all/app/src/components/MobileMoreSheet.tsx b/openless-all/app/src/components/MobileMoreSheet.tsx index 09d6395d..12670a75 100644 --- a/openless-all/app/src/components/MobileMoreSheet.tsx +++ b/openless-all/app/src/components/MobileMoreSheet.tsx @@ -7,6 +7,7 @@ const MORE_TABS: Array<{ id: AppTab; icon: string }> = [ { id: 'vocab', icon: 'vocab' }, { id: 'translation', icon: 'translate' }, { id: 'selectionAsk', icon: 'selectionAsk' }, + { id: 'corrections', icon: 'filter' }, ]; interface MobileMoreSheetProps { diff --git a/openless-all/app/src/components/SettingsModal.tsx b/openless-all/app/src/components/SettingsModal.tsx index 2c19a4da..de18c6fc 100644 --- a/openless-all/app/src/components/SettingsModal.tsx +++ b/openless-all/app/src/components/SettingsModal.tsx @@ -1,5 +1,5 @@ // Settings keeps one navigation level; every category reuses the existing settings consumers. -import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from './Icon'; import { SavedToast } from './SavedToast'; @@ -17,6 +17,9 @@ interface SettingsModalProps { os: OS; onClose: () => void; initialSettingsSection?: SettingsSectionId; + /** true 时反向播放入场动画(2.0 UI 走查「从哪来回到哪去」);由 FloatingShell + * 的 useExitMount 门控,动画播完才真正卸载。 */ + closing?: boolean; } const LINKS = [ @@ -24,7 +27,7 @@ const LINKS = [ { id: 'releaseNotes', icon: 'doc', href: 'https://github.com/Open-Less/openless/releases' }, ]; -export function SettingsModal({ os, onClose, initialSettingsSection }: SettingsModalProps) { +export function SettingsModal({ os, onClose, initialSettingsSection, closing = false }: SettingsModalProps) { const { t } = useTranslation(); const mobile = useMobileLayout(); const conservative = useConservativeLayout(); @@ -49,6 +52,19 @@ export function SettingsModal({ os, onClose, initialSettingsSection }: SettingsM const searching = query.trim().length > 0; const results = searchSettingsSections(sections, query); + // 2.0 UI 走查:桌面端设置侧栏的选中蓝框改为滑动指示块——切换分类时蓝框沿 + // 列表平滑移动到目标项(spring 曲线),而不是生硬地瞬间换底色。 + const railNavRef = useRef(null); + const railBtnRefs = useRef(new Map()); + const [railThumb, setRailThumb] = useState<{ top: number; height: number } | null>(null); + useLayoutEffect(() => { + if (mobile || searching) { setRailThumb(null); return; } + const nav = railNavRef.current; + const btn = railBtnRefs.current.get(section); + if (!nav || !btn) { setRailThumb(null); return; } + setRailThumb({ top: btn.offsetTop, height: btn.offsetHeight }); + }, [section, searching, mobile, sections.length]); + useEffect(() => { let cancelled = false; void getPlatformCapabilities().then(caps => { if (!cancelled) setPlatformCaps(caps); }); @@ -110,10 +126,22 @@ export function SettingsModal({ os, onClose, initialSettingsSection }: SettingsM } }; + // 搜索框:桌面端放在侧栏顶部(仿 macOS 系统设置的「搜索在导航栏上方」布局, + // 2.0 UI 走查);移动端仍留在标题栏下方整行。 + const searchBox = ( +
+ + setQuery(event.target.value)} placeholder={t('modal.searchPlaceholder')} aria-label={t('modal.searchPlaceholder')} style={{ width: '100%', minWidth: 0, border: 0, outline: 'none', background: 'transparent', color: 'var(--ol-ink)', font: 'inherit', fontSize: 14 }} /> + {query && } +
+ ); + return (
event.stopPropagation()} onKeyDown={handleKeyDown} - style={{ width: '100%', maxWidth: mobile ? undefined : 1020, height: '100%', maxHeight: mobile ? undefined : 760, minHeight: 0, background: 'var(--ol-settings-content-bg)', borderRadius: mobile ? 0 : 14, border: mobile ? 'none' : '0.5px solid var(--ol-line)', boxShadow: mobile ? 'none' : 'var(--ol-shadow-xl)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }} + style={{ width: '100%', maxWidth: mobile ? undefined : 1020, height: '100%', maxHeight: mobile ? undefined : 760, minHeight: 0, background: 'var(--ol-settings-content-bg)', borderRadius: mobile ? 0 : 14, border: mobile ? 'none' : '0.5px solid var(--ol-line)', boxShadow: mobile ? 'none' : 'var(--ol-shadow-xl)', display: 'flex', flexDirection: 'column', overflow: 'hidden', animation: mobile + ? closing ? 'ol-mobile-sheet-up 0.22s var(--ol-motion-soft) reverse both' : 'ol-mobile-sheet-up 0.26s var(--ol-motion-spring) both' + : closing ? 'ol-modal-card-in 0.2s var(--ol-motion-soft) reverse both' : 'ol-modal-card-in 0.28s var(--ol-motion-spring) both' }} > -
-
{t('shell.footer.settings')}
- {!mobile && {t('modal.autoSaveHint')}} - -
- - setQuery(event.target.value)} placeholder={t('modal.searchPlaceholder')} aria-label={t('modal.searchPlaceholder')} style={{ width: '100%', minWidth: 0, border: 0, outline: 'none', background: 'transparent', color: 'var(--ol-ink)', font: 'inherit', fontSize: 13 }} /> - {query && } -
-
+ {/* 桌面端不再有横跨两栏的标题栏(2.0 UI 走查:去掉「第三条横栏」); + 左侧栏与右侧内容各自通到顶,标题/自动保存/关闭并入右栏顶部。 + 移动端保留整宽 header(标题 + 关闭 + 整行搜索)。 */} + {mobile && ( +
+
{t('shell.footer.settings')}
+ +
{searchBox}
+
+ )}
-
@@ -3085,49 +3074,48 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { }} > {t("localAsr.mirrorLabel")} - + />
diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 3c76a86b..f33534dd 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; -import { getHotkeyUsageHint } from '../lib/hotkey'; import { getActivityStats, getCredentials, listHistory } from '../lib/ipc'; import { Heatmap } from '../components/Heatmap'; import { useMobileLayout } from '../lib/useMobileLayout'; @@ -229,85 +228,65 @@ export function Overview({ onOpenHistory, onOpenSettings }: OverviewProps) { hotkeyAvailable: prefs && capability ? capability.adapter !== 'unavailable' : null, hasShortcut: Boolean(prefs?.dictationHotkey.primary.trim()), }); - const shortcutHint = prefs && desktop && prefs.dictationHotkey.primary.trim() - ? getHotkeyUsageHint(prefs.hotkey, undefined, prefs.dictationHotkey) - : null; const openSettings = (section: OverviewSettingsSection) => onOpenSettings?.(section); - const guideDescription = setup.step === 'tryDictation' - ? t('overview.guide.tryDictationDesc', { shortcut: shortcutHint }) - : t(`overview.guide.${setup.step}Desc`); + // 2.0 UI 走查:已配置完成的服务商卡不再常驻(没有信息价值),只展示仍待配置的 + // 卡作为提醒;全部配置完成后整组「当前语音服务」隐藏。凭据加载中/拉取失败 + // (providers 为空)时保留占位卡,避免页面闪空。 + const pendingProviders = setup.providers.filter(p => !p.configured); + const showProvidersSection = setup.providers.length === 0 || pendingProviders.length > 0; return ( - // Own a natural-height column: the shell scrolls when the window is short. -
+ // 单屏固定页(2.0 UI 走查):不滚动,撑满外壳给定的高度,所有仪表盘在一屏内 + // 弹性分配;窗口压到很矮时由底部行内部收缩(最近识别列表内滚),页面本身不出滚动条。 +
{t('overview.refresh')}} /> - -
-
-
{t('overview.guide.nextStep')}
-

{t(`overview.guide.${setup.step}Title`)}

-

{guideDescription}

-
- {setup.action && ( - setup.action === 'refresh' ? refreshCredentials() : setup.action && openSettings(setup.action)} - > - {t(`overview.actions.${setup.action}`)} - + {showProvidersSection && ( +
+
+

{t('overview.servicesTitle')}

+ openSettings('services')}>{t('overview.actions.services')} +
+
+ {pendingProviders.map(provider => { + const nameKey = provider.id && (provider.kind === 'asr' ? ASR_NAME_KEY_BY_ID : LLM_NAME_KEY_BY_ID)[provider.id]; + const name = nameKey ? t(`settings.providers.presets.${nameKey}`) : provider.id || t(provider.kind === 'omni' ? 'overview.omniName' : 'overview.statusUnknown'); + return ( + openSettings('services') : undefined} + /> + ); + })} + {setup.providers.length === 0 && ( + +
{t(credsLoading ? 'overview.statusLoading' : 'overview.credentialsLoadError')}
+
)}
-
- {setup.action !== 'general' && openSettings('general')}>{t('overview.actions.general')}} - {desktop && setup.action !== 'shortcuts' && openSettings('shortcuts')}>{t('overview.actions.shortcuts')}} - {setup.action !== 'privacy' && openSettings('privacy')}>{t('overview.actions.privacy')}} +
+ )} + + {/* 使用记录:标题 + 四张指标卡为一组。 */} +
+

{t('overview.statsTitle')}

+
+ + + 0 ? t('overview.metricAvgTrend') : t('overview.metricNoData')} /> +
-

{t('overview.guide.permissionsHint')}

- - -
-

{t('overview.servicesTitle')}

- openSettings('services')}>{t('overview.actions.services')} -
-
- {setup.providers.map(provider => { - const nameKey = provider.id && (provider.kind === 'asr' ? ASR_NAME_KEY_BY_ID : LLM_NAME_KEY_BY_ID)[provider.id]; - const name = nameKey ? t(`settings.providers.presets.${nameKey}`) : provider.id || t(provider.kind === 'omni' ? 'overview.omniName' : 'overview.statusUnknown'); - return ( - openSettings('services') : undefined} - /> - ); - })} - {setup.providers.length === 0 && ( - -
{t(credsLoading ? 'overview.statusLoading' : 'overview.credentialsLoadError')}
-
- )}
-

{t('overview.statsTitle')}

- -
- - - 0 ? t('overview.metricAvgTrend') : t('overview.metricNoData')} /> - -
- - {/* Both cards keep their content height; overflow belongs to the shell. */} -
+ {/* 底部行吃掉剩余高度:周期卡图表区自适应拉高,最近识别列表内部滚动。 */} +
-
- {t('overview.recentTitle')} +
+ {t('overview.recentTitle')} {t('overview.recentAll')}
-
+
{historyError ? (
{t('overview.recentLoadFailed')} @@ -336,7 +315,7 @@ export function Overview({ onOpenHistory, onOpenSettings }: OverviewProps) { {t('overview.recentEmptyHint')}
)} - {history.slice(0, 5).map(s => ( + {history.slice(0, 8).map(s => ( ))} @@ -376,7 +355,7 @@ function ProviderCard({ kind, name, status, onConfigure }: ProviderCardProps) {
- {t(`overview.${kind}Kind`)} + {t(`overview.${kind}Kind`)} {status === 'configured' && ( @@ -387,11 +366,11 @@ function ProviderCard({ kind, name, status, onConfigure }: ProviderCardProps) { {t('overview.statusNotConfigured')} )}
-
{name}
+
{name}
- {t(`overview.providerHelp.${kind}`)} + {t(`overview.providerHelp.${kind}`)} {t(status === 'configured' ? 'overview.manageProvider' : 'overview.configureProvider')} @@ -404,20 +383,23 @@ function ProviderCard({ kind, name, status, onConfigure }: ProviderCardProps) { function ActivityHeatmapCard({ activity }: { activity: ActivityDay[] }) { const { t, i18n } = useTranslation(); const { endDate, startDate, data, labels } = useMemo(() => { - const end = new Date(); - const start = new Date(end); - start.setDate(end.getDate() - 364); + // 2.0 UI 走查:年历按日历年铺满——1 月 1 日起、12 月 31 日止,从最左排到最右; + // 此前的滚动 365 天窗口会让月份标号从年中开始、右侧留空,观感像「缺数据」。 + const now = new Date(); + const year = now.getFullYear(); + const start = new Date(year, 0, 1); + const end = new Date(year, 11, 31); const lang = i18n.language || 'en'; const monthFormat = new Intl.DateTimeFormat(lang, { month: 'short' }); const dayFormat = new Intl.DateTimeFormat(lang, { weekday: 'short' }); const dateFormat = new Intl.DateTimeFormat(lang, { dateStyle: 'medium' }); - const anchor = new Date(2026, 0, 4); // 周日 + const anchor = new Date(year, 0, 4); // 周日 return { endDate: end, startDate: start, data: activity.map(day => ({ date: day.date, value: day.count })), labels: { - months: Array.from({ length: 12 }, (_, m) => monthFormat.format(new Date(2026, m, 1))), + months: Array.from({ length: 12 }, (_, m) => monthFormat.format(new Date(year, m, 1))), days: Array.from({ length: 7 }, (_, d) => { const date = new Date(anchor); date.setDate(anchor.getDate() + d); @@ -428,10 +410,14 @@ function ActivityHeatmapCard({ activity }: { activity: ActivityDay[] }) { }; }, [activity, i18n.language]); return ( - - + + {t('overview.activityTitle')} + {/* 格子不再封顶:53 列随卡片宽度铺满到右缘(2.0 UI 走查:长度要排到最右侧)。 + 卡片高度收紧为内容高度(flex 默认 0 1 auto:不拉伸、窗口压矮时才收缩), + 四边 padding 一致;腾出的高度全部让给上方周期/最近识别行,热力图整体沉底 + (2.0 UI 走查:消除卡片底部大片留白、上方两卡拉高)。 */}
- {label} + {label}
{value}
-
{trend || ' '}
+
{trend || ' '}
); } @@ -500,7 +486,7 @@ function SegmentedToggle({ onClick={() => onChange(option.value)} style={{ padding: '3px 9px', - fontSize: 11, + fontSize: 12.5, fontWeight: selected ? 600 : 500, border: 0, borderRadius: 6, @@ -554,9 +540,9 @@ function PeriodMetricsCard({ })); return ( - + {/* flexWrap:卡片在 1fr 列里较窄,两组切换器放不下时换行而不是压扁按钮。 */} -
+
) : ( <> -
+
{formatMetricValue(series.total, metric, t)}
-
+
{t('overview.period.dailyAverage', { value: formatMetricValue(series.dailyAverage, metric, t), })} @@ -611,26 +597,27 @@ function PeriodChart({ const lastIndex = buckets.length - 1; const midIndex = Math.floor(lastIndex / 2); + // 图表区随卡片剩余高度拉伸(单屏固定页):柱高按容器百分比缩放,不再固定 100px。 return ( - <> -
+
+
{buckets.map((bucket, i) => { const isToday = i === lastIndex; return (
{!dense && ( -
+
{formatMetricValue(bucket.value, metric, t)}
)}
-
+
{dense ? [0, midIndex, lastIndex].map(i => {shortDateLabel(buckets[i].date)}) : buckets.map(bucket => {weekDayLabel(bucket.date, t('overview.weekDays', { returnObjects: true }) as string[])})}
- +
); } @@ -705,16 +692,16 @@ function RecentRow({ session, modeLabel }: { session: DictationSession; modeLabe return (
- + {formatTime(session.createdAt)} {modeLabel[session.mode]}
-
+
{session.finalText.split('\n')[0]}
- + {formatDuration(session.durationMs ?? 0, t)} - {/* 1. 工作语言 */} - -
{t('translation.working.title')}
-
- {SUPPORTED_LANGUAGES.map(lang => { - const checked = prefs.workingLanguages.includes(lang); - return ( - - ); - })} -
-
- - {/* 2. 翻译目标语言 */} - -
-
{t('translation.target.title')}
- + {/* 1. 工作语言 */} + +
{t('translation.working.title')}
+
- {enabled ? t('translation.statusEnabled') : t('translation.statusDisabled')} - -
- + {SUPPORTED_LANGUAGES.map(lang => { + const checked = prefs.workingLanguages.includes(lang); + return ( + + ); + })} +
+
+ + {/* 2. 翻译目标语言 */} + +
+
{t('translation.target.title')}
+ + {enabled ? t('translation.statusEnabled') : t('translation.statusDisabled')} + +
+ +
+
+
+ {t('translation.style.title')} +
+
+ {t('translation.style.desc')} +
+
+ + {stylePackLoadFailed + ? t('translation.style.unavailable') + : activeStylePackName ?? t('common.loading')} + +
+ {redundantTarget && ( +
+ {t('translation.target.sameAsWorking')} +
+ )} +
+
+ + {/* 3. 使用方法:编号步骤条,宽屏一行铺开、窄屏自动折行 */} + +
{t('translation.howto.title')}
-
-
- {t('translation.style.title')} + {[ + t('translation.howto.step1', { trigger: triggerLabel }), + t('translation.howto.step2', { trigger: triggerLabel }), + t('translation.howto.step3', { shortcut: translationHotkeyLabel }), + t('translation.howto.step4', { trigger: triggerLabel }), + t('translation.howto.step5'), + ].map((step, index) => ( +
+ + {index + 1} + + {step}
-
- {t('translation.style.desc')} -
-
- - {stylePackLoadFailed - ? t('translation.style.unavailable') - : activeStylePackName ?? t('common.loading')} - + ))}
- {redundantTarget && ( -
- {t('translation.target.sameAsWorking')} -
- )} - - - {/* 3. 使用方法 */} - -
{t('translation.howto.title')}
-
    -
  1. {t('translation.howto.step1', { trigger: triggerLabel })}
  2. -
  3. {t('translation.howto.step2', { trigger: triggerLabel })}
  4. -
  5. {t('translation.howto.step3', { shortcut: translationHotkeyLabel })}
  6. -
  7. {t('translation.howto.step4')}
  8. -
  9. {t('translation.howto.step5')}
  10. -
diff --git a/openless-all/app/src/pages/Vocab.tsx b/openless-all/app/src/pages/Vocab.tsx index ea13e8e6..896e22ef 100644 --- a/openless-all/app/src/pages/Vocab.tsx +++ b/openless-all/app/src/pages/Vocab.tsx @@ -1,38 +1,37 @@ -// Vocab.tsx — 接 Tauri 后端 list_vocab / add_vocab / remove_vocab / set_vocab_enabled。 +// Vocab.tsx — 「词典」页(2.0 UI 走查:由词汇表改版)。 +// 结构: +// - 顶部:标题 + 右上「新词」入口(弹窗:直接输入 或 从预设模板批量导入) +// - 工具行:所有 / 自动添加 / 手动添加 分段筛选 + 右侧圆形搜索(点击向左展开) +// - 词条网格:卡片默认只显文字,hover 变灰并浮现「编辑 / 删除」操作 +// - 编辑走弹窗(update_vocab 保 id/hits);场景预设保持卡片区块 +// - 纠正规则已迁往「工具 → 纠正规则」页(Corrections.tsx) // 数据落地到 ~/Library/Application Support/OpenLess/dictionary.json(与 Swift 同名)。 -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { Icon } from '../components/Icon'; +import { Tooltip } from '../components/Tooltip'; +import { SavedToast } from '../components/SavedToast'; import { - addCorrectionRule, addVocab, isTauri, - listCorrectionRules, listVocab, - removeCorrectionRule, removeVocab, - setCorrectionRuleEnabled, setVocabEnabled, + updateVocab, } from '../lib/ipc'; -import type { CorrectionRule, DictionaryEntry, VocabPreset } from '../lib/types'; +import type { DictionaryEntry, VocabPreset } from '../lib/types'; import { DEFAULT_VOCAB_PRESETS, loadVocabPresets, persistVocabPresets } from '../lib/vocabPresets'; +import { useExitMount } from '../lib/useExitMount'; import { useMobileLayout } from '../lib/useMobileLayout'; import { Btn, Card, Collapsible, PageHeader } from './_atoms'; const NEW_PRESET_DRAFT_ID = '__new__'; -const NUM_TOKEN = '{num}'; -function isSupportedCorrectionRule(pattern: string, replacement: string) { - const tokenCount = pattern.split(NUM_TOKEN).length - 1; - if (!pattern) return false; - if (tokenCount > 1) return false; - if (replacement.includes(NUM_TOKEN) && tokenCount === 0) return false; - if (tokenCount === 1) { - const [prefix, suffix] = pattern.split(NUM_TOKEN); - return Boolean(prefix || suffix); - } - return true; -} +/** 自动收集词条靠 note 认(后端 accept_pending_correction 打的就是这个标记)。 */ +const LEARNED_NOTE = '从手改中自动收集'; + +type SourceFilter = 'all' | 'auto' | 'manual'; export function Vocab() { const { t } = useTranslation(); @@ -47,12 +46,26 @@ export function Vocab() { const [editingPresetId, setEditingPresetId] = useState(null); const [presetNameDraft, setPresetNameDraft] = useState(''); const [presetPhrasesDraft, setPresetPhrasesDraft] = useState(''); - const [correctionRules, setCorrectionRules] = useState([]); - // 「只看自动收集的」筛选。自动收集能被信任的前提就是用户随时能把它们单独挑出来 - // 一眼看完并批量删掉 —— 混在手动规则里等于看不见。 - const [onlyLearnedRules, setOnlyLearnedRules] = useState(false); - const [rulePatternDraft, setRulePatternDraft] = useState(''); - const [ruleReplacementDraft, setRuleReplacementDraft] = useState(''); + + // 词典改版新增状态 + const [filter, setFilter] = useState('all'); + const [query, setQuery] = useState(''); + const [searchOpen, setSearchOpen] = useState(false); + const [editingEntry, setEditingEntry] = useState(null); + const [editDraft, setEditDraft] = useState(''); + const [editError, setEditError] = useState(null); + const [newWordOpen, setNewWordOpen] = useState(false); + const [newWordDraft, setNewWordDraft] = useState(''); + const [newWordTemplateIds, setNewWordTemplateIds] = useState([]); + const [saveState, setSaveState] = useState<'idle' | 'saved'>('idle'); + const editMount = useExitMount(editingEntry !== null); + const newWordMount = useExitMount(newWordOpen); + + // 词条网格 FLIP(2.0 UI 走查):增删/筛选让行位移时,从旧位置滑到新位置; + // 新卡片入场走 .ol-word-card 的 CSS 动画,删除走 onRemove 里的退场动画。 + const cardRefs = useRef(new Map()); + const prevCardTops = useRef(new Map()); + const [removingIds, setRemovingIds] = useState>(new Set()); const refresh = async () => { try { @@ -67,22 +80,8 @@ export function Vocab() { } }; - const refreshCorrectionRules = async () => { - try { - const data = await listCorrectionRules(); - setCorrectionRules(data); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } - }; - - const refreshAll = () => { - void refresh(); - void refreshCorrectionRules(); - }; - useEffect(() => { - refreshAll(); + void refresh(); void loadVocabPresets() .then(setPresets) .catch(err => setError(err instanceof Error ? err.message : String(err))); @@ -105,12 +104,23 @@ export function Vocab() { }; }, []); + const flashSaved = () => { + setSaveState('saved'); + window.setTimeout(() => setSaveState('idle'), 1600); + }; + const onAdd = async () => { const phrase = inputRef.current?.value.trim(); if (!phrase) return; - await addVocab(phrase); + try { + const entry = await addVocab(phrase); + // 乐观插入头部(addVocab 返回新 entry,浏览器 mock 下也能立刻看到)。 + setEntries(prev => [entry, ...prev]); + flashSaved(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } if (inputRef.current) inputRef.current.value = ''; - refresh(); }; const onKeyDown = (e: React.KeyboardEvent) => { @@ -120,89 +130,15 @@ export function Vocab() { } }; - const onAddCorrectionRule = async () => { - const pattern = rulePatternDraft.trim(); - if (!pattern) return; - const replacement = ruleReplacementDraft.trim(); - if (!isSupportedCorrectionRule(pattern, replacement)) { - setError(t('vocab.corrections.invalid')); - return; - } - try { - const rule = await addCorrectionRule(pattern, replacement); - setCorrectionRules(prev => [rule, ...prev]); - setRulePatternDraft(''); - setRuleReplacementDraft(''); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }; - - const onRemoveCorrectionRule = async (id: string) => { - try { - await removeCorrectionRule(id); - setCorrectionRules(prev => prev.filter(r => r.id !== id)); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }; - - const onRemoveAllLearnedRules = async () => { - const learned = correctionRules.filter(r => r.source === 'learned'); - if (learned.length === 0) return; - // 逐条删而不是加一个新的批量后端命令:规则数量是几十条量级,为此多开一条 IPC - // 不值得,而且逐条删失败一条也不影响其余。 - const removed: string[] = []; - for (const rule of learned) { - try { - await removeCorrectionRule(rule.id); - removed.push(rule.id); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } - } - setCorrectionRules(prev => prev.filter(r => !removed.includes(r.id))); - }; - - // 自动收集的词条靠 note 认。分成两区显示 —— 见下面渲染处的说明。 - const LEARNED_NOTE = '从手改中自动收集'; - const manualEntries = entries.filter(e => e.note !== LEARNED_NOTE); - const learnedEntries = entries.filter(e => e.note === LEARNED_NOTE); - - const onRemoveAllLearnedEntries = async () => { - // 逐条删而不是加一条批量后端命令:词条是几十条量级,为此多开一条 IPC 不值得, - // 而且逐条删失败一条也不影响其余。 - const removed: string[] = []; - for (const entry of learnedEntries) { - try { - await removeVocab(entry.id); - removed.push(entry.id); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } - } - setEntries(prev => prev.filter(e => !removed.includes(e.id))); - }; - - const learnedRuleCount = correctionRules.filter(r => r.source === 'learned').length; - const visibleCorrectionRules = onlyLearnedRules - ? correctionRules.filter(r => r.source === 'learned') - : correctionRules; - - const onToggleCorrectionRule = async (rule: CorrectionRule) => { - const next = !rule.enabled; - setCorrectionRules(prev => prev.map(r => (r.id === rule.id ? { ...r, enabled: next } : r))); - try { - await setCorrectionRuleEnabled(rule.id, next); - } catch (err) { - setCorrectionRules(prev => prev.map(r => (r.id === rule.id ? { ...r, enabled: rule.enabled } : r))); - setError(err instanceof Error ? err.message : String(err)); - } - }; - const onRemove = async (id: string) => { + await fadeOutCard(id); await removeVocab(id); setEntries(prev => prev.filter(e => e.id !== id)); + setRemovingIds(prev => { + const next = new Set(prev); + next.delete(id); + return next; + }); }; const onToggle = async (entry: DictionaryEntry) => { @@ -218,6 +154,47 @@ export function Vocab() { } }; + const openEdit = (entry: DictionaryEntry) => { + setEditingEntry(entry); + setEditDraft(entry.phrase); + setEditError(null); + }; + + const saveEdit = async () => { + if (!editingEntry) return; + const phrase = editDraft.trim(); + if (!phrase) { + setEditError(t('vocab.editEmpty')); + return; + } + if (phrase === editingEntry.phrase) { + setEditingEntry(null); + return; + } + try { + await updateVocab(editingEntry.id, phrase); + // 乐观改名:id / hits / enabled 保持不变(后端 update_vocab 原地改 phrase)。 + setEntries(prev => prev.map(e => (e.id === editingEntry.id ? { ...e, phrase } : e))); + setEditingEntry(null); + flashSaved(); + } catch (err) { + setEditError(err instanceof Error ? err.message : String(err)); + } + }; + + const addNewWord = async () => { + const phrase = newWordDraft.trim(); + if (!phrase) return; + try { + const entry = await addVocab(phrase); + setEntries(prev => [entry, ...prev]); + setNewWordDraft(''); + flashSaved(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + const togglePreset = (id: string) => { setSelectedPresetIds(prev => (prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])); }; @@ -259,9 +236,8 @@ export function Vocab() { setPresetPhrasesDraft(''); }; - const applySelectedPresets = async () => { - const selected = presets.filter(p => selectedPresetIds.includes(p.id)); - if (selected.length === 0) return; + /** 把一组模板的词条并入词典(已存在的按需启用)。返回失败条数。 */ + const applyPresets = async (selected: VocabPreset[]) => { const byPhrase = new Map(); const addedPhrases = new Set(); for (const entry of entries) { @@ -277,8 +253,9 @@ export function Vocab() { const existing = byPhrase.get(key) || []; if (existing.length === 0) { try { - await addVocab(phrase); + const entry = await addVocab(phrase); addedPhrases.add(key); + setEntries(prev => [entry, ...prev]); } catch { failures += 1; } @@ -295,324 +272,562 @@ export function Vocab() { } } } + return failures; + }; + + const applySelectedPresets = async () => { + const selected = presets.filter(p => selectedPresetIds.includes(p.id)); + if (selected.length === 0) return; + const failures = await applyPresets(selected); await refresh(); if (failures > 0) { setError(`部分词条添加失败(${failures})`); + } else { + flashSaved(); } }; + const applyNewWordTemplates = async () => { + const selected = presets.filter(p => newWordTemplateIds.includes(p.id)); + if (selected.length === 0) return; + const failures = await applyPresets(selected); + setNewWordTemplateIds([]); + setNewWordOpen(false); + if (failures > 0) { + setError(`部分词条添加失败(${failures})`); + } else { + flashSaved(); + } + }; + + // 自动收集的单独一区。不给每个词条挂 badge —— 混在一堆里要逐个看; + // 分段筛选一眼就看得完,「全部删除」也自然地只管自动这一块。 + // 用户随时能看清、能整块撤销,是自动收集能被信任的前提。 + const sourceOf = (entry: DictionaryEntry): Exclude => + entry.note === LEARNED_NOTE ? 'auto' : 'manual'; + const learnedEntries = entries.filter(e => sourceOf(e) === 'auto'); + + /** 删除退场动画(从哪来回到哪去):先淡出收缩,动画结束再真正删。 */ + const fadeOutCard = async (id: string) => { + const element = cardRefs.current.get(id); + if (!element) return; + setRemovingIds(prev => new Set(prev).add(id)); + try { + await element.animate( + [{ opacity: 1, transform: 'scale(1)' }, { opacity: 0, transform: 'scale(0.92)' }], + { duration: 160, easing: 'cubic-bezier(0.4, 0, 1, 1)' }, + ).finished; + } catch { /* 动画被打断(筛选切换/卸载)不阻塞删除 */ } + }; + + const onRemoveAllLearnedEntries = async () => { + // 逐条删而不是加一条批量后端命令:词条是几十条量级,为此多开一条 IPC 不值得, + // 而且逐条删失败一条也不影响其余。 + await Promise.all(learnedEntries.map(entry => fadeOutCard(entry.id))); + const removed: string[] = []; + for (const entry of learnedEntries) { + try { + await removeVocab(entry.id); + removed.push(entry.id); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + } + setEntries(prev => prev.filter(e => !removed.includes(e.id))); + setRemovingIds(new Set()); + }; + + const needle = query.trim().toLowerCase(); + const visibleEntries = entries.filter( + e => + (filter === 'all' || sourceOf(e) === filter) && + (!needle || e.phrase.toLowerCase().includes(needle)), + ); + + // FLIP:只量布局位置(offsetTop),rect 会被飞行中的动画 transform 污染。 + useLayoutEffect(() => { + const nextTops = new Map(); + cardRefs.current.forEach((element, id) => nextTops.set(id, element.offsetTop)); + cardRefs.current.forEach((element, id) => { + const current = nextTops.get(id); + if (current == null) return; + const previous = prevCardTops.current.get(id); + if (previous != null && Math.abs(previous - current) > 1) { + element.animate( + [{ transform: `translateY(${previous - current}px)` }, { transform: 'translateY(0)' }], + { duration: 280, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' }, + ); + } + }); + prevCardTops.current = nextTops; + }, [visibleEntries]); + return ( - <> +
- {t('common.refresh')} + { setNewWordOpen(true); setNewWordDraft(''); setNewWordTemplateIds([]); }}> + {t('vocab.newWord')} +
} /> - - + + {/* 工具行:来源分段筛选 + 圆形搜索(点击向左展开)。 */} +
+
+ {([ + { id: 'all', icon: null }, + { id: 'auto', icon: 'sparkle' }, + { id: 'manual', icon: 'feather' }, + ] as const).map(seg => ( + + ))} +
+
+ {/* 2.0 UI 走查:圆形控件原地展开成搜索框 —— 放大镜固定在右缘不动, + 占位文字「搜索」在框内;收起走同一条 width 过渡(从哪来回到哪去)。 */} +
+ setQuery(e.target.value)} + onBlur={() => { if (!query) setSearchOpen(false); }} + onKeyDown={e => { if (e.key === 'Escape') { setQuery(''); setSearchOpen(false); } }} + /> + +
+
+ + {error && ( +
-
- {presets.map(p => ( - - ))} - {t('vocab.presets.create')} - {t('vocab.presets.apply')} + {error} +
+ )} + + {/* 自动添加筛选下给「全部删除」留一个稳定的出口(信任前提,见上注释)。 */} + {filter === 'auto' && learnedEntries.length > 0 && ( +
+ + {t('vocab.learnedSection', { count: learnedEntries.length })} + + void onRemoveAllLearnedEntries()}> + {t('vocab.removeAllLearned')} + +
+ )} + + {/* 独立滚动区(2.0 UI 走查):词条多时只有这一格在滚,底部面板固定在视口 + 底缘、白底天然遮挡滚过去的内容;之前网格作为页根 flex item 被压扁、内容 + 溢出到下方区块背后的穿帮也从根上消除。 */} +
+ {/* 词条网格:hover 变灰 + 右侧浮现编辑/删除。 */} +
+ {loading &&
{t('common.loading')}
} + {!loading && !error && visibleEntries.length === 0 && ( +
+ {needle ? t('vocab.searchEmpty') : t('vocab.empty')}
- {editingPresetId && ( -
- setPresetNameDraft(e.target.value)} placeholder={t('vocab.presets.namePlaceholder')} /> -