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"] }); + }, + }); +}