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

View File

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

View File

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

View File

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

View File

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

View File

@@ -265,6 +265,8 @@
"loadEndpointsFailed": "Failed to load custom endpoints:",
"addEndpointFailed": "Failed to add 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",
"testUnavailable": "Speed test unavailable",
"noResult": "No result returned",

View File

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

View File

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