diff --git a/src/App.tsx b/src/App.tsx index ecc966bfa..a7860d577 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ import { } from "@/lib/api"; import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env"; import { useProviderActions } from "@/hooks/useProviderActions"; +import { useProxyStatus } from "@/hooks/useProxyStatus"; import { extractErrorMessage } from "@/utils/errorUtils"; import { cn } from "@/lib/utils"; import { AppSwitcher } from "@/components/AppSwitcher"; @@ -62,7 +63,13 @@ function App() { const addActionButtonClass = "bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8"; - const { data, isLoading, refetch } = useProvidersQuery(activeApp); + // 获取代理服务状态 + const { isRunning: isProxyRunning } = useProxyStatus(); + + // 获取供应商列表,当代理服务运行时自动刷新 + const { data, isLoading, refetch } = useProvidersQuery(activeApp, { + isProxyRunning, + }); const providers = useMemo(() => data?.providers ?? {}, [data]); const currentProviderId = data?.currentProviderId ?? ""; const isClaudeApp = activeApp === "claude"; @@ -74,7 +81,6 @@ function App() { switchProvider, deleteProvider, saveUsageScript, - setProxyTarget, } = useProviderActions(activeApp); // 监听来自托盘菜单的切换事件 @@ -313,8 +319,8 @@ function App() { currentProviderId={currentProviderId} appId={activeApp} isLoading={isLoading} + isProxyRunning={isProxyRunning} onSwitch={switchProvider} - onSetProxyTarget={setProxyTarget} onEdit={setEditingProvider} onDelete={setConfirmDelete} onDuplicate={handleDuplicateProvider} diff --git a/src/components/providers/EditProviderDialog.tsx b/src/components/providers/EditProviderDialog.tsx index d867c45da..2b9850438 100644 --- a/src/components/providers/EditProviderDialog.tsx +++ b/src/components/providers/EditProviderDialog.tsx @@ -33,13 +33,23 @@ export function EditProviderDialog({ unknown > | null>(null); + // 使用 ref 标记是否已经加载过,防止重复读取覆盖用户编辑 + const [hasLoadedLive, setHasLoadedLive] = useState(false); + useEffect(() => { let cancelled = false; const load = async () => { if (!open || !provider) { setLiveSettings(null); + setHasLoadedLive(false); return; } + + // 关键修复:只在首次打开时加载一次 + if (hasLoadedLive) { + return; + } + try { const currentId = await providersApi.getCurrent(appId); if (currentId && provider.id === currentId) { @@ -49,13 +59,20 @@ export function EditProviderDialog({ )) as Record; if (!cancelled && live && typeof live === "object") { setLiveSettings(live); + setHasLoadedLive(true); } } catch { // 读取实时配置失败则回退到 SSOT(不打断编辑流程) - if (!cancelled) setLiveSettings(null); + if (!cancelled) { + setLiveSettings(null); + setHasLoadedLive(true); + } } } else { - if (!cancelled) setLiveSettings(null); + if (!cancelled) { + setLiveSettings(null); + setHasLoadedLive(true); + } } } finally { // no-op @@ -65,14 +82,33 @@ export function EditProviderDialog({ return () => { cancelled = true; }; - }, [open, provider, appId]); + }, [open, provider?.id, appId, hasLoadedLive]); // 只依赖 provider.id,不依赖整个 provider 对象 const initialSettingsConfig = useMemo(() => { return (liveSettings ?? provider?.settingsConfig ?? {}) as Record< string, unknown >; - }, [liveSettings, provider]); + }, [liveSettings, provider?.settingsConfig]); // 只依赖 settingsConfig,不依赖整个 provider + + // 固定 initialData,防止 provider 对象更新时重置表单 + const initialData = useMemo(() => { + if (!provider) return null; + return { + name: provider.name, + notes: provider.notes, + websiteUrl: provider.websiteUrl, + settingsConfig: initialSettingsConfig, + category: provider.category, + meta: provider.meta, + icon: provider.icon, + iconColor: provider.iconColor, + }; + }, [ + provider?.id, // 只依赖 ID,provider 对象更新不会触发重新计算 + initialSettingsConfig, + // 注意:不依赖 provider 的其他字段,防止表单重置 + ]); const handleSubmit = useCallback( async (values: ProviderFormValues) => { @@ -104,7 +140,7 @@ export function EditProviderDialog({ [onSubmit, onOpenChange, provider], ); - if (!provider) { + if (!provider || !initialData) { return null; } @@ -130,17 +166,7 @@ export function EditProviderDialog({ submitLabel={t("common.save")} onSubmit={handleSubmit} onCancel={() => onOpenChange(false)} - initialData={{ - name: provider.name, - notes: provider.notes, - websiteUrl: provider.websiteUrl, - // 若读取到实时配置则优先使用 - settingsConfig: initialSettingsConfig, - category: provider.category, - meta: provider.meta, - icon: provider.icon, - iconColor: provider.iconColor, - }} + initialData={initialData} showButtons={false} /> diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index a0a8dec5d..4a35f27a0 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -7,6 +7,7 @@ import { Play, TestTube2, Trash2, + RotateCcw, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; @@ -21,6 +22,9 @@ interface ProviderActionsProps { onTest?: () => void; onConfigureUsage: () => void; onDelete: () => void; + onResetCircuitBreaker?: () => void; + isProxyTarget?: boolean; + consecutiveFailures?: number; } export function ProviderActions({ @@ -32,6 +36,9 @@ export function ProviderActions({ onTest, onConfigureUsage, onDelete, + onResetCircuitBreaker, + isProxyTarget, + consecutiveFailures = 0, }: ProviderActionsProps) { const { t } = useTranslation(); const iconButtonClass = "h-8 w-8 p-1"; @@ -110,6 +117,32 @@ export function ProviderActions({ + {/* 重置熔断器按钮 - 代理目标启用时显示 */} + {onResetCircuitBreaker && isProxyTarget && ( + + )} + + + + + {/* 说明信息 */} +
+

+ {t("proxy.autoFailover.explanationTitle", "工作原理")} +

+
    +
  • + •{" "} + + {t("proxy.autoFailover.failureThresholdLabel", "失败阈值")} + + : + {t( + "proxy.autoFailover.failureThresholdExplain", + "连续失败达到此次数时,熔断器打开,该供应商暂时不可用", + )} +
  • +
  • + •{" "} + + {t("proxy.autoFailover.timeoutLabel", "恢复等待时间")} + + : + {t( + "proxy.autoFailover.timeoutExplain", + "熔断器打开后,等待此时间后尝试半开状态", + )} +
  • +
  • + •{" "} + + {t( + "proxy.autoFailover.successThresholdLabel", + "恢复成功阈值", + )} + + : + {t( + "proxy.autoFailover.successThresholdExplain", + "半开状态下,成功达到此次数时关闭熔断器,供应商恢复可用", + )} +
  • +
  • + •{" "} + + {t("proxy.autoFailover.errorRateLabel", "错误率阈值")} + + : + {t( + "proxy.autoFailover.errorRateExplain", + "错误率超过此值时,即使未达到失败阈值也会打开熔断器", + )} +
  • +
+
+ + )} + + ); +} diff --git a/src/components/proxy/CircuitBreakerConfigPanel.tsx b/src/components/proxy/CircuitBreakerConfigPanel.tsx new file mode 100644 index 000000000..3014f532a --- /dev/null +++ b/src/components/proxy/CircuitBreakerConfigPanel.tsx @@ -0,0 +1,208 @@ +import { + useCircuitBreakerConfig, + useUpdateCircuitBreakerConfig, +} from "@/lib/query/failover"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { useState, useEffect } from "react"; +import { toast } from "sonner"; + +/** + * 熔断器配置面板 + * 允许用户调整熔断器参数 + */ +export function CircuitBreakerConfigPanel() { + const { data: config, isLoading } = useCircuitBreakerConfig(); + const updateConfig = useUpdateCircuitBreakerConfig(); + + const [formData, setFormData] = useState({ + failureThreshold: 5, + successThreshold: 2, + timeoutSeconds: 60, + errorRateThreshold: 0.5, + minRequests: 10, + }); + + // 当配置加载完成时更新表单数据 + useEffect(() => { + if (config) { + setFormData(config); + } + }, [config]); + + const handleSave = async () => { + try { + await updateConfig.mutateAsync(formData); + toast.success("熔断器配置已保存"); + } catch (error) { + toast.error("保存失败: " + String(error)); + } + }; + + const handleReset = () => { + if (config) { + setFormData(config); + } + }; + + if (isLoading) { + return
加载中...
; + } + + return ( +
+
+

熔断器配置

+

+ 调整熔断器参数以控制故障检测和恢复行为 +

+
+ +
+ +
+ {/* 失败阈值 */} +
+ + + setFormData({ + ...formData, + failureThreshold: parseInt(e.target.value) || 5, + }) + } + /> +

+ 连续失败多少次后打开熔断器 +

+
+ + {/* 超时时间 */} +
+ + + setFormData({ + ...formData, + timeoutSeconds: parseInt(e.target.value) || 60, + }) + } + /> +

+ 熔断器打开后多久尝试恢复(半开状态) +

+
+ + {/* 成功阈值 */} +
+ + + setFormData({ + ...formData, + successThreshold: parseInt(e.target.value) || 2, + }) + } + /> +

+ 半开状态下成功多少次后关闭熔断器 +

+
+ + {/* 错误率阈值 */} +
+ + + setFormData({ + ...formData, + errorRateThreshold: (parseInt(e.target.value) || 50) / 100, + }) + } + /> +

+ 错误率超过此值时打开熔断器 +

+
+ + {/* 最小请求数 */} +
+ + + setFormData({ + ...formData, + minRequests: parseInt(e.target.value) || 10, + }) + } + /> +

+ 计算错误率前的最小请求数 +

+
+
+ +
+ + +
+ + {/* 说明信息 */} +
+

配置说明

+
    +
  • + • 失败阈值:连续失败达到此次数时,熔断器打开 +
  • +
  • + • 超时时间:熔断器打开后,等待此时间后尝试半开 +
  • +
  • + • 成功阈值:半开状态下,成功达到此次数时关闭熔断器 +
  • +
  • + • 错误率阈值:错误率超过此值时,熔断器打开 +
  • +
  • + • 最小请求数:只有请求数达到此值后才计算错误率 +
  • +
+
+
+ ); +} diff --git a/src/components/proxy/ProxyPanel.tsx b/src/components/proxy/ProxyPanel.tsx index b2a8059e2..f4c4777fc 100644 --- a/src/components/proxy/ProxyPanel.tsx +++ b/src/components/proxy/ProxyPanel.tsx @@ -3,14 +3,30 @@ import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { useProxyStatus } from "@/hooks/useProxyStatus"; -import { Settings, Activity, Clock, TrendingUp, Server } from "lucide-react"; +import { + Settings, + Activity, + Clock, + TrendingUp, + Server, + ListOrdered, +} from "lucide-react"; import { ProxySettingsDialog } from "./ProxySettingsDialog"; import { toast } from "sonner"; +import { useProxyTargets } from "@/lib/query/failover"; +import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; +import { useProviderHealth } from "@/lib/query/failover"; +import type { ProxyStatus } from "@/types/proxy"; export function ProxyPanel() { const { status, isRunning, start, stop, isPending } = useProxyStatus(); const [showSettings, setShowSettings] = useState(false); + // 获取所有三个应用类型的代理目标列表 + const { data: claudeTargets = [] } = useProxyTargets("claude"); + const { data: codexTargets = [] } = useProxyTargets("codex"); + const { data: geminiTargets = [] } = useProxyTargets("gemini"); + const handleToggle = async () => { try { if (isRunning) { @@ -112,7 +128,7 @@ export function ProxyPanel() {

- 当前代理 + 使用中

{status.active_targets && status.active_targets.length > 0 ? (
@@ -146,6 +162,50 @@ export function ProxyPanel() {

)}
+ + {/* 供应商队列 - 按应用类型分组展示 */} + {(claudeTargets.length > 0 || + codexTargets.length > 0 || + geminiTargets.length > 0) && ( +
+
+ +

+ 故障转移队列 +

+
+ + {/* Claude 队列 */} + {claudeTargets.length > 0 && ( + + )} + + {/* Codex 队列 */} + {codexTargets.length > 0 && ( + + )} + + {/* Gemini 队列 */} + {geminiTargets.length > 0 && ( + + )} +
+ )}
@@ -220,3 +280,104 @@ function StatCard({ icon, label, value, variant = "default" }: StatCardProps) {
); } + +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 { data: health } = useProviderHealth(provider.id, appType); + + return ( +
+
+ + {priority} + + + {provider.name} + + {isCurrent && ( + + 使用中 + + )} +
+ {/* 健康徽章:队列中的代理目标始终显示,没有健康数据时默认为正常 */} + +
+ ); +} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index a8f78c206..70e11e9cf 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -20,6 +20,7 @@ import { AboutSection } from "@/components/settings/AboutSection"; import { ProxyPanel } from "@/components/proxy"; import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel"; import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel"; +import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel"; import { UsageDashboard } from "@/components/usage/UsageDashboard"; import { useSettings } from "@/hooks/useSettings"; import { useImportExport } from "@/hooks/useImportExport"; @@ -222,6 +223,9 @@ export function SettingsPage({ {/* 模型测试配置 */} + {/* 自动故障转移配置 */} + + { - await setProxyTargetMutation.mutateAsync(provider.id); - }, - [setProxyTargetMutation], - ); - // 删除供应商 const deleteProvider = useCallback( async (id: string) => { @@ -146,14 +136,12 @@ export function useProviderActions(activeApp: AppId) { addProvider, updateProvider, switchProvider, - setProxyTarget, deleteProvider, saveUsageScript, isLoading: addProviderMutation.isPending || updateProviderMutation.isPending || deleteProviderMutation.isPending || - switchProviderMutation.isPending || - setProxyTargetMutation.isPending, + switchProviderMutation.isPending, }; } diff --git a/tests/components/ProviderList.test.tsx b/tests/components/ProviderList.test.tsx index b53dd52e2..4c0a16884 100644 --- a/tests/components/ProviderList.test.tsx +++ b/tests/components/ProviderList.test.tsx @@ -125,7 +125,6 @@ describe("ProviderList Component", () => { onDelete={vi.fn()} onDuplicate={vi.fn()} onOpenWebsite={vi.fn()} - onSetProxyTarget={vi.fn()} isLoading />, ); @@ -154,7 +153,6 @@ describe("ProviderList Component", () => { onDelete={vi.fn()} onDuplicate={vi.fn()} onOpenWebsite={vi.fn()} - onSetProxyTarget={vi.fn()} onCreate={handleCreate} />, ); @@ -195,7 +193,6 @@ describe("ProviderList Component", () => { onDuplicate={handleDuplicate} onConfigureUsage={handleUsage} onOpenWebsite={handleOpenWebsite} - onSetProxyTarget={vi.fn()} />, );