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
This commit is contained in:
YoVinchen
2025-12-21 12:04:44 +08:00
parent b67cdbb18c
commit f05904821c
22 changed files with 292 additions and 127 deletions
+25
View File
@@ -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())
}
+19 -5
View File
@@ -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(())
}
+2
View File
@@ -678,6 +678,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,
+43 -32
View File
@@ -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(&current_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() {
+3 -1
View File
@@ -350,7 +350,9 @@ function App() {
appId={activeApp}
isLoading={isLoading}
isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
isProxyTakeover={
isProxyRunning && isCurrentAppTakeoverActive
}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
+1 -3
View File
@@ -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"
+1 -3
View File
@@ -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}
+1 -4
View File
@@ -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)}
+6 -2
View File
@@ -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", {
+5 -1
View File
@@ -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) => {
+1 -4
View File
@@ -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">
+39 -12
View File
@@ -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>
+6 -18
View File
@@ -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"
+3 -8
View File
@@ -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>
+3 -8
View File
@@ -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"
+1 -3
View File
@@ -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
+15 -15
View File
@@ -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>
),
},
];
+8 -4
View File
@@ -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,
+10
View File
@@ -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 });
},
};
+95 -1
View File
@@ -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"] });
},
});
}