From bdf2e499bcfc2883438d8fdce689d4d576c98405 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Mon, 27 Oct 2025 01:19:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=BD=E7=A6=BB=E5=85=AC=E5=85=B1=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/api_nodes.rs | 85 ++--- src-tauri/src/commands/packycode_nodes.rs | 179 +++-------- src-tauri/src/commands/relay_adapters.rs | 27 +- src-tauri/src/http_client.rs | 219 +++++++++++++ src-tauri/src/lib.rs | 3 + src-tauri/src/main.rs | 3 + src-tauri/src/types/mod.rs | 2 + src-tauri/src/types/node_test.rs | 367 ++++++++++++++++++++++ src-tauri/src/utils/error.rs | 235 ++++++++++++++ src-tauri/src/utils/mod.rs | 3 + src-tauri/src/utils/node_tester.rs | 264 ++++++++++++++++ src/components/NodeManager/index.tsx | 17 +- src/components/RelayStationManager.tsx | 30 -- src/components/SortableStationItem.tsx | 134 +------- 14 files changed, 1188 insertions(+), 380 deletions(-) create mode 100644 src-tauri/src/http_client.rs create mode 100644 src-tauri/src/types/mod.rs create mode 100644 src-tauri/src/types/node_test.rs create mode 100644 src-tauri/src/utils/error.rs create mode 100644 src-tauri/src/utils/mod.rs create mode 100644 src-tauri/src/utils/node_tester.rs diff --git a/src-tauri/src/commands/api_nodes.rs b/src-tauri/src/commands/api_nodes.rs index 8c120d5..2f4f4ea 100644 --- a/src-tauri/src/commands/api_nodes.rs +++ b/src-tauri/src/commands/api_nodes.rs @@ -4,6 +4,11 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; use uuid::Uuid; +// 导入公共模块 +use crate::http_client; +use crate::types::node_test::NodeTestResult; +use crate::utils::node_tester; + /// API 节点数据结构 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ApiNode { @@ -36,17 +41,6 @@ pub struct UpdateApiNodeRequest { pub enabled: Option, } -/// 节点测试结果 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NodeTestResult { - pub node_id: String, - pub url: String, - pub name: String, - pub response_time: Option, - pub status: String, - pub error: Option, -} - /// 获取数据库连接 fn get_connection() -> Result { let db_path = get_nodes_db_path()?; @@ -333,50 +327,16 @@ pub async fn delete_api_node(id: String) -> Result<(), String> { /// 测试单个节点 #[tauri::command] pub async fn test_api_node(url: String, timeout_ms: Option) -> Result { - let timeout = std::time::Duration::from_millis(timeout_ms.unwrap_or(5000)); - let start = std::time::Instant::now(); + let timeout = timeout_ms.unwrap_or(5000); - let client = reqwest::Client::builder() - .timeout(timeout) - .build() - .map_err(|e| e.to_string())?; + // 使用公共节点测试器 + let mut result = node_tester::test_node_connectivity(&url, timeout).await; - // 使用 HEAD 请求测试连通性,更轻量且不会触发 API 调用 - match client.head(&url).send().await { - Ok(response) => { - let response_time = start.elapsed().as_millis() as u64; - // 允许 2xx, 3xx, 4xx 状态码,说明服务器在线(5xx 视为失败) - let status_code = response.status(); - let status = if status_code.is_success() - || status_code.is_redirection() - || status_code.is_client_error() { - "success" - } else { - "failed" - }; + // 添加节点 ID 和名称(如果有) + result.node_id = Some(String::new()); + result.node_name = Some(String::new()); - Ok(NodeTestResult { - node_id: String::new(), - url: url.clone(), - name: String::new(), - response_time: Some(response_time), - status: status.to_string(), - error: if status == "failed" { - Some(format!("HTTP {}", status_code)) - } else { - None - }, - }) - } - Err(e) => Ok(NodeTestResult { - node_id: String::new(), - url: url.clone(), - name: String::new(), - response_time: None, - status: "failed".to_string(), - error: Some(e.to_string()), - }), - } + Ok(result) } /// 批量测试节点 @@ -386,15 +346,20 @@ pub async fn test_all_api_nodes( timeout_ms: Option, ) -> Result, String> { let nodes = list_api_nodes(adapter, Some(true)).await?; - let mut results = Vec::new(); + let timeout = timeout_ms.unwrap_or(5000); - for node in nodes { - let result = test_api_node(node.url.clone(), timeout_ms).await?; - results.push(NodeTestResult { - node_id: node.id.clone(), - name: node.name.clone(), - ..result - }); + // 提取所有节点的 URL + let urls: Vec = nodes.iter().map(|n| n.url.clone()).collect(); + + // 使用公共节点测试器批量测试 + let mut results = node_tester::test_nodes_batch(urls, timeout).await; + + // 添加节点 ID 和名称 + for (i, result) in results.iter_mut().enumerate() { + if let Some(node) = nodes.get(i) { + result.node_id = Some(node.id.clone()); + result.node_name = Some(node.name.clone()); + } } Ok(results) diff --git a/src-tauri/src/commands/packycode_nodes.rs b/src-tauri/src/commands/packycode_nodes.rs index cbb2577..f7c59ed 100644 --- a/src-tauri/src/commands/packycode_nodes.rs +++ b/src-tauri/src/commands/packycode_nodes.rs @@ -1,9 +1,11 @@ use anyhow::Result; -use reqwest::Client; use serde::{Deserialize, Serialize}; -use std::time::{Duration, Instant}; use tauri::command; +// 导入公共模块 +use crate::types::node_test::{NodeTestResult, TestStatus}; +use crate::utils::node_tester; + /// PackyCode 节点类型 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -24,15 +26,6 @@ pub struct PackycodeNode { pub available: Option, // 是否可用 } -/// 节点测速结果 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NodeSpeedTestResult { - pub node: PackycodeNode, - pub response_time: u64, - pub success: bool, - pub error: Option, -} - /// 获取所有 PackyCode 节点 pub fn get_all_nodes() -> Vec { vec![ @@ -122,100 +115,37 @@ pub fn get_all_nodes() -> Vec { } /// 测试单个节点速度(仅测试网络延时,不需要认证) -async fn test_node_speed(node: &PackycodeNode) -> NodeSpeedTestResult { - let client = Client::builder() - .timeout(Duration::from_secs(3)) // 减少超时时间 - .danger_accept_invalid_certs(true) // 接受自签名证书 - .build() - .unwrap_or_else(|_| Client::new()); - - let start_time = Instant::now(); - - // 使用 GET 请求到根路径,这是最简单的 ping 测试 - // 不需要 token,只测试网络延迟 +async fn test_node_speed(node: &PackycodeNode) -> NodeTestResult { let url = format!("{}/", node.url.trim_end_matches('/')); + let mut result = node_tester::test_node_connectivity(&url, 3000).await; - match client - .get(&url) - .timeout(Duration::from_secs(3)) - .send() - .await - { - Ok(_response) => { - let response_time = start_time.elapsed().as_millis() as u64; + // 添加节点名称 + result.node_name = Some(node.name.clone()); - // 只要能连接到服务器就算成功(不管状态码) - // 因为我们只是测试延迟,不是测试 API 功能 - let success = response_time < 3000; // 小于 3 秒就算成功 - - NodeSpeedTestResult { - node: PackycodeNode { - response_time: Some(response_time), - available: Some(success), - ..node.clone() - }, - response_time, - success, - error: if success { - None - } else { - Some("响应时间过长".to_string()) - }, - } - } - Err(e) => { - let response_time = start_time.elapsed().as_millis() as u64; - - // 如果是超时错误,特别标记 - let error_msg = if e.is_timeout() { - "连接超时".to_string() - } else if e.is_connect() { - "无法连接".to_string() - } else { - format!("网络错误: {}", e) - }; - - NodeSpeedTestResult { - node: PackycodeNode { - response_time: Some(response_time), - available: Some(false), - ..node.clone() - }, - response_time, - success: false, - error: Some(error_msg), - } - } - } + result } /// 测试所有节点速度(不需要 token,只测试延迟) #[command] -pub async fn test_all_packycode_nodes() -> Result, String> { +pub async fn test_all_packycode_nodes() -> Result, String> { let nodes = get_all_nodes(); - let mut results = Vec::new(); + let urls: Vec = nodes + .iter() + .map(|n| format!("{}/", n.url.trim_end_matches('/'))) + .collect(); - // 并发测试所有节点 - let futures: Vec<_> = nodes.iter().map(|node| test_node_speed(node)).collect(); + // 使用公共批量测试 + let mut results = node_tester::test_nodes_batch(urls, 3000).await; - // 等待所有测试完成 - for (i, future) in futures.into_iter().enumerate() { - let result = future.await; - log::info!( - "节点 {} 测速结果: {}ms, 成功: {}", - nodes[i].name, - result.response_time, - result.success - ); - results.push(result); + // 添加节点名称 + for (i, result) in results.iter_mut().enumerate() { + if let Some(node) = nodes.get(i) { + result.node_name = Some(node.name.clone()); + } } - // 按响应时间排序(成功的节点优先,然后按延迟排序) - results.sort_by(|a, b| match (a.success, b.success) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => a.response_time.cmp(&b.response_time), - }); + // 按响应时间排序(成功的节点优先) + node_tester::sort_by_response_time(&mut results); Ok(results) } @@ -224,7 +154,6 @@ pub async fn test_all_packycode_nodes() -> Result, Stri #[command] pub async fn auto_select_best_node() -> Result { let nodes = get_all_nodes(); - let mut best_node: Option<(PackycodeNode, u64)> = None; // 只测试直连和备用节点,过滤掉紧急节点 let test_nodes: Vec<_> = nodes @@ -234,52 +163,36 @@ pub async fn auto_select_best_node() -> Result { log::info!("开始测试 {} 个节点...", test_nodes.len()); - // 并发测试所有节点 - let futures: Vec<_> = test_nodes + // 提取 URL 列表 + let urls: Vec = test_nodes .iter() - .map(|node| test_node_speed(node)) + .map(|n| format!("{}/", n.url.trim_end_matches('/'))) .collect(); - // 收集结果并找出最佳节点 - for (i, future) in futures.into_iter().enumerate() { - let result = future.await; + // 使用公共批量测试 + let results = node_tester::test_nodes_batch(urls, 3000).await; + + // 查找最快的节点 + if let Some(fastest) = node_tester::find_fastest_node(&results) { + // 根据 URL 找到对应的节点 + let best_node = test_nodes + .into_iter() + .find(|n| { + let node_url = format!("{}/", n.url.trim_end_matches('/')); + node_url == fastest.url + }) + .ok_or_else(|| "未找到匹配的节点".to_string())?; log::info!( - "节点 {} - 延迟: {}ms, 可用: {}", - test_nodes[i].name, - result.response_time, - result.success + "最佳节点选择: {} (延迟: {}ms)", + best_node.name, + fastest.response_time_ms.unwrap_or(0) ); - if result.success { - match &best_node { - None => { - log::info!("初始最佳节点: {}", result.node.name); - best_node = Some((result.node, result.response_time)); - } - Some((_, best_time)) if result.response_time < *best_time => { - log::info!( - "发现更快节点: {} ({}ms < {}ms)", - result.node.name, - result.response_time, - best_time - ); - best_node = Some((result.node, result.response_time)); - } - _ => {} - } - } - } - - match best_node { - Some((node, time)) => { - log::info!("最佳节点选择: {} (延迟: {}ms)", node.name, time); - Ok(node) - } - None => { - log::error!("没有找到可用的节点"); - Err("没有找到可用的节点".to_string()) - } + Ok(best_node) + } else { + log::error!("没有找到可用的节点"); + Err("没有找到可用的节点".to_string()) } } diff --git a/src-tauri/src/commands/relay_adapters.rs b/src-tauri/src/commands/relay_adapters.rs index 97705b1..cb7e938 100644 --- a/src-tauri/src/commands/relay_adapters.rs +++ b/src-tauri/src/commands/relay_adapters.rs @@ -1,24 +1,15 @@ use anyhow::Result; use async_trait::async_trait; -use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::HashMap; -use std::time::Duration; use tauri::{command, State}; use crate::commands::agents::AgentDb; use crate::commands::relay_stations::{RelayStation, RelayStationAdapter}; +use crate::http_client; use crate::i18n; -// 创建HTTP客户端的辅助函数 -fn create_http_client() -> Client { - Client::builder() - .timeout(Duration::from_secs(10)) - .build() - .expect("Failed to create HTTP client") -} - /// 中转站信息 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StationInfo { @@ -143,7 +134,8 @@ impl StationAdapter for PackycodeAdapter { // PackyCode 使用简单的健康检查端点 let url = format!("{}/health", station.api_url.trim_end_matches('/')); - let client = create_http_client(); + let client = http_client::default_client() + .map_err(|e| anyhow::anyhow!("创建 HTTP 客户端失败: {}", e))?; let response = client .get(&url) .header("X-API-Key", &station.system_token) @@ -176,7 +168,8 @@ impl StationAdapter for PackycodeAdapter { // PackyCode 用户信息获取 let url = format!("{}/user/info", station.api_url.trim_end_matches('/')); - let client = create_http_client(); + let client = http_client::default_client() + .map_err(|e| anyhow::anyhow!("创建 HTTP 客户端失败: {}", e))?; let response = client .get(&url) .header("X-API-Key", &station.system_token) @@ -330,11 +323,12 @@ impl StationAdapter for CustomAdapter { let start_time = std::time::Instant::now(); // 尝试简单的 GET 请求测试连接 - let client = create_http_client(); + let client = http_client::create_client( + http_client::ClientConfig::new().timeout(5) + ).map_err(|e| anyhow::anyhow!("创建 HTTP 客户端失败: {}", e))?; let response = client .get(&station.api_url) .header("Authorization", format!("Bearer {}", station.system_token)) - .timeout(Duration::from_secs(5)) .send() .await; @@ -621,10 +615,7 @@ pub async fn packycode_get_user_quota( "https://www.packycode.com/api/backend/users/info" }; - let client = Client::builder() - .timeout(Duration::from_secs(30)) - .no_proxy() // 禁用所有代理 - .build() + let client = http_client::secure_client() .map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?; log::info!("正在请求 PackyCode 用户信息: {}", url); diff --git a/src-tauri/src/http_client.rs b/src-tauri/src/http_client.rs new file mode 100644 index 0000000..6b3908f --- /dev/null +++ b/src-tauri/src/http_client.rs @@ -0,0 +1,219 @@ +/// 公共 HTTP 客户端模块 +/// +/// 提供统一的 HTTP 客户端创建接口,消除代码重复 +/// 支持多种预设配置和自定义配置 + +use anyhow::Result; +use reqwest::Client; +use std::time::Duration; + +/// HTTP 客户端配置 +#[derive(Debug, Clone)] +pub struct ClientConfig { + /// 超时时间(秒) + pub timeout_secs: u64, + /// 是否接受无效证书(用于开发/测试) + pub accept_invalid_certs: bool, + /// 是否使用系统代理 + pub use_proxy: bool, + /// 自定义 User-Agent + pub user_agent: Option, +} + +impl Default for ClientConfig { + fn default() -> Self { + Self { + timeout_secs: 10, + accept_invalid_certs: false, + use_proxy: true, + user_agent: Some("Claudia/1.0".to_string()), + } + } +} + +impl ClientConfig { + /// 创建新的配置 + pub fn new() -> Self { + Self::default() + } + + /// 设置超时时间 + pub fn timeout(mut self, secs: u64) -> Self { + self.timeout_secs = secs; + self + } + + /// 设置是否接受无效证书 + pub fn accept_invalid_certs(mut self, accept: bool) -> Self { + self.accept_invalid_certs = accept; + self + } + + /// 设置是否使用代理 + pub fn use_proxy(mut self, use_proxy: bool) -> Self { + self.use_proxy = use_proxy; + self + } + + /// 设置 User-Agent + pub fn user_agent(mut self, user_agent: impl Into) -> Self { + self.user_agent = Some(user_agent.into()); + self + } +} + +/// 创建 HTTP 客户端(使用自定义配置) +/// +/// # Example +/// ``` +/// use claudia_lib::http_client::{ClientConfig, create_client}; +/// +/// let config = ClientConfig::new() +/// .timeout(5) +/// .accept_invalid_certs(true); +/// let client = create_client(config)?; +/// ``` +pub fn create_client(config: ClientConfig) -> Result { + let mut builder = Client::builder().timeout(Duration::from_secs(config.timeout_secs)); + + if config.accept_invalid_certs { + builder = builder.danger_accept_invalid_certs(true); + } + + if !config.use_proxy { + builder = builder.no_proxy(); + } + + if let Some(user_agent) = config.user_agent { + builder = builder.user_agent(user_agent); + } + + Ok(builder.build()?) +} + +/// 创建默认 HTTP 客户端 +/// +/// 配置: +/// - 超时: 10 秒 +/// - 接受无效证书: 否 +/// - 使用代理: 是 +/// - User-Agent: "Claudia/1.0" +/// +/// # Example +/// ``` +/// use claudia_lib::http_client::default_client; +/// +/// let client = default_client()?; +/// ``` +pub fn default_client() -> Result { + create_client(ClientConfig::default()) +} + +/// 创建快速客户端(用于节点测速) +/// +/// 配置: +/// - 超时: 3 秒 +/// - 接受无效证书: 是 +/// - 使用代理: 是 +/// - User-Agent: "Claudia/1.0" +/// +/// # Example +/// ``` +/// use claudia_lib::http_client::fast_client; +/// +/// let client = fast_client()?; +/// ``` +pub fn fast_client() -> Result { + create_client( + ClientConfig::default() + .timeout(3) + .accept_invalid_certs(true), + ) +} + +/// 创建安全客户端(用于 PackyCode API) +/// +/// 配置: +/// - 超时: 30 秒 +/// - 接受无效证书: 否 +/// - 使用代理: 否(禁用代理) +/// - User-Agent: "Claudia" +/// +/// # Example +/// ``` +/// use claudia_lib::http_client::secure_client; +/// +/// let client = secure_client()?; +/// ``` +pub fn secure_client() -> Result { + create_client( + ClientConfig::default() + .timeout(30) + .use_proxy(false) + .user_agent("Claudia"), + ) +} + +/// 创建长超时客户端(用于大文件传输等) +/// +/// 配置: +/// - 超时: 60 秒 +/// - 接受无效证书: 否 +/// - 使用代理: 是 +/// - User-Agent: "Claudia/1.0" +pub fn long_timeout_client() -> Result { + create_client(ClientConfig::default().timeout(60)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = ClientConfig::default(); + assert_eq!(config.timeout_secs, 10); + assert!(!config.accept_invalid_certs); + assert!(config.use_proxy); + assert_eq!(config.user_agent, Some("Claudia/1.0".to_string())); + } + + #[test] + fn test_config_builder() { + let config = ClientConfig::new() + .timeout(5) + .accept_invalid_certs(true) + .use_proxy(false) + .user_agent("TestAgent"); + + assert_eq!(config.timeout_secs, 5); + assert!(config.accept_invalid_certs); + assert!(!config.use_proxy); + assert_eq!(config.user_agent, Some("TestAgent".to_string())); + } + + #[test] + fn test_create_default_client() { + let result = default_client(); + assert!(result.is_ok()); + } + + #[test] + fn test_create_fast_client() { + let result = fast_client(); + assert!(result.is_ok()); + } + + #[test] + fn test_create_secure_client() { + let result = secure_client(); + assert!(result.is_ok()); + } + + #[test] + fn test_create_custom_client() { + let config = ClientConfig::new().timeout(15).use_proxy(false); + let result = create_client(config); + assert!(result.is_ok()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 32c0626..eefc111 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,8 +6,11 @@ pub mod claude_binary; pub mod claude_config; pub mod commands; pub mod file_watcher; +pub mod http_client; pub mod i18n; pub mod process; +pub mod types; +pub mod utils; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 6ef0725..7bcc79d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -6,8 +6,11 @@ mod claude_binary; mod claude_config; mod commands; mod file_watcher; +mod http_client; mod i18n; mod process; +mod types; +mod utils; use checkpoint::state::CheckpointState; use commands::agents::{ diff --git a/src-tauri/src/types/mod.rs b/src-tauri/src/types/mod.rs new file mode 100644 index 0000000..d9a136e --- /dev/null +++ b/src-tauri/src/types/mod.rs @@ -0,0 +1,2 @@ +/// 节点测试相关类型定义 +pub mod node_test; diff --git a/src-tauri/src/types/node_test.rs b/src-tauri/src/types/node_test.rs new file mode 100644 index 0000000..df100bd --- /dev/null +++ b/src-tauri/src/types/node_test.rs @@ -0,0 +1,367 @@ +/// 节点测试数据结构 +/// +/// 统一的节点连通性测试结果类型,用于替代分散在各模块的重复定义 + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// 节点测试状态 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TestStatus { + /// 测试成功 + Success, + /// 测试失败 + Failure, + /// 超时 + Timeout, +} + +impl TestStatus { + /// 判断测试是否成功 + pub fn is_success(&self) -> bool { + matches!(self, TestStatus::Success) + } + + /// 判断测试是否失败 + pub fn is_failure(&self) -> bool { + !self.is_success() + } + + /// 转换为字符串 + pub fn as_str(&self) -> &'static str { + match self { + TestStatus::Success => "success", + TestStatus::Failure => "failure", + TestStatus::Timeout => "timeout", + } + } +} + +/// 统一的节点测试结果 +/// +/// 整合了之前分散在 relay_adapters.rs, api_nodes.rs, packycode_nodes.rs 中的 +/// ConnectionTestResult 和 NodeTestResult +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeTestResult { + /// 节点 ID(可选,用于数据库查询) + #[serde(skip_serializing_if = "Option::is_none")] + pub node_id: Option, + + /// 节点名称(可选,用于显示) + #[serde(skip_serializing_if = "Option::is_none")] + pub node_name: Option, + + /// 节点 URL + pub url: String, + + /// 测试状态 + pub status: TestStatus, + + /// 响应时间(毫秒) + #[serde(skip_serializing_if = "Option::is_none")] + pub response_time_ms: Option, + + /// 状态消息 + pub message: String, + + /// 错误详情(失败时提供) + #[serde(skip_serializing_if = "Option::is_none")] + pub error_details: Option, + + /// 额外元数据(用于扩展) + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +impl NodeTestResult { + /// 创建成功的测试结果 + /// + /// # Example + /// ``` + /// use claudia_lib::types::node_test::NodeTestResult; + /// + /// let result = NodeTestResult::success( + /// "https://api.example.com".to_string(), + /// 150 + /// ); + /// assert!(result.status.is_success()); + /// assert_eq!(result.response_time_ms, Some(150)); + /// ``` + pub fn success(url: String, response_time: u64) -> Self { + Self { + node_id: None, + node_name: None, + url, + status: TestStatus::Success, + response_time_ms: Some(response_time), + message: "连接成功".to_string(), + error_details: None, + metadata: None, + } + } + + /// 创建成功的测试结果(带自定义消息) + pub fn success_with_message(url: String, response_time: u64, message: String) -> Self { + Self { + node_id: None, + node_name: None, + url, + status: TestStatus::Success, + response_time_ms: Some(response_time), + message, + error_details: None, + metadata: None, + } + } + + /// 创建失败的测试结果 + /// + /// # Example + /// ``` + /// use claudia_lib::types::node_test::NodeTestResult; + /// + /// let result = NodeTestResult::failure( + /// "https://api.example.com".to_string(), + /// "Connection refused".to_string() + /// ); + /// assert!(result.status.is_failure()); + /// ``` + pub fn failure(url: String, error: String) -> Self { + Self { + node_id: None, + node_name: None, + url, + status: TestStatus::Failure, + response_time_ms: None, + message: "连接失败".to_string(), + error_details: Some(error), + metadata: None, + } + } + + /// 创建失败的测试结果(带响应时间) + pub fn failure_with_time(url: String, response_time: u64, error: String) -> Self { + Self { + node_id: None, + node_name: None, + url, + status: TestStatus::Failure, + response_time_ms: Some(response_time), + message: "连接失败".to_string(), + error_details: Some(error), + metadata: None, + } + } + + /// 创建超时的测试结果 + /// + /// # Example + /// ``` + /// use claudia_lib::types::node_test::NodeTestResult; + /// + /// let result = NodeTestResult::timeout( + /// "https://api.example.com".to_string(), + /// 5000 + /// ); + /// assert_eq!(result.status, TestStatus::Timeout); + /// ``` + pub fn timeout(url: String, timeout_ms: u64) -> Self { + Self { + node_id: None, + node_name: None, + url, + status: TestStatus::Timeout, + response_time_ms: Some(timeout_ms), + message: "连接超时".to_string(), + error_details: Some(format!("请求超过 {} 毫秒未响应", timeout_ms)), + metadata: None, + } + } + + /// 设置节点 ID + pub fn with_node_id(mut self, node_id: String) -> Self { + self.node_id = Some(node_id); + self + } + + /// 设置节点名称 + pub fn with_node_name(mut self, node_name: String) -> Self { + self.node_name = Some(node_name); + self + } + + /// 设置元数据 + pub fn with_metadata(mut self, metadata: HashMap) -> Self { + self.metadata = Some(metadata); + self + } + + /// 添加单个元数据项 + pub fn add_metadata(mut self, key: String, value: serde_json::Value) -> Self { + if self.metadata.is_none() { + self.metadata = Some(HashMap::new()); + } + if let Some(ref mut meta) = self.metadata { + meta.insert(key, value); + } + self + } + + /// 判断测试是否成功 + pub fn is_success(&self) -> bool { + self.status.is_success() + } + + /// 判断测试是否失败 + pub fn is_failure(&self) -> bool { + self.status.is_failure() + } + + /// 获取响应时间(如果有) + pub fn response_time(&self) -> Option { + self.response_time_ms + } + + /// 获取错误信息(如果有) + pub fn error(&self) -> Option<&str> { + self.error_details.as_deref() + } +} + +/// 批量测试结果统计 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchTestSummary { + /// 总测试数 + pub total: usize, + /// 成功数 + pub success: usize, + /// 失败数 + pub failure: usize, + /// 超时数 + pub timeout: usize, + /// 平均响应时间(毫秒) + pub avg_response_time: Option, + /// 最快响应时间(毫秒) + pub min_response_time: Option, + /// 最慢响应时间(毫秒) + pub max_response_time: Option, +} + +impl BatchTestSummary { + /// 从测试结果列表生成统计摘要 + pub fn from_results(results: &[NodeTestResult]) -> Self { + let total = results.len(); + let success = results.iter().filter(|r| r.is_success()).count(); + let timeout = results + .iter() + .filter(|r| r.status == TestStatus::Timeout) + .count(); + let failure = total - success; + + let response_times: Vec = results + .iter() + .filter_map(|r| r.response_time_ms) + .collect(); + + let avg_response_time = if !response_times.is_empty() { + let sum: u64 = response_times.iter().sum(); + Some(sum as f64 / response_times.len() as f64) + } else { + None + }; + + let min_response_time = response_times.iter().copied().min(); + let max_response_time = response_times.iter().copied().max(); + + Self { + total, + success, + failure, + timeout, + avg_response_time, + min_response_time, + max_response_time, + } + } + + /// 获取成功率(百分比) + pub fn success_rate(&self) -> f64 { + if self.total == 0 { + 0.0 + } else { + (self.success as f64 / self.total as f64) * 100.0 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_success_result() { + let result = NodeTestResult::success("https://api.example.com".to_string(), 150); + assert!(result.is_success()); + assert_eq!(result.response_time(), Some(150)); + assert!(result.error().is_none()); + assert_eq!(result.status, TestStatus::Success); + } + + #[test] + fn test_failure_result() { + let result = NodeTestResult::failure( + "https://api.example.com".to_string(), + "Connection refused".to_string(), + ); + assert!(result.is_failure()); + assert_eq!(result.error(), Some("Connection refused")); + assert_eq!(result.status, TestStatus::Failure); + } + + #[test] + fn test_timeout_result() { + let result = NodeTestResult::timeout("https://api.example.com".to_string(), 5000); + assert_eq!(result.status, TestStatus::Timeout); + assert!(result.error().is_some()); + } + + #[test] + fn test_builder_pattern() { + let result = NodeTestResult::success("https://api.example.com".to_string(), 100) + .with_node_id("node-123".to_string()) + .with_node_name("Test Node".to_string()) + .add_metadata("region".to_string(), serde_json::json!("us-west")); + + assert_eq!(result.node_id, Some("node-123".to_string())); + assert_eq!(result.node_name, Some("Test Node".to_string())); + assert!(result.metadata.is_some()); + } + + #[test] + fn test_batch_summary() { + let results = vec![ + NodeTestResult::success("http://1".to_string(), 100), + NodeTestResult::success("http://2".to_string(), 200), + NodeTestResult::failure("http://3".to_string(), "error".to_string()), + NodeTestResult::timeout("http://4".to_string(), 5000), + ]; + + let summary = BatchTestSummary::from_results(&results); + assert_eq!(summary.total, 4); + assert_eq!(summary.success, 2); + assert_eq!(summary.failure, 2); + assert_eq!(summary.timeout, 1); + assert_eq!(summary.success_rate(), 50.0); + assert!(summary.avg_response_time.is_some()); + } + + #[test] + fn test_test_status() { + assert!(TestStatus::Success.is_success()); + assert!(!TestStatus::Failure.is_success()); + assert!(TestStatus::Failure.is_failure()); + assert_eq!(TestStatus::Success.as_str(), "success"); + assert_eq!(TestStatus::Timeout.as_str(), "timeout"); + } +} diff --git a/src-tauri/src/utils/error.rs b/src-tauri/src/utils/error.rs new file mode 100644 index 0000000..975aee2 --- /dev/null +++ b/src-tauri/src/utils/error.rs @@ -0,0 +1,235 @@ +/// 错误处理工具模块 +/// +/// 提供统一的错误转换函数,减少样板代码 + +use anyhow::Result; + +/// 将 anyhow::Result 转换为 Result +/// +/// 这是最常用的错误转换函数,用于 Tauri 命令的返回值 +/// +/// # Example +/// ``` +/// use claudia_lib::utils::error::to_string_error; +/// use anyhow::Result; +/// +/// fn some_operation() -> Result { +/// Ok("success".to_string()) +/// } +/// +/// #[tauri::command] +/// async fn my_command() -> Result { +/// to_string_error(some_operation()) +/// } +/// ``` +pub fn to_string_error(result: Result) -> Result { + result.map_err(|e| e.to_string()) +} + +/// 将 anyhow::Result 转换为 Result,并添加上下文信息 +/// +/// # Example +/// ``` +/// use claudia_lib::utils::error::to_string_error_ctx; +/// use anyhow::Result; +/// +/// fn database_operation() -> Result { +/// Ok("data".to_string()) +/// } +/// +/// #[tauri::command] +/// async fn get_data() -> Result { +/// to_string_error_ctx( +/// database_operation(), +/// "获取数据失败" +/// ) +/// } +/// ``` +pub fn to_string_error_ctx(result: Result, context: &str) -> Result { + result.map_err(|e| format!("{}: {}", context, e)) +} + +/// 将 rusqlite::Error 转换为用户友好的错误消息 +/// +/// # Example +/// ``` +/// use claudia_lib::utils::error::db_error_to_string; +/// use rusqlite::{Connection, Error}; +/// +/// fn query_database() -> Result { +/// let conn = Connection::open("test.db") +/// .map_err(db_error_to_string)?; +/// // ... +/// Ok("result".to_string()) +/// } +/// ``` +pub fn db_error_to_string(e: rusqlite::Error) -> String { + match e { + rusqlite::Error::QueryReturnedNoRows => "查询未返回任何行".to_string(), + rusqlite::Error::SqliteFailure(err, msg) => { + let code = err.extended_code; + let description = msg.unwrap_or_else(|| "未知数据库错误".to_string()); + format!("数据库错误 (代码 {}): {}", code, description) + } + rusqlite::Error::InvalidColumnType(idx, name, type_) => { + format!("列类型错误: 列 {} (索引 {}) 的类型为 {:?}", name, idx, type_) + } + rusqlite::Error::InvalidColumnIndex(idx) => { + format!("无效的列索引: {}", idx) + } + rusqlite::Error::InvalidColumnName(name) => { + format!("无效的列名: {}", name) + } + rusqlite::Error::ExecuteReturnedResults => "执行语句返回了结果(应使用查询)".to_string(), + rusqlite::Error::InvalidQuery => "无效的查询语句".to_string(), + _ => format!("数据库错误: {}", e), + } +} + +/// 将 reqwest::Error 转换为用户友好的错误消息 +/// +/// # Example +/// ``` +/// use claudia_lib::utils::error::http_error_to_string; +/// use reqwest::Error; +/// +/// async fn fetch_data(url: &str) -> Result { +/// let response = reqwest::get(url) +/// .await +/// .map_err(http_error_to_string)?; +/// response.text().await.map_err(http_error_to_string) +/// } +/// ``` +pub fn http_error_to_string(e: reqwest::Error) -> String { + if e.is_timeout() { + format!("请求超时: {}", e) + } else if e.is_connect() { + format!("连接失败: {}", e) + } else if e.is_status() { + format!( + "HTTP 错误: {}", + e.status() + .map(|s| s.to_string()) + .unwrap_or_else(|| "未知状态".to_string()) + ) + } else if e.is_decode() { + format!("解码响应失败: {}", e) + } else if e.is_request() { + format!("构建请求失败: {}", e) + } else { + format!("HTTP 请求错误: {}", e) + } +} + +/// 将 serde_json::Error 转换为用户友好的错误消息 +pub fn json_error_to_string(e: serde_json::Error) -> String { + format!("JSON 解析错误: {}", e) +} + +/// 将 std::io::Error 转换为用户友好的错误消息 +pub fn io_error_to_string(e: std::io::Error) -> String { + use std::io::ErrorKind; + + match e.kind() { + ErrorKind::NotFound => format!("文件或目录不存在: {}", e), + ErrorKind::PermissionDenied => format!("权限不足: {}", e), + ErrorKind::AlreadyExists => format!("文件或目录已存在: {}", e), + ErrorKind::WouldBlock => "操作将会阻塞".to_string(), + ErrorKind::InvalidInput => format!("无效的输入: {}", e), + ErrorKind::InvalidData => format!("无效的数据: {}", e), + ErrorKind::TimedOut => "操作超时".to_string(), + ErrorKind::WriteZero => "无法写入数据".to_string(), + ErrorKind::Interrupted => "操作被中断".to_string(), + ErrorKind::UnexpectedEof => "意外的文件结束".to_string(), + _ => format!("IO 错误: {}", e), + } +} + +/// 组合多个错误消息 +/// +/// # Example +/// ``` +/// use claudia_lib::utils::error::combine_errors; +/// +/// let errors = vec![ +/// "错误 1: 连接失败".to_string(), +/// "错误 2: 超时".to_string(), +/// ]; +/// let combined = combine_errors(&errors); +/// // 输出: "发生 2 个错误: 错误 1: 连接失败; 错误 2: 超时" +/// ``` +pub fn combine_errors(errors: &[String]) -> String { + if errors.is_empty() { + "无错误".to_string() + } else if errors.len() == 1 { + errors[0].clone() + } else { + format!("发生 {} 个错误: {}", errors.len(), errors.join("; ")) + } +} + +/// 创建带前缀的错误消息 +pub fn prefixed_error(prefix: &str, error: &str) -> String { + format!("{}: {}", prefix, error) +} + +/// 为错误添加建议 +pub fn error_with_suggestion(error: &str, suggestion: &str) -> String { + format!("{}。建议: {}", error, suggestion) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::anyhow; + + #[test] + fn test_to_string_error() { + let result: Result = Err(anyhow!("测试错误")); + let converted = to_string_error(result); + assert!(converted.is_err()); + assert_eq!(converted.unwrap_err(), "测试错误"); + } + + #[test] + fn test_to_string_error_ctx() { + let result: Result = Err(anyhow!("原始错误")); + let converted = to_string_error_ctx(result, "操作失败"); + assert!(converted.is_err()); + assert_eq!(converted.unwrap_err(), "操作失败: 原始错误"); + } + + #[test] + fn test_db_error_to_string() { + let error = rusqlite::Error::QueryReturnedNoRows; + assert_eq!(db_error_to_string(error), "查询未返回任何行"); + } + + #[test] + fn test_combine_errors() { + let errors = vec!["错误1".to_string(), "错误2".to_string()]; + let combined = combine_errors(&errors); + assert!(combined.contains("错误1")); + assert!(combined.contains("错误2")); + assert!(combined.contains("2 个错误")); + } + + #[test] + fn test_prefixed_error() { + let error = prefixed_error("数据库", "连接失败"); + assert_eq!(error, "数据库: 连接失败"); + } + + #[test] + fn test_error_with_suggestion() { + let error = error_with_suggestion("无法连接到服务器", "检查网络连接"); + assert_eq!(error, "无法连接到服务器。建议: 检查网络连接"); + } + + #[test] + fn test_io_error_conversions() { + let error = std::io::Error::new(std::io::ErrorKind::NotFound, "文件不存在"); + let converted = io_error_to_string(error); + assert!(converted.contains("文件或目录不存在")); + } +} diff --git a/src-tauri/src/utils/mod.rs b/src-tauri/src/utils/mod.rs new file mode 100644 index 0000000..0280315 --- /dev/null +++ b/src-tauri/src/utils/mod.rs @@ -0,0 +1,3 @@ +/// 工具函数模块 +pub mod error; +pub mod node_tester; diff --git a/src-tauri/src/utils/node_tester.rs b/src-tauri/src/utils/node_tester.rs new file mode 100644 index 0000000..d237438 --- /dev/null +++ b/src-tauri/src/utils/node_tester.rs @@ -0,0 +1,264 @@ +/// 通用节点测试器 +/// +/// 提供统一的节点连通性测试功能,替代分散在各模块的重复实现 + +use crate::http_client::{self, ClientConfig}; +use crate::types::node_test::{NodeTestResult, TestStatus}; +use std::time::Instant; + +/// 测试单个节点的连通性 +/// +/// 使用 HEAD 请求测试节点是否可访问,这是最轻量的测试方式 +/// +/// # Arguments +/// * `url` - 节点 URL +/// * `timeout_ms` - 超时时间(毫秒) +/// +/// # Example +/// ``` +/// use claudia_lib::utils::node_tester::test_node_connectivity; +/// +/// #[tokio::main] +/// async fn main() { +/// let result = test_node_connectivity("https://api.example.com", 5000).await; +/// if result.is_success() { +/// println!("节点可用,响应时间: {}ms", result.response_time().unwrap()); +/// } +/// } +/// ``` +pub async fn test_node_connectivity(url: &str, timeout_ms: u64) -> NodeTestResult { + let start = Instant::now(); + + // 创建快速客户端 + let client = match http_client::create_client( + ClientConfig::new() + .timeout(timeout_ms / 1000) + .accept_invalid_certs(true), // 节点测速允许自签名证书 + ) { + Ok(c) => c, + Err(e) => { + return NodeTestResult::failure( + url.to_string(), + format!("创建 HTTP 客户端失败: {}", e), + ); + } + }; + + // 使用 HEAD 请求测试连通性 + match client.head(url).send().await { + Ok(response) => { + let response_time = start.elapsed().as_millis() as u64; + let status_code = response.status(); + + // 2xx, 3xx, 4xx 都视为成功(说明服务器在线) + // 只有 5xx 或网络错误才视为失败 + if status_code.is_success() + || status_code.is_redirection() + || status_code.is_client_error() + { + NodeTestResult::success_with_message( + url.to_string(), + response_time, + format!("连接成功 (HTTP {})", status_code.as_u16()), + ) + } else { + NodeTestResult::failure_with_time( + url.to_string(), + response_time, + format!("服务器错误 (HTTP {})", status_code.as_u16()), + ) + } + } + Err(e) => { + let response_time = start.elapsed().as_millis() as u64; + + // 根据错误类型返回不同的结果 + if e.is_timeout() { + NodeTestResult::timeout(url.to_string(), response_time) + } else if e.is_connect() { + NodeTestResult::failure_with_time( + url.to_string(), + response_time, + format!("无法连接到服务器: {}", e), + ) + } else { + NodeTestResult::failure_with_time( + url.to_string(), + response_time, + format!("网络错误: {}", e), + ) + } + } + } +} + +/// 批量测试节点连通性(并发) +/// +/// 同时测试多个节点,提高测试效率 +/// +/// # Arguments +/// * `urls` - 节点 URL 列表 +/// * `timeout_ms` - 每个节点的超时时间(毫秒) +/// +/// # Example +/// ``` +/// use claudia_lib::utils::node_tester::test_nodes_batch; +/// +/// #[tokio::main] +/// async fn main() { +/// let urls = vec![ +/// "https://api1.example.com".to_string(), +/// "https://api2.example.com".to_string(), +/// ]; +/// let results = test_nodes_batch(urls, 5000).await; +/// println!("测试完成,成功: {}", results.iter().filter(|r| r.is_success()).count()); +/// } +/// ``` +pub async fn test_nodes_batch(urls: Vec, timeout_ms: u64) -> Vec { + // 创建所有测试任务 + let futures: Vec<_> = urls + .iter() + .map(|url| test_node_connectivity(url, timeout_ms)) + .collect(); + + // 并发执行所有测试 + futures::future::join_all(futures).await +} + +/// 批量测试节点连通性(顺序) +/// +/// 按顺序测试节点,适用于需要限制并发的场景 +/// +/// # Arguments +/// * `urls` - 节点 URL 列表 +/// * `timeout_ms` - 每个节点的超时时间(毫秒) +pub async fn test_nodes_sequential(urls: Vec, timeout_ms: u64) -> Vec { + let mut results = Vec::new(); + + for url in urls { + let result = test_node_connectivity(&url, timeout_ms).await; + results.push(result); + } + + results +} + +/// 查找最快的节点 +/// +/// 从测试结果中找出响应时间最短的成功节点 +/// +/// # Example +/// ``` +/// use claudia_lib::utils::node_tester::{test_nodes_batch, find_fastest_node}; +/// +/// #[tokio::main] +/// async fn main() { +/// let urls = vec!["https://api1.com".to_string(), "https://api2.com".to_string()]; +/// let results = test_nodes_batch(urls, 5000).await; +/// if let Some(fastest) = find_fastest_node(&results) { +/// println!("最快节点: {}, 响应时间: {}ms", fastest.url, fastest.response_time().unwrap()); +/// } +/// } +/// ``` +pub fn find_fastest_node(results: &[NodeTestResult]) -> Option<&NodeTestResult> { + results + .iter() + .filter(|r| r.is_success() && r.response_time().is_some()) + .min_by_key(|r| r.response_time().unwrap()) +} + +/// 过滤成功的节点 +pub fn filter_successful_nodes(results: &[NodeTestResult]) -> Vec<&NodeTestResult> { + results.iter().filter(|r| r.is_success()).collect() +} + +/// 过滤失败的节点 +pub fn filter_failed_nodes(results: &[NodeTestResult]) -> Vec<&NodeTestResult> { + results.iter().filter(|r| r.is_failure()).collect() +} + +/// 按响应时间排序(从快到慢) +pub fn sort_by_response_time(results: &mut [NodeTestResult]) { + results.sort_by(|a, b| { + // 成功的节点优先 + match (a.status, b.status) { + (TestStatus::Success, TestStatus::Success) => { + // 都成功,按响应时间排序 + match (a.response_time_ms, b.response_time_ms) { + (Some(t1), Some(t2)) => t1.cmp(&t2), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + } + } + (TestStatus::Success, _) => std::cmp::Ordering::Less, + (_, TestStatus::Success) => std::cmp::Ordering::Greater, + _ => { + // 都失败,按响应时间排序 + match (a.response_time_ms, b.response_time_ms) { + (Some(t1), Some(t2)) => t1.cmp(&t2), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + } + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_invalid_url() { + let result = test_node_connectivity("invalid-url", 1000).await; + assert!(result.is_failure()); + } + + #[test] + fn test_sort_by_response_time() { + let mut results = vec![ + NodeTestResult::success("http://3".to_string(), 300), + NodeTestResult::success("http://1".to_string(), 100), + NodeTestResult::failure("http://4".to_string(), "error".to_string()), + NodeTestResult::success("http://2".to_string(), 200), + ]; + + sort_by_response_time(&mut results); + + // 成功的节点应该在前面,且按响应时间排序 + assert_eq!(results[0].url, "http://1"); + assert_eq!(results[1].url, "http://2"); + assert_eq!(results[2].url, "http://3"); + assert_eq!(results[3].url, "http://4"); + } + + #[test] + fn test_find_fastest_node() { + let results = vec![ + NodeTestResult::success("http://1".to_string(), 200), + NodeTestResult::success("http://2".to_string(), 100), // 最快 + NodeTestResult::failure("http://3".to_string(), "error".to_string()), + ]; + + let fastest = find_fastest_node(&results); + assert!(fastest.is_some()); + assert_eq!(fastest.unwrap().url, "http://2"); + } + + #[test] + fn test_filter_nodes() { + let results = vec![ + NodeTestResult::success("http://1".to_string(), 100), + NodeTestResult::failure("http://2".to_string(), "error".to_string()), + NodeTestResult::success("http://3".to_string(), 200), + ]; + + let successful = filter_successful_nodes(&results); + assert_eq!(successful.len(), 2); + + let failed = filter_failed_nodes(&results); + assert_eq!(failed.len(), 1); + } +} diff --git a/src/components/NodeManager/index.tsx b/src/components/NodeManager/index.tsx index 620d024..a136fd1 100644 --- a/src/components/NodeManager/index.tsx +++ b/src/components/NodeManager/index.tsx @@ -48,7 +48,7 @@ export const NodeSelector: React.FC = ({ value = '', onChange, allowManualInput = true, - showToast = (msg, _type) => alert(msg), // 默认使用 alert + showToast = (msg, _type) => console.log(msg), // 默认使用 console.log }) => { const [showDialog, setShowDialog] = useState(false); const [nodes, setNodes] = useState([]); @@ -191,7 +191,7 @@ const NodeManagerDialog: React.FC = ({ adapter: filterAdapter, onSelectNode, currentUrl, - showToast = (msg) => alert(msg), + showToast = (msg) => console.log(msg), }) => { const [nodes, setNodes] = useState([]); const [loading, setLoading] = useState(false); @@ -267,12 +267,17 @@ const NodeManagerDialog: React.FC = ({ }; const handleDelete = async (node: ApiNode) => { - if (!confirm(`确定要删除节点 "${node.name}" 吗?`)) return; - try { await api.deleteApiNode(node.id); + // 直接从列表中移除,不重新加载 + setNodes(prev => prev.filter(n => n.id !== node.id)); + // 同时移除测试结果 + setTestResults(prev => { + const newResults = { ...prev }; + delete newResults[node.id]; + return newResults; + }); showToast('删除成功', 'success'); - loadNodes(); } catch (error) { showToast('删除失败', 'error'); console.error(error); @@ -492,7 +497,7 @@ const NodeFormDialog: React.FC = ({ node, defaultAdapter, onSuccess, - showToast = (msg) => alert(msg), + showToast = (msg) => console.log(msg), }) => { const [submitting, setSubmitting] = useState(false); const [formData, setFormData] = useState({ diff --git a/src/components/RelayStationManager.tsx b/src/components/RelayStationManager.tsx index defda65..e1a2707 100644 --- a/src/components/RelayStationManager.tsx +++ b/src/components/RelayStationManager.tsx @@ -29,7 +29,6 @@ import { UpdateRelayStationRequest, RelayStationAdapter, AuthMethod, - PackycodeUserQuota, ImportResult, api } from '@/lib/api'; @@ -110,10 +109,6 @@ const RelayStationManager: React.FC = ({ onBack }) => const [importProgress, setImportProgress] = useState(0); const [importResult, setImportResult] = useState(null); - // PackyCode 额度相关状态 - const [quotaData, setQuotaData] = useState>({}); - const [loadingQuota, setLoadingQuota] = useState>({}); - // 拖拽状态 const [activeStation, setActiveStation] = useState(null); @@ -339,20 +334,6 @@ const RelayStationManager: React.FC = ({ onBack }) => }; - // 查询 PackyCode 额度 - const fetchPackycodeQuota = async (stationId: string) => { - try { - setLoadingQuota(prev => ({ ...prev, [stationId]: true })); - const quota = await api.getPackycodeUserQuota(stationId); - setQuotaData(prev => ({ ...prev, [stationId]: quota })); - } catch (error) { - console.error('Failed to fetch PackyCode quota:', error); - // 不显示错误 Toast,因为可能是出租车服务或 Token 无效 - } finally { - setLoadingQuota(prev => ({ ...prev, [stationId]: false })); - } - }; - // 导出中转站配置 const handleExportStations = async () => { try { @@ -544,15 +525,6 @@ const RelayStationManager: React.FC = ({ onBack }) => loadCurrentConfig(); }, []); - // 当中转站加载完成后,自动获取所有 PackyCode 站点的额度 - useEffect(() => { - stations.forEach(station => { - if (station.adapter === 'packycode') { - fetchPackycodeQuota(station.id); - } - }); - }, [stations]); - return (
@@ -923,8 +895,6 @@ const RelayStationManager: React.FC = ({ onBack }) => setSelectedStation={handleSelectStation} setShowEditDialog={setShowEditDialog} openDeleteDialog={openDeleteDialog} - quotaData={quotaData} - loadingQuota={loadingQuota} />) )}
diff --git a/src/components/SortableStationItem.tsx b/src/components/SortableStationItem.tsx index 3353f72..fab3270 100644 --- a/src/components/SortableStationItem.tsx +++ b/src/components/SortableStationItem.tsx @@ -3,7 +3,6 @@ import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; import { Edit, Trash2, @@ -15,7 +14,6 @@ import { import { RelayStation, RelayStationAdapter, - PackycodeUserQuota, } from '@/lib/api'; interface SortableStationItemProps { @@ -25,8 +23,6 @@ interface SortableStationItemProps { setSelectedStation: (station: RelayStation) => void; setShowEditDialog: (show: boolean) => void; openDeleteDialog: (station: RelayStation) => void; - quotaData: Record; - loadingQuota: Record; } /** @@ -40,8 +36,6 @@ export const SortableStationItem: React.FC = ({ setSelectedStation, setShowEditDialog, openDeleteDialog, - quotaData, - loadingQuota, }) => { const { attributes, @@ -71,7 +65,7 @@ export const SortableStationItem: React.FC = ({ }; // 是否有详情内容需要显示 - const hasDetails = station.description || station.adapter === 'packycode'; + const hasDetails = station.description; return ( = ({ {station.description}

)} - - {/* PackyCode 额度显示 */} - {station.adapter === 'packycode' && ( -
- {loadingQuota[station.id] ? ( -
-
- 加载中... -
- ) : quotaData[station.id] ? ( -
- {/* 用户信息和计划 */} -
-
- {quotaData[station.id].username && ( - {quotaData[station.id].username} - )} - - {quotaData[station.id].plan_type.toUpperCase()} - - {quotaData[station.id].opus_enabled && ( - - Opus - - )} -
-
- - {/* 账户余额 */} -
- 余额: - - ${Number(quotaData[station.id].balance_usd).toFixed(2)} - -
- - {/* 日额度 */} -
-
- 日额度: -
- {(() => { - const daily_spent = Number(quotaData[station.id].daily_spent_usd); - const daily_budget = Number(quotaData[station.id].daily_budget_usd); - return ( - <> - daily_budget * 0.8 ? 'text-orange-600' : 'text-green-600'}> - ${daily_spent.toFixed(2)} - - / ${daily_budget.toFixed(2)} - - ); - })()} -
-
-
-
{ - const daily_spent = Number(quotaData[station.id].daily_spent_usd); - const daily_budget = Number(quotaData[station.id].daily_budget_usd); - return daily_spent / daily_budget > 0.8; - })() ? 'bg-orange-500' : 'bg-green-500' - }`} - style={{ width: `${Math.min( - (() => { - const daily_spent = Number(quotaData[station.id].daily_spent_usd); - const daily_budget = Number(quotaData[station.id].daily_budget_usd); - return (daily_spent / daily_budget) * 100; - })(), 100)}%` }} - /> -
-
- - {/* 月额度 */} -
-
- 月额度: -
- {(() => { - const monthly_spent = Number(quotaData[station.id].monthly_spent_usd); - const monthly_budget = Number(quotaData[station.id].monthly_budget_usd); - return ( - <> - monthly_budget * 0.8 ? 'text-orange-600' : 'text-green-600'}> - ${monthly_spent.toFixed(2)} - - / - ${monthly_budget.toFixed(2)} - - ); - })()} -
-
-
-
{ - const monthly_spent = Number(quotaData[station.id].monthly_spent_usd); - const monthly_budget = Number(quotaData[station.id].monthly_budget_usd); - return monthly_spent / monthly_budget > 0.8; - })() ? 'bg-orange-500' : 'bg-green-500' - }`} - style={{ width: `${Math.min( - (() => { - const monthly_spent = Number(quotaData[station.id].monthly_spent_usd); - const monthly_budget = Number(quotaData[station.id].monthly_budget_usd); - return (monthly_spent / monthly_budget) * 100; - })(), 100)}%` }} - /> -
-
- - {/* 总消费 */} -
- 总消费: - ${Number(quotaData[station.id].total_spent_usd).toFixed(2)} -
-
- ) : ( -
- 额度信息加载失败 -
- )} -
- )} )}