diff --git a/src/components/providers/EditProviderDialog.tsx b/src/components/providers/EditProviderDialog.tsx index a81fd25..dc18c6f 100644 --- a/src/components/providers/EditProviderDialog.tsx +++ b/src/components/providers/EditProviderDialog.tsx @@ -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} /> diff --git a/src/components/providers/forms/EndpointSpeedTest.tsx b/src/components/providers/forms/EndpointSpeedTest.tsx index affb9f0..c3329eb 100644 --- a/src/components/providers/forms/EndpointSpeedTest.tsx +++ b/src/components/providers/forms/EndpointSpeedTest.tsx @@ -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 = ({ // 加载保存的自定义端点(按正在编辑的供应商) useEffect(() => { + let cancelled = false; + const loadCustomEndpoints = async () => { try { if (!providerId) return; @@ -122,6 +118,9 @@ const EndpointSpeedTest: React.FC = ({ providerId, ); + // 检查是否已取消 + if (cancelled) return; + const candidates: EndpointCandidate[] = customEndpoints.map( (ep: CustomEndpoint) => ({ url: ep.url, @@ -155,13 +154,19 @@ const EndpointSpeedTest: React.FC = ({ return Array.from(map.values()); }); } catch (error) { - console.error(t("endpointTest.loadEndpointsFailed"), 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 = ({ } } - 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 = ({ 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); - return; + 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 = ({ 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 = ({ 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 ( diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index e620275..ac45223 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -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; + 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 }; } diff --git a/src/components/providers/forms/hooks/useProviderCategory.ts b/src/components/providers/forms/hooks/useProviderCategory.ts index d0b086e..b04958f 100644 --- a/src/components/providers/forms/hooks/useProviderCategory.ts +++ b/src/components/providers/forms/hooks/useProviderCategory.ts @@ -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( - 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 }; } diff --git a/src/components/providers/forms/hooks/useSpeedTestEndpoints.ts b/src/components/providers/forms/hooks/useSpeedTestEndpoints.ts index dbe6b61..10cd438 100644 --- a/src/components/providers/forms/hooks/useSpeedTestEndpoints.ts +++ b/src/components/providers/forms/hooks/useSpeedTestEndpoints.ts @@ -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; + 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(); + // 所有端点都标记为 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(); + // 所有端点都标记为 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)); } } } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a09fc50..96073c2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 1bea334..bf91abb 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -265,6 +265,8 @@ "loadEndpointsFailed": "加载自定义端点失败:", "addEndpointFailed": "添加自定义端点失败:", "removeEndpointFailed": "删除自定义端点失败:", + "removeFailed": "删除失败: {{error}}", + "updateLastUsedFailed": "更新端点使用时间失败", "pleaseAddEndpoint": "请先添加端点", "testUnavailable": "测速功能不可用", "noResult": "未返回结果", diff --git a/src/types.ts b/src/types.ts index 990b609..f6d020c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,13 @@ export interface CustomEndpoint { lastUsed?: number; } +// 端点候选项(用于端点测速弹窗) +export interface EndpointCandidate { + id?: string; + url: string; + isCustom?: boolean; +} + // 用量查询脚本配置 export interface UsageScript { enabled: boolean; // 是否启用用量查询