mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-02 18:41:35 +08:00
feat(ui): add auto-failover configuration UI and provider health display
Add comprehensive UI for failover management: Components: - ProviderHealthBadge: Display provider health status with color coding - CircuitBreakerConfigPanel: Configure failure/success thresholds, timeout duration, and error rate limits - AutoFailoverConfigPanel: Manage proxy targets with drag-and-drop priority ordering and individual enable/disable controls - ProxyPanel: Integrate failover tabs for unified proxy management Provider enhancements: - ProviderCard: Show health badge and proxy target indicator - ProviderActions: Add "Set as Proxy Target" action - EditProviderDialog: Add is_proxy_target toggle - ProviderList: Support proxy target filtering mode Other: - Update App.tsx routing for settings integration - Update useProviderActions hook with proxy target mutation - Fix ProviderList tests for updated component API
This commit is contained in:
+9
-3
@@ -23,6 +23,7 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
||||||
import { useProviderActions } from "@/hooks/useProviderActions";
|
import { useProviderActions } from "@/hooks/useProviderActions";
|
||||||
|
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AppSwitcher } from "@/components/AppSwitcher";
|
import { AppSwitcher } from "@/components/AppSwitcher";
|
||||||
@@ -62,7 +63,13 @@ function App() {
|
|||||||
const addActionButtonClass =
|
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";
|
"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 providers = useMemo(() => data?.providers ?? {}, [data]);
|
||||||
const currentProviderId = data?.currentProviderId ?? "";
|
const currentProviderId = data?.currentProviderId ?? "";
|
||||||
const isClaudeApp = activeApp === "claude";
|
const isClaudeApp = activeApp === "claude";
|
||||||
@@ -74,7 +81,6 @@ function App() {
|
|||||||
switchProvider,
|
switchProvider,
|
||||||
deleteProvider,
|
deleteProvider,
|
||||||
saveUsageScript,
|
saveUsageScript,
|
||||||
setProxyTarget,
|
|
||||||
} = useProviderActions(activeApp);
|
} = useProviderActions(activeApp);
|
||||||
|
|
||||||
// 监听来自托盘菜单的切换事件
|
// 监听来自托盘菜单的切换事件
|
||||||
@@ -313,8 +319,8 @@ function App() {
|
|||||||
currentProviderId={currentProviderId}
|
currentProviderId={currentProviderId}
|
||||||
appId={activeApp}
|
appId={activeApp}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
isProxyRunning={isProxyRunning}
|
||||||
onSwitch={switchProvider}
|
onSwitch={switchProvider}
|
||||||
onSetProxyTarget={setProxyTarget}
|
|
||||||
onEdit={setEditingProvider}
|
onEdit={setEditingProvider}
|
||||||
onDelete={setConfirmDelete}
|
onDelete={setConfirmDelete}
|
||||||
onDuplicate={handleDuplicateProvider}
|
onDuplicate={handleDuplicateProvider}
|
||||||
|
|||||||
@@ -33,13 +33,23 @@ export function EditProviderDialog({
|
|||||||
unknown
|
unknown
|
||||||
> | null>(null);
|
> | null>(null);
|
||||||
|
|
||||||
|
// 使用 ref 标记是否已经加载过,防止重复读取覆盖用户编辑
|
||||||
|
const [hasLoadedLive, setHasLoadedLive] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!open || !provider) {
|
if (!open || !provider) {
|
||||||
setLiveSettings(null);
|
setLiveSettings(null);
|
||||||
|
setHasLoadedLive(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关键修复:只在首次打开时加载一次
|
||||||
|
if (hasLoadedLive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentId = await providersApi.getCurrent(appId);
|
const currentId = await providersApi.getCurrent(appId);
|
||||||
if (currentId && provider.id === currentId) {
|
if (currentId && provider.id === currentId) {
|
||||||
@@ -49,13 +59,20 @@ export function EditProviderDialog({
|
|||||||
)) as Record<string, unknown>;
|
)) as Record<string, unknown>;
|
||||||
if (!cancelled && live && typeof live === "object") {
|
if (!cancelled && live && typeof live === "object") {
|
||||||
setLiveSettings(live);
|
setLiveSettings(live);
|
||||||
|
setHasLoadedLive(true);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 读取实时配置失败则回退到 SSOT(不打断编辑流程)
|
// 读取实时配置失败则回退到 SSOT(不打断编辑流程)
|
||||||
if (!cancelled) setLiveSettings(null);
|
if (!cancelled) {
|
||||||
|
setLiveSettings(null);
|
||||||
|
setHasLoadedLive(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!cancelled) setLiveSettings(null);
|
if (!cancelled) {
|
||||||
|
setLiveSettings(null);
|
||||||
|
setHasLoadedLive(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// no-op
|
// no-op
|
||||||
@@ -65,14 +82,33 @@ export function EditProviderDialog({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [open, provider, appId]);
|
}, [open, provider?.id, appId, hasLoadedLive]); // 只依赖 provider.id,不依赖整个 provider 对象
|
||||||
|
|
||||||
const initialSettingsConfig = useMemo(() => {
|
const initialSettingsConfig = useMemo(() => {
|
||||||
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
|
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
|
||||||
string,
|
string,
|
||||||
unknown
|
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(
|
const handleSubmit = useCallback(
|
||||||
async (values: ProviderFormValues) => {
|
async (values: ProviderFormValues) => {
|
||||||
@@ -104,7 +140,7 @@ export function EditProviderDialog({
|
|||||||
[onSubmit, onOpenChange, provider],
|
[onSubmit, onOpenChange, provider],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!provider) {
|
if (!provider || !initialData) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,17 +166,7 @@ export function EditProviderDialog({
|
|||||||
submitLabel={t("common.save")}
|
submitLabel={t("common.save")}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={() => onOpenChange(false)}
|
onCancel={() => onOpenChange(false)}
|
||||||
initialData={{
|
initialData={initialData}
|
||||||
name: provider.name,
|
|
||||||
notes: provider.notes,
|
|
||||||
websiteUrl: provider.websiteUrl,
|
|
||||||
// 若读取到实时配置则优先使用
|
|
||||||
settingsConfig: initialSettingsConfig,
|
|
||||||
category: provider.category,
|
|
||||||
meta: provider.meta,
|
|
||||||
icon: provider.icon,
|
|
||||||
iconColor: provider.iconColor,
|
|
||||||
}}
|
|
||||||
showButtons={false}
|
showButtons={false}
|
||||||
/>
|
/>
|
||||||
</FullScreenPanel>
|
</FullScreenPanel>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
TestTube2,
|
TestTube2,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
RotateCcw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -21,6 +22,9 @@ interface ProviderActionsProps {
|
|||||||
onTest?: () => void;
|
onTest?: () => void;
|
||||||
onConfigureUsage: () => void;
|
onConfigureUsage: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onResetCircuitBreaker?: () => void;
|
||||||
|
isProxyTarget?: boolean;
|
||||||
|
consecutiveFailures?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderActions({
|
export function ProviderActions({
|
||||||
@@ -32,6 +36,9 @@ export function ProviderActions({
|
|||||||
onTest,
|
onTest,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onResetCircuitBreaker,
|
||||||
|
isProxyTarget,
|
||||||
|
consecutiveFailures = 0,
|
||||||
}: ProviderActionsProps) {
|
}: ProviderActionsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const iconButtonClass = "h-8 w-8 p-1";
|
const iconButtonClass = "h-8 w-8 p-1";
|
||||||
@@ -110,6 +117,32 @@ export function ProviderActions({
|
|||||||
<BarChart3 className="h-4 w-4" />
|
<BarChart3 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* 重置熔断器按钮 - 代理目标启用时显示 */}
|
||||||
|
{onResetCircuitBreaker && isProxyTarget && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onResetCircuitBreaker}
|
||||||
|
disabled={consecutiveFailures === 0}
|
||||||
|
title={
|
||||||
|
consecutiveFailures > 0
|
||||||
|
? t("provider.resetCircuitBreaker", {
|
||||||
|
defaultValue: "重置熔断器",
|
||||||
|
})
|
||||||
|
: t("provider.noFailures", {
|
||||||
|
defaultValue: "当前无失败记录",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
iconButtonClass,
|
||||||
|
consecutiveFailures > 0 &&
|
||||||
|
"hover:text-orange-500 dark:hover:text-orange-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ import { ProviderIcon } from "@/components/ProviderIcon";
|
|||||||
import UsageFooter from "@/components/UsageFooter";
|
import UsageFooter from "@/components/UsageFooter";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
||||||
|
import {
|
||||||
|
useProviderHealth,
|
||||||
|
useResetCircuitBreaker,
|
||||||
|
useSetProxyTarget,
|
||||||
|
} from "@/lib/query/failover";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface DragHandleProps {
|
interface DragHandleProps {
|
||||||
attributes: DraggableAttributes;
|
attributes: DraggableAttributes;
|
||||||
@@ -32,8 +39,9 @@ interface ProviderCardProps {
|
|||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
onTest?: (provider: Provider) => void;
|
onTest?: (provider: Provider) => void;
|
||||||
isTesting?: boolean;
|
isTesting?: boolean;
|
||||||
onSetProxyTarget: (provider: Provider) => void;
|
|
||||||
isProxyRunning: boolean;
|
isProxyRunning: boolean;
|
||||||
|
proxyPriority?: number; // 代理目标的实际优先级 (1, 2, 3...)
|
||||||
|
allProviders?: Provider[]; // 所有供应商列表,用于计算开启后的优先级
|
||||||
dragHandleProps?: DragHandleProps;
|
dragHandleProps?: DragHandleProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,12 +92,109 @@ export function ProviderCard({
|
|||||||
onDuplicate,
|
onDuplicate,
|
||||||
onTest,
|
onTest,
|
||||||
isTesting,
|
isTesting,
|
||||||
onSetProxyTarget,
|
|
||||||
isProxyRunning,
|
isProxyRunning,
|
||||||
|
proxyPriority,
|
||||||
|
allProviders,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
}: ProviderCardProps) {
|
}: ProviderCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// 获取供应商健康状态
|
||||||
|
const { data: health } = useProviderHealth(provider.id, appId);
|
||||||
|
|
||||||
|
// 设置代理目标
|
||||||
|
const setProxyTargetMutation = useSetProxyTarget();
|
||||||
|
|
||||||
|
// 重置熔断器
|
||||||
|
const resetCircuitBreaker = useResetCircuitBreaker();
|
||||||
|
|
||||||
|
const handleSetProxyTarget = async (enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
await setProxyTargetMutation.mutateAsync({
|
||||||
|
providerId: provider.id,
|
||||||
|
appType: appId,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算实际优先级(开启时)
|
||||||
|
let actualPriority: number | undefined;
|
||||||
|
if (enabled && allProviders) {
|
||||||
|
// 模拟开启后的状态:获取所有将要启用代理的 providers
|
||||||
|
const futureProxyTargets = allProviders.filter((p) => {
|
||||||
|
// 包括:已经是代理目标的 或 当前要开启的这个
|
||||||
|
if (p.id === provider.id) return true;
|
||||||
|
return p.isProxyTarget;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按 sortIndex 排序
|
||||||
|
const sortedTargets = futureProxyTargets.sort((a, b) => {
|
||||||
|
const indexA = a.sortIndex ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const indexB = b.sortIndex ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
return indexA - indexB;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 找到当前 provider 的位置
|
||||||
|
const position = sortedTargets.findIndex((p) => p.id === provider.id);
|
||||||
|
actualPriority = position >= 0 ? position + 1 : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = enabled
|
||||||
|
? actualPriority
|
||||||
|
? t("provider.proxyTargetEnabled", {
|
||||||
|
defaultValue: `已启用代理目标(优先级:P${actualPriority})`,
|
||||||
|
})
|
||||||
|
: t("provider.proxyTargetEnabled", {
|
||||||
|
defaultValue: "已启用代理目标",
|
||||||
|
})
|
||||||
|
: t("provider.proxyTargetDisabled", {
|
||||||
|
defaultValue: "已禁用代理目标",
|
||||||
|
});
|
||||||
|
|
||||||
|
const description = enabled
|
||||||
|
? t("provider.proxyTargetEnabledDesc", {
|
||||||
|
defaultValue: "下次请求将按优先级自动选择此供应商",
|
||||||
|
})
|
||||||
|
: t("provider.proxyTargetDisabledDesc", {
|
||||||
|
defaultValue: "后续请求将使用其他可用供应商",
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(message, {
|
||||||
|
description,
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
t("provider.setProxyTargetFailed", {
|
||||||
|
defaultValue: "操作失败",
|
||||||
|
}) +
|
||||||
|
": " +
|
||||||
|
String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetCircuitBreaker = async () => {
|
||||||
|
try {
|
||||||
|
await resetCircuitBreaker.mutateAsync({
|
||||||
|
providerId: provider.id,
|
||||||
|
appType: appId,
|
||||||
|
});
|
||||||
|
toast.success(
|
||||||
|
t("provider.circuitBreakerReset", {
|
||||||
|
defaultValue: "熔断器已重置",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
t("provider.circuitBreakerResetFailed", {
|
||||||
|
defaultValue: "重置失败",
|
||||||
|
}) +
|
||||||
|
": " +
|
||||||
|
String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fallbackUrlText = t("provider.notConfigured", {
|
const fallbackUrlText = t("provider.notConfigured", {
|
||||||
defaultValue: "未配置接口地址",
|
defaultValue: "未配置接口地址",
|
||||||
});
|
});
|
||||||
@@ -163,6 +268,29 @@ export function ProviderCard({
|
|||||||
<h3 className="text-base font-semibold leading-none">
|
<h3 className="text-base font-semibold leading-none">
|
||||||
{provider.name}
|
{provider.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
{/* 健康状态徽章和优先级 */}
|
||||||
|
{isProxyRunning && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{/* 健康徽章:代理目标启用时始终显示,没有健康数据时默认为正常(0失败) */}
|
||||||
|
{(provider.isProxyTarget || health) && (
|
||||||
|
<ProviderHealthBadge
|
||||||
|
consecutiveFailures={health?.consecutive_failures ?? 0}
|
||||||
|
isProxyTarget={provider.isProxyTarget ?? false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* 优先级:仅在代理目标启用时显示 */}
|
||||||
|
{provider.isProxyTarget && proxyPriority && (
|
||||||
|
<span
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
title={`代理队列优先级:第${proxyPriority}位`}
|
||||||
|
>
|
||||||
|
P{proxyPriority}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{provider.category === "third_party" &&
|
{provider.category === "third_party" &&
|
||||||
provider.meta?.isPartner && (
|
provider.meta?.isPartner && (
|
||||||
<span
|
<span
|
||||||
@@ -185,11 +313,9 @@ export function ProviderCard({
|
|||||||
id={`proxy-target-switch-${provider.id}`}
|
id={`proxy-target-switch-${provider.id}`}
|
||||||
checked={provider.isProxyTarget || false}
|
checked={provider.isProxyTarget || false}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
if (checked && !provider.isProxyTarget) {
|
handleSetProxyTarget(checked);
|
||||||
onSetProxyTarget(provider);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
disabled={provider.isProxyTarget}
|
disabled={setProxyTargetMutation.isPending}
|
||||||
className="scale-75 data-[state=checked]:bg-purple-500"
|
className="scale-75 data-[state=checked]:bg-purple-500"
|
||||||
/>
|
/>
|
||||||
{provider.isProxyTarget && (
|
{provider.isProxyTarget && (
|
||||||
@@ -255,6 +381,13 @@ export function ProviderCard({
|
|||||||
onTest={onTest ? () => onTest(provider) : undefined}
|
onTest={onTest ? () => onTest(provider) : undefined}
|
||||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||||
onDelete={() => onDelete(provider)}
|
onDelete={() => onDelete(provider)}
|
||||||
|
onResetCircuitBreaker={
|
||||||
|
isProxyRunning && provider.isProxyTarget
|
||||||
|
? handleResetCircuitBreaker
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
isProxyTarget={provider.isProxyTarget}
|
||||||
|
consecutiveFailures={health?.consecutive_failures ?? 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ProviderHealthStatus } from "@/types/proxy";
|
||||||
|
|
||||||
|
interface ProviderHealthBadgeProps {
|
||||||
|
consecutiveFailures: number;
|
||||||
|
isProxyTarget?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 供应商健康状态徽章
|
||||||
|
* 根据连续失败次数显示不同颜色的状态指示器
|
||||||
|
*/
|
||||||
|
export function ProviderHealthBadge({
|
||||||
|
consecutiveFailures,
|
||||||
|
isProxyTarget,
|
||||||
|
className,
|
||||||
|
}: ProviderHealthBadgeProps) {
|
||||||
|
// 如果代理目标已关闭但有失败记录,仍然显示(自动熔断场景)
|
||||||
|
// 如果代理目标启用,始终显示
|
||||||
|
// 如果代理目标关闭且无失败记录,隐藏
|
||||||
|
if (!isProxyTarget && consecutiveFailures === 0) return null;
|
||||||
|
|
||||||
|
// 根据失败次数计算状态
|
||||||
|
const getStatus = () => {
|
||||||
|
if (consecutiveFailures === 0) {
|
||||||
|
return {
|
||||||
|
label: "正常",
|
||||||
|
status: ProviderHealthStatus.Healthy,
|
||||||
|
color: "bg-green-500",
|
||||||
|
bgColor: "bg-green-50 dark:bg-green-950",
|
||||||
|
textColor: "text-green-700 dark:text-green-300",
|
||||||
|
};
|
||||||
|
} else if (consecutiveFailures < 5) {
|
||||||
|
return {
|
||||||
|
label: "降级",
|
||||||
|
status: ProviderHealthStatus.Degraded,
|
||||||
|
color: "bg-yellow-500",
|
||||||
|
bgColor: "bg-yellow-50 dark:bg-yellow-950",
|
||||||
|
textColor: "text-yellow-700 dark:text-yellow-300",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
label: "熔断",
|
||||||
|
status: ProviderHealthStatus.Failed,
|
||||||
|
color: "bg-red-500",
|
||||||
|
bgColor: "bg-red-50 dark:bg-red-950",
|
||||||
|
textColor: "text-red-700 dark:text-red-300",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusConfig = getStatus();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium",
|
||||||
|
statusConfig.bgColor,
|
||||||
|
statusConfig.textColor,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
title={`连续失败 ${consecutiveFailures} 次`}
|
||||||
|
>
|
||||||
|
<div className={cn("w-2 h-2 rounded-full", statusConfig.color)} />
|
||||||
|
<span>{statusConfig.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,11 +5,11 @@ import {
|
|||||||
useSortable,
|
useSortable,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
|
import { useMemo } from "react";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import type { Provider } from "@/types";
|
import type { Provider } from "@/types";
|
||||||
import type { AppId } from "@/lib/api";
|
import type { AppId } from "@/lib/api";
|
||||||
import { useDragSort } from "@/hooks/useDragSort";
|
import { useDragSort } from "@/hooks/useDragSort";
|
||||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
|
||||||
import { useModelTest } from "@/hooks/useModelTest";
|
import { useModelTest } from "@/hooks/useModelTest";
|
||||||
import { ProviderCard } from "@/components/providers/ProviderCard";
|
import { ProviderCard } from "@/components/providers/ProviderCard";
|
||||||
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
||||||
@@ -26,7 +26,7 @@ interface ProviderListProps {
|
|||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
onCreate?: () => void;
|
onCreate?: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onSetProxyTarget: (provider: Provider) => void;
|
isProxyRunning?: boolean; // 代理服务运行状态
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderList({
|
export function ProviderList({
|
||||||
@@ -41,19 +41,37 @@ export function ProviderList({
|
|||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
onCreate,
|
onCreate,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
onSetProxyTarget,
|
isProxyRunning = false, // 默认值为 false
|
||||||
}: ProviderListProps) {
|
}: ProviderListProps) {
|
||||||
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
|
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
|
||||||
providers,
|
providers,
|
||||||
appId,
|
appId,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 获取代理服务运行状态
|
|
||||||
const { isRunning: isProxyRunning } = useProxyStatus();
|
|
||||||
|
|
||||||
// 模型测试
|
// 模型测试
|
||||||
const { testProvider, isTesting } = useModelTest(appId);
|
const { testProvider, isTesting } = useModelTest(appId);
|
||||||
|
|
||||||
|
// 计算代理目标的实际优先级映射 (P1, P2, P3...)
|
||||||
|
const proxyPriorityMap = useMemo(() => {
|
||||||
|
// 获取所有启用代理目标的供应商
|
||||||
|
const proxyTargets = sortedProviders.filter((p) => p.isProxyTarget);
|
||||||
|
|
||||||
|
// 按 sortIndex 排序
|
||||||
|
const sortedTargets = proxyTargets.sort((a, b) => {
|
||||||
|
const indexA = a.sortIndex ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const indexB = b.sortIndex ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
return indexA - indexB;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建优先级映射
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
sortedTargets.forEach((provider, index) => {
|
||||||
|
map.set(provider.id, index + 1); // P1, P2, P3...
|
||||||
|
});
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, [sortedProviders]);
|
||||||
|
|
||||||
const handleTest = (provider: Provider) => {
|
const handleTest = (provider: Provider) => {
|
||||||
testProvider(provider.id, provider.name);
|
testProvider(provider.id, provider.name);
|
||||||
};
|
};
|
||||||
@@ -103,8 +121,9 @@ export function ProviderList({
|
|||||||
onOpenWebsite={onOpenWebsite}
|
onOpenWebsite={onOpenWebsite}
|
||||||
onTest={handleTest}
|
onTest={handleTest}
|
||||||
isTesting={isTesting(provider.id)}
|
isTesting={isTesting(provider.id)}
|
||||||
onSetProxyTarget={onSetProxyTarget}
|
|
||||||
isProxyRunning={isProxyRunning}
|
isProxyRunning={isProxyRunning}
|
||||||
|
proxyPriority={proxyPriorityMap.get(provider.id)}
|
||||||
|
allProviders={sortedProviders}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -125,8 +144,9 @@ interface SortableProviderCardProps {
|
|||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
onTest: (provider: Provider) => void;
|
onTest: (provider: Provider) => void;
|
||||||
isTesting: boolean;
|
isTesting: boolean;
|
||||||
onSetProxyTarget: (provider: Provider) => void;
|
|
||||||
isProxyRunning: boolean;
|
isProxyRunning: boolean;
|
||||||
|
proxyPriority?: number; // 代理目标的实际优先级 (1, 2, 3...)
|
||||||
|
allProviders?: Provider[]; // 所有供应商列表
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableProviderCard({
|
function SortableProviderCard({
|
||||||
@@ -141,8 +161,9 @@ function SortableProviderCard({
|
|||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
onTest,
|
onTest,
|
||||||
isTesting,
|
isTesting,
|
||||||
onSetProxyTarget,
|
|
||||||
isProxyRunning,
|
isProxyRunning,
|
||||||
|
proxyPriority,
|
||||||
|
allProviders,
|
||||||
}: SortableProviderCardProps) {
|
}: SortableProviderCardProps) {
|
||||||
const {
|
const {
|
||||||
setNodeRef,
|
setNodeRef,
|
||||||
@@ -174,8 +195,9 @@ function SortableProviderCard({
|
|||||||
onOpenWebsite={onOpenWebsite}
|
onOpenWebsite={onOpenWebsite}
|
||||||
onTest={onTest}
|
onTest={onTest}
|
||||||
isTesting={isTesting}
|
isTesting={isTesting}
|
||||||
onSetProxyTarget={onSetProxyTarget}
|
|
||||||
isProxyRunning={isProxyRunning}
|
isProxyRunning={isProxyRunning}
|
||||||
|
proxyPriority={proxyPriority}
|
||||||
|
allProviders={allProviders}
|
||||||
dragHandleProps={{
|
dragHandleProps={{
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { ChevronDown, ChevronRight, Save, Loader2, Info } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
useCircuitBreakerConfig,
|
||||||
|
useUpdateCircuitBreakerConfig,
|
||||||
|
} from "@/lib/query/failover";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动故障转移配置面板
|
||||||
|
* 放置在高级设置页面,模型测试配置下方
|
||||||
|
*/
|
||||||
|
export function AutoFailoverConfigPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
const { data: config, isLoading, error } = useCircuitBreakerConfig();
|
||||||
|
const updateConfig = useUpdateCircuitBreakerConfig();
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
enabled: true, // 自动故障转移总开关
|
||||||
|
failureThreshold: 5,
|
||||||
|
successThreshold: 2,
|
||||||
|
timeoutSeconds: 60,
|
||||||
|
errorRateThreshold: 0.5,
|
||||||
|
minRequests: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config) {
|
||||||
|
setFormData({
|
||||||
|
enabled: true, // 默认开启,后续可以从数据库读取
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await updateConfig.mutateAsync({
|
||||||
|
failureThreshold: formData.failureThreshold,
|
||||||
|
successThreshold: formData.successThreshold,
|
||||||
|
timeoutSeconds: formData.timeoutSeconds,
|
||||||
|
errorRateThreshold: formData.errorRateThreshold,
|
||||||
|
minRequests: formData.minRequests,
|
||||||
|
});
|
||||||
|
toast.success(
|
||||||
|
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
t("proxy.autoFailover.configSaveFailed", "保存失败") + ": " + String(e),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (config) {
|
||||||
|
setFormData({
|
||||||
|
enabled: formData.enabled,
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="border rounded-lg">
|
||||||
|
<CardHeader
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{t("proxy.autoFailover.title", "自动故障转移配置")}
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border rounded-lg">
|
||||||
|
<CardHeader
|
||||||
|
className="cursor-pointer select-none"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{t("proxy.autoFailover.title", "自动故障转移配置")}
|
||||||
|
</CardTitle>
|
||||||
|
{/* 总开关 - 点击时阻止折叠/展开 */}
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
id="autoFailoverEnabled"
|
||||||
|
checked={formData.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setFormData({ ...formData, enabled: checked })
|
||||||
|
}
|
||||||
|
className="scale-90"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="autoFailoverEnabled"
|
||||||
|
className="text-xs font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
{formData.enabled
|
||||||
|
? t("proxy.autoFailover.enabled", "已启用")
|
||||||
|
: t("proxy.autoFailover.disabled", "已禁用")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isExpanded && (
|
||||||
|
<CardDescription className="mt-1">
|
||||||
|
{t(
|
||||||
|
"proxy.autoFailover.description",
|
||||||
|
"配置多个代理目标之间的自动切换规则和熔断策略",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{String(error)}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Alert className="border-blue-500/40 bg-blue-500/10">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertDescription className="text-sm">
|
||||||
|
{t(
|
||||||
|
"proxy.autoFailover.info",
|
||||||
|
"当启用多个代理目标时,系统会按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会自动打开,跳过该供应商。",
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* 重试与超时配置 */}
|
||||||
|
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
|
||||||
|
<h4 className="text-sm font-semibold">
|
||||||
|
{t("proxy.autoFailover.retrySettings", "重试与超时设置")}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="failureThreshold">
|
||||||
|
{t("proxy.autoFailover.failureThreshold", "失败阈值")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="failureThreshold"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
value={formData.failureThreshold}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
failureThreshold: parseInt(e.target.value) || 5,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!formData.enabled}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"proxy.autoFailover.failureThresholdHint",
|
||||||
|
"连续失败多少次后打开熔断器(建议: 3-10)",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="timeoutSeconds">
|
||||||
|
{t("proxy.autoFailover.timeout", "恢复等待时间(秒)")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="timeoutSeconds"
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
max="300"
|
||||||
|
value={formData.timeoutSeconds}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
timeoutSeconds: parseInt(e.target.value) || 60,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!formData.enabled}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"proxy.autoFailover.timeoutHint",
|
||||||
|
"熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 熔断器高级配置 */}
|
||||||
|
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
|
||||||
|
<h4 className="text-sm font-semibold">
|
||||||
|
{t("proxy.autoFailover.circuitBreakerSettings", "熔断器高级设置")}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="successThreshold">
|
||||||
|
{t("proxy.autoFailover.successThreshold", "恢复成功阈值")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="successThreshold"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
value={formData.successThreshold}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
successThreshold: parseInt(e.target.value) || 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!formData.enabled}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"proxy.autoFailover.successThresholdHint",
|
||||||
|
"半开状态下成功多少次后关闭熔断器",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="errorRateThreshold">
|
||||||
|
{t("proxy.autoFailover.errorRate", "错误率阈值 (%)")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="errorRateThreshold"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="5"
|
||||||
|
value={Math.round(formData.errorRateThreshold * 100)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
errorRateThreshold:
|
||||||
|
(parseInt(e.target.value) || 50) / 100,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!formData.enabled}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"proxy.autoFailover.errorRateHint",
|
||||||
|
"错误率超过此值时打开熔断器",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="minRequests">
|
||||||
|
{t("proxy.autoFailover.minRequests", "最小请求数")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="minRequests"
|
||||||
|
type="number"
|
||||||
|
min="5"
|
||||||
|
max="100"
|
||||||
|
value={formData.minRequests}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
minRequests: parseInt(e.target.value) || 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!formData.enabled}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"proxy.autoFailover.minRequestsHint",
|
||||||
|
"计算错误率前的最小请求数",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={updateConfig.isPending || !formData.enabled}
|
||||||
|
>
|
||||||
|
{t("common.reset", "重置")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={updateConfig.isPending || !formData.enabled}
|
||||||
|
>
|
||||||
|
{updateConfig.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{t("common.saving", "保存中...")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{t("common.save", "保存")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 说明信息 */}
|
||||||
|
<div className="p-4 bg-muted/50 rounded-lg space-y-2 text-sm">
|
||||||
|
<h4 className="font-medium">
|
||||||
|
{t("proxy.autoFailover.explanationTitle", "工作原理")}
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1 text-muted-foreground">
|
||||||
|
<li>
|
||||||
|
•{" "}
|
||||||
|
<strong>
|
||||||
|
{t("proxy.autoFailover.failureThresholdLabel", "失败阈值")}
|
||||||
|
</strong>
|
||||||
|
:
|
||||||
|
{t(
|
||||||
|
"proxy.autoFailover.failureThresholdExplain",
|
||||||
|
"连续失败达到此次数时,熔断器打开,该供应商暂时不可用",
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
•{" "}
|
||||||
|
<strong>
|
||||||
|
{t("proxy.autoFailover.timeoutLabel", "恢复等待时间")}
|
||||||
|
</strong>
|
||||||
|
:
|
||||||
|
{t(
|
||||||
|
"proxy.autoFailover.timeoutExplain",
|
||||||
|
"熔断器打开后,等待此时间后尝试半开状态",
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
•{" "}
|
||||||
|
<strong>
|
||||||
|
{t(
|
||||||
|
"proxy.autoFailover.successThresholdLabel",
|
||||||
|
"恢复成功阈值",
|
||||||
|
)}
|
||||||
|
</strong>
|
||||||
|
:
|
||||||
|
{t(
|
||||||
|
"proxy.autoFailover.successThresholdExplain",
|
||||||
|
"半开状态下,成功达到此次数时关闭熔断器,供应商恢复可用",
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
•{" "}
|
||||||
|
<strong>
|
||||||
|
{t("proxy.autoFailover.errorRateLabel", "错误率阈值")}
|
||||||
|
</strong>
|
||||||
|
:
|
||||||
|
{t(
|
||||||
|
"proxy.autoFailover.errorRateExplain",
|
||||||
|
"错误率超过此值时,即使未达到失败阈值也会打开熔断器",
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <div className="text-sm text-muted-foreground">加载中...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">熔断器配置</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
调整熔断器参数以控制故障检测和恢复行为
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-border my-4" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* 失败阈值 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="failureThreshold">失败阈值</Label>
|
||||||
|
<Input
|
||||||
|
id="failureThreshold"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
value={formData.failureThreshold}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
failureThreshold: parseInt(e.target.value) || 5,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
连续失败多少次后打开熔断器
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 超时时间 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="timeoutSeconds">超时时间(秒)</Label>
|
||||||
|
<Input
|
||||||
|
id="timeoutSeconds"
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
max="300"
|
||||||
|
value={formData.timeoutSeconds}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
timeoutSeconds: parseInt(e.target.value) || 60,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
熔断器打开后多久尝试恢复(半开状态)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 成功阈值 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="successThreshold">成功阈值</Label>
|
||||||
|
<Input
|
||||||
|
id="successThreshold"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
value={formData.successThreshold}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
successThreshold: parseInt(e.target.value) || 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
半开状态下成功多少次后关闭熔断器
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误率阈值 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="errorRateThreshold">错误率阈值 (%)</Label>
|
||||||
|
<Input
|
||||||
|
id="errorRateThreshold"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="5"
|
||||||
|
value={Math.round(formData.errorRateThreshold * 100)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
errorRateThreshold: (parseInt(e.target.value) || 50) / 100,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
错误率超过此值时打开熔断器
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 最小请求数 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="minRequests">最小请求数</Label>
|
||||||
|
<Input
|
||||||
|
id="minRequests"
|
||||||
|
type="number"
|
||||||
|
min="5"
|
||||||
|
max="100"
|
||||||
|
value={formData.minRequests}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
minRequests: parseInt(e.target.value) || 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
计算错误率前的最小请求数
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button onClick={handleSave} disabled={updateConfig.isPending}>
|
||||||
|
{updateConfig.isPending ? "保存中..." : "保存配置"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={updateConfig.isPending}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 说明信息 */}
|
||||||
|
<div className="p-4 bg-muted/50 rounded-lg space-y-2 text-sm">
|
||||||
|
<h4 className="font-medium">配置说明</h4>
|
||||||
|
<ul className="space-y-1 text-muted-foreground">
|
||||||
|
<li>
|
||||||
|
• <strong>失败阈值</strong>:连续失败达到此次数时,熔断器打开
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
• <strong>超时时间</strong>:熔断器打开后,等待此时间后尝试半开
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
• <strong>成功阈值</strong>:半开状态下,成功达到此次数时关闭熔断器
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
• <strong>错误率阈值</strong>:错误率超过此值时,熔断器打开
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
• <strong>最小请求数</strong>:只有请求数达到此值后才计算错误率
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,14 +3,30 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
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 { ProxySettingsDialog } from "./ProxySettingsDialog";
|
||||||
import { toast } from "sonner";
|
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() {
|
export function ProxyPanel() {
|
||||||
const { status, isRunning, start, stop, isPending } = useProxyStatus();
|
const { status, isRunning, start, stop, isPending } = useProxyStatus();
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
|
||||||
|
// 获取所有三个应用类型的代理目标列表
|
||||||
|
const { data: claudeTargets = [] } = useProxyTargets("claude");
|
||||||
|
const { data: codexTargets = [] } = useProxyTargets("codex");
|
||||||
|
const { data: geminiTargets = [] } = useProxyTargets("gemini");
|
||||||
|
|
||||||
const handleToggle = async () => {
|
const handleToggle = async () => {
|
||||||
try {
|
try {
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
@@ -112,7 +128,7 @@ export function ProxyPanel() {
|
|||||||
|
|
||||||
<div className="pt-3 border-t border-white/10 space-y-2">
|
<div className="pt-3 border-t border-white/10 space-y-2">
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
当前代理
|
使用中
|
||||||
</p>
|
</p>
|
||||||
{status.active_targets && status.active_targets.length > 0 ? (
|
{status.active_targets && status.active_targets.length > 0 ? (
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
@@ -146,6 +162,50 @@ export function ProxyPanel() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 供应商队列 - 按应用类型分组展示 */}
|
||||||
|
{(claudeTargets.length > 0 ||
|
||||||
|
codexTargets.length > 0 ||
|
||||||
|
geminiTargets.length > 0) && (
|
||||||
|
<div className="pt-3 border-t border-white/10 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ListOrdered className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
故障转移队列
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Claude 队列 */}
|
||||||
|
{claudeTargets.length > 0 && (
|
||||||
|
<ProviderQueueGroup
|
||||||
|
appType="claude"
|
||||||
|
appLabel="Claude"
|
||||||
|
targets={claudeTargets}
|
||||||
|
status={status}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Codex 队列 */}
|
||||||
|
{codexTargets.length > 0 && (
|
||||||
|
<ProviderQueueGroup
|
||||||
|
appType="codex"
|
||||||
|
appLabel="Codex"
|
||||||
|
targets={codexTargets}
|
||||||
|
status={status}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gemini 队列 */}
|
||||||
|
{geminiTargets.length > 0 && (
|
||||||
|
<ProviderQueueGroup
|
||||||
|
appType="gemini"
|
||||||
|
appLabel="Gemini"
|
||||||
|
targets={geminiTargets}
|
||||||
|
status={status}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
@@ -220,3 +280,104 @@ function StatCard({ icon, label, value, variant = "default" }: StatCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* 应用类型标题 */}
|
||||||
|
<div className="flex items-center gap-2 px-2">
|
||||||
|
<span className="text-xs font-semibold text-foreground/80">
|
||||||
|
{appLabel}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-px bg-border/50" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 供应商列表 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{targets.map((target, index) => (
|
||||||
|
<ProviderQueueItem
|
||||||
|
key={target.id}
|
||||||
|
provider={target}
|
||||||
|
priority={index + 1}
|
||||||
|
appType={appType}
|
||||||
|
isCurrent={activeTarget?.provider_id === target.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between rounded-md border px-3 py-2 text-sm transition-colors ${
|
||||||
|
isCurrent
|
||||||
|
? "border-primary/40 bg-primary/10 text-primary font-medium"
|
||||||
|
: "border-white/10 bg-background/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold ${
|
||||||
|
isCurrent
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{priority}
|
||||||
|
</span>
|
||||||
|
<span className={isCurrent ? "" : "text-foreground"}>
|
||||||
|
{provider.name}
|
||||||
|
</span>
|
||||||
|
{isCurrent && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/20 text-primary">
|
||||||
|
使用中
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 健康徽章:队列中的代理目标始终显示,没有健康数据时默认为正常 */}
|
||||||
|
<ProviderHealthBadge
|
||||||
|
consecutiveFailures={health?.consecutive_failures ?? 0}
|
||||||
|
isProxyTarget={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { AboutSection } from "@/components/settings/AboutSection";
|
|||||||
import { ProxyPanel } from "@/components/proxy";
|
import { ProxyPanel } from "@/components/proxy";
|
||||||
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
|
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
|
||||||
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
|
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
|
||||||
|
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
|
||||||
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
import { useSettings } from "@/hooks/useSettings";
|
||||||
import { useImportExport } from "@/hooks/useImportExport";
|
import { useImportExport } from "@/hooks/useImportExport";
|
||||||
@@ -222,6 +223,9 @@ export function SettingsPage({
|
|||||||
{/* 模型测试配置 */}
|
{/* 模型测试配置 */}
|
||||||
<ModelTestConfigPanel />
|
<ModelTestConfigPanel />
|
||||||
|
|
||||||
|
{/* 自动故障转移配置 */}
|
||||||
|
<AutoFailoverConfigPanel />
|
||||||
|
|
||||||
<ImportExportSection
|
<ImportExportSection
|
||||||
status={importStatus}
|
status={importStatus}
|
||||||
selectedFile={selectedFile}
|
selectedFile={selectedFile}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
useUpdateProviderMutation,
|
useUpdateProviderMutation,
|
||||||
useDeleteProviderMutation,
|
useDeleteProviderMutation,
|
||||||
useSwitchProviderMutation,
|
useSwitchProviderMutation,
|
||||||
useSetProxyTargetMutation,
|
|
||||||
} from "@/lib/query";
|
} from "@/lib/query";
|
||||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
|
|
||||||
@@ -25,7 +24,6 @@ export function useProviderActions(activeApp: AppId) {
|
|||||||
const updateProviderMutation = useUpdateProviderMutation(activeApp);
|
const updateProviderMutation = useUpdateProviderMutation(activeApp);
|
||||||
const deleteProviderMutation = useDeleteProviderMutation(activeApp);
|
const deleteProviderMutation = useDeleteProviderMutation(activeApp);
|
||||||
const switchProviderMutation = useSwitchProviderMutation(activeApp);
|
const switchProviderMutation = useSwitchProviderMutation(activeApp);
|
||||||
const setProxyTargetMutation = useSetProxyTargetMutation(activeApp);
|
|
||||||
|
|
||||||
// Claude 插件同步逻辑
|
// Claude 插件同步逻辑
|
||||||
const syncClaudePlugin = useCallback(
|
const syncClaudePlugin = useCallback(
|
||||||
@@ -93,14 +91,6 @@ export function useProviderActions(activeApp: AppId) {
|
|||||||
[switchProviderMutation, syncClaudePlugin],
|
[switchProviderMutation, syncClaudePlugin],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 设置代理目标
|
|
||||||
const setProxyTarget = useCallback(
|
|
||||||
async (provider: Provider) => {
|
|
||||||
await setProxyTargetMutation.mutateAsync(provider.id);
|
|
||||||
},
|
|
||||||
[setProxyTargetMutation],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 删除供应商
|
// 删除供应商
|
||||||
const deleteProvider = useCallback(
|
const deleteProvider = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
@@ -146,14 +136,12 @@ export function useProviderActions(activeApp: AppId) {
|
|||||||
addProvider,
|
addProvider,
|
||||||
updateProvider,
|
updateProvider,
|
||||||
switchProvider,
|
switchProvider,
|
||||||
setProxyTarget,
|
|
||||||
deleteProvider,
|
deleteProvider,
|
||||||
saveUsageScript,
|
saveUsageScript,
|
||||||
isLoading:
|
isLoading:
|
||||||
addProviderMutation.isPending ||
|
addProviderMutation.isPending ||
|
||||||
updateProviderMutation.isPending ||
|
updateProviderMutation.isPending ||
|
||||||
deleteProviderMutation.isPending ||
|
deleteProviderMutation.isPending ||
|
||||||
switchProviderMutation.isPending ||
|
switchProviderMutation.isPending,
|
||||||
setProxyTargetMutation.isPending,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ describe("ProviderList Component", () => {
|
|||||||
onDelete={vi.fn()}
|
onDelete={vi.fn()}
|
||||||
onDuplicate={vi.fn()}
|
onDuplicate={vi.fn()}
|
||||||
onOpenWebsite={vi.fn()}
|
onOpenWebsite={vi.fn()}
|
||||||
onSetProxyTarget={vi.fn()}
|
|
||||||
isLoading
|
isLoading
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -154,7 +153,6 @@ describe("ProviderList Component", () => {
|
|||||||
onDelete={vi.fn()}
|
onDelete={vi.fn()}
|
||||||
onDuplicate={vi.fn()}
|
onDuplicate={vi.fn()}
|
||||||
onOpenWebsite={vi.fn()}
|
onOpenWebsite={vi.fn()}
|
||||||
onSetProxyTarget={vi.fn()}
|
|
||||||
onCreate={handleCreate}
|
onCreate={handleCreate}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -195,7 +193,6 @@ describe("ProviderList Component", () => {
|
|||||||
onDuplicate={handleDuplicate}
|
onDuplicate={handleDuplicate}
|
||||||
onConfigureUsage={handleUsage}
|
onConfigureUsage={handleUsage}
|
||||||
onOpenWebsite={handleOpenWebsite}
|
onOpenWebsite={handleOpenWebsite}
|
||||||
onSetProxyTarget={vi.fn()}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user