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
This commit is contained in:
YoVinchen
2026-01-15 21:54:05 +08:00
committed by Jason
parent 22c0e7bb5c
commit a8ea99c3fe
12 changed files with 388 additions and 73 deletions
+31 -1
View File
@@ -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() {
>
<Settings className="w-4 h-4" />
</Button>
{isCurrentAppTakeoverActive && (
<Button
variant="ghost"
size="icon"
onClick={() => {
setSettingsDefaultTab("usage");
setCurrentView("settings");
}}
title={t("settings.usage.title", {
defaultValue: "使用统计",
})}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<BarChart2 className="w-4 h-4" />
</Button>
)}
</div>
)}
</div>
@@ -795,7 +813,19 @@ function App() {
{currentView === "providers" && (
<>
{activeApp !== "opencode" && (
<ProxyToggle activeApp={activeApp} />
<>
<ProxyToggle activeApp={activeApp} />
<div
className={cn(
"transition-all duration-300 ease-in-out overflow-hidden",
isCurrentAppTakeoverActive
? "opacity-100 max-w-[100px] scale-100"
: "opacity-0 max-w-0 scale-75 pointer-events-none",
)}
>
<FailoverToggle activeApp={activeApp} />
</div>
</>
)}
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+76
View File
@@ -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 (
<div
className={cn(
"flex items-center gap-1 px-1.5 h-8 rounded-lg bg-muted/50 transition-all",
className,
)}
title={tooltipText}
>
{setEnabled.isPending || isLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Shuffle
className={cn(
"h-4 w-4 transition-colors",
isEnabled
? "text-emerald-500 animate-pulse"
: "text-muted-foreground",
)}
/>
)}
<Switch
checked={isEnabled}
onCheckedChange={handleToggle}
disabled={setEnabled.isPending || isLoading}
/>
</div>
);
}
+16 -26
View File
@@ -55,39 +55,29 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
return (
<div
className={cn("p-1 rounded-xl transition-all", className)}
className={cn(
"flex items-center gap-1 px-1.5 h-8 rounded-lg bg-muted/50 transition-all",
className,
)}
title={tooltipText}
>
<div className="flex items-center gap-2 px-2 h-8 rounded-md cursor-default">
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Radio
className={cn(
"h-4 w-4 transition-colors",
takeoverEnabled
? "text-emerald-500 animate-pulse"
: "text-muted-foreground",
)}
/>
)}
<span
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Radio
className={cn(
"text-sm font-medium transition-colors select-none",
"h-4 w-4 transition-colors",
takeoverEnabled
? "text-emerald-600 dark:text-emerald-400"
? "text-emerald-500 animate-pulse"
: "text-muted-foreground",
)}
>
Proxy
</span>
<Switch
checked={takeoverEnabled}
onCheckedChange={handleToggle}
disabled={isPending}
className="ml-1"
/>
</div>
)}
<Switch
checked={takeoverEnabled}
onCheckedChange={handleToggle}
disabled={isPending}
/>
</div>
);
}
+2 -2
View File
@@ -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) {
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
<img src={appIcon} alt="CC Switch" className="h-5 w-5" />
<h4 className="text-lg font-semibold text-foreground">
CC Switch
</h4>
+14
View File
@@ -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",
+14
View File
@@ -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": "サービスアドレス",
+14
View File
@@ -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": "服务地址",
+36 -1
View File
@@ -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}`,
}),
);
},
// 无论成功失败,都重新获取