From c729fc59e94200b26aa99b961aebe749f6f20677 Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 20 May 2026 20:49:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui+network):=20=E6=9B=B4=E6=96=B0/=E7=99=BB?= =?UTF-8?q?=E5=BD=95/=E7=BD=91=E7=BB=9C=E6=A3=80=E6=B5=8B/=E5=B8=82?= =?UTF-8?q?=E5=9C=BA=E5=8A=A0=E8=BD=BD=E5=9B=9B=E9=A1=B9=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 更新检查:超时从 8s 提至 15s,error 态展示具体错误原因 + 重试按钮 2. GitHub 登录:device flow 两个 HTTP 请求加 15s timeout,防网络差时永远卡住 3. 网络连通性:新增 check_network 命令(HEAD apic.openless.top + github.com), PermissionsSection 从硬编码"可用"改为真实探测 + 30s 轮询 + 离线重试 4. Marketplace:加载失败卡片增加重试按钮 5. i18n:五语言新增 networkOffline / settings.about.retryBtn Co-Authored-By: Claude Opus 4.6 --- openless-all/app/src-tauri/src/commands.rs | 42 ++++++++++++++++++- openless-all/app/src-tauri/src/lib.rs | 1 + .../app/src/components/AutoUpdate.tsx | 10 ++++- openless-all/app/src/i18n/en.ts | 2 + openless-all/app/src/i18n/ja.ts | 2 + openless-all/app/src/i18n/ko.ts | 2 + openless-all/app/src/i18n/zh-CN.ts | 2 + openless-all/app/src/i18n/zh-TW.ts | 2 + openless-all/app/src/lib/ipc.ts | 12 ++++++ openless-all/app/src/pages/Marketplace.tsx | 9 +++- .../src/pages/settings/AboutUpdateControl.tsx | 21 ++++++++-- .../src/pages/settings/PermissionsSection.tsx | 40 +++++++++++++++++- 12 files changed, 135 insertions(+), 10 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index a689074d..6a915c26 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -474,6 +474,38 @@ async fn resolve_beta_manifest_endpoints() -> Result, String> { Ok(vec![mirror_url, direct_url]) } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkCheckResult { + pub online: bool, + pub latency_ms: Option, +} + +#[tauri::command] +pub async fn check_network() -> NetworkCheckResult { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build(); + let client = match client { + Ok(c) => c, + Err(_) => return NetworkCheckResult { online: false, latency_ms: None }, + }; + let start = std::time::Instant::now(); + let endpoints = [ + "https://apic.openless.top/health", + "https://github.com", + ]; + for url in &endpoints { + if let Ok(resp) = client.head(*url).send().await { + if resp.status().is_success() || resp.status().is_redirection() { + let ms = start.elapsed().as_millis() as u64; + return NetworkCheckResult { online: true, latency_ms: Some(ms) }; + } + } + } + NetworkCheckResult { online: false, latency_ms: None } +} + #[tauri::command] pub fn get_hotkey_status(coord: CoordinatorState<'_>) -> HotkeyStatus { coord.hotkey_status() @@ -2812,7 +2844,10 @@ pub struct GithubDeviceStartResponse { #[tauri::command] pub async fn github_device_flow_start() -> Result { let client_id = get_github_oauth_client_id()?; - let client = reqwest::Client::new(); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| format!("build http client: {e}"))?; let resp = client .post("https://github.com/login/device/code") .header("Accept", "application/json") @@ -2857,7 +2892,10 @@ pub async fn github_device_flow_poll( device_code: String, ) -> Result { let client_id = get_github_oauth_client_id()?; - let client = reqwest::Client::new(); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| format!("build http client: {e}"))?; let token_resp = client .post("https://github.com/login/oauth/access_token") .header("Accept", "application/json") diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index b347239a..d269738b 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -279,6 +279,7 @@ pub fn run() { commands::set_update_channel, commands::fetch_latest_beta_release, commands::app_check_update_with_channel, + commands::check_network, commands::get_hotkey_status, commands::get_hotkey_capability, commands::set_shortcut_recording_active, diff --git a/openless-all/app/src/components/AutoUpdate.tsx b/openless-all/app/src/components/AutoUpdate.tsx index cc23e57d..2a38be7b 100644 --- a/openless-all/app/src/components/AutoUpdate.tsx +++ b/openless-all/app/src/components/AutoUpdate.tsx @@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'; import { isTauri, restartApp } from '../lib/ipc'; import { Btn } from '../pages/_atoms'; -const UPDATE_CHECK_TIMEOUT_MS = 8_000; // 缩短超时,让镜像站慢的情况能更快 fallback +const UPDATE_CHECK_TIMEOUT_MS = 15_000; interface AppUpdateMetadata { rid: number; @@ -44,6 +44,7 @@ export interface UseAutoUpdate { contentLength: number | null; checking: boolean; busy: boolean; + errorMessage: string | null; /** 触发"检查更新"。如果发现新版本,状态变为 'available',需要 caller 渲染对话框让用户确认下载。 */ checkForUpdates: () => Promise; /** 用户在对话框里确认 → 下载 + 安装。完成后状态变为 'downloaded',等用户点重启。 */ @@ -58,6 +59,7 @@ export function useAutoUpdate(): UseAutoUpdate { const [version, setVersion] = useState(''); const [downloaded, setDownloaded] = useState(0); const [contentLength, setContentLength] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); const checking = status === 'checking'; const busy = status === 'downloading' || status === 'installing'; @@ -89,6 +91,7 @@ export function useAutoUpdate(): UseAutoUpdate { const checkForUpdates = async () => { setStatus('checking'); setVersion(''); + setErrorMessage(null); resetProgress(); await closeUpdate(); try { @@ -120,6 +123,8 @@ export function useAutoUpdate(): UseAutoUpdate { setStatus('available'); } catch (error) { console.error('[updater] failed to check update', error); + const msg = error instanceof Error ? error.message : String(error); + setErrorMessage(msg); setStatus('error'); } }; @@ -146,6 +151,8 @@ export function useAutoUpdate(): UseAutoUpdate { setStatus('downloaded'); } catch (error) { console.error('[updater] failed to install update', error); + const msg = error instanceof Error ? error.message : String(error); + setErrorMessage(msg); await closeUpdate(); setStatus('error'); } @@ -167,6 +174,7 @@ export function useAutoUpdate(): UseAutoUpdate { contentLength, checking, busy, + errorMessage, checkForUpdates, installUpdate, dismissDialog, diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 58834374..fa860125 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -682,6 +682,7 @@ export const en: typeof zhCN = { networkLabel: 'Network', networkDesc: 'Required for cloud ASR / LLM calls. Disable for local-only mode.', networkOk: 'Available', + networkOffline: 'Unavailable', checking: 'Checking…', granted: 'Granted', notApplicable: 'Not required', @@ -753,6 +754,7 @@ export const en: typeof zhCN = { checkingUpdate: 'Checking…', upToDate: 'You are already on the latest version.', updateError: 'Update check or install failed. Please try again later.', + retryBtn: 'Retry', openReleases: 'Open Releases', source: 'Source', docs: 'Docs', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index f106e3c5..244673b7 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -684,6 +684,7 @@ export const ja: typeof zhCN = { networkLabel: 'ネットワーク', networkDesc: 'クラウド ASR / LLM 呼び出しに必要。ローカルモードでは無効化可能。', networkOk: '利用可能', + networkOffline: '利用不可', checking: '確認中…', granted: '許可済み', notApplicable: '権限不要', @@ -755,6 +756,7 @@ export const ja: typeof zhCN = { checkingUpdate: '確認中…', upToDate: '現在最新バージョンです。', updateError: '確認またはアップデートに失敗しました。後で再試行してください。', + retryBtn: '再試行', openReleases: 'Releases を開く', source: 'ソース', docs: 'ドキュメント', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 854695fc..6dc84774 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -684,6 +684,7 @@ export const ko: typeof zhCN = { networkLabel: '네트워크', networkDesc: '클라우드 ASR / LLM 호출에 필요. 로컬 모드에서는 비활성화 가능.', networkOk: '사용 가능', + networkOffline: '사용 불가', checking: '확인 중…', granted: '허용됨', notApplicable: '권한 불필요', @@ -755,6 +756,7 @@ export const ko: typeof zhCN = { checkingUpdate: '확인 중…', upToDate: '현재 최신 버전입니다.', updateError: '확인 또는 업데이트에 실패했습니다. 잠시 후 다시 시도하세요.', + retryBtn: '다시 시도', openReleases: 'Releases 열기', source: '소스', docs: '문서', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index a46a3828..b4588314 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -680,6 +680,7 @@ export const zhCN = { networkLabel: '网络', networkDesc: '云端 ASR / LLM 必需,本地模式可关。', networkOk: '可用', + networkOffline: '不可用', checking: '检查中…', granted: '已授权', notApplicable: '无需授权', @@ -751,6 +752,7 @@ export const zhCN = { checkingUpdate: '检查中…', upToDate: '当前已是最新版本。', updateError: '检查或更新失败,请稍后重试。', + retryBtn: '重试', openReleases: '打开 Releases', source: '源码', docs: '文档', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 88e4f9a1..004111bf 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -682,6 +682,7 @@ export const zhTW: typeof zhCN = { networkLabel: '網絡', networkDesc: '雲端 ASR / LLM 調用所必需。本地模式可關閉。', networkOk: '可用', + networkOffline: '不可用', checking: '檢查中…', granted: '已授權', notApplicable: '無需授權', @@ -753,6 +754,7 @@ export const zhTW: typeof zhCN = { checkingUpdate: '檢查中…', upToDate: '當前已是最新版本。', updateError: '檢查或更新失敗,請稍後重試。', + retryBtn: '重試', openReleases: '打開 Releases', source: '源碼', docs: '文檔', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index fb551d5d..5663f701 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -511,6 +511,18 @@ export function getWindowsImeStatus(): Promise { return invokeOrMock('get_windows_ime_status', undefined, () => mockWindowsImeStatus); } +export interface NetworkCheckResult { + online: boolean; + latencyMs: number | null; +} + +export function checkNetwork(): Promise { + return invokeOrMock('check_network', undefined, () => ({ + online: true, + latencyMs: 42, + })); +} + export function listMicrophoneDevices(): Promise { return invokeOrMock('list_microphone_devices', undefined, () => mockMicrophoneDevices); } diff --git a/openless-all/app/src/pages/Marketplace.tsx b/openless-all/app/src/pages/Marketplace.tsx index f547872c..ddcabaaa 100644 --- a/openless-all/app/src/pages/Marketplace.tsx +++ b/openless-all/app/src/pages/Marketplace.tsx @@ -580,8 +580,13 @@ export function Marketplace() { {loadError && ( -
- {t('marketplace.loadFailed', { err: loadError })} +
+
+ {t('marketplace.loadFailed', { err: loadError })} +
+ void refresh()}> + {t('common.retry') ?? '重试'} +
)} diff --git a/openless-all/app/src/pages/settings/AboutUpdateControl.tsx b/openless-all/app/src/pages/settings/AboutUpdateControl.tsx index 16ed5719..e2fe75d5 100644 --- a/openless-all/app/src/pages/settings/AboutUpdateControl.tsx +++ b/openless-all/app/src/pages/settings/AboutUpdateControl.tsx @@ -17,9 +17,24 @@ export function AboutUpdateControl({ tagline }: { tagline: string }) { {u.checking ? t('settings.about.checkingUpdate') : t('settings.about.checkUpdateBtn')}
- {(u.status === 'none' || u.status === 'error') && ( -
- {u.status === 'none' ? t('settings.about.upToDate') : t('settings.about.updateError')} + {u.status === 'none' && ( +
+ {t('settings.about.upToDate')} +
+ )} + {u.status === 'error' && ( +
+
+ {t('settings.about.updateError')} +
+ {u.errorMessage && ( +
+ {u.errorMessage} +
+ )} + + {t('settings.about.retryBtn') ?? t('common.retry') ?? '重试'} +
)} {isDialogStatus(u.status) && ( diff --git a/openless-all/app/src/pages/settings/PermissionsSection.tsx b/openless-all/app/src/pages/settings/PermissionsSection.tsx index 9be20a16..ad151aa2 100644 --- a/openless-all/app/src/pages/settings/PermissionsSection.tsx +++ b/openless-all/app/src/pages/settings/PermissionsSection.tsx @@ -7,12 +7,14 @@ import { Icon } from '../../components/Icon'; import { checkAccessibilityPermission, checkMicrophonePermission, + checkNetwork, getHotkeyStatus, getWindowsImeStatus, openSystemSettings, requestAccessibilityPermission, requestMicrophonePermission, } from '../../lib/ipc'; +import type { NetworkCheckResult } from '../../lib/ipc'; import type { HotkeyCapability, HotkeyStatus, @@ -30,6 +32,7 @@ export function PermissionsSection() { const [microphone, setMicrophone] = useState('loading'); const [hotkey, setHotkey] = useState(null); const [windowsIme, setWindowsIme] = useState(null); + const [network, setNetwork] = useState(null); const { capability } = useHotkeySettings(); const refreshPermissions = async () => { @@ -49,22 +52,34 @@ export function PermissionsSection() { setWindowsIme(await getWindowsImeStatus()); }; + const refreshNetwork = async () => { + try { + setNetwork(await checkNetwork()); + } catch { + setNetwork({ online: false, latencyMs: null }); + } + }; + useEffect(() => { refreshPermissions(); refreshHotkey(); refreshWindowsIme(); + refreshNetwork(); const hotkeyId = window.setInterval(refreshHotkey, 1000); // 麦克风检查会短暂打开输入流,避免每秒探测导致隐私指示器频繁闪烁。 const permissionId = window.setInterval(refreshPermissions, 10000); + const networkId = window.setInterval(refreshNetwork, 30000); const onFocus = () => { refreshPermissions(); refreshHotkey(); refreshWindowsIme(); + refreshNetwork(); }; window.addEventListener('focus', onFocus); return () => { window.clearInterval(hotkeyId); window.clearInterval(permissionId); + window.clearInterval(networkId); window.removeEventListener('focus', onFocus); }; }, []); @@ -157,8 +172,18 @@ export function PermissionsSection() { )} -
- {t('settings.permissions.networkOk')} +
+ {network && network.latencyMs != null && ( + + {network.latencyMs}ms + + )} + + {network && !network.online && ( + + {t('common.retry') ?? '重试'} + + )}
@@ -207,6 +232,17 @@ function WindowsImeStatusPill({ status }: { status: WindowsImeStatus | null }) { return {t('settings.permissions.windowsImeUnavailable')}; } +function NetworkStatusPill({ status }: { status: NetworkCheckResult | null }) { + const { t } = useTranslation(); + if (!status) { + return {t('settings.permissions.checking')}; + } + if (status.online) { + return {t('settings.permissions.networkOk')}; + } + return {t('settings.permissions.networkOffline') ?? '不可用'}; +} + function adapterDisplayName(adapter: HotkeyCapability['adapter'] | HotkeyStatus['adapter']) { if (adapter === 'macEventTap') return i18n.t('hotkey.adapter.macEventTap'); if (adapter === 'windowsLowLevel') return i18n.t('hotkey.adapter.windowsLowLevel');