refactor: improve endpoint management type safety and error handling

- Unify EndpointCandidate type definition in types.ts
- Remove all 'as any' type assertions in useSpeedTestEndpoints
- Add cleanup function to prevent race conditions in async operations
- Fix stale error messages persisting after successful deletion
- Improve error handling for endpoint deletion (distinguish not-found vs network errors)
- Extract timeout magic numbers to ENDPOINT_TIMEOUT_SECS constant
- Clarify URL validation to explicitly allow only http/https
- Fix ambiguous payload.meta assignment logic in ProviderForm
- Add i18n for new error messages (removeFailed, updateLastUsedFailed)
This commit is contained in:
Jason
2025-10-24 09:24:03 +08:00
parent 6cc75d5c24
commit 495e66e3b6
8 changed files with 125 additions and 61 deletions

View File

@@ -49,6 +49,8 @@ export function EditProviderDialog({
websiteUrl: values.websiteUrl?.trim() || undefined, websiteUrl: values.websiteUrl?.trim() || undefined,
settingsConfig: parsedConfig, settingsConfig: parsedConfig,
...(values.presetCategory ? { category: values.presetCategory } : {}), ...(values.presetCategory ? { category: values.presetCategory } : {}),
// 保留或更新 meta 字段
...(values.meta ? { meta: values.meta } : {}),
}; };
await onSubmit(updatedProvider); await onSubmit(updatedProvider);
@@ -83,6 +85,8 @@ export function EditProviderDialog({
name: provider.name, name: provider.name,
websiteUrl: provider.websiteUrl, websiteUrl: provider.websiteUrl,
settingsConfig: provider.settingsConfig, settingsConfig: provider.settingsConfig,
category: provider.category,
meta: provider.meta,
}} }}
showButtons={false} showButtons={false}
/> />

View File

@@ -12,13 +12,13 @@ import {
DialogTitle, DialogTitle,
DialogFooter, DialogFooter,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import type { CustomEndpoint, EndpointCandidate } from "@/types";
// 临时类型定义,待后端 API 实现后替换 // 端点测速超时配置(秒)
interface CustomEndpoint { const ENDPOINT_TIMEOUT_SECS = {
url: string; codex: 12,
addedAt: number; claude: 8,
lastUsed?: number; } as const;
}
interface TestResult { interface TestResult {
url: string; url: string;
@@ -27,12 +27,6 @@ interface TestResult {
error?: string | null; error?: string | null;
} }
export interface EndpointCandidate {
id?: string;
url: string;
isCustom?: boolean;
}
interface EndpointSpeedTestProps { interface EndpointSpeedTestProps {
appType: AppType; appType: AppType;
providerId?: string; providerId?: string;
@@ -113,6 +107,8 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
// 加载保存的自定义端点(按正在编辑的供应商) // 加载保存的自定义端点(按正在编辑的供应商)
useEffect(() => { useEffect(() => {
let cancelled = false;
const loadCustomEndpoints = async () => { const loadCustomEndpoints = async () => {
try { try {
if (!providerId) return; if (!providerId) return;
@@ -122,6 +118,9 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
providerId, providerId,
); );
// 检查是否已取消
if (cancelled) return;
const candidates: EndpointCandidate[] = customEndpoints.map( const candidates: EndpointCandidate[] = customEndpoints.map(
(ep: CustomEndpoint) => ({ (ep: CustomEndpoint) => ({
url: ep.url, url: ep.url,
@@ -155,13 +154,19 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
return Array.from(map.values()); return Array.from(map.values());
}); });
} catch (error) { } catch (error) {
console.error(t("endpointTest.loadEndpointsFailed"), error); if (!cancelled) {
console.error(t("endpointTest.loadEndpointsFailed"), error);
}
} }
}; };
if (visible) { if (visible) {
loadCustomEndpoints(); loadCustomEndpoints();
} }
return () => {
cancelled = true;
};
}, [appType, visible, providerId, t]); }, [appType, visible, providerId, t]);
useEffect(() => { useEffect(() => {
@@ -253,7 +258,9 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
} }
} }
if (!errorMsg && parsed && !parsed.protocol.startsWith("http")) { // 明确只允许 http: 和 https:
const allowedProtocols = ['http:', 'https:'];
if (!errorMsg && parsed && !allowedProtocols.includes(parsed.protocol)) {
errorMsg = t("endpointTest.onlyHttps"); errorMsg = t("endpointTest.onlyHttps");
} }
@@ -318,17 +325,29 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
const handleRemoveEndpoint = useCallback( const handleRemoveEndpoint = useCallback(
async (entry: EndpointEntry) => { async (entry: EndpointEntry) => {
// 如果是自定义端点,尝试从后端删除(无 providerId 则仅本地删除) // 清空之前的错误提示
setLastError(null);
// 如果有 providerId尝试从后端删除
if (entry.isCustom && providerId) { if (entry.isCustom && providerId) {
try { try {
await vscodeApi.removeCustomEndpoint(appType, providerId, entry.url); await vscodeApi.removeCustomEndpoint(appType, providerId, entry.url);
} catch (error) { } catch (error) {
console.error(t("endpointTest.removeEndpointFailed"), error); const errorMsg = error instanceof Error ? error.message : String(error);
return;
// 只有"端点不存在"时才允许删除本地条目
if (errorMsg.includes('not found') || errorMsg.includes('does not exist') || errorMsg.includes('不存在')) {
console.warn(t('endpointTest.removeEndpointFailed'), errorMsg);
// 继续删除本地条目
} else {
// 其他错误:显示错误提示,阻止删除
setLastError(t('endpointTest.removeFailed', { error: errorMsg }));
return;
}
} }
} }
// 更新本地状态 // 更新本地状态(删除成功)
setEntries((prev) => { setEntries((prev) => {
const next = prev.filter((item) => item.id !== entry.id); const next = prev.filter((item) => item.id !== entry.id);
if (entry.url === normalizedSelected) { if (entry.url === normalizedSelected) {
@@ -363,7 +382,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
try { try {
const results = await vscodeApi.testApiEndpoints(urls, { const results = await vscodeApi.testApiEndpoints(urls, {
timeoutSecs: appType === "codex" ? 12 : 8, timeoutSecs: ENDPOINT_TIMEOUT_SECS[appType],
}); });
const resultMap = new Map( const resultMap = new Map(
@@ -425,13 +444,13 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
try { try {
await vscodeApi.updateEndpointLastUsed(appType, providerId, url); await vscodeApi.updateEndpointLastUsed(appType, providerId, url);
} catch (error) { } catch (error) {
console.error("Failed to update endpoint last used time:", error); console.error(t("endpointTest.updateLastUsedFailed"), error);
} }
} }
onChange(url); onChange(url);
}, },
[normalizedSelected, onChange, appType, entries, providerId], [normalizedSelected, onChange, appType, entries, providerId, t],
); );
return ( return (

View File

@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form"; import { Form } from "@/components/ui/form";
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider"; import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
import type { AppType } from "@/lib/api"; import type { AppType } from "@/lib/api";
import type { ProviderCategory, CustomEndpoint } from "@/types"; import type { ProviderCategory, CustomEndpoint, ProviderMeta } from "@/types";
import { providerPresets, type ProviderPreset } from "@/config/providerPresets"; import { providerPresets, type ProviderPreset } from "@/config/providerPresets";
import { import {
codexProviderPresets, codexProviderPresets,
@@ -52,6 +52,8 @@ interface ProviderFormProps {
name?: string; name?: string;
websiteUrl?: string; websiteUrl?: string;
settingsConfig?: Record<string, unknown>; settingsConfig?: Record<string, unknown>;
category?: ProviderCategory;
meta?: ProviderMeta;
}; };
showButtons?: boolean; showButtons?: boolean;
} }
@@ -86,6 +88,7 @@ export function ProviderForm({
appType, appType,
selectedPresetId, selectedPresetId,
isEditMode, isEditMode,
initialCategory: initialData?.category,
}); });
useEffect(() => { useEffect(() => {
@@ -320,8 +323,12 @@ export function ProviderForm({
} }
} }
// 新建供应商时:添加自定义端点 // 处理 meta 字段(新建与编辑使用不同策略)
if (!initialData && customEndpointsMap) { if (initialData?.meta) {
// 编辑模式:后端已通过 API 更新 meta直接使用原有值
payload.meta = initialData.meta;
} else if (customEndpointsMap) {
// 新建模式:从表单收集的自定义端点打包到 meta
payload.meta = { custom_endpoints: customEndpointsMap }; payload.meta = { custom_endpoints: customEndpointsMap };
} }

View File

@@ -8,6 +8,7 @@ interface UseProviderCategoryProps {
appType: AppType; appType: AppType;
selectedPresetId: string | null; selectedPresetId: string | null;
isEditMode: boolean; isEditMode: boolean;
initialCategory?: ProviderCategory;
} }
/** /**
@@ -18,14 +19,19 @@ export function useProviderCategory({
appType, appType,
selectedPresetId, selectedPresetId,
isEditMode, isEditMode,
initialCategory,
}: UseProviderCategoryProps) { }: UseProviderCategoryProps) {
const [category, setCategory] = useState<ProviderCategory | undefined>( const [category, setCategory] = useState<ProviderCategory | undefined>(
undefined, // 编辑模式:使用 initialCategory
isEditMode ? initialCategory : undefined,
); );
useEffect(() => { useEffect(() => {
// 编辑模式不自动设置类别 // 编辑模式:只在初始化时设置,后续不自动更新
if (isEditMode) return; if (isEditMode) {
setCategory(initialCategory);
return;
}
if (selectedPresetId === "custom") { if (selectedPresetId === "custom") {
setCategory("custom"); setCategory("custom");
@@ -56,7 +62,7 @@ export function useProviderCategory({
); );
} }
} }
}, [appType, selectedPresetId, isEditMode]); }, [appType, selectedPresetId, isEditMode, initialCategory]);
return { category, setCategory }; return { category, setCategory };
} }

View File

@@ -2,6 +2,7 @@ import { useMemo } from "react";
import type { AppType } from "@/lib/api"; import type { AppType } from "@/lib/api";
import type { ProviderPreset } from "@/config/providerPresets"; import type { ProviderPreset } from "@/config/providerPresets";
import type { CodexProviderPreset } from "@/config/codexProviderPresets"; import type { CodexProviderPreset } from "@/config/codexProviderPresets";
import type { ProviderMeta, EndpointCandidate } from "@/types";
type PresetEntry = { type PresetEntry = {
id: string; id: string;
@@ -16,20 +17,18 @@ interface UseSpeedTestEndpointsProps {
codexBaseUrl: string; codexBaseUrl: string;
initialData?: { initialData?: {
settingsConfig?: Record<string, unknown>; settingsConfig?: Record<string, unknown>;
meta?: ProviderMeta;
}; };
} }
export interface EndpointCandidate {
url: string;
}
/** /**
* 收集端点测速弹窗的初始端点列表 * 收集端点测速弹窗的初始端点列表
* *
* 收集来源: * 收集来源:
* 1. 当前选中的 Base URL * 1. 编辑模式:从 meta.custom_endpoints 读取已保存的端点(优先)
* 2. 编辑模式下的初始数据 URL * 2. 当前选中的 Base URL
* 3. 预设中的 endpointCandidates * 3. 编辑模式下的初始数据 URL
* 4. 预设中的 endpointCandidates
*/ */
export function useSpeedTestEndpoints({ export function useSpeedTestEndpoints({
appType, appType,
@@ -43,43 +42,53 @@ export function useSpeedTestEndpoints({
if (appType !== "claude") return []; if (appType !== "claude") return [];
const map = new Map<string, EndpointCandidate>(); const map = new Map<string, EndpointCandidate>();
// 所有端点都标记为 isCustom: true给用户完全的管理自由
const add = (url?: string) => { const add = (url?: string) => {
if (!url) return; if (!url) return;
const sanitized = url.trim().replace(/\/+$/, ""); const sanitized = url.trim().replace(/\/+$/, "");
if (!sanitized || map.has(sanitized)) return; if (!sanitized || map.has(sanitized)) return;
map.set(sanitized, { url: sanitized }); map.set(sanitized, { url: sanitized, isCustom: true });
}; };
// 1. 当前 Base URL // 1. 编辑模式:从 meta.custom_endpoints 读取已保存的端点(优先)
if (initialData?.meta?.custom_endpoints) {
const customEndpoints = initialData.meta.custom_endpoints;
for (const url of Object.keys(customEndpoints)) {
add(url);
}
}
// 2. 当前 Base URL
if (baseUrl) { if (baseUrl) {
add(baseUrl); add(baseUrl);
} }
// 2. 编辑模式:初始数据中的 URL // 3. 编辑模式:初始数据中的 URL
if (initialData && typeof initialData.settingsConfig === "object") { if (initialData && typeof initialData.settingsConfig === "object") {
const envUrl = (initialData.settingsConfig as any)?.env const configEnv = initialData.settingsConfig as {
?.ANTHROPIC_BASE_URL; env?: { ANTHROPIC_BASE_URL?: string };
};
const envUrl = configEnv.env?.ANTHROPIC_BASE_URL;
if (typeof envUrl === "string") { if (typeof envUrl === "string") {
add(envUrl); add(envUrl);
} }
} }
// 3. 预设中的 endpointCandidates // 4. 预设中的 endpointCandidates(也允许用户删除)
if (selectedPresetId && selectedPresetId !== "custom") { if (selectedPresetId && selectedPresetId !== "custom") {
const entry = presetEntries.find((item) => item.id === selectedPresetId); const entry = presetEntries.find((item) => item.id === selectedPresetId);
if (entry) { if (entry) {
const preset = entry.preset as ProviderPreset; const preset = entry.preset as ProviderPreset;
// 添加预设自己的 baseUrl // 添加预设自己的 baseUrl
const presetEnv = (preset.settingsConfig as any)?.env const presetEnv = preset.settingsConfig as {
?.ANTHROPIC_BASE_URL; env?: { ANTHROPIC_BASE_URL?: string };
if (typeof presetEnv === "string") { };
add(presetEnv); if (presetEnv.env?.ANTHROPIC_BASE_URL) {
add(presetEnv.env.ANTHROPIC_BASE_URL);
} }
// 添加预设的候选端点 // 添加预设的候选端点
if (Array.isArray((preset as any).endpointCandidates)) { if (preset.endpointCandidates) {
for (const u of (preset as any).endpointCandidates as string[]) { preset.endpointCandidates.forEach((url) => add(url));
add(u);
}
} }
} }
} }
@@ -91,30 +100,40 @@ export function useSpeedTestEndpoints({
if (appType !== "codex") return []; if (appType !== "codex") return [];
const map = new Map<string, EndpointCandidate>(); const map = new Map<string, EndpointCandidate>();
// 所有端点都标记为 isCustom: true给用户完全的管理自由
const add = (url?: string) => { const add = (url?: string) => {
if (!url) return; if (!url) return;
const sanitized = url.trim().replace(/\/+$/, ""); const sanitized = url.trim().replace(/\/+$/, "");
if (!sanitized || map.has(sanitized)) return; if (!sanitized || map.has(sanitized)) return;
map.set(sanitized, { url: sanitized }); map.set(sanitized, { url: sanitized, isCustom: true });
}; };
// 1. 当前 Codex Base URL // 1. 编辑模式:从 meta.custom_endpoints 读取已保存的端点(优先)
if (initialData?.meta?.custom_endpoints) {
const customEndpoints = initialData.meta.custom_endpoints;
for (const url of Object.keys(customEndpoints)) {
add(url);
}
}
// 2. 当前 Codex Base URL
if (codexBaseUrl) { if (codexBaseUrl) {
add(codexBaseUrl); add(codexBaseUrl);
} }
// 2. 编辑模式:初始数据中的 URL // 3. 编辑模式:初始数据中的 URL
const initialCodexConfig = const initialCodexConfig =
initialData && typeof initialData.settingsConfig?.config === "string" initialData?.settingsConfig as {
? (initialData.settingsConfig as any).config config?: string;
: ""; } | undefined;
const configStr = initialCodexConfig?.config ?? "";
// 从 TOML 中提取 base_url // 从 TOML 中提取 base_url
const match = /base_url\s*=\s*["']([^"']+)["']/i.exec(initialCodexConfig); const match = /base_url\s*=\s*["']([^"']+)["']/i.exec(configStr);
if (match?.[1]) { if (match?.[1]) {
add(match[1]); add(match[1]);
} }
// 3. 预设中的 endpointCandidates // 4. 预设中的 endpointCandidates(也允许用户删除)
if (selectedPresetId && selectedPresetId !== "custom") { if (selectedPresetId && selectedPresetId !== "custom") {
const entry = presetEntries.find((item) => item.id === selectedPresetId); const entry = presetEntries.find((item) => item.id === selectedPresetId);
if (entry) { if (entry) {
@@ -128,10 +147,8 @@ export function useSpeedTestEndpoints({
add(presetMatch[1]); add(presetMatch[1]);
} }
// 添加预设的候选端点 // 添加预设的候选端点
if (Array.isArray((preset as any).endpointCandidates)) { if (preset.endpointCandidates) {
for (const u of (preset as any).endpointCandidates as string[]) { preset.endpointCandidates.forEach((url) => add(url));
add(u);
}
} }
} }
} }

View File

@@ -265,6 +265,8 @@
"loadEndpointsFailed": "Failed to load custom endpoints:", "loadEndpointsFailed": "Failed to load custom endpoints:",
"addEndpointFailed": "Failed to add custom endpoint:", "addEndpointFailed": "Failed to add custom endpoint:",
"removeEndpointFailed": "Failed to remove custom endpoint:", "removeEndpointFailed": "Failed to remove custom endpoint:",
"removeFailed": "Remove failed: {{error}}",
"updateLastUsedFailed": "Failed to update endpoint last used time",
"pleaseAddEndpoint": "Please add an endpoint first", "pleaseAddEndpoint": "Please add an endpoint first",
"testUnavailable": "Speed test unavailable", "testUnavailable": "Speed test unavailable",
"noResult": "No result returned", "noResult": "No result returned",

View File

@@ -265,6 +265,8 @@
"loadEndpointsFailed": "加载自定义端点失败:", "loadEndpointsFailed": "加载自定义端点失败:",
"addEndpointFailed": "添加自定义端点失败:", "addEndpointFailed": "添加自定义端点失败:",
"removeEndpointFailed": "删除自定义端点失败:", "removeEndpointFailed": "删除自定义端点失败:",
"removeFailed": "删除失败: {{error}}",
"updateLastUsedFailed": "更新端点使用时间失败",
"pleaseAddEndpoint": "请先添加端点", "pleaseAddEndpoint": "请先添加端点",
"testUnavailable": "测速功能不可用", "testUnavailable": "测速功能不可用",
"noResult": "未返回结果", "noResult": "未返回结果",

View File

@@ -30,6 +30,13 @@ export interface CustomEndpoint {
lastUsed?: number; lastUsed?: number;
} }
// 端点候选项(用于端点测速弹窗)
export interface EndpointCandidate {
id?: string;
url: string;
isCustom?: boolean;
}
// 用量查询脚本配置 // 用量查询脚本配置
export interface UsageScript { export interface UsageScript {
enabled: boolean; // 是否启用用量查询 enabled: boolean; // 是否启用用量查询