refactor(failover): merge failover_queue table into providers

- Add in_failover_queue field to providers table
- Remove standalone failover_queue table and related indexes
- Simplify queue ordering by reusing sort_index field
- Remove reorder_failover_queue and set_failover_item_enabled commands
- Update frontend to use simplified FailoverQueueItem type
This commit is contained in:
YoVinchen
2025-12-22 16:41:34 +08:00
Unverified
parent cabe1944ad
commit f6e8d656ce
23 changed files with 172 additions and 610 deletions
+4 -49
View File
@@ -17,9 +17,8 @@ import {
useFailoverQueue,
useAddToFailoverQueue,
useRemoveFromFailoverQueue,
useReorderFailoverQueue,
} from "@/lib/query/failover";
import { useCallback, useEffect, useRef } from "react";
import { useCallback } from "react";
interface ProviderListProps {
providers: Record<string, Provider>;
@@ -67,22 +66,16 @@ export function ProviderList({
const { data: failoverQueue } = useFailoverQueue(appId);
const addToQueue = useAddToFailoverQueue();
const removeFromQueue = useRemoveFromFailoverQueue();
const reorderQueue = useReorderFailoverQueue();
// 联动状态:只有当前应用开启代理接管且故障转移开启时才启用故障转移模式
const isFailoverModeActive =
isProxyTakeover === true && isAutoFailoverEnabled === true;
// 防止重复调用的 ref
const lastReorderRef = useRef<string>("");
// 计算供应商在故障转移队列中的优先级
// 计算供应商在故障转移队列中的优先级(基于 sortIndex 排序)
const getFailoverPriority = useCallback(
(providerId: string): number | undefined => {
if (!isFailoverModeActive || !failoverQueue) return undefined;
// 只计算已启用的供应商的优先级
const enabledQueue = failoverQueue.filter((item) => item.enabled);
const index = enabledQueue.findIndex(
const index = failoverQueue.findIndex(
(item) => item.providerId === providerId,
);
return index >= 0 ? index + 1 : undefined;
@@ -94,9 +87,7 @@ export function ProviderList({
const isInFailoverQueue = useCallback(
(providerId: string): boolean => {
if (!isFailoverModeActive || !failoverQueue) return false;
return failoverQueue.some(
(item) => item.providerId === providerId && item.enabled,
);
return failoverQueue.some((item) => item.providerId === providerId);
},
[isFailoverModeActive, failoverQueue],
);
@@ -113,42 +104,6 @@ export function ProviderList({
[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);
};
+24 -177
View File
@@ -2,37 +2,14 @@
* 故障转移队列管理组件
*
* 允许用户管理代理模式下的故障转移队列,支持:
* - 拖拽排序
* - 添加/移除供应商
* - 启用/禁用队列项
* - 队列顺序基于首页供应商列表的 sort_index
*/
import { useState, useCallback, useMemo } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { CSS } from "@dnd-kit/utilities";
import { DndContext, closestCenter } from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import {
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { toast } from "sonner";
import {
GripVertical,
Plus,
Trash2,
Loader2,
Info,
AlertTriangle,
} from "lucide-react";
import { Plus, Trash2, Loader2, Info, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Alert, AlertDescription } from "@/components/ui/alert";
@@ -51,8 +28,6 @@ import {
useAvailableProvidersForFailover,
useAddToFailoverQueue,
useRemoveFromFailoverQueue,
useReorderFailoverQueue,
useSetFailoverItemEnabled,
useAutoFailoverEnabled,
useSetAutoFailoverEnabled,
} from "@/lib/query/failover";
@@ -85,65 +60,12 @@ export function FailoverQueueManager({
// Mutations
const addToQueue = useAddToFailoverQueue();
const removeFromQueue = useRemoveFromFailoverQueue();
const reorderQueue = useReorderFailoverQueue();
const setItemEnabled = useSetFailoverItemEnabled();
// 切换故障转移开关
const handleToggleFailover = (enabled: boolean) => {
setFailoverEnabled.mutate({ appType, enabled });
};
// 拖拽配置
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// 排序后的队列
const sortedQueue = useMemo(() => {
if (!queue) return [];
return [...queue].sort((a, b) => a.queueOrder - b.queueOrder);
}, [queue]);
// 处理拖拽结束
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id || !sortedQueue) return;
const oldIndex = sortedQueue.findIndex(
(item) => item.providerId === active.id,
);
const newIndex = sortedQueue.findIndex(
(item) => item.providerId === over.id,
);
if (oldIndex === -1 || newIndex === -1) return;
const reordered = arrayMove(sortedQueue, oldIndex, newIndex);
const providerIds = reordered.map((item) => item.providerId);
try {
await reorderQueue.mutateAsync({ appType, providerIds });
toast.success(
t("proxy.failoverQueue.reorderSuccess", "队列顺序已更新"),
{ closeButton: true },
);
} catch (error) {
toast.error(
t("proxy.failoverQueue.reorderFailed", "更新顺序失败") +
": " +
String(error),
);
}
},
[sortedQueue, appType, reorderQueue, t],
);
// 添加供应商到队列
const handleAddProvider = async () => {
if (!selectedProviderId) return;
@@ -182,19 +104,6 @@ export function FailoverQueueManager({
}
};
// 切换启用状态
const handleToggleEnabled = async (providerId: string, enabled: boolean) => {
try {
await setItemEnabled.mutateAsync({ appType, providerId, enabled });
} catch (error) {
toast.error(
t("proxy.failoverQueue.toggleFailed", "状态更新失败") +
": " +
String(error),
);
}
};
if (isQueueLoading) {
return (
<div className="flex items-center justify-center p-8">
@@ -248,7 +157,7 @@ export function FailoverQueueManager({
<AlertDescription className="text-sm">
{t(
"proxy.failoverQueue.info",
"当前激活的供应商始终优先。当请求失败时,系统会按队列顺序依次尝试其他供应商。",
"队列顺序与首页供应商列表顺序一致。当请求失败时,系统会按顺序依次尝试队列中的供应商。",
)}
</AlertDescription>
</Alert>
@@ -299,7 +208,7 @@ export function FailoverQueueManager({
</div>
{/* 队列列表 */}
{sortedQueue.length === 0 ? (
{!queue || queue.length === 0 ? (
<div className="rounded-lg border border-dashed border-muted-foreground/40 p-8 text-center">
<p className="text-sm text-muted-foreground">
{t(
@@ -309,39 +218,26 @@ export function FailoverQueueManager({
</p>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedQueue.map((item) => item.providerId)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{sortedQueue.map((item, index) => (
<SortableQueueItem
key={item.providerId}
item={item}
index={index}
disabled={disabled}
onToggleEnabled={handleToggleEnabled}
onRemove={handleRemoveProvider}
isRemoving={removeFromQueue.isPending}
isToggling={setItemEnabled.isPending}
/>
))}
</div>
</SortableContext>
</DndContext>
<div className="space-y-2">
{queue.map((item, index) => (
<QueueItem
key={item.providerId}
item={item}
index={index}
disabled={disabled}
onRemove={handleRemoveProvider}
isRemoving={removeFromQueue.isPending}
/>
))}
</div>
)}
{/* 队列说明 */}
{sortedQueue.length > 0 && (
{queue && queue.length > 0 && (
<p className="text-xs text-muted-foreground">
{t(
"proxy.failoverQueue.dragHint",
"拖拽供应商可调整故障转移顺序,序号越小优先级越高。",
"proxy.failoverQueue.orderHint",
"队列顺序与首页供应商列表顺序一致,可在首页拖拽调整顺序。",
)}
</p>
)}
@@ -349,65 +245,29 @@ export function FailoverQueueManager({
);
}
interface SortableQueueItemProps {
interface QueueItemProps {
item: FailoverQueueItem;
index: number;
disabled: boolean;
onToggleEnabled: (providerId: string, enabled: boolean) => void;
onRemove: (providerId: string) => void;
isRemoving: boolean;
isToggling: boolean;
}
function SortableQueueItem({
function QueueItem({
item,
index,
disabled,
onToggleEnabled,
onRemove,
isRemoving,
isToggling,
}: SortableQueueItemProps) {
}: QueueItemProps) {
const { t } = useTranslation();
const {
setNodeRef,
attributes,
listeners,
transform,
transition,
isDragging,
} = useSortable({ id: item.providerId, disabled });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"flex items-center gap-3 rounded-lg border bg-card p-3 transition-colors",
isDragging && "opacity-50 shadow-lg",
!item.enabled && "opacity-60",
)}
>
{/* 拖拽手柄 */}
<button
type="button"
className={cn(
"cursor-grab touch-none text-muted-foreground hover:text-foreground",
disabled && "cursor-not-allowed opacity-50",
)}
{...attributes}
{...listeners}
disabled={disabled}
aria-label={t("provider.dragHandle", "拖拽排序")}
>
<GripVertical className="h-5 w-5" />
</button>
{/* 序号 */}
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium">
{index + 1}
@@ -415,24 +275,11 @@ function SortableQueueItem({
{/* 供应商名称 */}
<div className="flex-1 min-w-0">
<span
className={cn(
"text-sm font-medium truncate block",
!item.enabled && "text-muted-foreground line-through",
)}
>
<span className="text-sm font-medium truncate block">
{item.providerName}
</span>
</div>
{/* 启用开关 */}
<Switch
checked={item.enabled}
onCheckedChange={(checked) => onToggleEnabled(item.providerId, checked)}
disabled={disabled || isToggling}
aria-label={t("proxy.failoverQueue.toggleEnabled", "启用/禁用")}
/>
{/* 删除按钮 */}
<Button
variant="ghost"
+12 -21
View File
@@ -147,13 +147,10 @@ export function ProxyPanel() {
<ProviderQueueGroup
appType="claude"
appLabel="Claude"
targets={claudeQueue
.filter((item) => item.enabled)
.sort((a, b) => a.queueOrder - b.queueOrder)
.map((item) => ({
id: item.providerId,
name: item.providerName,
}))}
targets={claudeQueue.map((item) => ({
id: item.providerId,
name: item.providerName,
}))}
status={status}
/>
)}
@@ -163,13 +160,10 @@ export function ProxyPanel() {
<ProviderQueueGroup
appType="codex"
appLabel="Codex"
targets={codexQueue
.filter((item) => item.enabled)
.sort((a, b) => a.queueOrder - b.queueOrder)
.map((item) => ({
id: item.providerId,
name: item.providerName,
}))}
targets={codexQueue.map((item) => ({
id: item.providerId,
name: item.providerName,
}))}
status={status}
/>
)}
@@ -179,13 +173,10 @@ export function ProxyPanel() {
<ProviderQueueGroup
appType="gemini"
appLabel="Gemini"
targets={geminiQueue
.filter((item) => item.enabled)
.sort((a, b) => a.queueOrder - b.queueOrder)
.map((item) => ({
id: item.providerId,
name: item.providerName,
}))}
targets={geminiQueue.map((item) => ({
id: item.providerId,
name: item.providerName,
}))}
status={status}
/>
)}
-21
View File
@@ -84,27 +84,6 @@ export const failoverApi = {
return invoke("remove_from_failover_queue", { appType, providerId });
},
// 重新排序故障转移队列
async reorderFailoverQueue(
appType: string,
providerIds: string[],
): Promise<void> {
return invoke("reorder_failover_queue", { appType, providerIds });
},
// 设置故障转移队列项的启用状态
async setFailoverItemEnabled(
appType: string,
providerId: string,
enabled: boolean,
): Promise<void> {
return invoke("set_failover_item_enabled", {
appType,
providerId,
enabled,
});
},
// 获取指定应用的自动故障转移开关状态
async getAutoFailoverEnabled(appType: string): Promise<boolean> {
return invoke("get_auto_failover_enabled", { appType });
+4 -85
View File
@@ -131,6 +131,9 @@ export function useAddToFailoverQueue() {
queryClient.invalidateQueries({
queryKey: ["availableProvidersForFailover", variables.appType],
});
queryClient.invalidateQueries({
queryKey: ["providers", variables.appType],
});
},
});
}
@@ -156,92 +159,8 @@ export function useRemoveFromFailoverQueue() {
queryClient.invalidateQueries({
queryKey: ["availableProvidersForFailover", variables.appType],
});
},
});
}
/**
* 重新排序故障转移队列
*/
export function useReorderFailoverQueue() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
appType,
providerIds,
}: {
appType: string;
providerIds: string[];
}) => failoverApi.reorderFailoverQueue(appType, providerIds),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["failoverQueue", variables.appType],
});
},
});
}
/**
* 设置故障转移队列项的启用状态
* 使用乐观更新(Optimistic Update)以提供即时反馈
*/
export function useSetFailoverItemEnabled() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
appType,
providerId,
enabled,
}: {
appType: string;
providerId: string;
enabled: boolean;
}) => failoverApi.setFailoverItemEnabled(appType, providerId, enabled),
// 乐观更新:立即更新缓存中的数据
onMutate: async (variables) => {
// 取消正在进行的查询,防止覆盖乐观更新
await queryClient.cancelQueries({
queryKey: ["failoverQueue", variables.appType],
});
// 保存之前的数据以便回滚
const previousQueue = queryClient.getQueryData<
import("@/types/proxy").FailoverQueueItem[]
>(["failoverQueue", variables.appType]);
// 乐观地更新缓存
if (previousQueue) {
queryClient.setQueryData<import("@/types/proxy").FailoverQueueItem[]>(
["failoverQueue", variables.appType],
previousQueue.map((item) =>
item.providerId === variables.providerId
? { ...item, enabled: variables.enabled }
: item,
),
);
}
// 返回上下文供 onError 使用
return { previousQueue };
},
// 错误时回滚
onError: (_error, variables, context) => {
if (context?.previousQueue) {
queryClient.setQueryData(
["failoverQueue", variables.appType],
context.previousQueue,
);
}
},
// 无论成功失败,都重新获取最新数据以确保一致性
onSettled: (_, __, variables) => {
queryClient.invalidateQueries({
queryKey: ["failoverQueue", variables.appType],
queryKey: ["providers", variables.appType],
});
},
});
+2
View File
@@ -23,6 +23,8 @@ export interface Provider {
// 图标配置
icon?: string; // 图标名称(如 "openai", "anthropic"
iconColor?: string; // 图标颜色(Hex 格式,如 "#00A67E"
// 是否加入故障转移队列
inFailoverQueue?: boolean;
}
export interface AppConfig {
+1 -3
View File
@@ -103,7 +103,5 @@ export interface ProxyUsageRecord {
export interface FailoverQueueItem {
providerId: string;
providerName: string;
queueOrder: number;
enabled: boolean;
createdAt: number;
sortIndex?: number;
}