feat: 重新设计设置界面并发布 1.0.1

统一设置主页和悬浮球定位页的视觉层级、状态反馈与无障碍交互,并同步发行元数据。

AI-assisted-by: Codex

AI-model: gpt-5.6-sol

AI-reasoning-effort: medium

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
2026-09-01 22:01:14 +08:00
co-authored by Codex
parent 2100ebab6b
commit 368bc643a4
15 changed files with 519 additions and 139 deletions
+8
View File
@@ -2,6 +2,14 @@
本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 的格式。 本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 的格式。
## [1.0.1] - 2026-09-01
### 变更
- 重新设计设置界面,统一 TamaASR 品牌色、控件层级、服务状态、快捷键、开关与即时保存反馈。
- 改进悬浮球位置调整页,增加桌面预览、九宫格选中状态、精确坐标与方向微调控件。
- 补充键盘焦点、辅助文本、减少动态效果偏好和窄窗口布局支持。
## [1.0.0] - 2026-09-01 ## [1.0.0] - 2026-09-01
### 变更 ### 变更
+1 -1
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.16) 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(GNUInstallDirs)
include(CTest) include(CTest)
+1 -1
View File
@@ -6,7 +6,7 @@ Comment[zh_CN]=将 TamaASR 网页后端连接到当前输入上下文
Type=SharedLibrary Type=SharedLibrary
Library=@TAMA_ASR_LIBRARY_PATH@ Library=@TAMA_ASR_LIBRARY_PATH@
Category=Module Category=Module
Version=1.0.0 Version=1.0.1
OnDemand=False OnDemand=False
Configurable=False Configurable=False
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "tama-asr", "name": "tama-asr",
"productName": "TamaASR", "productName": "TamaASR",
"version": "1.0.0", "version": "1.0.1",
"private": true, "private": true,
"packageManager": "pnpm@11.21.0", "packageManager": "pnpm@11.21.0",
"author": "TamaASR contributors", "author": "TamaASR contributors",
+3 -3
View File
@@ -1,13 +1,13 @@
# Linux 分发打包 # Linux 分发打包
TamaASR 1.0.0 的系统包由两部分组成: TamaASR 1.0.1 的系统包由两部分组成:
- Electron 应用安装到 `/opt/tama-asr`,命令入口为 `/usr/bin/tama-asr`; - Electron 应用安装到 `/opt/tama-asr`,命令入口为 `/usr/bin/tama-asr`;
- Fcitx5 常驻 addon 由目标发行版中的 CMake/Fcitx5 开发包编译,并安装到该环境报告的 addon 目录。 - Fcitx5 常驻 addon 由目标发行版中的 CMake/Fcitx5 开发包编译,并安装到该环境报告的 addon 目录。
安装包同时提供桌面入口、项目图标和 `/etc/xdg/autostart/tama-asr.desktop`,因此安装后的默认行为是随桌面会话启动。设置页关闭自启动时,应用需要在用户级 autostart 目录写入覆盖项,不能删除系统包所属文件。 安装包同时提供桌面入口、项目图标和 `/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/deb/build-deb.sh
``` ```
产物位于 `packaging/out/tama-asr_1.0.0_<arch>.deb`。依赖名称按 Debian/Ubuntu 系列填写,但尚未在各发行版完成安装验证。 产物位于 `packaging/out/tama-asr_1.0.1_<arch>.deb`。依赖名称按 Debian/Ubuntu 系列填写,但尚未在各发行版完成安装验证。
## rpm ## rpm
+1 -1
View File
@@ -1,5 +1,5 @@
pkgname=tama-asr pkgname=tama-asr
pkgver=1.0.0 pkgver=1.0.1
pkgrel=1 pkgrel=1
pkgdesc='Web-backed voice input for Fcitx5' pkgdesc='Web-backed voice input for Fcitx5'
arch=('x86_64') arch=('x86_64')
+1 -1
View File
@@ -4,7 +4,7 @@
```bash ```bash
curl --fail --location --remote-name \ 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 makepkg --syncdeps --cleanbuild
``` ```
+1
View File
@@ -16,6 +16,7 @@
<binary>tama-asr</binary> <binary>tama-asr</binary>
</provides> </provides>
<releases> <releases>
<release version="1.0.1" date="2026-09-01"/>
<release version="1.0.0" date="2026-09-01"/> <release version="1.0.0" date="2026-09-01"/>
<release version="0.1.17" date="2026-09-01"/> <release version="0.1.17" date="2026-09-01"/>
<release version="0.1.16" date="2026-08-26"/> <release version="0.1.16" date="2026-08-26"/>
+1 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" 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)}" arch="${TAMA_ASR_DEB_ARCH:-$(dpkg --print-architecture)}"
app_dir="${1:-${TAMA_ASR_ELECTRON_APP_DIR:-${project_dir}/release/linux-unpacked}}" app_dir="${1:-${TAMA_ASR_ELECTRON_APP_DIR:-${project_dir}/release/linux-unpacked}}"
work_dir="${project_dir}/packaging/out/deb-root" work_dir="${project_dir}/packaging/out/deb-root"
+1 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" 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}}" app_dir="${1:-${TAMA_ASR_ELECTRON_APP_DIR:-${project_dir}/release/linux-unpacked}}"
stage_dir="${project_dir}/packaging/out/rpm-root" stage_dir="${project_dir}/packaging/out/rpm-root"
top_dir="${project_dir}/packaging/out/rpmbuild" top_dir="${project_dir}/packaging/out/rpmbuild"
+3
View File
@@ -38,6 +38,9 @@ tar -C %{buildroot} -xzf %{SOURCE0}
/etc/xdg/autostart/tama-asr.desktop /etc/xdg/autostart/tama-asr.desktop
%changelog %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 * Tue Sep 01 2026 TamaASR contributors - 1.0.0-1
- Rename the package and replace the legacy meloasr package on upgrade. - Rename the package and replace the legacy meloasr package on upgrade.
+1 -1
View File
@@ -98,7 +98,7 @@ export class SettingsWindow {
overlayPosition: position, overlayPosition: position,
displays: screen.getAllDisplays().map((display, index) => ({ displays: screen.getAllDisplays().map((display, index) => ({
id: String(display.id), 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 workArea: display.workArea
})), })),
version: app.getVersion(), version: app.getVersion(),
+240 -49
View File
@@ -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; } * { box-sizing: border-box; }
[hidden] { display: none !important; } [hidden] { display: none !important; }
body { margin: 0; background: #f4f6fa; } html, body { min-width: 100%; min-height: 100%; }
header { display: flex; align-items: center; gap: 12px; padding: 22px 28px 12px; } body { margin: 0; overflow-x: hidden; background: #f6f7f5; }
header img { width: 44px; height: 44px; border-radius: 11px; } button, select, input { font: inherit; }
header h1 { margin: 0; font-size: 22px; } button { color: inherit; }
header span { color: #788398; } button, select { -webkit-tap-highlight-color: transparent; }
main { display: grid; gap: 14px; padding: 0 28px 30px; } button:focus-visible, select:focus-visible, input:focus-visible {
section { padding: 18px; background: white; border: 1px solid #e0e5ee; border-radius: 13px; box-shadow: 0 5px 20px rgba(33,48,77,.05); } outline: 2px solid rgba(99, 169, 120, .42);
h2 { margin: 0 0 14px; font-size: 16px; } outline-offset: 2px;
.row { display: flex; align-items: center; gap: 12px; min-height: 40px; } }
.row > label, .row > span:first-child { min-width: 80px; color: #5f6b80; } svg { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
select, input, button { height: 38px; border-radius: 8px; font: inherit; }
select, input { padding: 0 10px; border: 1px solid #cad3e1; background: white; } .view { width: 100%; padding: 20px 24px; }
select { flex: 1; } .settings-view { display: grid; gap: 12px; max-width: 720px; margin: 0 auto; }
button { padding: 0 14px; border: 0; background: #e9eef7; color: #26334a; cursor: pointer; font-weight: 600; } .card {
button.primary { background: #286df0; color: white; } border: 1px solid var(--border);
button:disabled { opacity: .55; cursor: default; } border-radius: 12px;
.status { padding: 4px 9px; border-radius: 99px; font-size: 12px; background: #edf0f5; } background: var(--surface);
.status[data-state="ready"] { color: #087b4d; background: #ddf6eb; } box-shadow: 0 1px 2px rgba(33, 46, 36, .025), 0 7px 24px rgba(33, 46, 36, .025);
.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; } .app-header { display: flex; align-items: center; justify-content: space-between; min-height: 48px; }
.switch input { width: 17px; height: 17px; } .brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
.position-panel { max-width: 760px; } .brand img { width: 42px; height: 42px; border-radius: 11px; object-fit: cover; box-shadow: 0 2px 8px rgba(67, 112, 78, .12); }
.page-title { display: flex; align-items: center; gap: 12px; } .brand h1, .position-header h1 { margin: 0; font-size: 20px; line-height: 1.2; letter-spacing: -.018em; }
.page-title h2 { margin: 0; } .brand p, .position-header p, .section-heading p { margin: 3px 0 0; color: var(--text-secondary); }
.page-title span { color: #788398; } .brand p { font-size: 13px; }
.position-screen { position: relative; height: 300px; margin: 16px 0; border: 2px solid #b9c4d6; border-radius: 10px; background: #f7f9fc; overflow: hidden; } .app-status {
.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); } display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px;
.position-controls { display: grid; grid-template-columns: 1fr 164px; align-items: center; gap: 24px; } border: 1px solid #d4e6d8; border-radius: 9px; color: #417d53; background: #f3f9f4;
.direction-pad { display: grid; grid-template-columns: repeat(3, 48px); grid-template-rows: repeat(3, 38px); gap: 4px; justify-content: center; } white-space: nowrap;
.direction-pad button { padding: 0; } }
.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="up"] { grid-column: 2; }
.direction-pad button[data-direction="left"] { grid-column: 1; grid-row: 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="center"] { grid-column: 2; grid-row: 2; }
.direction-pad button[data-direction="right"] { grid-column: 3; 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; } .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; } .hint { margin: 8px 0 0; color: var(--text-secondary); font-size: 11px; white-space: nowrap; }
.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; } .message {
.coordinates { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } position: fixed; z-index: 20; right: 18px; bottom: 16px; display: flex; align-items: center; gap: 8px;
.coordinates label { display: grid; grid-template-columns: 18px 1fr; align-items: center; color: #5f6b80; } max-width: calc(100vw - 36px); min-height: 38px; padding: 0 13px; border: 1px solid #cfe3d4; border-radius: 9px;
.coordinates input { width: 100%; } color: #39784c; background: rgba(245, 251, 246, .97); box-shadow: 0 9px 30px rgba(42, 61, 47, .12);
.buttons { display: flex; gap: 10px; margin-top: 12px; } opacity: 0; pointer-events: none; transform: translateY(8px);
.hint { margin-top: 7px; color: #7b8698; font-size: 12px; } transition: opacity 160ms ease, transform 180ms var(--ease-out);
.shortcut { margin-left: auto; min-width: 180px; } }
.link { margin-left: auto; color: #286df0; background: transparent; } .message::before { content: "✓"; display: grid; place-items: center; width: 18px; height: 18px; border: 1px solid currentColor; border-radius: 50%; font-size: 11px; }
#message { min-height: 21px; color: #55708f; } .message[data-visible="true"] { opacity: 1; transform: translateY(0); }
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); } .message[data-kind="error"] { border-color: #efcdc8; color: var(--error); background: rgba(255, 246, 245, .98); }
dialog::backdrop { background: rgba(18,27,43,.42); backdrop-filter: blur(2px); } .message[data-kind="error"]::before { content: "!"; font-weight: 700; }
.modal-content { padding: 22px; }
.modal-content h2 { margin-bottom: 8px; } 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); }
.modal-content p { margin: 0; color: #5f6b80; } dialog::backdrop { background: rgba(28, 34, 29, .38); backdrop-filter: blur(2px); }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; } .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; }
}
+108 -42
View File
@@ -7,62 +7,128 @@
<link rel="stylesheet" href="settings.css"> <link rel="stylesheet" href="settings.css">
</head> </head>
<body> <body>
<header><img src="../assets/logo.png" alt=""><div><h1>TamaASR</h1><span>设置</span></div></header> <main id="settings-view" class="view settings-view">
<main> <header class="app-header">
<section> <div class="brand">
<h2>语音后端</h2> <img src="../assets/logo.png" alt="TamaASR">
<div class="row"><label for="backend">后端</label><select id="backend"></select><span id="backend-status" class="status"></span></div> <div><h1>TamaASR</h1><p>Linux 语音输入</p></div>
<div id="login-warning" class="warning" hidden>当前后端尚未登录。</div> </div>
<button id="open-debug">打开登录/调试页面</button> <span id="app-status" class="app-status"><i></i><span>正在连接</span></span>
</header>
<section class="card service-card" aria-labelledby="service-title">
<div class="section-heading">
<h2 id="service-title">语音服务</h2>
<p>选择用于语音识别的网页服务</p>
</div>
<div class="service-row">
<label for="backend">当前服务</label>
<div class="select-shell service-select"><select id="backend"></select></div>
<span id="backend-status" class="backend-status"><i></i><span></span></span>
</div>
<div id="login-warning" class="warning" hidden>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 2.5a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15Zm0 4v4m0 3h.01"/></svg>
<span>当前服务尚未登录,完成登录后才能开始语音输入。</span>
</div>
<div class="service-actions"><button id="open-debug" class="primary-button" type="button">打开登录与调试</button></div>
</section> </section>
<section> <section class="card preferences-card" aria-labelledby="preferences-title">
<h2>悬浮球</h2> <h2 id="preferences-title">输入与启动</h2>
<button id="open-position">调整悬浮球位置</button> <div class="preference-row shortcut-row">
<div class="preference-copy"><strong>按住说话</strong><span>按住快捷键开始识别,松开后提交文本</span></div>
<button id="shortcut" class="shortcut" type="button"></button>
</div>
<label class="preference-row toggle-row" for="auto-start">
<span class="preference-copy"><strong>开机自动启动</strong><span>登录桌面后自动运行 TamaASR</span></span>
<span class="toggle"><input id="auto-start" type="checkbox"><span aria-hidden="true"></span></span>
</label>
<label class="preference-row toggle-row" for="diagnostic-logging">
<span class="preference-copy"><strong>诊断日志</strong><span>仅在排查问题时记录运行信息</span></span>
<span class="toggle"><input id="diagnostic-logging" type="checkbox"><span aria-hidden="true"></span></span>
</label>
</section> </section>
<section> <button id="open-position" class="card position-link" type="button">
<h2>常规</h2> <span class="position-icon"><img src="../assets/logo.png" alt=""></span>
<div class="row"><span>按住说话快捷键</span><button id="shortcut" class="shortcut"></button></div> <span class="position-link-copy"><strong>悬浮球位置</strong><span id="position-summary">正在读取位置…</span></span>
<label class="switch"><input id="auto-start" type="checkbox"><span>开机自动启动</span></label> <svg class="chevron" viewBox="0 0 20 20" aria-hidden="true"><path d="m7.5 4.5 5 5.5-5 5.5"/></svg>
<label class="switch"><input id="diagnostic-logging" type="checkbox"><span>记录诊断日志</span></label> </button>
</section>
<section> <footer class="app-footer">
<h2>关于</h2> <span>版本 <span id="version"></span></span>
<div class="row"><span>版本</span><span id="version"></span></div> <button id="repository" class="repository-link" type="button"><span></span><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M7 5h8v8m0-8L5 15"/></svg></button>
<div class="row"><span>项目仓库</span><button id="repository" class="link"></button></div> </footer>
</section>
<div id="message" role="status"></div>
</main> </main>
<main id="position-view" style="display: none">
<div class="page-title"><button id="back-settings" title="返回设置">返回</button><div><h2>悬浮球位置</h2><span>位置调整</span></div></div> <main id="position-view" class="view position-view" hidden>
<section class="position-panel"> <header class="position-header">
<div class="row"><label for="display">屏幕</label><select id="display"></select></div> <button id="back-settings" class="icon-button" type="button" title="返回设置" aria-label="返回设置">
<div id="position-screen" class="position-screen" aria-label="屏幕位置预览"><div id="position-ball" class="position-ball"></div></div> <svg viewBox="0 0 20 20" aria-hidden="true"><path d="m12.5 4.5-5 5.5 5 5.5"/></svg>
<div class="position-controls"> </button>
<div class="coordinates"><label for="position-x">X <input id="position-x" type="number" step="1"></label><label for="position-y">Y <input id="position-y" type="number" step="1"></label></div> <div><h1>悬浮球位置</h1><p>选择它在桌面上的默认位置</p></div>
<div class="direction-pad" aria-label="方向键"> </header>
<button data-direction="up" title="上移">上</button>
<button data-direction="left" title="左移">左</button><button data-direction="center" title="回到屏幕中央">●</button><button data-direction="right" title="右移">右</button> <section class="card position-panel">
<button data-direction="down" title="下移">下</button> <div class="display-row">
<label for="display">显示器</label>
<div class="select-shell"><select id="display"></select></div>
</div>
<div id="position-screen" class="position-screen" aria-label="当前显示器的位置预览">
<div class="desktop-bar"><span>活动</span><span>TamaASR 桌面</span><span class="desktop-status">●&nbsp; ●&nbsp; ●</span></div>
<div class="desktop-window window-back"><i></i><i></i><i></i><span></span><span></span><span></span></div>
<div class="desktop-window window-front"><i></i><i></i><i></i><span></span><span></span><span></span></div>
<div id="position-ball" class="position-ball"><img src="../assets/logo.png" alt="悬浮球"></div>
</div>
<div class="position-tools">
<div class="preset-section">
<h2>快速位置</h2>
<div class="preset-grid" aria-label="九宫格位置预设">
<button data-preset="top-left" type="button" title="左上" aria-label="左上"></button>
<button data-preset="top-center" type="button" title="顶部居中" aria-label="顶部居中"></button>
<button data-preset="top-right" type="button" title="右上" aria-label="右上"></button>
<button data-preset="middle-left" type="button" title="左侧居中" aria-label="左侧居中"></button>
<button data-preset="middle-center" type="button" title="屏幕中央" aria-label="屏幕中央"></button>
<button data-preset="middle-right" type="button" title="右侧居中" aria-label="右侧居中"></button>
<button data-preset="bottom-left" type="button" title="左下" aria-label="左下"></button>
<button data-preset="bottom-center" type="button" title="底部居中" aria-label="底部居中"></button>
<button data-preset="bottom-right" type="button" title="右下" aria-label="右下"></button>
</div>
</div>
<div class="precise-section">
<h2>精确位置</h2>
<div class="precise-controls">
<div class="coordinates">
<label for="position-x"><span>X</span><input id="position-x" type="number" step="1"></label>
<label for="position-y"><span>Y</span><input id="position-y" type="number" step="1"></label>
</div>
<div class="direction-pad" aria-label="方向微调">
<button data-direction="up" type="button" title="上移" aria-label="上移"><svg viewBox="0 0 20 20"><path d="m5 12.5 5-5 5 5"/></svg></button>
<button data-direction="left" type="button" title="左移" aria-label="左移"><svg viewBox="0 0 20 20"><path d="m12.5 5-5 5 5 5"/></svg></button>
<button data-direction="center" type="button" title="回到屏幕中央" aria-label="回到屏幕中央"><svg viewBox="0 0 20 20"><circle cx="10" cy="10" r="5.5"/><circle cx="10" cy="10" r="1.5"/><path d="M10 2v2.5M10 15.5V18M2 10h2.5M15.5 10H18"/></svg></button>
<button data-direction="right" type="button" title="右移" aria-label="右移"><svg viewBox="0 0 20 20"><path d="m7.5 5 5 5-5 5"/></svg></button>
<button data-direction="down" type="button" title="下移" aria-label="下移"><svg viewBox="0 0 20 20"><path d="m5 7.5 5 5 5-5"/></svg></button>
</div>
</div>
<p class="hint">方向键移动 1 像素,按住 Shift 移动 10 像素</p>
</div> </div>
</div> </div>
<div class="preset-grid" aria-label="九宫格位置预设">
<button data-preset="top-left" title="左上"></button><button data-preset="top-center" title="顶部居中"></button><button data-preset="top-right" title="右上"></button>
<button data-preset="middle-left" title="左侧居中"></button><button data-preset="middle-center" title="屏幕中央"></button><button data-preset="middle-right" title="右侧居中"></button>
<button data-preset="bottom-left" title="左下"></button><button data-preset="bottom-center" title="底部居中"></button><button data-preset="bottom-right" title="右下"></button>
</div>
<div class="hint">方向键 1 像素,Shift + 方向键 10 像素</div>
</section> </section>
</main> </main>
<div id="message" class="message" role="status" aria-live="polite"></div>
<dialog id="login-notice"> <dialog id="login-notice">
<div class="modal-content"> <div class="modal-content">
<h2 id="login-notice-title">登录后端</h2> <div class="modal-icon" aria-hidden="true"><img src="../assets/logo.png" alt=""></div>
<h2 id="login-notice-title">登录语音服务</h2>
<p>请先登录对应的网页平台。登录完成后即可关闭调试窗口。</p> <p>请先登录对应的网页平台。登录完成后即可关闭调试窗口。</p>
<div class="modal-actions"> <div class="modal-actions">
<button id="login-notice-cancel" type="button">取消</button> <button id="login-notice-cancel" type="button" class="secondary-button">取消</button>
<button id="login-notice-continue" type="button" class="primary">打开登录页面</button> <button id="login-notice-continue" type="button" class="primary-button">打开登录页面</button>
</div> </div>
</div> </div>
</dialog> </dialog>
+148 -37
View File
@@ -17,8 +17,23 @@ interface SettingsViewState {
repositoryUrl: string | null; repositoryUrl: string | null;
} }
const OVERLAY_SIZE = 88;
const PRESET_MARGIN = 32;
const PRESET_LABELS: Record<string, string> = {
'top-left': '左上角',
'top-center': '顶部居中',
'top-right': '右上角',
'middle-left': '左侧居中',
'middle-center': '屏幕中央',
'middle-right': '右侧居中',
'bottom-left': '左下角',
'bottom-center': '底部居中',
'bottom-right': '右下角'
};
const backend = document.querySelector<HTMLSelectElement>('#backend')!; const backend = document.querySelector<HTMLSelectElement>('#backend')!;
const backendStatus = document.querySelector<HTMLElement>('#backend-status')!; const backendStatus = document.querySelector<HTMLElement>('#backend-status')!;
const appStatus = document.querySelector<HTMLElement>('#app-status')!;
const loginWarning = document.querySelector<HTMLElement>('#login-warning')!; const loginWarning = document.querySelector<HTMLElement>('#login-warning')!;
const display = document.querySelector<HTMLSelectElement>('#display')!; const display = document.querySelector<HTMLSelectElement>('#display')!;
const x = document.querySelector<HTMLInputElement>('#position-x')!; const x = document.querySelector<HTMLInputElement>('#position-x')!;
@@ -28,21 +43,44 @@ const autoStart = document.querySelector<HTMLInputElement>('#auto-start')!;
const diagnosticLogging = document.querySelector<HTMLInputElement>('#diagnostic-logging')!; const diagnosticLogging = document.querySelector<HTMLInputElement>('#diagnostic-logging')!;
const version = document.querySelector<HTMLElement>('#version')!; const version = document.querySelector<HTMLElement>('#version')!;
const repository = document.querySelector<HTMLButtonElement>('#repository')!; const repository = document.querySelector<HTMLButtonElement>('#repository')!;
const repositoryLabel = repository.querySelector<HTMLElement>('span')!;
const message = document.querySelector<HTMLElement>('#message')!; const message = document.querySelector<HTMLElement>('#message')!;
const settingsView = document.querySelector<HTMLElement>('body > main')!; const settingsView = document.querySelector<HTMLElement>('#settings-view')!;
const positionView = document.querySelector<HTMLElement>('#position-view')!; const positionView = document.querySelector<HTMLElement>('#position-view')!;
const positionScreen = document.querySelector<HTMLElement>('#position-screen')!; const positionScreen = document.querySelector<HTMLElement>('#position-screen')!;
const positionBall = document.querySelector<HTMLElement>('#position-ball')!; const positionBall = document.querySelector<HTMLElement>('#position-ball')!;
const positionSummary = document.querySelector<HTMLElement>('#position-summary')!;
const loginNotice = document.querySelector<HTMLDialogElement>('#login-notice')!; const loginNotice = document.querySelector<HTMLDialogElement>('#login-notice')!;
const loginNoticeTitle = document.querySelector<HTMLElement>('#login-notice-title')!; const loginNoticeTitle = document.querySelector<HTMLElement>('#login-notice-title')!;
const presetButtons = [...document.querySelectorAll<HTMLButtonElement>('[data-preset]')];
let state: SettingsViewState; let state: SettingsViewState;
let capturingShortcut = false; let capturingShortcut = false;
let positionMode = false; let positionMode = false;
let messageTimer: ReturnType<typeof setTimeout> | null = null;
function statusText(status: BackendRuntimeStatus): string { function statusText(status: BackendRuntimeStatus): string {
if (status.ready) return '已就绪'; if (status.ready || status.login === 'logged-in') return '已登录';
if (status.login === 'logged-out') return '需要登录'; if (status.login === 'logged-out') return '需要登录';
return status.detail || '正在加载'; return '加载中';
}
function renderServiceStatus(status: BackendRuntimeStatus): void {
const backendStatusText = backendStatus.querySelector<HTMLElement>('span')!;
backendStatusText.textContent = statusText(status);
backendStatus.dataset.state = status.ready ? 'ready'
: status.login === 'logged-out' ? 'warning' : 'loading';
const appStatusText = appStatus.querySelector<HTMLElement>('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 { 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.replaceChildren(...next.backends.map((item) => new Option(item.label, item.id)));
backend.value = next.backendId; backend.value = next.backendId;
const active = next.backends.find((item) => item.id === next.backendId)!; const active = next.backends.find((item) => item.id === next.backendId)!;
backendStatus.textContent = statusText(active.status); renderServiceStatus(active.status);
backendStatus.dataset.state = active.status.ready ? 'ready'
: active.status.login === 'logged-out' ? 'warning' : 'loading';
loginWarning.hidden = active.status.login !== 'logged-out'; loginWarning.hidden = active.status.login !== 'logged-out';
const previousDisplay = display.value; const previousDisplay = display.value;
display.replaceChildren(...next.displays.map((item) => new Option(item.label, item.id))); display.replaceChildren(...next.displays.map((item) => new Option(item.label, item.id)));
const containing = next.displays.find((item) => { const containing = next.displays.find((item) => containsPosition(item.workArea, next.overlayPosition));
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;
});
display.value = next.displays.some((item) => item.id === previousDisplay) display.value = next.displays.some((item) => item.id === previousDisplay)
? previousDisplay : (containing?.id ?? next.displays[0]?.id ?? ''); ? previousDisplay : (containing?.id ?? next.displays[0]?.id ?? '');
x.value = String(next.overlayPosition.x); x.value = String(next.overlayPosition.x);
y.value = String(next.overlayPosition.y); y.value = String(next.overlayPosition.y);
renderPositionPreview(next); renderPositionPreview(next);
renderPositionSummary(next, containing);
shortcut.textContent = capturingShortcut ? '请按下新快捷键…' : displayShortcut(next.shortcut); shortcut.textContent = capturingShortcut ? '请按下新快捷键…' : displayShortcut(next.shortcut);
shortcut.dataset.capturing = String(capturingShortcut);
shortcut.setAttribute('aria-pressed', String(capturingShortcut));
autoStart.checked = next.autoStart; autoStart.checked = next.autoStart;
diagnosticLogging.checked = next.diagnosticLogging; diagnosticLogging.checked = next.diagnosticLogging;
version.textContent = next.version; version.textContent = next.version;
repository.textContent = next.repositoryUrl ?? '尚未配置'; repositoryLabel.textContent = next.repositoryUrl ? '项目仓库' : '仓库未配置';
repository.disabled = !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 { function renderPositionPreview(next: SettingsViewState): void {
const area = next.displays.find((item) => item.id === display.value)?.workArea ?? next.displays[0]?.workArea; const displayInfo = selectedDisplay(next);
if (!area) return; if (!displayInfo) return;
const px = Math.max(0, Math.min(area.width, next.overlayPosition.x - area.x)); const area = displayInfo.workArea;
const py = Math.max(0, Math.min(area.height, next.overlayPosition.y - area.y)); const centerX = next.overlayPosition.x - area.x + OVERLAY_SIZE / 2;
positionBall.style.left = `${(px / area.width) * 100}%`; const centerY = next.overlayPosition.y - area.y + OVERLAY_SIZE / 2;
positionBall.style.top = `${(py / area.height) * 100}%`; 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 { 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 { function formatShortcut(event: KeyboardEvent): string | null {
@@ -103,32 +194,47 @@ function formatShortcut(event: KeyboardEvent): string | null {
return [...modifiers, key].join('+'); return [...modifiers, key].join('+');
} }
async function apply(action: () => Promise<SettingsViewState>, confirmation = '已保存'): Promise<void> { 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<SettingsViewState>, confirmation = '设置已保存'): Promise<void> {
try { try {
render(await action()); render(await action());
message.textContent = confirmation; showMessage(confirmation);
} catch (error) { } catch (error) {
message.textContent = (error as Error).message; showMessage((error as Error).message, 'error');
} }
} }
async function applyPosition(): Promise<void> { async function applyPosition(): Promise<void> {
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-debug')!.addEventListener('click', () => void window.meloSettings.openDebug());
document.querySelector('#open-position')!.addEventListener('click', () => { document.querySelector('#open-position')!.addEventListener('click', () => {
positionMode = true; positionMode = true;
settingsView.style.display = 'none'; settingsView.hidden = true;
positionView.style.display = 'grid'; positionView.hidden = false;
void window.meloSettings.beginPositioning(); void window.meloSettings.beginPositioning();
renderPositionPreview(state); renderPositionPreview(state);
}); });
document.querySelector('#back-settings')!.addEventListener('click', () => { document.querySelector('#back-settings')!.addEventListener('click', () => {
positionMode = false; positionMode = false;
positionView.style.display = 'none'; positionView.hidden = true;
settingsView.style.display = 'grid'; settingsView.hidden = false;
void window.meloSettings.endPositioning(); void window.meloSettings.endPositioning();
}); });
document.querySelector('#login-notice-cancel')!.addEventListener('click', () => loginNotice.close()); document.querySelector('#login-notice-cancel')!.addEventListener('click', () => loginNotice.close());
@@ -136,7 +242,7 @@ document.querySelector('#login-notice-continue')!.addEventListener('click', () =
loginNotice.close(); loginNotice.close();
void window.meloSettings.openDebug(true); void window.meloSettings.openDebug(true);
}); });
document.querySelectorAll<HTMLButtonElement>('[data-preset]').forEach((button) => { presetButtons.forEach((button) => {
button.addEventListener('click', () => void apply( button.addEventListener('click', () => void apply(
() => window.meloSettings.setPreset(button.dataset.preset!, display.value), '位置已保存' () => window.meloSettings.setPreset(button.dataset.preset!, display.value), '位置已保存'
)); ));
@@ -147,12 +253,11 @@ async function nudge(direction: string): Promise<void> {
await apply(() => window.meloSettings.setPreset('middle-center', display.value), '位置已保存'); await apply(() => window.meloSettings.setPreset('middle-center', display.value), '位置已保存');
return; return;
} }
const step = 1;
const next = { x: Number(x.value), y: Number(y.value) }; const next = { x: Number(x.value), y: Number(y.value) };
if (direction === 'left') next.x -= step; if (direction === 'left') next.x -= 1;
if (direction === 'right') next.x += step; if (direction === 'right') next.x += 1;
if (direction === 'up') next.y -= step; if (direction === 'up') next.y -= 1;
if (direction === 'down') next.y += step; if (direction === 'down') next.y += 1;
x.value = String(next.x); x.value = String(next.x);
y.value = String(next.y); y.value = String(next.y);
await applyPosition(); await applyPosition();
@@ -191,11 +296,17 @@ document.addEventListener('keydown', (event) => {
shortcut.addEventListener('click', () => { shortcut.addEventListener('click', () => {
capturingShortcut = true; capturingShortcut = true;
shortcut.textContent = '请按下新快捷键…'; shortcut.textContent = '请按下新快捷键…';
shortcut.dataset.capturing = 'true';
shortcut.setAttribute('aria-pressed', 'true');
shortcut.focus(); 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( diagnosticLogging.addEventListener('change', () => void apply(
() => window.meloSettings.setDiagnosticLogging(diagnosticLogging.checked) () => window.meloSettings.setDiagnosticLogging(diagnosticLogging.checked),
diagnosticLogging.checked ? '已开启诊断日志' : '已关闭诊断日志'
)); ));
repository.addEventListener('click', () => void window.meloSettings.openRepository()); repository.addEventListener('click', () => void window.meloSettings.openRepository());
window.meloSettings.onState(render); window.meloSettings.onState(render);
@@ -206,4 +317,4 @@ window.meloSettings.onLoginNotice((backendLabel) => {
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
if (positionMode) void window.meloSettings.endPositioning(); 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'));