Files
cc-switch/src/components/providers/forms/EndpointSpeedTest.tsx
YoVinchen cf57fbed7b style: format template literals for better readability
Improve code formatting for conditional className expressions.
- Break long template literals across multiple lines
- Maintain consistent formatting in MCP form and endpoint test components
2025-11-22 17:22:36 +08:00

644 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Zap, Loader2, Plus, X, AlertCircle, Save } from "lucide-react";
import type { AppId } from "@/lib/api";
import { vscodeApi } from "@/lib/api/vscode";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import type { CustomEndpoint, EndpointCandidate } from "@/types";
// 端点测速超时配置(秒)
const ENDPOINT_TIMEOUT_SECS = {
codex: 12,
claude: 8,
gemini: 8, // 新增 gemini
} as const;
interface TestResult {
url: string;
latency: number | null;
status?: number;
error?: string | null;
}
interface EndpointSpeedTestProps {
appId: AppId;
providerId?: string;
value: string;
onChange: (url: string) => void;
initialEndpoints: EndpointCandidate[];
visible?: boolean;
onClose: () => void;
// 新建模式:当自定义端点列表变化时回传(仅包含 isCustom 的条目)
// 编辑模式:不使用此回调,端点直接保存到后端
onCustomEndpointsChange?: (urls: string[]) => void;
}
interface EndpointEntry extends EndpointCandidate {
id: string;
latency: number | null;
status?: number;
error?: string | null;
}
const randomId = () => `ep_${Math.random().toString(36).slice(2, 9)}`;
const normalizeEndpointUrl = (url: string): string =>
url.trim().replace(/\/+$/, "");
const buildInitialEntries = (
candidates: EndpointCandidate[],
selected: string,
): EndpointEntry[] => {
const map = new Map<string, EndpointEntry>();
const addCandidate = (candidate: EndpointCandidate) => {
const sanitized = candidate.url ? normalizeEndpointUrl(candidate.url) : "";
if (!sanitized) return;
if (map.has(sanitized)) return;
map.set(sanitized, {
id: candidate.id ?? randomId(),
url: sanitized,
isCustom: candidate.isCustom ?? false,
latency: null,
status: undefined,
error: null,
});
};
candidates.forEach(addCandidate);
const selectedUrl = normalizeEndpointUrl(selected);
if (selectedUrl && !map.has(selectedUrl)) {
addCandidate({ url: selectedUrl, isCustom: true });
}
return Array.from(map.values());
};
const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
appId,
providerId,
value,
onChange,
initialEndpoints,
visible = true,
onClose,
onCustomEndpointsChange,
}) => {
const { t } = useTranslation();
const [entries, setEntries] = useState<EndpointEntry[]>(() =>
buildInitialEntries(initialEndpoints, value),
);
const [customUrl, setCustomUrl] = useState("");
const [addError, setAddError] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
const [isTesting, setIsTesting] = useState(false);
const [lastError, setLastError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
// 记录初始的自定义端点,用于对比变化
const [initialCustomUrls, setInitialCustomUrls] = useState<Set<string>>(
new Set(),
);
const normalizedSelected = normalizeEndpointUrl(value);
const hasEndpoints = entries.length > 0;
const isEditMode = Boolean(providerId); // 编辑模式有 providerId
// 编辑模式:加载已保存的自定义端点
useEffect(() => {
let cancelled = false;
const loadCustomEndpoints = async () => {
try {
if (!providerId) return; // 新建模式不加载
const customEndpoints = await vscodeApi.getCustomEndpoints(
appId,
providerId,
);
if (cancelled) return;
const candidates: EndpointCandidate[] = customEndpoints.map(
(ep: CustomEndpoint) => ({
url: ep.url,
isCustom: true,
}),
);
// 记录初始的自定义端点
const customUrls = new Set(
customEndpoints.map((ep) => normalizeEndpointUrl(ep.url)),
);
setInitialCustomUrls(customUrls);
// 合并自定义端点与初始端点
setEntries((prev) => {
const map = new Map<string, EndpointEntry>();
// 先添加现有端点
prev.forEach((entry) => {
map.set(entry.url, entry);
});
// 添加从后端加载的自定义端点
candidates.forEach((candidate) => {
const sanitized = normalizeEndpointUrl(candidate.url);
if (sanitized && !map.has(sanitized)) {
map.set(sanitized, {
id: randomId(),
url: sanitized,
isCustom: true,
latency: null,
status: undefined,
error: null,
});
}
});
return Array.from(map.values());
});
} catch (error) {
if (!cancelled) {
console.error(t("endpointTest.loadEndpointsFailed"), error);
}
}
};
// 只在编辑模式下加载
if (providerId) {
loadCustomEndpoints();
}
return () => {
cancelled = true;
};
}, [appId, providerId, t, initialEndpoints]);
// 新建模式:将自定义端点变化透传给父组件(仅限 isCustom
// 编辑模式:不使用此回调,端点已通过 API 直接保存
useEffect(() => {
if (!onCustomEndpointsChange || isEditMode) return; // 编辑模式不使用回调
try {
const customUrls = Array.from(
new Set(
entries
.filter((e) => e.isCustom)
.map((e) => (e.url ? normalizeEndpointUrl(e.url) : ""))
.filter(Boolean),
),
);
onCustomEndpointsChange(customUrls);
} catch (err) {
// ignore
}
}, [entries, onCustomEndpointsChange, isEditMode]);
const sortedEntries = useMemo(() => {
return entries.slice().sort((a: TestResult, b: TestResult) => {
const aLatency = a.latency ?? Number.POSITIVE_INFINITY;
const bLatency = b.latency ?? Number.POSITIVE_INFINITY;
if (aLatency === bLatency) {
return a.url.localeCompare(b.url);
}
return aLatency - bLatency;
});
}, [entries]);
const handleAddEndpoint = useCallback(async () => {
const candidate = customUrl.trim();
let errorMsg: string | null = null;
if (!candidate) {
errorMsg = t("endpointTest.enterValidUrl");
}
let parsed: URL | null = null;
if (!errorMsg) {
try {
parsed = new URL(candidate);
} catch {
errorMsg = t("endpointTest.invalidUrlFormat");
}
}
// 明确只允许 http: 和 https:
const allowedProtocols = ["http:", "https:"];
if (!errorMsg && parsed && !allowedProtocols.includes(parsed.protocol)) {
errorMsg = t("endpointTest.onlyHttps");
}
let sanitized = "";
if (!errorMsg && parsed) {
sanitized = normalizeEndpointUrl(parsed.toString());
// 使用当前 entries 做去重校验
const isDuplicate = entries.some((entry) => entry.url === sanitized);
if (isDuplicate) {
errorMsg = t("endpointTest.urlExists");
}
}
if (errorMsg) {
setAddError(errorMsg);
return;
}
setAddError(null);
setLastError(null);
// 更新本地状态(延迟保存,点击保存按钮时统一处理)
setEntries((prev) => {
if (prev.some((e) => e.url === sanitized)) return prev;
return [
...prev,
{
id: randomId(),
url: sanitized,
isCustom: true,
latency: null,
status: undefined,
error: null,
},
];
});
if (!normalizedSelected) {
onChange(sanitized);
}
setCustomUrl("");
}, [customUrl, entries, normalizedSelected, onChange, t]);
const handleRemoveEndpoint = useCallback(
(entry: EndpointEntry) => {
// 清空之前的错误提示
setLastError(null);
// 更新本地状态(延迟保存,点击保存按钮时统一处理)
setEntries((prev) => {
const next = prev.filter((item) => item.id !== entry.id);
if (entry.url === normalizedSelected) {
const fallback = next[0];
onChange(fallback ? fallback.url : "");
}
return next;
});
},
[normalizedSelected, onChange],
);
const runSpeedTest = useCallback(async () => {
const urls = entries.map((entry) => entry.url);
if (urls.length === 0) {
setLastError(t("endpointTest.pleaseAddEndpoint"));
return;
}
setIsTesting(true);
setLastError(null);
// 清空所有延迟数据,显示 loading 状态
setEntries((prev) =>
prev.map((entry) => ({
...entry,
latency: null,
status: undefined,
error: null,
})),
);
try {
const results = await vscodeApi.testApiEndpoints(urls, {
timeoutSecs: ENDPOINT_TIMEOUT_SECS[appId],
});
const resultMap = new Map(
results.map((item) => [normalizeEndpointUrl(item.url), item]),
);
setEntries((prev) =>
prev.map((entry) => {
const match = resultMap.get(entry.url);
if (!match) {
return {
...entry,
latency: null,
status: undefined,
error: t("endpointTest.noResult"),
};
}
return {
...entry,
latency:
typeof match.latency === "number"
? Math.round(match.latency)
: null,
status: match.status,
error: match.error ?? null,
};
}),
);
if (autoSelect) {
const successful = results
.filter(
(item) => typeof item.latency === "number" && item.latency !== null,
)
.sort((a, b) => (a.latency! || 0) - (b.latency! || 0));
const best = successful[0];
if (best && best.url && best.url !== normalizedSelected) {
onChange(best.url);
}
}
} catch (error) {
const message =
error instanceof Error
? error.message
: `${t("endpointTest.testFailed", { error: String(error) })}`;
setLastError(message);
} finally {
setIsTesting(false);
}
}, [entries, autoSelect, appId, normalizedSelected, onChange, t]);
const handleSelect = useCallback(
(url: string) => {
if (!url || url === normalizedSelected) return;
onChange(url);
},
[normalizedSelected, onChange],
);
// 保存端点变更
const handleSave = useCallback(async () => {
// 编辑模式:对比初始端点和当前端点,批量保存变更
if (isEditMode && providerId) {
setIsSaving(true);
setLastError(null);
try {
// 获取当前的自定义端点
const currentCustomUrls = new Set(
entries
.filter((e) => e.isCustom)
.map((e) => normalizeEndpointUrl(e.url)),
);
// 找出新增的端点
const toAdd = Array.from(currentCustomUrls).filter(
(url) => !initialCustomUrls.has(url),
);
// 找出删除的端点
const toRemove = Array.from(initialCustomUrls).filter(
(url) => !currentCustomUrls.has(url),
);
// 批量添加
for (const url of toAdd) {
await vscodeApi.addCustomEndpoint(appId, providerId, url);
}
// 批量删除
for (const url of toRemove) {
await vscodeApi.removeCustomEndpoint(appId, providerId, url);
}
// 更新初始端点列表
setInitialCustomUrls(currentCustomUrls);
} catch (error) {
const message =
error instanceof Error ? error.message : t("endpointTest.saveFailed");
setLastError(message);
setIsSaving(false);
return;
} finally {
setIsSaving(false);
}
}
// 关闭弹窗
onClose();
}, [isEditMode, providerId, entries, initialCustomUrls, appId, onClose, t]);
if (!visible) return null;
const footer = (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={(event) => {
event.preventDefault();
onClose();
}}
disabled={isSaving}
>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={handleSave}
disabled={isSaving}
className="gap-2"
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t("common.saving")}
</>
) : (
<>
<Save className="w-4 h-4" />
{t("common.save")}
</>
)}
</Button>
</div>
);
return (
<FullScreenPanel
isOpen={visible}
title={t("endpointTest.title")}
onClose={onClose}
footer={footer}
>
<div className="glass rounded-xl p-6 border border-white/10 flex flex-col gap-6">
{/* 测速控制栏 */}
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{entries.length} {t("endpointTest.endpoints")}
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={autoSelect}
onChange={(event) => setAutoSelect(event.target.checked)}
className="h-3.5 w-3.5 rounded border-border-default bg-background text-primary focus:ring-2 focus:ring-primary/20"
/>
{t("endpointTest.autoSelect")}
</label>
<Button
type="button"
onClick={runSpeedTest}
disabled={isTesting || !hasEndpoints}
size="sm"
className="h-7 w-24 gap-1.5 text-xs bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-60"
>
{isTesting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("endpointTest.testing")}
</>
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t("endpointTest.testSpeed")}
</>
)}
</Button>
</div>
</div>
{/* 添加输入 */}
<div className="space-y-1.5">
<div className="flex gap-2">
<Input
type="url"
value={customUrl}
placeholder={t("endpointTest.addEndpointPlaceholder")}
onChange={(event) => setCustomUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAddEndpoint();
}
}}
className="flex-1"
/>
<Button
type="button"
onClick={handleAddEndpoint}
variant="outline"
size="icon"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{addError && (
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
<AlertCircle className="h-3 w-3" />
{addError}
</div>
)}
</div>
{/* 端点列表 */}
{hasEndpoints ? (
<div className="space-y-2">
{sortedEntries.map((entry) => {
const isSelected = normalizedSelected === entry.url;
const latency = entry.latency;
return (
<div
key={entry.id}
onClick={() => handleSelect(entry.url)}
className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition ${
isSelected
? "border-primary/70 bg-primary/5 shadow-sm"
: "border-border-default bg-background hover:bg-muted"
}`}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
{/* 选择指示器 */}
<div
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full transition ${
isSelected
? "bg-blue-500 dark:bg-blue-400"
: "bg-gray-300 dark:bg-gray-700"
}`}
/>
{/* 内容 */}
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-gray-900 dark:text-gray-100">
{entry.url}
</div>
</div>
</div>
{/* 右侧信息 */}
<div className="flex items-center gap-2">
{latency !== null ? (
<div className="text-right">
<div
className={`font-mono text-sm font-medium ${
latency < 300
? "text-emerald-600 dark:text-emerald-400"
: latency < 500
? "text-yellow-600 dark:text-yellow-400"
: latency < 800
? "text-orange-600 dark:text-orange-400"
: "text-red-600 dark:text-red-400"
}`}
>
{latency}ms
</div>
<div className="text-[10px] text-gray-500 dark:text-gray-400">
{entry.status
? t("endpointTest.status", { code: entry.status })
: t("endpointTest.notTested")}
</div>
</div>
) : isTesting ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
) : entry.error ? (
<div className="text-xs text-gray-400">
{t("endpointTest.failed")}
</div>
) : (
<div className="text-xs text-gray-400"></div>
)}
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleRemoveEndpoint(entry);
}}
className="opacity-0 transition hover:text-red-600 group-hover:opacity-100 dark:hover:text-red-400"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-md border border-dashed border-border-default bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
{t("endpointTest.empty")}
</div>
)}
{/* 错误提示 */}
{lastError && (
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
<AlertCircle className="h-3 w-3" />
{lastError}
</div>
)}
</div>
</FullScreenPanel>
);
};
export default EndpointSpeedTest;