抽离公共模块
Some checks failed
Build Linux / Build Linux x86_64 (push) Has been cancelled
Build Test / Build Test (Linux) (push) Has been cancelled
Build Test / Build Test (Windows) (push) Has been cancelled
Build Test / Build Test (macOS) (push) Has been cancelled
Build Test / Build Test Summary (push) Has been cancelled
Some checks failed
Build Linux / Build Linux x86_64 (push) Has been cancelled
Build Test / Build Test (Linux) (push) Has been cancelled
Build Test / Build Test (Windows) (push) Has been cancelled
Build Test / Build Test (macOS) (push) Has been cancelled
Build Test / Build Test Summary (push) Has been cancelled
This commit is contained in:
@@ -4,6 +4,11 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// 导入公共模块
|
||||||
|
use crate::http_client;
|
||||||
|
use crate::types::node_test::NodeTestResult;
|
||||||
|
use crate::utils::node_tester;
|
||||||
|
|
||||||
/// API 节点数据结构
|
/// API 节点数据结构
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ApiNode {
|
pub struct ApiNode {
|
||||||
@@ -36,17 +41,6 @@ pub struct UpdateApiNodeRequest {
|
|||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 节点测试结果
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct NodeTestResult {
|
|
||||||
pub node_id: String,
|
|
||||||
pub url: String,
|
|
||||||
pub name: String,
|
|
||||||
pub response_time: Option<u64>,
|
|
||||||
pub status: String,
|
|
||||||
pub error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取数据库连接
|
/// 获取数据库连接
|
||||||
fn get_connection() -> Result<Connection> {
|
fn get_connection() -> Result<Connection> {
|
||||||
let db_path = get_nodes_db_path()?;
|
let db_path = get_nodes_db_path()?;
|
||||||
@@ -333,50 +327,16 @@ pub async fn delete_api_node(id: String) -> Result<(), String> {
|
|||||||
/// 测试单个节点
|
/// 测试单个节点
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn test_api_node(url: String, timeout_ms: Option<u64>) -> Result<NodeTestResult, String> {
|
pub async fn test_api_node(url: String, timeout_ms: Option<u64>) -> Result<NodeTestResult, String> {
|
||||||
let timeout = std::time::Duration::from_millis(timeout_ms.unwrap_or(5000));
|
let timeout = timeout_ms.unwrap_or(5000);
|
||||||
let start = std::time::Instant::now();
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
// 使用公共节点测试器
|
||||||
.timeout(timeout)
|
let mut result = node_tester::test_node_connectivity(&url, timeout).await;
|
||||||
.build()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// 使用 HEAD 请求测试连通性,更轻量且不会触发 API 调用
|
// 添加节点 ID 和名称(如果有)
|
||||||
match client.head(&url).send().await {
|
result.node_id = Some(String::new());
|
||||||
Ok(response) => {
|
result.node_name = Some(String::new());
|
||||||
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"
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(NodeTestResult {
|
Ok(result)
|
||||||
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()),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 批量测试节点
|
/// 批量测试节点
|
||||||
@@ -386,15 +346,20 @@ pub async fn test_all_api_nodes(
|
|||||||
timeout_ms: Option<u64>,
|
timeout_ms: Option<u64>,
|
||||||
) -> Result<Vec<NodeTestResult>, String> {
|
) -> Result<Vec<NodeTestResult>, String> {
|
||||||
let nodes = list_api_nodes(adapter, Some(true)).await?;
|
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 {
|
// 提取所有节点的 URL
|
||||||
let result = test_api_node(node.url.clone(), timeout_ms).await?;
|
let urls: Vec<String> = nodes.iter().map(|n| n.url.clone()).collect();
|
||||||
results.push(NodeTestResult {
|
|
||||||
node_id: node.id.clone(),
|
// 使用公共节点测试器批量测试
|
||||||
name: node.name.clone(),
|
let mut results = node_tester::test_nodes_batch(urls, timeout).await;
|
||||||
..result
|
|
||||||
});
|
// 添加节点 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)
|
Ok(results)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use reqwest::Client;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
use tauri::command;
|
use tauri::command;
|
||||||
|
|
||||||
|
// 导入公共模块
|
||||||
|
use crate::types::node_test::{NodeTestResult, TestStatus};
|
||||||
|
use crate::utils::node_tester;
|
||||||
|
|
||||||
/// PackyCode 节点类型
|
/// PackyCode 节点类型
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@@ -24,15 +26,6 @@ pub struct PackycodeNode {
|
|||||||
pub available: Option<bool>, // 是否可用
|
pub available: Option<bool>, // 是否可用
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 节点测速结果
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct NodeSpeedTestResult {
|
|
||||||
pub node: PackycodeNode,
|
|
||||||
pub response_time: u64,
|
|
||||||
pub success: bool,
|
|
||||||
pub error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取所有 PackyCode 节点
|
/// 获取所有 PackyCode 节点
|
||||||
pub fn get_all_nodes() -> Vec<PackycodeNode> {
|
pub fn get_all_nodes() -> Vec<PackycodeNode> {
|
||||||
vec![
|
vec![
|
||||||
@@ -122,100 +115,37 @@ pub fn get_all_nodes() -> Vec<PackycodeNode> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 测试单个节点速度(仅测试网络延时,不需要认证)
|
/// 测试单个节点速度(仅测试网络延时,不需要认证)
|
||||||
async fn test_node_speed(node: &PackycodeNode) -> NodeSpeedTestResult {
|
async fn test_node_speed(node: &PackycodeNode) -> NodeTestResult {
|
||||||
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,只测试网络延迟
|
|
||||||
let url = format!("{}/", node.url.trim_end_matches('/'));
|
let url = format!("{}/", node.url.trim_end_matches('/'));
|
||||||
|
let mut result = node_tester::test_node_connectivity(&url, 3000).await;
|
||||||
|
|
||||||
match client
|
// 添加节点名称
|
||||||
.get(&url)
|
result.node_name = Some(node.name.clone());
|
||||||
.timeout(Duration::from_secs(3))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_response) => {
|
|
||||||
let response_time = start_time.elapsed().as_millis() as u64;
|
|
||||||
|
|
||||||
// 只要能连接到服务器就算成功(不管状态码)
|
result
|
||||||
// 因为我们只是测试延迟,不是测试 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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试所有节点速度(不需要 token,只测试延迟)
|
/// 测试所有节点速度(不需要 token,只测试延迟)
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn test_all_packycode_nodes() -> Result<Vec<NodeSpeedTestResult>, String> {
|
pub async fn test_all_packycode_nodes() -> Result<Vec<NodeTestResult>, String> {
|
||||||
let nodes = get_all_nodes();
|
let nodes = get_all_nodes();
|
||||||
let mut results = Vec::new();
|
let urls: Vec<String> = 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() {
|
for (i, result) in results.iter_mut().enumerate() {
|
||||||
let result = future.await;
|
if let Some(node) = nodes.get(i) {
|
||||||
log::info!(
|
result.node_name = Some(node.name.clone());
|
||||||
"节点 {} 测速结果: {}ms, 成功: {}",
|
}
|
||||||
nodes[i].name,
|
|
||||||
result.response_time,
|
|
||||||
result.success
|
|
||||||
);
|
|
||||||
results.push(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按响应时间排序(成功的节点优先,然后按延迟排序)
|
// 按响应时间排序(成功的节点优先)
|
||||||
results.sort_by(|a, b| match (a.success, b.success) {
|
node_tester::sort_by_response_time(&mut results);
|
||||||
(true, false) => std::cmp::Ordering::Less,
|
|
||||||
(false, true) => std::cmp::Ordering::Greater,
|
|
||||||
_ => a.response_time.cmp(&b.response_time),
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
@@ -224,7 +154,6 @@ pub async fn test_all_packycode_nodes() -> Result<Vec<NodeSpeedTestResult>, Stri
|
|||||||
#[command]
|
#[command]
|
||||||
pub async fn auto_select_best_node() -> Result<PackycodeNode, String> {
|
pub async fn auto_select_best_node() -> Result<PackycodeNode, String> {
|
||||||
let nodes = get_all_nodes();
|
let nodes = get_all_nodes();
|
||||||
let mut best_node: Option<(PackycodeNode, u64)> = None;
|
|
||||||
|
|
||||||
// 只测试直连和备用节点,过滤掉紧急节点
|
// 只测试直连和备用节点,过滤掉紧急节点
|
||||||
let test_nodes: Vec<_> = nodes
|
let test_nodes: Vec<_> = nodes
|
||||||
@@ -234,52 +163,36 @@ pub async fn auto_select_best_node() -> Result<PackycodeNode, String> {
|
|||||||
|
|
||||||
log::info!("开始测试 {} 个节点...", test_nodes.len());
|
log::info!("开始测试 {} 个节点...", test_nodes.len());
|
||||||
|
|
||||||
// 并发测试所有节点
|
// 提取 URL 列表
|
||||||
let futures: Vec<_> = test_nodes
|
let urls: Vec<String> = test_nodes
|
||||||
.iter()
|
.iter()
|
||||||
.map(|node| test_node_speed(node))
|
.map(|n| format!("{}/", n.url.trim_end_matches('/')))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// 收集结果并找出最佳节点
|
// 使用公共批量测试
|
||||||
for (i, future) in futures.into_iter().enumerate() {
|
let results = node_tester::test_nodes_batch(urls, 3000).await;
|
||||||
let result = future.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!(
|
log::info!(
|
||||||
"节点 {} - 延迟: {}ms, 可用: {}",
|
"最佳节点选择: {} (延迟: {}ms)",
|
||||||
test_nodes[i].name,
|
best_node.name,
|
||||||
result.response_time,
|
fastest.response_time_ms.unwrap_or(0)
|
||||||
result.success
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if result.success {
|
Ok(best_node)
|
||||||
match &best_node {
|
} else {
|
||||||
None => {
|
log::error!("没有找到可用的节点");
|
||||||
log::info!("初始最佳节点: {}", result.node.name);
|
Err("没有找到可用的节点".to_string())
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,15 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use reqwest::Client;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
|
||||||
use tauri::{command, State};
|
use tauri::{command, State};
|
||||||
|
|
||||||
use crate::commands::agents::AgentDb;
|
use crate::commands::agents::AgentDb;
|
||||||
use crate::commands::relay_stations::{RelayStation, RelayStationAdapter};
|
use crate::commands::relay_stations::{RelayStation, RelayStationAdapter};
|
||||||
|
use crate::http_client;
|
||||||
use crate::i18n;
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StationInfo {
|
pub struct StationInfo {
|
||||||
@@ -143,7 +134,8 @@ impl StationAdapter for PackycodeAdapter {
|
|||||||
// PackyCode 使用简单的健康检查端点
|
// PackyCode 使用简单的健康检查端点
|
||||||
let url = format!("{}/health", station.api_url.trim_end_matches('/'));
|
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
|
let response = client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("X-API-Key", &station.system_token)
|
.header("X-API-Key", &station.system_token)
|
||||||
@@ -176,7 +168,8 @@ impl StationAdapter for PackycodeAdapter {
|
|||||||
// PackyCode 用户信息获取
|
// PackyCode 用户信息获取
|
||||||
let url = format!("{}/user/info", station.api_url.trim_end_matches('/'));
|
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
|
let response = client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("X-API-Key", &station.system_token)
|
.header("X-API-Key", &station.system_token)
|
||||||
@@ -330,11 +323,12 @@ impl StationAdapter for CustomAdapter {
|
|||||||
let start_time = std::time::Instant::now();
|
let start_time = std::time::Instant::now();
|
||||||
|
|
||||||
// 尝试简单的 GET 请求测试连接
|
// 尝试简单的 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
|
let response = client
|
||||||
.get(&station.api_url)
|
.get(&station.api_url)
|
||||||
.header("Authorization", format!("Bearer {}", station.system_token))
|
.header("Authorization", format!("Bearer {}", station.system_token))
|
||||||
.timeout(Duration::from_secs(5))
|
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -621,10 +615,7 @@ pub async fn packycode_get_user_quota(
|
|||||||
"https://www.packycode.com/api/backend/users/info"
|
"https://www.packycode.com/api/backend/users/info"
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = Client::builder()
|
let client = http_client::secure_client()
|
||||||
.timeout(Duration::from_secs(30))
|
|
||||||
.no_proxy() // 禁用所有代理
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?;
|
.map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?;
|
||||||
|
|
||||||
log::info!("正在请求 PackyCode 用户信息: {}", url);
|
log::info!("正在请求 PackyCode 用户信息: {}", url);
|
||||||
|
|||||||
219
src-tauri/src/http_client.rs
Normal file
219
src-tauri/src/http_client.rs
Normal file
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>) -> 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<Client> {
|
||||||
|
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<Client> {
|
||||||
|
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<Client> {
|
||||||
|
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<Client> {
|
||||||
|
create_client(
|
||||||
|
ClientConfig::default()
|
||||||
|
.timeout(30)
|
||||||
|
.use_proxy(false)
|
||||||
|
.user_agent("Claudia"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建长超时客户端(用于大文件传输等)
|
||||||
|
///
|
||||||
|
/// 配置:
|
||||||
|
/// - 超时: 60 秒
|
||||||
|
/// - 接受无效证书: 否
|
||||||
|
/// - 使用代理: 是
|
||||||
|
/// - User-Agent: "Claudia/1.0"
|
||||||
|
pub fn long_timeout_client() -> Result<Client> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,11 @@ pub mod claude_binary;
|
|||||||
pub mod claude_config;
|
pub mod claude_config;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod file_watcher;
|
pub mod file_watcher;
|
||||||
|
pub mod http_client;
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
|
pub mod types;
|
||||||
|
pub mod utils;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ mod claude_binary;
|
|||||||
mod claude_config;
|
mod claude_config;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod file_watcher;
|
mod file_watcher;
|
||||||
|
mod http_client;
|
||||||
mod i18n;
|
mod i18n;
|
||||||
mod process;
|
mod process;
|
||||||
|
mod types;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
use checkpoint::state::CheckpointState;
|
use checkpoint::state::CheckpointState;
|
||||||
use commands::agents::{
|
use commands::agents::{
|
||||||
|
|||||||
2
src-tauri/src/types/mod.rs
Normal file
2
src-tauri/src/types/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/// 节点测试相关类型定义
|
||||||
|
pub mod node_test;
|
||||||
367
src-tauri/src/types/node_test.rs
Normal file
367
src-tauri/src/types/node_test.rs
Normal file
@@ -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<String>,
|
||||||
|
|
||||||
|
/// 节点名称(可选,用于显示)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub node_name: Option<String>,
|
||||||
|
|
||||||
|
/// 节点 URL
|
||||||
|
pub url: String,
|
||||||
|
|
||||||
|
/// 测试状态
|
||||||
|
pub status: TestStatus,
|
||||||
|
|
||||||
|
/// 响应时间(毫秒)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub response_time_ms: Option<u64>,
|
||||||
|
|
||||||
|
/// 状态消息
|
||||||
|
pub message: String,
|
||||||
|
|
||||||
|
/// 错误详情(失败时提供)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error_details: Option<String>,
|
||||||
|
|
||||||
|
/// 额外元数据(用于扩展)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub metadata: Option<HashMap<String, serde_json::Value>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, serde_json::Value>) -> 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<u64> {
|
||||||
|
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<f64>,
|
||||||
|
/// 最快响应时间(毫秒)
|
||||||
|
pub min_response_time: Option<u64>,
|
||||||
|
/// 最慢响应时间(毫秒)
|
||||||
|
pub max_response_time: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<u64> = 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
235
src-tauri/src/utils/error.rs
Normal file
235
src-tauri/src/utils/error.rs
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/// 错误处理工具模块
|
||||||
|
///
|
||||||
|
/// 提供统一的错误转换函数,减少样板代码
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
/// 将 anyhow::Result 转换为 Result<T, String>
|
||||||
|
///
|
||||||
|
/// 这是最常用的错误转换函数,用于 Tauri 命令的返回值
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```
|
||||||
|
/// use claudia_lib::utils::error::to_string_error;
|
||||||
|
/// use anyhow::Result;
|
||||||
|
///
|
||||||
|
/// fn some_operation() -> Result<String> {
|
||||||
|
/// Ok("success".to_string())
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// #[tauri::command]
|
||||||
|
/// async fn my_command() -> Result<String, String> {
|
||||||
|
/// to_string_error(some_operation())
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub fn to_string_error<T>(result: Result<T>) -> Result<T, String> {
|
||||||
|
result.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 anyhow::Result 转换为 Result<T, String>,并添加上下文信息
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```
|
||||||
|
/// use claudia_lib::utils::error::to_string_error_ctx;
|
||||||
|
/// use anyhow::Result;
|
||||||
|
///
|
||||||
|
/// fn database_operation() -> Result<String> {
|
||||||
|
/// Ok("data".to_string())
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// #[tauri::command]
|
||||||
|
/// async fn get_data() -> Result<String, String> {
|
||||||
|
/// to_string_error_ctx(
|
||||||
|
/// database_operation(),
|
||||||
|
/// "获取数据失败"
|
||||||
|
/// )
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub fn to_string_error_ctx<T>(result: Result<T>, context: &str) -> Result<T, String> {
|
||||||
|
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<String, String> {
|
||||||
|
/// 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<String, String> {
|
||||||
|
/// 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<String> = 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<String> = 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("文件或目录不存在"));
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src-tauri/src/utils/mod.rs
Normal file
3
src-tauri/src/utils/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/// 工具函数模块
|
||||||
|
pub mod error;
|
||||||
|
pub mod node_tester;
|
||||||
264
src-tauri/src/utils/node_tester.rs
Normal file
264
src-tauri/src/utils/node_tester.rs
Normal file
@@ -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<String>, timeout_ms: u64) -> Vec<NodeTestResult> {
|
||||||
|
// 创建所有测试任务
|
||||||
|
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<String>, timeout_ms: u64) -> Vec<NodeTestResult> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ export const NodeSelector: React.FC<NodeSelectorProps> = ({
|
|||||||
value = '',
|
value = '',
|
||||||
onChange,
|
onChange,
|
||||||
allowManualInput = true,
|
allowManualInput = true,
|
||||||
showToast = (msg, _type) => alert(msg), // 默认使用 alert
|
showToast = (msg, _type) => console.log(msg), // 默认使用 console.log
|
||||||
}) => {
|
}) => {
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
const [nodes, setNodes] = useState<ApiNode[]>([]);
|
const [nodes, setNodes] = useState<ApiNode[]>([]);
|
||||||
@@ -191,7 +191,7 @@ const NodeManagerDialog: React.FC<NodeManagerDialogProps> = ({
|
|||||||
adapter: filterAdapter,
|
adapter: filterAdapter,
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
currentUrl,
|
currentUrl,
|
||||||
showToast = (msg) => alert(msg),
|
showToast = (msg) => console.log(msg),
|
||||||
}) => {
|
}) => {
|
||||||
const [nodes, setNodes] = useState<ApiNode[]>([]);
|
const [nodes, setNodes] = useState<ApiNode[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -267,12 +267,17 @@ const NodeManagerDialog: React.FC<NodeManagerDialogProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (node: ApiNode) => {
|
const handleDelete = async (node: ApiNode) => {
|
||||||
if (!confirm(`确定要删除节点 "${node.name}" 吗?`)) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.deleteApiNode(node.id);
|
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');
|
showToast('删除成功', 'success');
|
||||||
loadNodes();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('删除失败', 'error');
|
showToast('删除失败', 'error');
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -492,7 +497,7 @@ const NodeFormDialog: React.FC<NodeFormDialogProps> = ({
|
|||||||
node,
|
node,
|
||||||
defaultAdapter,
|
defaultAdapter,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
showToast = (msg) => alert(msg),
|
showToast = (msg) => console.log(msg),
|
||||||
}) => {
|
}) => {
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [formData, setFormData] = useState<CreateApiNodeRequest>({
|
const [formData, setFormData] = useState<CreateApiNodeRequest>({
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import {
|
|||||||
UpdateRelayStationRequest,
|
UpdateRelayStationRequest,
|
||||||
RelayStationAdapter,
|
RelayStationAdapter,
|
||||||
AuthMethod,
|
AuthMethod,
|
||||||
PackycodeUserQuota,
|
|
||||||
ImportResult,
|
ImportResult,
|
||||||
api
|
api
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
@@ -110,10 +109,6 @@ const RelayStationManager: React.FC<RelayStationManagerProps> = ({ onBack }) =>
|
|||||||
const [importProgress, setImportProgress] = useState(0);
|
const [importProgress, setImportProgress] = useState(0);
|
||||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||||
|
|
||||||
// PackyCode 额度相关状态
|
|
||||||
const [quotaData, setQuotaData] = useState<Record<string, PackycodeUserQuota>>({});
|
|
||||||
const [loadingQuota, setLoadingQuota] = useState<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
// 拖拽状态
|
// 拖拽状态
|
||||||
const [activeStation, setActiveStation] = useState<RelayStation | null>(null);
|
const [activeStation, setActiveStation] = useState<RelayStation | null>(null);
|
||||||
|
|
||||||
@@ -339,20 +334,6 @@ const RelayStationManager: React.FC<RelayStationManagerProps> = ({ 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 () => {
|
const handleExportStations = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -544,15 +525,6 @@ const RelayStationManager: React.FC<RelayStationManagerProps> = ({ onBack }) =>
|
|||||||
loadCurrentConfig();
|
loadCurrentConfig();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 当中转站加载完成后,自动获取所有 PackyCode 站点的额度
|
|
||||||
useEffect(() => {
|
|
||||||
stations.forEach(station => {
|
|
||||||
if (station.adapter === 'packycode') {
|
|
||||||
fetchPackycodeQuota(station.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [stations]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col overflow-hidden">
|
<div className="h-full flex flex-col overflow-hidden">
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
@@ -923,8 +895,6 @@ const RelayStationManager: React.FC<RelayStationManagerProps> = ({ onBack }) =>
|
|||||||
setSelectedStation={handleSelectStation}
|
setSelectedStation={handleSelectStation}
|
||||||
setShowEditDialog={setShowEditDialog}
|
setShowEditDialog={setShowEditDialog}
|
||||||
openDeleteDialog={openDeleteDialog}
|
openDeleteDialog={openDeleteDialog}
|
||||||
quotaData={quotaData}
|
|
||||||
loadingQuota={loadingQuota}
|
|
||||||
/>)
|
/>)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useSortable } from '@dnd-kit/sortable';
|
|||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import {
|
import {
|
||||||
Edit,
|
Edit,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -15,7 +14,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
RelayStation,
|
RelayStation,
|
||||||
RelayStationAdapter,
|
RelayStationAdapter,
|
||||||
PackycodeUserQuota,
|
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
|
|
||||||
interface SortableStationItemProps {
|
interface SortableStationItemProps {
|
||||||
@@ -25,8 +23,6 @@ interface SortableStationItemProps {
|
|||||||
setSelectedStation: (station: RelayStation) => void;
|
setSelectedStation: (station: RelayStation) => void;
|
||||||
setShowEditDialog: (show: boolean) => void;
|
setShowEditDialog: (show: boolean) => void;
|
||||||
openDeleteDialog: (station: RelayStation) => void;
|
openDeleteDialog: (station: RelayStation) => void;
|
||||||
quotaData: Record<string, PackycodeUserQuota>;
|
|
||||||
loadingQuota: Record<string, boolean>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,8 +36,6 @@ export const SortableStationItem: React.FC<SortableStationItemProps> = ({
|
|||||||
setSelectedStation,
|
setSelectedStation,
|
||||||
setShowEditDialog,
|
setShowEditDialog,
|
||||||
openDeleteDialog,
|
openDeleteDialog,
|
||||||
quotaData,
|
|
||||||
loadingQuota,
|
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
@@ -71,7 +65,7 @@ export const SortableStationItem: React.FC<SortableStationItemProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 是否有详情内容需要显示
|
// 是否有详情内容需要显示
|
||||||
const hasDetails = station.description || station.adapter === 'packycode';
|
const hasDetails = station.description;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -162,132 +156,6 @@ export const SortableStationItem: React.FC<SortableStationItemProps> = ({
|
|||||||
{station.description}
|
{station.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* PackyCode 额度显示 */}
|
|
||||||
{station.adapter === 'packycode' && (
|
|
||||||
<div className="mt-2 p-2 bg-blue-50 dark:bg-blue-950/30 rounded-lg border border-blue-200 dark:border-blue-900">
|
|
||||||
{loadingQuota[station.id] ? (
|
|
||||||
<div className="flex items-center justify-center py-1">
|
|
||||||
<div className="h-3 w-3 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
|
||||||
<span className="ml-2 text-xs text-muted-foreground">加载中...</span>
|
|
||||||
</div>
|
|
||||||
) : quotaData[station.id] ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* 用户信息和计划 */}
|
|
||||||
<div className="flex items-center justify-between text-xs">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{quotaData[station.id].username && (
|
|
||||||
<span className="text-muted-foreground">{quotaData[station.id].username}</span>
|
|
||||||
)}
|
|
||||||
<Badge variant="secondary" className="text-xs h-5 px-1.5">
|
|
||||||
{quotaData[station.id].plan_type.toUpperCase()}
|
|
||||||
</Badge>
|
|
||||||
{quotaData[station.id].opus_enabled && (
|
|
||||||
<Badge variant="default" className="text-xs h-5 px-1.5 bg-purple-600">
|
|
||||||
Opus
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 账户余额 */}
|
|
||||||
<div className="flex items-center justify-between text-xs">
|
|
||||||
<span className="text-muted-foreground">余额:</span>
|
|
||||||
<span className="font-medium text-blue-600">
|
|
||||||
${Number(quotaData[station.id].balance_usd).toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 日额度 */}
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="flex items-center justify-between text-xs">
|
|
||||||
<span className="text-muted-foreground">日额度:</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{(() => {
|
|
||||||
const daily_spent = Number(quotaData[station.id].daily_spent_usd);
|
|
||||||
const daily_budget = Number(quotaData[station.id].daily_budget_usd);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span className={daily_spent > daily_budget * 0.8 ? 'text-orange-600' : 'text-green-600'}>
|
|
||||||
${daily_spent.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">/ ${daily_budget.toFixed(2)}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full transition-all ${
|
|
||||||
(() => {
|
|
||||||
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)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 月额度 */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">月额度:</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{(() => {
|
|
||||||
const monthly_spent = Number(quotaData[station.id].monthly_spent_usd);
|
|
||||||
const monthly_budget = Number(quotaData[station.id].monthly_budget_usd);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span className={monthly_spent > monthly_budget * 0.8 ? 'text-orange-600' : 'text-green-600'}>
|
|
||||||
${monthly_spent.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">/</span>
|
|
||||||
<span className="text-muted-foreground">${monthly_budget.toFixed(2)}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full transition-all ${
|
|
||||||
(() => {
|
|
||||||
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)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 总消费 */}
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t">
|
|
||||||
<span>总消费:</span>
|
|
||||||
<span className="font-medium">${Number(quotaData[station.id].total_spent_usd).toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-center text-muted-foreground py-2">
|
|
||||||
额度信息加载失败
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user