diff --git a/src/App.tsx b/src/App.tsx index d9b730c0..5831580d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -68,9 +68,20 @@ function App() { "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 { isRunning: isProxyRunning, takeoverStatus } = useProxyStatus(); + const { + isRunning: isProxyRunning, + takeoverStatus, + status: proxyStatus, + } = useProxyStatus(); // 当前应用的代理是否开启 const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false; + // 当前应用代理实际使用的供应商 ID(从 active_targets 中获取) + const activeProviderId = useMemo(() => { + const target = proxyStatus?.active_targets?.find( + (t) => t.app_type === activeApp, + ); + return target?.provider_id; + }, [proxyStatus?.active_targets, activeApp]); // 获取供应商列表,当代理服务运行时自动刷新 const { data, isLoading, refetch } = useProvidersQuery(activeApp, { @@ -353,6 +364,7 @@ function App() { isProxyTakeover={ isProxyRunning && isCurrentAppTakeoverActive } + activeProviderId={activeProviderId} onSwitch={switchProvider} onEdit={setEditingProvider} onDelete={setConfirmDelete} diff --git a/src/components/providers/FailoverPriorityBadge.tsx b/src/components/providers/FailoverPriorityBadge.tsx new file mode 100644 index 00000000..d7824c31 --- /dev/null +++ b/src/components/providers/FailoverPriorityBadge.tsx @@ -0,0 +1,34 @@ +import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; + +interface FailoverPriorityBadgeProps { + priority: number; // 1, 2, 3, ... + className?: string; +} + +/** + * 故障转移优先级徽章 + * 显示供应商在故障转移队列中的优先级顺序 + */ +export function FailoverPriorityBadge({ + priority, + className, +}: FailoverPriorityBadgeProps) { + const { t } = useTranslation(); + + return ( +
+ P{priority} +
+ ); +} diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index 361fa2b2..e3047e18 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -5,6 +5,7 @@ import { Edit, Loader2, Play, + Plus, TestTube2, Trash2, } from "lucide-react"; @@ -22,6 +23,10 @@ interface ProviderActionsProps { onTest?: () => void; onConfigureUsage: () => void; onDelete: () => void; + // 故障转移相关 + isAutoFailoverEnabled?: boolean; + isInFailoverQueue?: boolean; + onToggleFailover?: (enabled: boolean) => void; } export function ProviderActions({ @@ -34,38 +39,88 @@ export function ProviderActions({ onTest, onConfigureUsage, onDelete, + // 故障转移相关 + isAutoFailoverEnabled = false, + isInFailoverQueue = false, + onToggleFailover, }: ProviderActionsProps) { const { t } = useTranslation(); const iconButtonClass = "h-8 w-8 p-1"; + // 故障转移模式下的按钮逻辑 + const isFailoverMode = isAutoFailoverEnabled && onToggleFailover; + + // 处理主按钮点击 + const handleMainButtonClick = () => { + if (isFailoverMode) { + // 故障转移模式:切换队列状态 + onToggleFailover(!isInFailoverQueue); + } else { + // 普通模式:切换供应商 + onSwitch(); + } + }; + + // 主按钮的状态和样式 + const getMainButtonState = () => { + if (isFailoverMode) { + // 故障转移模式 + if (isInFailoverQueue) { + return { + disabled: false, + variant: "secondary" as const, + className: + "bg-blue-100 text-blue-600 hover:bg-blue-200 dark:bg-blue-900/50 dark:text-blue-400 dark:hover:bg-blue-900/70", + icon: , + text: t("failover.inQueue", { defaultValue: "已加入" }), + }; + } + return { + disabled: false, + variant: "default" as const, + className: + "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700", + icon: , + text: t("failover.addQueue", { defaultValue: "加入" }), + }; + } + + // 普通模式 + if (isCurrent) { + return { + disabled: true, + variant: "secondary" as const, + className: + "bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700", + icon: , + text: t("provider.inUse"), + }; + } + + return { + disabled: false, + variant: "default" as const, + className: isProxyTakeover + ? "bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700" + : "", + icon: , + text: t("provider.enable"), + }; + }; + + const buttonState = getMainButtonState(); + return (
diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index d5488fad..1925f911 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -12,6 +12,7 @@ import { ProviderActions } from "@/components/providers/ProviderActions"; import { ProviderIcon } from "@/components/ProviderIcon"; import UsageFooter from "@/components/UsageFooter"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; +import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge"; import { useProviderHealth } from "@/lib/query/failover"; import { useUsageQuery } from "@/lib/query/queries"; @@ -36,6 +37,12 @@ interface ProviderCardProps { isProxyRunning: boolean; isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换) dragHandleProps?: DragHandleProps; + // 故障转移相关 + isAutoFailoverEnabled?: boolean; // 是否开启自动故障转移 + failoverPriority?: number; // 故障转移优先级(1 = P1, 2 = P2, ...) + isInFailoverQueue?: boolean; // 是否在故障转移队列中 + onToggleFailover?: (enabled: boolean) => void; // 切换故障转移队列 + activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框) } const extractApiUrl = (provider: Provider, fallbackText: string) => { @@ -88,6 +95,12 @@ export function ProviderCard({ isProxyRunning, isProxyTakeover = false, dragHandleProps, + // 故障转移相关 + isAutoFailoverEnabled = false, + failoverPriority, + isInFailoverQueue = false, + onToggleFailover, + activeProviderId, }: ProviderCardProps) { const { t } = useTranslation(); @@ -148,21 +161,27 @@ export function ProviderCard({ onOpenWebsite(displayUrl); }; + // 判断是否是"当前使用中"的供应商 + // - 故障转移模式:代理实际使用的供应商(activeProviderId) + // - 代理接管模式(非故障转移):isCurrent + // - 普通模式:isCurrent + const isActiveProvider = isAutoFailoverEnabled + ? activeProviderId === provider.id + : isCurrent; + return (
@@ -209,13 +226,20 @@ export function ProviderCard({ {provider.name} - {/* 健康状态徽章和优先级 */} + {/* 健康状态徽章 */} {isProxyRunning && health && ( )} + {/* 故障转移优先级徽章 */} + {isAutoFailoverEnabled && + isInFailoverQueue && + failoverPriority && ( + + )} + {provider.category === "third_party" && provider.meta?.isPartner && ( onTest(provider) : undefined} onConfigureUsage={() => onConfigureUsage(provider)} onDelete={() => onDelete(provider)} + // 故障转移相关 + isAutoFailoverEnabled={isAutoFailoverEnabled} + isInFailoverQueue={isInFailoverQueue} + onToggleFailover={onToggleFailover} />
diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index f9957913..443fd1f0 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -12,6 +12,14 @@ import { useDragSort } from "@/hooks/useDragSort"; import { useStreamCheck } from "@/hooks/useStreamCheck"; import { ProviderCard } from "@/components/providers/ProviderCard"; import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState"; +import { + useAutoFailoverEnabled, + useFailoverQueue, + useAddToFailoverQueue, + useRemoveFromFailoverQueue, + useReorderFailoverQueue, +} from "@/lib/query/failover"; +import { useCallback, useEffect, useRef } from "react"; interface ProviderListProps { providers: Record; @@ -27,6 +35,7 @@ interface ProviderListProps { isLoading?: boolean; isProxyRunning?: boolean; // 代理服务运行状态 isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管) + activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框) } export function ProviderList({ @@ -41,8 +50,9 @@ export function ProviderList({ onOpenWebsite, onCreate, isLoading = false, - isProxyRunning = false, // 默认值为 false - isProxyTakeover = false, // 默认值为 false + isProxyRunning = false, + isProxyTakeover = false, + activeProviderId, }: ProviderListProps) { const { sortedProviders, sensors, handleDragEnd } = useDragSort( providers, @@ -52,6 +62,93 @@ export function ProviderList({ // 流式健康检查 const { checkProvider, isChecking } = useStreamCheck(appId); + // 故障转移相关 + const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId); + const { data: failoverQueue } = useFailoverQueue(appId); + const addToQueue = useAddToFailoverQueue(); + const removeFromQueue = useRemoveFromFailoverQueue(); + const reorderQueue = useReorderFailoverQueue(); + + // 联动状态:只有当前应用开启代理接管且故障转移开启时才启用故障转移模式 + const isFailoverModeActive = + isProxyTakeover === true && isAutoFailoverEnabled === true; + + // 防止重复调用的 ref + const lastReorderRef = useRef(""); + + // 计算供应商在故障转移队列中的优先级 + const getFailoverPriority = useCallback( + (providerId: string): number | undefined => { + if (!isFailoverModeActive || !failoverQueue) return undefined; + // 只计算已启用的供应商的优先级 + const enabledQueue = failoverQueue.filter((item) => item.enabled); + const index = enabledQueue.findIndex( + (item) => item.providerId === providerId, + ); + return index >= 0 ? index + 1 : undefined; + }, + [isFailoverModeActive, failoverQueue], + ); + + // 判断供应商是否在故障转移队列中 + const isInFailoverQueue = useCallback( + (providerId: string): boolean => { + if (!isFailoverModeActive || !failoverQueue) return false; + return failoverQueue.some( + (item) => item.providerId === providerId && item.enabled, + ); + }, + [isFailoverModeActive, failoverQueue], + ); + + // 切换供应商的故障转移队列状态 + const handleToggleFailover = useCallback( + (providerId: string, enabled: boolean) => { + if (enabled) { + addToQueue.mutate({ appType: appId, providerId }); + } else { + removeFromQueue.mutate({ appType: appId, providerId }); + } + }, + [appId, addToQueue, removeFromQueue], + ); + + // 当拖拽排序后,同步故障转移队列顺序 + useEffect(() => { + if (!isFailoverModeActive || !failoverQueue || failoverQueue.length === 0) + return; + + // 获取当前在队列中且已启用的供应商 ID 列表(按显示顺序) + const enabledProviderIds = sortedProviders + .filter((p) => isInFailoverQueue(p.id)) + .map((p) => p.id); + + if (enabledProviderIds.length === 0) return; + + // 生成唯一标识防止重复调用 + const orderKey = enabledProviderIds.join(","); + if (orderKey === lastReorderRef.current) return; + + // 检查顺序是否需要更新 + const currentOrder = failoverQueue + .filter((item) => item.enabled) + .sort((a, b) => a.queueOrder - b.queueOrder) + .map((item) => item.providerId) + .join(","); + + if (orderKey !== currentOrder) { + lastReorderRef.current = orderKey; + reorderQueue.mutate({ appType: appId, providerIds: enabledProviderIds }); + } + }, [ + sortedProviders, + isFailoverModeActive, + failoverQueue, + isInFailoverQueue, + appId, + reorderQueue, + ]); + const handleTest = (provider: Provider) => { checkProvider(provider.id, provider.name); }; @@ -100,6 +197,14 @@ export function ProviderList({ isTesting={isChecking(provider.id)} isProxyRunning={isProxyRunning} isProxyTakeover={isProxyTakeover} + // 故障转移相关:联动状态 + isAutoFailoverEnabled={isFailoverModeActive} + failoverPriority={getFailoverPriority(provider.id)} + isInFailoverQueue={isInFailoverQueue(provider.id)} + onToggleFailover={(enabled) => + handleToggleFailover(provider.id, enabled) + } + activeProviderId={activeProviderId} /> ))}
@@ -122,6 +227,12 @@ interface SortableProviderCardProps { isTesting: boolean; isProxyRunning: boolean; isProxyTakeover: boolean; + // 故障转移相关 + isAutoFailoverEnabled: boolean; + failoverPriority?: number; + isInFailoverQueue: boolean; + onToggleFailover: (enabled: boolean) => void; + activeProviderId?: string; } function SortableProviderCard({ @@ -138,6 +249,11 @@ function SortableProviderCard({ isTesting, isProxyRunning, isProxyTakeover, + isAutoFailoverEnabled, + failoverPriority, + isInFailoverQueue, + onToggleFailover, + activeProviderId, }: SortableProviderCardProps) { const { setNodeRef, @@ -176,6 +292,12 @@ function SortableProviderCard({ listeners, isDragging, }} + // 故障转移相关 + isAutoFailoverEnabled={isAutoFailoverEnabled} + failoverPriority={failoverPriority} + isInFailoverQueue={isInFailoverQueue} + onToggleFailover={onToggleFailover} + activeProviderId={activeProviderId} />
); diff --git a/src/components/proxy/AutoFailoverConfigPanel.tsx b/src/components/proxy/AutoFailoverConfigPanel.tsx index 055fcf25..182dbce5 100644 --- a/src/components/proxy/AutoFailoverConfigPanel.tsx +++ b/src/components/proxy/AutoFailoverConfigPanel.tsx @@ -12,14 +12,14 @@ import { } from "@/lib/query/failover"; export interface AutoFailoverConfigPanelProps { - enabled: boolean; - onEnabledChange: (enabled: boolean) => void; + enabled?: boolean; + onEnabledChange?: (enabled: boolean) => void; } export function AutoFailoverConfigPanel({ - enabled, + enabled = true, onEnabledChange: _onEnabledChange, -}: AutoFailoverConfigPanelProps) { +}: AutoFailoverConfigPanelProps = {}) { // Note: onEnabledChange is currently unused but kept in the interface // for potential future use by parent components void _onEnabledChange; diff --git a/src/components/proxy/FailoverQueueManager.tsx b/src/components/proxy/FailoverQueueManager.tsx index 11da074e..dbc6434f 100644 --- a/src/components/proxy/FailoverQueueManager.tsx +++ b/src/components/proxy/FailoverQueueManager.tsx @@ -53,6 +53,8 @@ import { useRemoveFromFailoverQueue, useReorderFailoverQueue, useSetFailoverItemEnabled, + useAutoFailoverEnabled, + useSetAutoFailoverEnabled, } from "@/lib/query/failover"; interface FailoverQueueManagerProps { @@ -67,6 +69,10 @@ export function FailoverQueueManager({ const { t } = useTranslation(); const [selectedProviderId, setSelectedProviderId] = useState(""); + // 故障转移开关状态(每个应用独立) + const { data: isFailoverEnabled = false } = useAutoFailoverEnabled(appType); + const setFailoverEnabled = useSetAutoFailoverEnabled(); + // 查询数据 const { data: queue, @@ -82,6 +88,11 @@ export function FailoverQueueManager({ const reorderQueue = useReorderFailoverQueue(); const setItemEnabled = useSetFailoverItemEnabled(); + // 切换故障转移开关 + const handleToggleFailover = (enabled: boolean) => { + setFailoverEnabled.mutate({ appType, enabled }); + }; + // 拖拽配置 const sensors = useSensors( useSensor(PointerSensor, { @@ -203,6 +214,34 @@ export function FailoverQueueManager({ return (
+ {/* 自动故障转移开关 */} +
+
+
+ + {t("proxy.failover.autoSwitch", { + defaultValue: "自动故障转移", + })} + + {isFailoverEnabled && ( + + {t("common.enabled", { defaultValue: "已开启" })} + + )} +
+

+ {t("proxy.failover.autoSwitchDescription", { + defaultValue: "开启后,请求失败时自动切换到队列中的下一个供应商", + })} +

+
+ +
+ {/* 说明信息 */} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 9695e974..30481bd3 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -47,10 +47,6 @@ 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; @@ -187,17 +183,9 @@ export function SettingsPage({ isPending: isProxyPending, } = useProxyStatus(); - // 使用持久化的自动故障转移开关状态 - 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(); @@ -207,19 +195,6 @@ 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 (
{isBusy ? ( @@ -380,37 +355,36 @@ export function SettingsPage({ - - -
- -
-

- {t("settings.advanced.failover.title")} -

-

- {t("settings.advanced.failover.description")} -

-
+ +
+ +
+

+ {t("settings.advanced.failover.title")} +

+

+ {t("settings.advanced.failover.description")} +

- - - -
-
- +
- {/* 故障转移队列管理 */} + {/* 代理未运行时的提示 */} + {!isRunning && ( +
+

+ {t("proxy.failover.proxyRequired", { + defaultValue: + "需要先启动代理服务才能配置故障转移", + })} +

+
+ )} + + {/* 故障转移队列管理 - 每个应用独立 */}

@@ -429,30 +403,27 @@ export function SettingsPage({

- {/* 熔断器配置 */} + {/* 熔断器配置 - 全局共享 */}
- +
diff --git a/src/hooks/useProxyStatus.ts b/src/hooks/useProxyStatus.ts index e43a159e..a1c6d326 100644 --- a/src/hooks/useProxyStatus.ts +++ b/src/hooks/useProxyStatus.ts @@ -75,6 +75,7 @@ export function useProxyStatus() { queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] }); // 清除所有供应商健康状态缓存(后端已清空数据库记录) queryClient.invalidateQueries({ queryKey: ["providerHealth"] }); + // 注意:故障转移队列和开关状态会保留,不需要刷新 }, onError: (error: Error) => { const detail =