diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index a811d7a..d0df92b 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -34,7 +34,7 @@ interface ClaudeFormFieldsProps { onBaseUrlChange: (url: string) => void; isEndpointModalOpen: boolean; onEndpointModalToggle: (open: boolean) => void; - onCustomEndpointsChange: (endpoints: string[]) => void; + onCustomEndpointsChange?: (endpoints: string[]) => void; // Model Selector shouldShowModelSelector: boolean; diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index 686610d..400642a 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -24,7 +24,7 @@ interface CodexFormFieldsProps { onBaseUrlChange: (url: string) => void; isEndpointModalOpen: boolean; onEndpointModalToggle: (open: boolean) => void; - onCustomEndpointsChange: (endpoints: string[]) => void; + onCustomEndpointsChange?: (endpoints: string[]) => void; // Speed Test Endpoints speedTestEndpoints: EndpointCandidate[]; diff --git a/src/components/providers/forms/EndpointSpeedTest.tsx b/src/components/providers/forms/EndpointSpeedTest.tsx index 1ceb46e..fb061fe 100644 --- a/src/components/providers/forms/EndpointSpeedTest.tsx +++ b/src/components/providers/forms/EndpointSpeedTest.tsx @@ -36,7 +36,8 @@ interface EndpointSpeedTestProps { initialEndpoints: EndpointCandidate[]; visible?: boolean; onClose: () => void; - // 当自定义端点列表变化时回传(仅包含 isCustom 的条目) + // 新建模式:当自定义端点列表变化时回传(仅包含 isCustom 的条目) + // 编辑模式:不使用此回调,端点直接保存到后端 onCustomEndpointsChange?: (urls: string[]) => void; } @@ -101,25 +102,31 @@ const EndpointSpeedTest: React.FC = ({ const [autoSelect, setAutoSelect] = useState(true); const [isTesting, setIsTesting] = useState(false); const [lastError, setLastError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + // 记录初始的自定义端点,用于对比变化 + const [initialCustomUrls, setInitialCustomUrls] = useState>( + 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; + if (!providerId) return; // 新建模式不加载 const customEndpoints = await vscodeApi.getCustomEndpoints( appId, providerId, ); - // 检查是否已取消 if (cancelled) return; const candidates: EndpointCandidate[] = customEndpoints.map( @@ -129,6 +136,13 @@ const EndpointSpeedTest: React.FC = ({ }), ); + // 记录初始的自定义端点 + const customUrls = new Set( + customEndpoints.map((ep) => normalizeEndpointUrl(ep.url)), + ); + setInitialCustomUrls(customUrls); + + // 合并自定义端点与初始端点 setEntries((prev) => { const map = new Map(); @@ -137,7 +151,7 @@ const EndpointSpeedTest: React.FC = ({ map.set(entry.url, entry); }); - // 合并自定义端点 + // 添加从后端加载的自定义端点 candidates.forEach((candidate) => { const sanitized = normalizeEndpointUrl(candidate.url); if (sanitized && !map.has(sanitized)) { @@ -161,60 +175,20 @@ const EndpointSpeedTest: React.FC = ({ } }; - if (visible) { + // 只在编辑模式下加载 + if (providerId) { loadCustomEndpoints(); } return () => { cancelled = true; }; - }, [appId, visible, providerId, t]); + }, [appId, providerId, t, initialEndpoints]); + // 新建模式:将自定义端点变化透传给父组件(仅限 isCustom) + // 编辑模式:不使用此回调,端点已通过 API 直接保存 useEffect(() => { - setEntries((prev) => { - const map = new Map(); - prev.forEach((entry) => { - map.set(entry.url, entry); - }); - - let changed = false; - - const mergeCandidate = (candidate: EndpointCandidate) => { - const sanitized = candidate.url - ? normalizeEndpointUrl(candidate.url) - : ""; - if (!sanitized) return; - const existing = map.get(sanitized); - if (existing) return; - - map.set(sanitized, { - id: candidate.id ?? randomId(), - url: sanitized, - isCustom: candidate.isCustom ?? false, - latency: null, - status: undefined, - error: null, - }); - changed = true; - }; - - initialEndpoints.forEach(mergeCandidate); - - if (normalizedSelected && !map.has(normalizedSelected)) { - mergeCandidate({ url: normalizedSelected, isCustom: true }); - } - - if (!changed) { - return prev; - } - - return Array.from(map.values()); - }); - }, [initialEndpoints, normalizedSelected]); - - // 将自定义端点变化透传给父组件(仅限 isCustom) - useEffect(() => { - if (!onCustomEndpointsChange) return; + if (!onCustomEndpointsChange || isEditMode) return; // 编辑模式不使用回调 try { const customUrls = Array.from( new Set( @@ -228,8 +202,7 @@ const EndpointSpeedTest: React.FC = ({ } catch (err) { // ignore } - // 仅在 entries 变化时同步 - }, [entries, onCustomEndpointsChange]); + }, [entries, onCustomEndpointsChange, isEditMode]); const sortedEntries = useMemo(() => { return entries.slice().sort((a: TestResult, b: TestResult) => { @@ -268,7 +241,7 @@ const EndpointSpeedTest: React.FC = ({ let sanitized = ""; if (!errorMsg && parsed) { sanitized = normalizeEndpointUrl(parsed.toString()); - // 使用当前 entries 做去重校验,避免依赖可能过期的 addError + // 使用当前 entries 做去重校验 const isDuplicate = entries.some((entry) => entry.url === sanitized); if (isDuplicate) { errorMsg = t("endpointTest.urlExists"); @@ -281,8 +254,9 @@ const EndpointSpeedTest: React.FC = ({ } setAddError(null); + setLastError(null); - // 更新本地状态(延迟提交,不立即保存到后端) + // 更新本地状态(延迟保存,点击保存按钮时统一处理) setEntries((prev) => { if (prev.some((e) => e.url === sanitized)) return prev; return [ @@ -303,14 +277,14 @@ const EndpointSpeedTest: React.FC = ({ } setCustomUrl(""); - }, [customUrl, entries, normalizedSelected, onChange]); + }, [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) { @@ -405,6 +379,58 @@ const EndpointSpeedTest: React.FC = ({ [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 ( !open && onClose()}> = ({ )} - - + diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 1f96cf8..b68bc53 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -100,16 +100,16 @@ export function ProviderForm({ partnerPromotionKey?: string; } | null>(null); const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false); + const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] = + useState(false); // 新建供应商:收集端点测速弹窗中的"自定义端点",提交时一次性落盘到 meta.custom_endpoints - // 编辑供应商:从 initialData.meta.custom_endpoints 恢复端点列表 + // 编辑供应商:端点已通过 API 直接保存,不再需要此状态 const [draftCustomEndpoints, setDraftCustomEndpoints] = useState( () => { - if (!initialData?.meta?.custom_endpoints) { - return []; - } - // 从 Record 中提取 URL 列表 - return Object.keys(initialData.meta.custom_endpoints); + // 仅在新建模式下使用 + if (initialData) return []; + return []; }, ); @@ -125,10 +125,8 @@ export function ProviderForm({ setSelectedPresetId(initialData ? null : "custom"); setActivePreset(null); - // 重新初始化 draftCustomEndpoints(编辑模式时从 meta 恢复) - if (initialData?.meta?.custom_endpoints) { - setDraftCustomEndpoints(Object.keys(initialData.meta.custom_endpoints)); - } else { + // 编辑模式不需要恢复 draftCustomEndpoints,端点已通过 API 管理 + if (!initialData) { setDraftCustomEndpoints([]); } }, [appId, initialData]); @@ -220,8 +218,6 @@ export function ProviderForm({ [originalHandleCodexConfigChange, debouncedValidate], ); - const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] = - useState(false); const [isCodexTemplateModalOpen, setIsCodexTemplateModalOpen] = useState(false); @@ -361,60 +357,51 @@ export function ProviderForm({ } } - // 处理 meta 字段:基于 draftCustomEndpoints 生成 custom_endpoints - // 注意:不使用 customEndpointsMap,因为它包含了候选端点(预设、Base URL 等) - // 而我们只需要保存用户真正添加的自定义端点 - const customEndpointsToSave: Record< - string, - import("@/types").CustomEndpoint - > | null = - draftCustomEndpoints.length > 0 - ? draftCustomEndpoints.reduce( - (acc, url) => { - // 尝试从 initialData.meta 中获取原有的端点元数据(保留 addedAt 和 lastUsed) - const existing = initialData?.meta?.custom_endpoints?.[url]; - if (existing) { - acc[url] = existing; - } else { - // 新端点:使用当前时间戳 - const now = Date.now(); - acc[url] = { url, addedAt: now, lastUsed: undefined }; - } - return acc; - }, - {} as Record, - ) - : null; + // 处理 meta 字段:仅在新建模式下从 draftCustomEndpoints 生成 custom_endpoints + // 编辑模式:端点已通过 API 直接保存,不在此处理 + if (!isEditMode && draftCustomEndpoints.length > 0) { + const customEndpointsToSave: Record< + string, + import("@/types").CustomEndpoint + > = draftCustomEndpoints.reduce( + (acc, url) => { + const now = Date.now(); + acc[url] = { url, addedAt: now, lastUsed: undefined }; + return acc; + }, + {} as Record, + ); - // 检测是否需要清空端点(重要:区分"用户清空端点"和"用户没有修改端点") - const hadEndpoints = - initialData?.meta?.custom_endpoints && - Object.keys(initialData.meta.custom_endpoints).length > 0; - const needsClearEndpoints = - hadEndpoints && draftCustomEndpoints.length === 0; + // 检测是否需要清空端点(重要:区分"用户清空端点"和"用户没有修改端点") + const hadEndpoints = + initialData?.meta?.custom_endpoints && + Object.keys(initialData.meta.custom_endpoints).length > 0; + const needsClearEndpoints = + hadEndpoints && draftCustomEndpoints.length === 0; - // 如果用户明确清空了端点,传递空对象(而不是 null)让后端知道要删除 - let mergedMeta = needsClearEndpoints - ? mergeProviderMeta(initialData?.meta, {}) - : mergeProviderMeta(initialData?.meta, customEndpointsToSave); + // 如果用户明确清空了端点,传递空对象(而不是 null)让后端知道要删除 + let mergedMeta = needsClearEndpoints + ? mergeProviderMeta(initialData?.meta, {}) + : mergeProviderMeta(initialData?.meta, customEndpointsToSave); - // 添加合作伙伴标识与促销 key - if (activePreset?.isPartner) { - mergedMeta = { - ...(mergedMeta ?? {}), - isPartner: true, - }; - } + // 添加合作伙伴标识与促销 key + if (activePreset?.isPartner) { + mergedMeta = { + ...(mergedMeta ?? {}), + isPartner: true, + }; + } - if (activePreset?.partnerPromotionKey) { - mergedMeta = { - ...(mergedMeta ?? {}), - partnerPromotionKey: activePreset.partnerPromotionKey, - }; - } + if (activePreset?.partnerPromotionKey) { + mergedMeta = { + ...(mergedMeta ?? {}), + partnerPromotionKey: activePreset.partnerPromotionKey, + }; + } - if (mergedMeta !== undefined) { - payload.meta = mergedMeta; + if (mergedMeta !== undefined) { + payload.meta = mergedMeta; + } } onSubmit(payload); @@ -609,7 +596,9 @@ export function ProviderForm({ onBaseUrlChange={handleClaudeBaseUrlChange} isEndpointModalOpen={isEndpointModalOpen} onEndpointModalToggle={setIsEndpointModalOpen} - onCustomEndpointsChange={setDraftCustomEndpoints} + onCustomEndpointsChange={ + isEditMode ? undefined : setDraftCustomEndpoints + } shouldShowModelSelector={category !== "official"} claudeModel={claudeModel} defaultHaikuModel={defaultHaikuModel} @@ -636,7 +625,9 @@ export function ProviderForm({ onBaseUrlChange={handleCodexBaseUrlChange} isEndpointModalOpen={isCodexEndpointModalOpen} onEndpointModalToggle={setIsCodexEndpointModalOpen} - onCustomEndpointsChange={setDraftCustomEndpoints} + onCustomEndpointsChange={ + isEditMode ? undefined : setDraftCustomEndpoints + } speedTestEndpoints={speedTestEndpoints} /> )} diff --git a/src/components/providers/forms/hooks/useSpeedTestEndpoints.ts b/src/components/providers/forms/hooks/useSpeedTestEndpoints.ts index 99fc28a..ab9f54b 100644 --- a/src/components/providers/forms/hooks/useSpeedTestEndpoints.ts +++ b/src/components/providers/forms/hooks/useSpeedTestEndpoints.ts @@ -25,10 +25,12 @@ interface UseSpeedTestEndpointsProps { * 收集端点测速弹窗的初始端点列表 * * 收集来源: - * 1. 编辑模式:从 meta.custom_endpoints 读取已保存的端点(优先) - * 2. 当前选中的 Base URL - * 3. 编辑模式下的初始数据 URL - * 4. 预设中的 endpointCandidates + * 1. 当前选中的 Base URL + * 2. 编辑模式下的初始数据 URL + * 3. 预设中的 endpointCandidates + * + * 注意:已保存的自定义端点通过 getCustomEndpoints API 在 EndpointSpeedTest 组件中加载, + * 不在此处读取,避免重复导入。 */ export function useSpeedTestEndpoints({ appId, @@ -43,28 +45,21 @@ export function useSpeedTestEndpoints({ if (appId !== "claude" && appId !== "gemini") return []; const map = new Map(); - // 所有端点都标记为 isCustom: true,给用户完全的管理自由 - const add = (url?: string) => { + // 候选端点标记为 isCustom: false,表示来自预设或配置 + // 已保存的自定义端点会在 EndpointSpeedTest 组件中通过 API 加载 + const add = (url?: string, isCustom = false) => { if (!url) return; const sanitized = url.trim().replace(/\/+$/, ""); if (!sanitized || map.has(sanitized)) return; - map.set(sanitized, { url: sanitized, isCustom: true }); + map.set(sanitized, { url: sanitized, isCustom }); }; - // 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 + // 1. 当前 Base URL if (baseUrl) { add(baseUrl); } - // 3. 编辑模式:初始数据中的 URL + // 2. 编辑模式:初始数据中的 URL if (initialData && typeof initialData.settingsConfig === "object") { const configEnv = initialData.settingsConfig as { env?: { ANTHROPIC_BASE_URL?: string; GOOGLE_GEMINI_BASE_URL?: string }; @@ -78,7 +73,7 @@ export function useSpeedTestEndpoints({ }); } - // 4. 预设中的 endpointCandidates(也允许用户删除) + // 3. 预设中的 endpointCandidates if (selectedPresetId && selectedPresetId !== "custom") { const entry = presetEntries.find((item) => item.id === selectedPresetId); if (entry) { @@ -112,28 +107,21 @@ export function useSpeedTestEndpoints({ if (appId !== "codex") return []; const map = new Map(); - // 所有端点都标记为 isCustom: true,给用户完全的管理自由 - const add = (url?: string) => { + // 候选端点标记为 isCustom: false,表示来自预设或配置 + // 已保存的自定义端点会在 EndpointSpeedTest 组件中通过 API 加载 + const add = (url?: string, isCustom = false) => { if (!url) return; const sanitized = url.trim().replace(/\/+$/, ""); if (!sanitized || map.has(sanitized)) return; - map.set(sanitized, { url: sanitized, isCustom: true }); + map.set(sanitized, { url: sanitized, isCustom }); }; - // 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 + // 1. 当前 Codex Base URL if (codexBaseUrl) { add(codexBaseUrl); } - // 3. 编辑模式:初始数据中的 URL + // 2. 编辑模式:初始数据中的 URL const initialCodexConfig = initialData?.settingsConfig as | { config?: string; @@ -146,7 +134,7 @@ export function useSpeedTestEndpoints({ add(match[1]); } - // 4. 预设中的 endpointCandidates(也允许用户删除) + // 3. 预设中的 endpointCandidates if (selectedPresetId && selectedPresetId !== "custom") { const entry = presetEntries.find((item) => item.id === selectedPresetId); if (entry) {