Merge branch 'appergb:beta' into beta
This commit is contained in:
18 files changed
+1820
-1879
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "openless-app",
|
||||
"private": true,
|
||||
"version": "1.2.24-5",
|
||||
"version": "1.2.24-6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -3751,7 +3751,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openless"
|
||||
version = "1.2.24-5"
|
||||
version = "1.2.24-6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "openless"
|
||||
version = "1.2.24-5"
|
||||
version = "1.2.24-6"
|
||||
description = "OpenLess — local voice input that types where your cursor is"
|
||||
authors = ["OpenLess"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -150,10 +150,22 @@ pub fn set_settings(
|
||||
// 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时
|
||||
// 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。
|
||||
persist_settings(&*coord, prefs.clone())?;
|
||||
if let Err(err) = crate::refresh_tray_microphone_menu(&app) {
|
||||
log::warn!("[tray] refresh microphone menu after settings save failed: {err}");
|
||||
sync_tray_microphone_selection(&tray_microphones.lock(), &prefs.microphone_device_name);
|
||||
}
|
||||
// refresh_tray_microphone_menu 内部会调用 NSStatusItem.set_menu,必须在主线程上跑。
|
||||
// set_settings 本身是同步 Tauri command,在 IPC handler 线程上执行;从这里直接调
|
||||
// 会触发 macOS 主线程断言或在 dispatch 队列上死锁,导致整个 UI 无响应(用户改
|
||||
// 偏好后所有按键都没反应即此根因)。dispatch 到主线程后立即返回,IPC 线程不阻塞。
|
||||
let app_for_main = app.clone();
|
||||
let prefs_for_main = prefs.clone();
|
||||
let _ = app.run_on_main_thread(move || {
|
||||
if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) {
|
||||
log::warn!("[tray] refresh microphone menu after settings save failed: {err}");
|
||||
let tray_state = app_for_main.state::<TrayMicrophoneMenuState>();
|
||||
sync_tray_microphone_selection(&tray_state.lock(), &prefs_for_main.microphone_device_name);
|
||||
}
|
||||
});
|
||||
// 抑制 unused 警告:tray_microphones 现在改在闭包里通过 app.state 取,
|
||||
// 但函数签名保留 State 入参,以便 Tauri 在调用前注入。
|
||||
let _ = tray_microphones;
|
||||
let _ = app.emit("prefs:changed", &prefs);
|
||||
Ok(())
|
||||
}
|
||||
@@ -929,9 +941,14 @@ pub fn set_default_polish_mode(
|
||||
let mut prefs = coord.prefs().get();
|
||||
prefs.default_mode = mode;
|
||||
coord.prefs().set(prefs.clone()).map_err(|e| e.to_string())?;
|
||||
if let Err(err) = crate::refresh_tray_microphone_menu(&app) {
|
||||
log::warn!("[tray] refresh style menu after polish mode IPC change failed: {err}");
|
||||
}
|
||||
// 跟 set_settings 同样:refresh_tray_microphone_menu 里 tray.set_menu 改 NSStatusItem,
|
||||
// 必须主线程;这里是同步 Tauri command 跑在 IPC 线程,直调会让 macOS 死锁。
|
||||
let app_for_main = app.clone();
|
||||
let _ = app.run_on_main_thread(move || {
|
||||
if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) {
|
||||
log::warn!("[tray] refresh style menu after polish mode IPC change failed: {err}");
|
||||
}
|
||||
});
|
||||
let _ = app.emit("prefs:changed", &prefs);
|
||||
let _ = app.emit_to("main", "prefs:changed", &prefs);
|
||||
Ok(())
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,124 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::Emitter;
|
||||
|
||||
use crate::coordinator_state::{initial_session_id, SessionId};
|
||||
use crate::selection::SelectionContext;
|
||||
use crate::types::CapsuleState;
|
||||
|
||||
use super::{
|
||||
begin_qa_session, cancel_qa_session, capture_frontmost_app, emit_capsule, end_qa_session, Inner,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum QaPhase {
|
||||
Idle,
|
||||
Recording,
|
||||
Processing,
|
||||
}
|
||||
|
||||
pub(super) struct QaSessionState {
|
||||
pub(super) phase: QaPhase,
|
||||
pub(super) cancelled: bool,
|
||||
pub(super) selection: Option<SelectionContext>,
|
||||
pub(super) front_app: Option<String>,
|
||||
/// 用于忽略迟到的 RMS / runtime error。
|
||||
pub(super) session_id: SessionId,
|
||||
/// QA 浮窗是否被用户钉住(pinned)。pinned=true 时不自动隐藏。
|
||||
pub(super) pinned: bool,
|
||||
/// 浮窗是否对用户可见。Cmd+Shift+; 边沿 toggle 此 flag;
|
||||
/// 主听写 hotkey(rightOption)边沿来时,看这个 flag 决定是走 QA 还是走 dictation。
|
||||
/// 详见 issue #118 v2。
|
||||
pub(super) panel_visible: bool,
|
||||
/// 多轮对话累积。每轮 user→assistant 加两条;关浮窗清空。
|
||||
pub(super) messages: Vec<crate::types::QaChatMessage>,
|
||||
}
|
||||
|
||||
impl Default for QaSessionState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
phase: QaPhase::Idle,
|
||||
cancelled: false,
|
||||
selection: None,
|
||||
front_app: None,
|
||||
session_id: initial_session_id(),
|
||||
pinned: false,
|
||||
panel_visible: false,
|
||||
messages: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_qa_hotkey_pressed(inner: &Arc<Inner>) {
|
||||
// QA hotkey(默认 Cmd+Shift+;)现在只 toggle 浮窗可见性。
|
||||
// 浮窗内的录音 / 提问由 Option 边沿驱动(handle_pressed_edge → handle_qa_option_edge)。
|
||||
let visible = inner.qa_state.lock().panel_visible;
|
||||
log::info!("[coord] QA hotkey edge (panel_visible={visible})");
|
||||
if visible {
|
||||
close_qa_panel(inner);
|
||||
} else {
|
||||
open_qa_panel(inner);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_qa_option_edge(inner: &Arc<Inner>) {
|
||||
let phase = inner.qa_state.lock().phase;
|
||||
log::info!("[coord] QA option edge (phase={phase:?})");
|
||||
match phase {
|
||||
QaPhase::Idle => {
|
||||
let _ = begin_qa_session(inner).await;
|
||||
}
|
||||
QaPhase::Recording => {
|
||||
let _ = end_qa_session(inner).await;
|
||||
}
|
||||
// Processing 阶段再次按键忽略(避免与正在跑的 LLM 冲突)。
|
||||
QaPhase::Processing => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn open_qa_panel(inner: &Arc<Inner>) {
|
||||
{
|
||||
let mut state = inner.qa_state.lock();
|
||||
state.panel_visible = true;
|
||||
state.phase = QaPhase::Idle;
|
||||
state.cancelled = false;
|
||||
state.messages.clear();
|
||||
state.selection = None;
|
||||
state.front_app = capture_frontmost_app();
|
||||
}
|
||||
// 先把胶囊清干净,避免主听写上一次 Done 状态残留的 message/insertedChars
|
||||
// 在 QA Done 阶段被 capsule UI 错误复用("已之一粘贴这个 0" 那种)。
|
||||
emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None);
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
crate::show_qa_window(&app, "idle");
|
||||
let _ = app.emit_to(
|
||||
"qa",
|
||||
"qa:state",
|
||||
serde_json::json!({
|
||||
"kind": "idle",
|
||||
"messages": Vec::<crate::types::QaChatMessage>::new(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
log::info!("[coord] QA panel opened (awaiting Option to record)");
|
||||
}
|
||||
|
||||
pub(super) fn close_qa_panel(inner: &Arc<Inner>) {
|
||||
cancel_qa_session(inner);
|
||||
{
|
||||
let mut state = inner.qa_state.lock();
|
||||
state.panel_visible = false;
|
||||
state.pinned = false;
|
||||
state.messages.clear();
|
||||
state.selection = None;
|
||||
state.front_app = None;
|
||||
state.phase = QaPhase::Idle;
|
||||
state.cancelled = false;
|
||||
}
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
crate::hide_qa_window(&app);
|
||||
}
|
||||
// 胶囊一同收掉,避免浮窗关了胶囊还在显示。
|
||||
emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None);
|
||||
log::info!("[coord] QA panel closed, history cleared");
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::coordinator_state::{SessionId, SessionPhase};
|
||||
use crate::recorder::Recorder;
|
||||
use crate::types::CapsuleState;
|
||||
use tauri::Manager;
|
||||
|
||||
use super::{emit_capsule, ActiveAsr, Inner};
|
||||
|
||||
pub(super) struct SessionResource<T> {
|
||||
pub(super) session_id: SessionId,
|
||||
resource: T,
|
||||
}
|
||||
|
||||
impl<T> SessionResource<T> {
|
||||
pub(super) fn new(session_id: SessionId, resource: T) -> Self {
|
||||
Self {
|
||||
session_id,
|
||||
resource,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_inner(self) -> T {
|
||||
self.resource
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct SharedRecordingMuteState {
|
||||
guard: Option<crate::audio_mute::AudioMuteGuard>,
|
||||
holders: u32,
|
||||
}
|
||||
|
||||
impl SharedRecordingMuteState {
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
guard: None,
|
||||
holders: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn take_session_resource<T>(
|
||||
slot: &mut Option<SessionResource<T>>,
|
||||
session_id: SessionId,
|
||||
) -> Option<T> {
|
||||
if slot
|
||||
.as_ref()
|
||||
.map(|resource| resource.session_id == session_id)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
slot.take().map(SessionResource::into_inner)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn store_asr_for_session(inner: &Arc<Inner>, session_id: SessionId, asr: ActiveAsr) {
|
||||
*inner.asr.lock() = Some(SessionResource::new(session_id, asr));
|
||||
}
|
||||
|
||||
pub(super) fn take_asr_for_session(inner: &Arc<Inner>, session_id: SessionId) -> Option<ActiveAsr> {
|
||||
let mut slot = inner.asr.lock();
|
||||
take_session_resource(&mut slot, session_id)
|
||||
}
|
||||
|
||||
pub(super) fn cancel_active_asr(asr: ActiveAsr) {
|
||||
match asr {
|
||||
ActiveAsr::Volcengine(v) => v.cancel(),
|
||||
ActiveAsr::Whisper(w) => w.cancel(),
|
||||
#[cfg(target_os = "windows")]
|
||||
ActiveAsr::FoundryLocalWhisper(local) => local.cancel(),
|
||||
#[cfg(target_os = "macos")]
|
||||
ActiveAsr::Local(local) => local.cancel(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn cancel_asr_for_session(inner: &Arc<Inner>, session_id: SessionId) {
|
||||
if let Some(asr) = take_asr_for_session(inner, session_id) {
|
||||
cancel_active_asr(asr);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn store_recorder_for_session(
|
||||
inner: &Arc<Inner>,
|
||||
session_id: SessionId,
|
||||
recorder: Recorder,
|
||||
) {
|
||||
*inner.recorder.lock() = Some(SessionResource::new(session_id, recorder));
|
||||
}
|
||||
|
||||
pub(super) fn selected_microphone_device_name(inner: &Arc<Inner>) -> Option<String> {
|
||||
let name = inner.prefs.get().microphone_device_name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(name)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn stop_microphone_preview_monitor(inner: &Arc<Inner>, owner: &str) {
|
||||
let Some(app) = inner.app.lock().as_ref().cloned() else {
|
||||
return;
|
||||
};
|
||||
let state = app.state::<crate::commands::MicrophoneMonitorState>();
|
||||
let recorder = state.lock().take();
|
||||
if let Some(recorder) = recorder {
|
||||
log::info!("[recorder] stopping microphone preview monitor before {owner}");
|
||||
recorder.stop();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn acquire_recording_mute(inner: &Arc<Inner>, owner: &str) {
|
||||
if !inner.prefs.get().mute_during_recording {
|
||||
return;
|
||||
}
|
||||
let mut mute = inner.recording_mute.lock();
|
||||
if mute.holders == 0 {
|
||||
match crate::audio_mute::AudioMuteGuard::activate() {
|
||||
Ok(guard) => {
|
||||
mute.guard = Some(guard);
|
||||
log::info!("[audio-mute] system output muted for recording");
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("[audio-mute] failed to mute output for {owner}: {err}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
mute.holders = mute.holders.saturating_add(1);
|
||||
log::info!("[audio-mute] acquired by {owner}; holders={}", mute.holders);
|
||||
}
|
||||
|
||||
pub(super) fn release_recording_mute(inner: &Arc<Inner>, owner: &str) {
|
||||
let mut mute = inner.recording_mute.lock();
|
||||
if mute.holders == 0 {
|
||||
return;
|
||||
}
|
||||
mute.holders -= 1;
|
||||
log::info!("[audio-mute] released by {owner}; holders={}", mute.holders);
|
||||
if mute.holders == 0 {
|
||||
mute.guard.take();
|
||||
log::info!("[audio-mute] system output mute restored after recording");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn stop_qa_recorder(inner: &Arc<Inner>) {
|
||||
if let Some(rec) = inner.qa_recorder.lock().take() {
|
||||
rec.stop();
|
||||
release_recording_mute(inner, "qa");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn take_recorder_for_session(
|
||||
inner: &Arc<Inner>,
|
||||
session_id: SessionId,
|
||||
) -> Option<Recorder> {
|
||||
let mut slot = inner.recorder.lock();
|
||||
take_session_resource(&mut slot, session_id)
|
||||
}
|
||||
|
||||
pub(super) fn stop_recorder_for_session(inner: &Arc<Inner>, session_id: SessionId) {
|
||||
if let Some(recorder) = take_recorder_for_session(inner, session_id) {
|
||||
recorder.stop();
|
||||
release_recording_mute(inner, "dictation");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn discard_startup_resources_for_session(inner: &Arc<Inner>, session_id: SessionId) {
|
||||
stop_recorder_for_session(inner, session_id);
|
||||
cancel_asr_for_session(inner, session_id);
|
||||
}
|
||||
|
||||
pub(super) fn stop_recorder_if_pending_start_stop(inner: &Arc<Inner>) {
|
||||
let (should_stop, session_id) = {
|
||||
let state = inner.state.lock();
|
||||
(
|
||||
state.phase == SessionPhase::Starting && state.pending_stop,
|
||||
state.session_id,
|
||||
)
|
||||
};
|
||||
if !should_stop {
|
||||
return;
|
||||
}
|
||||
if let Some(rec) = take_recorder_for_session(inner, session_id) {
|
||||
rec.stop();
|
||||
release_recording_mute(inner, "dictation");
|
||||
let elapsed = inner.state.lock().started_at.elapsed().as_millis() as u64;
|
||||
emit_capsule(inner, CapsuleState::Transcribing, 0.0, elapsed, None, None);
|
||||
log::info!("[coord] stopped recorder while ASR is still connecting");
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,23 @@ fn run_audio_thread(
|
||||
while !stop_flag_for_watchdog.load(Ordering::SeqCst) {
|
||||
thread::sleep(std::time::Duration::from_millis(WATCHDOG_CHECK_INTERVAL_MS));
|
||||
|
||||
// 关键:sleep 醒来后必须重新检查 stop_flag,再去看 elapsed。
|
||||
//
|
||||
// 否则会与 hotkey-release 停采路径产生竞态:
|
||||
// 1. 用户松开 hotkey → end_session 调 rec.stop() → 设置 stop_flag
|
||||
// → audio 线程 pause 了 cpal Stream → 回调真的静默
|
||||
// 2. 但 watchdog 此时正卡在上面的 1 秒 sleep 里
|
||||
// 3. sleep 结束后,若不重新检查 stop_flag,
|
||||
// 就会读到 last_callback_time 已经"老 4 秒",
|
||||
// 把"我们主动停掉的录音"错报成 EngineFailed("录音回调静默停止 N 秒"),
|
||||
// coordinator 收到错误后会终止 session、胶囊弹错。
|
||||
//
|
||||
// 修复方式是 sleep 后立即再 load 一次:进入 stop 流程后 watchdog 静默退出,
|
||||
// 不影响 watchdog 在真正活动期捕获 CoreAudio 设备掉线等真故障。
|
||||
if stop_flag_for_watchdog.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
|
||||
let last_callback = *state_for_watchdog.last_callback_time.lock();
|
||||
match last_callback {
|
||||
Some(last_time) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenLess",
|
||||
"version": "1.2.24-5",
|
||||
"version": "1.2.24-6",
|
||||
"identifier": "com.openless.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
//
|
||||
// Ported verbatim from design_handoff_openless/variants.jsx::FloatingShell.
|
||||
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type ComponentType, type ReactNode } from 'react';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentType } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { isDialogStatus, UpdateDialog, useAutoUpdate } from './AutoUpdate';
|
||||
import { Icon } from './Icon';
|
||||
import { WindowChrome, detectOS, type OS } from './WindowChrome';
|
||||
import { SettingsModal } from './SettingsModal';
|
||||
@@ -23,15 +22,13 @@ import {
|
||||
HOTKEY_MODE_MIGRATION_DEFERRED_KEY,
|
||||
shouldShowHotkeyModeMigrationPrompt,
|
||||
} from '../lib/hotkeyMigration';
|
||||
import { formatComboLabel } from '../lib/hotkey';
|
||||
import { applyFontScale, readFontScale } from '../lib/fontScale';
|
||||
import { getCredentials, openExternal } from '../lib/ipc';
|
||||
import { getCredentials } from '../lib/ipc';
|
||||
import {
|
||||
PROVIDER_SETUP_PROMPT_DEFERRED_KEY,
|
||||
shouldShowProviderSetupPrompt,
|
||||
} from '../lib/providerSetup';
|
||||
import { NAVIGATE_LOCAL_ASR_EVENT, type SettingsSectionId } from '../pages/Settings';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
import { useAppState, type AppTab } from '../state/useAppState';
|
||||
|
||||
interface NavItem {
|
||||
@@ -51,9 +48,6 @@ const NAV_BASE: Array<Omit<NavItem, 'name'>> = [
|
||||
{ id: 'localAsr', icon: 'archive', cmp: LocalAsr },
|
||||
];
|
||||
|
||||
const RELEASE_NOTES_URL = 'https://github.com/appergb/openless/releases';
|
||||
const HELP_DOCS_URL = 'https://github.com/appergb/openless#readme';
|
||||
|
||||
interface FloatingShellProps {
|
||||
os?: OS;
|
||||
initialTab?: AppTab;
|
||||
@@ -75,8 +69,6 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionId | undefined>();
|
||||
const [providerPromptOpen, setProviderPromptOpen] = useState(false);
|
||||
const [hotkeyModePromptOpen, setHotkeyModePromptOpen] = useState(false);
|
||||
const [helpPopoverOpen, setHelpPopoverOpen] = useState(false);
|
||||
const { prefs } = useHotkeySettings();
|
||||
|
||||
// tab 切换的 cross-fade:旧页 blur+fade out(180ms),结束后挂载新页(走 ol-page-slide enter)。
|
||||
// displayTab 是实际渲染的 tab,currentTab 是用户点中的目标 tab。
|
||||
@@ -97,26 +89,23 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
applyFontScale(readFontScale());
|
||||
}, []);
|
||||
|
||||
// help popover 打开时,点击其他位置自动关闭
|
||||
useEffect(() => {
|
||||
if (!helpPopoverOpen) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Element | null;
|
||||
if (target && target.closest('[data-ol-footer-popover]')) return;
|
||||
setHelpPopoverOpen(false);
|
||||
};
|
||||
const id = window.setTimeout(() => document.addEventListener('mousedown', onDown), 0);
|
||||
return () => {
|
||||
window.clearTimeout(id);
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
};
|
||||
}, [helpPopoverOpen]);
|
||||
const NAV = useMemo<NavItem[]>(
|
||||
() => NAV_BASE.map(b => ({ ...b, name: t(`nav.${b.id}`) })),
|
||||
[t],
|
||||
);
|
||||
const Page = (NAV.find((n) => n.id === displayTab) ?? NAV[0]).cmp;
|
||||
|
||||
// sidebar nav 滑动指示器:测量当前 active button 的 offsetTop / height,
|
||||
// 用一个 absolute pill 平滑滑过去,而不是每个按钮各自瞬切背景色。
|
||||
const navItemRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const idx = NAV.findIndex(n => n.id === currentTab);
|
||||
const el = navItemRefs.current[idx];
|
||||
if (!el) return;
|
||||
setPillRect({ top: el.offsetTop, height: el.offsetHeight });
|
||||
}, [currentTab, NAV]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
@@ -221,25 +210,47 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, letterSpacing: '-0.01em', color: 'var(--ol-ink)' }}>OpenLess</div>
|
||||
</div>
|
||||
|
||||
{/* nav */}
|
||||
<nav style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{NAV.map((n) => {
|
||||
{/* nav — 滑动指示器:active pill 是 absolute 元素,currentTab 改变时 top/height
|
||||
过渡到目标按钮的位置,而非各按钮自己瞬切背景色。hover 灰底通过 .ol-nav-btn 的
|
||||
CSS :hover 规则实现,仅对非 active 项生效。 */}
|
||||
<nav style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{pillRect && (
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: pillRect.top,
|
||||
height: pillRect.height,
|
||||
background: 'var(--ol-surface)',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)',
|
||||
transition: 'top 0.36s var(--ol-motion-spring), height 0.36s var(--ol-motion-spring)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{NAV.map((n, i) => {
|
||||
const active = currentTab === n.id;
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
ref={el => { navItemRefs.current[i] = el; }}
|
||||
onClick={() => setCurrentTab(n.id)}
|
||||
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '7px 10px',
|
||||
borderRadius: 8, border: 0,
|
||||
background: active ? 'var(--ol-surface)' : 'transparent',
|
||||
color: active ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
|
||||
fontFamily: 'inherit', fontSize: 13, fontWeight: active ? 600 : 500,
|
||||
boxShadow: active ? '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)' : 'none',
|
||||
background: 'transparent',
|
||||
fontFamily: 'inherit', fontSize: 13,
|
||||
cursor: 'default',
|
||||
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft)',
|
||||
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
|
||||
textAlign: 'left',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}>
|
||||
|
||||
<Icon name={n.icon} size={14} />
|
||||
@@ -250,30 +261,6 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
</nav>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* shortcut hint — 不要 dashed 边框,否则会切断"整片磨砂玻璃"的视觉 */}
|
||||
<div style={{ padding: '10px 10px 6px', marginTop: 6 }}>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--ol-ink-4)', marginBottom: 6, letterSpacing: '0.02em' }}>{t('shell.shortcutLabel')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'var(--ol-ink-2)' }}>
|
||||
<kbd style={{
|
||||
padding: '2px 7px', fontSize: 10.5,
|
||||
background: 'rgba(255,255,255,0.7)', borderRadius: 5,
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
fontFamily: 'var(--ol-font-mono)', color: 'var(--ol-ink)',
|
||||
boxShadow: '0 1px 0 rgba(0,0,0,.04)',
|
||||
}}>{prefs ? formatComboLabel(prefs.dictationHotkey) : ''}</kbd>
|
||||
<span style={{ color: 'var(--ol-ink-4)' }}>{t('shell.shortcutHint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BETA 区域 — 仅在 Beta build(version 含 semver prerelease 段,如 1.2.24-1)显示。
|
||||
正式版 build 完全不渲染这块,避免普通用户误以为正式版是 Beta。 */}
|
||||
{IS_BETA_BUILD && (
|
||||
<div style={{ marginTop: 8, padding: '10px 10px 4px' }}>
|
||||
<div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--ol-blue)', letterSpacing: '0.04em', textTransform: 'uppercase' }}>{t('shell.betaTag')}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-2)', marginTop: 4, lineHeight: 1.5 }}>{t('shell.betaNote')}</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Main content — inset white card sitting on the frosted backplate.
|
||||
@@ -312,6 +299,9 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
flex: 1, minHeight: 0,
|
||||
overflow: 'auto',
|
||||
padding: '24px 28px 32px',
|
||||
// position:relative 让页面里的"已保存"toast 用 absolute top:16 right:16
|
||||
// 锚到这块控制台卡的右上角,而不是横在页头变成长横幅。
|
||||
position: 'relative',
|
||||
// 苹果"spring out"风格的曲线:开始快、收尾顺滑,符合人体直觉
|
||||
animation: tabPhase === 'exiting'
|
||||
? 'ol-page-fadeout 0.18s var(--ol-motion-soft) forwards'
|
||||
@@ -345,24 +335,27 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
zIndex: 2,
|
||||
}}>
|
||||
|
||||
<FooterIcon name="user" tip={t('shell.footer.account')} onClick={() => openSettings('providers')} />
|
||||
<FooterIcon name="mail" tip={t('shell.footer.feedback')} onClick={() => openExternal('https://github.com/appergb/openless/issues')} />
|
||||
<FooterIcon name="settings" tip={t('shell.footer.settings')} active={settingsOpen} onClick={() => openSettings()} />
|
||||
|
||||
{/* 问号 — 点击展开版本说明 popover */}
|
||||
<FooterIconWithPopover
|
||||
name="help"
|
||||
tip={t('shell.footer.help')}
|
||||
open={helpPopoverOpen}
|
||||
onToggle={() => setHelpPopoverOpen(o => !o)}
|
||||
>
|
||||
<HelpPopoverBody />
|
||||
</FooterIconWithPopover>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<span style={{ fontFamily: 'var(--ol-font-sans)' }}>{t('shell.footer.version', { version: APP_VERSION_LABEL })}</span>
|
||||
<FooterAutoUpdateButton />
|
||||
{IS_BETA_BUILD && (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--ol-blue)',
|
||||
background: 'rgba(37,99,235,0.10)',
|
||||
borderRadius: 999,
|
||||
marginRight: 8,
|
||||
}}>{t('shell.betaTag')}</span>
|
||||
)}
|
||||
|
||||
<span style={{ fontFamily: 'var(--ol-font-sans)', marginRight: 12 }}>{t('shell.footer.version', { version: APP_VERSION_LABEL })}</span>
|
||||
<div style={{ marginRight: 12 }}>
|
||||
<FooterIcon name="settings" tip={t('shell.footer.settings')} active={settingsOpen} onClick={() => openSettings()} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings modal — rendered inside this window */}
|
||||
@@ -389,6 +382,24 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
|
||||
{/* tab 切换 + provider prompt + footer popover 公用的入场关键帧 */}
|
||||
<style>{`
|
||||
/* nav 三段视觉层次:
|
||||
基础态 → ink-3(中灰文字 + 透明底)
|
||||
hover → ink(深色文字 + 浅灰底) ← 让"翻译"等字词在悬停时高亮,跟基础/选中都拉开差距
|
||||
选中 → ink(深色文字 + 白色 pill 底,由 absolute pill 提供)
|
||||
inline color/fontWeight 留给 active 项写最高优先级;非 active 走 class,
|
||||
这样 :hover 能正确覆盖(CSS 不能盖 inline style)。 */
|
||||
.ol-nav-btn {
|
||||
color: var(--ol-ink-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
.ol-nav-btn.ol-nav-btn-active {
|
||||
color: var(--ol-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ol-nav-btn:not(.ol-nav-btn-active):hover {
|
||||
background: rgba(0,0,0,0.04);
|
||||
color: var(--ol-ink);
|
||||
}
|
||||
@keyframes ol-page-slide {
|
||||
from { opacity: 0; transform: translate3d(10px, 0, 0) scale(.996); filter: blur(6px); }
|
||||
to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); filter: blur(0); }
|
||||
@@ -405,10 +416,6 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
from { opacity: 0; transform: translateY(6px) scale(.97); filter: blur(6px); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
|
||||
}
|
||||
@keyframes ol-popover-pop {
|
||||
from { opacity: 0; transform: translateY(6px) scale(.96); filter: blur(6px); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
@@ -634,132 +641,3 @@ function FooterIcon({ name, tip, active, onClick }: FooterIconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// 把 footer icon 和它的 popover 绑在同一个相对定位容器里,popover 锚定在按钮正上方。
|
||||
function FooterIconWithPopover({
|
||||
name, tip, open, onToggle, children,
|
||||
}: {
|
||||
name: string;
|
||||
tip: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div data-ol-footer-popover style={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<FooterIcon name={name} tip={tip} active={open} onClick={onToggle} />
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 'calc(100% + 8px)',
|
||||
left: 0,
|
||||
zIndex: 80,
|
||||
minWidth: 220,
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
background: 'rgba(255,255,255,0.96)',
|
||||
backdropFilter: 'blur(var(--ol-glass-blur)) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(var(--ol-glass-blur)) saturate(180%)',
|
||||
border: '0.5px solid rgba(0,0,0,0.08)',
|
||||
boxShadow: '0 18px 50px -22px rgba(15,17,22,0.32), 0 0 0 0.5px rgba(0,0,0,0.05)',
|
||||
animation: 'ol-popover-pop 0.22s var(--ol-motion-spring) both',
|
||||
transformOrigin: 'bottom left',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpPopoverBody() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div style={{ minWidth: 240 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
||||
<img src="AppIcon.png" alt="" style={{ width: 26, height: 26, borderRadius: 6, boxShadow: '0 1px 2px rgba(0,0,0,.1), 0 0 0 0.5px rgba(0,0,0,.06)' }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ol-ink)' }}>OpenLess</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', fontFamily: 'var(--ol-font-mono)', marginTop: 1 }}>{APP_VERSION_LABEL}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-3)', lineHeight: 1.55, marginBottom: 10 }}>
|
||||
{t('shell.footer.helpPopover.tagline')}
|
||||
</div>
|
||||
<button onClick={() => openExternal(RELEASE_NOTES_URL)} style={popoverLinkStyle}>
|
||||
{t('shell.footer.helpPopover.releaseNotes')}
|
||||
</button>
|
||||
<button onClick={() => openExternal(HELP_DOCS_URL)} style={popoverLinkStyle}>
|
||||
{t('shell.footer.helpPopover.docs')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const popoverLinkStyle: CSSProperties = {
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
color: 'var(--ol-blue)',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
cursor: 'default',
|
||||
textAlign: 'left',
|
||||
padding: '6px 4px',
|
||||
};
|
||||
|
||||
// Footer 的"检查更新"按钮 — 复用 Settings 页面的 useAutoUpdate hook + UpdateDialog 窗口,
|
||||
// 跟"关于"section 走完全相同的状态机和确认对话框。按钮本身只显示触发文案 + 简短状态。
|
||||
function FooterAutoUpdateButton() {
|
||||
const { t } = useTranslation();
|
||||
const u = useAutoUpdate();
|
||||
|
||||
const inlineHint = u.status === 'none'
|
||||
? t('settings.about.upToDate')
|
||||
: u.status === 'error'
|
||||
? t('settings.about.updateError')
|
||||
: null;
|
||||
const inlineHintColor = u.status === 'error' ? 'var(--ol-err)' : 'var(--ol-ink-4)';
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={u.checkForUpdates}
|
||||
disabled={u.checking || u.busy}
|
||||
style={{
|
||||
color: 'var(--ol-blue)',
|
||||
marginLeft: 8,
|
||||
textDecoration: 'none',
|
||||
fontWeight: 500,
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
cursor: 'default',
|
||||
padding: 0,
|
||||
opacity: u.checking || u.busy ? 0.7 : 1,
|
||||
transition: 'opacity 0.16s var(--ol-motion-soft)',
|
||||
}}
|
||||
>
|
||||
{u.checking ? t('settings.about.checkingUpdate') : t('settings.about.checkUpdateBtn')}
|
||||
</button>
|
||||
{inlineHint && (
|
||||
<span style={{ marginLeft: 8, color: inlineHintColor, fontSize: 11 }}>{inlineHint}</span>
|
||||
)}
|
||||
{isDialogStatus(u.status) && (
|
||||
<UpdateDialog
|
||||
status={u.status}
|
||||
version={u.version}
|
||||
progress={u.progress}
|
||||
downloaded={u.downloaded}
|
||||
contentLength={u.contentLength}
|
||||
onInstall={u.installUpdate}
|
||||
onClose={u.dismissDialog}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// SavedToast.tsx — 控制台卡右上角的"正在保存 / 已保存 / 失败"小 pill。
|
||||
// 父级 scroll wrapper(FloatingShell main 区)已设 position:relative,
|
||||
// 此 pill 用 absolute 锚到右上角,避免在页面顶部撑成一条难看的长横幅。
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export type SaveToastState = 'idle' | 'saving' | 'saved' | 'failed';
|
||||
|
||||
interface SavedToastProps {
|
||||
saveState: SaveToastState;
|
||||
message: string;
|
||||
/** 覆盖默认 top:16 right:16 偏移,例如 SettingsModal 里要避开 28×28 的关闭按钮。 */
|
||||
offsetStyle?: Pick<CSSProperties, 'top' | 'right' | 'left' | 'bottom'>;
|
||||
}
|
||||
|
||||
export function SavedToast({ saveState, message, offsetStyle }: SavedToastProps) {
|
||||
if (saveState === 'idle') return null;
|
||||
const failed = saveState === 'failed';
|
||||
const style: CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
...offsetStyle,
|
||||
zIndex: 5,
|
||||
padding: '5px 12px',
|
||||
borderRadius: 999,
|
||||
border: failed
|
||||
? '0.5px solid rgba(239,68,68,0.22)'
|
||||
: '0.5px solid rgba(37,99,235,0.16)',
|
||||
background: failed ? 'rgba(239,68,68,0.10)' : 'rgba(37,99,235,0.10)',
|
||||
color: failed ? 'var(--ol-red, #ef4444)' : 'var(--ol-blue)',
|
||||
fontSize: 11.5,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.4,
|
||||
boxShadow: '0 4px 12px -4px rgba(15,17,22,0.18), 0 0 0 0.5px rgba(0,0,0,0.04)',
|
||||
backdropFilter: 'blur(12px) saturate(160%)',
|
||||
WebkitBackdropFilter: 'blur(12px) saturate(160%)',
|
||||
pointerEvents: 'none',
|
||||
animation: 'ol-toast-pop 0.22s var(--ol-motion-spring)',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
return (
|
||||
<div role={failed ? 'alert' : 'status'} style={style}>
|
||||
{message}
|
||||
<style>{`
|
||||
@keyframes ol-toast-pop {
|
||||
from { opacity: 0; transform: translateY(-6px) scale(.96); filter: blur(4px); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,13 @@
|
||||
// 开机自启)已从此弹窗移除,避免 "看似可点实际无效" 的负面体感。
|
||||
// 待 backend 就位后再补回(参见 issue #69)。
|
||||
|
||||
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from './Icon';
|
||||
import { AboutUpdateControl, Settings as SettingsContent, Toggle, type SettingsSectionId } from '../pages/Settings';
|
||||
import { Row } from './ui/Row';
|
||||
import { SavedToast } from './SavedToast';
|
||||
import { useSavedToastListener } from '../lib/savedEvent';
|
||||
import { readFontScale, setFontScale, type FontScaleId } from '../lib/fontScale';
|
||||
import {
|
||||
exportErrorLog,
|
||||
@@ -55,6 +57,7 @@ const RELEASE_NOTES_URL = 'https://github.com/appergb/openless/releases';
|
||||
export function SettingsModal({ os: _os, onClose, initialSettingsSection }: SettingsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [section, setSection] = useState<ModalSectionId>('settings');
|
||||
const savedToast = useSavedToastListener();
|
||||
const groups: ModalGroup[] = [
|
||||
{
|
||||
items: [
|
||||
@@ -71,6 +74,16 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
|
||||
},
|
||||
];
|
||||
|
||||
// 与 sidebar nav 一致的滑动指示器:仅第一组(可选中)有 pill;外链组永远不 active 不画 pill。
|
||||
const firstGroupRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const idx = groups[0].items.findIndex(it => it.id === section);
|
||||
const el = firstGroupRefs.current[idx];
|
||||
if (!el) return;
|
||||
setPillRect({ top: el.offsetTop, height: el.offsetHeight });
|
||||
}, [section]);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
@@ -109,12 +122,31 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
|
||||
}}>
|
||||
|
||||
{groups.map((g, gi) => (
|
||||
<div key={gi} style={{ display: 'flex', flexDirection: 'column', gap: 1, paddingTop: gi === 1 ? 8 : 0, borderTop: gi === 1 ? '0.5px solid var(--ol-line-soft)' : 'none' }}>
|
||||
{g.items.map((it) => {
|
||||
<div key={gi} style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 1, paddingTop: gi === 1 ? 8 : 0, borderTop: gi === 1 ? '0.5px solid var(--ol-line-soft)' : 'none' }}>
|
||||
{gi === 0 && pillRect && (
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: pillRect.top,
|
||||
height: pillRect.height,
|
||||
background: '#fff',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)',
|
||||
transition: 'top 0.36s var(--ol-motion-spring), height 0.36s var(--ol-motion-spring)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{g.items.map((it, idx) => {
|
||||
const active = section === it.id && !it.external;
|
||||
return (
|
||||
<button
|
||||
key={it.id}
|
||||
ref={gi === 0 ? (el => { firstGroupRefs.current[idx] = el; }) : undefined}
|
||||
onClick={() => {
|
||||
if (it.external && it.href) {
|
||||
void openExternal(it.href);
|
||||
@@ -122,16 +154,17 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
|
||||
setSection(it.id as ModalSectionId);
|
||||
}
|
||||
}}
|
||||
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '7px 10px',
|
||||
borderRadius: 8, border: 0,
|
||||
background: active ? '#fff' : 'transparent',
|
||||
color: active ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
|
||||
fontFamily: 'inherit', fontSize: 13, fontWeight: active ? 600 : 500,
|
||||
boxShadow: active ? '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)' : 'none',
|
||||
background: 'transparent',
|
||||
fontFamily: 'inherit', fontSize: 13,
|
||||
cursor: 'default', textAlign: 'left',
|
||||
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
|
||||
}}>
|
||||
|
||||
<Icon name={it.icon} size={14} />
|
||||
@@ -148,6 +181,13 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
|
||||
只有最里层的 scroll wrapper 真正滚动。这样模态左 sidebar、关闭按钮、
|
||||
section 标题都不会跟着内容一起飘。 */}
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* "已保存"toast 在内容区右上角;right:54 避开 28×28 关闭按钮 + 12px gap。
|
||||
CredentialField 等通过 emitSaved 发事件,useSavedToastListener 接收。 */}
|
||||
<SavedToast
|
||||
saveState={savedToast.state}
|
||||
message={savedToast.message}
|
||||
offsetStyle={{ top: 16, right: 54 }}
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// savedEvent.ts — 跨组件的"已保存 / 失败"统一事件通道。
|
||||
//
|
||||
// 触发:任意组件保存成功 / 失败时调用 emitSaved(...)。
|
||||
// 监听:根容器(Settings / Translation / SelectionAsk)通过 useSavedToastListener 订阅,
|
||||
// 状态喂给 <SavedToast>,pill 浮在右上角。
|
||||
//
|
||||
// 用 DOM CustomEvent(而不是 React Context)是为了让 CredentialField / ProviderTools
|
||||
// 这类深层叶子组件不必沿 props 链传 dispatcher,跟 NAVIGATE_LOCAL_ASR_EVENT 同惯例。
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const SAVED_TOAST_EVENT = 'openless:saved-toast';
|
||||
|
||||
export type SavedToastEventState = 'saving' | 'saved' | 'failed';
|
||||
|
||||
export interface SavedToastDetail {
|
||||
state: SavedToastEventState;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function emitSaved(state: SavedToastEventState, message: string): void {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<SavedToastDetail>(SAVED_TOAST_EVENT, { detail: { state, message } }),
|
||||
);
|
||||
}
|
||||
|
||||
interface ToastSnapshot {
|
||||
state: 'idle' | SavedToastEventState;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const IDLE_SNAPSHOT: ToastSnapshot = { state: 'idle', message: '' };
|
||||
|
||||
/**
|
||||
* 订阅 saved-toast 事件,自动管理"非 saving 状态 1.6s 后回 idle"逻辑。
|
||||
* saving 状态保持显示直到下一条事件覆盖(避免长任务里 saving 中途消失)。
|
||||
*/
|
||||
export function useSavedToastListener(): ToastSnapshot {
|
||||
const [snapshot, setSnapshot] = useState<ToastSnapshot>(IDLE_SNAPSHOT);
|
||||
useEffect(() => {
|
||||
let timer: number | null = null;
|
||||
const handle = (event: Event) => {
|
||||
const detail = (event as CustomEvent<SavedToastDetail>).detail;
|
||||
if (!detail) return;
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
setSnapshot({ state: detail.state, message: detail.message });
|
||||
if (detail.state !== 'saving') {
|
||||
timer = window.setTimeout(() => {
|
||||
setSnapshot(IDLE_SNAPSHOT);
|
||||
timer = null;
|
||||
}, 1600);
|
||||
}
|
||||
};
|
||||
window.addEventListener(SAVED_TOAST_EVENT, handle);
|
||||
return () => {
|
||||
window.removeEventListener(SAVED_TOAST_EVENT, handle);
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
return snapshot;
|
||||
}
|
||||
@@ -124,34 +124,7 @@ export function Overview({ onOpenHistory }: OverviewProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
kicker={t('overview.kicker')}
|
||||
title={t('overview.title')}
|
||||
desc={t('overview.desc')}
|
||||
right={
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||
padding: '6px 12px',
|
||||
borderRadius: 999,
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
background: 'var(--ol-surface-2)',
|
||||
color: 'var(--ol-ink-3)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<Icon name="cmd" size={12} />
|
||||
{t('overview.pressPrefix')}
|
||||
<kbd style={{
|
||||
padding: '2px 7px', fontSize: 11, fontFamily: 'var(--ol-font-mono)',
|
||||
background: '#fff', borderRadius: 5,
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
color: 'var(--ol-ink)',
|
||||
}}>{prefs ? formatComboLabel(prefs.dictationHotkey) : ''}</kbd>
|
||||
{t('overview.pressSuffix')}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<PageHeader title={t('overview.title')} />
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 18 }}>
|
||||
<ProviderCard
|
||||
|
||||
@@ -8,11 +8,10 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, PageHeader } from './_atoms';
|
||||
import { SavedToast } from '../components/SavedToast';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
import { setQaHotkey } from '../lib/ipc';
|
||||
import { defaultQaShortcut, formatComboLabel } from '../lib/hotkey';
|
||||
import { ShortcutRecorder } from '../components/ShortcutRecorder';
|
||||
import type { QaHotkeyBinding, UserPreferences } from '../lib/types';
|
||||
import type { UserPreferences } from '../lib/types';
|
||||
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'failed';
|
||||
|
||||
@@ -22,8 +21,7 @@ export function SelectionAsk() {
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||
const [saveMessage, setSaveMessage] = useState('');
|
||||
const statusTimer = useRef<number | null>(null);
|
||||
const defaultQaHotkey = defaultQaShortcut();
|
||||
const defaultHotkeyLabel = formatComboLabel(defaultQaHotkey);
|
||||
const defaultHotkeyLabel = formatComboLabel(defaultQaShortcut());
|
||||
const recordHotkeyLabel = prefs ? formatComboLabel(prefs.dictationHotkey) : '快捷键';
|
||||
|
||||
useEffect(() => () => {
|
||||
@@ -64,35 +62,10 @@ export function SelectionAsk() {
|
||||
}
|
||||
};
|
||||
|
||||
const saveQaHotkey = async (nextHotkey: QaHotkeyBinding | null) => {
|
||||
showSaveStatus('saving', t('common.saving'));
|
||||
try {
|
||||
await setQaHotkey(nextHotkey);
|
||||
} catch (error) {
|
||||
console.error('[selection-ask] failed to register QA hotkey', error);
|
||||
showSaveStatus('failed', t('selectionAsk.save.hotkeyRegisterFailed'));
|
||||
await refresh().catch(refreshError => {
|
||||
console.warn('[selection-ask] failed to refresh preferences after hotkey error', refreshError);
|
||||
});
|
||||
return;
|
||||
}
|
||||
await persistPrefs(
|
||||
current => ({ ...current, qaHotkey: nextHotkey }),
|
||||
t('selectionAsk.save.hotkeySaveFailed'),
|
||||
);
|
||||
};
|
||||
|
||||
if (!prefs) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
kicker={t('selectionAsk.kicker')}
|
||||
title={t('selectionAsk.title')}
|
||||
desc={t('selectionAsk.desc', {
|
||||
hotkey: defaultHotkeyLabel,
|
||||
recordHotkey: recordHotkeyLabel,
|
||||
})}
|
||||
/>
|
||||
<PageHeader title={t('selectionAsk.title')} />
|
||||
<Card>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
|
||||
</Card>
|
||||
@@ -114,72 +87,61 @@ export function SelectionAsk() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
kicker={t('selectionAsk.kicker')}
|
||||
title={t('selectionAsk.title')}
|
||||
desc={t('selectionAsk.desc', {
|
||||
hotkey: enabled ? currentLabel : defaultHotkeyLabel,
|
||||
recordHotkey: recordHotkeyLabel,
|
||||
})}
|
||||
/>
|
||||
<PageHeader title={t('selectionAsk.title')} />
|
||||
|
||||
<SavedToast saveState={saveState} message={saveMessage} />
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{saveState !== 'idle' && (
|
||||
<div
|
||||
role={saveState === 'failed' ? 'alert' : 'status'}
|
||||
{/* 1. 历史保存 — 小型自适宽度模块,避免在宽页面里拉成一条长横条 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignSelf: 'flex-start',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '8px 14px',
|
||||
borderRadius: 10,
|
||||
background: 'rgba(0,0,0,0.04)',
|
||||
border: '0.5px solid var(--ol-line)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--ol-ink-2)' }}>
|
||||
{t('selectionAsk.history.title')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onSaveHistoryChange(!prefs.qaSaveHistory)}
|
||||
aria-pressed={prefs.qaSaveHistory}
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 10,
|
||||
border: saveState === 'failed'
|
||||
? '0.5px solid rgba(239,68,68,0.22)'
|
||||
: '0.5px solid rgba(37,99,235,0.16)',
|
||||
background: saveState === 'failed' ? 'rgba(239,68,68,0.07)' : 'rgba(37,99,235,0.06)',
|
||||
color: saveState === 'failed' ? 'var(--ol-red, #ef4444)' : 'var(--ol-blue)',
|
||||
fontSize: 11.5,
|
||||
lineHeight: 1.5,
|
||||
position: 'relative',
|
||||
width: 36,
|
||||
height: 20,
|
||||
borderRadius: 999,
|
||||
border: 0,
|
||||
background: prefs.qaSaveHistory ? 'var(--ol-blue)' : 'rgba(0,0,0,0.18)',
|
||||
cursor: saving ? 'not-allowed' : 'default',
|
||||
opacity: saving ? 0.68 : 1,
|
||||
transition: 'background 0.16s var(--ol-motion-quick)',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{saveMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 1. 触发快捷键 */}
|
||||
<Card>
|
||||
<CardHeaderToggle
|
||||
title={t('selectionAsk.hotkey.title')}
|
||||
checked={enabled}
|
||||
disabled={saving}
|
||||
onToggle={() => {
|
||||
const nextHotkey = enabled ? null : defaultQaHotkey;
|
||||
void saveQaHotkey(nextHotkey);
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: prefs.qaHotkey ? 12 : 0, lineHeight: 1.55 }}>
|
||||
{t('selectionAsk.hotkey.desc', { recordHotkey: recordHotkeyLabel })}
|
||||
</div>
|
||||
{prefs.qaHotkey && (
|
||||
<ShortcutRecorder
|
||||
value={prefs.qaHotkey}
|
||||
onSave={saveQaHotkey}
|
||||
disabled={saving}
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
left: prefs.qaSaveHistory ? 18 : 2,
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 999,
|
||||
background: '#fff',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,.2)',
|
||||
transition: 'left .16s var(--ol-motion-spring)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 2. 历史保存 */}
|
||||
<Card>
|
||||
<CardHeaderToggle
|
||||
title={t('selectionAsk.history.title')}
|
||||
checked={prefs.qaSaveHistory}
|
||||
disabled={saving}
|
||||
onToggle={() => onSaveHistoryChange(!prefs.qaSaveHistory)}
|
||||
/>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.55 }}>
|
||||
{t('selectionAsk.history.desc')}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 3. 使用方法 */}
|
||||
{/* 2. 使用方法 */}
|
||||
<Card>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 10 }}>{t('selectionAsk.howto.title')}</div>
|
||||
<ol style={{ margin: 0, paddingLeft: 18, fontSize: 12.5, color: 'var(--ol-ink-2)', lineHeight: 1.7 }}>
|
||||
@@ -189,88 +151,8 @@ export function SelectionAsk() {
|
||||
<li>{t('selectionAsk.howto.step4', { recordHotkey: recordHotkeyLabel })}</li>
|
||||
<li>{t('selectionAsk.howto.step5', { hotkey: enabled ? currentLabel : defaultHotkeyLabel })}</li>
|
||||
</ol>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 10,
|
||||
background: 'rgba(37,99,235,0.06)',
|
||||
border: '0.5px solid rgba(37,99,235,0.15)',
|
||||
fontSize: 11.5,
|
||||
color: 'var(--ol-ink-2)',
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, color: 'var(--ol-blue)', marginBottom: 4 }}>{t('selectionAsk.howto.windowTitle')}</div>
|
||||
{t('selectionAsk.howto.windowDesc')}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 10,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 10,
|
||||
background: 'rgba(0,0,0,0.04)',
|
||||
fontSize: 11.5,
|
||||
color: 'var(--ol-ink-3)',
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, color: 'var(--ol-ink-2)', marginBottom: 4 }}>{t('selectionAsk.howto.privacyTitle')}</div>
|
||||
{t('selectionAsk.howto.privacyDesc')}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
// 卡片标题行右侧开关:与 Style 页面顶栏的 36×20 toggle 同款,保持全局视觉一致。
|
||||
function CardHeaderToggle({
|
||||
title,
|
||||
checked,
|
||||
disabled = false,
|
||||
onToggle,
|
||||
}: {
|
||||
title: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{title}</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
aria-pressed={checked}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 36,
|
||||
height: 20,
|
||||
borderRadius: 999,
|
||||
border: 0,
|
||||
background: checked ? 'var(--ol-blue)' : 'rgba(0,0,0,0.15)',
|
||||
cursor: disabled ? 'not-allowed' : 'default',
|
||||
opacity: disabled ? 0.68 : 1,
|
||||
transition: 'background 0.16s var(--ol-motion-quick)',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
left: checked ? 18 : 2,
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 999,
|
||||
background: '#fff',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,.2)',
|
||||
transition: 'left .16s var(--ol-motion-spring)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// Internal sub-sections (Recording / Providers / Shortcuts / Permissions / Language / About)
|
||||
// keep their inline-style literals 1:1 with the source JSX.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { ShortcutRecorder } from '../components/ShortcutRecorder';
|
||||
@@ -52,6 +52,7 @@ import type {
|
||||
PermissionStatus,
|
||||
WindowsImeStatus,
|
||||
} from '../lib/types';
|
||||
import { emitSaved } from '../lib/savedEvent';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
import i18n, {
|
||||
FOLLOW_SYSTEM,
|
||||
@@ -105,6 +106,17 @@ export function Settings({ embedded = false, initialSection = 'recording' }: Set
|
||||
setSection(initialSection);
|
||||
}, [initialSection]);
|
||||
|
||||
// 跟 sidebar / SettingsModal 同款滑动 pill:测当前 active section 的 offsetTop/height
|
||||
// → 用 absolute pill 平滑滑过去;--ol-motion-spring 是项目里的 Apple 风格 ease-out-quint。
|
||||
const sectionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const idx = SECTION_ORDER.indexOf(section);
|
||||
const el = sectionRefs.current[idx];
|
||||
if (!el) return;
|
||||
setPillRect({ top: el.offsetTop, height: el.offsetHeight });
|
||||
}, [section]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!embedded && (
|
||||
@@ -126,23 +138,47 @@ export function Settings({ embedded = false, initialSection = 'recording' }: Set
|
||||
...(embedded ? { flex: 1, minHeight: 0, gridTemplateRows: 'minmax(0, 1fr)' } : {}),
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{SECTION_ORDER.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSection(s)}
|
||||
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{pillRect && (
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
padding: '8px 12px', textAlign: 'left',
|
||||
fontSize: 13, color: section === s ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
|
||||
background: section === s ? 'rgba(0,0,0,0.04)' : 'transparent',
|
||||
border: 0, borderRadius: 8, fontFamily: 'inherit', fontWeight: section === s ? 600 : 500,
|
||||
cursor: 'default',
|
||||
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: pillRect.top,
|
||||
height: pillRect.height,
|
||||
background: 'rgba(0,0,0,0.04)',
|
||||
borderRadius: 8,
|
||||
transition: 'top 0.36s var(--ol-motion-spring), height 0.36s var(--ol-motion-spring)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
{t(`settings.sections.${s}`)}
|
||||
</button>
|
||||
))}
|
||||
/>
|
||||
)}
|
||||
{SECTION_ORDER.map((s, i) => {
|
||||
const active = section === s;
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
ref={el => { sectionRefs.current[i] = el; }}
|
||||
onClick={() => setSection(s)}
|
||||
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
|
||||
style={{
|
||||
padding: '8px 12px', textAlign: 'left',
|
||||
fontSize: 13,
|
||||
background: 'transparent',
|
||||
border: 0, borderRadius: 8, fontFamily: 'inherit',
|
||||
cursor: 'default',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
|
||||
}}
|
||||
>
|
||||
{t(`settings.sections.${s}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className={embedded ? 'ol-thinscroll' : undefined}
|
||||
@@ -1532,7 +1568,21 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 改造:除 readError(持续错误,留在输入旁标识字段不可用)外,所有 saving / saved /
|
||||
// saveError / copied / copyError 一律发到右上角 SavedToast。原内联文案太挤、跟其它
|
||||
// 页面 toast 风格不统一。
|
||||
const showTemporaryStatus = (next: CredentialFieldStatus) => {
|
||||
if (next === 'saving') {
|
||||
emitSaved('saving', t('common.saving'));
|
||||
} else if (next === 'saved') {
|
||||
emitSaved('saved', t('common.saved'));
|
||||
} else if (next === 'saveError') {
|
||||
emitSaved('failed', t('common.operationFailed'));
|
||||
} else if (next === 'copied') {
|
||||
emitSaved('saved', t('common.copied'));
|
||||
} else if (next === 'copyError') {
|
||||
emitSaved('failed', t('common.operationFailed'));
|
||||
}
|
||||
setStatus(next);
|
||||
if (statusRef.current) clearTimeout(statusRef.current);
|
||||
statusRef.current = window.setTimeout(() => setStatus('idle'), 1600);
|
||||
@@ -1541,6 +1591,7 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue
|
||||
const save = async (v: string, force = false) => {
|
||||
if (!loaded || (!dirty && !force)) return;
|
||||
setStatus('saving');
|
||||
emitSaved('saving', t('common.saving'));
|
||||
try {
|
||||
await setCredential(account, v);
|
||||
setDirty(false);
|
||||
@@ -1628,23 +1679,18 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue
|
||||
>
|
||||
<Icon name="copy" size={14} />
|
||||
</button>
|
||||
{status !== 'idle' && (
|
||||
{/* readError 是字段无法读取的持续错误,留在原位提示用户该字段不可用;
|
||||
其它瞬态状态(saving / saved / saveError / copied / copyError)都通过
|
||||
emitSaved 发到右上角统一 toast,不再内联占位。 */}
|
||||
{status === 'readError' && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: status.endsWith('Error') ? 'var(--ol-warn)' : 'var(--ol-ok)',
|
||||
color: 'var(--ol-warn)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{status === 'saving'
|
||||
? t('common.saving')
|
||||
: status === 'saved'
|
||||
? t('common.saved')
|
||||
: status === 'copied'
|
||||
? t('common.copied')
|
||||
: status === 'readError'
|
||||
? t('settings.providers.readFailed')
|
||||
: t('common.operationFailed')}
|
||||
{t('settings.providers.readFailed')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,12 +7,11 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, PageHeader } from './_atoms';
|
||||
import { SavedToast } from '../components/SavedToast';
|
||||
import { SUPPORTED_LANGUAGES } from '../lib/types';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
import { formatComboLabel } from '../lib/hotkey';
|
||||
import { ShortcutRecorder } from '../components/ShortcutRecorder';
|
||||
import { setTranslationHotkey } from '../lib/ipc';
|
||||
import type { UserPreferences, ShortcutBinding } from '../lib/types';
|
||||
import type { UserPreferences } from '../lib/types';
|
||||
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'failed';
|
||||
|
||||
@@ -63,11 +62,7 @@ export function Translation() {
|
||||
if (!prefs) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
kicker={t('translation.kicker')}
|
||||
title={t('translation.title')}
|
||||
desc={t('translation.desc')}
|
||||
/>
|
||||
<PageHeader title={t('translation.title')} />
|
||||
<Card>
|
||||
{error ? (
|
||||
<div role="alert" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
@@ -120,20 +115,6 @@ export function Translation() {
|
||||
t('translation.save.targetFailed'),
|
||||
);
|
||||
};
|
||||
const onTranslationHotkeySave = async (binding: ShortcutBinding) => {
|
||||
showSaveStatus('saving', t('common.saving'));
|
||||
try {
|
||||
await setTranslationHotkey(binding);
|
||||
} catch (error) {
|
||||
console.error('[translation] failed to register translation hotkey', error);
|
||||
showSaveStatus('failed', t('translation.save.hotkeyRegisterFailed'));
|
||||
return;
|
||||
}
|
||||
await persistPrefs(
|
||||
current => ({ ...current, translationHotkey: binding }),
|
||||
t('translation.save.hotkeySaveFailed'),
|
||||
);
|
||||
};
|
||||
|
||||
const triggerLabel = formatComboLabel(prefs.dictationHotkey);
|
||||
const translationHotkeyLabel = formatComboLabel(prefs.translationHotkey);
|
||||
@@ -141,11 +122,7 @@ export function Translation() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
kicker={t('translation.kicker')}
|
||||
title={t('translation.title')}
|
||||
desc={t('translation.desc')}
|
||||
/>
|
||||
<PageHeader title={t('translation.title')} />
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{error && (
|
||||
@@ -188,31 +165,11 @@ export function Translation() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveState !== 'idle' && (
|
||||
<div
|
||||
role={saveState === 'failed' ? 'alert' : 'status'}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 10,
|
||||
border: saveState === 'failed'
|
||||
? '0.5px solid rgba(239,68,68,0.22)'
|
||||
: '0.5px solid rgba(37,99,235,0.16)',
|
||||
background: saveState === 'failed' ? 'rgba(239,68,68,0.07)' : 'rgba(37,99,235,0.06)',
|
||||
color: saveState === 'failed' ? 'var(--ol-red, #ef4444)' : 'var(--ol-blue)',
|
||||
fontSize: 11.5,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{saveMessage}
|
||||
</div>
|
||||
)}
|
||||
<SavedToast saveState={saveState} message={saveMessage} />
|
||||
|
||||
{/* 1. 工作语言 */}
|
||||
<Card>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{t('translation.working.title')}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 12, lineHeight: 1.55 }}>
|
||||
{t('translation.working.desc')}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 12 }}>{t('translation.working.title')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{SUPPORTED_LANGUAGES.map(lang => {
|
||||
const checked = prefs.workingLanguages.includes(lang);
|
||||
@@ -242,7 +199,7 @@ export function Translation() {
|
||||
|
||||
{/* 2. 翻译目标语言 */}
|
||||
<Card>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{t('translation.target.title')}</div>
|
||||
<span
|
||||
style={{
|
||||
@@ -259,9 +216,6 @@ export function Translation() {
|
||||
{enabled ? t('translation.statusEnabled') : t('translation.statusDisabled')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 12, lineHeight: 1.55 }}>
|
||||
{t('translation.target.desc')}
|
||||
</div>
|
||||
<select
|
||||
value={prefs.translationTargetLanguage}
|
||||
onChange={e => onTargetChange(e.target.value)}
|
||||
@@ -286,17 +240,6 @@ export function Translation() {
|
||||
</select>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{t('translation.hotkey.title', 'Translation shortcut')}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 12, lineHeight: 1.55 }}>
|
||||
{t('translation.hotkey.desc', 'Press this during recording to switch the current dictation into translation mode.')}
|
||||
</div>
|
||||
<ShortcutRecorder
|
||||
value={prefs.translationHotkey}
|
||||
onSave={onTranslationHotkeySave}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 3. 使用方法 */}
|
||||
<Card>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 10 }}>{t('translation.howto.title')}</div>
|
||||
@@ -307,37 +250,6 @@ export function Translation() {
|
||||
<li>{t('translation.howto.step4')}</li>
|
||||
<li>{t('translation.howto.step5')}</li>
|
||||
</ol>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 10,
|
||||
background: 'rgba(37,99,235,0.06)',
|
||||
border: '0.5px solid rgba(37,99,235,0.15)',
|
||||
fontSize: 11.5,
|
||||
color: 'var(--ol-ink-2)',
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, color: 'var(--ol-blue)', marginBottom: 4 }}>{t('translation.howto.indicatorTitle')}</div>
|
||||
{t('translation.howto.indicatorDesc')}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 10,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 10,
|
||||
background: 'rgba(0,0,0,0.04)',
|
||||
fontSize: 11.5,
|
||||
color: 'var(--ol-ink-3)',
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, color: 'var(--ol-ink-2)', marginBottom: 4 }}>{t('translation.howto.fallbackTitle')}</div>
|
||||
{t('translation.howto.fallbackDesc')}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in new issue
Block a user