mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-01 12:22:09 +08:00
Feat/auto failover (#367)
* feat(db): add circuit breaker config table and provider proxy target APIs Add database support for auto-failover feature: - Add circuit_breaker_config table for storing failover thresholds - Add get/update_circuit_breaker_config methods in proxy DAO - Add reset_provider_health method for manual recovery - Add set_proxy_target and get_proxy_targets methods in providers DAO for managing multi-provider failover configuration * feat(proxy): implement circuit breaker and provider router for auto-failover Add core failover logic: - CircuitBreaker: Tracks provider health with three states: - Closed: Normal operation, requests pass through - Open: Circuit broken after consecutive failures, skip provider - HalfOpen: Testing recovery with limited requests - ProviderRouter: Routes requests across multiple providers with: - Health tracking and automatic failover - Configurable failure/success thresholds - Auto-disable proxy target after reaching failure threshold - Support for manual circuit breaker reset - Export new types in proxy module * feat(proxy): add failover Tauri commands and integrate with forwarder Expose failover functionality to frontend: - Add Tauri commands: get_proxy_targets, set_proxy_target, get_provider_health, reset_circuit_breaker, get/update_circuit_breaker_config, get_circuit_breaker_stats - Register all new commands in lib.rs invoke handler - Update forwarder with improved error handling and logging - Integrate ProviderRouter with proxy server startup - Add provider health tracking in request handlers * feat(frontend): add failover API layer and TanStack Query hooks Add frontend data layer for failover management: - Add failover.ts API: Tauri invoke wrappers for all failover commands - Add failover.ts query hooks: TanStack Query mutations and queries - useProxyTargets, useProviderHealth queries - useSetProxyTarget, useResetCircuitBreaker mutations - useCircuitBreakerConfig query and mutation - Update queries.ts with provider health query key - Update mutations.ts to invalidate health on provider changes - Add CircuitBreakerConfig and ProviderHealth types * 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 * fix(usage): stabilize date range to prevent infinite re-renders * feat(backend): add tool version check command Add get_tool_versions command to check local and latest versions of Claude, Codex, and Gemini CLI tools: - Detect local installed versions via command line execution - Fetch latest versions from npm registry (Claude, Gemini) and GitHub releases API (Codex) - Return comprehensive version info including error details for uninstalled tools - Register command in Tauri invoke handler * style(ui): format accordion component code style Apply consistent code formatting to accordion component: - Convert double quotes to semicolons at line endings - Adjust indentation to 2-space standard - Align with project code style conventions * refactor(providers): update provider card styling to use theme tokens Replace hardcoded color classes with semantic design tokens: - Use bg-card, border-border, text-card-foreground instead of glass-card - Replace gray/white color literals with muted/foreground tokens - Change proxy target indicator color from purple to green - Improve hover states with border-border-active - Ensure consistent dark mode support via CSS variables * refactor(proxy): simplify auto-failover config panel structure Restructure AutoFailoverConfigPanel for better integration: - Remove internal Card wrapper and expansion toggle (now handled by parent) - Extract enabled state to props for external control - Simplify loading state display - Clean up redundant CardHeader/CardContent wrappers - ProxyPanel: reduce complexity by delegating to parent components * feat(settings): enhance settings page with accordion layout and tool versions Major settings page improvements: AboutSection: - Add local tool version detection (Claude, Codex, Gemini) - Display installed vs latest version comparison with visual indicators - Show update availability badges and environment check cards SettingsPage: - Reorganize advanced settings into collapsible accordion sections - Add proxy control panel with inline status toggle - Integrate auto-failover configuration with accordion UI - Add database and cost calculation config sections DirectorySettings & WindowSettings: - Minor styling adjustments for consistency settings.ts API: - Add getToolVersions() wrapper for new backend command * refactor(usage): restructure usage dashboard components Comprehensive usage statistics panel refactoring: UsageDashboard: - Reorganize layout with improved section headers - Add better loading states and empty state handling ModelStatsTable & ProviderStatsTable: - Minor styling updates for consistency ModelTestConfigPanel & PricingConfigPanel: - Simplify component structure - Remove redundant Card wrappers - Improve form field organization RequestLogTable: - Enhance table layout with better column sizing - Improve pagination controls UsageSummaryCards: - Update card styling with semantic tokens - Better responsive grid layout UsageTrendChart: - Refine chart container styling - Improve legend and tooltip display * chore(deps): add accordion and animation dependencies Package updates: - Add @radix-ui/react-accordion for collapsible sections - Add cmdk for command palette support - Add framer-motion for enhanced animations Tailwind config: - Add accordion-up/accordion-down animations - Update darkMode config to support both selector and class - Reorganize color and keyframe definitions for clarity * style(app): update header and app switcher styling App.tsx: - Replace glass-header with explicit bg-background/80 backdrop-blur - Update navigation button container to use bg-muted AppSwitcher: - Replace hardcoded gray colors with semantic muted/foreground tokens - Ensure consistent dark mode support via CSS variables - Add group class for better hover state transitions
This commit is contained in:
@@ -33,13 +33,23 @@ export function EditProviderDialog({
|
||||
unknown
|
||||
> | null>(null);
|
||||
|
||||
// 使用 ref 标记是否已经加载过,防止重复读取覆盖用户编辑
|
||||
const [hasLoadedLive, setHasLoadedLive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
if (!open || !provider) {
|
||||
setLiveSettings(null);
|
||||
setHasLoadedLive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 关键修复:只在首次打开时加载一次
|
||||
if (hasLoadedLive) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentId = await providersApi.getCurrent(appId);
|
||||
if (currentId && provider.id === currentId) {
|
||||
@@ -49,13 +59,20 @@ export function EditProviderDialog({
|
||||
)) as Record<string, unknown>;
|
||||
if (!cancelled && live && typeof live === "object") {
|
||||
setLiveSettings(live);
|
||||
setHasLoadedLive(true);
|
||||
}
|
||||
} catch {
|
||||
// 读取实时配置失败则回退到 SSOT(不打断编辑流程)
|
||||
if (!cancelled) setLiveSettings(null);
|
||||
if (!cancelled) {
|
||||
setLiveSettings(null);
|
||||
setHasLoadedLive(true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!cancelled) setLiveSettings(null);
|
||||
if (!cancelled) {
|
||||
setLiveSettings(null);
|
||||
setHasLoadedLive(true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// no-op
|
||||
@@ -65,14 +82,33 @@ export function EditProviderDialog({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, provider, appId]);
|
||||
}, [open, provider?.id, appId, hasLoadedLive]); // 只依赖 provider.id,不依赖整个 provider 对象
|
||||
|
||||
const initialSettingsConfig = useMemo(() => {
|
||||
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
|
||||
string,
|
||||
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(
|
||||
async (values: ProviderFormValues) => {
|
||||
@@ -104,7 +140,7 @@ export function EditProviderDialog({
|
||||
[onSubmit, onOpenChange, provider],
|
||||
);
|
||||
|
||||
if (!provider) {
|
||||
if (!provider || !initialData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -130,17 +166,7 @@ export function EditProviderDialog({
|
||||
submitLabel={t("common.save")}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
initialData={{
|
||||
name: provider.name,
|
||||
notes: provider.notes,
|
||||
websiteUrl: provider.websiteUrl,
|
||||
// 若读取到实时配置则优先使用
|
||||
settingsConfig: initialSettingsConfig,
|
||||
category: provider.category,
|
||||
meta: provider.meta,
|
||||
icon: provider.icon,
|
||||
iconColor: provider.iconColor,
|
||||
}}
|
||||
initialData={initialData}
|
||||
showButtons={false}
|
||||
/>
|
||||
</FullScreenPanel>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Play,
|
||||
TestTube2,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -21,6 +22,9 @@ interface ProviderActionsProps {
|
||||
onTest?: () => void;
|
||||
onConfigureUsage: () => void;
|
||||
onDelete: () => void;
|
||||
onResetCircuitBreaker?: () => void;
|
||||
isProxyTarget?: boolean;
|
||||
consecutiveFailures?: number;
|
||||
}
|
||||
|
||||
export function ProviderActions({
|
||||
@@ -32,6 +36,9 @@ export function ProviderActions({
|
||||
onTest,
|
||||
onConfigureUsage,
|
||||
onDelete,
|
||||
onResetCircuitBreaker,
|
||||
isProxyTarget,
|
||||
consecutiveFailures = 0,
|
||||
}: ProviderActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const iconButtonClass = "h-8 w-8 p-1";
|
||||
@@ -110,6 +117,32 @@ export function ProviderActions({
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</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
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
|
||||
@@ -13,6 +13,13 @@ import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
import UsageFooter from "@/components/UsageFooter";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
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 {
|
||||
attributes: DraggableAttributes;
|
||||
@@ -32,8 +39,9 @@ interface ProviderCardProps {
|
||||
onDuplicate: (provider: Provider) => void;
|
||||
onTest?: (provider: Provider) => void;
|
||||
isTesting?: boolean;
|
||||
onSetProxyTarget: (provider: Provider) => void;
|
||||
isProxyRunning: boolean;
|
||||
proxyPriority?: number; // 代理目标的实际优先级 (1, 2, 3...)
|
||||
allProviders?: Provider[]; // 所有供应商列表,用于计算开启后的优先级
|
||||
dragHandleProps?: DragHandleProps;
|
||||
}
|
||||
|
||||
@@ -84,12 +92,109 @@ export function ProviderCard({
|
||||
onDuplicate,
|
||||
onTest,
|
||||
isTesting,
|
||||
onSetProxyTarget,
|
||||
isProxyRunning,
|
||||
proxyPriority,
|
||||
allProviders,
|
||||
dragHandleProps,
|
||||
}: ProviderCardProps) {
|
||||
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", {
|
||||
defaultValue: "未配置接口地址",
|
||||
});
|
||||
@@ -124,9 +229,9 @@ export function ProviderCard({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"glass-card relative overflow-hidden rounded-xl p-4 transition-all duration-300",
|
||||
"group hover:bg-black/[0.02] dark:hover:bg-white/[0.02] hover:border-primary/50",
|
||||
isCurrent ? "glass-card-active" : "hover:scale-[1.01]",
|
||||
"relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300",
|
||||
"bg-card text-card-foreground group hover:border-border-active",
|
||||
isCurrent ? "border-primary/50 shadow-sm" : "hover:shadow-sm",
|
||||
dragHandleProps?.isDragging &&
|
||||
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
|
||||
)}
|
||||
@@ -149,7 +254,7 @@ export function ProviderCard({
|
||||
</button>
|
||||
|
||||
{/* 供应商图标 */}
|
||||
<div className="h-8 w-8 rounded-lg bg-white/5 flex items-center justify-center border border-gray-200 dark:border-white/10 group-hover:scale-105 transition-transform duration-300">
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
|
||||
<ProviderIcon
|
||||
icon={provider.icon}
|
||||
name={provider.name}
|
||||
@@ -163,6 +268,29 @@ export function ProviderCard({
|
||||
<h3 className="text-base font-semibold leading-none">
|
||||
{provider.name}
|
||||
</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.meta?.isPartner && (
|
||||
<span
|
||||
@@ -185,17 +313,15 @@ export function ProviderCard({
|
||||
id={`proxy-target-switch-${provider.id}`}
|
||||
checked={provider.isProxyTarget || false}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked && !provider.isProxyTarget) {
|
||||
onSetProxyTarget(provider);
|
||||
}
|
||||
handleSetProxyTarget(checked);
|
||||
}}
|
||||
disabled={provider.isProxyTarget}
|
||||
className="scale-75 data-[state=checked]:bg-purple-500"
|
||||
disabled={setProxyTargetMutation.isPending}
|
||||
className="scale-75 data-[state=checked]:bg-green-500"
|
||||
/>
|
||||
{provider.isProxyTarget && (
|
||||
<Label
|
||||
htmlFor={`proxy-target-switch-${provider.id}`}
|
||||
className="text-xs font-medium text-purple-500 dark:text-purple-400 cursor-pointer"
|
||||
className="text-xs font-medium text-green-600 dark:text-green-400 cursor-pointer"
|
||||
>
|
||||
{t("provider.proxyTarget", { defaultValue: "代理目标" })}
|
||||
</Label>
|
||||
@@ -255,6 +381,13 @@ export function ProviderCard({
|
||||
onTest={onTest ? () => onTest(provider) : undefined}
|
||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||
onDelete={() => onDelete(provider)}
|
||||
onResetCircuitBreaker={
|
||||
isProxyRunning && provider.isProxyTarget
|
||||
? handleResetCircuitBreaker
|
||||
: undefined
|
||||
}
|
||||
isProxyTarget={provider.isProxyTarget}
|
||||
consecutiveFailures={health?.consecutive_failures ?? 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ export function ProviderEmptyState({ onCreate }: ProviderEmptyStateProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-muted-foreground/30 p-10 text-center">
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border p-10 text-center">
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Users className="h-7 w-7 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
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-500/10",
|
||||
textColor: "text-green-600 dark:text-green-400",
|
||||
};
|
||||
} else if (consecutiveFailures < 5) {
|
||||
return {
|
||||
label: "降级",
|
||||
status: ProviderHealthStatus.Degraded,
|
||||
color: "bg-yellow-500",
|
||||
bgColor: "bg-yellow-500/10",
|
||||
textColor: "text-yellow-600 dark:text-yellow-400",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
label: "熔断",
|
||||
status: ProviderHealthStatus.Failed,
|
||||
color: "bg-red-500",
|
||||
bgColor: "bg-red-500/10",
|
||||
textColor: "text-red-600 dark:text-red-400",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { useMemo } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import type { Provider } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { useDragSort } from "@/hooks/useDragSort";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import { useModelTest } from "@/hooks/useModelTest";
|
||||
import { ProviderCard } from "@/components/providers/ProviderCard";
|
||||
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
||||
@@ -26,7 +26,7 @@ interface ProviderListProps {
|
||||
onOpenWebsite: (url: string) => void;
|
||||
onCreate?: () => void;
|
||||
isLoading?: boolean;
|
||||
onSetProxyTarget: (provider: Provider) => void;
|
||||
isProxyRunning?: boolean; // 代理服务运行状态
|
||||
}
|
||||
|
||||
export function ProviderList({
|
||||
@@ -41,19 +41,37 @@ export function ProviderList({
|
||||
onOpenWebsite,
|
||||
onCreate,
|
||||
isLoading = false,
|
||||
onSetProxyTarget,
|
||||
isProxyRunning = false, // 默认值为 false
|
||||
}: ProviderListProps) {
|
||||
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
|
||||
providers,
|
||||
appId,
|
||||
);
|
||||
|
||||
// 获取代理服务运行状态
|
||||
const { isRunning: isProxyRunning } = useProxyStatus();
|
||||
|
||||
// 模型测试
|
||||
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) => {
|
||||
testProvider(provider.id, provider.name);
|
||||
};
|
||||
@@ -103,8 +121,9 @@ export function ProviderList({
|
||||
onOpenWebsite={onOpenWebsite}
|
||||
onTest={handleTest}
|
||||
isTesting={isTesting(provider.id)}
|
||||
onSetProxyTarget={onSetProxyTarget}
|
||||
isProxyRunning={isProxyRunning}
|
||||
proxyPriority={proxyPriorityMap.get(provider.id)}
|
||||
allProviders={sortedProviders}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -125,8 +144,9 @@ interface SortableProviderCardProps {
|
||||
onOpenWebsite: (url: string) => void;
|
||||
onTest: (provider: Provider) => void;
|
||||
isTesting: boolean;
|
||||
onSetProxyTarget: (provider: Provider) => void;
|
||||
isProxyRunning: boolean;
|
||||
proxyPriority?: number; // 代理目标的实际优先级 (1, 2, 3...)
|
||||
allProviders?: Provider[]; // 所有供应商列表
|
||||
}
|
||||
|
||||
function SortableProviderCard({
|
||||
@@ -141,8 +161,9 @@ function SortableProviderCard({
|
||||
onOpenWebsite,
|
||||
onTest,
|
||||
isTesting,
|
||||
onSetProxyTarget,
|
||||
isProxyRunning,
|
||||
proxyPriority,
|
||||
allProviders,
|
||||
}: SortableProviderCardProps) {
|
||||
const {
|
||||
setNodeRef,
|
||||
@@ -174,8 +195,9 @@ function SortableProviderCard({
|
||||
onOpenWebsite={onOpenWebsite}
|
||||
onTest={onTest}
|
||||
isTesting={isTesting}
|
||||
onSetProxyTarget={onSetProxyTarget}
|
||||
isProxyRunning={isProxyRunning}
|
||||
proxyPriority={proxyPriority}
|
||||
allProviders={allProviders}
|
||||
dragHandleProps={{
|
||||
attributes,
|
||||
listeners,
|
||||
|
||||
Reference in New Issue
Block a user