diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 5508172..04a9aa4 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -9,7 +9,7 @@ use crate::app_config::AppType; use crate::claude_plugin; use crate::codex_config; use crate::config::{self, get_claude_settings_path, ConfigStatus}; -use crate::provider::Provider; +use crate::provider::{Provider, ProviderMeta}; use crate::speedtest; use crate::store::AppState; @@ -742,36 +742,80 @@ pub async fn test_api_endpoints( /// 获取自定义端点列表 #[tauri::command] -pub async fn get_custom_endpoints(app_type: AppType) -> Result, String> { - let settings = crate::settings::get_settings(); - let endpoints = match app_type { - AppType::Claude => &settings.custom_endpoints_claude, - AppType::Codex => &settings.custom_endpoints_codex, +pub async fn get_custom_endpoints( + state: State<'_, crate::store::AppState>, + app_type: Option, + app: Option, + appType: Option, + provider_id: Option, + providerId: Option, +) -> Result, String> { + let app_type = app_type + .or_else(|| app.as_deref().map(|s| s.into())) + .or_else(|| appType.as_deref().map(|s| s.into())) + .unwrap_or(AppType::Claude); + let provider_id = provider_id + .or(providerId) + .ok_or_else(|| "缺少 providerId".to_string())?; + let mut cfg_guard = state + .config + .lock() + .map_err(|e| format!("获取锁失败: {}", e))?; + + let manager = cfg_guard + .get_manager_mut(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + + let Some(provider) = manager.providers.get_mut(&provider_id) else { + return Ok(vec![]); }; - let mut result: Vec = endpoints.values().cloned().collect(); - // 按添加时间降序排序(最新的在前) - result.sort_by(|a, b| b.added_at.cmp(&a.added_at)); + // 首选从 provider.meta 读取 + let meta = provider.meta.get_or_insert_with(ProviderMeta::default); + if !meta.custom_endpoints.is_empty() { + let mut result: Vec<_> = meta.custom_endpoints.values().cloned().collect(); + result.sort_by(|a, b| b.added_at.cmp(&a.added_at)); + return Ok(result); + } - Ok(result) + Ok(vec![]) } /// 添加自定义端点 #[tauri::command] pub async fn add_custom_endpoint( - app_type: AppType, + state: State<'_, crate::store::AppState>, + app_type: Option, + app: Option, + appType: Option, + provider_id: Option, + providerId: Option, url: String, ) -> Result<(), String> { + let app_type = app_type + .or_else(|| app.as_deref().map(|s| s.into())) + .or_else(|| appType.as_deref().map(|s| s.into())) + .unwrap_or(AppType::Claude); + let provider_id = provider_id + .or(providerId) + .ok_or_else(|| "缺少 providerId".to_string())?; let normalized = url.trim().trim_end_matches('/').to_string(); if normalized.is_empty() { return Err("URL 不能为空".to_string()); } - let mut settings = crate::settings::get_settings(); - let endpoints = match app_type { - AppType::Claude => &mut settings.custom_endpoints_claude, - AppType::Codex => &mut settings.custom_endpoints_codex, + let mut cfg_guard = state + .config + .lock() + .map_err(|e| format!("获取锁失败: {}", e))?; + let manager = cfg_guard + .get_manager_mut(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + + let Some(provider) = manager.providers.get_mut(&provider_id) else { + return Err("供应商不存在或未选择".to_string()); }; + let meta = provider.meta.get_or_insert_with(ProviderMeta::default); let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -783,56 +827,90 @@ pub async fn add_custom_endpoint( added_at: timestamp, last_used: None, }; - - endpoints.insert(normalized, endpoint); - crate::settings::update_settings(settings)?; - + meta.custom_endpoints.insert(normalized, endpoint); + drop(cfg_guard); + state.save()?; Ok(()) } /// 删除自定义端点 #[tauri::command] pub async fn remove_custom_endpoint( - app_type: AppType, + state: State<'_, crate::store::AppState>, + app_type: Option, + app: Option, + appType: Option, + provider_id: Option, + providerId: Option, url: String, ) -> Result<(), String> { + let app_type = app_type + .or_else(|| app.as_deref().map(|s| s.into())) + .or_else(|| appType.as_deref().map(|s| s.into())) + .unwrap_or(AppType::Claude); + let provider_id = provider_id + .or(providerId) + .ok_or_else(|| "缺少 providerId".to_string())?; let normalized = url.trim().trim_end_matches('/').to_string(); - let mut settings = crate::settings::get_settings(); - let endpoints = match app_type { - AppType::Claude => &mut settings.custom_endpoints_claude, - AppType::Codex => &mut settings.custom_endpoints_codex, - }; - - endpoints.remove(&normalized); - crate::settings::update_settings(settings)?; + let mut cfg_guard = state + .config + .lock() + .map_err(|e| format!("获取锁失败: {}", e))?; + let manager = cfg_guard + .get_manager_mut(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + if let Some(provider) = manager.providers.get_mut(&provider_id) { + if let Some(meta) = provider.meta.as_mut() { + meta.custom_endpoints.remove(&normalized); + } + } + drop(cfg_guard); + state.save()?; Ok(()) } /// 更新端点最后使用时间 #[tauri::command] pub async fn update_endpoint_last_used( - app_type: AppType, + state: State<'_, crate::store::AppState>, + app_type: Option, + app: Option, + appType: Option, + provider_id: Option, + providerId: Option, url: String, ) -> Result<(), String> { + let app_type = app_type + .or_else(|| app.as_deref().map(|s| s.into())) + .or_else(|| appType.as_deref().map(|s| s.into())) + .unwrap_or(AppType::Claude); + let provider_id = provider_id + .or(providerId) + .ok_or_else(|| "缺少 providerId".to_string())?; let normalized = url.trim().trim_end_matches('/').to_string(); - let mut settings = crate::settings::get_settings(); - let endpoints = match app_type { - AppType::Claude => &mut settings.custom_endpoints_claude, - AppType::Codex => &mut settings.custom_endpoints_codex, - }; + let mut cfg_guard = state + .config + .lock() + .map_err(|e| format!("获取锁失败: {}", e))?; + let manager = cfg_guard + .get_manager_mut(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - if let Some(endpoint) = endpoints.get_mut(&normalized) { - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as i64; - - endpoint.last_used = Some(timestamp); - crate::settings::update_settings(settings)?; + if let Some(provider) = manager.providers.get_mut(&provider_id) { + if let Some(meta) = provider.meta.as_mut() { + if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + endpoint.last_used = Some(timestamp); + } + } } - + drop(cfg_guard); + state.save()?; Ok(()) } diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 20a3ff3..6bb9acd 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -19,6 +19,9 @@ pub struct Provider { #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "createdAt")] pub created_at: Option, + /// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json) + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, } impl Provider { @@ -36,6 +39,7 @@ impl Provider { website_url, category: None, created_at: None, + meta: None, } } } @@ -56,6 +60,14 @@ impl Default for ProviderManager { } } +/// 供应商元数据 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProviderMeta { + /// 自定义端点列表(按 URL 去重存储) + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub custom_endpoints: HashMap, +} + impl ProviderManager { /// 获取所有供应商 pub fn get_all_providers(&self) -> &HashMap { diff --git a/src/components/ProviderForm.tsx b/src/components/ProviderForm.tsx index 6513216..3d701d7 100644 --- a/src/components/ProviderForm.tsx +++ b/src/components/ProviderForm.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useMemo } from "react"; -import { Provider, ProviderCategory } from "../types"; +import { Provider, ProviderCategory, CustomEndpoint } from "../types"; import { AppType } from "../lib/tauri-api"; import { updateCommonConfigSnippet, @@ -219,6 +219,10 @@ const ProviderForm: React.FC = ({ const [codexBaseUrl, setCodexBaseUrl] = useState(""); const [isCodexTemplateModalOpen, setIsCodexTemplateModalOpen] = useState(false); + // 新建供应商:收集端点测速弹窗中的“自定义端点”,提交时一次性落盘到 meta.custom_endpoints + const [draftCustomEndpoints, setDraftCustomEndpoints] = useState( + [] + ); // 端点测速弹窗状态 const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false); const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] = useState(false); @@ -603,13 +607,31 @@ const ProviderForm: React.FC = ({ } } - onSubmit({ + // 构造基础提交数据 + const basePayload: Omit = { name: formData.name, websiteUrl: formData.websiteUrl, settingsConfig, // 仅在用户选择了预设或手动选择“自定义”时持久化分类 ...(category ? { category } : {}), - }); + }; + + // 若为“新建供应商”,且已在弹窗中添加了自定义端点,则随提交一并落盘 + if (!initialData && draftCustomEndpoints.length > 0) { + const now = Date.now(); + const customMap: Record = {}; + for (const raw of draftCustomEndpoints) { + const url = raw.trim().replace(/\/+$/, ""); + if (!url) continue; + if (!customMap[url]) { + customMap[url] = { url, addedAt: now }; + } + } + onSubmit({ ...basePayload, meta: { custom_endpoints: customMap } }); + return; + } + + onSubmit(basePayload); }; const handleChange = ( @@ -1620,11 +1642,13 @@ const ProviderForm: React.FC = ({ {!isCodex && shouldShowSpeedTest && isEndpointModalOpen && ( setIsEndpointModalOpen(false)} + onCustomEndpointsChange={setDraftCustomEndpoints} /> )} @@ -1705,11 +1729,13 @@ const ProviderForm: React.FC = ({ {isCodex && shouldShowSpeedTest && isCodexEndpointModalOpen && ( setIsCodexEndpointModalOpen(false)} + onCustomEndpointsChange={setDraftCustomEndpoints} /> )} diff --git a/src/components/ProviderForm/EndpointSpeedTest.tsx b/src/components/ProviderForm/EndpointSpeedTest.tsx index c011a85..a42e128 100644 --- a/src/components/ProviderForm/EndpointSpeedTest.tsx +++ b/src/components/ProviderForm/EndpointSpeedTest.tsx @@ -12,11 +12,14 @@ export interface EndpointCandidate { interface EndpointSpeedTestProps { appType: AppType; + providerId?: string; value: string; onChange: (url: string) => void; initialEndpoints: EndpointCandidate[]; visible?: boolean; onClose: () => void; + // 当自定义端点列表变化时回传(仅包含 isCustom 的条目) + onCustomEndpointsChange?: (urls: string[]) => void; } interface EndpointEntry extends EndpointCandidate { @@ -63,11 +66,13 @@ const buildInitialEntries = ( const EndpointSpeedTest: React.FC = ({ appType, + providerId, value, onChange, initialEndpoints, visible = true, onClose, + onCustomEndpointsChange, }) => { const [entries, setEntries] = useState(() => buildInitialEntries(initialEndpoints, value), @@ -82,11 +87,15 @@ const EndpointSpeedTest: React.FC = ({ const hasEndpoints = entries.length > 0; - // 加载保存的自定义端点 + // 加载保存的自定义端点(按正在编辑的供应商) useEffect(() => { const loadCustomEndpoints = async () => { try { - const customEndpoints = await window.api.getCustomEndpoints(appType); + if (!providerId) return; + const customEndpoints = await window.api.getCustomEndpoints( + appType, + providerId, + ); const candidates: EndpointCandidate[] = customEndpoints.map((ep) => ({ url: ep.url, isCustom: true, @@ -125,7 +134,7 @@ const EndpointSpeedTest: React.FC = ({ if (visible) { loadCustomEndpoints(); } - }, [appType, visible]); + }, [appType, visible, providerId]); useEffect(() => { setEntries((prev) => { @@ -169,6 +178,25 @@ const EndpointSpeedTest: React.FC = ({ }); }, [initialEndpoints, normalizedSelected]); + // 将自定义端点变化透传给父组件(仅限 isCustom) + useEffect(() => { + if (!onCustomEndpointsChange) 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 变化时同步 + }, [entries, onCustomEndpointsChange]); + const sortedEntries = useMemo(() => { return entries.slice().sort((a, b) => { const aLatency = a.latency ?? Number.POSITIVE_INFINITY; @@ -183,55 +211,63 @@ const EndpointSpeedTest: React.FC = ({ const handleAddEndpoint = useCallback( async () => { const candidate = customUrl.trim(); - setAddError(null); + let errorMsg: string | null = null; if (!candidate) { - setAddError("请输入有效的 URL"); - return; + errorMsg = "请输入有效的 URL"; } - let parsed: URL; - try { - parsed = new URL(candidate); - } catch { - setAddError("URL 格式不正确"); - return; - } - - if (!parsed.protocol.startsWith("http")) { - setAddError("仅支持 HTTP/HTTPS"); - return; - } - - const sanitized = normalizeEndpointUrl(parsed.toString()); - - // 检查是否已存在 - setEntries((prev) => { - if (prev.some((entry) => entry.url === sanitized)) { - setAddError("该地址已存在"); - return prev; + let parsed: URL | null = null; + if (!errorMsg) { + try { + parsed = new URL(candidate); + } catch { + errorMsg = "URL 格式不正确"; } - return prev; - }); + } - if (addError) return; + if (!errorMsg && parsed && !parsed.protocol.startsWith("http")) { + errorMsg = "仅支持 HTTP/HTTPS"; + } + + let sanitized = ""; + if (!errorMsg && parsed) { + sanitized = normalizeEndpointUrl(parsed.toString()); + // 使用当前 entries 做去重校验,避免依赖可能过期的 addError + const isDuplicate = entries.some((entry) => entry.url === sanitized); + if (isDuplicate) { + errorMsg = "该地址已存在"; + } + } + + if (errorMsg) { + setAddError(errorMsg); + return; + } + + setAddError(null); // 保存到后端 try { - await window.api.addCustomEndpoint(appType, sanitized); + if (providerId) { + await window.api.addCustomEndpoint(appType, providerId, sanitized); + } // 更新本地状态 - setEntries((prev) => [ - ...prev, - { - id: randomId(), - url: sanitized, - isCustom: true, - latency: null, - status: undefined, - error: null, - }, - ]); + 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, + }, + ]; + }); if (!normalizedSelected) { onChange(sanitized); @@ -239,19 +275,21 @@ const EndpointSpeedTest: React.FC = ({ setCustomUrl(""); } catch (error) { - setAddError("保存失败,请重试"); + const message = + error instanceof Error ? error.message : String(error); + setAddError(message || "保存失败,请重试"); console.error("添加自定义端点失败:", error); } }, - [customUrl, normalizedSelected, onChange, appType, addError], + [customUrl, entries, normalizedSelected, onChange, appType, providerId], ); const handleRemoveEndpoint = useCallback( async (entry: EndpointEntry) => { - // 如果是自定义端点,从后端删除 - if (entry.isCustom) { + // 如果是自定义端点,尝试从后端删除(无 providerId 则仅本地删除) + if (entry.isCustom && providerId) { try { - await window.api.removeCustomEndpoint(appType, entry.url); + await window.api.removeCustomEndpoint(appType, providerId, entry.url); } catch (error) { console.error("删除自定义端点失败:", error); return; @@ -268,7 +306,7 @@ const EndpointSpeedTest: React.FC = ({ return next; }); }, - [normalizedSelected, onChange, appType], + [normalizedSelected, onChange, appType, providerId], ); const runSpeedTest = useCallback(async () => { @@ -339,13 +377,13 @@ const EndpointSpeedTest: React.FC = ({ // 更新最后使用时间(对自定义端点) const entry = entries.find((e) => e.url === url); - if (entry?.isCustom) { - await window.api.updateEndpointLastUsed(appType, url); + if (entry?.isCustom && providerId) { + await window.api.updateEndpointLastUsed(appType, providerId, url); } onChange(url); }, - [normalizedSelected, onChange, appType, entries], + [normalizedSelected, onChange, appType, entries, providerId], ); // 支持按下 ESC 关闭弹窗 diff --git a/src/lib/tauri-api.ts b/src/lib/tauri-api.ts index 151db46..7d5c521 100644 --- a/src/lib/tauri-api.ts +++ b/src/lib/tauri-api.ts @@ -337,10 +337,18 @@ export const tauriAPI = { }, // 获取自定义端点列表 - getCustomEndpoints: async (appType: AppType): Promise => { + getCustomEndpoints: async ( + appType: AppType, + providerId: string, + ): Promise => { try { return await invoke("get_custom_endpoints", { + // 兼容不同后端参数命名 app_type: appType, + app: appType, + appType: appType, + provider_id: providerId, + providerId: providerId, }); } catch (error) { console.error("获取自定义端点列表失败:", error); @@ -349,26 +357,44 @@ export const tauriAPI = { }, // 添加自定义端点 - addCustomEndpoint: async (appType: AppType, url: string): Promise => { + addCustomEndpoint: async ( + appType: AppType, + providerId: string, + url: string, + ): Promise => { try { await invoke("add_custom_endpoint", { app_type: appType, + app: appType, + appType: appType, + provider_id: providerId, + providerId: providerId, url, }); } catch (error) { console.error("添加自定义端点失败:", error); - throw error; + // 尽量抛出可读信息 + if (error instanceof Error) { + throw error; + } else { + throw new Error(String(error)); + } } }, // 删除自定义端点 removeCustomEndpoint: async ( appType: AppType, + providerId: string, url: string, ): Promise => { try { await invoke("remove_custom_endpoint", { app_type: appType, + app: appType, + appType: appType, + provider_id: providerId, + providerId: providerId, url, }); } catch (error) { @@ -380,11 +406,16 @@ export const tauriAPI = { // 更新端点最后使用时间 updateEndpointLastUsed: async ( appType: AppType, + providerId: string, url: string, ): Promise => { try { await invoke("update_endpoint_last_used", { app_type: appType, + app: appType, + appType: appType, + provider_id: providerId, + providerId: providerId, url, }); } catch (error) { diff --git a/src/types.ts b/src/types.ts index 358c596..3a4e0f1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,8 @@ export interface Provider { // 新增:供应商分类(用于差异化提示/能力开关) category?: ProviderCategory; createdAt?: number; // 添加时间戳(毫秒) + // 可选:供应商元数据(仅存于 ~/.cc-switch/config.json,不写入 live 配置) + meta?: ProviderMeta; } export interface AppConfig { @@ -27,6 +29,12 @@ export interface CustomEndpoint { lastUsed?: number; } +// 供应商元数据(字段名与后端一致,保持 snake_case) +export interface ProviderMeta { + // 自定义端点:以 URL 为键,值为端点信息 + custom_endpoints?: Record; +} + // 应用设置类型(用于 SettingsModal 与 Tauri API) export interface Settings { // 是否在系统托盘(macOS 菜单栏)显示图标 diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 6524501..32b0a5d 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -59,10 +59,25 @@ declare global { error?: string; }>>; // 自定义端点管理 - getCustomEndpoints: (appType: AppType) => Promise; - addCustomEndpoint: (appType: AppType, url: string) => Promise; - removeCustomEndpoint: (appType: AppType, url: string) => Promise; - updateEndpointLastUsed: (appType: AppType, url: string) => Promise; + getCustomEndpoints: ( + appType: AppType, + providerId: string + ) => Promise; + addCustomEndpoint: ( + appType: AppType, + providerId: string, + url: string + ) => Promise; + removeCustomEndpoint: ( + appType: AppType, + providerId: string, + url: string + ) => Promise; + updateEndpointLastUsed: ( + appType: AppType, + providerId: string, + url: string + ) => Promise; }; platform: { isMac: boolean;