mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 23:56:02 +08:00
68a0c304d8
Provider request formats are complex and varied, making it difficult to create a unified test mechanism. Users may incorrectly assume request format issues indicate provider unavailability. Code is commented out (not deleted) for easy restoration if needed.
431 lines
14 KiB
TypeScript
431 lines
14 KiB
TypeScript
import { CSS } from "@dnd-kit/utilities";
|
|
import { DndContext, closestCenter } from "@dnd-kit/core";
|
|
import {
|
|
SortableContext,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
} from "@dnd-kit/sortable";
|
|
import {
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type CSSProperties,
|
|
} from "react";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import { Search, X } from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import type { Provider } from "@/types";
|
|
import type { AppId } from "@/lib/api";
|
|
import { providersApi } from "@/lib/api/providers";
|
|
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,
|
|
} from "@/lib/query/failover";
|
|
import { useCallback } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
interface ProviderListProps {
|
|
providers: Record<string, Provider>;
|
|
currentProviderId: string;
|
|
appId: AppId;
|
|
onSwitch: (provider: Provider) => void;
|
|
onEdit: (provider: Provider) => void;
|
|
onDelete: (provider: Provider) => void;
|
|
/** OpenCode: remove from live config (not delete from database) */
|
|
onRemoveFromConfig?: (provider: Provider) => void;
|
|
onDuplicate: (provider: Provider) => void;
|
|
onConfigureUsage?: (provider: Provider) => void;
|
|
onOpenWebsite: (url: string) => void;
|
|
onOpenTerminal?: (provider: Provider) => void;
|
|
onCreate?: () => void;
|
|
isLoading?: boolean;
|
|
isProxyRunning?: boolean; // 代理服务运行状态
|
|
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管)
|
|
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
|
|
}
|
|
|
|
export function ProviderList({
|
|
providers,
|
|
currentProviderId,
|
|
appId,
|
|
onSwitch,
|
|
onEdit,
|
|
onDelete,
|
|
onRemoveFromConfig,
|
|
onDuplicate,
|
|
onConfigureUsage,
|
|
onOpenWebsite,
|
|
onOpenTerminal,
|
|
onCreate,
|
|
isLoading = false,
|
|
isProxyRunning = false,
|
|
isProxyTakeover = false,
|
|
activeProviderId,
|
|
}: ProviderListProps) {
|
|
const { t } = useTranslation();
|
|
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
|
|
providers,
|
|
appId,
|
|
);
|
|
|
|
// OpenCode: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig
|
|
const { data: opencodeLiveIds } = useQuery({
|
|
queryKey: ["opencodeLiveProviderIds"],
|
|
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
|
|
enabled: appId === "opencode",
|
|
});
|
|
|
|
// OpenCode: 判断供应商是否已添加到 opencode.json
|
|
const isProviderInConfig = useCallback(
|
|
(providerId: string): boolean => {
|
|
if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true
|
|
return opencodeLiveIds?.includes(providerId) ?? false;
|
|
},
|
|
[appId, opencodeLiveIds],
|
|
);
|
|
|
|
// 流式健康检查 - 功能已隐藏
|
|
// const { checkProvider, isChecking } = useStreamCheck(appId);
|
|
|
|
// 故障转移相关
|
|
const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId);
|
|
const { data: failoverQueue } = useFailoverQueue(appId);
|
|
const addToQueue = useAddToFailoverQueue();
|
|
const removeFromQueue = useRemoveFromFailoverQueue();
|
|
|
|
// 联动状态:只有当前应用开启代理接管且故障转移开启时才启用故障转移模式
|
|
const isFailoverModeActive =
|
|
isProxyTakeover === true && isAutoFailoverEnabled === true;
|
|
|
|
// 计算供应商在故障转移队列中的优先级(基于 sortIndex 排序)
|
|
const getFailoverPriority = useCallback(
|
|
(providerId: string): number | undefined => {
|
|
if (!isFailoverModeActive || !failoverQueue) return undefined;
|
|
const index = failoverQueue.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);
|
|
},
|
|
[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],
|
|
);
|
|
|
|
// handleTest 功能已隐藏 - 供应商请求格式复杂难以统一测试
|
|
// const handleTest = (provider: Provider) => {
|
|
// checkProvider(provider.id, provider.name);
|
|
// };
|
|
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
const key = event.key.toLowerCase();
|
|
if ((event.metaKey || event.ctrlKey) && key === "f") {
|
|
event.preventDefault();
|
|
setIsSearchOpen(true);
|
|
return;
|
|
}
|
|
|
|
if (key === "escape") {
|
|
setIsSearchOpen(false);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isSearchOpen) {
|
|
const frame = requestAnimationFrame(() => {
|
|
searchInputRef.current?.focus();
|
|
searchInputRef.current?.select();
|
|
});
|
|
return () => cancelAnimationFrame(frame);
|
|
}
|
|
}, [isSearchOpen]);
|
|
|
|
const filteredProviders = useMemo(() => {
|
|
const keyword = searchTerm.trim().toLowerCase();
|
|
if (!keyword) return sortedProviders;
|
|
return sortedProviders.filter((provider) => {
|
|
const fields = [provider.name, provider.notes, provider.websiteUrl];
|
|
return fields.some((field) =>
|
|
field?.toString().toLowerCase().includes(keyword),
|
|
);
|
|
});
|
|
}, [searchTerm, sortedProviders]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-3">
|
|
{[0, 1, 2].map((index) => (
|
|
<div
|
|
key={index}
|
|
className="w-full border border-dashed rounded-lg h-28 border-muted-foreground/40 bg-muted/40"
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (sortedProviders.length === 0) {
|
|
return <ProviderEmptyState onCreate={onCreate} />;
|
|
}
|
|
|
|
const renderProviderList = () => (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={filteredProviders.map((provider) => provider.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<div className="space-y-3">
|
|
{filteredProviders.map((provider) => (
|
|
<SortableProviderCard
|
|
key={provider.id}
|
|
provider={provider}
|
|
isCurrent={provider.id === currentProviderId}
|
|
appId={appId}
|
|
isInConfig={isProviderInConfig(provider.id)}
|
|
onSwitch={onSwitch}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
onRemoveFromConfig={onRemoveFromConfig}
|
|
onDuplicate={onDuplicate}
|
|
onConfigureUsage={onConfigureUsage}
|
|
onOpenWebsite={onOpenWebsite}
|
|
onOpenTerminal={onOpenTerminal}
|
|
// onTest 功能已隐藏 - 供应商请求格式复杂难以统一测试
|
|
// onTest={appId !== "opencode" ? handleTest : undefined}
|
|
isTesting={false} // 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}
|
|
/>
|
|
))}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
);
|
|
|
|
return (
|
|
<div className="mt-4 space-y-4">
|
|
<AnimatePresence>
|
|
{isSearchOpen && (
|
|
<motion.div
|
|
key="provider-search"
|
|
initial={{ opacity: 0, y: -8, scale: 0.98 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: -8, scale: 0.98 }}
|
|
transition={{ duration: 0.18, ease: "easeOut" }}
|
|
className="fixed left-1/2 top-[6.5rem] z-40 w-[min(90vw,26rem)] -translate-x-1/2 sm:right-6 sm:left-auto sm:translate-x-0"
|
|
>
|
|
<div className="p-4 space-y-3 border shadow-md rounded-2xl border-white/10 bg-background/95 shadow-black/20 backdrop-blur-md">
|
|
<div className="relative flex items-center gap-2">
|
|
<Search className="absolute w-4 h-4 -translate-y-1/2 pointer-events-none left-3 top-1/2 text-muted-foreground" />
|
|
<Input
|
|
ref={searchInputRef}
|
|
value={searchTerm}
|
|
onChange={(event) => setSearchTerm(event.target.value)}
|
|
placeholder={t("provider.searchPlaceholder", {
|
|
defaultValue: "Search name, notes, or URL...",
|
|
})}
|
|
aria-label={t("provider.searchAriaLabel", {
|
|
defaultValue: "Search providers",
|
|
})}
|
|
className="pr-16 pl-9"
|
|
/>
|
|
{searchTerm && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="absolute text-xs -translate-y-1/2 right-11 top-1/2"
|
|
onClick={() => setSearchTerm("")}
|
|
>
|
|
{t("common.clear", { defaultValue: "Clear" })}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="ml-auto"
|
|
onClick={() => setIsSearchOpen(false)}
|
|
aria-label={t("provider.searchCloseAriaLabel", {
|
|
defaultValue: "Close provider search",
|
|
})}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
|
<span>
|
|
{t("provider.searchScopeHint", {
|
|
defaultValue: "Matches provider name, notes, and URL.",
|
|
})}
|
|
</span>
|
|
<span>
|
|
{t("provider.searchCloseHint", {
|
|
defaultValue: "Press Esc to close",
|
|
})}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{filteredProviders.length === 0 ? (
|
|
<div className="px-6 py-8 text-sm text-center border border-dashed rounded-lg border-border text-muted-foreground">
|
|
{t("provider.noSearchResults", {
|
|
defaultValue: "No providers match your search.",
|
|
})}
|
|
</div>
|
|
) : (
|
|
renderProviderList()
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface SortableProviderCardProps {
|
|
provider: Provider;
|
|
isCurrent: boolean;
|
|
appId: AppId;
|
|
isInConfig: boolean;
|
|
onSwitch: (provider: Provider) => void;
|
|
onEdit: (provider: Provider) => void;
|
|
onDelete: (provider: Provider) => void;
|
|
/** OpenCode: remove from live config (not delete from database) */
|
|
onRemoveFromConfig?: (provider: Provider) => void;
|
|
onDuplicate: (provider: Provider) => void;
|
|
onConfigureUsage?: (provider: Provider) => void;
|
|
onOpenWebsite: (url: string) => void;
|
|
onOpenTerminal?: (provider: Provider) => void;
|
|
onTest?: (provider: Provider) => void;
|
|
isTesting: boolean;
|
|
isProxyRunning: boolean;
|
|
isProxyTakeover: boolean;
|
|
// 故障转移相关
|
|
isAutoFailoverEnabled: boolean;
|
|
failoverPriority?: number;
|
|
isInFailoverQueue: boolean;
|
|
onToggleFailover: (enabled: boolean) => void;
|
|
activeProviderId?: string;
|
|
}
|
|
|
|
function SortableProviderCard({
|
|
provider,
|
|
isCurrent,
|
|
appId,
|
|
isInConfig,
|
|
onSwitch,
|
|
onEdit,
|
|
onDelete,
|
|
onRemoveFromConfig,
|
|
onDuplicate,
|
|
onConfigureUsage,
|
|
onOpenWebsite,
|
|
onOpenTerminal,
|
|
onTest,
|
|
isTesting,
|
|
isProxyRunning,
|
|
isProxyTakeover,
|
|
isAutoFailoverEnabled,
|
|
failoverPriority,
|
|
isInFailoverQueue,
|
|
onToggleFailover,
|
|
activeProviderId,
|
|
}: SortableProviderCardProps) {
|
|
const {
|
|
setNodeRef,
|
|
attributes,
|
|
listeners,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id: provider.id });
|
|
|
|
const style: CSSProperties = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
};
|
|
|
|
return (
|
|
<div ref={setNodeRef} style={style}>
|
|
<ProviderCard
|
|
provider={provider}
|
|
isCurrent={isCurrent}
|
|
appId={appId}
|
|
isInConfig={isInConfig}
|
|
onSwitch={onSwitch}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
onRemoveFromConfig={onRemoveFromConfig}
|
|
onDuplicate={onDuplicate}
|
|
onConfigureUsage={
|
|
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
|
}
|
|
onOpenWebsite={onOpenWebsite}
|
|
onOpenTerminal={onOpenTerminal}
|
|
onTest={onTest}
|
|
isTesting={isTesting}
|
|
isProxyRunning={isProxyRunning}
|
|
isProxyTakeover={isProxyTakeover}
|
|
dragHandleProps={{
|
|
attributes,
|
|
listeners,
|
|
isDragging,
|
|
}}
|
|
// 故障转移相关
|
|
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
|
failoverPriority={failoverPriority}
|
|
isInFailoverQueue={isInFailoverQueue}
|
|
onToggleFailover={onToggleFailover}
|
|
activeProviderId={activeProviderId}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|