fix(windows): 补齐运行时反馈和平台化交互
This commit is contained in:
1 parent
a8843802ec
commit
e01552d2fc
15 files changed
+264
-49
No files matched your search
@@ -8,7 +8,8 @@ use crate::coordinator::Coordinator;
|
||||
use crate::permissions::{self, PermissionStatus};
|
||||
use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVault};
|
||||
use crate::types::{
|
||||
CredentialsStatus, DictationSession, DictionaryEntry, PolishMode, UserPreferences,
|
||||
CredentialsStatus, DictationSession, DictionaryEntry, HotkeyStatus, PolishMode,
|
||||
UserPreferences,
|
||||
};
|
||||
|
||||
type CoordinatorState<'a> = State<'a, Arc<Coordinator>>;
|
||||
@@ -27,6 +28,11 @@ pub fn set_settings(coord: CoordinatorState<'_>, prefs: UserPreferences) -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_hotkey_status(coord: CoordinatorState<'_>) -> HotkeyStatus {
|
||||
coord.hotkey_status()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_credentials() -> CredentialsStatus {
|
||||
let snap = CredentialsVault::snapshot();
|
||||
@@ -221,8 +227,17 @@ pub fn open_system_settings(pane: String) -> Result<(), String> {
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = pane;
|
||||
Ok(())
|
||||
let uri = match pane.as_str() {
|
||||
"microphone" => "ms-settings:privacy-microphone",
|
||||
"sound" => "ms-settings:sound",
|
||||
"accessibility" => "ms-settings:easeofaccess",
|
||||
_ => "ms-settings:",
|
||||
};
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/C", "start", "", uri])
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ use crate::persistence::{
|
||||
use crate::polish::{OpenAICompatibleConfig, OpenAICompatibleLLMProvider};
|
||||
use crate::recorder::Recorder;
|
||||
use crate::types::{
|
||||
CapsulePayload, CapsuleState, DictationSession, HotkeyMode, InsertStatus, PolishMode,
|
||||
UserPreferences,
|
||||
CapsulePayload, CapsuleState, DictationSession, HotkeyMode, HotkeyStatus,
|
||||
HotkeyStatusState, InsertStatus, PolishMode,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -63,6 +63,7 @@ struct Inner {
|
||||
asr: Mutex<Option<Arc<VolcengineStreamingASR>>>,
|
||||
recorder: Mutex<Option<Recorder>>,
|
||||
hotkey: Mutex<Option<HotkeyMonitor>>,
|
||||
hotkey_status: Mutex<HotkeyStatus>,
|
||||
}
|
||||
|
||||
impl Coordinator {
|
||||
@@ -85,6 +86,7 @@ impl Coordinator {
|
||||
asr: Mutex::new(None),
|
||||
recorder: Mutex::new(None),
|
||||
hotkey: Mutex::new(None),
|
||||
hotkey_status: Mutex::new(HotkeyStatus::default()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -119,6 +121,10 @@ impl Coordinator {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hotkey_status(&self) -> HotkeyStatus {
|
||||
self.inner.hotkey_status.lock().clone()
|
||||
}
|
||||
|
||||
pub async fn start_dictation(&self) -> Result<(), String> {
|
||||
begin_session(&self.inner).await
|
||||
}
|
||||
@@ -147,11 +153,22 @@ fn hotkey_supervisor_loop(inner: Arc<Inner>) {
|
||||
if inner.hotkey.lock().is_some() {
|
||||
return;
|
||||
}
|
||||
*inner.hotkey_status.lock() = HotkeyStatus {
|
||||
state: HotkeyStatusState::Starting,
|
||||
message: Some(format!(
|
||||
"正在安装全局快捷键监听(第 {} 次)",
|
||||
attempts + 1
|
||||
)),
|
||||
};
|
||||
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
|
||||
let binding = inner.prefs.get().hotkey;
|
||||
match HotkeyMonitor::start(binding, tx) {
|
||||
Ok(monitor) => {
|
||||
*inner.hotkey.lock() = Some(monitor);
|
||||
*inner.hotkey_status.lock() = HotkeyStatus {
|
||||
state: HotkeyStatusState::Installed,
|
||||
message: None,
|
||||
};
|
||||
log::info!(
|
||||
"[coord] hotkey listener installed (after {} attempt(s))",
|
||||
attempts + 1
|
||||
@@ -165,6 +182,10 @@ fn hotkey_supervisor_loop(inner: Arc<Inner>) {
|
||||
}
|
||||
Err(e) => {
|
||||
attempts += 1;
|
||||
*inner.hotkey_status.lock() = HotkeyStatus {
|
||||
state: HotkeyStatusState::Failed,
|
||||
message: Some(e.to_string()),
|
||||
};
|
||||
if attempts <= 3 || attempts % 10 == 0 {
|
||||
log::warn!(
|
||||
"[coord] hotkey listener attempt #{attempts} failed: {e}; retrying in 3s"
|
||||
@@ -392,12 +413,22 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
log::error!("[coord] history append failed: {e}");
|
||||
}
|
||||
|
||||
let done_message = match status {
|
||||
InsertStatus::Inserted => None,
|
||||
InsertStatus::CopiedFallback => Some(if cfg!(target_os = "windows") {
|
||||
"已复制,请 Ctrl+V".to_string()
|
||||
} else {
|
||||
"已复制,请粘贴".to_string()
|
||||
}),
|
||||
InsertStatus::Failed => Some("插入失败".to_string()),
|
||||
};
|
||||
|
||||
emit_capsule(
|
||||
inner,
|
||||
CapsuleState::Done,
|
||||
0.0,
|
||||
elapsed,
|
||||
None,
|
||||
done_message,
|
||||
Some(inserted_chars),
|
||||
);
|
||||
|
||||
|
||||
@@ -305,9 +305,10 @@ mod platform {
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
mod platform {
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use rdev::{listen, Event, EventType, Key};
|
||||
|
||||
@@ -319,13 +320,25 @@ mod platform {
|
||||
tx: Sender<HotkeyEvent>,
|
||||
status_tx: std::sync::mpsc::Sender<bool>,
|
||||
) {
|
||||
// rdev 没有"安装即可知"的 API;我们乐观汇报成功,Linux/Win 上一般直接生效。
|
||||
let _ = status_tx.send(true);
|
||||
// rdev 没有"安装即可知"的 API。给 listen 一个短窗口:
|
||||
// 如果 hook 立即失败,向 supervisor 汇报失败;否则视为已进入监听循环。
|
||||
let status_sent = Arc::new(AtomicBool::new(false));
|
||||
let ready_status_sent = Arc::clone(&status_sent);
|
||||
let ready_status_tx = status_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(350));
|
||||
if !ready_status_sent.swap(true, Ordering::SeqCst) {
|
||||
let _ = ready_status_tx.send(true);
|
||||
}
|
||||
});
|
||||
let cb_shared = Arc::clone(&shared);
|
||||
let result = listen(move |event: Event| {
|
||||
dispatch_event(&cb_shared, &tx, event);
|
||||
});
|
||||
if let Err(err) = result {
|
||||
if !status_sent.swap(true, Ordering::SeqCst) {
|
||||
let _ = status_tx.send(false);
|
||||
}
|
||||
log::error!("[hotkey] rdev::listen 启动失败: {:?}", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,11 @@ impl TextInserter {
|
||||
if !copy_to_clipboard(text) {
|
||||
return InsertStatus::Failed;
|
||||
}
|
||||
match simulate_paste() {
|
||||
Ok(()) => InsertStatus::Inserted,
|
||||
Err(err) => {
|
||||
log::warn!("[insertion] simulated paste failed: {}", err);
|
||||
InsertStatus::CopiedFallback
|
||||
}
|
||||
if let Err(err) = simulate_paste() {
|
||||
log::warn!("[insertion] simulated paste failed: {}", err);
|
||||
return InsertStatus::CopiedFallback;
|
||||
}
|
||||
insertion_success_status()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +76,17 @@ fn simulate_paste() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn insertion_success_status() -> InsertStatus {
|
||||
InsertStatus::Inserted
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn insertion_success_status() -> InsertStatus {
|
||||
// Windows/Linux 的 Ctrl+V 只能证明粘贴快捷键已发送,不能证明目标控件已接收。
|
||||
InsertStatus::CopiedFallback
|
||||
}
|
||||
|
||||
// ─────────────────────────── macOS native CGEvent paste ───────────────────────────
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
@@ -125,6 +125,7 @@ pub fn run() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_settings,
|
||||
commands::set_settings,
|
||||
commands::get_hotkey_status,
|
||||
commands::get_credentials,
|
||||
commands::set_credential,
|
||||
commands::list_history,
|
||||
|
||||
@@ -248,6 +248,7 @@ mod platform {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
mod platform {
|
||||
use super::PermissionStatus;
|
||||
use cpal::traits::{DeviceTrait, HostTrait};
|
||||
|
||||
/// Windows / Linux 不存在 macOS 那种 Accessibility 概念;rdev 直接监听键盘。
|
||||
pub fn check_accessibility() -> PermissionStatus {
|
||||
@@ -259,14 +260,35 @@ mod platform {
|
||||
}
|
||||
|
||||
/// Windows 的麦克风权限走系统设置 → 隐私 → 麦克风;
|
||||
/// 我们没法在用户态直接查授权状态,启动 cpal stream 时 Win10+ 会自动弹一次提示。
|
||||
/// 这里乐观返回 Granted;UI 上不需要展示 Denied 状态。
|
||||
/// 这里用 cpal 做最小可用性探测,避免 UI 在无设备或权限被拒时误报已授权。
|
||||
pub fn check_microphone() -> PermissionStatus {
|
||||
PermissionStatus::Granted
|
||||
let host = cpal::default_host();
|
||||
let Some(device) = host.default_input_device() else {
|
||||
log::warn!("[mic] no default input device");
|
||||
return PermissionStatus::Denied;
|
||||
};
|
||||
match device.default_input_config() {
|
||||
Ok(_) => PermissionStatus::Granted,
|
||||
Err(err) => classify_audio_probe_error(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request_microphone() -> PermissionStatus {
|
||||
PermissionStatus::Granted
|
||||
check_microphone()
|
||||
}
|
||||
|
||||
fn classify_audio_probe_error(message: String) -> PermissionStatus {
|
||||
let lower = message.to_lowercase();
|
||||
log::warn!("[mic] default input config failed: {message}");
|
||||
if lower.contains("denied")
|
||||
|| lower.contains("permission")
|
||||
|| lower.contains("authoriz")
|
||||
|| lower.contains("access")
|
||||
{
|
||||
PermissionStatus::Denied
|
||||
} else {
|
||||
PermissionStatus::NotDetermined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,30 @@ pub struct HotkeyBinding {
|
||||
pub mode: HotkeyMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HotkeyStatus {
|
||||
pub state: HotkeyStatusState,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum HotkeyStatusState {
|
||||
Starting,
|
||||
Installed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl Default for HotkeyStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: HotkeyStatusState::Starting,
|
||||
message: Some("正在安装全局快捷键监听".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HotkeyBinding {
|
||||
fn default() -> Self {
|
||||
// Right Option (mac) / Right Alt (win) — toggle by default per design.
|
||||
|
||||
@@ -188,7 +188,7 @@ function Pill({ state, level, insertedChars, message, onCancel, onConfirm }: Pil
|
||||
);
|
||||
break;
|
||||
case 'done':
|
||||
center = <CenterText text={`已插入 ${insertedChars}`} />;
|
||||
center = <CenterText text={message || `已插入 ${insertedChars}`} />;
|
||||
break;
|
||||
case 'cancelled':
|
||||
center = <CenterText text="已取消" />;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { History } from '../pages/History';
|
||||
import { Vocab } from '../pages/Vocab';
|
||||
import { Style } from '../pages/Style';
|
||||
import { APP_VERSION_LABEL } from '../lib/appVersion';
|
||||
import { getCredentials } from '../lib/ipc';
|
||||
import { getCredentials, openExternal } from '../lib/ipc';
|
||||
import { OL_DATA } from '../lib/mockData';
|
||||
import {
|
||||
PROVIDER_SETUP_PROMPT_SEEN_KEY,
|
||||
@@ -56,6 +56,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionId | undefined>();
|
||||
const [providerPromptOpen, setProviderPromptOpen] = useState(false);
|
||||
const Page = (NAV.find((n) => n.id === currentTab) ?? NAV[0]).cmp;
|
||||
const hotkeyLabel = os === 'win' ? '右 Alt' : '右 Option';
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -176,7 +177,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
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)',
|
||||
}}>右 Option</kbd>
|
||||
}}>{hotkeyLabel}</kbd>
|
||||
<span style={{ color: 'var(--ol-ink-4)' }}>开始 / 停止</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,7 +212,11 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '24px 28px 32px', flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||
<Page />
|
||||
{currentTab === 'overview' ? (
|
||||
<Overview onOpenHistory={() => setCurrentTab('history')} />
|
||||
) : (
|
||||
<Page />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -231,15 +236,31 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
zIndex: 2,
|
||||
}}>
|
||||
|
||||
<FooterIcon name="user" tip="账户" />
|
||||
<FooterIcon name="mail" tip="反馈" />
|
||||
<FooterIcon name="user" tip="账户" onClick={() => openSettings('提供商')} />
|
||||
<FooterIcon name="mail" tip="反馈" onClick={() => openExternal('https://github.com/appergb/openless/issues')} />
|
||||
<FooterIcon name="settings" tip="设置" active={settingsOpen} onClick={() => openSettings()} />
|
||||
<FooterIcon name="help" tip="帮助" />
|
||||
<FooterIcon name="help" tip="帮助" onClick={() => openExternal('https://github.com/appergb/openless#readme')} />
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<span style={{ fontFamily: 'var(--ol-font-sans)' }}>版本 {APP_VERSION_LABEL}</span>
|
||||
<a style={{ color: 'var(--ol-blue)', marginLeft: 8, textDecoration: 'none', fontWeight: 500 }}>检查更新</a>
|
||||
<button
|
||||
onClick={() => openExternal('https://github.com/appergb/openless/releases')}
|
||||
style={{
|
||||
color: 'var(--ol-blue)',
|
||||
marginLeft: 8,
|
||||
textDecoration: 'none',
|
||||
fontWeight: 500,
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
cursor: 'default',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
检查更新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Settings modal — rendered inside this window */}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
requestMicrophonePermission,
|
||||
} from '../lib/ipc';
|
||||
import type { PermissionStatus } from '../lib/types';
|
||||
import { detectOS } from './WindowChrome';
|
||||
|
||||
interface OnboardingProps {
|
||||
onComplete: () => void;
|
||||
@@ -21,6 +22,7 @@ export function Onboarding({ onComplete }: OnboardingProps) {
|
||||
const [accessibility, setAccessibility] = useState<PermissionStatus>('notDetermined');
|
||||
const [microphone, setMicrophone] = useState<PermissionStatus>('notDetermined');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const os = detectOS();
|
||||
|
||||
const refresh = async () => {
|
||||
const [a, m] = await Promise.all([
|
||||
@@ -122,19 +124,23 @@ export function Onboarding({ onComplete }: OnboardingProps) {
|
||||
|
||||
<PermissionStep
|
||||
index={1}
|
||||
title="辅助功能"
|
||||
desc="用于监听全局快捷键(默认 右 Option)并把识别结果写入光标位置。"
|
||||
title={os === 'win' ? '全局快捷键' : '辅助功能'}
|
||||
desc={os === 'win'
|
||||
? 'Windows 不需要 macOS 辅助功能权限;这里用于确认全局快捷键监听可用。'
|
||||
: '用于监听全局快捷键(默认 右 Option)并把识别结果写入光标位置。'}
|
||||
status={accessibility}
|
||||
actionLabel={
|
||||
accessibility === 'granted'
|
||||
accessibility === 'notApplicable'
|
||||
? '无需授权'
|
||||
: accessibility === 'granted'
|
||||
? '已授权'
|
||||
: accessibility === 'denied'
|
||||
? '打开系统设置'
|
||||
: '授权'
|
||||
}
|
||||
onAction={onGrantAccessibility}
|
||||
disabled={busy || accessibility === 'granted'}
|
||||
hint="授权后必须**完全退出 OpenLess** 再重新打开(macOS TCC 规则)。"
|
||||
disabled={busy || accessibility === 'granted' || accessibility === 'notApplicable'}
|
||||
hint={os === 'mac' ? '授权后必须**完全退出 OpenLess** 再重新打开(macOS TCC 规则)。' : undefined}
|
||||
/>
|
||||
|
||||
<PermissionStep
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
CredentialsStatus,
|
||||
DictationSession,
|
||||
DictionaryEntry,
|
||||
HotkeyStatus,
|
||||
PermissionStatus,
|
||||
PolishMode,
|
||||
UserPreferences,
|
||||
@@ -48,6 +49,11 @@ const mockCredentialsStatus: CredentialsStatus = {
|
||||
arkConfigured: true,
|
||||
};
|
||||
|
||||
const mockHotkeyStatus: HotkeyStatus = {
|
||||
state: 'installed',
|
||||
message: null,
|
||||
};
|
||||
|
||||
const mockHistory: DictationSession[] = OL_DATA.history.map((h, i) => ({
|
||||
id: `mock-${i}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -80,6 +86,10 @@ export function setSettings(prefs: UserPreferences): Promise<void> {
|
||||
return invokeOrMock('set_settings', { prefs }, () => undefined);
|
||||
}
|
||||
|
||||
export function getHotkeyStatus(): Promise<HotkeyStatus> {
|
||||
return invokeOrMock('get_hotkey_status', undefined, () => mockHotkeyStatus);
|
||||
}
|
||||
|
||||
// ── Credentials ────────────────────────────────────────────────────────
|
||||
export function getCredentials(): Promise<CredentialsStatus> {
|
||||
return invokeOrMock('get_credentials', undefined, () => mockCredentialsStatus);
|
||||
@@ -144,8 +154,8 @@ export function cancelDictation(): Promise<void> {
|
||||
}
|
||||
|
||||
// ── Polish ─────────────────────────────────────────────────────────────
|
||||
export function repolish(id: string, mode: PolishMode): Promise<DictationSession> {
|
||||
return invokeOrMock('repolish', { id, mode }, () => mockHistory[0]);
|
||||
export function repolish(rawText: string, mode: PolishMode): Promise<string> {
|
||||
return invokeOrMock('repolish', { rawText, mode }, () => rawText);
|
||||
}
|
||||
|
||||
export function setDefaultPolishMode(mode: PolishMode): Promise<void> {
|
||||
@@ -181,4 +191,13 @@ export function triggerMicrophonePrompt(): Promise<void> {
|
||||
return invokeOrMock('trigger_microphone_prompt', undefined, () => undefined);
|
||||
}
|
||||
|
||||
export async function openExternal(url: string): Promise<void> {
|
||||
if (!isTauri) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
const { open } = await import('@tauri-apps/plugin-shell');
|
||||
await open(url);
|
||||
}
|
||||
|
||||
export { isTauri };
|
||||
@@ -45,6 +45,13 @@ export interface HotkeyBinding {
|
||||
mode: HotkeyMode;
|
||||
}
|
||||
|
||||
export type HotkeyStatusState = 'starting' | 'installed' | 'failed';
|
||||
|
||||
export interface HotkeyStatus {
|
||||
state: HotkeyStatusState;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
hotkey: HotkeyBinding;
|
||||
defaultMode: PolishMode;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { detectOS } from '../components/WindowChrome';
|
||||
import { clearHistory, deleteHistoryEntry, listHistory } from '../lib/ipc';
|
||||
import type { DictationSession, PolishMode } from '../lib/types';
|
||||
import { Btn, Card, PageHeader, Pill } from './_atoms';
|
||||
@@ -27,6 +28,7 @@ export function History() {
|
||||
const [items, setItems] = useState<DictationSession[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const hotkeyLabel = detectOS() === 'win' ? '右 Alt' : '右 Option';
|
||||
|
||||
const refresh = async () => {
|
||||
const data = await listHistory();
|
||||
@@ -114,7 +116,7 @@ export function History() {
|
||||
{loading && <div style={{ padding: 16, fontSize: 12, color: 'var(--ol-ink-4)' }}>加载中…</div>}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div style={{ padding: 16, fontSize: 12, color: 'var(--ol-ink-4)' }}>
|
||||
还没有历史记录。按 右 Option 录一段试试。
|
||||
还没有历史记录。按 {hotkeyLabel} 录一段试试。
|
||||
</div>
|
||||
)}
|
||||
{filtered.map(s => (
|
||||
@@ -181,7 +183,7 @@ export function History() {
|
||||
{item.dictionaryEntryCount != null && item.dictionaryEntryCount > 0 && (
|
||||
<span>{item.dictionaryEntryCount} 个热词</span>
|
||||
)}
|
||||
<span>{item.insertStatus === 'inserted' ? '已插入' : item.insertStatus === 'copiedFallback' ? '已复制(需 ⌘V)' : '插入失败'}</span>
|
||||
<span>{item.insertStatus === 'inserted' ? '已插入' : item.insertStatus === 'copiedFallback' ? `已复制(需 ${detectOS() === 'win' ? 'Ctrl+V' : '⌘V'})` : '插入失败'}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { detectOS } from '../components/WindowChrome';
|
||||
import { getCredentials, listHistory } from '../lib/ipc';
|
||||
import type { CredentialsStatus, DictationSession, PolishMode } from '../lib/types';
|
||||
import { Btn, Card, PageHeader, Pill } from './_atoms';
|
||||
@@ -13,12 +14,18 @@ const MODE_LABEL: Record<PolishMode, string> = {
|
||||
formal: '正式表达',
|
||||
};
|
||||
|
||||
export function Overview() {
|
||||
interface OverviewProps {
|
||||
onOpenHistory?: () => void;
|
||||
}
|
||||
|
||||
export function Overview({ onOpenHistory }: OverviewProps) {
|
||||
const [history, setHistory] = useState<DictationSession[]>([]);
|
||||
const [creds, setCreds] = useState<CredentialsStatus>({
|
||||
volcengineConfigured: false,
|
||||
arkConfigured: false,
|
||||
});
|
||||
const os = detectOS();
|
||||
const hotkeyLabel = os === 'win' ? '右 Alt' : '右 Option';
|
||||
|
||||
useEffect(() => {
|
||||
listHistory().then(setHistory);
|
||||
@@ -76,7 +83,7 @@ export function Overview() {
|
||||
background: '#fff', borderRadius: 5,
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
color: 'var(--ol-ink)',
|
||||
}}>右 Option</kbd>
|
||||
}}>{hotkeyLabel}</kbd>
|
||||
开始录音
|
||||
</div>
|
||||
}
|
||||
@@ -119,12 +126,12 @@ export function Overview() {
|
||||
<Card padding={0}>
|
||||
<div style={{ padding: '14px 18px', borderBottom: '0.5px solid var(--ol-line)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-2)' }}>最近识别</span>
|
||||
<Btn size="sm" variant="ghost">全部记录 →</Btn>
|
||||
<Btn size="sm" variant="ghost" onClick={onOpenHistory}>全部记录 →</Btn>
|
||||
</div>
|
||||
<div>
|
||||
{history.length === 0 && (
|
||||
<div style={{ padding: 24, textAlign: 'center', fontSize: 12, color: 'var(--ol-ink-4)' }}>
|
||||
还没有记录。按 右 Option 开始第一次录音。
|
||||
还没有记录。按 {hotkeyLabel} 开始第一次录音。
|
||||
</div>
|
||||
)}
|
||||
{history.slice(0, 5).map(s => (
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
|
||||
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { detectOS } from '../components/WindowChrome';
|
||||
import { APP_VERSION_LABEL } from '../lib/appVersion';
|
||||
import {
|
||||
checkAccessibilityPermission,
|
||||
checkMicrophonePermission,
|
||||
getHotkeyStatus,
|
||||
getSettings,
|
||||
openExternal,
|
||||
openSystemSettings,
|
||||
readCredential,
|
||||
requestAccessibilityPermission,
|
||||
@@ -16,7 +19,7 @@ import {
|
||||
setCredential,
|
||||
setSettings,
|
||||
} from '../lib/ipc';
|
||||
import type { HotkeyMode, HotkeyTrigger, PermissionStatus, UserPreferences } from '../lib/types';
|
||||
import type { HotkeyMode, HotkeyStatus, HotkeyTrigger, PermissionStatus, UserPreferences } from '../lib/types';
|
||||
import { Btn, Card, PageHeader, Pill } from './_atoms';
|
||||
|
||||
interface SettingsProps {
|
||||
@@ -101,7 +104,7 @@ const TRIGGER_LABEL: Record<HotkeyTrigger, string> = {
|
||||
rightAlt: '右 Alt',
|
||||
};
|
||||
|
||||
const TRIGGER_OPTIONS: HotkeyTrigger[] = [
|
||||
const MAC_TRIGGER_OPTIONS: HotkeyTrigger[] = [
|
||||
'rightOption',
|
||||
'leftOption',
|
||||
'rightControl',
|
||||
@@ -110,8 +113,17 @@ const TRIGGER_OPTIONS: HotkeyTrigger[] = [
|
||||
'fn',
|
||||
];
|
||||
|
||||
const WIN_TRIGGER_OPTIONS: HotkeyTrigger[] = [
|
||||
'rightAlt',
|
||||
'rightControl',
|
||||
'leftControl',
|
||||
'rightCommand',
|
||||
];
|
||||
|
||||
function RecordingSection() {
|
||||
const [prefs, setPrefs] = useState<UserPreferences | null>(null);
|
||||
const os = detectOS();
|
||||
const triggerOptions = os === 'win' ? WIN_TRIGGER_OPTIONS : MAC_TRIGGER_OPTIONS;
|
||||
|
||||
useEffect(() => {
|
||||
getSettings().then(setPrefs);
|
||||
@@ -156,7 +168,7 @@ function RecordingSection() {
|
||||
fontFamily: 'var(--ol-font-mono)',
|
||||
}}
|
||||
>
|
||||
{TRIGGER_OPTIONS.map(t => (
|
||||
{triggerOptions.map(t => (
|
||||
<option key={t} value={t}>{TRIGGER_LABEL[t]}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -326,12 +338,13 @@ const iconBtnStyle: CSSProperties = {
|
||||
};
|
||||
|
||||
function ShortcutsSection() {
|
||||
const os = detectOS();
|
||||
const rows: Array<[string, string]> = [
|
||||
['开始 / 停止录音', '右 Option'],
|
||||
['开始 / 停止录音', os === 'win' ? '右 Alt' : '右 Option'],
|
||||
['取消本次录音', 'Esc'],
|
||||
['胶囊确认插入', '点击右侧 ✓'],
|
||||
['切换上一次风格', '⌘ ⇧ S'],
|
||||
['打开 OpenLess', '⌘ ⇧ O'],
|
||||
['切换上一次风格', os === 'win' ? '暂未支持' : '⌘ ⇧ S'],
|
||||
['打开 OpenLess', os === 'win' ? '暂未支持' : '⌘ ⇧ O'],
|
||||
];
|
||||
return (
|
||||
<Card>
|
||||
@@ -356,6 +369,7 @@ function ShortcutsSection() {
|
||||
function PermissionsSection() {
|
||||
const [accessibility, setAccessibility] = useState<PermissionStatus | 'loading'>('loading');
|
||||
const [microphone, setMicrophone] = useState<PermissionStatus | 'loading'>('loading');
|
||||
const [hotkey, setHotkey] = useState<HotkeyStatus | null>(null);
|
||||
|
||||
const refresh = async () => {
|
||||
const [a, m] = await Promise.all([
|
||||
@@ -364,6 +378,7 @@ function PermissionsSection() {
|
||||
]);
|
||||
setAccessibility(a);
|
||||
setMicrophone(m);
|
||||
setHotkey(await getHotkeyStatus());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -423,6 +438,16 @@ function PermissionsSection() {
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label="全局快捷键" desc="用于判断 Windows 上快捷键监听是否已经安装。">
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', minWidth: 0 }}>
|
||||
<HotkeyStatusPill status={hotkey} />
|
||||
{hotkey?.message && (
|
||||
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{hotkey.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label="网络" desc="云端 ASR / LLM 调用所必需。本地模式可关闭。">
|
||||
<Pill tone="ok"><Icon name="check" size={11} />可用</Pill>
|
||||
</SettingRow>
|
||||
@@ -463,12 +488,25 @@ function AboutSection() {
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)' }}>自然说话,完美书写 · {APP_VERSION_LABEL}</div>
|
||||
</div>
|
||||
</div>
|
||||
<SettingRow label="检查更新"><Btn variant="ghost" size="sm">检查</Btn></SettingRow>
|
||||
<SettingRow label="文档"><Btn variant="ghost" size="sm" icon="link">openless.app/docs</Btn></SettingRow>
|
||||
<SettingRow label="反馈渠道"><Btn variant="ghost" size="sm" icon="link">GitHub Issues</Btn></SettingRow>
|
||||
<SettingRow label="检查更新"><Btn variant="ghost" size="sm" onClick={() => openExternal('https://github.com/appergb/openless/releases')}>打开 Releases</Btn></SettingRow>
|
||||
<SettingRow label="文档"><Btn variant="ghost" size="sm" icon="link" onClick={() => openExternal('https://github.com/appergb/openless#readme')}>README</Btn></SettingRow>
|
||||
<SettingRow label="反馈渠道"><Btn variant="ghost" size="sm" icon="link" onClick={() => openExternal('https://github.com/appergb/openless/issues')}>GitHub Issues</Btn></SettingRow>
|
||||
<SettingRow label="隐私" desc="所有识别结果仅保存在本机。云端 API 仅用于实时转写与润色,不会保留你的录音。">
|
||||
<Pill tone="default">本地优先</Pill>
|
||||
</SettingRow>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function HotkeyStatusPill({ status }: { status: HotkeyStatus | null }) {
|
||||
if (!status) {
|
||||
return <Pill tone="default">检查中…</Pill>;
|
||||
}
|
||||
if (status.state === 'installed') {
|
||||
return <Pill tone="ok"><Icon name="check" size={11} />已安装</Pill>;
|
||||
}
|
||||
if (status.state === 'starting') {
|
||||
return <Pill tone="default">安装中…</Pill>;
|
||||
}
|
||||
return <Pill tone="outline">监听失败</Pill>;
|
||||
}
|
||||
Reference in new issue
Block a user