- feat(types): add Provider.meta and ProviderMeta (snake_case) with custom_endpoints map

- feat(provider-form): persist custom endpoints on provider create by merging EndpointSpeedTest’s custom URLs into meta.custom_endpoints on submit

- feat(endpoint-speed-test): add onCustomEndpointsChange callback emitting normalized custom URLs; wire it for both Claude/Codex modals

- fix(api): send alias param names (app/appType/app_type and provider_id/providerId) in Tauri invokes to avoid “missing providerId” with older backends

- storage: custom endpoints are stored in ~/.cc-switch/config.json under providers[<id>].meta.custom_endpoints (not in settings.json)

- behavior: edit flow remains immediate writes; create flow now writes once via addProvider, removing the providerId dependency during creation
This commit is contained in:
Jason
2025-10-07 11:09:00 +08:00
parent 498920dea6
commit 061aef1c2f
7 changed files with 311 additions and 103 deletions

View File

@@ -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<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,
pub async fn get_custom_endpoints(
state: State<'_, crate::store::AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
) -> Result<Vec<crate::settings::CustomEndpoint>, 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<crate::settings::CustomEndpoint> = 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<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
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<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
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<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
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(())
}

View File

@@ -19,6 +19,9 @@ pub struct Provider {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "createdAt")]
pub created_at: Option<i64>,
/// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<ProviderMeta>,
}
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<String, crate::settings::CustomEndpoint>,
}
impl ProviderManager {
/// 获取所有供应商
pub fn get_all_providers(&self) -> &HashMap<String, Provider> {