Files
cc-switch/src/components/providers/forms/EndpointSpeedTest.tsx

643 lines
20 KiB
TypeScript
Raw Normal View History

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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import type { CustomEndpoint, EndpointCandidate } from "@/types";
// 端点测速超时配置(秒)
const ENDPOINT_TIMEOUT_SECS = {
codex: 12,
claude: 8,
feat(gemini): add Gemini provider integration (#202) * feat(gemini): add Gemini provider integration - Add gemini_config.rs module for .env file parsing - Extend AppType enum to support Gemini - Implement GeminiConfigEditor and GeminiFormFields components - Add GeminiIcon with standardized 1024x1024 viewBox - Add Gemini provider presets configuration - Update i18n translations for Gemini support - Extend ProviderService and McpService for Gemini * fix(gemini): resolve TypeScript errors, add i18n support, and fix MCP logic **Critical Fixes:** - Fix TS2741 errors in tests/msw/state.ts by adding missing Gemini type definitions - Fix ProviderCard.extractApiUrl to support GOOGLE_GEMINI_BASE_URL display - Add missing apps.gemini i18n keys (zh/en) for proper app name display - Fix MCP service Gemini cross-app duplication logic to prevent self-copy **Technical Details:** - tests/msw/state.ts: Add gemini default providers, current ID, and MCP config - ProviderCard.tsx: Check both ANTHROPIC_BASE_URL and GOOGLE_GEMINI_BASE_URL - services/mcp.rs: Skip Gemini in sync_other_side logic with unreachable!() guards - Run pnpm format to auto-fix code style issues **Verification:** - ✅ pnpm typecheck passes - ✅ pnpm format completed * feat(gemini): enhance authentication and config parsing - Add strict and lenient .env parsing modes - Implement PackyCode partner authentication detection - Support Google OAuth official authentication - Auto-configure security.auth.selectedType for PackyCode - Add comprehensive test coverage for all auth types - Update i18n for OAuth hints and Gemini config --------- Co-authored-by: Jason <farion1231@gmail.com>
2025-11-12 10:47:34 +08:00
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);
// 更新本地状态(延迟保存,点击保存按钮时统一处理)
refactor(endpoints): implement deferred submission and fix clear-all bug Implement Solution A (complete deferred submission) for custom endpoint management, replacing the dual-mode system with unified local staging. Changes: - Remove immediate backend saves from EndpointSpeedTest * handleAddEndpoint: local state update only * handleRemoveEndpoint: local state update only * handleSelect: remove lastUsed timestamp update - Add explicit clear detection in ProviderForm * Distinguish "user cleared endpoints" from "user didn't modify" * Pass empty object {} as clear signal vs null for no-change - Fix mergeProviderMeta to handle three distinct cases: * null/undefined: don't modify endpoints (no meta sent) * empty object {}: explicitly clear endpoints (send empty meta) * with data: add/update endpoints (overwrite) Fixed Critical Bug: When users deleted all custom endpoints, changes were not saved because: - draftCustomEndpoints=[] resulted in customEndpointsToSave=null - mergeProviderMeta(meta, null) returned undefined - Backend interpreted missing meta as "don't modify", preserving old values Solution: Detect when user had endpoints and cleared them (hadEndpoints && length===0), then pass empty object to mergeProviderMeta as explicit clear signal. Architecture Improvements: - Transaction atomicity: all fields submitted together on form save - UX consistency: add/edit modes behave identically - Cancel button: true rollback with no immediate saves - Code simplification: removed ~40 lines of immediate save error handling Testing: - TypeScript type check: passed - Rust backend tests: 10/10 passed - Build: successful
2025-11-04 15:30:54 +08:00
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,
},
];
});
refactor(endpoints): implement deferred submission and fix clear-all bug Implement Solution A (complete deferred submission) for custom endpoint management, replacing the dual-mode system with unified local staging. Changes: - Remove immediate backend saves from EndpointSpeedTest * handleAddEndpoint: local state update only * handleRemoveEndpoint: local state update only * handleSelect: remove lastUsed timestamp update - Add explicit clear detection in ProviderForm * Distinguish "user cleared endpoints" from "user didn't modify" * Pass empty object {} as clear signal vs null for no-change - Fix mergeProviderMeta to handle three distinct cases: * null/undefined: don't modify endpoints (no meta sent) * empty object {}: explicitly clear endpoints (send empty meta) * with data: add/update endpoints (overwrite) Fixed Critical Bug: When users deleted all custom endpoints, changes were not saved because: - draftCustomEndpoints=[] resulted in customEndpointsToSave=null - mergeProviderMeta(meta, null) returned undefined - Backend interpreted missing meta as "don't modify", preserving old values Solution: Detect when user had endpoints and cleared them (hadEndpoints && length===0), then pass empty object to mergeProviderMeta as explicit clear signal. Architecture Improvements: - Transaction atomicity: all fields submitted together on form save - UX consistency: add/edit modes behave identically - Cancel button: true rollback with no immediate saves - Code simplification: removed ~40 lines of immediate save error handling Testing: - TypeScript type check: passed - Rust backend tests: 10/10 passed - Build: successful
2025-11-04 15:30:54 +08:00
if (!normalizedSelected) {
onChange(sanitized);
}
refactor(endpoints): implement deferred submission and fix clear-all bug Implement Solution A (complete deferred submission) for custom endpoint management, replacing the dual-mode system with unified local staging. Changes: - Remove immediate backend saves from EndpointSpeedTest * handleAddEndpoint: local state update only * handleRemoveEndpoint: local state update only * handleSelect: remove lastUsed timestamp update - Add explicit clear detection in ProviderForm * Distinguish "user cleared endpoints" from "user didn't modify" * Pass empty object {} as clear signal vs null for no-change - Fix mergeProviderMeta to handle three distinct cases: * null/undefined: don't modify endpoints (no meta sent) * empty object {}: explicitly clear endpoints (send empty meta) * with data: add/update endpoints (overwrite) Fixed Critical Bug: When users deleted all custom endpoints, changes were not saved because: - draftCustomEndpoints=[] resulted in customEndpointsToSave=null - mergeProviderMeta(meta, null) returned undefined - Backend interpreted missing meta as "don't modify", preserving old values Solution: Detect when user had endpoints and cleared them (hadEndpoints && length===0), then pass empty object to mergeProviderMeta as explicit clear signal. Architecture Improvements: - Transaction atomicity: all fields submitted together on form save - UX consistency: add/edit modes behave identically - Cancel button: true rollback with no immediate saves - Code simplification: removed ~40 lines of immediate save error handling Testing: - TypeScript type check: passed - Rust backend tests: 10/10 passed - Build: successful
2025-11-04 15:30:54 +08:00
setCustomUrl("");
}, [customUrl, entries, normalizedSelected, onChange, t]);
const handleRemoveEndpoint = useCallback(
refactor(endpoints): implement deferred submission and fix clear-all bug Implement Solution A (complete deferred submission) for custom endpoint management, replacing the dual-mode system with unified local staging. Changes: - Remove immediate backend saves from EndpointSpeedTest * handleAddEndpoint: local state update only * handleRemoveEndpoint: local state update only * handleSelect: remove lastUsed timestamp update - Add explicit clear detection in ProviderForm * Distinguish "user cleared endpoints" from "user didn't modify" * Pass empty object {} as clear signal vs null for no-change - Fix mergeProviderMeta to handle three distinct cases: * null/undefined: don't modify endpoints (no meta sent) * empty object {}: explicitly clear endpoints (send empty meta) * with data: add/update endpoints (overwrite) Fixed Critical Bug: When users deleted all custom endpoints, changes were not saved because: - draftCustomEndpoints=[] resulted in customEndpointsToSave=null - mergeProviderMeta(meta, null) returned undefined - Backend interpreted missing meta as "don't modify", preserving old values Solution: Detect when user had endpoints and cleared them (hadEndpoints && length===0), then pass empty object to mergeProviderMeta as explicit clear signal. Architecture Improvements: - Transaction atomicity: all fields submitted together on form save - UX consistency: add/edit modes behave identically - Cancel button: true rollback with no immediate saves - Code simplification: removed ~40 lines of immediate save error handling Testing: - TypeScript type check: passed - Rust backend tests: 10/10 passed - Build: successful
2025-11-04 15:30:54 +08:00
(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;
});
},
refactor(endpoints): implement deferred submission and fix clear-all bug Implement Solution A (complete deferred submission) for custom endpoint management, replacing the dual-mode system with unified local staging. Changes: - Remove immediate backend saves from EndpointSpeedTest * handleAddEndpoint: local state update only * handleRemoveEndpoint: local state update only * handleSelect: remove lastUsed timestamp update - Add explicit clear detection in ProviderForm * Distinguish "user cleared endpoints" from "user didn't modify" * Pass empty object {} as clear signal vs null for no-change - Fix mergeProviderMeta to handle three distinct cases: * null/undefined: don't modify endpoints (no meta sent) * empty object {}: explicitly clear endpoints (send empty meta) * with data: add/update endpoints (overwrite) Fixed Critical Bug: When users deleted all custom endpoints, changes were not saved because: - draftCustomEndpoints=[] resulted in customEndpointsToSave=null - mergeProviderMeta(meta, null) returned undefined - Backend interpreted missing meta as "don't modify", preserving old values Solution: Detect when user had endpoints and cleared them (hadEndpoints && length===0), then pass empty object to mergeProviderMeta as explicit clear signal. Architecture Improvements: - Transaction atomicity: all fields submitted together on form save - UX consistency: add/edit modes behave identically - Cancel button: true rollback with no immediate saves - Code simplification: removed ~40 lines of immediate save error handling Testing: - TypeScript type check: passed - Rust backend tests: 10/10 passed - Build: successful
2025-11-04 15:30:54 +08:00
[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(
refactor(endpoints): implement deferred submission and fix clear-all bug Implement Solution A (complete deferred submission) for custom endpoint management, replacing the dual-mode system with unified local staging. Changes: - Remove immediate backend saves from EndpointSpeedTest * handleAddEndpoint: local state update only * handleRemoveEndpoint: local state update only * handleSelect: remove lastUsed timestamp update - Add explicit clear detection in ProviderForm * Distinguish "user cleared endpoints" from "user didn't modify" * Pass empty object {} as clear signal vs null for no-change - Fix mergeProviderMeta to handle three distinct cases: * null/undefined: don't modify endpoints (no meta sent) * empty object {}: explicitly clear endpoints (send empty meta) * with data: add/update endpoints (overwrite) Fixed Critical Bug: When users deleted all custom endpoints, changes were not saved because: - draftCustomEndpoints=[] resulted in customEndpointsToSave=null - mergeProviderMeta(meta, null) returned undefined - Backend interpreted missing meta as "don't modify", preserving old values Solution: Detect when user had endpoints and cleared them (hadEndpoints && length===0), then pass empty object to mergeProviderMeta as explicit clear signal. Architecture Improvements: - Transaction atomicity: all fields submitted together on form save - UX consistency: add/edit modes behave identically - Cancel button: true rollback with no immediate saves - Code simplification: removed ~40 lines of immediate save error handling Testing: - TypeScript type check: passed - Rust backend tests: 10/10 passed - Build: successful
2025-11-04 15:30:54 +08:00
(url: string) => {
if (!url || url === normalizedSelected) return;
onChange(url);
},
refactor(endpoints): implement deferred submission and fix clear-all bug Implement Solution A (complete deferred submission) for custom endpoint management, replacing the dual-mode system with unified local staging. Changes: - Remove immediate backend saves from EndpointSpeedTest * handleAddEndpoint: local state update only * handleRemoveEndpoint: local state update only * handleSelect: remove lastUsed timestamp update - Add explicit clear detection in ProviderForm * Distinguish "user cleared endpoints" from "user didn't modify" * Pass empty object {} as clear signal vs null for no-change - Fix mergeProviderMeta to handle three distinct cases: * null/undefined: don't modify endpoints (no meta sent) * empty object {}: explicitly clear endpoints (send empty meta) * with data: add/update endpoints (overwrite) Fixed Critical Bug: When users deleted all custom endpoints, changes were not saved because: - draftCustomEndpoints=[] resulted in customEndpointsToSave=null - mergeProviderMeta(meta, null) returned undefined - Backend interpreted missing meta as "don't modify", preserving old values Solution: Detect when user had endpoints and cleared them (hadEndpoints && length===0), then pass empty object to mergeProviderMeta as explicit clear signal. Architecture Improvements: - Transaction atomicity: all fields submitted together on form save - UX consistency: add/edit modes behave identically - Cancel button: true rollback with no immediate saves - Code simplification: removed ~40 lines of immediate save error handling Testing: - TypeScript type check: passed - Rust backend tests: 10/10 passed - Build: successful
2025-11-04 15:30:54 +08:00
[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]);
return (
<Dialog open={visible} onOpenChange={(open) => !open && onClose()}>
<DialogContent
zIndex="nested"
className="max-w-2xl max-h-[80vh] flex flex-col p-0"
>
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle>{t("endpointTest.title")}</DialogTitle>
</DialogHeader>
{/* Content */}
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
{/* 测速控制栏 */}
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
{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 "
/>
{t("endpointTest.autoSelect")}
</label>
<Button
type="button"
onClick={runSpeedTest}
disabled={isTesting || !hasEndpoints}
size="sm"
className="h-7 w-20 gap-1.5 text-xs"
>
{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-blue-500 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/20"
: "border-border-default bg-white hover:border-border-default hover:bg-gray-50 dark:bg-gray-900 dark:hover:border-gray-600 dark:hover:bg-gray-800"
}`}
>
<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-green-600 dark:text-green-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>
) : 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={(e) => {
e.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-gray-50 py-8 text-center text-xs text-gray-500 dark:bg-gray-900 dark:text-gray-400">
{t("endpointTest.noEndpoints")}
</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>
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
onClick={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>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default EndpointSpeedTest;