From a8ea99c3fee19e10d847579ebf61889d8a50bdac Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Thu, 15 Jan 2026 21:54:05 +0800 Subject: [PATCH] feat(ui): add failover toggle and improve proxy controls - Add FailoverToggle component with slide animation - Simplify ProxyToggle style to match FailoverToggle - Add usage statistics button when proxy is active - Fix i18n parameter passing for failover messages - Add missing failover translation keys (inQueue, addQueue, priority) - Replace AboutSection icon with app logo --- src-tauri/src/commands/failover.rs | 12 +- src-tauri/src/database/dao/proxy.rs | 47 +++++++ src-tauri/src/tray.rs | 169 +++++++++++++++++------ src/App.tsx | 32 ++++- src/assets/icons/app-icon.png | Bin 0 -> 2258 bytes src/components/proxy/FailoverToggle.tsx | 76 ++++++++++ src/components/proxy/ProxyToggle.tsx | 42 +++--- src/components/settings/AboutSection.tsx | 4 +- src/i18n/locales/en.json | 14 ++ src/i18n/locales/ja.json | 14 ++ src/i18n/locales/zh.json | 14 ++ src/lib/query/failover.ts | 37 ++++- 12 files changed, 388 insertions(+), 73 deletions(-) create mode 100644 src/assets/icons/app-icon.png create mode 100644 src/components/proxy/FailoverToggle.tsx diff --git a/src-tauri/src/commands/failover.rs b/src-tauri/src/commands/failover.rs index 4c1e71d56..8269f5fc3 100644 --- a/src-tauri/src/commands/failover.rs +++ b/src-tauri/src/commands/failover.rs @@ -75,6 +75,7 @@ pub async fn get_auto_failover_enabled( /// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用 #[tauri::command] pub async fn set_auto_failover_enabled( + app: tauri::AppHandle, state: tauri::State<'_, AppState>, app_type: String, enabled: bool, @@ -98,5 +99,14 @@ pub async fn set_auto_failover_enabled( .db .update_proxy_config_for_app(config) .await - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + + // 刷新托盘菜单,确保状态同步 + if let Ok(new_menu) = crate::tray::create_tray_menu(&app, &state) { + if let Some(tray) = app.tray_by_id("main") { + let _ = tray.set_menu(Some(new_menu)); + } + } + + Ok(()) } diff --git a/src-tauri/src/database/dao/proxy.rs b/src-tauri/src/database/dao/proxy.rs index 806c14abb..009f24af7 100644 --- a/src-tauri/src/database/dao/proxy.rs +++ b/src-tauri/src/database/dao/proxy.rs @@ -614,4 +614,51 @@ impl Database { log::info!("已删除所有 Live 配置备份"); Ok(()) } + + // ==================== Sync Methods for Tray Menu ==================== + + /// 同步获取应用的 proxy 启用状态和自动故障转移状态 + /// + /// 用于托盘菜单构建等同步场景 + /// 返回 (enabled, auto_failover_enabled) + pub fn get_proxy_flags_sync(&self, app_type: &str) -> (bool, bool) { + let conn = match self.conn.lock() { + Ok(c) => c, + Err(_) => return (false, false), + }; + + conn.query_row( + "SELECT enabled, auto_failover_enabled FROM proxy_config WHERE app_type = ?1", + [app_type], + |row| Ok((row.get::<_, i32>(0)? != 0, row.get::<_, i32>(1)? != 0)), + ) + .unwrap_or((false, false)) + } + + /// 同步设置应用的 proxy 启用状态和自动故障转移状态 + /// + /// 用于托盘菜单点击等同步场景 + pub fn set_proxy_flags_sync( + &self, + app_type: &str, + enabled: bool, + auto_failover_enabled: bool, + ) -> Result<(), AppError> { + let conn = self + .conn + .lock() + .map_err(|e| AppError::Database(format!("Mutex lock failed: {e}")))?; + + conn.execute( + "UPDATE proxy_config SET enabled = ?2, auto_failover_enabled = ?3, updated_at = datetime('now') WHERE app_type = ?1", + rusqlite::params![ + app_type, + if enabled { 1 } else { 0 }, + if auto_failover_enabled { 1 } else { 0 }, + ], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(()) + } } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 054574edc..f07b262c9 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -15,6 +15,7 @@ pub struct TrayTexts { pub show_main: &'static str, pub no_provider_hint: &'static str, pub quit: &'static str, + pub auto_label: &'static str, } impl TrayTexts { @@ -24,17 +25,20 @@ impl TrayTexts { show_main: "Open main window", no_provider_hint: " (No providers yet, please add them from the main window)", quit: "Quit", + auto_label: "Auto (Failover)", }, "ja" => Self { show_main: "メインウィンドウを開く", no_provider_hint: " (プロバイダーがまだありません。メイン画面から追加してください)", quit: "終了", + auto_label: "自動 (フェイルオーバー)", }, _ => Self { show_main: "打开主界面", no_provider_hint: " (无供应商,请在主界面添加)", quit: "退出", + auto_label: "自动 (故障转移)", }, } } @@ -50,6 +54,9 @@ pub struct TrayAppSection { pub log_name: &'static str, } +/// Auto 菜单项后缀 +pub const AUTO_SUFFIX: &str = "auto"; + pub const TRAY_SECTIONS: [TrayAppSection; 3] = [ TrayAppSection { app_type: AppType::Claude, @@ -84,6 +91,7 @@ fn append_provider_section<'a>( manager: Option<&crate::provider::ProviderManager>, section: &TrayAppSection, tray_texts: &TrayTexts, + app_state: &AppState, ) -> Result>, AppError> { let Some(manager) = manager else { return Ok(menu_builder); @@ -111,6 +119,23 @@ fn append_provider_section<'a>( return Ok(menu_builder.item(&empty_hint)); } + // 获取 proxy 状态,决定 Auto 是否选中 + let (proxy_enabled, auto_failover) = + app_state.db.get_proxy_flags_sync(section.app_type.as_str()); + let auto_mode = proxy_enabled && auto_failover; + + // 添加 Auto 菜单项(始终显示在供应商列表前) + let auto_item = CheckMenuItem::with_id( + app, + format!("{}{}", section.prefix, AUTO_SUFFIX), + tray_texts.auto_label, + true, + auto_mode, + None::<&str>, + ) + .map_err(|e| AppError::Message(format!("创建{}Auto菜单项失败: {e}", section.log_name)))?; + menu_builder = menu_builder.item(&auto_item); + let mut sorted_providers: Vec<_> = manager.providers.iter().collect(); sorted_providers.sort_by(|(_, a), (_, b)| { match (a.sort_index, b.sort_index) { @@ -131,7 +156,8 @@ fn append_provider_section<'a>( }); for (id, provider) in sorted_providers { - let is_current = manager.current == *id; + // Auto 模式下所有供应商都不选中 + let is_current = !auto_mode && manager.current == *id; let item = CheckMenuItem::with_id( app, format!("{}{}", section.prefix, id), @@ -150,13 +176,27 @@ fn append_provider_section<'a>( /// 处理供应商托盘事件 pub fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool { for section in TRAY_SECTIONS.iter() { - if let Some(provider_id) = event_id.strip_prefix(section.prefix) { - log::info!("切换到{}供应商: {provider_id}", section.log_name); + if let Some(suffix) = event_id.strip_prefix(section.prefix) { + // 处理 Auto 点击 + if suffix == AUTO_SUFFIX { + log::info!("切换到{} Auto模式", section.log_name); + let app_handle = app.clone(); + let app_type = section.app_type.clone(); + tauri::async_runtime::spawn_blocking(move || { + if let Err(e) = handle_auto_click(&app_handle, &app_type) { + log::error!("切换{}Auto模式失败: {e}", section.log_name); + } + }); + return true; + } + + // 处理供应商点击 + log::info!("切换到{}供应商: {suffix}", section.log_name); let app_handle = app.clone(); - let provider_id = provider_id.to_string(); + let provider_id = suffix.to_string(); let app_type = section.app_type.clone(); tauri::async_runtime::spawn_blocking(move || { - if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) { + if let Err(e) = handle_provider_click(&app_handle, &app_type, &provider_id) { log::error!("切换{}供应商失败: {e}", section.log_name); } }); @@ -166,6 +206,80 @@ pub fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> boo false } +/// 处理 Auto 点击:启用 proxy 和 auto_failover +fn handle_auto_click(app: &tauri::AppHandle, app_type: &AppType) -> Result<(), AppError> { + if let Some(app_state) = app.try_state::() { + let app_type_str = app_type.as_str(); + + // 启用 proxy 和 auto_failover + app_state + .db + .set_proxy_flags_sync(app_type_str, true, true)?; + + // 更新托盘菜单 + if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) { + if let Some(tray) = app.tray_by_id("main") { + let _ = tray.set_menu(Some(new_menu)); + } + } + + // 发射事件到前端 + let event_data = serde_json::json!({ + "appType": app_type_str, + "proxyEnabled": true, + "autoFailoverEnabled": true + }); + if let Err(e) = app.emit("proxy-flags-changed", event_data) { + log::error!("发射 proxy-flags-changed 事件失败: {e}"); + } + } + Ok(()) +} + +/// 处理供应商点击:关闭 auto_failover + 切换供应商 +fn handle_provider_click( + app: &tauri::AppHandle, + app_type: &AppType, + provider_id: &str, +) -> Result<(), AppError> { + if let Some(app_state) = app.try_state::() { + let app_type_str = app_type.as_str(); + + // 获取当前 proxy 状态,保持 enabled 不变,只关闭 auto_failover + let (proxy_enabled, _) = app_state.db.get_proxy_flags_sync(app_type_str); + app_state + .db + .set_proxy_flags_sync(app_type_str, proxy_enabled, false)?; + + // 切换供应商 + crate::commands::switch_provider( + app_state.clone(), + app_type_str.to_string(), + provider_id.to_string(), + ) + .map_err(AppError::Message)?; + + // 更新托盘菜单 + if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) { + if let Some(tray) = app.tray_by_id("main") { + let _ = tray.set_menu(Some(new_menu)); + } + } + + // 发射事件到前端 + let event_data = serde_json::json!({ + "appType": app_type_str, + "proxyEnabled": proxy_enabled, + "autoFailoverEnabled": false, + "providerId": provider_id + }); + if let Err(e) = app.emit("proxy-flags-changed", event_data) { + log::error!("发射 proxy-flags-changed 事件失败: {e}"); + } + } + Ok(()) +} + /// 创建动态托盘菜单 pub fn create_tray_menu( app: &tauri::AppHandle, @@ -197,8 +311,14 @@ pub fn create_tray_menu( current: current_id, }; - menu_builder = - append_provider_section(app, menu_builder, Some(&manager), section, &tray_texts)?; + menu_builder = append_provider_section( + app, + menu_builder, + Some(&manager), + section, + &tray_texts, + app_state, + )?; } // 分隔符和退出菜单 @@ -263,38 +383,3 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) { } } } - -/// 内部切换供应商函数 -pub fn switch_provider_internal( - app: &tauri::AppHandle, - app_type: AppType, - provider_id: String, -) -> Result<(), AppError> { - if let Some(app_state) = app.try_state::() { - // 在使用前先保存需要的值 - let app_type_str = app_type.as_str().to_string(); - let provider_id_clone = provider_id.clone(); - - crate::commands::switch_provider(app_state.clone(), app_type_str.clone(), provider_id) - .map_err(AppError::Message)?; - - // 切换成功后重新创建托盘菜单 - if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) { - if let Some(tray) = app.tray_by_id("main") { - if let Err(e) = tray.set_menu(Some(new_menu)) { - log::error!("更新托盘菜单失败: {e}"); - } - } - } - - // 发射事件到前端,通知供应商已切换 - let event_data = serde_json::json!({ - "appType": app_type_str, - "providerId": provider_id_clone - }); - if let Err(e) = app.emit("provider-switched", event_data) { - log::error!("发射供应商切换事件失败: {e}"); - } - } - Ok(()) -} diff --git a/src/App.tsx b/src/App.tsx index 094856990..251028e78 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { RefreshCw, Search, Download, + BarChart2, } from "lucide-react"; import type { Provider } from "@/types"; import type { EnvConflict } from "@/types/env"; @@ -41,6 +42,7 @@ import { SettingsPage } from "@/components/settings/SettingsPage"; import { UpdateBadge } from "@/components/UpdateBadge"; import { EnvWarningBanner } from "@/components/env/EnvWarningBanner"; import { ProxyToggle } from "@/components/proxy/ProxyToggle"; +import { FailoverToggle } from "@/components/proxy/FailoverToggle"; import UsageScriptModal from "@/components/UsageScriptModal"; import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel"; import PromptPanel from "@/components/prompts/PromptPanel"; @@ -707,6 +709,22 @@ function App() { > + {isCurrentAppTakeoverActive && ( + + )} )} @@ -795,7 +813,19 @@ function App() { {currentView === "providers" && ( <> {activeApp !== "opencode" && ( - + <> + +
+ +
+ )} diff --git a/src/assets/icons/app-icon.png b/src/assets/icons/app-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6ea9e37ce4d55a30388036a3a0c9337159ce2387 GIT binary patch literal 2258 zcmV;@2rc)CP)Y3l$MT12R=jAJX5Iyz!IUIR|2 z=%|I(3+qTJBFBtYthBTejwT#Qfy4kg2;|x%yV>mdyWacty-ij*8jwyq{iDC#o&CM< z`@Qe^`5r$G{Esj0|6T!jI}bAq1Bd?@crBfN0Yh)$b$hqE*xOXti2qY9V#3MS_=-+% z464w7#RKk#R{d(pZ??9lOemOPkPAY{Qh@>oxetCg>!aGI<~6!=9d5$_2K5Al0CFtD z{aXse{LHc(VH5L`H8nMR;-UrX9WGbSl^gJirbKH})lYuBGkdywZqz@di&iTa@OU~&h-`vMvif|1?$zxdcix>Uxcw^9 zBnh#+MT6!91i~m9c0X~dxTQyKkMouUhLcr8r(mS1dV>c>iVC1Ople-G<<`SB@2kYp z%(BusNY#KK3W+R)BGM5y3vA-I*xn3LiPECLQm9fldjip(s-K^j+~!d(S4~Q+SaFwg z`Ex6?p53%2dvjw)WZNfkjF|+$j`!)rrXmMrIw{tT13}B|#5S#2g zIC6O4aMk+8JE#JPgoL}g@K6@7EG>U?)y%w=-ms!-I#y*J$qF(QpLg&Q?mj4EBMXEv z%M7uSk)wDCiVcWH44@EGG=NrAj9Y7UVxR#TXqpaqG@Jt7_K0`y5#OHSj4+IAx*;}( z3Da^MGX;(V2_hC42?&ImK(}%n2^og20VC8wjiuTW0T3tzZOjO!V#UUQY$#yhP!#EA zD<=%9^5q@Jy59cqc>jkDEpk)5dC@9)4F+iKrVQHpgXc;1BzE~DU4s!N5K%N)Ll_f* zd{`qWAOdybeuY$M;`$L3#~LC1kT5bt2^J!i18PJtw4jNRur(<+DXE}ftaSop41C2G zy%JA(=K8i8M`!%92&~D(vt|`x`Ti*x_dmO+2`{p`Z$iEs;# zRFWnPqmMkw5g3xSfk=w8EM}mfD8D=2{*I1V_Kt$&yVLBJtOQ9AS>iRG;8oSJGHM;T zzQ<2YPb_?OS^4&vuH@+f1p$*Tg9bj?Xc3%XeyBP2>}TdY?AARnNecr7Lu9cjY@g=u zc=Vm7@Bih@P))AWoKuu$E1sI|m~q7yZr^^a{jInR_GZE#pIxxw?Ufb#GKd8_*-?T) z|6VqGV0*@F+c?E*OqIJJ0V00i>4xWgYI8OASg|1Tnha5T8k4m8J1GyD1w-0>bYRcL zzR-owFZ3O1xZ*#3({4!I9;0npQuf-GrPF?ih;L024^Eu#{QP?ddmsHI)ota8v-4DM z%zb`F#GE98+8}7%bg1PShnXiC#bAH02}gn|dSdmsCy%^5>2QWaNTqzq@FLu59*Bj- zZWhKc0!N2D{EPkd8|VJ^_&2%v%hoxpCYy=O7^yfy&l-L)fn!h@+*4q=^R>FptvhSm zb|u>+UR8J`dy?4bprTbyu~nVgR?^@~6SGPDRoV|mor+04pk)I*xwroD3!XspXJ7Ol zINjkpIpg6qTXQFlFAtKTL+WllYgc_)SP<+Hta-DHUTb-B&^&d5qk7eQbq|z`b4<%i zNpuDMCLKyPXtIVtYPvKw*~UBDdX$b>#TqU?Y+BqJZG19J3~6+-%mEao2|C?7F#@EQJG~9<*{=6(fgvLj*23~;XymQ z=fKde`+jzIerKvL#{ib&Ilk<{m9JBUIZ*_Mf4yy43X1UN1as{4 zpH6k|H+ou{9D)~A7Yc3~!E;@K#ArK54$(g_4%H`e~84obKU;a z0aXouS{FD#yIrqW?G8jxFjnJ7(n?JoBW^ezkibp!?z_eYn5GP8g%|zKz$iFNI&flv9 gV`L4lUVq)|pSM;gdKoTVk^lez07*qoM6N<$fk literal 0 HcmV?d00001 diff --git a/src/components/proxy/FailoverToggle.tsx b/src/components/proxy/FailoverToggle.tsx new file mode 100644 index 000000000..9ec4c0222 --- /dev/null +++ b/src/components/proxy/FailoverToggle.tsx @@ -0,0 +1,76 @@ +/** + * 故障转移切换开关组件 + * + * 放置在主界面头部,用于一键启用/关闭自动故障转移 + */ + +import { Shuffle, Loader2 } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { + useAutoFailoverEnabled, + useSetAutoFailoverEnabled, +} from "@/lib/query/failover"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; +import type { AppId } from "@/lib/api"; + +interface FailoverToggleProps { + className?: string; + activeApp: AppId; +} + +export function FailoverToggle({ className, activeApp }: FailoverToggleProps) { + const { t } = useTranslation(); + const { data: isEnabled = false, isLoading } = + useAutoFailoverEnabled(activeApp); + const setEnabled = useSetAutoFailoverEnabled(); + + const handleToggle = (checked: boolean) => { + setEnabled.mutate({ appType: activeApp, enabled: checked }); + }; + + const appLabel = + activeApp === "claude" + ? "Claude" + : activeApp === "codex" + ? "Codex" + : "Gemini"; + + const tooltipText = isEnabled + ? t("failover.tooltip.enabled", { + app: appLabel, + defaultValue: `${appLabel} 故障转移已启用\n自动切换到下一个可用供应商`, + }) + : t("failover.tooltip.disabled", { + app: appLabel, + defaultValue: `启用 ${appLabel} 故障转移\n当当前供应商失败时自动切换`, + }); + + return ( +
+ {setEnabled.isPending || isLoading ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/src/components/proxy/ProxyToggle.tsx b/src/components/proxy/ProxyToggle.tsx index ca526eb4b..462c3d9bb 100644 --- a/src/components/proxy/ProxyToggle.tsx +++ b/src/components/proxy/ProxyToggle.tsx @@ -55,39 +55,29 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) { return (
-
- {isPending ? ( - - ) : ( - - )} - + ) : ( + - Proxy - - -
+ )} +
); } diff --git a/src/components/settings/AboutSection.tsx b/src/components/settings/AboutSection.tsx index 6db2fd83d..6194c3bc6 100644 --- a/src/components/settings/AboutSection.tsx +++ b/src/components/settings/AboutSection.tsx @@ -9,7 +9,6 @@ import { Terminal, CheckCircle2, AlertCircle, - Sparkles, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useTranslation } from "react-i18next"; @@ -20,6 +19,7 @@ import { useUpdate } from "@/contexts/UpdateContext"; import { relaunchApp } from "@/lib/updater"; import { Badge } from "@/components/ui/badge"; import { motion } from "framer-motion"; +import appIcon from "@/assets/icons/app-icon.png"; interface AboutSectionProps { isPortable: boolean; @@ -204,7 +204,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
- + CC Switch

CC Switch

diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index cf246739f..6553c7a0b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1088,6 +1088,20 @@ "circuitOpen": "Circuit Open", "consecutiveFailures": "{{count}} consecutive failures" }, + "failover": { + "enabled": "{{app}} failover enabled", + "disabled": "{{app}} failover disabled", + "toggleFailed": "Operation failed: {{detail}}", + "inQueue": "In queue", + "addQueue": "Add", + "priority": { + "tooltip": "Failover priority {{priority}}" + }, + "tooltip": { + "enabled": "{{app}} failover enabled\nAutomatically switch to next available provider", + "disabled": "Enable {{app}} failover\nAutomatically switch when current provider fails" + } + }, "proxy": { "panel": { "serviceAddress": "Service Address", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 81a19a50d..3e2a0abc9 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1088,6 +1088,20 @@ "circuitOpen": "サーキットオープン", "consecutiveFailures": "{{count}} 回連続失敗" }, + "failover": { + "enabled": "{{app}} フェイルオーバーが有効になりました", + "disabled": "{{app}} フェイルオーバーが無効になりました", + "toggleFailed": "操作に失敗しました: {{detail}}", + "inQueue": "キュー内", + "addQueue": "追加", + "priority": { + "tooltip": "フェイルオーバー優先度 {{priority}}" + }, + "tooltip": { + "enabled": "{{app}} フェイルオーバーが有効\n次の利用可能なプロバイダーに自動切り替え", + "disabled": "{{app}} フェイルオーバーを有効にする\n現在のプロバイダーが失敗した場合に自動切り替え" + } + }, "proxy": { "panel": { "serviceAddress": "サービスアドレス", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 1f90c7217..67388b1a6 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1088,6 +1088,20 @@ "circuitOpen": "熔断", "consecutiveFailures": "连续失败 {{count}} 次" }, + "failover": { + "enabled": "{{app}} 故障转移已启用", + "disabled": "{{app}} 故障转移已关闭", + "toggleFailed": "操作失败: {{detail}}", + "inQueue": "已加入", + "addQueue": "加入", + "priority": { + "tooltip": "故障转移优先级 {{priority}}" + }, + "tooltip": { + "enabled": "{{app}} 故障转移已启用\n自动切换到下一个可用供应商", + "disabled": "启用 {{app}} 故障转移\n当当前供应商失败时自动切换" + } + }, "proxy": { "panel": { "serviceAddress": "服务地址", diff --git a/src/lib/query/failover.ts b/src/lib/query/failover.ts index 4ba57bcfa..87d3d1d5a 100644 --- a/src/lib/query/failover.ts +++ b/src/lib/query/failover.ts @@ -1,5 +1,8 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { failoverApi } from "@/lib/api/failover"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { extractErrorMessage } from "@/utils/errorUtils"; // ========== 熔断器 Hooks ========== @@ -197,6 +200,7 @@ export function useAutoFailoverEnabled(appType: string) { */ export function useSetAutoFailoverEnabled() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: ({ appType, enabled }: { appType: string; enabled: boolean }) => @@ -217,14 +221,45 @@ export function useSetAutoFailoverEnabled() { return { previousValue, appType }; }, + onSuccess: (_data, variables) => { + const appLabel = + variables.appType === "claude" + ? "Claude" + : variables.appType === "codex" + ? "Codex" + : "Gemini"; + + toast.success( + variables.enabled + ? t("failover.enabled", { + app: appLabel, + defaultValue: `${appLabel} 故障转移已启用`, + }) + : t("failover.disabled", { + app: appLabel, + defaultValue: `${appLabel} 故障转移已关闭`, + }), + { closeButton: true }, + ); + }, + // 错误时回滚 - onError: (_error, _variables, context) => { + onError: (error: Error, _variables, context) => { if (context?.previousValue !== undefined) { queryClient.setQueryData( ["autoFailoverEnabled", context.appType], context.previousValue, ); } + + const detail = + extractErrorMessage(error) || + t("common.unknown", { defaultValue: "未知错误" }); + toast.error( + t("failover.toggleFailed", { + defaultValue: `操作失败: ${detail}`, + }), + ); }, // 无论成功失败,都重新获取