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 =