From ace9b38ceec409e2f6a59067484ab2f98b71408e Mon Sep 17 00:00:00 2001 From: TinsFox Date: Sun, 21 Dec 2025 21:09:37 +0800 Subject: [PATCH 1/3] style: remove shadow (#431) --- src/index.css | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/index.css b/src/index.css index 4c2fb6d7e..a2a405d45 100644 --- a/src/index.css +++ b/src/index.css @@ -88,9 +88,6 @@ backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid rgba(0, 0, 0, 0.1); - box-shadow: - 0 4px 6px -1px rgba(0, 0, 0, 0.1), - 0 2px 4px -1px rgba(0, 0, 0, 0.06); } .dark .glass-card { @@ -107,13 +104,11 @@ .glass-card-active { background: rgba(59, 130, 246, 0.08); border: 1px solid rgba(59, 130, 246, 0.4); - box-shadow: 0 0 20px rgba(59, 130, 246, 0.15); } .dark .glass-card-active { background: rgba(59, 130, 246, 0.12); border: 1px solid rgba(59, 130, 246, 0.3); - box-shadow: 0 0 24px rgba(59, 130, 246, 0.2); } .glass-header { From f047960a338004100214aecff3ac11c8578e8625 Mon Sep 17 00:00:00 2001 From: TinsFox Date: Sun, 21 Dec 2025 21:10:00 +0800 Subject: [PATCH 2/3] Use macOS tray template icon (#434) --- src-tauri/Cargo.lock | 60 ++++++++++++++++-- src-tauri/Cargo.toml | 2 +- .../tray/macos/statusbar_template_3x.png | Bin 0 -> 2751 bytes src-tauri/src/lib.rs | 40 ++++++++++-- 4 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 src-tauri/icons/tray/macos/statusbar_template_3x.png diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a19bf1f2b..afa032432 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -586,6 +586,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.10.1" @@ -2214,7 +2220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -2330,6 +2336,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.0", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2759,6 +2778,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "moxcms" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.17.1" @@ -2774,7 +2803,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation 0.3.1", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.17", "windows-sys 0.60.2", @@ -3563,6 +3592,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.9.4", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -3694,6 +3736,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "pxfm" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84" +dependencies = [ + "num-traits", +] + [[package]] name = "quick-xml" version = "0.37.5" @@ -4987,6 +5038,7 @@ dependencies = [ "heck 0.5.0", "http", "http-range", + "image", "jni", "libc", "log", @@ -5055,7 +5107,7 @@ dependencies = [ "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -5810,7 +5862,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation 0.3.1", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.17", "windows-sys 0.59.0", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 596f59f40..471996b19 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,7 +26,7 @@ serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" chrono = { version = "0.4", features = ["serde"] } -tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset"] } +tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset", "image-png"] } tauri-plugin-log = "2" tauri-plugin-opener = "2" tauri-plugin-process = "2" diff --git a/src-tauri/icons/tray/macos/statusbar_template_3x.png b/src-tauri/icons/tray/macos/statusbar_template_3x.png new file mode 100644 index 0000000000000000000000000000000000000000..d56a1da84ab02d85cd49260cf1d5700825b7b9ef GIT binary patch literal 2751 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 Option> { + const ICON_BYTES: &[u8] = include_bytes!("../icons/tray/macos/statusbar_template_3x.png"); + + match Image::from_bytes(ICON_BYTES) { + Ok(icon) => Some(icon), + Err(err) => { + log::warn!("Failed to load macOS tray icon: {err}"); + None + } + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let mut builder = tauri::Builder::default(); @@ -503,11 +518,26 @@ pub fn run() { }) .show_menu_on_left_click(true); - // 统一使用应用默认图标;待托盘模板图标就绪后再启用 - if let Some(icon) = app.default_window_icon() { - tray_builder = tray_builder.icon(icon.clone()); - } else { - log::warn!("Failed to get default window icon for tray"); + // 使用平台对应的托盘图标(macOS 使用模板图标适配深浅色) + #[cfg(target_os = "macos")] + { + if let Some(icon) = macos_tray_icon() { + tray_builder = tray_builder.icon(icon).icon_as_template(true); + } else if let Some(icon) = app.default_window_icon() { + log::warn!("Falling back to default window icon for tray"); + tray_builder = tray_builder.icon(icon.clone()); + } else { + log::warn!("Failed to load macOS tray icon for tray"); + } + } + + #[cfg(not(target_os = "macos"))] + { + if let Some(icon) = app.default_window_icon() { + tray_builder = tray_builder.icon(icon.clone()); + } else { + log::warn!("Failed to get default window icon for tray"); + } } let _tray = tray_builder.build(app)?; From a1537807eb0d22553d2ee5cc28845ebb8b5c12de Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Sun, 21 Dec 2025 22:39:50 +0800 Subject: [PATCH 3/3] feat(failover): add auto-failover master switch with proxy integration (#427) * feat(failover): add auto-failover master switch with proxy integration - Add persistent auto_failover_enabled setting in database - Add get/set_auto_failover_enabled commands - Provider router respects master switch state - Proxy shutdown automatically disables failover - Enabling failover auto-starts proxy server - Optimistic updates for failover queue toggle * feat(proxy): persist proxy takeover state across app restarts - Add proxy_takeover_{app_type} settings for per-app state tracking - Restore proxy takeover state automatically on app startup - Preserve state on normal exit, clear on manual stop - Add stop_with_restore_keep_state method for graceful shutdown * fix(proxy): set takeover state for all apps in start_with_takeover --- src-tauri/src/commands/failover.rs | 25 +++++ src-tauri/src/database/dao/failover.rs | 24 ++++- src-tauri/src/database/dao/proxy.rs | 12 ++- src-tauri/src/database/dao/settings.rs | 52 ++++++++++ src-tauri/src/lib.rs | 64 ++++++++++++- src-tauri/src/proxy/provider_router.rs | 75 ++++++++------- src-tauri/src/services/proxy.rs | 75 +++++++++++++-- src/App.tsx | 4 +- src/components/mcp/UnifiedMcpPanel.tsx | 4 +- src/components/prompts/PromptListItem.tsx | 4 +- src/components/prompts/PromptPanel.tsx | 5 +- .../providers/forms/BasicFormFields.tsx | 5 +- .../forms/ProviderPresetSelector.tsx | 3 +- src/components/proxy/ProxyPanel.tsx | 8 +- src/components/proxy/ProxySettingsDialog.tsx | 6 +- src/components/proxy/ProxyToggle.tsx | 5 +- src/components/settings/SettingsPage.tsx | 51 +++++++--- src/components/usage/ModelTestConfigPanel.tsx | 24 ++--- src/components/usage/PricingConfigPanel.tsx | 11 +-- src/components/usage/RequestLogTable.tsx | 11 +-- src/components/usage/UsageDashboard.tsx | 4 +- src/components/usage/UsageSummaryCards.tsx | 30 +++--- src/hooks/useProxyStatus.ts | 12 ++- src/lib/api/failover.ts | 10 ++ src/lib/query/failover.ts | 96 ++++++++++++++++++- 25 files changed, 479 insertions(+), 141 deletions(-) diff --git a/src-tauri/src/commands/failover.rs b/src-tauri/src/commands/failover.rs index ddceeef13..bc8058ce2 100644 --- a/src-tauri/src/commands/failover.rs +++ b/src-tauri/src/commands/failover.rs @@ -82,3 +82,28 @@ pub async fn set_failover_item_enabled( .set_failover_item_enabled(&app_type, &provider_id, enabled) .map_err(|e| e.to_string()) } + +/// 获取自动故障转移总开关状态 +#[tauri::command] +pub async fn get_auto_failover_enabled(state: tauri::State<'_, AppState>) -> Result { + state + .db + .get_setting("auto_failover_enabled") + .map(|v| v.map(|s| s == "true").unwrap_or(false)) // 默认关闭 + .map_err(|e| e.to_string()) +} + +/// 设置自动故障转移总开关状态 +#[tauri::command] +pub async fn set_auto_failover_enabled( + state: tauri::State<'_, AppState>, + enabled: bool, +) -> Result<(), String> { + state + .db + .set_setting( + "auto_failover_enabled", + if enabled { "true" } else { "false" }, + ) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/database/dao/failover.rs b/src-tauri/src/database/dao/failover.rs index 41cff95ff..35009ea4c 100644 --- a/src-tauri/src/database/dao/failover.rs +++ b/src-tauri/src/database/dao/failover.rs @@ -182,11 +182,25 @@ impl Database { ) -> Result<(), AppError> { let conn = lock_conn!(self.conn); - conn.execute( - "UPDATE failover_queue SET enabled = ?3 WHERE app_type = ?1 AND provider_id = ?2", - rusqlite::params![app_type, provider_id, enabled], - ) - .map_err(|e| AppError::Database(e.to_string()))?; + let rows_affected = conn + .execute( + "UPDATE failover_queue SET enabled = ?3 WHERE app_type = ?1 AND provider_id = ?2", + rusqlite::params![app_type, provider_id, enabled], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + if rows_affected == 0 { + log::warn!( + "set_failover_item_enabled: 未找到匹配记录 app_type={app_type}, provider_id={provider_id}" + ); + return Err(AppError::Database(format!( + "未找到故障转移队列项: app_type={app_type}, provider_id={provider_id}" + ))); + } + + log::info!( + "set_failover_item_enabled: 已更新 app_type={app_type}, provider_id={provider_id}, enabled={enabled}" + ); Ok(()) } diff --git a/src-tauri/src/database/dao/proxy.rs b/src-tauri/src/database/dao/proxy.rs index 53bffeeeb..9017ebe60 100644 --- a/src-tauri/src/database/dao/proxy.rs +++ b/src-tauri/src/database/dao/proxy.rs @@ -71,21 +71,27 @@ impl Database { Ok(()) } - /// 设置 Live 接管状态 + /// 设置 Live 接管状态(仅更新 proxy_config 表,兼容旧逻辑) + /// + /// 注意:此方法不会清除 settings 表中的 proxy_takeover_* 状态。 + /// settings 表的状态由 set_proxy_takeover_enabled 单独管理,用于跨重启保持状态。 pub async fn set_live_takeover_active(&self, active: bool) -> Result<(), AppError> { + // 仅更新 proxy_config 表(兼容旧版本) let conn = lock_conn!(self.conn); conn.execute( "UPDATE proxy_config SET live_takeover_active = ?1, updated_at = datetime('now') WHERE id = 1", rusqlite::params![if active { 1 } else { 0 }], ) .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) } /// 检查是否处于 Live 接管模式 + /// + /// v3.8.0+:以 settings 表中的 `proxy_takeover_{app_type}` 为真实来源 pub async fn is_live_takeover_active(&self) -> Result { - // v3.7.0+:以 proxy_live_backup 是否存在作为“接管状态”的真实来源(更贴近 per-app 接管) - self.has_any_live_backup().await + self.has_any_proxy_takeover() } // ==================== Provider Health ==================== diff --git a/src-tauri/src/database/dao/settings.rs b/src-tauri/src/database/dao/settings.rs index 90272aabe..68a5c68b8 100644 --- a/src-tauri/src/database/dao/settings.rs +++ b/src-tauri/src/database/dao/settings.rs @@ -62,4 +62,56 @@ impl Database { Ok(()) } } + + // --- 代理接管状态管理 --- + + /// 获取指定应用的代理接管状态 + /// + /// 使用 settings 表存储各应用的接管状态,key 格式: `proxy_takeover_{app_type}` + pub fn get_proxy_takeover_enabled(&self, app_type: &str) -> Result { + let key = format!("proxy_takeover_{app_type}"); + match self.get_setting(&key)? { + Some(value) => Ok(value == "true"), + None => Ok(false), + } + } + + /// 设置指定应用的代理接管状态 + /// + /// - `true` = 开启代理接管 + /// - `false` = 关闭代理接管 + pub fn set_proxy_takeover_enabled( + &self, + app_type: &str, + enabled: bool, + ) -> Result<(), AppError> { + let key = format!("proxy_takeover_{app_type}"); + let value = if enabled { "true" } else { "false" }; + self.set_setting(&key, value) + } + + /// 检查是否有任一应用开启了代理接管 + pub fn has_any_proxy_takeover(&self) -> Result { + let conn = lock_conn!(self.conn); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM settings WHERE key LIKE 'proxy_takeover_%' AND value = 'true'", + [], + |row| row.get(0), + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(count > 0) + } + + /// 清除所有代理接管状态(将所有 proxy_takeover_* 设置为 false) + pub fn clear_all_proxy_takeover(&self) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "UPDATE settings SET value = 'false' WHERE key LIKE 'proxy_takeover_%'", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + log::info!("已清除所有代理接管状态"); + Ok(()) + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index df50fa0f9..6da99ea5b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -554,7 +554,7 @@ pub fn run() { } } - // 异常退出恢复 + // 异常退出恢复 + 代理状态自动恢复 let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { let state = app_handle.state::(); @@ -578,6 +578,9 @@ pub fn run() { log::info!("Live 配置已恢复"); } } + + // 检查 settings 表中的代理状态,自动恢复代理服务 + restore_proxy_state_on_startup(&state).await; }); Ok(()) @@ -708,6 +711,8 @@ pub fn run() { commands::remove_from_failover_queue, commands::reorder_failover_queue, commands::set_failover_item_enabled, + commands::get_auto_failover_enabled, + commands::set_auto_failover_enabled, // Usage statistics commands::get_usage_summary, commands::get_usage_trends, @@ -839,6 +844,7 @@ pub fn run() { /// /// 在应用退出前检查代理服务器状态,如果正在运行则停止代理并恢复 Live 配置。 /// 确保 Claude Code/Codex/Gemini 的配置不会处于损坏状态。 +/// 使用 stop_with_restore_keep_state 保留 settings 表中的代理状态,下次启动时自动恢复。 pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) { if let Some(state) = app_handle.try_state::() { let proxy_service = &state.proxy_service; @@ -855,11 +861,12 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) { let needs_restore = has_backups || live_taken_over; if needs_restore { - log::info!("检测到接管残留,开始恢复 Live 配置..."); - if let Err(e) = proxy_service.stop_with_restore().await { + log::info!("检测到接管残留,开始恢复 Live 配置(保留代理状态)..."); + // 使用 keep_state 版本,保留 settings 表中的代理状态 + if let Err(e) = proxy_service.stop_with_restore_keep_state().await { log::error!("退出时恢复 Live 配置失败: {e}"); } else { - log::info!("已恢复 Live 配置"); + log::info!("已恢复 Live 配置(代理状态已保留,下次启动将自动恢复)"); } return; } @@ -875,6 +882,55 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) { } } +// ============================================================ +// 启动时恢复代理状态 +// ============================================================ + +/// 启动时根据 settings 表中的代理状态自动恢复代理服务 +/// +/// 检查 `proxy_takeover_claude`、`proxy_takeover_codex`、`proxy_takeover_gemini` 的值, +/// 如果有任一应用的状态为 `true`,则自动启动代理服务并接管对应应用的 Live 配置。 +async fn restore_proxy_state_on_startup(state: &store::AppState) { + // 收集需要恢复接管的应用列表 + let apps_to_restore: Vec<&str> = ["claude", "codex", "gemini"] + .iter() + .filter(|app_type| { + state + .db + .get_proxy_takeover_enabled(app_type) + .unwrap_or(false) + }) + .copied() + .collect(); + + if apps_to_restore.is_empty() { + log::debug!("启动时无需恢复代理状态"); + return; + } + + log::info!("检测到上次代理状态需要恢复,应用列表: {apps_to_restore:?}"); + + // 逐个恢复接管状态 + for app_type in apps_to_restore { + match state + .proxy_service + .set_takeover_for_app(app_type, true) + .await + { + Ok(()) => { + log::info!("✓ 已恢复 {app_type} 的代理接管状态"); + } + Err(e) => { + log::error!("✗ 恢复 {app_type} 的代理接管状态失败: {e}"); + // 失败时清除该应用的状态,避免下次启动再次尝试 + if let Err(clear_err) = state.db.set_proxy_takeover_enabled(app_type, false) { + log::error!("清除 {app_type} 代理状态失败: {clear_err}"); + } + } + } + } +} + // ============================================================ // 迁移错误对话框辅助函数 // ============================================================ diff --git a/src-tauri/src/proxy/provider_router.rs b/src-tauri/src/proxy/provider_router.rs index d6f302268..3a5466f36 100644 --- a/src-tauri/src/proxy/provider_router.rs +++ b/src-tauri/src/proxy/provider_router.rs @@ -31,12 +31,19 @@ impl ProviderRouter { /// /// 返回按优先级排序的可用供应商列表: /// 1. 当前供应商(is_current=true)始终第一位 - /// 2. 故障转移队列中的其他供应商(按 queue_order 排序) + /// 2. 故障转移队列中的其他供应商(按 queue_order 排序)- 仅当自动故障转移开关开启时 /// 3. 只返回熔断器未打开的供应商 pub async fn select_providers(&self, app_type: &str) -> Result, AppError> { let mut result = Vec::new(); let all_providers = self.db.get_all_providers(app_type)?; + // 检查自动故障转移总开关是否开启 + let auto_failover_enabled = self + .db + .get_setting("auto_failover_enabled") + .map(|v| v.map(|s| s == "true").unwrap_or(false)) // 默认关闭 + .unwrap_or(false); + // 1. 当前供应商始终第一位 if let Some(current_id) = self.db.get_current_provider(app_type)? { if let Some(current) = all_providers.get(¤t_id) { @@ -61,43 +68,47 @@ impl ProviderRouter { } } - // 2. 获取故障转移队列中的供应商 - let queue = self.db.get_failover_queue(app_type)?; + // 2. 获取故障转移队列中的供应商(仅当自动故障转移开关开启时) + if auto_failover_enabled { + let queue = self.db.get_failover_queue(app_type)?; - for item in queue { - // 跳过已添加的当前供应商 - if result.iter().any(|p| p.id == item.provider_id) { - continue; - } + for item in queue { + // 跳过已添加的当前供应商 + if result.iter().any(|p| p.id == item.provider_id) { + continue; + } - // 跳过禁用的队列项 - if !item.enabled { - continue; - } + // 跳过禁用的队列项 + if !item.enabled { + continue; + } - // 获取供应商信息 - if let Some(provider) = all_providers.get(&item.provider_id) { - // 检查熔断器状态 - let circuit_key = format!("{}:{}", app_type, provider.id); - let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; + // 获取供应商信息 + if let Some(provider) = all_providers.get(&item.provider_id) { + // 检查熔断器状态 + let circuit_key = format!("{}:{}", app_type, provider.id); + let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; - if breaker.is_available().await { - log::info!( - "[{}] Failover provider available: {} ({}) at queue position {}", - app_type, - provider.name, - provider.id, - item.queue_order - ); - result.push(provider.clone()); - } else { - log::debug!( - "[{}] Failover provider {} circuit breaker open, skipping", - app_type, - provider.name - ); + if breaker.is_available().await { + log::info!( + "[{}] Failover provider available: {} ({}) at queue position {}", + app_type, + provider.name, + provider.id, + item.queue_order + ); + result.push(provider.clone()); + } else { + log::debug!( + "[{}] Failover provider {} circuit breaker open, skipping", + app_type, + provider.name + ); + } } } + } else { + log::info!("[{app_type}] Auto-failover disabled, using only current provider"); } if result.is_empty() { diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 7c720a9ad..605d979e3 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -115,7 +115,14 @@ impl ProxyService { return Err(e); } - // 5. 启动代理服务器 + // 5. 设置 settings 表中所有应用的接管状态(用于重启后自动恢复) + for app in ["claude", "codex", "gemini"] { + if let Err(e) = self.db.set_proxy_takeover_enabled(app, true) { + log::warn!("设置 {app} 接管状态失败: {e}"); + } + } + + // 6. 启动代理服务器 match self.start().await { Ok(info) => Ok(info), Err(e) => { @@ -125,6 +132,8 @@ impl ProxyService { Ok(()) => { let _ = self.db.set_live_takeover_active(false).await; let _ = self.db.delete_all_live_backups().await; + // 清除 settings 状态 + let _ = self.db.clear_all_proxy_takeover(); } Err(restore_err) => { log::error!("恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}"); @@ -214,7 +223,12 @@ impl ProxyService { return Err(e); } - // 6) 兼容旧逻辑:写入 any-of 标志(失败不影响功能) + // 6) 设置 settings 表中的接管状态 + self.db + .set_proxy_takeover_enabled(app_type_str, true) + .map_err(|e| format!("设置 {app_type_str} 接管状态失败: {e}"))?; + + // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能) let _ = self.db.set_live_takeover_active(true).await; return Ok(()); } @@ -239,7 +253,12 @@ impl ProxyService { .await .map_err(|e| format!("删除 {app_type_str} Live 备份失败: {e}"))?; - // 3) 若无其它接管,更新旧标志,并停止代理服务 + // 3) 清除 settings 表中该应用的接管状态 + self.db + .set_proxy_takeover_enabled(app_type_str, false) + .map_err(|e| format!("清除 {app_type_str} 接管状态失败: {e}"))?; + + // 4) 若无其它接管,更新旧标志,并停止代理服务 let has_any_backup = self .db .has_any_live_backup() @@ -484,7 +503,9 @@ impl ProxyService { } } - /// 停止代理服务器(恢复 Live 配置) + /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用) + /// + /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。 pub async fn stop_with_restore(&self) -> Result<(), String> { // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑) if let Err(e) = self.stop().await { @@ -494,19 +515,24 @@ impl ProxyService { // 2. 恢复原始 Live 配置 self.restore_live_configs().await?; - // 3. 清除接管状态 + // 3. 清除 proxy_config 表中的接管状态(兼容旧版) self.db .set_live_takeover_active(false) .await .map_err(|e| format!("清除接管状态失败: {e}"))?; - // 4. 删除备份 + // 4. 清除 settings 表中的代理状态(用户手动关闭,不需要下次自动恢复) + self.db + .clear_all_proxy_takeover() + .map_err(|e| format!("清除代理状态失败: {e}"))?; + + // 5. 删除备份 self.db .delete_all_live_backups() .await .map_err(|e| format!("删除备份失败: {e}"))?; - // 5. 重置健康状态(让健康徽章恢复为正常) + // 6. 重置健康状态(让健康徽章恢复为正常) self.db .clear_all_provider_health() .await @@ -516,6 +542,41 @@ impl ProxyService { Ok(()) } + /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态) + /// + /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复 + pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> { + // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑) + if let Err(e) = self.stop().await { + log::warn!("停止代理服务器失败(将继续恢复 Live 配置): {e}"); + } + + // 2. 恢复原始 Live 配置 + self.restore_live_configs().await?; + + // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版) + // 注意:仅更新 proxy_config 表,不清除 settings 表中的 proxy_takeover_* 状态 + if let Ok(mut config) = self.db.get_proxy_config().await { + config.live_takeover_active = false; + let _ = self.db.update_proxy_config(config).await; + } + + // 4. 删除备份(Live 配置已恢复,备份不再需要) + self.db + .delete_all_live_backups() + .await + .map_err(|e| format!("删除备份失败: {e}"))?; + + // 5. 重置健康状态 + self.db + .clear_all_provider_health() + .await + .map_err(|e| format!("重置健康状态失败: {e}"))?; + + log::info!("代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)"); + Ok(()) + } + /// 备份各应用的 Live 配置 async fn backup_live_configs(&self) -> Result<(), String> { // Claude diff --git a/src/App.tsx b/src/App.tsx index a73fa88ff..d9b730c0f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -350,7 +350,9 @@ function App() { appId={activeApp} isLoading={isLoading} isProxyRunning={isProxyRunning} - isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive} + isProxyTakeover={ + isProxyRunning && isCurrentAppTakeoverActive + } onSwitch={switchProvider} onEdit={setEditingProvider} onDelete={setConfirmDelete} diff --git a/src/components/mcp/UnifiedMcpPanel.tsx b/src/components/mcp/UnifiedMcpPanel.tsx index 9f765bbae..f004b1c7d 100644 --- a/src/components/mcp/UnifiedMcpPanel.tsx +++ b/src/components/mcp/UnifiedMcpPanel.tsx @@ -237,9 +237,7 @@ const UnifiedMcpListItem: React.FC = ({ {/* 左侧:服务器信息 */}
-

- {name} -

+

{name}

{docsUrl && (
-

- {prompt.name} -

+

{prompt.name}

{prompt.description && (

{prompt.description} diff --git a/src/components/prompts/PromptPanel.tsx b/src/components/prompts/PromptPanel.tsx index 88822b3ac..af73626aa 100644 --- a/src/components/prompts/PromptPanel.tsx +++ b/src/components/prompts/PromptPanel.tsx @@ -114,10 +114,7 @@ const PromptPanel = React.forwardRef( ) : promptEntries.length === 0 ? (

- +

{t("prompts.empty")} diff --git a/src/components/providers/forms/BasicFormFields.tsx b/src/components/providers/forms/BasicFormFields.tsx index 82477c2a3..514343474 100644 --- a/src/components/providers/forms/BasicFormFields.tsx +++ b/src/components/providers/forms/BasicFormFields.tsx @@ -153,7 +153,10 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) { {t("provider.websiteUrl")} - + diff --git a/src/components/providers/forms/ProviderPresetSelector.tsx b/src/components/providers/forms/ProviderPresetSelector.tsx index 56523af00..80d8220f6 100644 --- a/src/components/providers/forms/ProviderPresetSelector.tsx +++ b/src/components/providers/forms/ProviderPresetSelector.tsx @@ -149,8 +149,7 @@ export function ProviderPresetSelector({ className={`${getPresetButtonClass(isSelected, entry.preset)} relative`} style={getPresetButtonStyle(isSelected, entry.preset)} title={ - presetCategoryLabels[category] ?? - t("providerPreset.other") + presetCategoryLabels[category] ?? t("providerPreset.other") } > {renderPresetIcon(entry.preset)} diff --git a/src/components/proxy/ProxyPanel.tsx b/src/components/proxy/ProxyPanel.tsx index 6d0141de6..08df765b2 100644 --- a/src/components/proxy/ProxyPanel.tsx +++ b/src/components/proxy/ProxyPanel.tsx @@ -51,7 +51,9 @@ export function ProxyPanel() {

- {t("proxy.panel.serviceAddress", { defaultValue: "服务地址" })} + {t("proxy.panel.serviceAddress", { + defaultValue: "服务地址", + })}

- {t("proxy.panel.stoppedTitle", { defaultValue: "代理服务已停止" })} + {t("proxy.panel.stoppedTitle", { + defaultValue: "代理服务已停止", + })}

{t("proxy.panel.stoppedDescription", { diff --git a/src/components/proxy/ProxySettingsDialog.tsx b/src/components/proxy/ProxySettingsDialog.tsx index 0a5b8afdb..3ae452f89 100644 --- a/src/components/proxy/ProxySettingsDialog.tsx +++ b/src/components/proxy/ProxySettingsDialog.tsx @@ -29,7 +29,11 @@ import type { ProxyConfig } from "@/types/proxy"; // 表单数据类型(仅包含可编辑字段) type ProxyConfigForm = Pick< ProxyConfig, - "listen_address" | "listen_port" | "max_retries" | "request_timeout" | "enable_logging" + | "listen_address" + | "listen_port" + | "max_retries" + | "request_timeout" + | "enable_logging" >; const createProxyConfigSchema = (t: TFunction) => { diff --git a/src/components/proxy/ProxyToggle.tsx b/src/components/proxy/ProxyToggle.tsx index 631ce7c71..7b007c63c 100644 --- a/src/components/proxy/ProxyToggle.tsx +++ b/src/components/proxy/ProxyToggle.tsx @@ -49,10 +49,7 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) { return (

diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index ddffc0dd5..9695e9741 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -47,6 +47,10 @@ import type { SettingsFormState } from "@/hooks/useSettings"; import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; import { useProxyStatus } from "@/hooks/useProxyStatus"; +import { + useAutoFailoverEnabled, + useSetAutoFailoverEnabled, +} from "@/lib/query/failover"; interface SettingsDialogProps { open: boolean; @@ -182,11 +186,18 @@ export function SettingsPage({ stopWithRestore, isPending: isProxyPending, } = useProxyStatus(); - const [failoverEnabled, setFailoverEnabled] = useState(true); + + // 使用持久化的自动故障转移开关状态 + const { data: failoverEnabled = false } = useAutoFailoverEnabled(); + const setAutoFailoverEnabled = useSetAutoFailoverEnabled(); const handleToggleProxy = async (checked: boolean) => { try { if (!checked) { + // 关闭代理时,同时关闭故障转移 + if (failoverEnabled) { + setAutoFailoverEnabled.mutate(false); + } await stopWithRestore(); } else { await startProxyServer(); @@ -196,6 +207,19 @@ export function SettingsPage({ } }; + // 处理故障转移开关:开启时自动启动代理 + const handleToggleFailover = async (checked: boolean) => { + try { + if (checked && !isRunning) { + // 开启故障转移时,先启动代理 + await startProxyServer(); + } + setAutoFailoverEnabled.mutate(checked); + } catch (error) { + console.error("Toggle failover failed:", error); + } + }; + return (
{isBusy ? ( @@ -215,9 +239,7 @@ export function SettingsPage({ {t("settings.tabAdvanced")} - - {t("usage.title")} - + {t("usage.title")} {t("common.about")} @@ -318,7 +340,9 @@ export function SettingsPage({ - {isRunning ? t("settings.advanced.proxy.running") : t("settings.advanced.proxy.stopped")} + {isRunning + ? t("settings.advanced.proxy.running") + : t("settings.advanced.proxy.stopped")}
@@ -402,19 +429,19 @@ export function SettingsPage({ @@ -423,8 +450,8 @@ export function SettingsPage({ {/* 熔断器配置 */}
diff --git a/src/components/usage/ModelTestConfigPanel.tsx b/src/components/usage/ModelTestConfigPanel.tsx index 998b954ad..213cf6152 100644 --- a/src/components/usage/ModelTestConfigPanel.tsx +++ b/src/components/usage/ModelTestConfigPanel.tsx @@ -51,9 +51,7 @@ export function ModelTestConfigPanel() { closeButton: true, }); } catch (e) { - toast.error( - t("streamCheck.configSaveFailed") + ": " + String(e), - ); + toast.error(t("streamCheck.configSaveFailed") + ": " + String(e)); } finally { setIsSaving(false); } @@ -82,9 +80,7 @@ export function ModelTestConfigPanel() {

- +
- +
- +
- +
- + - {t("usage.loadPricingError")}:{" "} - {String(error)} + {t("usage.loadPricingError")}: {String(error)} @@ -124,9 +123,7 @@ export function PricingConfigPanel() {
{!pricing || pricing.length === 0 ? ( - - {t("usage.noPricingData")} - + {t("usage.noPricingData")} ) : (
@@ -220,9 +217,7 @@ export function PricingConfigPanel() { > - - {t("usage.deleteConfirmTitle")} - + {t("usage.deleteConfirmTitle")} {t("usage.deleteConfirmDesc")} diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index 39d8d7169..6f6436833 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -87,9 +87,7 @@ export function RequestLogTable() { - - {t("usage.allApps")} - + {t("usage.allApps")} Claude Codex Gemini @@ -149,9 +147,7 @@ export function RequestLogTable() {
- - {t("usage.timeRange")}: - + {t("usage.timeRange")}: - {log.providerName || - t("usage.unknownProvider")} + {log.providerName || t("usage.unknownProvider")}

{t("usage.title")}

-

- {t("usage.subtitle")} -

+

{t("usage.subtitle")}

+
{t("usage.input")} @@ -78,21 +78,21 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { icon: Database, color: "text-orange-500", bg: "bg-orange-500/10", - subValue: ( -
-
- {t("usage.cacheWrite")} - - {(cacheWriteTokens / 1000).toFixed(1)}k - -
-
- {t("usage.cacheRead")} - - {(cacheReadTokens / 1000).toFixed(1)}k - -
+ subValue: ( +
+
+ {t("usage.cacheWrite")} + + {(cacheWriteTokens / 1000).toFixed(1)}k +
+
+ {t("usage.cacheRead")} + + {(cacheReadTokens / 1000).toFixed(1)}k + +
+
), }, ]; diff --git a/src/hooks/useProxyStatus.ts b/src/hooks/useProxyStatus.ts index e679513a1..e43a159eb 100644 --- a/src/hooks/useProxyStatus.ts +++ b/src/hooks/useProxyStatus.ts @@ -51,7 +51,8 @@ export function useProxyStatus() { }, onError: (error: Error) => { const detail = - extractErrorMessage(error) || t("common.unknown", { defaultValue: "未知错误" }); + extractErrorMessage(error) || + t("common.unknown", { defaultValue: "未知错误" }); toast.error( t("proxy.server.startFailed", { defaultValue: `启动代理服务失败: ${detail}`, @@ -77,7 +78,8 @@ export function useProxyStatus() { }, onError: (error: Error) => { const detail = - extractErrorMessage(error) || t("common.unknown", { defaultValue: "未知错误" }); + extractErrorMessage(error) || + t("common.unknown", { defaultValue: "未知错误" }); toast.error( t("proxy.stopWithRestoreFailed", { defaultValue: `停止失败: ${detail}`, @@ -114,7 +116,8 @@ export function useProxyStatus() { }, onError: (error: Error) => { const detail = - extractErrorMessage(error) || t("common.unknown", { defaultValue: "未知错误" }); + extractErrorMessage(error) || + t("common.unknown", { defaultValue: "未知错误" }); toast.error( t("proxy.takeover.failed", { defaultValue: `操作失败: ${detail}`, @@ -137,7 +140,8 @@ export function useProxyStatus() { }, onError: (error: Error) => { const detail = - extractErrorMessage(error) || t("common.unknown", { defaultValue: "未知错误" }); + extractErrorMessage(error) || + t("common.unknown", { defaultValue: "未知错误" }); toast.error( t("proxy.switchFailed", { error: detail, diff --git a/src/lib/api/failover.ts b/src/lib/api/failover.ts index 58fc26e21..85c00c852 100644 --- a/src/lib/api/failover.ts +++ b/src/lib/api/failover.ts @@ -104,4 +104,14 @@ export const failoverApi = { enabled, }); }, + + // 获取自动故障转移总开关状态 + async getAutoFailoverEnabled(): Promise { + return invoke("get_auto_failover_enabled"); + }, + + // 设置自动故障转移总开关状态 + async setAutoFailoverEnabled(enabled: boolean): Promise { + return invoke("set_auto_failover_enabled", { enabled }); + }, }; diff --git a/src/lib/query/failover.ts b/src/lib/query/failover.ts index 9f0c7d9fb..bfba5b52a 100644 --- a/src/lib/query/failover.ts +++ b/src/lib/query/failover.ts @@ -173,6 +173,7 @@ export function useReorderFailoverQueue() { /** * 设置故障转移队列项的启用状态 + * 使用乐观更新(Optimistic Update)以提供即时反馈 */ export function useSetFailoverItemEnabled() { const queryClient = useQueryClient(); @@ -187,10 +188,103 @@ export function useSetFailoverItemEnabled() { providerId: string; enabled: boolean; }) => failoverApi.setFailoverItemEnabled(appType, providerId, enabled), - onSuccess: (_, variables) => { + + // 乐观更新:立即更新缓存中的数据 + onMutate: async (variables) => { + // 取消正在进行的查询,防止覆盖乐观更新 + await queryClient.cancelQueries({ + queryKey: ["failoverQueue", variables.appType], + }); + + // 保存之前的数据以便回滚 + const previousQueue = queryClient.getQueryData< + import("@/types/proxy").FailoverQueueItem[] + >(["failoverQueue", variables.appType]); + + // 乐观地更新缓存 + if (previousQueue) { + queryClient.setQueryData( + ["failoverQueue", variables.appType], + previousQueue.map((item) => + item.providerId === variables.providerId + ? { ...item, enabled: variables.enabled } + : item, + ), + ); + } + + // 返回上下文供 onError 使用 + return { previousQueue }; + }, + + // 错误时回滚 + onError: (_error, variables, context) => { + if (context?.previousQueue) { + queryClient.setQueryData( + ["failoverQueue", variables.appType], + context.previousQueue, + ); + } + }, + + // 无论成功失败,都重新获取最新数据以确保一致性 + onSettled: (_, __, variables) => { queryClient.invalidateQueries({ queryKey: ["failoverQueue", variables.appType], }); }, }); } + +// ========== 自动故障转移总开关 Hooks ========== + +/** + * 获取自动故障转移总开关状态 + */ +export function useAutoFailoverEnabled() { + return useQuery({ + queryKey: ["autoFailoverEnabled"], + queryFn: () => failoverApi.getAutoFailoverEnabled(), + // 默认值为 false(与后端保持一致) + placeholderData: false, + }); +} + +/** + * 设置自动故障转移总开关状态 + */ +export function useSetAutoFailoverEnabled() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (enabled: boolean) => + failoverApi.setAutoFailoverEnabled(enabled), + + // 乐观更新 + onMutate: async (enabled) => { + await queryClient.cancelQueries({ queryKey: ["autoFailoverEnabled"] }); + const previousValue = queryClient.getQueryData([ + "autoFailoverEnabled", + ]); + + queryClient.setQueryData(["autoFailoverEnabled"], enabled); + + return { previousValue }; + }, + + // 错误时回滚 + onError: (_error, _enabled, context) => { + if (context?.previousValue !== undefined) { + queryClient.setQueryData( + ["autoFailoverEnabled"], + context.previousValue, + ); + } + }, + + // 无论成功失败,都重新获取 + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["autoFailoverEnabled"] }); + }, + }); +}