mirror of
https://github.com/Melorise/TamaASR.git
synced 2026-11-07 14:39:32 +00:00
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:
@@ -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
|
||||
|
||||
### 变更
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+3
-3
@@ -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_<arch>.deb`。依赖名称按 Debian/Ubuntu 系列填写,但尚未在各发行版完成安装验证。
|
||||
产物位于 `packaging/out/tama-asr_1.0.1_<arch>.deb`。依赖名称按 Debian/Ubuntu 系列填写,但尚未在各发行版完成安装验证。
|
||||
|
||||
## rpm
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<binary>tama-asr</binary>
|
||||
</provides>
|
||||
<releases>
|
||||
<release version="1.0.1" 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.16" date="2026-08-26"/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+240
-49
@@ -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; }
|
||||
}
|
||||
|
||||
+108
-42
@@ -7,62 +7,128 @@
|
||||
<link rel="stylesheet" href="settings.css">
|
||||
</head>
|
||||
<body>
|
||||
<header><img src="../assets/logo.png" alt=""><div><h1>TamaASR</h1><span>设置</span></div></header>
|
||||
<main>
|
||||
<section>
|
||||
<h2>语音后端</h2>
|
||||
<div class="row"><label for="backend">后端</label><select id="backend"></select><span id="backend-status" class="status"></span></div>
|
||||
<div id="login-warning" class="warning" hidden>当前后端尚未登录。</div>
|
||||
<button id="open-debug">打开登录/调试页面</button>
|
||||
<main id="settings-view" class="view settings-view">
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<img src="../assets/logo.png" alt="TamaASR">
|
||||
<div><h1>TamaASR</h1><p>Linux 语音输入</p></div>
|
||||
</div>
|
||||
<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>
|
||||
<h2>悬浮球</h2>
|
||||
<button id="open-position">调整悬浮球位置</button>
|
||||
<section class="card preferences-card" aria-labelledby="preferences-title">
|
||||
<h2 id="preferences-title">输入与启动</h2>
|
||||
<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>
|
||||
<h2>常规</h2>
|
||||
<div class="row"><span>按住说话快捷键</span><button id="shortcut" class="shortcut"></button></div>
|
||||
<label class="switch"><input id="auto-start" type="checkbox"><span>开机自动启动</span></label>
|
||||
<label class="switch"><input id="diagnostic-logging" type="checkbox"><span>记录诊断日志</span></label>
|
||||
</section>
|
||||
<button id="open-position" class="card position-link" type="button">
|
||||
<span class="position-icon"><img src="../assets/logo.png" alt=""></span>
|
||||
<span class="position-link-copy"><strong>悬浮球位置</strong><span id="position-summary">正在读取位置…</span></span>
|
||||
<svg class="chevron" viewBox="0 0 20 20" aria-hidden="true"><path d="m7.5 4.5 5 5.5-5 5.5"/></svg>
|
||||
</button>
|
||||
|
||||
<section>
|
||||
<h2>关于</h2>
|
||||
<div class="row"><span>版本</span><span id="version"></span></div>
|
||||
<div class="row"><span>项目仓库</span><button id="repository" class="link"></button></div>
|
||||
</section>
|
||||
<div id="message" role="status"></div>
|
||||
<footer class="app-footer">
|
||||
<span>版本 <span id="version"></span></span>
|
||||
<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>
|
||||
</footer>
|
||||
</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>
|
||||
<section class="position-panel">
|
||||
<div class="row"><label for="display">屏幕</label><select id="display"></select></div>
|
||||
<div id="position-screen" class="position-screen" aria-label="屏幕位置预览"><div id="position-ball" class="position-ball"></div></div>
|
||||
<div class="position-controls">
|
||||
<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 class="direction-pad" aria-label="方向键">
|
||||
<button data-direction="up" title="上移">上</button>
|
||||
<button data-direction="left" title="左移">左</button><button data-direction="center" title="回到屏幕中央">●</button><button data-direction="right" title="右移">右</button>
|
||||
<button data-direction="down" title="下移">下</button>
|
||||
|
||||
<main id="position-view" class="view position-view" hidden>
|
||||
<header class="position-header">
|
||||
<button id="back-settings" class="icon-button" type="button" title="返回设置" aria-label="返回设置">
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m12.5 4.5-5 5.5 5 5.5"/></svg>
|
||||
</button>
|
||||
<div><h1>悬浮球位置</h1><p>选择它在桌面上的默认位置</p></div>
|
||||
</header>
|
||||
|
||||
<section class="card position-panel">
|
||||
<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">● ● ●</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 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>
|
||||
</main>
|
||||
|
||||
<div id="message" class="message" role="status" aria-live="polite"></div>
|
||||
|
||||
<dialog id="login-notice">
|
||||
<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>
|
||||
<div class="modal-actions">
|
||||
<button id="login-notice-cancel" type="button">取消</button>
|
||||
<button id="login-notice-continue" type="button" class="primary">打开登录页面</button>
|
||||
<button id="login-notice-cancel" type="button" class="secondary-button">取消</button>
|
||||
<button id="login-notice-continue" type="button" class="primary-button">打开登录页面</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
+148
-37
@@ -17,8 +17,23 @@ interface SettingsViewState {
|
||||
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 backendStatus = document.querySelector<HTMLElement>('#backend-status')!;
|
||||
const appStatus = document.querySelector<HTMLElement>('#app-status')!;
|
||||
const loginWarning = document.querySelector<HTMLElement>('#login-warning')!;
|
||||
const display = document.querySelector<HTMLSelectElement>('#display')!;
|
||||
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 version = document.querySelector<HTMLElement>('#version')!;
|
||||
const repository = document.querySelector<HTMLButtonElement>('#repository')!;
|
||||
const repositoryLabel = repository.querySelector<HTMLElement>('span')!;
|
||||
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 positionScreen = document.querySelector<HTMLElement>('#position-screen')!;
|
||||
const positionBall = document.querySelector<HTMLElement>('#position-ball')!;
|
||||
const positionSummary = document.querySelector<HTMLElement>('#position-summary')!;
|
||||
const loginNotice = document.querySelector<HTMLDialogElement>('#login-notice')!;
|
||||
const loginNoticeTitle = document.querySelector<HTMLElement>('#login-notice-title')!;
|
||||
const presetButtons = [...document.querySelectorAll<HTMLButtonElement>('[data-preset]')];
|
||||
let state: SettingsViewState;
|
||||
let capturingShortcut = false;
|
||||
let positionMode = false;
|
||||
let messageTimer: ReturnType<typeof setTimeout> | 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<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 {
|
||||
@@ -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<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 {
|
||||
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<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-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<HTMLButtonElement>('[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<void> {
|
||||
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'));
|
||||
|
||||
Reference in New Issue
Block a user