diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 2185f20..5508172 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -739,3 +739,100 @@ pub async fn test_api_endpoints( .collect(); speedtest::test_endpoints(filtered, timeout_secs).await } + +/// 获取自定义端点列表 +#[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, + }; + + let mut result: Vec = endpoints.values().cloned().collect(); + // 按添加时间降序排序(最新的在前) + result.sort_by(|a, b| b.added_at.cmp(&a.added_at)); + + Ok(result) +} + +/// 添加自定义端点 +#[tauri::command] +pub async fn add_custom_endpoint( + app_type: AppType, + url: String, +) -> Result<(), 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 timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + + let endpoint = crate::settings::CustomEndpoint { + url: normalized.clone(), + added_at: timestamp, + last_used: None, + }; + + endpoints.insert(normalized, endpoint); + crate::settings::update_settings(settings)?; + + Ok(()) +} + +/// 删除自定义端点 +#[tauri::command] +pub async fn remove_custom_endpoint( + app_type: AppType, + url: String, +) -> Result<(), 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)?; + + Ok(()) +} + +/// 更新端点最后使用时间 +#[tauri::command] +pub async fn update_endpoint_last_used( + app_type: AppType, + url: String, +) -> Result<(), 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, + }; + + 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)?; + } + + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c558e47..6c838eb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -421,6 +421,10 @@ pub fn run() { commands::apply_claude_plugin_config, commands::is_claude_plugin_applied, commands::test_api_endpoints, + commands::get_custom_endpoints, + commands::add_custom_endpoint, + commands::remove_custom_endpoint, + commands::update_endpoint_last_used, update_tray_menu, ]); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index defc88f..7dec859 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,8 +1,19 @@ use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::sync::{OnceLock, RwLock}; +/// 自定义端点配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomEndpoint { + pub url: String, + pub added_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_used: Option, +} + /// 应用设置结构,允许覆盖默认配置目录 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -17,6 +28,12 @@ pub struct AppSettings { pub codex_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, + /// Claude 自定义端点列表 + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub custom_endpoints_claude: HashMap, + /// Codex 自定义端点列表 + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub custom_endpoints_codex: HashMap, } fn default_show_in_tray() -> bool { @@ -35,6 +52,8 @@ impl Default for AppSettings { claude_config_dir: None, codex_config_dir: None, language: None, + custom_endpoints_claude: HashMap::new(), + custom_endpoints_codex: HashMap::new(), } } } diff --git a/src/components/ProviderForm/EndpointSpeedTest.tsx b/src/components/ProviderForm/EndpointSpeedTest.tsx index ef09eb2..c011a85 100644 --- a/src/components/ProviderForm/EndpointSpeedTest.tsx +++ b/src/components/ProviderForm/EndpointSpeedTest.tsx @@ -82,6 +82,51 @@ const EndpointSpeedTest: React.FC = ({ const hasEndpoints = entries.length > 0; + // 加载保存的自定义端点 + useEffect(() => { + const loadCustomEndpoints = async () => { + try { + const customEndpoints = await window.api.getCustomEndpoints(appType); + const candidates: EndpointCandidate[] = customEndpoints.map((ep) => ({ + url: ep.url, + isCustom: true, + })); + + setEntries((prev) => { + const map = new Map(); + + // 先添加现有端点 + prev.forEach((entry) => { + map.set(entry.url, entry); + }); + + // 合并自定义端点 + candidates.forEach((candidate) => { + const sanitized = normalizeEndpointUrl(candidate.url); + if (sanitized && !map.has(sanitized)) { + map.set(sanitized, { + id: randomId(), + url: sanitized, + isCustom: true, + latency: null, + status: undefined, + error: null, + }); + } + }); + + return Array.from(map.values()); + }); + } catch (error) { + console.error("加载自定义端点失败:", error); + } + }; + + if (visible) { + loadCustomEndpoints(); + } + }, [appType, visible]); + useEffect(() => { setEntries((prev) => { const map = new Map(); @@ -135,57 +180,85 @@ const EndpointSpeedTest: React.FC = ({ }); }, [entries]); - const handleAddEndpoint = useCallback(() => { - const candidate = customUrl.trim(); - setAddError(null); + const handleAddEndpoint = useCallback( + async () => { + const candidate = customUrl.trim(); + setAddError(null); - if (!candidate) { - setAddError("请输入有效的 URL"); - return; - } - - 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; + if (!candidate) { + setAddError("请输入有效的 URL"); + return; } - return [ - ...prev, - { - id: randomId(), - url: sanitized, - isCustom: true, - latency: null, - status: undefined, - error: null, - }, - ]; - }); - if (!normalizedSelected) { - onChange(sanitized); - } + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + setAddError("URL 格式不正确"); + return; + } - setCustomUrl(""); - }, [customUrl, normalizedSelected, onChange]); + 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; + } + return prev; + }); + + if (addError) return; + + // 保存到后端 + try { + await window.api.addCustomEndpoint(appType, sanitized); + + // 更新本地状态 + setEntries((prev) => [ + ...prev, + { + id: randomId(), + url: sanitized, + isCustom: true, + latency: null, + status: undefined, + error: null, + }, + ]); + + if (!normalizedSelected) { + onChange(sanitized); + } + + setCustomUrl(""); + } catch (error) { + setAddError("保存失败,请重试"); + console.error("添加自定义端点失败:", error); + } + }, + [customUrl, normalizedSelected, onChange, appType, addError], + ); const handleRemoveEndpoint = useCallback( - (entry: EndpointEntry) => { + async (entry: EndpointEntry) => { + // 如果是自定义端点,从后端删除 + if (entry.isCustom) { + try { + await window.api.removeCustomEndpoint(appType, entry.url); + } catch (error) { + console.error("删除自定义端点失败:", error); + return; + } + } + + // 更新本地状态 setEntries((prev) => { const next = prev.filter((item) => item.id !== entry.id); if (entry.url === normalizedSelected) { @@ -195,7 +268,7 @@ const EndpointSpeedTest: React.FC = ({ return next; }); }, - [normalizedSelected, onChange], + [normalizedSelected, onChange, appType], ); const runSpeedTest = useCallback(async () => { @@ -261,11 +334,18 @@ const EndpointSpeedTest: React.FC = ({ }, [entries, autoSelect, appType, normalizedSelected, onChange]); const handleSelect = useCallback( - (url: string) => { + async (url: string) => { if (!url || url === normalizedSelected) return; + + // 更新最后使用时间(对自定义端点) + const entry = entries.find((e) => e.url === url); + if (entry?.isCustom) { + await window.api.updateEndpointLastUsed(appType, url); + } + onChange(url); }, - [normalizedSelected, onChange], + [normalizedSelected, onChange, appType, entries], ); // 支持按下 ESC 关闭弹窗 diff --git a/src/lib/tauri-api.ts b/src/lib/tauri-api.ts index 33176bb..151db46 100644 --- a/src/lib/tauri-api.ts +++ b/src/lib/tauri-api.ts @@ -1,6 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import { listen, UnlistenFn } from "@tauri-apps/api/event"; -import { Provider, Settings } from "../types"; +import { Provider, Settings, CustomEndpoint } from "../types"; // 应用类型 export type AppType = "claude" | "codex"; @@ -335,6 +335,63 @@ export const tauriAPI = { throw error; } }, + + // 获取自定义端点列表 + getCustomEndpoints: async (appType: AppType): Promise => { + try { + return await invoke("get_custom_endpoints", { + app_type: appType, + }); + } catch (error) { + console.error("获取自定义端点列表失败:", error); + return []; + } + }, + + // 添加自定义端点 + addCustomEndpoint: async (appType: AppType, url: string): Promise => { + try { + await invoke("add_custom_endpoint", { + app_type: appType, + url, + }); + } catch (error) { + console.error("添加自定义端点失败:", error); + throw error; + } + }, + + // 删除自定义端点 + removeCustomEndpoint: async ( + appType: AppType, + url: string, + ): Promise => { + try { + await invoke("remove_custom_endpoint", { + app_type: appType, + url, + }); + } catch (error) { + console.error("删除自定义端点失败:", error); + throw error; + } + }, + + // 更新端点最后使用时间 + updateEndpointLastUsed: async ( + appType: AppType, + url: string, + ): Promise => { + try { + await invoke("update_endpoint_last_used", { + app_type: appType, + url, + }); + } catch (error) { + console.error("更新端点最后使用时间失败:", error); + // 不抛出错误,因为这不是关键操作 + } + }, }; // 创建全局 API 对象,兼容现有代码 diff --git a/src/types.ts b/src/types.ts index ccb14ee..358c596 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,6 +20,13 @@ export interface AppConfig { current: string; } +// 自定义端点配置 +export interface CustomEndpoint { + url: string; + addedAt: number; + lastUsed?: number; +} + // 应用设置类型(用于 SettingsModal 与 Tauri API) export interface Settings { // 是否在系统托盘(macOS 菜单栏)显示图标 @@ -32,4 +39,8 @@ export interface Settings { codexConfigDir?: string; // 首选语言(可选,默认中文) language?: "en" | "zh"; + // Claude 自定义端点列表 + customEndpointsClaude?: Record; + // Codex 自定义端点列表 + customEndpointsCodex?: Record; } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 1737139..6524501 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,6 +1,6 @@ /// -import { Provider, Settings } from "./types"; +import { Provider, Settings, CustomEndpoint } from "./types"; import { AppType } from "./lib/tauri-api"; import type { UnlistenFn } from "@tauri-apps/api/event"; @@ -58,6 +58,11 @@ declare global { status?: number; error?: string; }>>; + // 自定义端点管理 + getCustomEndpoints: (appType: AppType) => Promise; + addCustomEndpoint: (appType: AppType, url: string) => Promise; + removeCustomEndpoint: (appType: AppType, url: string) => Promise; + updateEndpointLastUsed: (appType: AppType, url: string) => Promise; }; platform: { isMac: boolean;