diff --git a/CHANGELOG.md b/CHANGELOG.md index f6e7a75..eb7e9d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ 本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 的格式。 +## [1.0.1] - 2026-09-01 + +### 变更 + +- 重新设计设置界面,统一 TamaASR 品牌色、控件层级、服务状态、快捷键、开关与即时保存反馈。 +- 改进悬浮球位置调整页,增加桌面预览、九宫格选中状态、精确坐标与方向微调控件。 +- 补充键盘焦点、辅助文本、减少动态效果偏好和窄窗口布局支持。 + ## [1.0.0] - 2026-09-01 ### 变更 diff --git a/linux/fcitx5/CMakeLists.txt b/linux/fcitx5/CMakeLists.txt index a462272..16ab19a 100644 --- a/linux/fcitx5/CMakeLists.txt +++ b/linux/fcitx5/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.16) -project(tama-asr-fcitx5 VERSION 1.0.0 LANGUAGES CXX) +project(tama-asr-fcitx5 VERSION 1.0.1 LANGUAGES CXX) include(GNUInstallDirs) include(CTest) diff --git a/linux/fcitx5/tama-asr.conf.in b/linux/fcitx5/tama-asr.conf.in index 578eaa2..d38f03a 100644 --- a/linux/fcitx5/tama-asr.conf.in +++ b/linux/fcitx5/tama-asr.conf.in @@ -6,7 +6,7 @@ Comment[zh_CN]=将 TamaASR 网页后端连接到当前输入上下文 Type=SharedLibrary Library=@TAMA_ASR_LIBRARY_PATH@ Category=Module -Version=1.0.0 +Version=1.0.1 OnDemand=False Configurable=False diff --git a/package.json b/package.json index 3f7ca30..81e9f7e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tama-asr", "productName": "TamaASR", - "version": "1.0.0", + "version": "1.0.1", "private": true, "packageManager": "pnpm@11.21.0", "author": "TamaASR contributors", diff --git a/packaging/README.md b/packaging/README.md index f7b8017..a11a3a1 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -1,13 +1,13 @@ # Linux 分发打包 -TamaASR 1.0.0 的系统包由两部分组成: +TamaASR 1.0.1 的系统包由两部分组成: - Electron 应用安装到 `/opt/tama-asr`,命令入口为 `/usr/bin/tama-asr`; - Fcitx5 常驻 addon 由目标发行版中的 CMake/Fcitx5 开发包编译,并安装到该环境报告的 addon 目录。 安装包同时提供桌面入口、项目图标和 `/etc/xdg/autostart/tama-asr.desktop`,因此安装后的默认行为是随桌面会话启动。设置页关闭自启动时,应用需要在用户级 autostart 目录写入覆盖项,不能删除系统包所属文件。 -`tama-asr` 1.0.0 的 deb、RPM 和 Arch 元数据均声明替换旧 `meloasr` 包;通过对应发行版的包管理器安装新包时,旧应用、Fcitx5 addon 和桌面文件会一并卸载。 +`tama-asr` 1.0.1 的 deb、RPM 和 Arch 元数据均声明替换旧 `meloasr` 包;通过对应发行版的包管理器安装新包时,旧应用、Fcitx5 addon 和桌面文件会一并卸载。 ## 统一构建契约 @@ -42,7 +42,7 @@ pnpm run build:linux:dir packaging/deb/build-deb.sh ``` -产物位于 `packaging/out/tama-asr_1.0.0_.deb`。依赖名称按 Debian/Ubuntu 系列填写,但尚未在各发行版完成安装验证。 +产物位于 `packaging/out/tama-asr_1.0.1_.deb`。依赖名称按 Debian/Ubuntu 系列填写,但尚未在各发行版完成安装验证。 ## rpm diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index ba0b795..c010f20 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,5 +1,5 @@ pkgname=tama-asr -pkgver=1.0.0 +pkgver=1.0.1 pkgrel=1 pkgdesc='Web-backed voice input for Fcitx5' arch=('x86_64') diff --git a/packaging/arch/README.md b/packaging/arch/README.md index 47b16c7..3340bbd 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -4,7 +4,7 @@ ```bash curl --fail --location --remote-name \ - https://github.com/Melorise/TamaASR/releases/download/v1.0.0/PKGBUILD + https://github.com/Melorise/TamaASR/releases/download/v1.0.1/PKGBUILD makepkg --syncdeps --cleanbuild ``` diff --git a/packaging/assets/tama-asr.metainfo.xml b/packaging/assets/tama-asr.metainfo.xml index 44607f7..f1fbec9 100644 --- a/packaging/assets/tama-asr.metainfo.xml +++ b/packaging/assets/tama-asr.metainfo.xml @@ -16,6 +16,7 @@ tama-asr + diff --git a/packaging/deb/build-deb.sh b/packaging/deb/build-deb.sh index 45ca8e5..8e70fa8 100755 --- a/packaging/deb/build-deb.sh +++ b/packaging/deb/build-deb.sh @@ -2,7 +2,7 @@ set -euo pipefail project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" -version="${TAMA_ASR_VERSION:-1.0.0}" +version="${TAMA_ASR_VERSION:-1.0.1}" arch="${TAMA_ASR_DEB_ARCH:-$(dpkg --print-architecture)}" app_dir="${1:-${TAMA_ASR_ELECTRON_APP_DIR:-${project_dir}/release/linux-unpacked}}" work_dir="${project_dir}/packaging/out/deb-root" diff --git a/packaging/rpm/build-rpm.sh b/packaging/rpm/build-rpm.sh index 7490b19..cf76ad4 100755 --- a/packaging/rpm/build-rpm.sh +++ b/packaging/rpm/build-rpm.sh @@ -2,7 +2,7 @@ set -euo pipefail project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" -version="${TAMA_ASR_VERSION:-1.0.0}" +version="${TAMA_ASR_VERSION:-1.0.1}" app_dir="${1:-${TAMA_ASR_ELECTRON_APP_DIR:-${project_dir}/release/linux-unpacked}}" stage_dir="${project_dir}/packaging/out/rpm-root" top_dir="${project_dir}/packaging/out/rpmbuild" diff --git a/packaging/rpm/tama-asr-binary.spec b/packaging/rpm/tama-asr-binary.spec index 09f637a..7ac7d80 100644 --- a/packaging/rpm/tama-asr-binary.spec +++ b/packaging/rpm/tama-asr-binary.spec @@ -38,6 +38,9 @@ tar -C %{buildroot} -xzf %{SOURCE0} /etc/xdg/autostart/tama-asr.desktop %changelog +* Tue Sep 01 2026 TamaASR contributors - 1.0.1-1 +- Redesign the settings and overlay-position interfaces. + * Tue Sep 01 2026 TamaASR contributors - 1.0.0-1 - Rename the package and replace the legacy meloasr package on upgrade. diff --git a/src/main/settings-window.ts b/src/main/settings-window.ts index e3a7ac6..1351c64 100644 --- a/src/main/settings-window.ts +++ b/src/main/settings-window.ts @@ -98,7 +98,7 @@ export class SettingsWindow { overlayPosition: position, displays: screen.getAllDisplays().map((display, index) => ({ id: String(display.id), - label: `屏幕 ${index + 1}(${display.workArea.width} × ${display.workArea.height})`, + label: `屏幕 ${index + 1} · ${display.workArea.width} × ${display.workArea.height}`, workArea: display.workArea })), version: app.getVersion(), diff --git a/src/renderer/settings.css b/src/renderer/settings.css index e6db390..3f1ad29 100644 --- a/src/renderer/settings.css +++ b/src/renderer/settings.css @@ -1,56 +1,247 @@ -:root { color-scheme: light; font: 14px/1.5 system-ui, "Microsoft YaHei", sans-serif; color: #182033; } +:root { + color-scheme: light; + font: 14px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; + color: #202421; + background: #f6f7f5; + --brand: #63a978; + --brand-strong: #4f9564; + --brand-soft: #edf6ef; + --text: #202421; + --text-secondary: #707672; + --text-tertiary: #929792; + --border: #e2e6e1; + --border-strong: #cfd5cf; + --surface: #fff; + --surface-muted: #f8f9f7; + --warning: #a66b20; + --warning-soft: #fff7e9; + --error: #b34d43; + --error-soft: #fff0ee; + --ease-out: cubic-bezier(.23, 1, .32, 1); + --ease-in-out: cubic-bezier(.77, 0, .175, 1); +} + * { box-sizing: border-box; } [hidden] { display: none !important; } -body { margin: 0; background: #f4f6fa; } -header { display: flex; align-items: center; gap: 12px; padding: 22px 28px 12px; } -header img { width: 44px; height: 44px; border-radius: 11px; } -header h1 { margin: 0; font-size: 22px; } -header span { color: #788398; } -main { display: grid; gap: 14px; padding: 0 28px 30px; } -section { padding: 18px; background: white; border: 1px solid #e0e5ee; border-radius: 13px; box-shadow: 0 5px 20px rgba(33,48,77,.05); } -h2 { margin: 0 0 14px; font-size: 16px; } -.row { display: flex; align-items: center; gap: 12px; min-height: 40px; } -.row > label, .row > span:first-child { min-width: 80px; color: #5f6b80; } -select, input, button { height: 38px; border-radius: 8px; font: inherit; } -select, input { padding: 0 10px; border: 1px solid #cad3e1; background: white; } -select { flex: 1; } -button { padding: 0 14px; border: 0; background: #e9eef7; color: #26334a; cursor: pointer; font-weight: 600; } -button.primary { background: #286df0; color: white; } -button:disabled { opacity: .55; cursor: default; } -.status { padding: 4px 9px; border-radius: 99px; font-size: 12px; background: #edf0f5; } -.status[data-state="ready"] { color: #087b4d; background: #ddf6eb; } -.status[data-state="warning"] { color: #a84332; background: #fde8e4; } -.warning { margin: 8px 0 12px; padding: 9px 11px; border-radius: 8px; color: #a84332; background: #fff0ed; } -.switch { display: flex; align-items: center; gap: 10px; min-height: 40px; } -.switch input { width: 17px; height: 17px; } -.position-panel { max-width: 760px; } -.page-title { display: flex; align-items: center; gap: 12px; } -.page-title h2 { margin: 0; } -.page-title span { color: #788398; } -.position-screen { position: relative; height: 300px; margin: 16px 0; border: 2px solid #b9c4d6; border-radius: 10px; background: #f7f9fc; overflow: hidden; } -.position-ball { position: absolute; width: 28px; height: 28px; transform: translate(-50%, -50%); border: 3px solid #3476ed; border-radius: 50%; background: #76a4f8; box-shadow: 0 3px 10px rgba(52,118,237,.3); } -.position-controls { display: grid; grid-template-columns: 1fr 164px; align-items: center; gap: 24px; } -.direction-pad { display: grid; grid-template-columns: repeat(3, 48px); grid-template-rows: repeat(3, 38px); gap: 4px; justify-content: center; } -.direction-pad button { padding: 0; } +html, body { min-width: 100%; min-height: 100%; } +body { margin: 0; overflow-x: hidden; background: #f6f7f5; } +button, select, input { font: inherit; } +button { color: inherit; } +button, select { -webkit-tap-highlight-color: transparent; } +button:focus-visible, select:focus-visible, input:focus-visible { + outline: 2px solid rgba(99, 169, 120, .42); + outline-offset: 2px; +} +svg { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; } + +.view { width: 100%; padding: 20px 24px; } +.settings-view { display: grid; gap: 12px; max-width: 720px; margin: 0 auto; } +.card { + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + box-shadow: 0 1px 2px rgba(33, 46, 36, .025), 0 7px 24px rgba(33, 46, 36, .025); +} + +.app-header { display: flex; align-items: center; justify-content: space-between; min-height: 48px; } +.brand { display: flex; align-items: center; gap: 12px; min-width: 0; } +.brand img { width: 42px; height: 42px; border-radius: 11px; object-fit: cover; box-shadow: 0 2px 8px rgba(67, 112, 78, .12); } +.brand h1, .position-header h1 { margin: 0; font-size: 20px; line-height: 1.2; letter-spacing: -.018em; } +.brand p, .position-header p, .section-heading p { margin: 3px 0 0; color: var(--text-secondary); } +.brand p { font-size: 13px; } +.app-status { + display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px; + border: 1px solid #d4e6d8; border-radius: 9px; color: #417d53; background: #f3f9f4; + white-space: nowrap; +} +.app-status i, .backend-status i { display: block; width: 8px; height: 8px; border-radius: 50%; background: currentColor; } +.app-status[data-state="loading"] { border-color: var(--border); color: var(--text-secondary); background: var(--surface-muted); } +.app-status[data-state="warning"] { border-color: #eeddbf; color: var(--warning); background: var(--warning-soft); } + +.service-card { padding: 17px 18px 16px; } +.section-heading h2, .preferences-card h2, .position-tools h2 { margin: 0; font-size: 16px; line-height: 1.25; letter-spacing: -.01em; } +.section-heading p { font-size: 13px; } +.service-row { display: grid; grid-template-columns: 92px minmax(150px, 1fr) auto; align-items: center; gap: 12px; margin-top: 15px; } +.service-row > label { font-weight: 600; } +.select-shell { position: relative; min-width: 0; } +.select-shell::after { + content: ""; position: absolute; right: 13px; top: 50%; width: 7px; height: 7px; + border-right: 1.5px solid #5f665f; border-bottom: 1.5px solid #5f665f; + transform: translateY(-70%) rotate(45deg); pointer-events: none; +} +select { + width: 100%; height: 38px; padding: 0 34px 0 12px; appearance: none; + border: 1px solid var(--border-strong); border-radius: 8px; color: var(--text); background: var(--surface); + cursor: pointer; transition: border-color 140ms ease, box-shadow 140ms ease; +} +select:focus { border-color: #8abb98; } +.backend-status { display: inline-flex; align-items: center; gap: 7px; min-width: 74px; color: var(--text-secondary); white-space: nowrap; } +.backend-status[data-state="ready"] { color: #478b5b; } +.backend-status[data-state="warning"] { color: var(--warning); } +.backend-status[data-state="loading"] i { background: #a5aaa6; } +.warning { + display: flex; align-items: flex-start; gap: 8px; margin-top: 12px; padding: 9px 11px; + border: 1px solid #f0dfbf; border-radius: 8px; color: #83571e; background: var(--warning-soft); font-size: 12px; +} +.warning svg { flex: 0 0 auto; width: 17px; height: 17px; } +.service-actions { display: flex; justify-content: flex-end; margin-top: 13px; } +.primary-button, .secondary-button, .icon-button, .direction-pad button { + border: 0; border-radius: 8px; cursor: pointer; + transition: transform 140ms var(--ease-out), background-color 140ms ease, border-color 140ms ease; +} +.primary-button { height: 38px; padding: 0 15px; color: #fff; background: var(--brand); font-weight: 600; box-shadow: 0 2px 7px rgba(79, 149, 100, .16); } +.primary-button:hover { background: var(--brand-strong); } +.primary-button:disabled, .secondary-button:disabled { opacity: .5; cursor: default; } + +.preferences-card { padding: 16px 18px 2px; } +.preferences-card > h2 { margin-bottom: 6px; } +.preference-row { display: flex; align-items: center; justify-content: space-between; gap: 18px; min-height: 60px; border-top: 1px solid #ecefeb; } +.preferences-card > h2 + .preference-row { border-top: 0; } +.preference-copy { display: grid; gap: 2px; min-width: 0; } +.preference-copy strong { font-size: 14px; font-weight: 600; } +.preference-copy span { color: var(--text-secondary); font-size: 12px; } +.shortcut { + flex: 0 0 auto; min-width: 148px; height: 38px; padding: 0 13px; border: 1px solid var(--border-strong); + border-radius: 8px; color: #353a36; background: linear-gradient(#fff, #fafbfa); cursor: pointer; + box-shadow: inset 0 -1px 0 rgba(30, 40, 32, .035); font-variant-numeric: tabular-nums; +} +.shortcut[data-capturing="true"] { border-color: #88b995; color: #39794c; background: var(--brand-soft); } +.toggle-row { cursor: pointer; user-select: none; } +.toggle { position: relative; flex: 0 0 auto; width: 45px; height: 26px; } +.toggle input { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); } +.toggle > span { + display: block; width: 45px; height: 26px; border-radius: 999px; background: #cdd1cd; + transition: background-color 160ms ease; +} +.toggle > span::after { + content: ""; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; + background: #fff; box-shadow: 0 1px 4px rgba(25, 36, 28, .18); + transform: translateX(0); transition: transform 180ms var(--ease-out); +} +.toggle input:checked + span { background: var(--brand); } +.toggle input:checked + span::after { transform: translateX(19px); } +.toggle input:focus-visible + span { outline: 2px solid rgba(99, 169, 120, .42); outline-offset: 2px; } + +.position-link { + display: flex; align-items: center; width: 100%; min-height: 72px; padding: 11px 16px; + color: var(--text); text-align: left; cursor: pointer; + transition: transform 140ms var(--ease-out), border-color 140ms ease, background-color 140ms ease; +} +.position-icon { display: grid; place-items: center; width: 44px; height: 44px; margin-right: 13px; border: 1px solid #b8dbbf; border-radius: 50%; background: #f6fbf7; } +.position-icon img { width: 31px; height: 31px; border-radius: 50%; object-fit: cover; } +.position-link-copy { display: grid; gap: 3px; flex: 1; } +.position-link-copy strong { font-size: 14px; } +.position-link-copy span { color: var(--text-secondary); font-size: 12px; } +.chevron { width: 21px; height: 21px; color: #6e756f; } +.app-footer { display: flex; align-items: center; justify-content: space-between; min-height: 26px; padding: 0 7px; color: var(--text-secondary); font-size: 12px; } +.repository-link { display: inline-flex; align-items: center; gap: 3px; padding: 4px 0; border: 0; color: #4a8c5e; background: transparent; cursor: pointer; } +.repository-link svg { width: 15px; height: 15px; } +.repository-link:disabled { color: var(--text-tertiary); cursor: default; } + +.position-view { max-width: 780px; margin: 0 auto; } +.position-header { display: flex; align-items: center; gap: 13px; min-height: 49px; margin-bottom: 12px; } +.position-header p { font-size: 13px; } +.icon-button { display: grid; place-items: center; width: 40px; height: 40px; border: 1px solid var(--border); background: var(--surface); } +.icon-button svg { width: 20px; height: 20px; } +.position-panel { padding: 15px 16px 14px; } +.display-row { display: grid; grid-template-columns: 90px minmax(230px, 1fr); align-items: center; gap: 16px; } +.display-row label { font-size: 15px; font-weight: 600; } +.display-row .select-shell { justify-self: end; width: min(100%, 315px); } + +.position-screen { + position: relative; height: 250px; margin-top: 14px; overflow: hidden; + border: 1px solid #cfd7d0; border-radius: 9px; + background: linear-gradient(160deg, #f2f7f3 0%, #e9f1eb 45%, #dce9df 100%); +} +.position-screen::before, .position-screen::after { + content: ""; position: absolute; left: -8%; width: 116%; height: 62%; border-radius: 50%; pointer-events: none; +} +.position-screen::before { bottom: -35%; background: rgba(150, 183, 159, .23); transform: rotate(2deg); } +.position-screen::after { bottom: -47%; background: rgba(126, 165, 137, .18); transform: rotate(-4deg); } +.desktop-bar { position: relative; z-index: 2; display: flex; align-items: center; justify-content: space-between; height: 22px; padding: 0 9px; border-bottom: 1px solid #d3dad4; color: #69706a; background: rgba(251, 252, 251, .88); font-size: 9px; } +.desktop-status { letter-spacing: 1px; } +.desktop-window { + position: absolute; z-index: 1; width: 38%; height: 53%; border: 1px solid rgba(148, 158, 150, .58); + border-radius: 5px; background: rgba(255, 255, 255, .86); box-shadow: 0 8px 22px rgba(61, 76, 64, .055); +} +.desktop-window::before { content: ""; display: block; height: 13px; border-bottom: 1px solid #e0e4e0; } +.desktop-window i { position: absolute; top: 4px; width: 4px; height: 4px; border-radius: 50%; background: #c4cac5; } +.desktop-window i:nth-child(1) { right: 17px; }.desktop-window i:nth-child(2) { right: 10px; }.desktop-window i:nth-child(3) { right: 3px; } +.desktop-window span { display: block; width: 52%; height: 7px; margin: 11px 12px -4px; border-radius: 3px; background: #ecefec; } +.desktop-window span:nth-of-type(2) { width: 38%; }.desktop-window span:nth-of-type(3) { width: 68%; } +.window-back { top: 28%; left: 17%; }.window-front { top: 46%; left: 38%; width: 35%; height: 47%; } +.position-ball { + position: absolute; z-index: 4; width: 50px; height: 50px; padding: 5px; border: 2px solid var(--brand); + border-radius: 50%; background: rgba(247, 251, 248, .78); box-shadow: 0 6px 18px rgba(66, 113, 78, .2); + transform: translate(-50%, -50%); +} +.position-ball img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; } + +.position-tools { display: grid; grid-template-columns: minmax(145px, .82fr) minmax(265px, 1.55fr); gap: 18px; margin-top: 14px; } +.position-tools h2 { margin-bottom: 9px; font-size: 13px; } +.preset-section { padding-right: 18px; border-right: 1px solid var(--border); } +.preset-grid { display: grid; grid-template-columns: repeat(3, 1fr); place-items: center; gap: 12px 18px; min-height: 118px; padding: 14px; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--surface-muted); } +.preset-grid button { + position: relative; width: 18px; height: 18px; padding: 0; border: 1.5px solid #aeb5af; border-radius: 50%; background: #fff; cursor: pointer; +} +.preset-grid button[aria-pressed="true"] { border-color: var(--brand); background: var(--brand); box-shadow: 0 0 0 6px #e7f3ea; } +.precise-controls { display: grid; grid-template-columns: minmax(125px, 1fr) 132px; align-items: center; gap: 16px; } +.coordinates { display: grid; gap: 10px; } +.coordinates label { display: grid; grid-template-columns: 35px 1fr; height: 38px; border: 1px solid var(--border-strong); border-radius: 8px; background: #fff; overflow: hidden; } +.coordinates label > span { display: grid; place-items: center; border-right: 1px solid var(--border); color: var(--text-secondary); } +.coordinates input { width: 100%; min-width: 0; height: 100%; padding: 0 10px; border: 0; color: var(--text); background: transparent; font-variant-numeric: tabular-nums; } +.coordinates input:focus-visible { outline-offset: -2px; } +.direction-pad { display: grid; grid-template-columns: repeat(3, 38px); grid-template-rows: repeat(3, 34px); gap: 4px; justify-content: center; } +.direction-pad button { display: grid; place-items: center; padding: 0; border: 1px solid var(--border-strong); background: #fbfcfb; } +.direction-pad button svg { width: 18px; height: 18px; } .direction-pad button[data-direction="up"] { grid-column: 2; } .direction-pad button[data-direction="left"] { grid-column: 1; grid-row: 2; } .direction-pad button[data-direction="center"] { grid-column: 2; grid-row: 2; } .direction-pad button[data-direction="right"] { grid-column: 3; grid-row: 2; } .direction-pad button[data-direction="down"] { grid-column: 2; grid-row: 3; } -.preset-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 18px 0 12px; padding: 12px; border: 1px solid #dbe2ee; border-radius: 10px; background: #f7f9fc; } -.preset-grid button { align-self: center; justify-self: center; width: 24px; height: 24px; padding: 0; border: 3px solid #3476ed; border-radius: 50%; background: #76a4f8; } -.screen button { align-self: center; justify-self: center; width: 24px; height: 24px; padding: 0; border: 3px solid #3476ed; border-radius: 50%; background: #76a4f8; } -.coordinates { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } -.coordinates label { display: grid; grid-template-columns: 18px 1fr; align-items: center; color: #5f6b80; } -.coordinates input { width: 100%; } -.buttons { display: flex; gap: 10px; margin-top: 12px; } -.hint { margin-top: 7px; color: #7b8698; font-size: 12px; } -.shortcut { margin-left: auto; min-width: 180px; } -.link { margin-left: auto; color: #286df0; background: transparent; } -#message { min-height: 21px; color: #55708f; } -dialog { width: min(420px, calc(100vw - 48px)); padding: 0; border: 0; border-radius: 14px; color: inherit; box-shadow: 0 18px 55px rgba(20,32,55,.25); } -dialog::backdrop { background: rgba(18,27,43,.42); backdrop-filter: blur(2px); } -.modal-content { padding: 22px; } -.modal-content h2 { margin-bottom: 8px; } -.modal-content p { margin: 0; color: #5f6b80; } -.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; } +.hint { margin: 8px 0 0; color: var(--text-secondary); font-size: 11px; white-space: nowrap; } + +.message { + position: fixed; z-index: 20; right: 18px; bottom: 16px; display: flex; align-items: center; gap: 8px; + max-width: calc(100vw - 36px); min-height: 38px; padding: 0 13px; border: 1px solid #cfe3d4; border-radius: 9px; + color: #39784c; background: rgba(245, 251, 246, .97); box-shadow: 0 9px 30px rgba(42, 61, 47, .12); + opacity: 0; pointer-events: none; transform: translateY(8px); + transition: opacity 160ms ease, transform 180ms var(--ease-out); +} +.message::before { content: "✓"; display: grid; place-items: center; width: 18px; height: 18px; border: 1px solid currentColor; border-radius: 50%; font-size: 11px; } +.message[data-visible="true"] { opacity: 1; transform: translateY(0); } +.message[data-kind="error"] { border-color: #efcdc8; color: var(--error); background: rgba(255, 246, 245, .98); } +.message[data-kind="error"]::before { content: "!"; font-weight: 700; } + +dialog { width: min(410px, calc(100vw - 40px)); padding: 0; border: 1px solid var(--border); border-radius: 14px; color: inherit; background: var(--surface); box-shadow: 0 22px 70px rgba(26, 38, 29, .22); } +dialog::backdrop { background: rgba(28, 34, 29, .38); backdrop-filter: blur(2px); } +.modal-content { padding: 23px; } +.modal-icon { display: grid; place-items: center; width: 42px; height: 42px; margin-bottom: 15px; border-radius: 11px; background: var(--brand-soft); } +.modal-icon img { width: 32px; height: 32px; border-radius: 8px; } +.modal-content h2 { margin: 0 0 7px; font-size: 18px; } +.modal-content p { margin: 0; color: var(--text-secondary); } +.modal-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 22px; } +.secondary-button { height: 38px; padding: 0 15px; border: 1px solid var(--border-strong); background: var(--surface); font-weight: 600; } + +@media (hover: hover) and (pointer: fine) { + .position-link:hover { border-color: #cfd9d1; background: #fcfdfc; } + .repository-link:hover { color: var(--brand-strong); } + .icon-button:hover, .secondary-button:hover, .direction-pad button:hover { background: #f2f5f2; } + .preset-grid button:hover { border-color: var(--brand); } + button:not(:disabled):active { transform: scale(.97); } + .direction-pad button:active, .preset-grid button:active, .shortcut:active { transform: none; } +} + +@media (max-width: 590px) { + .view { padding-right: 18px; padding-left: 18px; } + .service-row { grid-template-columns: 82px 1fr; } + .backend-status { grid-column: 2; } + .position-tools { grid-template-columns: 1fr; } + .preset-section { padding-right: 0; padding-bottom: 14px; border-right: 0; border-bottom: 1px solid var(--border); } + .preset-grid { grid-template-columns: repeat(3, 45px); justify-content: center; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; } + button, select, .toggle > span, .toggle > span::after, .position-ball, .message { transition-duration: 0ms !important; } +} diff --git a/src/renderer/settings.html b/src/renderer/settings.html index 9091117..3e1f01e 100644 --- a/src/renderer/settings.html +++ b/src/renderer/settings.html @@ -7,62 +7,128 @@ -

TamaASR

设置
-
-
-

语音后端

-
- - +
+
+
+ TamaASR +

TamaASR

Linux 语音输入

+
+ 正在连接 +
+ +
+
+

语音服务

+

选择用于语音识别的网页服务

+
+
+ +
+ +
+ +
-
-

悬浮球

- +
+

输入与启动

+
+
按住说话按住快捷键开始识别,松开后提交文本
+ +
+ +
-
-

常规

-
按住说话快捷键
- - -
+ -
-

关于

-
版本
-
项目仓库
-
-
+
+ 版本 + +
-
-

悬浮球位置

位置调整
-
-
-
-
-
-
- - - + +
+
+ +

悬浮球位置

选择它在桌面上的默认位置

+
+ +
+
+ +
+
+ +
+
活动TamaASR 桌面●  ●  ●
+
+
+
悬浮球
+
+ +
+
+

快速位置

+
+ + + + + + + + + +
+
+ +
+

精确位置

+
+
+ + +
+
+ + + + + +
+
+

方向键移动 1 像素,按住 Shift 移动 10 像素

-
- - - -
-
方向键 1 像素,Shift + 方向键 10 像素
+ +
+ diff --git a/src/renderer/settings.ts b/src/renderer/settings.ts index 62a2d5c..ae1f078 100644 --- a/src/renderer/settings.ts +++ b/src/renderer/settings.ts @@ -17,8 +17,23 @@ interface SettingsViewState { repositoryUrl: string | null; } +const OVERLAY_SIZE = 88; +const PRESET_MARGIN = 32; +const PRESET_LABELS: Record = { + 'top-left': '左上角', + 'top-center': '顶部居中', + 'top-right': '右上角', + 'middle-left': '左侧居中', + 'middle-center': '屏幕中央', + 'middle-right': '右侧居中', + 'bottom-left': '左下角', + 'bottom-center': '底部居中', + 'bottom-right': '右下角' +}; + const backend = document.querySelector('#backend')!; const backendStatus = document.querySelector('#backend-status')!; +const appStatus = document.querySelector('#app-status')!; const loginWarning = document.querySelector('#login-warning')!; const display = document.querySelector('#display')!; const x = document.querySelector('#position-x')!; @@ -28,21 +43,44 @@ const autoStart = document.querySelector('#auto-start')!; const diagnosticLogging = document.querySelector('#diagnostic-logging')!; const version = document.querySelector('#version')!; const repository = document.querySelector('#repository')!; +const repositoryLabel = repository.querySelector('span')!; const message = document.querySelector('#message')!; -const settingsView = document.querySelector('body > main')!; +const settingsView = document.querySelector('#settings-view')!; const positionView = document.querySelector('#position-view')!; const positionScreen = document.querySelector('#position-screen')!; const positionBall = document.querySelector('#position-ball')!; +const positionSummary = document.querySelector('#position-summary')!; const loginNotice = document.querySelector('#login-notice')!; const loginNoticeTitle = document.querySelector('#login-notice-title')!; +const presetButtons = [...document.querySelectorAll('[data-preset]')]; let state: SettingsViewState; let capturingShortcut = false; let positionMode = false; +let messageTimer: ReturnType | null = null; function statusText(status: BackendRuntimeStatus): string { - if (status.ready) return '已就绪'; + if (status.ready || status.login === 'logged-in') return '已登录'; if (status.login === 'logged-out') return '需要登录'; - return status.detail || '正在加载'; + return '加载中'; +} + +function renderServiceStatus(status: BackendRuntimeStatus): void { + const backendStatusText = backendStatus.querySelector('span')!; + backendStatusText.textContent = statusText(status); + backendStatus.dataset.state = status.ready ? 'ready' + : status.login === 'logged-out' ? 'warning' : 'loading'; + + const appStatusText = appStatus.querySelector('span')!; + if (status.ready) { + appStatusText.textContent = '服务已就绪'; + appStatus.dataset.state = 'ready'; + } else if (status.login === 'logged-out') { + appStatusText.textContent = '需要登录'; + appStatus.dataset.state = 'warning'; + } else { + appStatusText.textContent = '正在连接'; + appStatus.dataset.state = 'loading'; + } } function render(next: SettingsViewState): void { @@ -50,42 +88,95 @@ function render(next: SettingsViewState): void { backend.replaceChildren(...next.backends.map((item) => new Option(item.label, item.id))); backend.value = next.backendId; const active = next.backends.find((item) => item.id === next.backendId)!; - backendStatus.textContent = statusText(active.status); - backendStatus.dataset.state = active.status.ready ? 'ready' - : active.status.login === 'logged-out' ? 'warning' : 'loading'; + renderServiceStatus(active.status); loginWarning.hidden = active.status.login !== 'logged-out'; const previousDisplay = display.value; display.replaceChildren(...next.displays.map((item) => new Option(item.label, item.id))); - const containing = next.displays.find((item) => { - const area = item.workArea; - return next.overlayPosition.x >= area.x && next.overlayPosition.x < area.x + area.width && - next.overlayPosition.y >= area.y && next.overlayPosition.y < area.y + area.height; - }); + const containing = next.displays.find((item) => containsPosition(item.workArea, next.overlayPosition)); display.value = next.displays.some((item) => item.id === previousDisplay) ? previousDisplay : (containing?.id ?? next.displays[0]?.id ?? ''); x.value = String(next.overlayPosition.x); y.value = String(next.overlayPosition.y); renderPositionPreview(next); + renderPositionSummary(next, containing); + shortcut.textContent = capturingShortcut ? '请按下新快捷键…' : displayShortcut(next.shortcut); + shortcut.dataset.capturing = String(capturingShortcut); + shortcut.setAttribute('aria-pressed', String(capturingShortcut)); autoStart.checked = next.autoStart; diagnosticLogging.checked = next.diagnosticLogging; version.textContent = next.version; - repository.textContent = next.repositoryUrl ?? '尚未配置'; + repositoryLabel.textContent = next.repositoryUrl ? '项目仓库' : '仓库未配置'; repository.disabled = !next.repositoryUrl; } +function containsPosition(area: SettingsViewState['displays'][number]['workArea'], point: { x: number; y: number }): boolean { + return point.x >= area.x && point.x < area.x + area.width && point.y >= area.y && point.y < area.y + area.height; +} + +function selectedDisplay(next: SettingsViewState): SettingsViewState['displays'][number] | undefined { + return next.displays.find((item) => item.id === display.value) ?? next.displays[0]; +} + +function presetPoint(area: SettingsViewState['displays'][number]['workArea'], preset: string): { x: number; y: number } { + const [vertical, horizontal] = preset.split('-'); + const pointX = horizontal === 'left' ? area.x + PRESET_MARGIN + : horizontal === 'right' ? area.x + area.width - OVERLAY_SIZE - PRESET_MARGIN + : area.x + (area.width - OVERLAY_SIZE) / 2; + const pointY = vertical === 'top' ? area.y + PRESET_MARGIN + : vertical === 'bottom' ? area.y + area.height - OVERLAY_SIZE - PRESET_MARGIN + : area.y + (area.height - OVERLAY_SIZE) / 2; + return { x: Math.round(pointX), y: Math.round(pointY) }; +} + +function matchingPreset(next: SettingsViewState, displayInfo = selectedDisplay(next)): string | null { + if (!displayInfo) return null; + for (const button of presetButtons) { + const preset = button.dataset.preset!; + const point = presetPoint(displayInfo.workArea, preset); + if (point.x === next.overlayPosition.x && point.y === next.overlayPosition.y) return preset; + } + return null; +} + function renderPositionPreview(next: SettingsViewState): void { - const area = next.displays.find((item) => item.id === display.value)?.workArea ?? next.displays[0]?.workArea; - if (!area) return; - const px = Math.max(0, Math.min(area.width, next.overlayPosition.x - area.x)); - const py = Math.max(0, Math.min(area.height, next.overlayPosition.y - area.y)); - positionBall.style.left = `${(px / area.width) * 100}%`; - positionBall.style.top = `${(py / area.height) * 100}%`; + const displayInfo = selectedDisplay(next); + if (!displayInfo) return; + const area = displayInfo.workArea; + const centerX = next.overlayPosition.x - area.x + OVERLAY_SIZE / 2; + const centerY = next.overlayPosition.y - area.y + OVERLAY_SIZE / 2; + const px = Math.max(OVERLAY_SIZE / 2, Math.min(area.width - OVERLAY_SIZE / 2, centerX)); + const py = Math.max(OVERLAY_SIZE / 2, Math.min(area.height - OVERLAY_SIZE / 2, centerY)); + positionBall.style.left = `clamp(25px, ${(px / area.width) * 100}%, calc(100% - 25px))`; + positionBall.style.top = `clamp(25px, ${(py / area.height) * 100}%, calc(100% - 25px))`; + + const preset = matchingPreset(next, displayInfo); + presetButtons.forEach((button) => button.setAttribute('aria-pressed', String(button.dataset.preset === preset))); +} + +function renderPositionSummary( + next: SettingsViewState, + containing = next.displays.find((item) => containsPosition(item.workArea, next.overlayPosition)) +): void { + const displayInfo = containing ?? selectedDisplay(next); + if (!displayInfo) { + positionSummary.textContent = '未检测到显示器'; + return; + } + const preset = matchingPreset(next, displayInfo); + const screenName = displayInfo.label.split(' · ')[0].split('(')[0]; + positionSummary.textContent = preset + ? `${screenName} · ${PRESET_LABELS[preset]}` + : `${screenName} · X ${next.overlayPosition.x},Y ${next.overlayPosition.y}`; } function displayShortcut(value: string): string { - return value.replaceAll('Control', 'Ctrl').replaceAll('Super', 'Meta').replace(/\+space$/i, '+Space'); + return value + .replaceAll('Control', 'Ctrl') + .replaceAll('Super', 'Meta') + .replace(/\+space$/i, '+Space') + .replaceAll('+', ' + '); } function formatShortcut(event: KeyboardEvent): string | null { @@ -103,32 +194,47 @@ function formatShortcut(event: KeyboardEvent): string | null { return [...modifiers, key].join('+'); } -async function apply(action: () => Promise, confirmation = '已保存'): Promise { +function showMessage(text: string, kind: 'success' | 'error' = 'success'): void { + if (messageTimer) clearTimeout(messageTimer); + message.textContent = text; + message.dataset.kind = kind; + message.dataset.visible = 'true'; + messageTimer = setTimeout(() => { + message.dataset.visible = 'false'; + messageTimer = null; + }, kind === 'error' ? 4200 : 2200); +} + +async function apply(action: () => Promise, confirmation = '设置已保存'): Promise { try { render(await action()); - message.textContent = confirmation; + showMessage(confirmation); } catch (error) { - message.textContent = (error as Error).message; + showMessage((error as Error).message, 'error'); } } async function applyPosition(): Promise { - await apply(() => window.meloSettings.setPosition({ x: Number(x.value), y: Number(y.value) }, display.value)); + await apply(() => window.meloSettings.setPosition({ x: Number(x.value), y: Number(y.value) }, display.value), '位置已保存'); } -backend.addEventListener('change', () => void apply(() => window.meloSettings.setBackend(backend.value))); +backend.addEventListener('change', () => void apply(() => window.meloSettings.setBackend(backend.value), '语音服务已切换')); +display.addEventListener('change', () => { + renderPositionPreview(state); + renderPositionSummary(state); +}); document.querySelector('#open-debug')!.addEventListener('click', () => void window.meloSettings.openDebug()); document.querySelector('#open-position')!.addEventListener('click', () => { positionMode = true; - settingsView.style.display = 'none'; - positionView.style.display = 'grid'; + settingsView.hidden = true; + positionView.hidden = false; void window.meloSettings.beginPositioning(); renderPositionPreview(state); }); document.querySelector('#back-settings')!.addEventListener('click', () => { positionMode = false; - positionView.style.display = 'none'; - settingsView.style.display = 'grid'; + positionView.hidden = true; + settingsView.hidden = false; void window.meloSettings.endPositioning(); }); document.querySelector('#login-notice-cancel')!.addEventListener('click', () => loginNotice.close()); @@ -136,7 +242,7 @@ document.querySelector('#login-notice-continue')!.addEventListener('click', () = loginNotice.close(); void window.meloSettings.openDebug(true); }); -document.querySelectorAll('[data-preset]').forEach((button) => { +presetButtons.forEach((button) => { button.addEventListener('click', () => void apply( () => window.meloSettings.setPreset(button.dataset.preset!, display.value), '位置已保存' )); @@ -147,12 +253,11 @@ async function nudge(direction: string): Promise { await apply(() => window.meloSettings.setPreset('middle-center', display.value), '位置已保存'); return; } - const step = 1; const next = { x: Number(x.value), y: Number(y.value) }; - if (direction === 'left') next.x -= step; - if (direction === 'right') next.x += step; - if (direction === 'up') next.y -= step; - if (direction === 'down') next.y += step; + if (direction === 'left') next.x -= 1; + if (direction === 'right') next.x += 1; + if (direction === 'up') next.y -= 1; + if (direction === 'down') next.y += 1; x.value = String(next.x); y.value = String(next.y); await applyPosition(); @@ -191,11 +296,17 @@ document.addEventListener('keydown', (event) => { shortcut.addEventListener('click', () => { capturingShortcut = true; shortcut.textContent = '请按下新快捷键…'; + shortcut.dataset.capturing = 'true'; + shortcut.setAttribute('aria-pressed', 'true'); shortcut.focus(); }); -autoStart.addEventListener('change', () => void apply(() => window.meloSettings.setAutoStart(autoStart.checked))); +autoStart.addEventListener('change', () => void apply( + () => window.meloSettings.setAutoStart(autoStart.checked), + autoStart.checked ? '已开启开机自动启动' : '已关闭开机自动启动' +)); diagnosticLogging.addEventListener('change', () => void apply( - () => window.meloSettings.setDiagnosticLogging(diagnosticLogging.checked) + () => window.meloSettings.setDiagnosticLogging(diagnosticLogging.checked), + diagnosticLogging.checked ? '已开启诊断日志' : '已关闭诊断日志' )); repository.addEventListener('click', () => void window.meloSettings.openRepository()); window.meloSettings.onState(render); @@ -206,4 +317,4 @@ window.meloSettings.onLoginNotice((backendLabel) => { window.addEventListener('beforeunload', () => { if (positionMode) void window.meloSettings.endPositioning(); }); -void window.meloSettings.getState().then(render).catch((error: Error) => { message.textContent = error.message; }); +void window.meloSettings.getState().then(render).catch((error: Error) => showMessage(error.message, 'error'));