mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 23:56:02 +08:00
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
This commit is contained in:
@@ -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<bool, String> {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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<bool, AppError> {
|
||||
// v3.7.0+:以 proxy_live_backup 是否存在作为“接管状态”的真实来源(更贴近 per-app 接管)
|
||||
self.has_any_live_backup().await
|
||||
self.has_any_proxy_takeover()
|
||||
}
|
||||
|
||||
// ==================== Provider Health ====================
|
||||
|
||||
@@ -62,4 +62,56 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- 代理接管状态管理 ---
|
||||
|
||||
/// 获取指定应用的代理接管状态
|
||||
///
|
||||
/// 使用 settings 表存储各应用的接管状态,key 格式: `proxy_takeover_{app_type}`
|
||||
pub fn get_proxy_takeover_enabled(&self, app_type: &str) -> Result<bool, AppError> {
|
||||
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<bool, AppError> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
+60
-4
@@ -554,7 +554,7 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 异常退出恢复
|
||||
// 异常退出恢复 + 代理状态自动恢复
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = app_handle.state::<AppState>();
|
||||
@@ -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::<store::AppState>() {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 迁移错误对话框辅助函数
|
||||
// ============================================================
|
||||
|
||||
@@ -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<Vec<Provider>, 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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -350,7 +350,9 @@ function App() {
|
||||
appId={activeApp}
|
||||
isLoading={isLoading}
|
||||
isProxyRunning={isProxyRunning}
|
||||
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
|
||||
isProxyTakeover={
|
||||
isProxyRunning && isCurrentAppTakeoverActive
|
||||
}
|
||||
onSwitch={switchProvider}
|
||||
onEdit={setEditingProvider}
|
||||
onDelete={setConfirmDelete}
|
||||
|
||||
@@ -237,9 +237,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||
{/* 左侧:服务器信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium text-foreground">
|
||||
{name}
|
||||
</h3>
|
||||
<h3 className="font-medium text-foreground">{name}</h3>
|
||||
{docsUrl && (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -36,9 +36,7 @@ const PromptListItem: React.FC<PromptListItemProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-foreground mb-1">
|
||||
{prompt.name}
|
||||
</h3>
|
||||
<h3 className="font-medium text-foreground mb-1">{prompt.name}</h3>
|
||||
{prompt.description && (
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{prompt.description}
|
||||
|
||||
@@ -114,10 +114,7 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
|
||||
) : promptEntries.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
|
||||
<FileText
|
||||
size={24}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<FileText size={24} className="text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{t("prompts.empty")}
|
||||
|
||||
@@ -153,7 +153,10 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
||||
<FormItem>
|
||||
<FormLabel>{t("provider.websiteUrl")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={t("providerForm.websiteUrlPlaceholder")} />
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t("providerForm.websiteUrlPlaceholder")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -51,7 +51,9 @@ export function ProxyPanel() {
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.panel.serviceAddress", { defaultValue: "服务地址" })}
|
||||
{t("proxy.panel.serviceAddress", {
|
||||
defaultValue: "服务地址",
|
||||
})}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -229,7 +231,9 @@ export function ProxyPanel() {
|
||||
<Server className="h-8 w-8" />
|
||||
</div>
|
||||
<p className="text-base font-medium text-foreground mb-1">
|
||||
{t("proxy.panel.stoppedTitle", { defaultValue: "代理服务已停止" })}
|
||||
{t("proxy.panel.stoppedTitle", {
|
||||
defaultValue: "代理服务已停止",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t("proxy.panel.stoppedDescription", {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -49,10 +49,7 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"p-1 rounded-xl transition-all",
|
||||
className,
|
||||
)}
|
||||
className={cn("p-1 rounded-xl transition-all", className)}
|
||||
title={tooltipText}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-2 h-8 rounded-md cursor-default">
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6">
|
||||
{isBusy ? (
|
||||
@@ -215,9 +239,7 @@ export function SettingsPage({
|
||||
<TabsTrigger value="advanced">
|
||||
{t("settings.tabAdvanced")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="usage">
|
||||
{t("usage.title")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="usage">{t("usage.title")}</TabsTrigger>
|
||||
<TabsTrigger value="about">{t("common.about")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -318,7 +340,9 @@ export function SettingsPage({
|
||||
<Activity
|
||||
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
|
||||
/>
|
||||
{isRunning ? t("settings.advanced.proxy.running") : t("settings.advanced.proxy.stopped")}
|
||||
{isRunning
|
||||
? t("settings.advanced.proxy.running")
|
||||
: t("settings.advanced.proxy.stopped")}
|
||||
</Badge>
|
||||
<Switch
|
||||
checked={isRunning}
|
||||
@@ -376,8 +400,11 @@ export function SettingsPage({
|
||||
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
<Switch
|
||||
checked={failoverEnabled}
|
||||
onCheckedChange={setFailoverEnabled}
|
||||
checked={failoverEnabled && isRunning}
|
||||
onCheckedChange={handleToggleFailover}
|
||||
disabled={
|
||||
setAutoFailoverEnabled.isPending || isProxyPending
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</AccordionPrimitive.Header>
|
||||
@@ -402,19 +429,19 @@ export function SettingsPage({
|
||||
<TabsContent value="claude" className="mt-4">
|
||||
<FailoverQueueManager
|
||||
appType="claude"
|
||||
disabled={!failoverEnabled}
|
||||
disabled={!failoverEnabled || !isRunning}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="codex" className="mt-4">
|
||||
<FailoverQueueManager
|
||||
appType="codex"
|
||||
disabled={!failoverEnabled}
|
||||
disabled={!failoverEnabled || !isRunning}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="gemini" className="mt-4">
|
||||
<FailoverQueueManager
|
||||
appType="gemini"
|
||||
disabled={!failoverEnabled}
|
||||
disabled={!failoverEnabled || !isRunning}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
@@ -423,8 +450,8 @@ export function SettingsPage({
|
||||
{/* 熔断器配置 */}
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
enabled={failoverEnabled}
|
||||
onEnabledChange={setFailoverEnabled}
|
||||
enabled={failoverEnabled && isRunning}
|
||||
onEnabledChange={handleToggleFailover}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="claudeModel">
|
||||
{t("streamCheck.claudeModel")}
|
||||
</Label>
|
||||
<Label htmlFor="claudeModel">{t("streamCheck.claudeModel")}</Label>
|
||||
<Input
|
||||
id="claudeModel"
|
||||
value={config.claudeModel}
|
||||
@@ -96,9 +92,7 @@ export function ModelTestConfigPanel() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="codexModel">
|
||||
{t("streamCheck.codexModel")}
|
||||
</Label>
|
||||
<Label htmlFor="codexModel">{t("streamCheck.codexModel")}</Label>
|
||||
<Input
|
||||
id="codexModel"
|
||||
value={config.codexModel}
|
||||
@@ -110,9 +104,7 @@ export function ModelTestConfigPanel() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="geminiModel">
|
||||
{t("streamCheck.geminiModel")}
|
||||
</Label>
|
||||
<Label htmlFor="geminiModel">{t("streamCheck.geminiModel")}</Label>
|
||||
<Input
|
||||
id="geminiModel"
|
||||
value={config.geminiModel}
|
||||
@@ -132,9 +124,7 @@ export function ModelTestConfigPanel() {
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timeoutSecs">
|
||||
{t("streamCheck.timeout")}
|
||||
</Label>
|
||||
<Label htmlFor="timeoutSecs">{t("streamCheck.timeout")}</Label>
|
||||
<Input
|
||||
id="timeoutSecs"
|
||||
type="number"
|
||||
@@ -151,9 +141,7 @@ export function ModelTestConfigPanel() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxRetries">
|
||||
{t("streamCheck.maxRetries")}
|
||||
</Label>
|
||||
<Label htmlFor="maxRetries">{t("streamCheck.maxRetries")}</Label>
|
||||
<Input
|
||||
id="maxRetries"
|
||||
type="number"
|
||||
|
||||
@@ -93,8 +93,7 @@ export function PricingConfigPanel() {
|
||||
<CardContent>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t("usage.loadPricingError")}:{" "}
|
||||
{String(error)}
|
||||
{t("usage.loadPricingError")}: {String(error)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
@@ -124,9 +123,7 @@ export function PricingConfigPanel() {
|
||||
<div className="space-y-4">
|
||||
{!pricing || pricing.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t("usage.noPricingData")}
|
||||
</AlertDescription>
|
||||
<AlertDescription>{t("usage.noPricingData")}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="rounded-md bg-card/60 shadow-sm">
|
||||
@@ -220,9 +217,7 @@ export function PricingConfigPanel() {
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("usage.deleteConfirmTitle")}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{t("usage.deleteConfirmTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("usage.deleteConfirmDesc")}
|
||||
</DialogDescription>
|
||||
|
||||
@@ -87,9 +87,7 @@ export function RequestLogTable() {
|
||||
<SelectValue placeholder={t("usage.appType")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("usage.allApps")}
|
||||
</SelectItem>
|
||||
<SelectItem value="all">{t("usage.allApps")}</SelectItem>
|
||||
<SelectItem value="claude">Claude</SelectItem>
|
||||
<SelectItem value="codex">Codex</SelectItem>
|
||||
<SelectItem value="gemini">Gemini</SelectItem>
|
||||
@@ -149,9 +147,7 @@ export function RequestLogTable() {
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="whitespace-nowrap">
|
||||
{t("usage.timeRange")}:
|
||||
</span>
|
||||
<span className="whitespace-nowrap">{t("usage.timeRange")}:</span>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
className="h-8 w-[200px] bg-background"
|
||||
@@ -283,8 +279,7 @@ export function RequestLogTable() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.providerName ||
|
||||
t("usage.unknownProvider")}
|
||||
{log.providerName || t("usage.unknownProvider")}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="font-mono text-sm max-w-[280px] truncate"
|
||||
|
||||
@@ -26,9 +26,7 @@ export function UsageDashboard() {
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-2xl font-bold">{t("usage.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("usage.subtitle")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("usage.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
|
||||
@@ -56,7 +56,7 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
||||
color: "text-purple-500",
|
||||
bg: "bg-purple-500/10",
|
||||
subValue: (
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{t("usage.input")}</span>
|
||||
<span className="text-foreground/80">
|
||||
@@ -78,21 +78,21 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
||||
icon: Database,
|
||||
color: "text-orange-500",
|
||||
bg: "bg-orange-500/10",
|
||||
subValue: (
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{t("usage.cacheWrite")}</span>
|
||||
<span className="text-foreground/80">
|
||||
{(cacheWriteTokens / 1000).toFixed(1)}k
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{t("usage.cacheRead")}</span>
|
||||
<span className="text-foreground/80">
|
||||
{(cacheReadTokens / 1000).toFixed(1)}k
|
||||
</span>
|
||||
</div>
|
||||
subValue: (
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{t("usage.cacheWrite")}</span>
|
||||
<span className="text-foreground/80">
|
||||
{(cacheWriteTokens / 1000).toFixed(1)}k
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{t("usage.cacheRead")}</span>
|
||||
<span className="text-foreground/80">
|
||||
{(cacheReadTokens / 1000).toFixed(1)}k
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -104,4 +104,14 @@ export const failoverApi = {
|
||||
enabled,
|
||||
});
|
||||
},
|
||||
|
||||
// 获取自动故障转移总开关状态
|
||||
async getAutoFailoverEnabled(): Promise<boolean> {
|
||||
return invoke("get_auto_failover_enabled");
|
||||
},
|
||||
|
||||
// 设置自动故障转移总开关状态
|
||||
async setAutoFailoverEnabled(enabled: boolean): Promise<void> {
|
||||
return invoke("set_auto_failover_enabled", { enabled });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<import("@/types/proxy").FailoverQueueItem[]>(
|
||||
["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<boolean>([
|
||||
"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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user