import { useState, useEffect } from "react"; import { Activity, Clock, TrendingUp, Server, ListOrdered, Save, Loader2, Zap, Power, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { ToggleRow } from "@/components/ui/toggle-row"; import { useProxyStatus } from "@/hooks/useProxyStatus"; import { toast } from "sonner"; import { useFailoverQueue } from "@/lib/query/failover"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; import { useProviderHealth } from "@/lib/query/failover"; import { useProxyTakeoverStatus, useSetProxyTakeoverForApp, useGlobalProxyConfig, useUpdateGlobalProxyConfig, } from "@/lib/query/proxy"; import type { ProxyStatus } from "@/types/proxy"; import { useTranslation } from "react-i18next"; import { AnimatePresence, motion } from "framer-motion"; import { extractErrorMessage } from "@/utils/errorUtils"; interface ProxyPanelProps { enableLocalProxy: boolean; onEnableLocalProxyChange: (checked: boolean) => void; onToggleProxy: (checked: boolean) => Promise; isProxyPending: boolean; } export function ProxyPanel({ enableLocalProxy, onEnableLocalProxyChange, onToggleProxy, isProxyPending, }: ProxyPanelProps) { const { t } = useTranslation(); const { status, isRunning } = useProxyStatus(); // 获取应用接管状态 const { data: takeoverStatus } = useProxyTakeoverStatus(); const setTakeoverForApp = useSetProxyTakeoverForApp(); // 获取全局代理配置 const { data: globalConfig } = useGlobalProxyConfig(); const updateGlobalConfig = useUpdateGlobalProxyConfig(); // 监听地址/端口的本地状态(端口用字符串以支持完全清空) const [listenAddress, setListenAddress] = useState("127.0.0.1"); const [listenPort, setListenPort] = useState("15721"); // 同步全局配置到本地状态 useEffect(() => { if (globalConfig) { setListenAddress(globalConfig.listenAddress); setListenPort(String(globalConfig.listenPort)); } }, [globalConfig]); // 获取所有三个应用类型的故障转移队列 // 启用自动故障转移后,将按队列优先级(P1→P2→...)选择供应商 const { data: claudeQueue = [] } = useFailoverQueue("claude"); const { data: codexQueue = [] } = useFailoverQueue("codex"); const { data: geminiQueue = [] } = useFailoverQueue("gemini"); const handleTakeoverChange = async (appType: string, enabled: boolean) => { try { await setTakeoverForApp.mutateAsync({ appType, enabled }); toast.success( enabled ? t("proxy.takeover.enabled", { app: appType, defaultValue: `${appType} 接管已启用`, }) : t("proxy.takeover.disabled", { app: appType, defaultValue: `${appType} 接管已关闭`, }), { closeButton: true }, ); } catch (error) { const detail = extractErrorMessage(error) || t("common.unknown", { defaultValue: "未知错误" }); toast.error( t("proxy.takeover.failed", { detail, defaultValue: "切换接管状态失败", }), ); } }; const handleLoggingChange = async (enabled: boolean) => { if (!globalConfig) return; try { await updateGlobalConfig.mutateAsync({ ...globalConfig, enableLogging: enabled, }); toast.success( enabled ? t("proxy.logging.enabled", { defaultValue: "日志记录已启用" }) : t("proxy.logging.disabled", { defaultValue: "日志记录已关闭" }), { closeButton: true }, ); } catch (error) { toast.error( t("proxy.logging.failed", { defaultValue: "切换日志状态失败" }), ); } }; const handleSaveBasicConfig = async () => { if (!globalConfig) return; // 校验地址格式(IPv4 / IPv6 字面量 / localhost) const addressTrimmed = listenAddress.trim(); const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; const isValidIpv4 = (addr: string): boolean => ipv4Regex.test(addr) && addr.split(".").every((n) => { const num = parseInt(n, 10); return num >= 0 && num <= 255; }); // IPv6 字面量校验:必须含 `:` 且能在 [..] 包装后被 URL 解析器接受。 // 后端 (services/proxy.rs) 会把 `::` 改写成 `::1`,所以这里也接受 `::`。 const isValidIpv6 = (addr: string): boolean => { if (!addr.includes(":")) return false; try { new URL(`http://[${addr}]/`); return true; } catch { return false; } }; const isValidAddress = addressTrimmed === "localhost" || addressTrimmed === "0.0.0.0" || isValidIpv4(addressTrimmed) || isValidIpv6(addressTrimmed); if (!isValidAddress) { toast.error( t("proxy.settings.invalidAddress", { defaultValue: "地址无效,请输入 IPv4(如 127.0.0.1)、IPv6(如 ::1)或 localhost", }), ); return; } // 严格校验端口:必须是纯数字 const portTrimmed = listenPort.trim(); if (!/^\d+$/.test(portTrimmed)) { toast.error( t("proxy.settings.invalidPort", { defaultValue: "端口无效,请输入 1024-65535 之间的数字", }), ); return; } const port = parseInt(portTrimmed); if (isNaN(port) || port < 1024 || port > 65535) { toast.error( t("proxy.settings.invalidPort", { defaultValue: "端口无效,请输入 1024-65535 之间的数字", }), ); return; } try { await updateGlobalConfig.mutateAsync({ ...globalConfig, listenAddress: addressTrimmed, listenPort: port, }); toast.success( t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }), { closeButton: true }, ); } catch (error) { toast.error( t("proxy.settings.configSaveFailed", { defaultValue: "保存配置失败" }), ); } }; const formatUptime = (seconds: number): string => { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = seconds % 60; if (hours > 0) { return `${hours}h ${minutes}m ${secs}s`; } else if (minutes > 0) { return `${minutes}m ${secs}s`; } else { return `${secs}s`; } }; // 格式化地址用于 URL(IPv6 需要方括号) const formatAddressForUrl = (address: string, port: number): string => { const isIPv6 = address.includes(":"); const host = isIPv6 ? `[${address}]` : address; return `http://${host}:${port}`; }; return ( <>
{/* [1] Enable proxy button on main page — always visible */} } title={t("settings.advanced.proxy.enableFeature")} description={t("settings.advanced.proxy.enableFeatureDescription")} checked={enableLocalProxy} onCheckedChange={onEnableLocalProxyChange} /> {/* [2] Proxy service toggle — always visible */}

{t("proxyConfig.proxyEnabled", { defaultValue: "代理服务", })}

{isRunning ? t("settings.advanced.proxy.running") : t("settings.advanced.proxy.stopped")}

{/* [3] App takeover switches — animated, visible only when proxy is running */} {isRunning && (

{t("proxyConfig.appTakeover", { defaultValue: "应用接管", })}

{(["claude", "codex", "gemini"] as const).map((appType) => { const isEnabled = takeoverStatus?.[ appType as keyof typeof takeoverStatus ] ?? false; return (
{appType} handleTakeoverChange(appType, checked) } disabled={setTakeoverForApp.isPending} />
); })}

{t("proxy.takeover.hint", { defaultValue: "选择要接管的应用,启用后该应用的请求将通过本地代理转发", })}

)}
{/* Running state: service info + stats */} {isRunning && status ? (
{/* [4] Running info: address + current provider */}

{t("proxy.panel.serviceAddress", { defaultValue: "服务地址", })}

{formatAddressForUrl(status.address, status.port)}

{t("proxy.settings.restartRequired", { defaultValue: "修改监听地址/端口需要先停止代理服务", })}

{t("provider.inUse")}

{status.active_targets && status.active_targets.length > 0 ? (
{status.active_targets.map((target) => (
{target.app_type} {target.provider_name}
))}
) : status.current_provider ? (

{t("proxy.panel.currentProvider", { defaultValue: "当前 Provider:", })}{" "} {status.current_provider}

) : (

{t("proxy.panel.waitingFirstRequest", { defaultValue: "当前 Provider:等待首次请求…", })}

)}
{/* [5] Logging toggle */}

{t("proxy.settings.fields.enableLogging.description", { defaultValue: "记录所有代理请求,便于排查问题", })}

{/* [6] Provider queues */} {(claudeQueue.length > 0 || codexQueue.length > 0 || geminiQueue.length > 0) && (

{t("proxy.failoverQueue.title")}

{claudeQueue.length > 0 && ( ({ id: item.providerId, name: item.providerName, }))} status={status} /> )} {codexQueue.length > 0 && ( ({ id: item.providerId, name: item.providerName, }))} status={status} /> )} {geminiQueue.length > 0 && ( ({ id: item.providerId, name: item.providerName, }))} status={status} /> )}
)}
{/* [7] Stats cards */}
} label={t("proxy.panel.stats.activeConnections", { defaultValue: "活跃连接", })} value={status.active_connections} /> } label={t("proxy.panel.stats.totalRequests", { defaultValue: "总请求数", })} value={status.total_requests} /> } label={t("proxy.panel.stats.successRate", { defaultValue: "成功率", })} value={`${status.success_rate.toFixed(1)}%`} variant={status.success_rate > 90 ? "success" : "warning"} /> } label={t("proxy.panel.stats.uptime", { defaultValue: "运行时间", })} value={formatUptime(status.uptime_seconds)} />
) : (
{/* [8] Basic settings — address/port (only when stopped) */}

{t("proxy.settings.basic.title", { defaultValue: "基础设置", })}

{t("proxy.settings.basic.description", { defaultValue: "配置代理服务监听的地址与端口。", })}

setListenAddress(e.target.value)} placeholder={t( "proxy.settings.fields.listenAddress.placeholder", { defaultValue: "127.0.0.1", }, )} />

{t("proxy.settings.fields.listenAddress.description", { defaultValue: "代理服务器监听的 IP 地址(推荐 127.0.0.1)", })}

setListenPort(e.target.value)} placeholder={t( "proxy.settings.fields.listenPort.placeholder", { defaultValue: "15721", }, )} />

{t("proxy.settings.fields.listenPort.description", { defaultValue: "代理服务器监听的端口号(1024 ~ 65535)", })}

{/* Stopped hint */}

{t("proxy.panel.stoppedTitle", { defaultValue: "代理服务已停止", })}

{t("proxy.panel.stoppedDescription", { defaultValue: "使用上方开关即可启动服务", })}

)}
); } interface StatCardProps { icon: React.ReactNode; label: string; value: string | number; variant?: "default" | "success" | "warning"; } function StatCard({ icon, label, value, variant = "default" }: StatCardProps) { const variantStyles = { default: "", success: "border-green-500/40 bg-green-500/5", warning: "border-yellow-500/40 bg-yellow-500/5", }; return (
{icon} {label}

{value}

); } interface ProviderQueueGroupProps { appType: string; appLabel: string; targets: Array<{ id: string; name: string; }>; status: ProxyStatus; } function ProviderQueueGroup({ appType, appLabel, targets, status, }: ProviderQueueGroupProps) { // 查找该应用类型的当前活跃目标 const activeTarget = status.active_targets?.find( (t) => t.app_type === appType, ); return (
{/* 应用类型标题 */}
{appLabel}
{/* 供应商列表 */}
{targets.map((target, index) => ( ))}
); } interface ProviderQueueItemProps { provider: { id: string; name: string; }; priority: number; appType: string; isCurrent: boolean; } function ProviderQueueItem({ provider, priority, appType, isCurrent, }: ProviderQueueItemProps) { const { t } = useTranslation(); const { data: health } = useProviderHealth(provider.id, appType); return (
{priority} {provider.name} {isCurrent && ( {t("provider.inUse")} )}
{/* 健康徽章 */}
); }