From 5016c1d9d66035ad87ced757a0b94c9151ca1524 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Fri, 8 Aug 2025 12:27:56 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E8=AE=A1=E7=AE=97=E8=A7=84?= =?UTF-8?q?=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 2 +- src-tauri/src/claude_config.rs | 64 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/packycode_nodes.rs | 216 +++++ src-tauri/src/commands/relay_adapters.rs | 234 +++++- src-tauri/src/commands/relay_stations.rs | 10 +- src-tauri/src/i18n.rs | 2 + src-tauri/src/main.rs | 8 + src/components/RelayStationManager.tsx | 923 +++++++++++++++++++--- src/contexts/ThemeContext.tsx | 5 +- src/lib/api.ts | 71 +- src/locales/en/common.json | 29 +- src/locales/zh/common.json | 29 +- 13 files changed, 1462 insertions(+), 132 deletions(-) create mode 100644 src-tauri/src/commands/packycode_nodes.rs diff --git a/index.html b/index.html index 9cc75af..2f90462 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ - + diff --git a/src-tauri/src/claude_config.rs b/src-tauri/src/claude_config.rs index 9b9c7e6..24e4202 100644 --- a/src-tauri/src/claude_config.rs +++ b/src-tauri/src/claude_config.rs @@ -12,24 +12,20 @@ pub struct ClaudeConfig { pub env: ClaudeEnv, #[serde(default)] pub permissions: Option, - #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, - #[serde(rename = "apiKeyHelper")] + #[serde(rename = "apiKeyHelper", skip_serializing_if = "Option::is_none")] pub api_key_helper: Option, - #[serde(flatten)] - pub other: Value, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ClaudeEnv { - #[serde(rename = "ANTHROPIC_AUTH_TOKEN")] + #[serde(rename = "ANTHROPIC_AUTH_TOKEN", skip_serializing_if = "Option::is_none")] pub anthropic_auth_token: Option, - #[serde(rename = "ANTHROPIC_BASE_URL")] + #[serde(rename = "ANTHROPIC_BASE_URL", skip_serializing_if = "Option::is_none")] pub anthropic_base_url: Option, - #[serde(rename = "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC")] + #[serde(rename = "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", skip_serializing_if = "Option::is_none")] pub disable_nonessential_traffic: Option, - #[serde(flatten)] - pub other: Value, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -63,32 +59,63 @@ pub fn read_claude_config() -> Result { permissions: Some(ClaudePermissions::default()), model: None, api_key_helper: None, - other: json!({}), }); } let content = fs::read_to_string(&config_path) .map_err(|e| format!("读取配置文件失败: {}", e))?; - serde_json::from_str(&content) - .map_err(|e| format!("解析配置文件失败: {}", e)) + // 首先尝试解析为 JSON Value,以便处理可能的格式问题 + let mut json_value: Value = serde_json::from_str(&content) + .map_err(|e| format!("解析配置文件失败: {}", e))?; + + // 如果JSON解析成功,再转换为ClaudeConfig + if let Some(obj) = json_value.as_object_mut() { + // 确保必要的字段存在 + if !obj.contains_key("env") { + obj.insert("env".to_string(), json!({})); + } + } + + serde_json::from_value(json_value) + .map_err(|e| format!("转换配置结构失败: {}", e)) } /// 写入 Claude 配置文件 pub fn write_claude_config(config: &ClaudeConfig) -> Result<(), String> { let config_path = get_claude_config_path()?; + log::info!("尝试写入配置文件到: {:?}", config_path); + // 确保目录存在 if let Some(parent) = config_path.parent() { + log::info!("确保目录存在: {:?}", parent); fs::create_dir_all(parent) - .map_err(|e| format!("创建配置目录失败: {}", e))?; + .map_err(|e| { + let error_msg = format!("创建配置目录失败: {}", e); + log::error!("{}", error_msg); + error_msg + })?; } let content = serde_json::to_string_pretty(config) - .map_err(|e| format!("序列化配置失败: {}", e))?; + .map_err(|e| { + let error_msg = format!("序列化配置失败: {}", e); + log::error!("{}", error_msg); + error_msg + })?; - fs::write(&config_path, content) - .map_err(|e| format!("写入配置文件失败: {}", e)) + log::info!("准备写入内容:\n{}", content); + + fs::write(&config_path, &content) + .map_err(|e| { + let error_msg = format!("写入配置文件失败: {} (路径: {:?})", e, config_path); + log::error!("{}", error_msg); + error_msg + })?; + + log::info!("配置文件写入成功: {:?}", config_path); + Ok(()) } /// 备份当前配置 @@ -137,8 +164,11 @@ pub fn apply_relay_station_to_config(station: &RelayStation) -> Result<(), Strin // 格式:echo 'token' config.api_key_helper = Some(format!("echo '{}'", station.system_token)); - // 如果是自定义适配器,可能需要特殊处理 + // 如果是特定适配器,可能需要特殊处理 match station.adapter.as_str() { + "packycode" => { + // PackyCode 使用原始配置,不做特殊处理 + } "newapi" | "oneapi" => { // NewAPI 和 OneAPI 兼容 OpenAI 格式,不需要特殊处理 } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d7f83a9..02e70b6 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -8,3 +8,4 @@ pub mod proxy; pub mod language; pub mod relay_stations; pub mod relay_adapters; +pub mod packycode_nodes; diff --git a/src-tauri/src/commands/packycode_nodes.rs b/src-tauri/src/commands/packycode_nodes.rs new file mode 100644 index 0000000..93ee292 --- /dev/null +++ b/src-tauri/src/commands/packycode_nodes.rs @@ -0,0 +1,216 @@ +use serde::{Deserialize, Serialize}; +use std::time::{Duration, Instant}; +use reqwest::Client; +use tauri::command; +use anyhow::Result; + +/// PackyCode 节点类型 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NodeType { + Direct, // 直连节点 + Backup, // 备用节点 + Emergency, // 紧急节点(非紧急情况不要使用) +} + +/// PackyCode 节点信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PackycodeNode { + pub name: String, + pub url: String, + pub node_type: NodeType, + pub description: String, + pub response_time: Option, // 响应时间(毫秒) + pub available: Option, // 是否可用 +} + +/// 节点测速结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeSpeedTestResult { + pub node: PackycodeNode, + pub response_time: u64, + pub success: bool, + pub error: Option, +} + +/// 获取所有 PackyCode 节点 +pub fn get_all_nodes() -> Vec { + vec![ + // 直连节点 + PackycodeNode { + name: "直连1".to_string(), + url: "https://api.packycode.com".to_string(), + node_type: NodeType::Direct, + description: "默认直连节点".to_string(), + response_time: None, + available: None, + }, + PackycodeNode { + name: "直连2 (HK-CN2)".to_string(), + url: "https://api-hk-cn2.packycode.com".to_string(), + node_type: NodeType::Direct, + description: "香港 CN2 线路".to_string(), + response_time: None, + available: None, + }, + PackycodeNode { + name: "直连3 (US-CMIN2)".to_string(), + url: "https://api-us-cmin2.packycode.com".to_string(), + node_type: NodeType::Direct, + description: "美国 CMIN2 线路".to_string(), + response_time: None, + available: None, + }, + PackycodeNode { + name: "直连4 (US-4837)".to_string(), + url: "https://api-us-4837.packycode.com".to_string(), + node_type: NodeType::Direct, + description: "美国 4837 线路".to_string(), + response_time: None, + available: None, + }, + // 备用节点 + PackycodeNode { + name: "备用1 (US-CN2)".to_string(), + url: "https://api-us-cn2.packycode.com".to_string(), + node_type: NodeType::Backup, + description: "美国 CN2 备用线路".to_string(), + response_time: None, + available: None, + }, + PackycodeNode { + name: "备用2 (CF-Pro)".to_string(), + url: "https://api-cf-pro.packycode.com".to_string(), + node_type: NodeType::Backup, + description: "CloudFlare Pro 备用线路".to_string(), + response_time: None, + available: None, + }, + // 紧急节点 + PackycodeNode { + name: "测试节点1".to_string(), + url: "https://api-test.packyme.com".to_string(), + node_type: NodeType::Emergency, + description: "测试节点(非紧急情况勿用)".to_string(), + response_time: None, + available: None, + }, + PackycodeNode { + name: "测试节点2".to_string(), + url: "https://api-test-custom.packycode.com".to_string(), + node_type: NodeType::Emergency, + description: "自定义测试节点(非紧急情况勿用)".to_string(), + response_time: None, + available: None, + }, + PackycodeNode { + name: "测试节点3".to_string(), + url: "https://api-tmp-test.dzz.ai".to_string(), + node_type: NodeType::Emergency, + description: "临时测试节点(非紧急情况勿用)".to_string(), + response_time: None, + available: None, + }, + ] +} + +/// 测试单个节点速度(仅测试网络延时,不需要认证) +async fn test_node_speed(node: &PackycodeNode, _token: &str) -> NodeSpeedTestResult { + let client = Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap_or_else(|_| Client::new()); + + let start_time = Instant::now(); + + // 只需要测试服务器的可达性和延时,使用简单的 HEAD 请求 + match client + .head(&node.url) + .send() + .await + { + Ok(_response) => { + let response_time = start_time.elapsed().as_millis() as u64; + + // 只要能连接到服务器就算成功,不管返回什么状态码 + NodeSpeedTestResult { + node: PackycodeNode { + response_time: Some(response_time), + available: Some(true), + ..node.clone() + }, + response_time, + success: true, + error: None, + } + } + Err(e) => { + let response_time = start_time.elapsed().as_millis() as u64; + NodeSpeedTestResult { + node: PackycodeNode { + response_time: Some(response_time), + available: Some(false), + ..node.clone() + }, + response_time, + success: false, + error: Some(format!("连接失败: {}", e.to_string())), + } + } + } +} + +/// 测试所有节点速度 +#[command] +pub async fn test_all_packycode_nodes(token: String) -> Result, String> { + let nodes = get_all_nodes(); + let mut results = Vec::new(); + + for node in nodes { + let result = test_node_speed(&node, &token).await; + results.push(result); + } + + // 按响应时间排序 + results.sort_by(|a, b| { + match (a.success, b.success) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.response_time.cmp(&b.response_time), + } + }); + + Ok(results) +} + +/// 自动选择最快的节点(仅从直连和备用中选择) +#[command] +pub async fn auto_select_best_node(token: String) -> Result { + let nodes = get_all_nodes(); + let mut best_node: Option<(PackycodeNode, u64)> = None; + + // 只测试直连和备用节点 + for node in nodes.iter().filter(|n| matches!(n.node_type, NodeType::Direct | NodeType::Backup)) { + let result = test_node_speed(node, &token).await; + + if result.success { + match &best_node { + None => best_node = Some((result.node, result.response_time)), + Some((_, best_time)) if result.response_time < *best_time => { + best_node = Some((result.node, result.response_time)); + } + _ => {} + } + } + } + + best_node + .map(|(node, _)| node) + .ok_or_else(|| "No available nodes found".to_string()) +} + +/// 获取节点列表(不测速) +#[command] +pub fn get_packycode_nodes() -> Vec { + get_all_nodes() +} \ No newline at end of file diff --git a/src-tauri/src/commands/relay_adapters.rs b/src-tauri/src/commands/relay_adapters.rs index d835713..ed55766 100644 --- a/src-tauri/src/commands/relay_adapters.rs +++ b/src-tauri/src/commands/relay_adapters.rs @@ -51,6 +51,237 @@ pub trait StationAdapter: Send + Sync { async fn delete_token(&self, station: &RelayStation, token_id: &str) -> Result; } +/// PackyCode 适配器(默认使用 API Key 认证) +pub struct PackycodeAdapter; + +#[async_trait] +impl StationAdapter for PackycodeAdapter { + async fn get_station_info(&self, station: &RelayStation) -> Result { + let url = format!("{}/api/status", station.api_url.trim_end_matches('/')); + + let response = HTTP_CLIENT + .get(&url) + .header("Authorization", format!("sk-{}", station.system_token)) + .send() + .await?; + + let data: Value = response.json().await?; + + if !data.get("success").and_then(|v| v.as_bool()).unwrap_or(false) { + return Err(anyhow::anyhow!("API Error: {}", + data.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"))); + } + + let default_data = json!({}); + let data = data.get("data").unwrap_or(&default_data); + + Ok(StationInfo { + name: data.get("system_name") + .and_then(|v| v.as_str()) + .unwrap_or("PackyCode") + .to_string(), + announcement: data.get("announcement") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + api_url: station.api_url.clone(), + version: data.get("version") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + metadata: Some({ + let mut map = HashMap::new(); + map.insert("adapter_type".to_string(), json!("packycode")); + map.insert("service_type".to_string(), json!( + if station.api_url.contains("share.packycode.com") { + "bus" // 公交车 + } else { + "taxi" // 滴滴车 + } + )); + if let Some(quota_per_unit) = data.get("quota_per_unit").and_then(|v| v.as_i64()) { + map.insert("quota_per_unit".to_string(), json!(quota_per_unit)); + } + map + }), + quota_per_unit: data.get("quota_per_unit").and_then(|v| v.as_i64()), + }) + } + + async fn get_user_info(&self, station: &RelayStation, user_id: &str) -> Result { + let url = format!("{}/api/user/self", station.api_url.trim_end_matches('/')); + + let response = HTTP_CLIENT + .get(&url) + .header("Authorization", format!("sk-{}", station.system_token)) + .header("X-User-Id", user_id) + .send() + .await?; + + let data: Value = response.json().await?; + + if !data.get("success").and_then(|v| v.as_bool()).unwrap_or(false) { + return Err(anyhow::anyhow!("API Error: {}", + data.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"))); + } + + let user_data = data.get("data").ok_or_else(|| anyhow!("No user data returned"))?; + + Ok(UserInfo { + user_id: user_data.get("id") + .and_then(|v| v.as_i64()) + .map(|id| id.to_string()) + .unwrap_or_else(|| user_id.to_string()), + username: user_data.get("username") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + email: user_data.get("email") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + balance_remaining: user_data.get("quota") + .and_then(|v| v.as_i64()) + .map(|q| q as f64 / 500000.0), // 转换为美元 + amount_used: user_data.get("used_quota") + .and_then(|v| v.as_i64()) + .map(|q| q as f64 / 500000.0), + request_count: user_data.get("request_count") + .and_then(|v| v.as_i64()), + status: match user_data.get("status").and_then(|v| v.as_i64()) { + Some(1) => Some("active".to_string()), + Some(0) => Some("disabled".to_string()), + _ => Some("unknown".to_string()), + }, + metadata: Some({ + let mut map = HashMap::new(); + map.insert("raw_data".to_string(), user_data.clone()); + map + }), + }) + } + + async fn test_connection(&self, station: &RelayStation) -> Result { + let start_time = Instant::now(); + let url = format!("{}/api/status", station.api_url.trim_end_matches('/')); + + match HTTP_CLIENT + .get(&url) + .header("Authorization", format!("sk-{}", station.system_token)) + .timeout(Duration::from_secs(10)) + .send() + .await + { + Ok(response) => { + let response_time = start_time.elapsed().as_millis() as u64; + + if response.status().is_success() { + match response.json::().await { + Ok(data) => { + let success = data.get("success").and_then(|v| v.as_bool()).unwrap_or(false); + if success { + Ok(ConnectionTestResult { + success: true, + response_time: Some(response_time), + message: i18n::t("relay_adapter.connection_success"), + error: None, + }) + } else { + let error_msg = data.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"); + Ok(ConnectionTestResult { + success: false, + response_time: Some(response_time), + message: i18n::t("relay_adapter.api_error"), + error: Some(error_msg.to_string()), + }) + } + } + Err(e) => Ok(ConnectionTestResult { + success: false, + response_time: Some(response_time), + message: i18n::t("relay_adapter.parse_error"), + error: Some(e.to_string()), + }) + } + } else { + Ok(ConnectionTestResult { + success: false, + response_time: Some(response_time), + message: i18n::t("relay_adapter.http_error"), + error: Some(format!("HTTP {}", response.status())), + }) + } + } + Err(e) => { + let response_time = start_time.elapsed().as_millis() as u64; + Ok(ConnectionTestResult { + success: false, + response_time: Some(response_time), + message: i18n::t("relay_adapter.network_error"), + error: Some(e.to_string()), + }) + } + } + } + + async fn get_usage_logs(&self, station: &RelayStation, user_id: &str, page: Option, size: Option) -> Result { + let page = page.unwrap_or(1); + let size = size.unwrap_or(10); + let url = format!("{}/api/log/self?page={}&size={}", + station.api_url.trim_end_matches('/'), page, size); + + let response = HTTP_CLIENT + .get(&url) + .header("Authorization", format!("sk-{}", station.system_token)) + .header("X-User-Id", user_id) + .send() + .await?; + + let data: Value = response.json().await?; + + if !data.get("success").and_then(|v| v.as_bool()).unwrap_or(false) { + return Err(anyhow::anyhow!("API Error: {}", + data.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"))); + } + + Ok(data.get("data").cloned().unwrap_or(json!([]))) + } + + async fn list_tokens(&self, station: &RelayStation, page: Option, size: Option) -> Result { + // PackyCode 使用简化的 Token 管理 + let page = page.unwrap_or(1); + let size = size.unwrap_or(10); + + // 返回当前使用的 API Key 作为唯一 Token + let token = TokenInfo { + id: "1".to_string(), + name: "API Key".to_string(), + token: format!("sk-{}...", &station.system_token[..8]), + quota: None, + used_quota: None, + status: "active".to_string(), + created_at: station.created_at, + updated_at: station.updated_at, + }; + + Ok(TokenPaginationResponse { + tokens: vec![token], + total: 1, + page, + size, + has_more: false, + }) + } + + async fn create_token(&self, _station: &RelayStation, _name: &str, _quota: Option) -> Result { + Err(anyhow::anyhow!(i18n::t("relay_adapter.packycode_single_token"))) + } + + async fn update_token(&self, _station: &RelayStation, _token_id: &str, _name: Option<&str>, _quota: Option) -> Result { + Err(anyhow::anyhow!(i18n::t("relay_adapter.packycode_single_token"))) + } + + async fn delete_token(&self, _station: &RelayStation, _token_id: &str) -> Result { + Err(anyhow::anyhow!(i18n::t("relay_adapter.packycode_single_token"))) + } +} + /// NewAPI 适配器(支持 NewAPI 和 OneAPI) pub struct NewApiAdapter; @@ -575,7 +806,7 @@ impl StationAdapter for CustomAdapter { // Custom 适配器跳过连接测试,直接返回成功 Ok(ConnectionTestResult { success: true, - response_time: Some(0), + response_time: None, // 不显示响应时间 message: i18n::t("relay_adapter.custom_no_test"), error: None, }) @@ -605,6 +836,7 @@ impl StationAdapter for CustomAdapter { /// 适配器工厂函数 pub fn create_adapter(adapter_type: &RelayStationAdapter) -> Box { match adapter_type { + RelayStationAdapter::Packycode => Box::new(PackycodeAdapter), RelayStationAdapter::Newapi => Box::new(NewApiAdapter), RelayStationAdapter::Oneapi => Box::new(NewApiAdapter), // OneAPI 兼容 NewAPI RelayStationAdapter::Yourapi => Box::new(YourApiAdapter::new()), diff --git a/src-tauri/src/commands/relay_stations.rs b/src-tauri/src/commands/relay_stations.rs index 0c971b6..ad2229f 100644 --- a/src-tauri/src/commands/relay_stations.rs +++ b/src-tauri/src/commands/relay_stations.rs @@ -14,6 +14,7 @@ use crate::claude_config; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RelayStationAdapter { + Packycode, // PackyCode 平台(放在第一位) Newapi, // NewAPI 兼容平台 Oneapi, // OneAPI 兼容平台 Yourapi, // YourAPI 特定平台 @@ -23,6 +24,7 @@ pub enum RelayStationAdapter { impl RelayStationAdapter { pub fn as_str(&self) -> &str { match self { + RelayStationAdapter::Packycode => "packycode", RelayStationAdapter::Newapi => "newapi", RelayStationAdapter::Oneapi => "oneapi", RelayStationAdapter::Yourapi => "yourapi", @@ -520,12 +522,10 @@ pub async fn relay_station_toggle_enable( let station = relay_station_get_internal(&conn, &id)?; // 将中转站配置应用到 Claude 配置文件 - if let Err(e) = claude_config::apply_relay_station_to_config(&station) { + claude_config::apply_relay_station_to_config(&station).map_err(|e| { log::error!("Failed to apply relay station config: {}", e); - // 不中断流程,但记录错误 - } else { - log::info!("Applied relay station config to Claude settings"); - } + format!("配置文件写入失败: {}", e) + })?; } else { // 如果禁用中转站,清除 Claude 配置中的相关设置 if let Err(e) = claude_config::clear_relay_station_from_config() { diff --git a/src-tauri/src/i18n.rs b/src-tauri/src/i18n.rs index 4cd39a0..1f28e79 100644 --- a/src-tauri/src/i18n.rs +++ b/src-tauri/src/i18n.rs @@ -45,6 +45,7 @@ impl SimpleI18n { // Relay Station English translations ("en-US", "relay_adapter.custom_no_test") => "Custom configuration, connection test skipped".to_string(), + ("en-US", "relay_adapter.packycode_single_token") => "PackyCode only supports single API key".to_string(), ("en-US", "relay_adapter.user_info_not_available") => "User info not available for this configuration".to_string(), ("en-US", "relay_adapter.usage_logs_not_available") => "Usage logs not available for this configuration".to_string(), ("en-US", "relay_adapter.token_management_not_available") => "Token management not available for this configuration".to_string(), @@ -65,6 +66,7 @@ impl SimpleI18n { // Relay Station Chinese translations ("zh-CN", "relay_adapter.custom_no_test") => "自定义配置,跳过连接测试".to_string(), + ("zh-CN", "relay_adapter.packycode_single_token") => "PackyCode 仅支持单个 API 密钥".to_string(), ("zh-CN", "relay_adapter.user_info_not_available") => "该配置不支持用户信息查询".to_string(), ("zh-CN", "relay_adapter.usage_logs_not_available") => "该配置不支持使用日志查询".to_string(), ("zh-CN", "relay_adapter.token_management_not_available") => "该配置不支持 Token 管理".to_string(), diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c695b8d..2a1a589 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -56,6 +56,9 @@ use commands::relay_adapters::{ relay_station_test_connection, relay_station_get_usage_logs, relay_station_list_tokens, relay_station_create_token, relay_station_update_token, relay_station_delete_token, }; +use commands::packycode_nodes::{ + test_all_packycode_nodes, auto_select_best_node, get_packycode_nodes, +}; use process::ProcessRegistryState; use std::sync::Mutex; use tauri::Manager; @@ -286,6 +289,11 @@ fn main() { relay_station_create_token, relay_station_update_token, relay_station_delete_token, + + // PackyCode Nodes + test_all_packycode_nodes, + auto_select_best_node, + get_packycode_nodes, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/RelayStationManager.tsx b/src/components/RelayStationManager.tsx index 871793e..818ad0c 100644 --- a/src/components/RelayStationManager.tsx +++ b/src/components/RelayStationManager.tsx @@ -2,13 +2,22 @@ import React, { useState, useEffect } from 'react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogDescription, + DialogFooter +} from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; import { useTranslation } from '@/hooks/useTranslation'; +import { Toast, ToastContainer } from "@/components/ui/toast"; import { RelayStation, CreateRelayStationRequest, @@ -43,14 +52,22 @@ const RelayStationManager: React.FC = ({ onBack }) => const [selectedStation, setSelectedStation] = useState(null); const [showCreateDialog, setShowCreateDialog] = useState(false); const [showEditDialog, setShowEditDialog] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [stationToDelete, setStationToDelete] = useState(null); const [connectionTests, setConnectionTests] = useState>({}); const [testingConnections, setTestingConnections] = useState>({}); const [togglingEnable, setTogglingEnable] = useState>({}); const [currentConfig, setCurrentConfig] = useState>({}); const [loadingConfig, setLoadingConfig] = useState(false); + const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); const { t } = useTranslation(); + // 显示Toast + const showToast = (message: string, type: "success" | "error" = "success") => { + setToast({ message, type }); + }; + // 加载中转站列表 const loadStations = async () => { try { @@ -59,7 +76,7 @@ const RelayStationManager: React.FC = ({ onBack }) => setStations(stationList); } catch (error) { console.error('Failed to load stations:', error); - alert(t('relayStation.loadFailed')); + showToast(t('relayStation.loadFailed'), "error"); } finally { setLoading(false); } @@ -82,11 +99,11 @@ const RelayStationManager: React.FC = ({ onBack }) => const syncConfig = async () => { try { const result = await api.relayStationSyncConfig(); - alert(result); + showToast(result, "success"); loadCurrentConfig(); } catch (error) { console.error('Failed to sync config:', error); - alert(t('relayStation.syncFailed')); + showToast(t('relayStation.syncFailed'), "error"); } }; @@ -98,35 +115,44 @@ const RelayStationManager: React.FC = ({ onBack }) => setConnectionTests(prev => ({ ...prev, [stationId]: result })); if (result.success) { - alert(t('relayStation.connectionSuccess')); + showToast(t('relayStation.connectionSuccess'), "success"); } else { - alert(result.message); + showToast(result.message, "error"); } } catch (error) { console.error('Connection test failed:', error); - alert(t('relayStation.connectionFailed')); + showToast(t('relayStation.connectionFailed'), "error"); } finally { setTestingConnections(prev => ({ ...prev, [stationId]: false })); } }; // 删除中转站 - const deleteStation = async (stationId: string) => { - if (!confirm(t('relayStation.deleteConfirm'))) return; + const deleteStation = async () => { + if (!stationToDelete) return; try { - await api.relayStationDelete(stationId); - alert(t('relayStation.deleteSuccess')); + await api.relayStationDelete(stationToDelete.id); loadStations(); + setShowDeleteDialog(false); + setStationToDelete(null); + showToast(t('relayStation.deleteSuccess'), "success"); } catch (error) { console.error('Failed to delete station:', error); - alert(t('relayStation.deleteFailed')); + showToast(t('relayStation.deleteFailed'), "error"); } }; + // 打开删除确认对话框 + const openDeleteDialog = (station: RelayStation) => { + setStationToDelete(station); + setShowDeleteDialog(true); + }; + // 获取适配器类型显示名称 const getAdapterDisplayName = (adapter: RelayStationAdapter): string => { switch (adapter) { + case 'packycode': return 'PackyCode'; case 'newapi': return 'NewAPI'; case 'oneapi': return 'OneAPI'; case 'yourapi': return 'YourAPI'; @@ -141,12 +167,12 @@ const RelayStationManager: React.FC = ({ onBack }) => setTogglingEnable(prev => ({ ...prev, [stationId]: true })); const newEnabled = !currentEnabled; await api.relayStationToggleEnable(stationId, newEnabled); - alert(newEnabled ? t('relayStation.enabledSuccess') : t('relayStation.disabledSuccess')); + showToast(newEnabled ? t('relayStation.enabledSuccess') : t('relayStation.disabledSuccess'), "success"); loadStations(); loadCurrentConfig(); // 重新加载配置状态 } catch (error) { console.error('Failed to toggle enable status:', error); - alert(t('relayStation.toggleEnableFailed')); + showToast(t('relayStation.toggleEnableFailed'), "error"); } finally { setTogglingEnable(prev => ({ ...prev, [stationId]: false })); } @@ -212,6 +238,7 @@ const RelayStationManager: React.FC = ({ onBack }) => onSuccess={() => { setShowCreateDialog(false); loadStations(); + showToast(t('relayStation.createSuccess'), "success"); }} /> @@ -318,7 +345,7 @@ const RelayStationManager: React.FC = ({ onBack }) => )} {connectionTests[station.id].message} - {connectionTests[station.id].response_time && ( + {connectionTests[station.id].response_time !== undefined && connectionTests[station.id].response_time !== null && ( ({connectionTests[station.id].response_time}ms) @@ -331,7 +358,10 @@ const RelayStationManager: React.FC = ({ onBack }) => + + + + + + {/* Toast 容器 */} + {toast && ( + + setToast(null)} + /> + + )} ); }; @@ -401,42 +482,122 @@ const CreateStationDialog: React.FC<{ name: '', description: '', api_url: '', - adapter: 'newapi', - auth_method: 'bearer_token', + adapter: 'packycode', // 默认使用 PackyCode + auth_method: 'api_key', // PackyCode 默认使用 API Key system_token: '', user_id: '', enabled: false, // 默认不启用,需要通过主界面切换 }); const [submitting, setSubmitting] = useState(false); + const [formToast, setFormToast] = useState<{ message: string; type: "success" | "error" } | null>(null); + const [packycodeService, setPackycodeService] = useState('bus'); // 默认公交车 + const [packycodeNode, setPackycodeNode] = useState('https://api.packycode.com'); // 默认节点(公交车用) + const [testingNodes, setTestingNodes] = useState(false); + const [nodeTestResults, setNodeTestResults] = useState([]); + const [autoSelectingNode, setAutoSelectingNode] = useState(false); const { t } = useTranslation(); + // 当适配器改变时更新认证方式和 URL + useEffect(() => { + if (formData.adapter === 'packycode') { + setFormData(prev => ({ + ...prev, + auth_method: 'api_key', // PackyCode 固定使用 API Key + api_url: packycodeService === 'taxi' + ? 'https://share-api.packycode.com' + : (packycodeNode === 'auto' ? 'https://api.packycode.com' : packycodeNode) + })); + } else if (formData.adapter === 'custom') { + setFormData(prev => ({ + ...prev, + auth_method: 'custom' + })); + } else { + setFormData(prev => ({ + ...prev, + auth_method: 'bearer_token' + })); + } + }, [formData.adapter, packycodeService, packycodeNode]); + + // 自动填充中转站名称 + const fillStationName = (serviceType: string) => { + const serviceName = serviceType === 'taxi' ? t('relayStation.taxiService') : t('relayStation.busService'); + const newName = `PackyCode ${serviceName}`; + + // 如果名称为空,或者当前名称是之前自动生成的PackyCode名称,则更新 + if (!formData.name.trim() || + formData.name.startsWith('PackyCode ') || + formData.name === `PackyCode ${t('relayStation.taxiService')}` || + formData.name === `PackyCode ${t('relayStation.busService')}`) { + setFormData(prev => ({ + ...prev, + name: newName + })); + } + }; + + // 测试所有节点速度(仅公交车服务需要) + const testAllNodes = async () => { + setTestingNodes(true); + try { + // 不需要 token,只测试网络延时 + const results = await api.testAllPackycodeNodes('dummy_token'); + setNodeTestResults(results); + setFormToast({ message: t('relayStation.testCompleted'), type: "success" }); + } catch (error) { + console.error('Failed to test nodes:', error); + setFormToast({ message: t('relayStation.testFailed'), type: "error" }); + } finally { + setTestingNodes(false); + } + }; + + // 自动选择最快节点(仅公交车服务需要) + const autoSelectBestNode = async () => { + setAutoSelectingNode(true); + try { + // 不需要 token,只测试网络延时 + const bestNode = await api.autoSelectBestNode('dummy_token'); + setPackycodeNode(bestNode.url); + setFormToast({ + message: `${t('relayStation.autoSelectedNode')}: ${bestNode.name} (${bestNode.response_time}ms)`, + type: "success" + }); + } catch (error) { + console.error('Failed to auto-select node:', error); + setFormToast({ message: t('relayStation.autoSelectFailed'), type: "error" }); + } finally { + setAutoSelectingNode(false); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!formData.name.trim()) { - alert(t('relayStation.nameRequired')); + setFormToast({ message: t('relayStation.nameRequired'), type: "error" }); return; } if (!formData.api_url.trim()) { - alert(t('relayStation.apiUrlRequired')); + setFormToast({ message: t('relayStation.apiUrlRequired'), type: "error" }); return; } if (!formData.system_token.trim()) { - alert(t('relayStation.tokenRequired')); + setFormToast({ message: t('relayStation.tokenRequired'), type: "error" }); return; } try { setSubmitting(true); await api.relayStationCreate(formData); - alert(t('relayStation.createSuccess')); onSuccess(); } catch (error) { console.error('Failed to create station:', error); - alert(t('relayStation.createFailed')); + setFormToast({ message: t('relayStation.createFailed'), type: "error" }); } finally { setSubmitting(false); } @@ -447,8 +608,8 @@ const CreateStationDialog: React.FC<{ {t('relayStation.createTitle')} -
-
+ +
setFormData(prev => ({ ...prev, name: e.target.value }))} placeholder={t('relayStation.namePlaceholder')} + className="w-full" />
@@ -467,10 +629,11 @@ const CreateStationDialog: React.FC<{ setFormData(prev => ({ ...prev, adapter: value })) } > - + + PackyCode NewAPI OneAPI YourAPI @@ -480,6 +643,165 @@ const CreateStationDialog: React.FC<{
+ {formData.adapter === 'packycode' && ( +
+
+ +
+ + + +
+

+ {packycodeService === 'taxi' + ? `${t('relayStation.fixedUrl')}: https://share-api.packycode.com` + : t('relayStation.busServiceNote') + } +

+
+
+ )} + + {formData.adapter === 'packycode' && packycodeService === 'bus' && ( +
+ +
+
+
+ +
+ +
+ + {/* 显示测速结果 */} + {nodeTestResults.length > 0 && ( +
+

{t('relayStation.testResults')}:

+
+ {nodeTestResults.map((result, index) => ( +
+ + {result.success ? ( + + ) : ( + + )} + {result.node.name} + + + {result.success ? `${result.response_time}ms` : t('relayStation.failed')} + +
+ ))} +
+
+ )} + +

+ {autoSelectingNode + ? t('relayStation.selectingBestNode') + : packycodeNode === 'auto' + ? t('relayStation.autoSelectDesc') + : t('relayStation.selectedNode') + ': ' + packycodeNode} +

+
+
+ )} +