mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 08:14:33 +08:00
8b92982112
* refactor(proxy): simplify logging for better readability - Delete 17 verbose debug logs from handlers, streaming, and response_processor - Convert excessive INFO logs to DEBUG level for internal processing details - Add 2 critical INFO logs in forwarder.rs for failover scenarios: - Log when switching to next provider after failure - Log when all providers have been exhausted - Fix clippy uninlined_format_args warning This reduces log noise while maintaining visibility into key user-facing decisions. * fix: replace unsafe unwrap() calls with proper error handling - database/dao/mcp.rs: Use map_err for serde_json serialization - database/dao/providers.rs: Use map_err for settings_config and meta serialization - commands/misc.rs: Use expect() for compile-time regex pattern - services/prompt.rs: Use unwrap_or_default() for SystemTime - deeplink/provider.rs: Replace unwrap() with is_none_or pattern for Option checks Reduces potential panic points from 26 to 1 (static regex init, safe). * refactor(proxy): simplify verbose logging output - Remove response JSON full output logging in response_processor - Remove per-request INFO logs in provider_router (failover status, provider selection) - Change model mapping log from INFO to DEBUG - Change usage logging failure from INFO to WARN - Remove redundant debug logs for circuit breaker operations Reduces log noise significantly while preserving important warnings and errors. * feat(proxy): add structured log codes for i18n support Add error code system to proxy module logs for multi-language support: - CB-001~006: Circuit breaker state transitions and triggers - SRV-001~004: Proxy server lifecycle events - FWD-001~002: Request forwarding and failover - FO-001~005: Failover switch operations - USG-001~002: Usage logging errors Log format: [CODE] Chinese message Frontend/log tools can map codes to any language. New file: src/proxy/log_codes.rs - centralized code definitions * chore: bump version to 3.9.1 * style: format code with prettier and rustfmt * fix(ui): allow number inputs to be fully cleared before saving - Convert numeric state to string type for controlled inputs - Use isNaN() check instead of || fallback to allow 0 values - Apply fix to ProxyPanel, CircuitBreakerConfigPanel, AutoFailoverConfigPanel, and ModelTestConfigPanel * feat(pricing): support @ separator in model name matching - Refactor model name cleaning into chained method calls - Add @ to - replacement (e.g., gpt-5.2-codex@low → gpt-5.2-codex-low) - Add test case for @ separator matching * feat(proxy): add global proxy settings support Add ability to configure a global HTTP/HTTPS proxy for all outbound requests including provider API calls, speed tests, and stream checks. * fix(proxy): improve validation and error handling in proxy config panels - Add StopTimeout/StopFailed error types for proper stop() error reporting - Replace silent clamp with validation-and-block in config panels - Add listenAddress format validation in ProxyPanel - Use log_codes constants instead of hardcoded strings - Use once_cell::Lazy for regex precompilation * fix(proxy): harden error handling and input validation - Handle RwLock poisoning in settings.rs with unwrap_or_else - Add fallback for dirs::home_dir() in config modules - Normalize localhost to 127.0.0.1 in ProxyPanel - Format IPv6 addresses with brackets for valid URLs - Strict port validation with pure digit regex - Treat NaN as validation failure in config panels - Log warning on cost_multiplier parse failure - Align timeoutSeconds range to [0, 300] across all panels * feat(proxy): add local proxy auto-scan and fix hot-reload - Add scan_local_proxies command to detect common proxy ports - Fix SkillService not using updated proxy after hot-reload - Move global proxy settings to advanced tab - Add error handling for scan failures * fix(proxy): allow localhost input in proxy address field * fix(proxy): restore request timeout and fix proxy hot-reload issues - Add URL scheme validation in build_client (http/https/socks5/socks5h) - Restore per-request timeout for speedtest, stream_check, usage_script, forwarder - Fix set_global_proxy_url to validate before persisting to DB - Mask proxy credentials in all log outputs - Fix forwarder hot-reload by fetching client on each request * style: format code with prettier * fix(proxy): improve global proxy stability and error handling - Fix RwLock silent failures with explicit error propagation - Handle init() duplicate calls gracefully with warning log - Align fallback client config with build_client settings - Make scan_local_proxies async to avoid UI blocking - Add mixed mode support for Clash 7890 port (http+socks5) - Use multiple test targets for better proxy connectivity test - Clear invalid proxy config on init failure - Restore timeout constraints in usage_script - Fix mask_url output for URLs without port - Add structured error codes [GP-001 to GP-009] * feat(proxy): add username/password authentication support - Add separate username and password input fields - Implement password visibility toggle with eye icon - Add clear button to reset all proxy fields - Auto-extract auth info from saved URL and merge on save - Update i18n translations (zh/en/ja) * fix(proxy): fix double encoding issue in proxy auth and add debug logs - Remove encodeURIComponent in mergeAuth() since URL object's username/password setters already do percent-encoding automatically - Add GP-010 debug log for database read operations - Add GP-011 debug log to track incoming URL info (length, has_auth) - Fix username.trim() in fallback branch for consistent behavior
652 lines
22 KiB
TypeScript
652 lines
22 KiB
TypeScript
import { useState, useEffect } from "react";
|
||
import {
|
||
Activity,
|
||
Clock,
|
||
TrendingUp,
|
||
Server,
|
||
ListOrdered,
|
||
Save,
|
||
Loader2,
|
||
} from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Input } from "@/components/ui/input";
|
||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||
import { toast } from "sonner";
|
||
import { useFailoverQueue } from "@/lib/query/failover";
|
||
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
||
import { useProviderHealth } from "@/lib/query/failover";
|
||
import {
|
||
useProxyTakeoverStatus,
|
||
useSetProxyTakeoverForApp,
|
||
useGlobalProxyConfig,
|
||
useUpdateGlobalProxyConfig,
|
||
} from "@/lib/query/proxy";
|
||
import type { ProxyStatus } from "@/types/proxy";
|
||
import { useTranslation } from "react-i18next";
|
||
|
||
export function ProxyPanel() {
|
||
const { t } = useTranslation();
|
||
const { status, isRunning } = useProxyStatus();
|
||
|
||
// 获取应用接管状态
|
||
const { data: takeoverStatus } = useProxyTakeoverStatus();
|
||
const setTakeoverForApp = useSetProxyTakeoverForApp();
|
||
|
||
// 获取全局代理配置
|
||
const { data: globalConfig } = useGlobalProxyConfig();
|
||
const updateGlobalConfig = useUpdateGlobalProxyConfig();
|
||
|
||
// 监听地址/端口的本地状态(端口用字符串以支持完全清空)
|
||
const [listenAddress, setListenAddress] = useState("127.0.0.1");
|
||
const [listenPort, setListenPort] = useState("15721");
|
||
|
||
// 同步全局配置到本地状态
|
||
useEffect(() => {
|
||
if (globalConfig) {
|
||
setListenAddress(globalConfig.listenAddress);
|
||
setListenPort(String(globalConfig.listenPort));
|
||
}
|
||
}, [globalConfig]);
|
||
|
||
// 获取所有三个应用类型的故障转移队列(不包含当前供应商)
|
||
// 当前供应商始终优先,队列仅用于失败后的备用顺序
|
||
const { data: claudeQueue = [] } = useFailoverQueue("claude");
|
||
const { data: codexQueue = [] } = useFailoverQueue("codex");
|
||
const { data: geminiQueue = [] } = useFailoverQueue("gemini");
|
||
|
||
const handleTakeoverChange = async (appType: string, enabled: boolean) => {
|
||
try {
|
||
await setTakeoverForApp.mutateAsync({ appType, enabled });
|
||
toast.success(
|
||
enabled
|
||
? t("proxy.takeover.enabled", {
|
||
app: appType,
|
||
defaultValue: `${appType} 接管已启用`,
|
||
})
|
||
: t("proxy.takeover.disabled", {
|
||
app: appType,
|
||
defaultValue: `${appType} 接管已关闭`,
|
||
}),
|
||
{ closeButton: true },
|
||
);
|
||
} catch (error) {
|
||
toast.error(
|
||
t("proxy.takeover.failed", {
|
||
defaultValue: "切换接管状态失败",
|
||
}),
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleLoggingChange = async (enabled: boolean) => {
|
||
if (!globalConfig) return;
|
||
try {
|
||
await updateGlobalConfig.mutateAsync({
|
||
...globalConfig,
|
||
enableLogging: enabled,
|
||
});
|
||
toast.success(
|
||
enabled
|
||
? t("proxy.logging.enabled", { defaultValue: "日志记录已启用" })
|
||
: t("proxy.logging.disabled", { defaultValue: "日志记录已关闭" }),
|
||
{ closeButton: true },
|
||
);
|
||
} catch (error) {
|
||
toast.error(
|
||
t("proxy.logging.failed", { defaultValue: "切换日志状态失败" }),
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleSaveBasicConfig = async () => {
|
||
if (!globalConfig) return;
|
||
|
||
// 校验地址格式(简单的 IP 地址或 localhost 校验)
|
||
const addressTrimmed = listenAddress.trim();
|
||
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||
const isValidAddress =
|
||
addressTrimmed === "localhost" ||
|
||
addressTrimmed === "0.0.0.0" ||
|
||
(ipv4Regex.test(addressTrimmed) &&
|
||
addressTrimmed.split(".").every((n) => {
|
||
const num = parseInt(n);
|
||
return num >= 0 && num <= 255;
|
||
}));
|
||
if (!isValidAddress) {
|
||
toast.error(
|
||
t("proxy.settings.invalidAddress", {
|
||
defaultValue:
|
||
"地址无效,请输入有效的 IP 地址(如 127.0.0.1)或 localhost",
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
// 严格校验端口:必须是纯数字
|
||
const portTrimmed = listenPort.trim();
|
||
if (!/^\d+$/.test(portTrimmed)) {
|
||
toast.error(
|
||
t("proxy.settings.invalidPort", {
|
||
defaultValue: "端口无效,请输入 1024-65535 之间的数字",
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
const port = parseInt(portTrimmed);
|
||
if (isNaN(port) || port < 1024 || port > 65535) {
|
||
toast.error(
|
||
t("proxy.settings.invalidPort", {
|
||
defaultValue: "端口无效,请输入 1024-65535 之间的数字",
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
try {
|
||
await updateGlobalConfig.mutateAsync({
|
||
...globalConfig,
|
||
listenAddress: addressTrimmed,
|
||
listenPort: port,
|
||
});
|
||
toast.success(
|
||
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
|
||
{ closeButton: true },
|
||
);
|
||
} catch (error) {
|
||
toast.error(
|
||
t("proxy.settings.configSaveFailed", { defaultValue: "保存配置失败" }),
|
||
);
|
||
}
|
||
};
|
||
|
||
const formatUptime = (seconds: number): string => {
|
||
const hours = Math.floor(seconds / 3600);
|
||
const minutes = Math.floor((seconds % 3600) / 60);
|
||
const secs = seconds % 60;
|
||
|
||
if (hours > 0) {
|
||
return `${hours}h ${minutes}m ${secs}s`;
|
||
} else if (minutes > 0) {
|
||
return `${minutes}m ${secs}s`;
|
||
} else {
|
||
return `${secs}s`;
|
||
}
|
||
};
|
||
|
||
// 格式化地址用于 URL(IPv6 需要方括号)
|
||
const formatAddressForUrl = (address: string, port: number): string => {
|
||
const isIPv6 = address.includes(":");
|
||
const host = isIPv6 ? `[${address}]` : address;
|
||
return `http://${host}:${port}`;
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<section className="space-y-6">
|
||
{isRunning && status ? (
|
||
<div className="space-y-6">
|
||
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
|
||
<div>
|
||
<p className="text-xs text-muted-foreground mb-2">
|
||
{t("proxy.panel.serviceAddress", {
|
||
defaultValue: "服务地址",
|
||
})}
|
||
</p>
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||
<code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60">
|
||
{formatAddressForUrl(status.address, status.port)}
|
||
</code>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => {
|
||
navigator.clipboard.writeText(
|
||
formatAddressForUrl(status.address, status.port),
|
||
);
|
||
toast.success(
|
||
t("proxy.panel.addressCopied", {
|
||
defaultValue: "地址已复制",
|
||
}),
|
||
{ closeButton: true },
|
||
);
|
||
}}
|
||
>
|
||
{t("common.copy")}
|
||
</Button>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground mt-2">
|
||
{t("proxy.settings.restartRequired", {
|
||
defaultValue: "修改监听地址/端口需要先停止代理服务",
|
||
})}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="pt-3 border-t border-border space-y-2">
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("provider.inUse")}
|
||
</p>
|
||
{status.active_targets && status.active_targets.length > 0 ? (
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
{status.active_targets.map((target) => (
|
||
<div
|
||
key={target.app_type}
|
||
className="flex items-center justify-between rounded-md border border-border bg-background/60 px-2 py-1.5 text-xs"
|
||
>
|
||
<span className="text-muted-foreground">
|
||
{target.app_type}
|
||
</span>
|
||
<span
|
||
className="ml-2 font-medium truncate text-foreground"
|
||
title={target.provider_name}
|
||
>
|
||
{target.provider_name}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : status.current_provider ? (
|
||
<p className="text-sm text-muted-foreground">
|
||
{t("proxy.panel.currentProvider", {
|
||
defaultValue: "当前 Provider:",
|
||
})}{" "}
|
||
<span className="font-medium text-foreground">
|
||
{status.current_provider}
|
||
</span>
|
||
</p>
|
||
) : (
|
||
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
||
{t("proxy.panel.waitingFirstRequest", {
|
||
defaultValue: "当前 Provider:等待首次请求…",
|
||
})}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 应用接管开关 */}
|
||
<div className="pt-3 border-t border-border space-y-3">
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("proxyConfig.appTakeover", {
|
||
defaultValue: "应用接管",
|
||
})}
|
||
</p>
|
||
<div className="grid gap-2 sm:grid-cols-3">
|
||
{(["claude", "codex", "gemini"] as const).map((appType) => {
|
||
const isEnabled =
|
||
takeoverStatus?.[
|
||
appType as keyof typeof takeoverStatus
|
||
] ?? false;
|
||
return (
|
||
<div
|
||
key={appType}
|
||
className="flex items-center justify-between rounded-md border border-border bg-background/60 px-3 py-2"
|
||
>
|
||
<span className="text-sm font-medium capitalize">
|
||
{appType}
|
||
</span>
|
||
<Switch
|
||
checked={isEnabled}
|
||
onCheckedChange={(checked) =>
|
||
handleTakeoverChange(appType, checked)
|
||
}
|
||
disabled={setTakeoverForApp.isPending}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 日志记录开关 */}
|
||
<div className="pt-3 border-t border-border">
|
||
<div className="flex items-center justify-between rounded-md border border-border bg-background/60 px-3 py-2">
|
||
<div className="space-y-0.5">
|
||
<Label className="text-sm font-medium">
|
||
{t("proxy.settings.fields.enableLogging.label", {
|
||
defaultValue: "启用日志记录",
|
||
})}
|
||
</Label>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("proxy.settings.fields.enableLogging.description", {
|
||
defaultValue: "记录所有代理请求,便于排查问题",
|
||
})}
|
||
</p>
|
||
</div>
|
||
<Switch
|
||
checked={globalConfig?.enableLogging ?? true}
|
||
onCheckedChange={handleLoggingChange}
|
||
disabled={updateGlobalConfig.isPending}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 供应商队列 - 按应用类型分组展示 */}
|
||
{(claudeQueue.length > 0 ||
|
||
codexQueue.length > 0 ||
|
||
geminiQueue.length > 0) && (
|
||
<div className="pt-3 border-t border-border 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 text-muted-foreground">
|
||
{t("proxy.failoverQueue.title")}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Claude 队列 */}
|
||
{claudeQueue.length > 0 && (
|
||
<ProviderQueueGroup
|
||
appType="claude"
|
||
appLabel="Claude"
|
||
targets={claudeQueue.map((item) => ({
|
||
id: item.providerId,
|
||
name: item.providerName,
|
||
}))}
|
||
status={status}
|
||
/>
|
||
)}
|
||
|
||
{/* Codex 队列 */}
|
||
{codexQueue.length > 0 && (
|
||
<ProviderQueueGroup
|
||
appType="codex"
|
||
appLabel="Codex"
|
||
targets={codexQueue.map((item) => ({
|
||
id: item.providerId,
|
||
name: item.providerName,
|
||
}))}
|
||
status={status}
|
||
/>
|
||
)}
|
||
|
||
{/* Gemini 队列 */}
|
||
{geminiQueue.length > 0 && (
|
||
<ProviderQueueGroup
|
||
appType="gemini"
|
||
appLabel="Gemini"
|
||
targets={geminiQueue.map((item) => ({
|
||
id: item.providerId,
|
||
name: item.providerName,
|
||
}))}
|
||
status={status}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid gap-3 md:grid-cols-4">
|
||
<StatCard
|
||
icon={<Activity className="h-4 w-4" />}
|
||
label={t("proxy.panel.stats.activeConnections", {
|
||
defaultValue: "活跃连接",
|
||
})}
|
||
value={status.active_connections}
|
||
/>
|
||
<StatCard
|
||
icon={<TrendingUp className="h-4 w-4" />}
|
||
label={t("proxy.panel.stats.totalRequests", {
|
||
defaultValue: "总请求数",
|
||
})}
|
||
value={status.total_requests}
|
||
/>
|
||
<StatCard
|
||
icon={<Clock className="h-4 w-4" />}
|
||
label={t("proxy.panel.stats.successRate", {
|
||
defaultValue: "成功率",
|
||
})}
|
||
value={`${status.success_rate.toFixed(1)}%`}
|
||
variant={status.success_rate > 90 ? "success" : "warning"}
|
||
/>
|
||
<StatCard
|
||
icon={<Clock className="h-4 w-4" />}
|
||
label={t("proxy.panel.stats.uptime", {
|
||
defaultValue: "运行时间",
|
||
})}
|
||
value={formatUptime(status.uptime_seconds)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-6">
|
||
{/* 空白区域避免冲突 */}
|
||
<div className="h-4"></div>
|
||
|
||
{/* 基础设置 - 监听地址/端口 */}
|
||
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
|
||
<div>
|
||
<h4 className="text-sm font-semibold">
|
||
{t("proxy.settings.basic.title", {
|
||
defaultValue: "基础设置",
|
||
})}
|
||
</h4>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("proxy.settings.basic.description", {
|
||
defaultValue: "配置代理服务监听的地址与端口。",
|
||
})}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="listen-address">
|
||
{t("proxy.settings.fields.listenAddress.label", {
|
||
defaultValue: "监听地址",
|
||
})}
|
||
</Label>
|
||
<Input
|
||
id="listen-address"
|
||
value={listenAddress}
|
||
onChange={(e) => setListenAddress(e.target.value)}
|
||
placeholder={t(
|
||
"proxy.settings.fields.listenAddress.placeholder",
|
||
{
|
||
defaultValue: "127.0.0.1",
|
||
},
|
||
)}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("proxy.settings.fields.listenAddress.description", {
|
||
defaultValue:
|
||
"代理服务器监听的 IP 地址(推荐 127.0.0.1)",
|
||
})}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="listen-port">
|
||
{t("proxy.settings.fields.listenPort.label", {
|
||
defaultValue: "监听端口",
|
||
})}
|
||
</Label>
|
||
<Input
|
||
id="listen-port"
|
||
type="number"
|
||
value={listenPort}
|
||
onChange={(e) => setListenPort(e.target.value)}
|
||
placeholder={t(
|
||
"proxy.settings.fields.listenPort.placeholder",
|
||
{
|
||
defaultValue: "15721",
|
||
},
|
||
)}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("proxy.settings.fields.listenPort.description", {
|
||
defaultValue: "代理服务器监听的端口号(1024 ~ 65535)",
|
||
})}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-end">
|
||
<Button
|
||
size="sm"
|
||
onClick={handleSaveBasicConfig}
|
||
disabled={updateGlobalConfig.isPending}
|
||
>
|
||
{updateGlobalConfig.isPending ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
{t("common.saving", { defaultValue: "保存中..." })}
|
||
</>
|
||
) : (
|
||
<>
|
||
<Save className="mr-2 h-4 w-4" />
|
||
{t("common.save", { defaultValue: "保存" })}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 代理服务已停止提示 */}
|
||
<div className="text-center py-6 text-muted-foreground">
|
||
<div className="mx-auto w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
||
<Server className="h-8 w-8" />
|
||
</div>
|
||
<p className="text-base font-medium text-foreground mb-1">
|
||
{t("proxy.panel.stoppedTitle", {
|
||
defaultValue: "代理服务已停止",
|
||
})}
|
||
</p>
|
||
<p className="text-sm text-muted-foreground">
|
||
{t("proxy.panel.stoppedDescription", {
|
||
defaultValue: "使用右上角开关即可启动服务",
|
||
})}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</>
|
||
);
|
||
}
|
||
|
||
interface StatCardProps {
|
||
icon: React.ReactNode;
|
||
label: string;
|
||
value: string | number;
|
||
variant?: "default" | "success" | "warning";
|
||
}
|
||
|
||
function StatCard({ icon, label, value, variant = "default" }: StatCardProps) {
|
||
const variantStyles = {
|
||
default: "",
|
||
success: "border-green-500/40 bg-green-500/5",
|
||
warning: "border-yellow-500/40 bg-yellow-500/5",
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className={`rounded-lg border border-border bg-card/60 p-4 text-sm text-muted-foreground ${variantStyles[variant]}`}
|
||
>
|
||
<div className="flex items-center gap-2 text-muted-foreground mb-2">
|
||
{icon}
|
||
<span className="text-xs">{label}</span>
|
||
</div>
|
||
<p className="text-xl font-semibold text-foreground">{value}</p>
|
||
</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 { t } = useTranslation();
|
||
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-border 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">
|
||
{t("provider.inUse")}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{/* 健康徽章 */}
|
||
<ProviderHealthBadge
|
||
consecutiveFailures={health?.consecutive_failures ?? 0}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|