diff --git a/src-tauri/src/commands/global_proxy.rs b/src-tauri/src/commands/global_proxy.rs new file mode 100644 index 000000000..6a48de3e2 --- /dev/null +++ b/src-tauri/src/commands/global_proxy.rs @@ -0,0 +1,132 @@ +//! 全局出站代理相关命令 +//! +//! 提供获取、设置和测试全局代理的 Tauri 命令。 + +use crate::database::Database; +use crate::proxy::http_client; +use serde::Serialize; +use std::sync::Arc; +use std::time::Instant; + +/// 获取全局代理 URL +/// +/// 返回当前配置的代理 URL,null 表示直连。 +#[tauri::command] +pub fn get_global_proxy_url(db: tauri::State<'_, Arc>) -> Result, String> { + db.get_global_proxy_url().map_err(|e| e.to_string()) +} + +/// 设置全局代理 URL +/// +/// - 传入非空字符串:启用代理 +/// - 传入空字符串:清除代理(直连) +#[tauri::command] +pub fn set_global_proxy_url( + db: tauri::State<'_, Arc>, + url: String, +) -> Result<(), String> { + let url_opt = if url.trim().is_empty() { + None + } else { + Some(url.as_str()) + }; + + // 1. 保存到数据库 + db.set_global_proxy_url(url_opt).map_err(|e| e.to_string())?; + + // 2. 热更新全局 HTTP 客户端 + http_client::update_proxy(url_opt)?; + + log::info!( + "[GlobalProxy] Configuration updated: {}", + url_opt.unwrap_or("direct connection") + ); + + Ok(()) +} + +/// 代理测试结果 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProxyTestResult { + /// 是否连接成功 + pub success: bool, + /// 延迟(毫秒) + pub latency_ms: u64, + /// 错误信息 + pub error: Option, +} + +/// 测试代理连接 +/// +/// 通过指定的代理 URL 发送测试请求,返回连接结果和延迟。 +#[tauri::command] +pub async fn test_proxy_url(url: String) -> Result { + if url.trim().is_empty() { + return Err("Proxy URL is empty".to_string()); + } + + let start = Instant::now(); + + // 构建带代理的临时客户端 + let proxy = reqwest::Proxy::all(&url).map_err(|e| format!("Invalid proxy URL: {}", e))?; + + let client = reqwest::Client::builder() + .proxy(proxy) + .timeout(std::time::Duration::from_secs(10)) + .connect_timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| format!("Failed to build client: {}", e))?; + + // 测试连接到 api.anthropic.com + // 使用 HEAD 请求,即使返回 401 也说明网络通了 + let test_url = "https://api.anthropic.com"; + + match client.head(test_url).send().await { + Ok(resp) => { + let latency = start.elapsed().as_millis() as u64; + log::debug!( + "[GlobalProxy] Test successful: {} -> {} ({}ms)", + url, + resp.status(), + latency + ); + Ok(ProxyTestResult { + success: true, + latency_ms: latency, + error: None, + }) + } + Err(e) => { + let latency = start.elapsed().as_millis() as u64; + log::debug!("[GlobalProxy] Test failed: {} -> {} ({}ms)", url, e, latency); + Ok(ProxyTestResult { + success: false, + latency_ms: latency, + error: Some(e.to_string()), + }) + } + } +} + +/// 获取当前出站代理状态 +/// +/// 返回当前是否启用了出站代理以及代理 URL。 +#[tauri::command] +pub fn get_upstream_proxy_status() -> UpstreamProxyStatus { + let url = http_client::get_current_proxy_url(); + UpstreamProxyStatus { + enabled: url.is_some(), + proxy_url: url, + } +} + +/// 出站代理状态信息 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpstreamProxyStatus { + /// 是否启用代理 + pub enabled: bool, + /// 代理 URL + pub proxy_url: Option, +} diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 36ffd2b63..012d10ebb 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -85,11 +85,8 @@ pub async fn get_tool_versions() -> Result, String> { let tools = vec!["claude", "codex", "gemini"]; let mut results = Vec::new(); - // 用于获取远程版本的 client - let client = reqwest::Client::builder() - .user_agent("cc-switch/1.0") - .build() - .map_err(|e| e.to_string())?; + // 使用全局 HTTP 客户端(已包含代理配置) + let client = crate::proxy::http_client::get(); for tool in tools { // 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径 diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 85dfb3adc..09b48b646 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ mod config; mod deeplink; mod env; mod failover; +mod global_proxy; mod import_export; mod mcp; mod misc; @@ -20,6 +21,7 @@ pub use config::*; pub use deeplink::*; pub use env::*; pub use failover::*; +pub use global_proxy::*; pub use import_export::*; pub use mcp::*; pub use misc::*; diff --git a/src-tauri/src/database/dao/settings.rs b/src-tauri/src/database/dao/settings.rs index 65d2328f9..01cb6e640 100644 --- a/src-tauri/src/database/dao/settings.rs +++ b/src-tauri/src/database/dao/settings.rs @@ -63,6 +63,41 @@ impl Database { } } + // --- 全局出站代理 --- + + /// 全局代理 URL 的存储键名 + const GLOBAL_PROXY_URL_KEY: &'static str = "global_proxy_url"; + + /// 获取全局出站代理 URL + /// + /// 返回 None 表示未配置代理(直连) + /// 返回 Some("") 或 Some(url) 需要调用方判断是否为空 + pub fn get_global_proxy_url(&self) -> Result, AppError> { + self.get_setting(Self::GLOBAL_PROXY_URL_KEY) + } + + /// 设置全局出站代理 URL + /// + /// - 传入非空字符串:启用代理 + /// - 传入空字符串或 None:清除代理设置(直连) + pub fn set_global_proxy_url(&self, url: Option<&str>) -> Result<(), AppError> { + match url { + Some(u) if !u.trim().is_empty() => { + self.set_setting(Self::GLOBAL_PROXY_URL_KEY, u.trim()) + } + _ => { + // 清除代理设置 + let conn = lock_conn!(self.conn); + conn.execute( + "DELETE FROM settings WHERE key = ?1", + params![Self::GLOBAL_PROXY_URL_KEY], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + } + } + // --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)--- /// 获取指定应用的代理接管状态 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1869a9b58..69c69444e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -643,6 +643,19 @@ pub fn run() { let skill_service = SkillService::new(); app.manage(commands::skill::SkillServiceState(Arc::new(skill_service))); + // 初始化全局出站代理 HTTP 客户端 + { + let proxy_url = app + .state::() + .db + .get_global_proxy_url() + .ok() + .flatten(); + if let Err(e) = crate::proxy::http_client::init(proxy_url.as_deref()) { + log::error!("[GlobalProxy] Failed to initialize: {e}"); + } + } + // 异常退出恢复 + 代理状态自动恢复 let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { @@ -838,6 +851,11 @@ pub fn run() { commands::upsert_universal_provider, commands::delete_universal_provider, commands::sync_universal_provider, + // Global upstream proxy + commands::get_global_proxy_url, + commands::set_global_proxy_url, + commands::test_proxy_url, + commands::get_upstream_proxy_status, ]); let app = builder diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index b3d47fcf3..7b0b6a1e3 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -15,7 +15,6 @@ use crate::{app_config::AppType, provider::Provider}; use reqwest::{Client, Response}; use serde_json::Value; use std::sync::Arc; -use std::time::Duration; use tokio::sync::RwLock; /// Headers 黑名单 - 不透传到上游的 Headers @@ -99,7 +98,7 @@ impl RequestForwarder { #[allow(clippy::too_many_arguments)] pub fn new( router: Arc, - non_streaming_timeout: u64, + _non_streaming_timeout: u64, status: Arc>, current_providers: Arc>>, failover_manager: Arc, @@ -108,45 +107,13 @@ impl RequestForwarder { _streaming_first_byte_timeout: u64, _streaming_idle_timeout: u64, ) -> Self { - // 全局超时设置为 1800 秒(30 分钟),确保业务层超时配置能正常工作 - // 参考 Claude Code Hub 的 undici 全局超时设计 - const GLOBAL_TIMEOUT_SECS: u64 = 1800; - - let timeout_secs = if non_streaming_timeout > 0 { - non_streaming_timeout - } else { - GLOBAL_TIMEOUT_SECS - }; - - // 注意:这里不能用 expect/unwrap。 - // release 配置为 panic=abort,一旦 build 失败会导致整个应用闪退。 - // 常见原因:用户环境变量里存在不合法/不支持的代理(HTTP(S)_PROXY/ALL_PROXY 等)。 - let (client, client_init_error) = match Client::builder() - .timeout(Duration::from_secs(timeout_secs)) - .build() - { - Ok(client) => (Some(client), None), - Err(e) => { - // 降级:忽略系统/环境代理,避免因代理配置问题导致整个应用崩溃 - match Client::builder() - .timeout(Duration::from_secs(timeout_secs)) - .no_proxy() - .build() - { - Ok(client) => (Some(client), Some(e.to_string())), - Err(fallback_err) => ( - None, - Some(format!( - "Failed to create HTTP client: {e}; no_proxy fallback failed: {fallback_err}" - )), - ), - } - } - }; + // 使用全局 HTTP 客户端(已包含代理配置) + // 如果全局客户端未初始化,会自动创建默认客户端 + let client = super::http_client::get(); Self { - client, - client_init_error, + client: Some(client), + client_init_error: None, router, status, current_providers, diff --git a/src-tauri/src/proxy/http_client.rs b/src-tauri/src/proxy/http_client.rs new file mode 100644 index 000000000..943d8000b --- /dev/null +++ b/src-tauri/src/proxy/http_client.rs @@ -0,0 +1,192 @@ +//! 全局 HTTP 客户端模块 +//! +//! 提供支持全局代理配置的 HTTP 客户端。 +//! 所有需要发送 HTTP 请求的模块都应使用此模块提供的客户端。 + +use once_cell::sync::OnceCell; +use reqwest::Client; +use std::sync::RwLock; +use std::time::Duration; + +/// 全局 HTTP 客户端实例 +static GLOBAL_CLIENT: OnceCell> = OnceCell::new(); + +/// 当前代理 URL(用于日志和状态查询) +static CURRENT_PROXY_URL: OnceCell>> = OnceCell::new(); + +/// 初始化全局 HTTP 客户端 +/// +/// 应在应用启动时调用一次。 +/// +/// # Arguments +/// * `proxy_url` - 代理 URL,如 `http://127.0.0.1:7890` 或 `socks5://127.0.0.1:1080` +/// 传入 None 或空字符串表示直连 +pub fn init(proxy_url: Option<&str>) -> Result<(), String> { + let effective_url = proxy_url.filter(|s| !s.trim().is_empty()); + let client = build_client(effective_url)?; + + let _ = GLOBAL_CLIENT.set(RwLock::new(client)); + let _ = CURRENT_PROXY_URL.set(RwLock::new(effective_url.map(|s| s.to_string()))); + + log::info!( + "[GlobalProxy] Initialized: {}", + effective_url.unwrap_or("direct connection") + ); + + Ok(()) +} + +/// 更新代理配置(热更新) +/// +/// 可在运行时调用以更改代理设置,无需重启应用。 +/// +/// # Arguments +/// * `proxy_url` - 新的代理 URL,None 或空字符串表示直连 +pub fn update_proxy(proxy_url: Option<&str>) -> Result<(), String> { + let effective_url = proxy_url.filter(|s| !s.trim().is_empty()); + let new_client = build_client(effective_url)?; + + // 更新客户端 + if let Some(lock) = GLOBAL_CLIENT.get() { + if let Ok(mut client) = lock.write() { + *client = new_client; + } + } else { + // 如果还没初始化,则初始化 + return init(proxy_url); + } + + // 更新代理 URL 记录 + if let Some(lock) = CURRENT_PROXY_URL.get() { + if let Ok(mut url) = lock.write() { + *url = effective_url.map(|s| s.to_string()); + } + } + + log::info!( + "[GlobalProxy] Updated: {}", + effective_url.unwrap_or("direct connection") + ); + + Ok(()) +} + +/// 获取全局 HTTP 客户端 +/// +/// 返回配置了代理的客户端(如果已配置代理),否则返回直连客户端。 +pub fn get() -> Client { + GLOBAL_CLIENT + .get() + .and_then(|lock| lock.read().ok()) + .map(|c| c.clone()) + .unwrap_or_else(|| { + // 如果还没初始化,创建一个默认客户端 + log::warn!("[GlobalProxy] Client not initialized, using default"); + Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .unwrap_or_default() + }) +} + +/// 获取当前代理 URL +/// +/// 返回当前配置的代理 URL,None 表示直连。 +pub fn get_current_proxy_url() -> Option { + CURRENT_PROXY_URL + .get() + .and_then(|lock| lock.read().ok()) + .and_then(|url| url.clone()) +} + +/// 检查是否正在使用代理 +pub fn is_proxy_enabled() -> bool { + get_current_proxy_url().is_some() +} + +/// 构建 HTTP 客户端 +fn build_client(proxy_url: Option<&str>) -> Result { + let mut builder = Client::builder() + .timeout(Duration::from_secs(600)) + .connect_timeout(Duration::from_secs(30)) + .pool_max_idle_per_host(10) + .tcp_keepalive(Duration::from_secs(60)); + + // 有代理地址则使用代理,否则直连 + if let Some(url) = proxy_url { + let proxy = reqwest::Proxy::all(url).map_err(|e| { + format!("Invalid proxy URL '{}': {}", mask_url(url), e) + })?; + builder = builder.proxy(proxy); + log::debug!("[GlobalProxy] Proxy configured: {}", mask_url(url)); + } else { + builder = builder.no_proxy(); + log::debug!("[GlobalProxy] Direct connection (no proxy)"); + } + + builder + .build() + .map_err(|e| format!("Failed to build HTTP client: {}", e)) +} + +/// 隐藏 URL 中的敏感信息(用于日志) +fn mask_url(url: &str) -> String { + if let Ok(parsed) = url::Url::parse(url) { + // 隐藏用户名和密码 + format!( + "{}://{}:{}", + parsed.scheme(), + parsed.host_str().unwrap_or("?"), + parsed.port().map(|p| p.to_string()).unwrap_or_else(|| "?".to_string()) + ) + } else { + // URL 解析失败,返回部分内容 + if url.len() > 20 { + format!("{}...", &url[..20]) + } else { + url.to_string() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mask_url() { + assert_eq!(mask_url("http://127.0.0.1:7890"), "http://127.0.0.1:7890"); + assert_eq!( + mask_url("http://user:pass@127.0.0.1:7890"), + "http://127.0.0.1:7890" + ); + assert_eq!( + mask_url("socks5://admin:secret@proxy.example.com:1080"), + "socks5://proxy.example.com:1080" + ); + } + + #[test] + fn test_build_client_direct() { + let result = build_client(None); + assert!(result.is_ok()); + } + + #[test] + fn test_build_client_with_http_proxy() { + let result = build_client(Some("http://127.0.0.1:7890")); + assert!(result.is_ok()); + } + + #[test] + fn test_build_client_with_socks5_proxy() { + let result = build_client(Some("socks5://127.0.0.1:1080")); + assert!(result.is_ok()); + } + + #[test] + fn test_build_client_invalid_url() { + let result = build_client(Some("not-a-valid-url")); + assert!(result.is_err()); + } +} diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index bcd946a77..9d49a717b 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -6,6 +6,7 @@ pub mod body_filter; pub mod circuit_breaker; pub mod error; pub mod error_mapper; +pub mod http_client; pub(crate) mod failover_switch; mod forwarder; pub mod handler_config; diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 1729eda5e..f6743e778 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -156,11 +156,8 @@ impl Default for SkillService { impl SkillService { pub fn new() -> Self { Self { - http_client: Client::builder() - .user_agent("cc-switch") - .timeout(std::time::Duration::from_secs(10)) - .build() - .expect("Failed to create HTTP client"), + // 使用全局 HTTP 客户端(已包含代理配置) + http_client: crate::proxy::http_client::get(), } } diff --git a/src-tauri/src/services/speedtest.rs b/src-tauri/src/services/speedtest.rs index 57916e98d..85fc5498a 100644 --- a/src-tauri/src/services/speedtest.rs +++ b/src-tauri/src/services/speedtest.rs @@ -1,7 +1,7 @@ use futures::future::join_all; use reqwest::{Client, Url}; use serde::Serialize; -use std::time::{Duration, Instant}; +use std::time::Instant; use crate::error::AppError; @@ -112,19 +112,11 @@ impl SpeedtestService { Ok(results.into_iter().flatten().collect::>()) } - fn build_client(timeout_secs: u64) -> Result { - Client::builder() - .timeout(Duration::from_secs(timeout_secs)) - .redirect(reqwest::redirect::Policy::limited(5)) - .user_agent("cc-switch-speedtest/1.0") - .build() - .map_err(|e| { - AppError::localized( - "speedtest.client_create_failed", - format!("创建 HTTP 客户端失败: {e}"), - format!("Failed to create HTTP client: {e}"), - ) - }) + fn build_client(_timeout_secs: u64) -> Result { + // 使用全局 HTTP 客户端(已包含代理配置) + // 注意:speedtest 使用全局客户端的代理配置,如果需要单独的超时设置 + // 可以考虑在请求级别设置超时 + Ok(crate::proxy::http_client::get()) } fn sanitize_timeout(timeout_secs: Option) -> u64 { diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index d8a5db6f7..3a09b3615 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -7,7 +7,7 @@ use regex::Regex; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::json; -use std::time::{Duration, Instant}; +use std::time::Instant; use crate::app_config::AppType; use crate::error::AppError; @@ -136,11 +136,8 @@ impl StreamCheckService { .extract_auth(provider) .ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?; - let client = Client::builder() - .timeout(Duration::from_secs(config.timeout_secs)) - .user_agent("cc-switch/1.0") - .build() - .map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?; + // 使用全局 HTTP 客户端(已包含代理配置) + let client = crate::proxy::http_client::get(); let model_to_test = Self::resolve_test_model(app_type, provider, config); diff --git a/src-tauri/src/usage_script.rs b/src-tauri/src/usage_script.rs index 2fe515c28..237309875 100644 --- a/src-tauri/src/usage_script.rs +++ b/src-tauri/src/usage_script.rs @@ -1,8 +1,6 @@ -use reqwest::Client; use rquickjs::{Context, Function, Runtime}; use serde_json::Value; use std::collections::HashMap; -use std::time::Duration; use url::{Host, Url}; use crate::error::AppError; @@ -214,19 +212,9 @@ struct RequestConfig { } /// 发送 HTTP 请求 -async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result { - // 约束超时范围,防止异常配置导致长时间阻塞 - let timeout = timeout_secs.clamp(2, 30); - let client = Client::builder() - .timeout(Duration::from_secs(timeout)) - .build() - .map_err(|e| { - AppError::localized( - "usage_script.client_create_failed", - format!("创建客户端失败: {e}"), - format!("Failed to create client: {e}"), - ) - })?; +async fn send_http_request(config: &RequestConfig, _timeout_secs: u64) -> Result { + // 使用全局 HTTP 客户端(已包含代理配置) + let client = crate::proxy::http_client::get(); // 严格校验 HTTP 方法,非法值不回退为 GET let method: reqwest::Method = config.method.parse().map_err(|_| { diff --git a/src/components/settings/GlobalProxySettings.tsx b/src/components/settings/GlobalProxySettings.tsx new file mode 100644 index 000000000..ef01966ab --- /dev/null +++ b/src/components/settings/GlobalProxySettings.tsx @@ -0,0 +1,114 @@ +/** + * 全局出站代理设置组件 + * + * 提供配置全局代理的输入界面。 + */ + +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Loader2, TestTube2, Globe } from "lucide-react"; +import { + useGlobalProxyUrl, + useSetGlobalProxyUrl, + useTestProxy, +} from "@/hooks/useGlobalProxy"; + +export function GlobalProxySettings() { + const { t } = useTranslation(); + const { data: savedUrl, isLoading } = useGlobalProxyUrl(); + const setMutation = useSetGlobalProxyUrl(); + const testMutation = useTestProxy(); + + const [url, setUrl] = useState(""); + const [dirty, setDirty] = useState(false); + + // 同步远程配置 + useEffect(() => { + if (savedUrl !== undefined) { + setUrl(savedUrl || ""); + setDirty(false); + } + }, [savedUrl]); + + const handleSave = async () => { + await setMutation.mutateAsync(url.trim()); + setDirty(false); + }; + + const handleTest = async () => { + if (url.trim()) { + await testMutation.mutateAsync(url.trim()); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && dirty && !setMutation.isPending) { + handleSave(); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* 标题 */} +
+ + +
+ + {/* 描述 */} +

+ {t("settings.globalProxy.hint")} +

+ + {/* 输入框和按钮 */} +
+ { + setUrl(e.target.value); + setDirty(true); + }} + onKeyDown={handleKeyDown} + className="font-mono text-sm flex-1" + /> + + +
+
+ ); +} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 5f5c725c2..67ac3a065 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -34,6 +34,7 @@ import { WindowSettings } from "@/components/settings/WindowSettings"; import { DirectorySettings } from "@/components/settings/DirectorySettings"; import { ImportExportSection } from "@/components/settings/ImportExportSection"; import { AboutSection } from "@/components/settings/AboutSection"; +import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings"; import { ProxyPanel } from "@/components/proxy"; import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel"; import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel"; @@ -238,6 +239,7 @@ export function SettingsPage({ settings={settings} onChange={handleAutoSave} /> + ) : null} diff --git a/src/hooks/useGlobalProxy.ts b/src/hooks/useGlobalProxy.ts new file mode 100644 index 000000000..711563d5b --- /dev/null +++ b/src/hooks/useGlobalProxy.ts @@ -0,0 +1,84 @@ +/** + * 全局出站代理 React Hooks + * + * 提供获取、设置和测试全局代理的 React Query hooks。 + */ + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { + getGlobalProxyUrl, + setGlobalProxyUrl, + testProxyUrl, + getUpstreamProxyStatus, + type ProxyTestResult, + type UpstreamProxyStatus, +} from "@/lib/api/globalProxy"; + +/** + * 获取全局代理 URL + */ +export function useGlobalProxyUrl() { + return useQuery({ + queryKey: ["globalProxyUrl"], + queryFn: getGlobalProxyUrl, + }); +} + +/** + * 设置全局代理 URL + */ +export function useSetGlobalProxyUrl() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: setGlobalProxyUrl, + onSuccess: () => { + toast.success(t("settings.globalProxy.saved")); + queryClient.invalidateQueries({ queryKey: ["globalProxyUrl"] }); + queryClient.invalidateQueries({ queryKey: ["upstreamProxyStatus"] }); + }, + onError: (error: Error) => { + toast.error( + t("settings.globalProxy.saveFailed", { error: error.message }) + ); + }, + }); +} + +/** + * 测试代理连接 + */ +export function useTestProxy() { + const { t } = useTranslation(); + + return useMutation({ + mutationFn: testProxyUrl, + onSuccess: (result: ProxyTestResult) => { + if (result.success) { + toast.success( + t("settings.globalProxy.testSuccess", { latency: result.latencyMs }) + ); + } else { + toast.error( + t("settings.globalProxy.testFailed", { error: result.error }) + ); + } + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +/** + * 获取当前出站代理状态 + */ +export function useUpstreamProxyStatus() { + return useQuery({ + queryKey: ["upstreamProxyStatus"], + queryFn: getUpstreamProxyStatus, + }); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 51e5356ed..76ad513f1 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -271,7 +271,16 @@ "restartLater": "Restart Later", "restartFailed": "Application restart failed, please manually close and reopen.", "devModeRestartHint": "Dev Mode: Automatic restart not supported, please manually restart the application.", - "saving": "Saving..." + "saving": "Saving...", + "globalProxy": { + "label": "Global Proxy", + "hint": "Proxy all requests (API, Skills download, etc.). Leave empty for direct connection. Supports HTTP and SOCKS5.", + "test": "Test Connection", + "saved": "Proxy settings saved", + "saveFailed": "Save failed: {{error}}", + "testSuccess": "Connected! Latency {{latency}}ms", + "testFailed": "Connection failed: {{error}}" + } }, "apps": { "claude": "Claude Code", @@ -1229,4 +1238,4 @@ "configJsonPreview": "Config JSON Preview", "configJsonPreviewHint": "The following configurations will be synced to each app (only the displayed fields will be overwritten, other custom settings will be preserved)" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index d1dcec2bf..a6f5e95bc 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -271,7 +271,16 @@ "restartLater": "稍后重启", "restartFailed": "应用重启失败,请手动关闭后重新打开。", "devModeRestartHint": "开发模式下不支持自动重启,请手动重新启动应用。", - "saving": "正在保存..." + "saving": "正在保存...", + "globalProxy": { + "label": "全局代理", + "hint": "代理所有请求(API、Skills 下载等)。留空表示直连。支持 HTTP 和 SOCKS5 代理。", + "test": "测试连接", + "saved": "代理设置已保存", + "saveFailed": "保存失败:{{error}}", + "testSuccess": "连接成功!延迟 {{latency}}ms", + "testFailed": "连接失败:{{error}}" + } }, "apps": { "claude": "Claude Code", @@ -1229,4 +1238,4 @@ "configJsonPreview": "配置 JSON 预览", "configJsonPreviewHint": "以下是将要同步到各应用的配置内容(仅覆盖显示的字段,保留其他自定义配置)" } -} +} \ No newline at end of file diff --git a/src/lib/api/globalProxy.ts b/src/lib/api/globalProxy.ts new file mode 100644 index 000000000..f9f14a543 --- /dev/null +++ b/src/lib/api/globalProxy.ts @@ -0,0 +1,62 @@ +/** + * 全局出站代理 API + * + * 提供获取、设置和测试全局代理的功能。 + */ + +import { invoke } from "@tauri-apps/api/core"; + +/** + * 代理测试结果 + */ +export interface ProxyTestResult { + success: boolean; + latencyMs: number; + error: string | null; +} + +/** + * 出站代理状态 + */ +export interface UpstreamProxyStatus { + enabled: boolean; + proxyUrl: string | null; +} + +/** + * 获取全局代理 URL + * + * @returns 代理 URL,null 表示未配置(直连) + */ +export async function getGlobalProxyUrl(): Promise { + return invoke("get_global_proxy_url"); +} + +/** + * 设置全局代理 URL + * + * @param url - 代理 URL(如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080) + * 空字符串表示清除代理(直连) + */ +export async function setGlobalProxyUrl(url: string): Promise { + return invoke("set_global_proxy_url", { url }); +} + +/** + * 测试代理连接 + * + * @param url - 要测试的代理 URL + * @returns 测试结果,包含是否成功、延迟和错误信息 + */ +export async function testProxyUrl(url: string): Promise { + return invoke("test_proxy_url", { url }); +} + +/** + * 获取当前出站代理状态 + * + * @returns 代理状态,包含是否启用和代理 URL + */ +export async function getUpstreamProxyStatus(): Promise { + return invoke("get_upstream_proxy_status"); +}