mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +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:
+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