feat: persist custom endpoints to settings.json
- Extend AppSettings to store custom endpoints for Claude and Codex - Add Tauri commands: get/add/remove/update custom endpoints - Update frontend API with endpoint persistence methods - Modify EndpointSpeedTest to load/save custom endpoints via API - Track endpoint last used time for future sorting/cleanup - Store endpoints per app type in settings.json instead of localStorage
This commit is contained in:
@@ -739,3 +739,100 @@ pub async fn test_api_endpoints(
|
|||||||
.collect();
|
.collect();
|
||||||
speedtest::test_endpoints(filtered, timeout_secs).await
|
speedtest::test_endpoints(filtered, timeout_secs).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取自定义端点列表
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_custom_endpoints(app_type: AppType) -> Result<Vec<crate::settings::CustomEndpoint>, 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<crate::settings::CustomEndpoint> = 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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -421,6 +421,10 @@ pub fn run() {
|
|||||||
commands::apply_claude_plugin_config,
|
commands::apply_claude_plugin_config,
|
||||||
commands::is_claude_plugin_applied,
|
commands::is_claude_plugin_applied,
|
||||||
commands::test_api_endpoints,
|
commands::test_api_endpoints,
|
||||||
|
commands::get_custom_endpoints,
|
||||||
|
commands::add_custom_endpoint,
|
||||||
|
commands::remove_custom_endpoint,
|
||||||
|
commands::update_endpoint_last_used,
|
||||||
update_tray_menu,
|
update_tray_menu,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{OnceLock, RwLock};
|
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<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
/// 应用设置结构,允许覆盖默认配置目录
|
/// 应用设置结构,允许覆盖默认配置目录
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -17,6 +28,12 @@ pub struct AppSettings {
|
|||||||
pub codex_config_dir: Option<String>,
|
pub codex_config_dir: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
|
/// Claude 自定义端点列表
|
||||||
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
|
pub custom_endpoints_claude: HashMap<String, CustomEndpoint>,
|
||||||
|
/// Codex 自定义端点列表
|
||||||
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
|
pub custom_endpoints_codex: HashMap<String, CustomEndpoint>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_show_in_tray() -> bool {
|
fn default_show_in_tray() -> bool {
|
||||||
@@ -35,6 +52,8 @@ impl Default for AppSettings {
|
|||||||
claude_config_dir: None,
|
claude_config_dir: None,
|
||||||
codex_config_dir: None,
|
codex_config_dir: None,
|
||||||
language: None,
|
language: None,
|
||||||
|
custom_endpoints_claude: HashMap::new(),
|
||||||
|
custom_endpoints_codex: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,51 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
|
|
||||||
const hasEndpoints = entries.length > 0;
|
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<string, EndpointEntry>();
|
||||||
|
|
||||||
|
// 先添加现有端点
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
setEntries((prev) => {
|
setEntries((prev) => {
|
||||||
const map = new Map<string, EndpointEntry>();
|
const map = new Map<string, EndpointEntry>();
|
||||||
@@ -135,57 +180,85 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
});
|
});
|
||||||
}, [entries]);
|
}, [entries]);
|
||||||
|
|
||||||
const handleAddEndpoint = useCallback(() => {
|
const handleAddEndpoint = useCallback(
|
||||||
const candidate = customUrl.trim();
|
async () => {
|
||||||
setAddError(null);
|
const candidate = customUrl.trim();
|
||||||
|
setAddError(null);
|
||||||
|
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
setAddError("请输入有效的 URL");
|
setAddError("请输入有效的 URL");
|
||||||
return;
|
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;
|
|
||||||
}
|
}
|
||||||
return [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: randomId(),
|
|
||||||
url: sanitized,
|
|
||||||
isCustom: true,
|
|
||||||
latency: null,
|
|
||||||
status: undefined,
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!normalizedSelected) {
|
let parsed: URL;
|
||||||
onChange(sanitized);
|
try {
|
||||||
}
|
parsed = new URL(candidate);
|
||||||
|
} catch {
|
||||||
|
setAddError("URL 格式不正确");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setCustomUrl("");
|
if (!parsed.protocol.startsWith("http")) {
|
||||||
}, [customUrl, normalizedSelected, onChange]);
|
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(
|
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) => {
|
setEntries((prev) => {
|
||||||
const next = prev.filter((item) => item.id !== entry.id);
|
const next = prev.filter((item) => item.id !== entry.id);
|
||||||
if (entry.url === normalizedSelected) {
|
if (entry.url === normalizedSelected) {
|
||||||
@@ -195,7 +268,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[normalizedSelected, onChange],
|
[normalizedSelected, onChange, appType],
|
||||||
);
|
);
|
||||||
|
|
||||||
const runSpeedTest = useCallback(async () => {
|
const runSpeedTest = useCallback(async () => {
|
||||||
@@ -261,11 +334,18 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
}, [entries, autoSelect, appType, normalizedSelected, onChange]);
|
}, [entries, autoSelect, appType, normalizedSelected, onChange]);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(url: string) => {
|
async (url: string) => {
|
||||||
if (!url || url === normalizedSelected) return;
|
if (!url || url === normalizedSelected) return;
|
||||||
|
|
||||||
|
// 更新最后使用时间(对自定义端点)
|
||||||
|
const entry = entries.find((e) => e.url === url);
|
||||||
|
if (entry?.isCustom) {
|
||||||
|
await window.api.updateEndpointLastUsed(appType, url);
|
||||||
|
}
|
||||||
|
|
||||||
onChange(url);
|
onChange(url);
|
||||||
},
|
},
|
||||||
[normalizedSelected, onChange],
|
[normalizedSelected, onChange, appType, entries],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 支持按下 ESC 关闭弹窗
|
// 支持按下 ESC 关闭弹窗
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen, UnlistenFn } from "@tauri-apps/api/event";
|
import { listen, UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import { Provider, Settings } from "../types";
|
import { Provider, Settings, CustomEndpoint } from "../types";
|
||||||
|
|
||||||
// 应用类型
|
// 应用类型
|
||||||
export type AppType = "claude" | "codex";
|
export type AppType = "claude" | "codex";
|
||||||
@@ -335,6 +335,63 @@ export const tauriAPI = {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 获取自定义端点列表
|
||||||
|
getCustomEndpoints: async (appType: AppType): Promise<CustomEndpoint[]> => {
|
||||||
|
try {
|
||||||
|
return await invoke<CustomEndpoint[]>("get_custom_endpoints", {
|
||||||
|
app_type: appType,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取自定义端点列表失败:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 添加自定义端点
|
||||||
|
addCustomEndpoint: async (appType: AppType, url: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await invoke("add_custom_endpoint", {
|
||||||
|
app_type: appType,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("添加自定义端点失败:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除自定义端点
|
||||||
|
removeCustomEndpoint: async (
|
||||||
|
appType: AppType,
|
||||||
|
url: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await invoke("remove_custom_endpoint", {
|
||||||
|
app_type: appType,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("删除自定义端点失败:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新端点最后使用时间
|
||||||
|
updateEndpointLastUsed: async (
|
||||||
|
appType: AppType,
|
||||||
|
url: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await invoke("update_endpoint_last_used", {
|
||||||
|
app_type: appType,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("更新端点最后使用时间失败:", error);
|
||||||
|
// 不抛出错误,因为这不是关键操作
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 创建全局 API 对象,兼容现有代码
|
// 创建全局 API 对象,兼容现有代码
|
||||||
|
|||||||
11
src/types.ts
11
src/types.ts
@@ -20,6 +20,13 @@ export interface AppConfig {
|
|||||||
current: string;
|
current: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自定义端点配置
|
||||||
|
export interface CustomEndpoint {
|
||||||
|
url: string;
|
||||||
|
addedAt: number;
|
||||||
|
lastUsed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
// 应用设置类型(用于 SettingsModal 与 Tauri API)
|
// 应用设置类型(用于 SettingsModal 与 Tauri API)
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
// 是否在系统托盘(macOS 菜单栏)显示图标
|
// 是否在系统托盘(macOS 菜单栏)显示图标
|
||||||
@@ -32,4 +39,8 @@ export interface Settings {
|
|||||||
codexConfigDir?: string;
|
codexConfigDir?: string;
|
||||||
// 首选语言(可选,默认中文)
|
// 首选语言(可选,默认中文)
|
||||||
language?: "en" | "zh";
|
language?: "en" | "zh";
|
||||||
|
// Claude 自定义端点列表
|
||||||
|
customEndpointsClaude?: Record<string, CustomEndpoint>;
|
||||||
|
// Codex 自定义端点列表
|
||||||
|
customEndpointsCodex?: Record<string, CustomEndpoint>;
|
||||||
}
|
}
|
||||||
|
|||||||
7
src/vite-env.d.ts
vendored
7
src/vite-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
import { Provider, Settings } from "./types";
|
import { Provider, Settings, CustomEndpoint } from "./types";
|
||||||
import { AppType } from "./lib/tauri-api";
|
import { AppType } from "./lib/tauri-api";
|
||||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
@@ -58,6 +58,11 @@ declare global {
|
|||||||
status?: number;
|
status?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>>;
|
}>>;
|
||||||
|
// 自定义端点管理
|
||||||
|
getCustomEndpoints: (appType: AppType) => Promise<CustomEndpoint[]>;
|
||||||
|
addCustomEndpoint: (appType: AppType, url: string) => Promise<void>;
|
||||||
|
removeCustomEndpoint: (appType: AppType, url: string) => Promise<void>;
|
||||||
|
updateEndpointLastUsed: (appType: AppType, url: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
platform: {
|
platform: {
|
||||||
isMac: boolean;
|
isMac: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user