feat: 完成托盘设置页和悬浮球交互

实现隐藏启动、就绪状态托盘、登录提醒、后端切换、快捷键与自启动设置、九宫格和坐标微调,以及常驻悬浮球点击录音。

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-08-23 02:34:38 +08:00
co-authored by Codex
parent 6ff1d3bf77
commit 5a3aa9cfcb
16 changed files with 827 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
import { app } from 'electron';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
function desktopEscape(value: string): string {
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('`', '\\`').replaceAll('$', '\\$')}"`;
}
export class AutoStartService {
private readonly filePath = path.join(os.homedir(), '.config', 'autostart', 'meloasr.desktop');
apply(enabled: boolean): void {
if (!enabled) {
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
fs.writeFileSync(this.filePath, [
'[Desktop Entry]',
'Type=Application',
'Name=MeloASR',
'Hidden=true',
''
].join('\n'), { mode: 0o644 });
return;
}
const args = app.isPackaged ? [] : [app.getAppPath()];
const command = [process.execPath, ...args].map(desktopEscape).join(' ');
const contents = [
'[Desktop Entry]',
'Type=Application',
'Name=MeloASR',
`Exec=${command}`,
'Terminal=false',
'X-GNOME-Autostart-enabled=true',
'Comment=MeloASR',
''
].join('\n');
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
fs.writeFileSync(this.filePath, contents, { mode: 0o644 });
}
}
+177
View File
@@ -0,0 +1,177 @@
import { app, ipcMain } from 'electron';
import { AutoStartService } from './auto-start-service';
import { BackendManager } from './backend-manager';
import { FcitxBridge } from './fcitx-bridge';
import { OverlayController } from './overlay-controller';
import { SessionController } from './session-controller';
import { SettingsStore } from './settings-store';
import { SettingsWindow } from './settings-window';
import { TrayController } from './tray-controller';
import type { BackendStatusPayload, Point } from '../shared/contracts';
app.setName('MeloASR');
const singleInstance = app.requestSingleInstanceLock();
if (!singleInstance) {
app.quit();
} else {
void app.whenReady().then(bootstrap).catch((error) => {
console.error('[MeloASR 启动失败]', error);
app.quit();
});
}
async function bootstrap(): Promise<void> {
const settings = new SettingsStore();
const currentSettings = settings.load();
const autoStart = new AutoStartService();
try { autoStart.apply(currentSettings.autoStart); }
catch (error) { console.error('[开机自启动设置失败]', error); }
const bridge = new FcitxBridge();
bridge.configure(currentSettings.shortcut);
const backends = new BackendManager(settings);
const overlay = new OverlayController(settings);
await overlay.create();
const sessions = new SessionController(backends, bridge, overlay);
const repositoryUrl = process.env.MELOASR_REPOSITORY_URL?.trim() || null;
const settingsWindow = new SettingsWindow(settings, backends, overlay, sessions, repositoryUrl);
const settingsWindowReady = settingsWindow.create();
registerIpc({ settings, autoStart, bridge, backends, overlay, sessions, settingsWindow });
await settingsWindowReady;
const tray = new TrayController(
backends,
settingsWindow,
() => { app.relaunch(); app.exit(0); },
() => app.quit()
);
tray.create();
bridge.on('request-start', () => void sessions.start());
bridge.on('request-stop', () => sessions.stop());
bridge.on('request-cancel', () => sessions.cancel());
bridge.on('disconnect', () => sessions.cancel('Fcitx5 插件连接已断开'));
bridge.on('connect', () => settingsWindow.publish());
bridge.on('protocol-error', (error) => console.error('[Fcitx5 协议错误]', error));
bridge.on('client-error', (error) => console.error('[Fcitx5 客户端错误]', error));
bridge.on('server-error', (error) => console.error('[Fcitx5 Socket 错误]', error));
backends.on('status-changed', () => { tray.refresh(); settingsWindow.publish(); });
backends.on('active-changed', () => { tray.refresh(); settingsWindow.publish(); });
sessions.on('state-changed', () => settingsWindow.publish());
settings.on('changed', () => settingsWindow.publish());
await bridge.start();
bridge.configure(settings.get().shortcut);
await backends.ensure(settings.get().backendId);
tray.refresh();
app.on('second-instance', () => settingsWindow.show());
app.on('before-quit', () => { (app as typeof app & { isQuitting?: boolean }).isQuitting = true; });
app.on('will-quit', () => {
tray.destroy();
void bridge.stop();
});
}
interface Services {
settings: SettingsStore;
autoStart: AutoStartService;
bridge: FcitxBridge;
backends: BackendManager;
overlay: OverlayController;
sessions: SessionController;
settingsWindow: SettingsWindow;
}
function registerIpc(services: Services): void {
const { settings, autoStart, bridge, backends, overlay, sessions, settingsWindow } = services;
const settingsSender = (senderId: number): boolean =>
Boolean(settingsWindow.window && !settingsWindow.window.isDestroyed() && settingsWindow.window.webContents.id === senderId);
const overlaySender = (senderId: number): boolean =>
Boolean(overlay.window && !overlay.window.isDestroyed() && overlay.window.webContents.id === senderId);
ipcMain.handle('settings:get-state', (event) => {
if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的设置请求');
return settingsWindow.state();
});
ipcMain.handle('settings:set-backend', async (event, id: unknown) => {
if (!settingsSender(event.sender.id) || typeof id !== 'string') throw new Error('无效的后端设置');
if (sessions.state !== 'idle') throw new Error('请先结束当前语音输入');
await backends.select(id);
return settingsWindow.state();
});
ipcMain.handle('settings:open-debug', async (event) => {
if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的窗口请求');
await backends.showDebug();
});
ipcMain.handle('settings:set-position', (event, point: Point, displayId: string) => {
if (!settingsSender(event.sender.id) || !isPoint(point)) throw new Error('无效的悬浮球坐标');
overlay.applyPosition(point, displayId);
return settingsWindow.state();
});
ipcMain.handle('settings:set-preset', (event, preset: unknown, displayId: string) => {
if (!settingsSender(event.sender.id) || typeof preset !== 'string') throw new Error('无效的位置预设');
overlay.applyPreset(preset, displayId);
return settingsWindow.state();
});
ipcMain.handle('settings:preview-overlay', (event) => {
if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的预览请求');
overlay.preview();
});
ipcMain.handle('settings:set-shortcut', (event, shortcut: unknown) => {
if (!settingsSender(event.sender.id) || typeof shortcut !== 'string' || !validShortcut(shortcut)) {
throw new Error('快捷键必须包含修饰键和一个主键');
}
settings.update({ shortcut });
bridge.configure(shortcut);
return settingsWindow.state();
});
ipcMain.handle('settings:set-auto-start', (event, enabled: unknown) => {
if (!settingsSender(event.sender.id) || typeof enabled !== 'boolean') throw new Error('无效的自启动设置');
autoStart.apply(enabled);
settings.update({ autoStart: enabled });
return settingsWindow.state();
});
ipcMain.handle('settings:set-overlay-persistent', (event, enabled: unknown) => {
if (!settingsSender(event.sender.id) || typeof enabled !== 'boolean') throw new Error('无效的悬浮球设置');
settings.update({ overlayPersistent: enabled });
overlay.reconcilePersistent();
return settingsWindow.state();
});
ipcMain.handle('settings:open-repository', async (event) => {
if (!settingsSender(event.sender.id)) throw new Error('拒绝未授权的外部链接请求');
await settingsWindow.openRepository();
});
ipcMain.on('overlay:toggle-recording', (event) => {
if (overlaySender(event.sender.id)) sessions.requestStartFromOverlay();
});
ipcMain.on('backend-status', (event, payload: BackendStatusPayload) => backends.handleStatus(event, payload));
ipcMain.on('backend-transcript', (event, payload: { backend?: string; text?: string }) => {
if (typeof payload?.backend === 'string' && typeof payload.text === 'string' && backends.senderIsActive(event, payload.backend)) {
sessions.update(payload.text);
}
});
ipcMain.on('backend-session-ended', (event, payload: { backend?: string; text?: string }) => {
if (typeof payload?.backend === 'string' && typeof payload.text === 'string' && backends.senderIsActive(event, payload.backend)) {
sessions.finish(payload.text);
}
});
ipcMain.on('backend-start-error', (event, payload: { backend?: string; message?: string }) => {
if (typeof payload?.backend === 'string' && backends.senderIsActive(event, payload.backend)) {
sessions.backendError(payload.message || '后端启动失败');
}
});
}
function isPoint(value: unknown): value is Point {
return typeof value === 'object' && value !== null &&
Number.isFinite((value as Point).x) && Number.isFinite((value as Point).y);
}
function validShortcut(value: string): boolean {
const parts = value.split('+').filter(Boolean);
return parts.length >= 2 && parts.some((part) => ['Control', 'Alt', 'Shift', 'Super'].includes(part)) &&
!['Control', 'Alt', 'Shift', 'Super'].includes(parts.at(-1)!);
}
app.on('window-all-closed', () => { /* 托盘进程继续常驻。 */ });
+97
View File
@@ -0,0 +1,97 @@
import { BrowserWindow, screen } from 'electron';
import path from 'node:path';
import type { Point, SessionState } from '../shared/contracts';
import { clampPosition, presetPosition } from './overlay-position';
import type { SettingsStore } from './settings-store';
const SIZE = { width: 88, height: 88 };
export class OverlayController {
window: BrowserWindow | null = null;
private hideTimer: NodeJS.Timeout | null = null;
constructor(private readonly settings: SettingsStore) {}
async create(): Promise<void> {
this.window = new BrowserWindow({
...SIZE,
show: false,
frame: false,
transparent: true,
resizable: false,
movable: false,
alwaysOnTop: true,
focusable: false,
skipTaskbar: true,
hasShadow: false,
webPreferences: {
preload: path.join(__dirname, '..', 'preload', 'overlay.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
backgroundThrottling: false
}
});
this.window.setAlwaysOnTop(true, 'floating');
await this.window.loadFile(path.join(__dirname, '..', 'renderer', 'overlay.html'));
this.position();
this.reconcilePersistent();
}
position(point = this.settings.get().overlayPosition): Point {
const initial = point ?? presetPosition(screen.getPrimaryDisplay().workArea, SIZE, 'bottom-center');
const display = screen.getDisplayNearestPoint(initial);
const result = clampPosition(initial, display.workArea, SIZE);
this.window?.setPosition(result.x, result.y, false);
if (!point) this.settings.update({ overlayPosition: result });
return result;
}
applyPosition(point: Point, displayId?: string): Point {
const display = screen.getAllDisplays().find((item) => String(item.id) === String(displayId)) ??
screen.getDisplayNearestPoint(point);
const result = clampPosition(point, display.workArea, SIZE);
this.settings.update({ overlayPosition: result });
this.window?.setPosition(result.x, result.y, false);
return result;
}
applyPreset(preset: string, displayId?: string): Point {
const display = screen.getAllDisplays().find((item) => String(item.id) === String(displayId)) ??
screen.getPrimaryDisplay();
return this.applyPosition(presetPosition(display.workArea, SIZE, preset), String(display.id));
}
showState(state: SessionState | 'done' | 'error'): void {
if (!this.window || this.window.isDestroyed()) return;
this.clearHideTimer();
this.window.webContents.send('overlay-state', { state });
if (!this.window.isVisible()) this.window.showInactive();
}
hideLater(delay = 900): void {
this.clearHideTimer();
if (this.settings.get().overlayPersistent) return;
this.hideTimer = setTimeout(() => this.window?.hide(), delay);
}
preview(): void {
this.position();
this.showState('recording');
this.hideLater(1800);
}
reconcilePersistent(): void {
if (!this.window || this.window.isDestroyed()) return;
if (this.settings.get().overlayPersistent) {
this.showState('idle');
} else {
this.window.hide();
}
}
private clearHideTimer(): void {
if (this.hideTimer) clearTimeout(this.hideTimer);
this.hideTimer = null;
}
}
+29
View File
@@ -0,0 +1,29 @@
import type { Rectangle } from 'electron';
import type { Point } from '../shared/contracts';
export const PRESETS = new Set([
'top-left', 'top-center', 'top-right',
'middle-left', 'middle-center', 'middle-right',
'bottom-left', 'bottom-center', 'bottom-right'
]);
export function presetPosition(workArea: Rectangle, size: { width: number; height: number }, preset: string, margin = 32): Point {
if (!PRESETS.has(preset)) throw new Error('未知的悬浮球位置预设');
const [vertical, horizontal] = preset.split('-');
const x = horizontal === 'left' ? workArea.x + margin
: horizontal === 'right' ? workArea.x + workArea.width - size.width - margin
: workArea.x + (workArea.width - size.width) / 2;
const y = vertical === 'top' ? workArea.y + margin
: vertical === 'bottom' ? workArea.y + workArea.height - size.height - margin
: workArea.y + (workArea.height - size.height) / 2;
return { x: Math.round(x), y: Math.round(y) };
}
export function clampPosition(position: Point, workArea: Rectangle, size: { width: number; height: number }): Point {
const maxX = workArea.x + Math.max(0, workArea.width - size.width);
const maxY = workArea.y + Math.max(0, workArea.height - size.height);
return {
x: Math.round(Math.min(Math.max(position.x, workArea.x), maxX)),
y: Math.round(Math.min(Math.max(position.y, workArea.y), maxY))
};
}
+81
View File
@@ -0,0 +1,81 @@
import { app, BrowserWindow, screen, shell } from 'electron';
import path from 'node:path';
import type { SettingsViewState } from '../shared/contracts';
import type { BackendManager } from './backend-manager';
import type { OverlayController } from './overlay-controller';
import type { SessionController } from './session-controller';
import type { SettingsStore } from './settings-store';
export class SettingsWindow {
window: BrowserWindow | null = null;
constructor(
private readonly settings: SettingsStore,
private readonly backends: BackendManager,
private readonly overlay: OverlayController,
private readonly sessions: SessionController,
private readonly repositoryUrl: string | null
) {}
async create(): Promise<void> {
this.window = new BrowserWindow({
width: 720,
height: 820,
minWidth: 640,
minHeight: 680,
show: false,
title: 'MeloASR 设置',
autoHideMenuBar: true,
icon: path.join(app.getAppPath(), 'dist', 'assets', 'logo.png'),
webPreferences: {
preload: path.join(__dirname, '..', 'preload', 'settings.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: true
}
});
this.window.on('close', (event) => {
if ((app as typeof app & { isQuitting?: boolean }).isQuitting) return;
event.preventDefault();
this.window?.hide();
});
await this.window.loadFile(path.join(__dirname, '..', 'renderer', 'settings.html'));
}
show(): void {
this.window?.show();
this.window?.focus();
this.publish();
}
publish(): void {
if (!this.window || this.window.isDestroyed()) return;
this.window.webContents.send('settings:state', this.state());
}
state(): SettingsViewState {
const current = this.settings.get();
const position = this.overlay.position(current.overlayPosition);
return {
backendId: current.backendId,
backends: this.backends.definitions().map(({ id, label }) => ({ id, label, status: this.backends.status(id) })),
sessionState: this.sessions.state,
shortcut: current.shortcut,
autoStart: current.autoStart,
overlayPersistent: current.overlayPersistent,
overlayPosition: position,
displays: screen.getAllDisplays().map((display, index) => ({
id: String(display.id),
label: `屏幕 ${index + 1}(${display.workArea.width} × ${display.workArea.height})`,
workArea: display.workArea
})),
version: app.getVersion(),
repositoryUrl: this.repositoryUrl
};
}
async openRepository(): Promise<void> {
if (!this.repositoryUrl) throw new Error('尚未配置项目仓库地址');
await shell.openExternal(this.repositoryUrl);
}
}
+61
View File
@@ -0,0 +1,61 @@
import { Menu, Tray, nativeImage } from 'electron';
import type { BackendManager } from './backend-manager';
import type { SettingsWindow } from './settings-window';
type TrayState = 'loading' | 'ready' | 'login-required' | 'error';
const COLORS: Record<TrayState, string> = {
loading: '#e6a23c',
ready: '#22b573',
'login-required': '#ef6c57',
error: '#d9465f'
};
function trayImage(state: TrayState): Electron.NativeImage {
const color = COLORS[state];
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="14" fill="${color}"/>
<path d="M8 19v-6h3v6zm5 4V9h3v14zm5-2V11h3v10zm5-3v-4h3v4z" fill="white"/>
</svg>`;
return nativeImage.createFromDataURL(`data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`).resize({ width: 22, height: 22 });
}
export class TrayController {
private tray: Tray | null = null;
constructor(
private readonly backends: BackendManager,
private readonly settingsWindow: SettingsWindow,
private readonly onRestart: () => void,
private readonly onQuit: () => void
) {}
create(): void {
this.tray = new Tray(trayImage('loading'));
this.tray.setToolTip('MeloASR · 正在加载');
this.tray.on('click', () => this.settingsWindow.show());
this.refresh();
}
refresh(): void {
if (!this.tray) return;
const status = this.backends.status(this.backends.activeDefinition().id);
const state: TrayState = status.ready ? 'ready'
: status.login === 'logged-out' ? 'login-required'
: status.detail.startsWith('加载失败') ? 'error' : 'loading';
this.tray.setImage(trayImage(state));
this.tray.setToolTip(`MeloASR · ${status.detail}`);
this.tray.setContextMenu(Menu.buildFromTemplate([
{ label: '设置', click: () => this.settingsWindow.show() },
{ label: '打开登录/调试页面', click: () => void this.backends.showDebug() },
{ type: 'separator' },
{ label: '重启', click: this.onRestart },
{ label: '退出', click: this.onQuit }
]));
}
destroy(): void {
this.tray?.destroy();
this.tray = null;
}
}
+8
View File
@@ -0,0 +1,8 @@
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('meloOverlay', {
toggleRecording: (): void => ipcRenderer.send('overlay:toggle-recording'),
onState: (listener: (state: string) => void): void => {
ipcRenderer.on('overlay-state', (_event, payload: { state: string }) => listener(payload.state));
}
});
+21
View File
@@ -0,0 +1,21 @@
import { contextBridge, ipcRenderer } from 'electron';
import type { Point, SettingsViewState } from '../shared/contracts';
contextBridge.exposeInMainWorld('meloSettings', {
getState: (): Promise<SettingsViewState> => ipcRenderer.invoke('settings:get-state'),
setBackend: (id: string): Promise<SettingsViewState> => ipcRenderer.invoke('settings:set-backend', id),
openDebug: (): Promise<void> => ipcRenderer.invoke('settings:open-debug'),
setPosition: (position: Point, displayId: string): Promise<SettingsViewState> =>
ipcRenderer.invoke('settings:set-position', position, displayId),
setPreset: (preset: string, displayId: string): Promise<SettingsViewState> =>
ipcRenderer.invoke('settings:set-preset', preset, displayId),
previewOverlay: (): Promise<void> => ipcRenderer.invoke('settings:preview-overlay'),
setShortcut: (shortcut: string): Promise<SettingsViewState> => ipcRenderer.invoke('settings:set-shortcut', shortcut),
setAutoStart: (enabled: boolean): Promise<SettingsViewState> => ipcRenderer.invoke('settings:set-auto-start', enabled),
setOverlayPersistent: (enabled: boolean): Promise<SettingsViewState> =>
ipcRenderer.invoke('settings:set-overlay-persistent', enabled),
openRepository: (): Promise<void> => ipcRenderer.invoke('settings:open-repository'),
onState: (listener: (state: SettingsViewState) => void): void => {
ipcRenderer.on('settings:state', (_event, state: SettingsViewState) => listener(state));
}
});
+25
View File
@@ -0,0 +1,25 @@
import type { Point, SettingsViewState } from '../shared/contracts';
declare global {
interface Window {
meloSettings: {
getState(): Promise<SettingsViewState>;
setBackend(id: string): Promise<SettingsViewState>;
openDebug(): Promise<void>;
setPosition(position: Point, displayId: string): Promise<SettingsViewState>;
setPreset(preset: string, displayId: string): Promise<SettingsViewState>;
previewOverlay(): Promise<void>;
setShortcut(shortcut: string): Promise<SettingsViewState>;
setAutoStart(enabled: boolean): Promise<SettingsViewState>;
setOverlayPersistent(enabled: boolean): Promise<SettingsViewState>;
openRepository(): Promise<void>;
onState(listener: (state: SettingsViewState) => void): void;
};
meloOverlay: {
toggleRecording(): void;
onState(listener: (state: string) => void): void;
};
}
}
export {};
+17
View File
@@ -0,0 +1,17 @@
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: transparent; }
body { display: grid; place-items: center; }
.voice-overlay { position: relative; display: grid; place-items: center; width: 68px; height: 68px; padding: 0; border: 0; border-radius: 50%; background: radial-gradient(circle at 35% 28%, #71aaff, #2568de 70%); box-shadow: 0 8px 24px rgba(23,79,177,.35); cursor: pointer; }
.ring { position: absolute; inset: 0; border: 2px solid rgba(69,136,245,.45); border-radius: 50%; opacity: 0; }
.voice-overlay[data-state="recording"] .ring { animation: ripple 1.7s ease-out infinite; }
.voice-overlay[data-state="recording"] .ring.two { animation-delay: .55s; }
.bars { display: flex; align-items: center; gap: 3px; height: 24px; }
.bars i { display: block; width: 4px; height: 10px; border-radius: 3px; background: white; }
.voice-overlay[data-state="recording"] .bars i { animation: wave .75s ease-in-out infinite alternate; }
.bars i:nth-child(2) { height: 20px; animation-delay: .12s !important; }
.bars i:nth-child(3) { height: 15px; animation-delay: .24s !important; }
.bars i:nth-child(4) { animation-delay: .36s !important; }
.voice-overlay[data-state="stopping"] { background: radial-gradient(circle at 35% 28%, #ffd087, #e68a22 70%); }
.voice-overlay[data-state="done"] { background: radial-gradient(circle at 35% 28%, #83ebbd, #22aa72 70%); }
.voice-overlay[data-state="error"] { background: radial-gradient(circle at 35% 28%, #ff9b9b, #dc3f52 70%); }
@keyframes ripple { from { transform: scale(.9); opacity: .7; } to { transform: scale(1.28); opacity: 0; } }
@keyframes wave { to { transform: scaleY(.45); } }
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="overlay.css">
</head>
<body>
<button class="voice-overlay" data-state="idle" aria-label="开始或停止语音输入">
<span class="ring one"></span><span class="ring two"></span>
<span class="bars"><i></i><i></i><i></i><i></i></span>
</button>
<script src="overlay.js"></script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
const root = document.querySelector<HTMLElement>('.voice-overlay')!;
root.addEventListener('click', () => window.meloOverlay.toggleRecording());
root.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') window.meloOverlay.toggleRecording();
});
window.meloOverlay.onState((state) => { root.dataset.state = state; });
+34
View File
@@ -0,0 +1,34 @@
:root { color-scheme: light; font: 14px/1.5 system-ui, "Microsoft YaHei", sans-serif; color: #182033; }
* { box-sizing: border-box; }
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; }
.screen { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 12px 0; padding: 15px; aspect-ratio: 16/5; border: 2px solid #b9c4d6; border-radius: 10px; background: #f7f9fc; }
.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; }
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>MeloASR 设置</title>
<link rel="stylesheet" href="settings.css">
</head>
<body>
<header><img src="../assets/logo.png" alt=""><div><h1>MeloASR</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>
</section>
<section>
<h2>悬浮球</h2>
<label class="switch"><input id="overlay-persistent" type="checkbox"><span>始终显示悬浮球</span></label>
<div class="row"><label for="display">屏幕</label><select id="display"></select></div>
<div class="screen" 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="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="hint">方向键微调 1 像素,Shift + 方向键微调 10 像素</div>
<div class="buttons"><button id="apply-position" class="primary">应用坐标</button><button id="preview-overlay">预览</button></div>
</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>
</section>
<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>
</main>
<script src="settings.js"></script>
</body>
</html>
+148
View File
@@ -0,0 +1,148 @@
interface BackendRuntimeStatus {
ready: boolean;
login: 'unknown' | 'logged-in' | 'logged-out';
detail: string;
}
interface SettingsViewState {
backendId: string;
backends: Array<{ id: string; label: string; status: BackendRuntimeStatus }>;
sessionState: 'idle' | 'starting' | 'recording' | 'stopping';
shortcut: string;
autoStart: boolean;
overlayPersistent: boolean;
overlayPosition: { x: number; y: number };
displays: Array<{ id: string; label: string; workArea: { x: number; y: number; width: number; height: number } }>;
version: string;
repositoryUrl: string | null;
}
const backend = document.querySelector<HTMLSelectElement>('#backend')!;
const backendStatus = document.querySelector<HTMLElement>('#backend-status')!;
const loginWarning = document.querySelector<HTMLElement>('#login-warning')!;
const display = document.querySelector<HTMLSelectElement>('#display')!;
const x = document.querySelector<HTMLInputElement>('#position-x')!;
const y = document.querySelector<HTMLInputElement>('#position-y')!;
const shortcut = document.querySelector<HTMLButtonElement>('#shortcut')!;
const autoStart = document.querySelector<HTMLInputElement>('#auto-start')!;
const overlayPersistent = document.querySelector<HTMLInputElement>('#overlay-persistent')!;
const version = document.querySelector<HTMLElement>('#version')!;
const repository = document.querySelector<HTMLButtonElement>('#repository')!;
const message = document.querySelector<HTMLElement>('#message')!;
let state: SettingsViewState;
let capturingShortcut = false;
function statusText(status: BackendRuntimeStatus): string {
if (status.ready) return '已就绪';
if (status.login === 'logged-out') return '需要登录';
return status.detail || '正在加载';
}
function render(next: SettingsViewState): void {
state = next;
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';
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;
});
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);
shortcut.textContent = capturingShortcut ? '请按下新快捷键…' : displayShortcut(next.shortcut);
autoStart.checked = next.autoStart;
overlayPersistent.checked = next.overlayPersistent;
version.textContent = next.version;
repository.textContent = next.repositoryUrl ?? '尚未配置';
repository.disabled = !next.repositoryUrl;
}
function displayShortcut(value: string): string {
return value.replaceAll('Control', 'Ctrl').replaceAll('Super', 'Meta').replace(/\+space$/i, '+Space');
}
function formatShortcut(event: KeyboardEvent): string | null {
const modifiers = [
event.ctrlKey ? 'Control' : '',
event.altKey ? 'Alt' : '',
event.shiftKey ? 'Shift' : '',
event.metaKey ? 'Super' : ''
].filter(Boolean);
if (['Control', 'Alt', 'Shift', 'Meta'].includes(event.key) || modifiers.length === 0) return null;
const key = event.code === 'Space' ? 'space'
: event.code.startsWith('Key') ? event.code.slice(3).toLowerCase()
: event.code.startsWith('Digit') ? event.code.slice(5)
: event.key.length === 1 ? event.key.toLowerCase() : event.key;
return [...modifiers, key].join('+');
}
async function apply(action: () => Promise<SettingsViewState>, confirmation = '已保存'): Promise<void> {
try {
render(await action());
message.textContent = confirmation;
} catch (error) {
message.textContent = (error as Error).message;
}
}
async function applyPosition(): Promise<void> {
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)));
document.querySelector('#open-debug')!.addEventListener('click', () => void window.meloSettings.openDebug());
document.querySelectorAll<HTMLButtonElement>('[data-preset]').forEach((button) => {
button.addEventListener('click', () => void apply(
() => window.meloSettings.setPreset(button.dataset.preset!, display.value), '位置已保存'
));
});
document.querySelector('#apply-position')!.addEventListener('click', () => void applyPosition());
document.querySelector('#preview-overlay')!.addEventListener('click', () => void window.meloSettings.previewOverlay());
document.addEventListener('keydown', (event) => {
if (capturingShortcut) {
event.preventDefault();
if (event.key === 'Escape') {
capturingShortcut = false;
render(state);
return;
}
const value = formatShortcut(event);
if (!value) return;
capturingShortcut = false;
void apply(() => window.meloSettings.setShortcut(value), '快捷键已保存');
return;
}
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
if ((event.target as HTMLElement).matches('select, button, input[type="checkbox"]')) return;
event.preventDefault();
const step = event.shiftKey ? 10 : 1;
if (event.key === 'ArrowLeft') x.value = String(Number(x.value) - step);
if (event.key === 'ArrowRight') x.value = String(Number(x.value) + step);
if (event.key === 'ArrowUp') y.value = String(Number(y.value) - step);
if (event.key === 'ArrowDown') y.value = String(Number(y.value) + step);
void applyPosition();
});
shortcut.addEventListener('click', () => {
capturingShortcut = true;
shortcut.textContent = '请按下新快捷键…';
shortcut.focus();
});
autoStart.addEventListener('change', () => void apply(() => window.meloSettings.setAutoStart(autoStart.checked)));
overlayPersistent.addEventListener('change', () => void apply(
() => window.meloSettings.setOverlayPersistent(overlayPersistent.checked)
));
repository.addEventListener('click', () => void window.meloSettings.openRepository());
window.meloSettings.onState(render);
void window.meloSettings.getState().then(render).catch((error: Error) => { message.textContent = error.message; });
+16
View File
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { clampPosition, presetPosition } from '../src/main/overlay-position';
const workArea = { x: 100, y: 50, width: 1200, height: 800 };
const size = { width: 88, height: 88 };
test('九宫格预设基于屏幕工作区计算', () => {
assert.deepEqual(presetPosition(workArea, size, 'top-left'), { x: 132, y: 82 });
assert.deepEqual(presetPosition(workArea, size, 'middle-center'), { x: 656, y: 406 });
assert.deepEqual(presetPosition(workArea, size, 'bottom-right'), { x: 1180, y: 730 });
});
test('精确坐标被限制在工作区内', () => {
assert.deepEqual(clampPosition({ x: -500, y: 2000 }, workArea, size), { x: 100, y: 762 });
});