mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 08:44:41 +08:00
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:
+31
-1
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "サービスアドレス",
|
||||
|
||||
@@ -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": "服务地址",
|
||||
|
||||
@@ -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}`,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
// 无论成功失败,都重新获取
|
||||
|
||||
Reference in New Issue
Block a user