抽离公共模块
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:
2025-10-27 01:19:14 +08:00
parent 11aac96b53
commit bdf2e499bc
14 changed files with 1188 additions and 380 deletions

View File

@@ -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)

View File

@@ -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())
}
} }
} }

View File

@@ -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);

View 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());
}
}

View File

@@ -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() {

View File

@@ -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::{

View File

@@ -0,0 +1,2 @@
/// 节点测试相关类型定义
pub mod node_test;

View 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");
}
}

View 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("文件或目录不存在"));
}
}

View File

@@ -0,0 +1,3 @@
/// 工具函数模块
pub mod error;
pub mod node_tester;

View 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);
}
}

View File

@@ -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>({

View File

@@ -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>

View File

@@ -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>