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