From 3bd38cf0f44c8e847c9897b61acb5e43daaaab16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cchaoji233=E2=80=9D?= Date: Tue, 13 Jan 2026 00:45:17 +0800 Subject: [PATCH 01/11] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E6=A8=A1=E5=9D=97=E5=B9=B6=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E7=BB=93=E6=9E=84=EF=BC=8C=E6=9B=B4=E6=98=93=E7=BB=B4?= =?UTF-8?q?=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/autostart.rs | 25 + src-tauri/src/commands/download.rs | 417 +++++++++++++ src-tauri/src/commands/http.rs | 58 ++ src-tauri/src/commands/mod.rs | 12 + src-tauri/src/commands/process.rs | 322 ++++++++++ src-tauri/src/lib.rs | 937 +--------------------------- src-tauri/src/models.rs | 81 +++ src-tauri/src/utils.rs | 32 + 8 files changed, 966 insertions(+), 918 deletions(-) create mode 100644 src-tauri/src/commands/autostart.rs create mode 100644 src-tauri/src/commands/download.rs create mode 100644 src-tauri/src/commands/http.rs create mode 100644 src-tauri/src/commands/mod.rs create mode 100644 src-tauri/src/commands/process.rs create mode 100644 src-tauri/src/models.rs create mode 100644 src-tauri/src/utils.rs diff --git a/src-tauri/src/commands/autostart.rs b/src-tauri/src/commands/autostart.rs new file mode 100644 index 0000000..e7a08d9 --- /dev/null +++ b/src-tauri/src/commands/autostart.rs @@ -0,0 +1,25 @@ +#[tauri::command] +pub async fn is_autostart_enabled( + state: tauri::State<'_, tauri_plugin_autostart::AutoLaunchManager>, +) -> Result { + state + .is_enabled() + .map_err(|e| format!("检查开机自启状态失败: {}", e)) +} + +#[tauri::command] +pub async fn set_autostart( + enabled: bool, + state: tauri::State<'_, tauri_plugin_autostart::AutoLaunchManager>, +) -> Result<(), String> { + if enabled { + state + .enable() + .map_err(|e| format!("启用开机自启失败: {}", e)) + } else { + state + .disable() + .map_err(|e| format!("禁用开机自启失败: {}", e)) + } +} + diff --git a/src-tauri/src/commands/download.rs b/src-tauri/src/commands/download.rs new file mode 100644 index 0000000..eb6b65e --- /dev/null +++ b/src-tauri/src/commands/download.rs @@ -0,0 +1,417 @@ +use crate::models::{DownloadInfo, DownloadProgress, FrpcDownload, FrpcInfoResponse}; +use futures_util::StreamExt; +use sha2::{Digest, Sha256}; +use std::fs; +use std::io::Read; +use tauri::{Emitter, Manager}; + +// 从 API 获取下载信息 +pub async fn get_download_info() -> Result { + let api_url = "https://cf-v1.uapis.cn/download/frpc/frpc_info.json"; + + let os = std::env::consts::OS; + let arch = std::env::consts::ARCH; + + let mut client_builder = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .user_agent("ChmlFrpLauncher/1.0"); + + // 检查是否需要绕过代理 + let bypass_proxy = std::env::var("BYPASS_PROXY") + .unwrap_or_else(|_| "true".to_string()) + .parse::() + .unwrap_or(true); + + if bypass_proxy { + client_builder = client_builder.no_proxy(); + } + + let client = client_builder + .build() + .map_err(|e| format!("Failed to create client: {}", e))?; + + let response = client + .get(api_url) + .send() + .await + .map_err(|e| format!("Failed to fetch frpc info: {}", e))?; + + if !response.status().is_success() { + return Err(format!("API request failed with status: {}", response.status())); + } + + let info_response: FrpcInfoResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse API response: {}", e))?; + + if info_response.code != 200 || info_response.state != "success" { + return Err(format!("API returned error: {}", info_response.msg)); + } + + let mut matched_downloads: Vec<&FrpcDownload> = Vec::new(); + + // 首先尝试通过 platform 字段匹配(更精确) + let platform = match (os, arch) { + ("windows", "x86_64") => "win_amd64.exe", + ("windows", "x86") => "win_386.exe", + ("windows", "aarch64") => "win_arm64.exe", + ("linux", "x86") => "linux_386", + ("linux", "x86_64") => "linux_amd64", + ("linux", "arm") => "linux_arm", + ("linux", "aarch64") => "linux_arm64", + ("linux", "mips64") => "linux_mips64", + ("linux", "mips") => "linux_mips", + ("linux", "riscv64") => "linux_riscv64", + ("macos", "x86_64") => "darwin_amd64", + ("macos", "aarch64") => "darwin_arm64", + _ => return Err(format!("Unsupported platform: {} {}", os, arch)), + }; + + for download in &info_response.data.downloads { + if download.platform == platform { + matched_downloads.push(download); + } + } + + // 如果 platform 匹配失败,尝试通过 os 和 arch 匹配 + if matched_downloads.is_empty() { + let target_os = match os { + "macos" => "darwin", + _ => os, + }; + + for download in &info_response.data.downloads { + if download.os == target_os { + let matches_arch = match (os, arch) { + ("windows", "x86_64") => download.arch == "x86_64", + ("windows", "x86") => download.arch == "x86", + ("windows", "aarch64") => download.arch == "aarch64", + ("linux", "x86") => download.arch == "x86", + ("linux", "x86_64") => download.arch == "x86_64", + ("linux", "arm") => download.arch == "arm", + ("linux", "aarch64") => download.arch == "aarch64" || download.arch == "arm", + ("linux", "mips64") => download.arch == "mips64", + ("linux", "mips") => download.arch == "mips", + ("linux", "riscv64") => download.arch == "riscv64", + ("macos", "x86_64") => download.arch == "x86_64", + ("macos", "aarch64") => download.arch == "aarch64", + _ => false, + }; + + if matches_arch { + matched_downloads.push(download); + } + } + } + } + + let download = if matched_downloads.is_empty() { + return Err(format!( + "No matching download found for platform: {} {}", + os, arch + )); + } else if matched_downloads.len() == 1 { + matched_downloads[0] + } else { + // 如果有多个匹配项,选择 size 最大的(通常是最新版本) + matched_downloads + .iter() + .max_by_key(|d| d.size) + .unwrap() + }; + + Ok(DownloadInfo { + url: download.link.clone(), + hash: download.hash.clone(), + size: download.size, + }) +} + +#[tauri::command] +pub async fn check_frpc_exists(app_handle: tauri::AppHandle) -> Result { + let app_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| e.to_string())?; + + let frpc_path = if cfg!(target_os = "windows") { + app_dir.join("frpc.exe") + } else { + app_dir.join("frpc") + }; + + Ok(frpc_path.exists()) +} + +#[tauri::command] +pub async fn get_download_url() -> Result { + let info = get_download_info().await?; + Ok(info.url) +} + +#[tauri::command] +pub async fn download_frpc(app_handle: tauri::AppHandle) -> Result { + // 从 API 获取下载信息(包括 URL、hash、size) + let download_info = get_download_info().await?; + let url = download_info.url; + let expected_hash = download_info.hash; + let expected_size = download_info.size; + + let app_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| e.to_string())?; + + fs::create_dir_all(&app_dir).map_err(|e| e.to_string())?; + + let frpc_path = if cfg!(target_os = "windows") { + app_dir.join("frpc.exe") + } else { + app_dir.join("frpc") + }; + + let mut client_builder = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(600)) + .connect_timeout(std::time::Duration::from_secs(30)) + .pool_idle_timeout(std::time::Duration::from_secs(90)) + .tcp_keepalive(std::time::Duration::from_secs(60)) + .user_agent("ChmlFrpLauncher/1.0"); + + // 检查是否需要绕过代理 + let bypass_proxy = std::env::var("BYPASS_PROXY") + .unwrap_or_else(|_| "true".to_string()) + .parse::() + .unwrap_or(true); + + if bypass_proxy { + client_builder = client_builder.no_proxy(); + } + + let client = client_builder + .build() + .map_err(|e| format!("Failed to create client: {}", e))?; + + // 使用 API 返回的文件大小,如果没有则尝试从 HTTP 响应获取 + let mut total_size: u64 = expected_size; + + // 如果 API 没有提供大小,尝试 HEAD 请求获取 + if total_size == 0 { + if let Ok(head_response) = client.head(&url).send().await { + if let Some(len) = head_response.content_length() { + total_size = len; + } + } + + if total_size == 0 { + eprintln!("HEAD 请求未获取文件大小,将从 GET 响应头获取"); + } + } + + use std::fs::OpenOptions; + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&frpc_path) + .map_err(|e| e.to_string())?; + + let mut downloaded: u64 = 0; + let mut retry_count = 0; + const MAX_RETRIES: u32 = 5; + const CHUNK_SIZE: u64 = 1024 * 1024; // 1MB 分块 + + // 断点续传 + loop { + let mut request = client.get(&url); + + if downloaded == 0 && total_size == 0 { + request = request.header("Range", format!("bytes=0-{}", CHUNK_SIZE - 1)); + } else if downloaded > 0 { + let end = if total_size > 0 { + std::cmp::min(downloaded + CHUNK_SIZE - 1, total_size - 1) + } else { + downloaded + CHUNK_SIZE - 1 + }; + request = request.header("Range", format!("bytes={}-{}", downloaded, end)); + } else if total_size > 0 { + let end = std::cmp::min(CHUNK_SIZE - 1, total_size - 1); + request = request.header("Range", format!("bytes=0-{}", end)); + } + + let response = match request.send().await { + Ok(resp) => resp, + Err(e) => { + retry_count += 1; + if retry_count >= MAX_RETRIES { + return Err(format!("下载失败,已重试 {} 次: {}", MAX_RETRIES, e)); + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + continue; + } + }; + + let status = response.status(); + let is_success = status.is_success() || status.as_u16() == 206; // 206 = Partial Content + if !is_success { + return Err(format!("下载失败,HTTP 状态码: {}", status)); + } + + if status.as_u16() == 206 { + if let Some(content_range) = response.headers().get("content-range") { + if let Ok(range_str) = content_range.to_str() { + // 格式: bytes start-end/total + if let Some(slash_pos) = range_str.rfind('/') { + if let Ok(size) = range_str[slash_pos + 1..].parse::() { + if size > 0 && total_size != size { + total_size = size; + } + } + } + } + } + } else if let Some(content_len) = response.content_length() { + if total_size == 0 { + total_size = content_len; + } + } + + retry_count = 0; + + let mut stream = response.bytes_stream(); + let mut chunk_error = false; + let mut this_chunk_size: u64 = 0; + + while let Some(item) = stream.next().await { + match item { + Ok(chunk) => { + use std::io::Write; + file.write_all(&chunk) + .map_err(|e| format!("写入文件失败: {}", e))?; + + let chunk_len = chunk.len() as u64; + downloaded += chunk_len; + this_chunk_size += chunk_len; + + let percentage = if total_size > 0 { + (downloaded as f64 / total_size as f64) * 100.0 + } else { + 0.0 + }; + + // 发送进度更新(每 100KB 发送一次) + if this_chunk_size >= 100 * 1024 { + let _ = app_handle.emit( + "download-progress", + DownloadProgress { + downloaded, + total: total_size, + percentage, + }, + ); + this_chunk_size = 0; + } + } + Err(_e) => { + chunk_error = true; + break; // 跳出内层循环,外层循环会重试 + } + } + } + + if !chunk_error { + if total_size > 0 && downloaded >= total_size { + break; + } + if total_size == 0 && this_chunk_size < CHUNK_SIZE { + break; + } + if this_chunk_size == 0 { + break; + } + } + + if chunk_error { + retry_count += 1; + if retry_count >= MAX_RETRIES { + return Err(format!("下载失败,已重试 {} 次", MAX_RETRIES)); + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + } + + use std::io::Write; + file.flush().map_err(|e| format!("刷新文件失败: {}", e))?; + + let _ = app_handle.emit( + "download-progress", + DownloadProgress { + downloaded, + total: total_size, + percentage: 100.0, + }, + ); + + // 验证下载的文件大小(如果知道预期大小) + if total_size > 0 && downloaded < total_size { + return Err(format!( + "下载不完整: 预期 {} bytes, 实际下载 {} bytes", + total_size, downloaded + )); + } + + if downloaded == 0 { + return Err("下载失败: 没有接收到任何数据".to_string()); + } + + // 验证 SHA256 hash + eprintln!("开始验证文件 hash..."); + let mut file_for_hash = std::fs::File::open(&frpc_path) + .map_err(|e| format!("无法打开文件进行 hash 验证: {}", e))?; + + let mut hasher = Sha256::new(); + let mut buffer = vec![0u8; 8192]; // 8KB 缓冲区 + + loop { + let bytes_read = file_for_hash + .read(&mut buffer) + .map_err(|e| format!("读取文件失败: {}", e))?; + + if bytes_read == 0 { + break; + } + + hasher.update(&buffer[..bytes_read]); + } + + let computed_hash = hasher.finalize(); + let computed_hash_hex = hex::encode(computed_hash); + + eprintln!("预期 hash: {}", expected_hash); + eprintln!("计算 hash: {}", computed_hash_hex); + + if computed_hash_hex.to_lowercase() != expected_hash.to_lowercase() { + // 删除损坏的文件 + let _ = fs::remove_file(&frpc_path); + return Err(format!( + "文件 hash 验证失败: 预期 {}, 实际 {}", + expected_hash, computed_hash_hex + )); + } + + eprintln!("文件 hash 验证成功"); + + // 在 Unix 系统上设置执行权限 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&frpc_path) + .map_err(|e| e.to_string())? + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&frpc_path, perms).map_err(|e| e.to_string())?; + } + + Ok(frpc_path.to_string_lossy().to_string()) +} + diff --git a/src-tauri/src/commands/http.rs b/src-tauri/src/commands/http.rs new file mode 100644 index 0000000..0a555ab --- /dev/null +++ b/src-tauri/src/commands/http.rs @@ -0,0 +1,58 @@ +use crate::models::HttpRequestOptions; + +#[tauri::command] +pub async fn http_request(options: HttpRequestOptions) -> Result { + let bypass_proxy = options.bypass_proxy.unwrap_or(true); + + let mut client_builder = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .user_agent("ChmlFrpLauncher/1.0"); + + // 如果绕过代理,使用自定义代理函数返回 None 来禁用代理 + if bypass_proxy { + client_builder = client_builder.proxy( + reqwest::Proxy::custom(move |_url| -> Option { None }) + ); + } + + let client = client_builder + .build() + .map_err(|e| format!("Failed to create client: {}", e))?; + + let mut request = match options.method.as_str() { + "GET" => client.get(&options.url), + "POST" => client.post(&options.url), + "PUT" => client.put(&options.url), + "DELETE" => client.delete(&options.url), + "PATCH" => client.patch(&options.url), + _ => return Err(format!("Unsupported method: {}", options.method)), + }; + + if let Some(headers) = options.headers { + for (key, value) in headers { + request = request.header(&key, &value); + } + } + + if let Some(body) = options.body { + request = request.body(body); + } + + let response = request + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + let status = response.status(); + let text = response + .text() + .await + .map_err(|e| format!("Failed to read response: {}", e))?; + + if !status.is_success() { + return Err(format!("HTTP {}: {}", status.as_u16(), text)); + } + + Ok(text) +} + diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..6f0d037 --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -0,0 +1,12 @@ +// 命令模块 +pub mod download; +pub mod process; +pub mod autostart; +pub mod http; + +// 重新导出所有命令函数,方便使用 +pub use download::*; +pub use process::*; +pub use autostart::*; +pub use http::*; + diff --git a/src-tauri/src/commands/process.rs b/src-tauri/src/commands/process.rs new file mode 100644 index 0000000..76c4fad --- /dev/null +++ b/src-tauri/src/commands/process.rs @@ -0,0 +1,322 @@ +use crate::models::{FrpcProcesses, LogMessage}; +use crate::utils::sanitize_log; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::process::{Command as StdCommand, Stdio}; +use std::thread; +use tauri::{Emitter, Manager, State}; + +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; + +#[tauri::command] +pub async fn start_frpc( + app_handle: tauri::AppHandle, + tunnel_id: i32, + user_token: String, + processes: State<'_, FrpcProcesses>, +) -> Result { + eprintln!("========================================"); + eprintln!("[隧道 {}] 开始启动 frpc", tunnel_id); + + { + let procs = processes.processes.lock().map_err(|e| { + eprintln!("[隧道 {}] 获取进程锁失败: {}", tunnel_id, e); + format!("获取进程锁失败: {}", e) + })?; + if procs.contains_key(&tunnel_id) { + eprintln!("[隧道 {}] 该隧道已在运行中", tunnel_id); + return Err("该隧道已在运行中".to_string()); + } + eprintln!("[隧道 {}] 当前管理的进程数: {}", tunnel_id, procs.len()); + } + + let app_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| e.to_string())?; + + let frpc_path = if cfg!(target_os = "windows") { + app_dir.join("frpc.exe") + } else { + app_dir.join("frpc") + }; + + eprintln!("[隧道 {}] frpc 路径: {:?}", tunnel_id, frpc_path); + + if !frpc_path.exists() { + eprintln!("[隧道 {}] frpc 文件不存在", tunnel_id); + return Err("frpc 未找到,请先下载".to_string()); + } + + // Unix系统上确保有执行权限 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let metadata = fs::metadata(&frpc_path).map_err(|e| e.to_string())?; + let mut perms = metadata.permissions(); + if perms.mode() & 0o111 == 0 { + eprintln!("[隧道 {}] 设置执行权限", tunnel_id); + perms.set_mode(0o755); + fs::set_permissions(&frpc_path, perms).map_err(|e| e.to_string())?; + } + } + + // 启动 frpc 进程,设置工作目录为应用数据目录 + eprintln!("[隧道 {}] 准备启动进程...", tunnel_id); + eprintln!("[隧道 {}] 工作目录: {:?}", tunnel_id, app_dir); + let mut cmd = StdCommand::new(&frpc_path); + cmd.current_dir(&app_dir) // 没有这个会在src-tauri目录生成frpc.ini文件 + .arg("-u") + .arg(&user_token) + .arg("-p") + .arg(tunnel_id.to_string()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + // Windows上隐藏控制台窗口 + #[cfg(target_os = "windows")] + { + cmd.creation_flags(0x08000000); + } + + let mut child = cmd.spawn() + .map_err(|e| { + eprintln!("[隧道 {}] 启动进程失败: {}", tunnel_id, e); + format!("启动 frpc 失败: {}", e) + })?; + + let pid = child.id(); + eprintln!("[隧道 {}] 进程已启动,PID: {}", tunnel_id, pid); + + // 发送启动日志(不包含敏感信息) + let timestamp = chrono::Local::now().format("%H:%M:%S").to_string(); + eprintln!("[隧道 {}] 发送启动日志事件", tunnel_id); + match app_handle.emit( + "frpc-log", + LogMessage { + tunnel_id, + message: format!("frpc 进程已启动 (PID: {}), 开始连接服务器...", pid), + timestamp: timestamp.clone(), + }, + ) { + Ok(_) => eprintln!("[隧道 {}] 启动日志事件已发送", tunnel_id), + Err(e) => eprintln!("[隧道 {}] 发送启动日志事件失败: {}", tunnel_id, e), + } + + // 捕获 stdout + if let Some(stdout) = child.stdout.take() { + let app_handle_clone = app_handle.clone(); + let tunnel_id_clone = tunnel_id; + let user_token_clone = user_token.clone(); + match thread::Builder::new() + .name(format!("frpc-stdout-{}", tunnel_id)) + .spawn(move || { + eprintln!("[线程 stdout-{}] 开始监听", tunnel_id_clone); + let reader = BufReader::new(stdout); + for line in reader.lines().flatten() { + // 去除 ANSI 颜色代码 + let clean_line = strip_ansi_escapes::strip_str(&line); + + // 隐藏用户 token + let sanitized_line = sanitize_log(&clean_line, &user_token_clone); + + let timestamp = chrono::Local::now().format("%H:%M:%S").to_string(); + if let Err(e) = app_handle_clone.emit( + "frpc-log", + LogMessage { + tunnel_id: tunnel_id_clone, + message: sanitized_line, + timestamp, + }, + ) { + eprintln!("[线程 stdout-{}] 发送日志事件失败: {}", tunnel_id_clone, e); + break; // 如果事件发送失败,停止监听 + } + } + eprintln!("[线程 stdout-{}] 结束监听", tunnel_id_clone); + }) { + Ok(_) => eprintln!("[隧道 {}] stdout 监听线程已启动", tunnel_id), + Err(e) => eprintln!("[隧道 {}] 创建 stdout 监听线程失败: {}", tunnel_id, e), + } + } + + // 捕获 stderr + if let Some(stderr) = child.stderr.take() { + let app_handle_clone = app_handle.clone(); + let tunnel_id_clone = tunnel_id; + let user_token_clone = user_token.clone(); + match thread::Builder::new() + .name(format!("frpc-stderr-{}", tunnel_id)) + .spawn(move || { + eprintln!("[线程 stderr-{}] 开始监听", tunnel_id_clone); + let reader = BufReader::new(stderr); + for line in reader.lines().flatten() { + // 去除 ANSI 颜色代码 + let clean_line = strip_ansi_escapes::strip_str(&line); + + // 隐藏用户 token + let sanitized_line = sanitize_log(&clean_line, &user_token_clone); + + let timestamp = chrono::Local::now().format("%H:%M:%S").to_string(); + if let Err(e) = app_handle_clone.emit( + "frpc-log", + LogMessage { + tunnel_id: tunnel_id_clone, + message: format!("[ERR] {}", sanitized_line), + timestamp, + }, + ) { + eprintln!("[线程 stderr-{}] 发送日志事件失败: {}", tunnel_id_clone, e); + break; + } + } + eprintln!("[线程 stderr-{}] 结束监听", tunnel_id_clone); + }) { + Ok(_) => eprintln!("[隧道 {}] stderr 监听线程已启动", tunnel_id), + Err(e) => eprintln!("[隧道 {}] 创建 stderr 监听线程失败: {}", tunnel_id, e), + } + } + + { + let mut procs = processes + .processes + .lock() + .map_err(|e| format!("获取进程锁失败: {}", e))?; + eprintln!("[隧道 {}] 将进程 PID {} 存储到管理器", tunnel_id, pid); + procs.insert(tunnel_id, child); + eprintln!( + "[隧道 {}] 进程存储成功,当前管理的进程数: {}", + tunnel_id, + procs.len() + ); + } + + eprintln!("[隧道 {}] frpc 启动完成", tunnel_id); + Ok(format!("frpc 已启动 (PID: {})", pid)) +} + +#[tauri::command] +pub async fn stop_frpc(tunnel_id: i32, processes: State<'_, FrpcProcesses>) -> Result { + eprintln!("[隧道 {}] 请求停止进程", tunnel_id); + + let mut procs = processes + .processes + .lock() + .map_err(|e| format!("获取进程锁失败: {}", e))?; + + if let Some(mut child) = procs.remove(&tunnel_id) { + eprintln!("[隧道 {}] 找到进程,准备停止", tunnel_id); + match child.kill() { + Ok(_) => { + eprintln!("[隧道 {}] kill 信号已发送", tunnel_id); + match child.wait() { + Ok(status) => { + eprintln!("[隧道 {}] 进程已退出,状态: {:?}", tunnel_id, status); + Ok("frpc 已停止".to_string()) + } + Err(e) => { + eprintln!("[隧道 {}] 等待进程退出失败: {}", tunnel_id, e); + Ok("frpc 已停止".to_string()) + } + } + } + Err(e) => { + eprintln!("[隧道 {}] kill 失败: {}", tunnel_id, e); + // 即使 kill 失败,也尝试等待进程 + let _ = child.wait(); + Err(format!("停止进程失败: {}", e)) + } + } + } else { + eprintln!("[隧道 {}] 进程未找到", tunnel_id); + Err("该隧道未在运行".to_string()) + } +} + +#[tauri::command] +pub async fn is_frpc_running( + tunnel_id: i32, + processes: State<'_, FrpcProcesses>, +) -> Result { + let mut procs = processes.processes.lock().map_err(|e| { + eprintln!("[隧道 {}] 检查状态时获取锁失败: {}", tunnel_id, e); + format!("获取进程锁失败: {}", e) + })?; + + if let Some(child) = procs.get_mut(&tunnel_id) { + match child.try_wait() { + Ok(Some(status)) => { + eprintln!("[隧道 {}] 进程已退出,状态: {:?}", tunnel_id, status); + procs.remove(&tunnel_id); + Ok(false) + } + Ok(None) => { + Ok(true) + } + Err(e) => { + eprintln!("[隧道 {}] 检查进程状态失败: {}", tunnel_id, e); + procs.remove(&tunnel_id); + Ok(false) + } + } + } else { + Ok(false) + } +} + +#[tauri::command] +pub async fn test_log_event(app_handle: tauri::AppHandle, tunnel_id: i32) -> Result { + eprintln!("[测试] 发送测试日志事件"); + let timestamp = chrono::Local::now().format("%H:%M:%S").to_string(); + + match app_handle.emit( + "frpc-log", + LogMessage { + tunnel_id, + message: "这是一条测试日志".to_string(), + timestamp, + }, + ) { + Ok(_) => { + eprintln!("[测试] 测试日志事件发送成功"); + Ok("测试日志已发送".to_string()) + } + Err(e) => { + eprintln!("[测试] 测试日志事件发送失败: {}", e); + Err(format!("发送失败: {}", e)) + } + } +} + +#[tauri::command] +pub async fn get_running_tunnels(processes: State<'_, FrpcProcesses>) -> Result, String> { + let mut procs = processes + .processes + .lock() + .map_err(|e| format!("获取进程锁失败: {}", e))?; + + let mut running_tunnels = Vec::new(); + let mut stopped_tunnels = Vec::new(); + + for (tunnel_id, child) in procs.iter_mut() { + match child.try_wait() { + Ok(Some(_)) => { + stopped_tunnels.push(*tunnel_id); + } + Ok(None) => { + running_tunnels.push(*tunnel_id); + } + Err(_) => { + stopped_tunnels.push(*tunnel_id); + } + } + } + + for tunnel_id in stopped_tunnels { + procs.remove(&tunnel_id); + } + + Ok(running_tunnels) +} + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b88bc12..10776f8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,913 +1,14 @@ -use futures_util::StreamExt; -use std::collections::HashMap; -use std::fs; -use std::io::{BufRead, BufReader, Read}; -use std::process::{Child, Command as StdCommand, Stdio}; -use std::sync::Mutex; -use std::thread; -use tauri::{Emitter, Manager, State}; -use sha2::{Sha256, Digest}; -use hex; +// 核心模块 +mod models; +mod utils; -// 隐藏用户日志里面的token -fn sanitize_log(message: &str, user_token: &str) -> String { - let mut result = message.to_string(); +// 命令模块 +mod commands; - result = result.replace(user_token, "***TOKEN***"); +// 导出模块供外部使用 +pub use models::FrpcProcesses; - if let Some(dot_pos) = user_token.find('.') { - let first_part = &user_token[..dot_pos]; - let second_part = &user_token[dot_pos + 1..]; - - if first_part.len() >= 6 { - result = result.replace(first_part, "***"); - } - if second_part.len() >= 6 { - result = result.replace(second_part, "***"); - } - } - - if user_token.len() >= 10 { - for window_size in (8..=user_token.len()).rev() { - if window_size <= user_token.len() { - let substr = &user_token[..window_size]; - if result.contains(substr) && substr.len() >= 8 { - result = result.replace(substr, "***"); - } - } - } - } - - result -} - -#[derive(serde::Serialize, Clone)] -struct DownloadProgress { - downloaded: u64, - total: u64, - percentage: f64, -} - -// API 响应数据结构 -#[derive(serde::Deserialize, Debug)] -struct FrpcInfoResponse { - msg: String, - state: String, - code: u32, - data: FrpcInfoData, -} - -#[derive(serde::Deserialize, Debug)] -struct FrpcInfoData { - downloads: Vec, - #[allow(dead_code)] - version: String, - #[allow(dead_code)] - release_notes: Vec, -} - -#[derive(serde::Deserialize, Debug, Clone)] -struct FrpcDownload { - hash: String, - os: String, - #[allow(dead_code)] - hash_type: String, - platform: String, - link: String, - arch: String, - size: u64, -} - -// 下载信息结构 -struct DownloadInfo { - url: String, - hash: String, - size: u64, -} - -// 存储运行中的frpc进程 -struct FrpcProcesses { - processes: Mutex>, -} - -impl FrpcProcesses { - fn new() -> Self { - Self { - processes: Mutex::new(HashMap::new()), - } - } -} - -#[tauri::command] -async fn check_frpc_exists(app_handle: tauri::AppHandle) -> Result { - let app_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| e.to_string())?; - - let frpc_path = if cfg!(target_os = "windows") { - app_dir.join("frpc.exe") - } else { - app_dir.join("frpc") - }; - - Ok(frpc_path.exists()) -} - -// 从 API 获取下载信息 -async fn get_download_info() -> Result { - let api_url = "https://cf-v1.uapis.cn/download/frpc/frpc_info.json"; - - let os = std::env::consts::OS; - let arch = std::env::consts::ARCH; - - let mut client_builder = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .user_agent("ChmlFrpLauncher/1.0"); - - // 检查是否需要绕过代理 - let bypass_proxy = std::env::var("BYPASS_PROXY") - .unwrap_or_else(|_| "true".to_string()) - .parse::() - .unwrap_or(true); - - if bypass_proxy { - client_builder = client_builder.no_proxy(); - } - - let client = client_builder - .build() - .map_err(|e| format!("Failed to create client: {}", e))?; - - let response = client - .get(api_url) - .send() - .await - .map_err(|e| format!("Failed to fetch frpc info: {}", e))?; - - if !response.status().is_success() { - return Err(format!("API request failed with status: {}", response.status())); - } - - let info_response: FrpcInfoResponse = response - .json() - .await - .map_err(|e| format!("Failed to parse API response: {}", e))?; - - if info_response.code != 200 || info_response.state != "success" { - return Err(format!("API returned error: {}", info_response.msg)); - } - - let mut matched_downloads: Vec<&FrpcDownload> = Vec::new(); - - // 首先尝试通过 platform 字段匹配(更精确) - let platform = match (os, arch) { - ("windows", "x86_64") => "win_amd64.exe", - ("windows", "x86") => "win_386.exe", - ("windows", "aarch64") => "win_arm64.exe", - ("linux", "x86") => "linux_386", - ("linux", "x86_64") => "linux_amd64", - ("linux", "arm") => "linux_arm", - ("linux", "aarch64") => "linux_arm64", - ("linux", "mips64") => "linux_mips64", - ("linux", "mips") => "linux_mips", - ("linux", "riscv64") => "linux_riscv64", - ("macos", "x86_64") => "darwin_amd64", - ("macos", "aarch64") => "darwin_arm64", - _ => return Err(format!("Unsupported platform: {} {}", os, arch)), - }; - - for download in &info_response.data.downloads { - if download.platform == platform { - matched_downloads.push(download); - } - } - - // 如果 platform 匹配失败,尝试通过 os 和 arch 匹配 - if matched_downloads.is_empty() { - let target_os = match os { - "macos" => "darwin", - _ => os, - }; - - for download in &info_response.data.downloads { - if download.os == target_os { - let matches_arch = match (os, arch) { - ("windows", "x86_64") => download.arch == "x86_64", - ("windows", "x86") => download.arch == "x86", - ("windows", "aarch64") => download.arch == "aarch64", - ("linux", "x86") => download.arch == "x86", - ("linux", "x86_64") => download.arch == "x86_64", - ("linux", "arm") => download.arch == "arm", - ("linux", "aarch64") => download.arch == "aarch64" || download.arch == "arm", - ("linux", "mips64") => download.arch == "mips64", - ("linux", "mips") => download.arch == "mips", - ("linux", "riscv64") => download.arch == "riscv64", - ("macos", "x86_64") => download.arch == "x86_64", - ("macos", "aarch64") => download.arch == "aarch64", - _ => false, - }; - - if matches_arch { - matched_downloads.push(download); - } - } - } - } - - let download = if matched_downloads.is_empty() { - return Err(format!( - "No matching download found for platform: {} {}", - os, arch - )); - } else if matched_downloads.len() == 1 { - matched_downloads[0] - } else { - // 如果有多个匹配项,选择 size 最大的(通常是最新版本) - matched_downloads - .iter() - .max_by_key(|d| d.size) - .unwrap() - }; - - Ok(DownloadInfo { - url: download.link.clone(), - hash: download.hash.clone(), - size: download.size, - }) -} - -#[tauri::command] -async fn get_download_url() -> Result { - let info = get_download_info().await?; - Ok(info.url) -} - -#[tauri::command] -async fn download_frpc(app_handle: tauri::AppHandle) -> Result { - // 从 API 获取下载信息(包括 URL、hash、size) - let download_info = get_download_info().await?; - let url = download_info.url; - let expected_hash = download_info.hash; - let expected_size = download_info.size; - - let app_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| e.to_string())?; - - fs::create_dir_all(&app_dir).map_err(|e| e.to_string())?; - - let frpc_path = if cfg!(target_os = "windows") { - app_dir.join("frpc.exe") - } else { - app_dir.join("frpc") - }; - - let mut client_builder = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(600)) - .connect_timeout(std::time::Duration::from_secs(30)) - .pool_idle_timeout(std::time::Duration::from_secs(90)) - .tcp_keepalive(std::time::Duration::from_secs(60)) - .user_agent("ChmlFrpLauncher/1.0"); - - // 检查是否需要绕过代理 - let bypass_proxy = std::env::var("BYPASS_PROXY") - .unwrap_or_else(|_| "true".to_string()) - .parse::() - .unwrap_or(true); - - if bypass_proxy { - client_builder = client_builder.no_proxy(); - } - - let client = client_builder - .build() - .map_err(|e| format!("Failed to create client: {}", e))?; - - // 使用 API 返回的文件大小,如果没有则尝试从 HTTP 响应获取 - let mut total_size: u64 = expected_size; - - // 如果 API 没有提供大小,尝试 HEAD 请求获取 - if total_size == 0 { - if let Ok(head_response) = client.head(&url).send().await { - if let Some(len) = head_response.content_length() { - total_size = len; - } - } - - if total_size == 0 { - eprintln!("HEAD 请求未获取文件大小,将从 GET 响应头获取"); - } - } - - use std::fs::OpenOptions; - let mut file = OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(&frpc_path) - .map_err(|e| e.to_string())?; - - let mut downloaded: u64 = 0; - let mut retry_count = 0; - const MAX_RETRIES: u32 = 5; - const CHUNK_SIZE: u64 = 1024 * 1024; // 1MB 分块 - - // 断点续传 - loop { - let mut request = client.get(&url); - - if downloaded == 0 && total_size == 0 { - request = request.header("Range", format!("bytes=0-{}", CHUNK_SIZE - 1)); - } else if downloaded > 0 { - let end = if total_size > 0 { - std::cmp::min(downloaded + CHUNK_SIZE - 1, total_size - 1) - } else { - downloaded + CHUNK_SIZE - 1 - }; - request = request.header("Range", format!("bytes={}-{}", downloaded, end)); - } else if total_size > 0 { - let end = std::cmp::min(CHUNK_SIZE - 1, total_size - 1); - request = request.header("Range", format!("bytes=0-{}", end)); - } - - let response = match request.send().await { - Ok(resp) => resp, - Err(e) => { - retry_count += 1; - if retry_count >= MAX_RETRIES { - return Err(format!("下载失败,已重试 {} 次: {}", MAX_RETRIES, e)); - } - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - continue; - } - }; - - let status = response.status(); - let is_success = status.is_success() || status.as_u16() == 206; // 206 = Partial Content - if !is_success { - return Err(format!("下载失败,HTTP 状态码: {}", status)); - } - - if status.as_u16() == 206 { - if let Some(content_range) = response.headers().get("content-range") { - if let Ok(range_str) = content_range.to_str() { - // 格式: bytes start-end/total - if let Some(slash_pos) = range_str.rfind('/') { - if let Ok(size) = range_str[slash_pos + 1..].parse::() { - if size > 0 && total_size != size { - total_size = size; - } - } - } - } - } - } else if let Some(content_len) = response.content_length() { - if total_size == 0 { - total_size = content_len; - } - } - - retry_count = 0; - - let mut stream = response.bytes_stream(); - let mut chunk_error = false; - let mut this_chunk_size: u64 = 0; - - while let Some(item) = stream.next().await { - match item { - Ok(chunk) => { - use std::io::Write; - file.write_all(&chunk) - .map_err(|e| format!("写入文件失败: {}", e))?; - - let chunk_len = chunk.len() as u64; - downloaded += chunk_len; - this_chunk_size += chunk_len; - - let percentage = if total_size > 0 { - (downloaded as f64 / total_size as f64) * 100.0 - } else { - 0.0 - }; - - // 发送进度更新(每 100KB 发送一次) - if this_chunk_size >= 100 * 1024 { - let _ = app_handle.emit( - "download-progress", - DownloadProgress { - downloaded, - total: total_size, - percentage, - }, - ); - this_chunk_size = 0; - } - } - Err(_e) => { - chunk_error = true; - break; // 跳出内层循环,外层循环会重试 - } - } - } - - if !chunk_error { - if total_size > 0 && downloaded >= total_size { - break; - } - if total_size == 0 && this_chunk_size < CHUNK_SIZE { - break; - } - if this_chunk_size == 0 { - break; - } - } - - if chunk_error { - retry_count += 1; - if retry_count >= MAX_RETRIES { - return Err(format!("下载失败,已重试 {} 次", MAX_RETRIES)); - } - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - } - } - - use std::io::Write; - file.flush().map_err(|e| format!("刷新文件失败: {}", e))?; - - let _ = app_handle.emit( - "download-progress", - DownloadProgress { - downloaded, - total: total_size, - percentage: 100.0, - }, - ); - - // 验证下载的文件大小(如果知道预期大小) - if total_size > 0 && downloaded < total_size { - return Err(format!( - "下载不完整: 预期 {} bytes, 实际下载 {} bytes", - total_size, downloaded - )); - } - - if downloaded == 0 { - return Err("下载失败: 没有接收到任何数据".to_string()); - } - - // 验证 SHA256 hash - eprintln!("开始验证文件 hash..."); - let mut file_for_hash = std::fs::File::open(&frpc_path) - .map_err(|e| format!("无法打开文件进行 hash 验证: {}", e))?; - - let mut hasher = Sha256::new(); - let mut buffer = vec![0u8; 8192]; // 8KB 缓冲区 - - loop { - let bytes_read = file_for_hash - .read(&mut buffer) - .map_err(|e| format!("读取文件失败: {}", e))?; - - if bytes_read == 0 { - break; - } - - hasher.update(&buffer[..bytes_read]); - } - - let computed_hash = hasher.finalize(); - let computed_hash_hex = hex::encode(computed_hash); - - eprintln!("预期 hash: {}", expected_hash); - eprintln!("计算 hash: {}", computed_hash_hex); - - if computed_hash_hex.to_lowercase() != expected_hash.to_lowercase() { - // 删除损坏的文件 - let _ = fs::remove_file(&frpc_path); - return Err(format!( - "文件 hash 验证失败: 预期 {}, 实际 {}", - expected_hash, computed_hash_hex - )); - } - - eprintln!("文件 hash 验证成功"); - - // 在 Unix 系统上设置执行权限 - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(&frpc_path) - .map_err(|e| e.to_string())? - .permissions(); - perms.set_mode(0o755); - fs::set_permissions(&frpc_path, perms).map_err(|e| e.to_string())?; - } - - Ok(frpc_path.to_string_lossy().to_string()) -} - -#[derive(serde::Serialize, Clone)] -struct LogMessage { - tunnel_id: i32, - message: String, - timestamp: String, -} - -#[tauri::command] -async fn start_frpc( - app_handle: tauri::AppHandle, - tunnel_id: i32, - user_token: String, - processes: State<'_, FrpcProcesses>, -) -> Result { - eprintln!("========================================"); - eprintln!("[隧道 {}] 开始启动 frpc", tunnel_id); - - { - let procs = processes.processes.lock().map_err(|e| { - eprintln!("[隧道 {}] 获取进程锁失败: {}", tunnel_id, e); - format!("获取进程锁失败: {}", e) - })?; - if procs.contains_key(&tunnel_id) { - eprintln!("[隧道 {}] 该隧道已在运行中", tunnel_id); - return Err("该隧道已在运行中".to_string()); - } - eprintln!("[隧道 {}] 当前管理的进程数: {}", tunnel_id, procs.len()); - } - - let app_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| e.to_string())?; - - let frpc_path = if cfg!(target_os = "windows") { - app_dir.join("frpc.exe") - } else { - app_dir.join("frpc") - }; - - eprintln!("[隧道 {}] frpc 路径: {:?}", tunnel_id, frpc_path); - - if !frpc_path.exists() { - eprintln!("[隧道 {}] frpc 文件不存在", tunnel_id); - return Err("frpc 未找到,请先下载".to_string()); - } - - // 在 Unix 系统上确保有执行权限 - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let metadata = fs::metadata(&frpc_path).map_err(|e| e.to_string())?; - let mut perms = metadata.permissions(); - if perms.mode() & 0o111 == 0 { - eprintln!("[隧道 {}] 设置执行权限", tunnel_id); - perms.set_mode(0o755); - fs::set_permissions(&frpc_path, perms).map_err(|e| e.to_string())?; - } - } - - // 启动 frpc 进程,设置工作目录为应用数据目录 - eprintln!("[隧道 {}] 准备启动进程...", tunnel_id); - eprintln!("[隧道 {}] 工作目录: {:?}", tunnel_id, app_dir); - let mut child = StdCommand::new(&frpc_path) - .current_dir(&app_dir) // 设置工作目录,避免在 src-tauri 目录生成文件 - .arg("-u") - .arg(&user_token) - .arg("-p") - .arg(tunnel_id.to_string()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| { - eprintln!("[隧道 {}] 启动进程失败: {}", tunnel_id, e); - format!("启动 frpc 失败: {}", e) - })?; - - let pid = child.id(); - eprintln!("[隧道 {}] 进程已启动,PID: {}", tunnel_id, pid); - - // 发送启动日志(不包含敏感信息) - let timestamp = chrono::Local::now().format("%H:%M:%S").to_string(); - eprintln!("[隧道 {}] 发送启动日志事件", tunnel_id); - match app_handle.emit( - "frpc-log", - LogMessage { - tunnel_id, - message: format!("frpc 进程已启动 (PID: {}), 开始连接服务器...", pid), - timestamp: timestamp.clone(), - }, - ) { - Ok(_) => eprintln!("[隧道 {}] 启动日志事件已发送", tunnel_id), - Err(e) => eprintln!("[隧道 {}] 发送启动日志事件失败: {}", tunnel_id, e), - } - - // 捕获 stdout - if let Some(stdout) = child.stdout.take() { - let app_handle_clone = app_handle.clone(); - let tunnel_id_clone = tunnel_id; - let user_token_clone = user_token.clone(); - match thread::Builder::new() - .name(format!("frpc-stdout-{}", tunnel_id)) - .spawn(move || { - eprintln!("[线程 stdout-{}] 开始监听", tunnel_id_clone); - let reader = BufReader::new(stdout); - for line in reader.lines().flatten() { - // 去除 ANSI 颜色代码 - let clean_line = strip_ansi_escapes::strip_str(&line); - - // 隐藏用户 token - let sanitized_line = sanitize_log(&clean_line, &user_token_clone); - - let timestamp = chrono::Local::now().format("%H:%M:%S").to_string(); - if let Err(e) = app_handle_clone.emit( - "frpc-log", - LogMessage { - tunnel_id: tunnel_id_clone, - message: sanitized_line, - timestamp, - }, - ) { - eprintln!("[线程 stdout-{}] 发送日志事件失败: {}", tunnel_id_clone, e); - break; // 如果事件发送失败,停止监听 - } - } - eprintln!("[线程 stdout-{}] 结束监听", tunnel_id_clone); - }) { - Ok(_) => eprintln!("[隧道 {}] stdout 监听线程已启动", tunnel_id), - Err(e) => eprintln!("[隧道 {}] 创建 stdout 监听线程失败: {}", tunnel_id, e), - } - } - - // 捕获 stderr - if let Some(stderr) = child.stderr.take() { - let app_handle_clone = app_handle.clone(); - let tunnel_id_clone = tunnel_id; - let user_token_clone = user_token.clone(); - match thread::Builder::new() - .name(format!("frpc-stderr-{}", tunnel_id)) - .spawn(move || { - eprintln!("[线程 stderr-{}] 开始监听", tunnel_id_clone); - let reader = BufReader::new(stderr); - for line in reader.lines().flatten() { - // 去除 ANSI 颜色代码 - let clean_line = strip_ansi_escapes::strip_str(&line); - - // 隐藏用户 token - let sanitized_line = sanitize_log(&clean_line, &user_token_clone); - - let timestamp = chrono::Local::now().format("%H:%M:%S").to_string(); - if let Err(e) = app_handle_clone.emit( - "frpc-log", - LogMessage { - tunnel_id: tunnel_id_clone, - message: format!("[ERR] {}", sanitized_line), - timestamp, - }, - ) { - eprintln!("[线程 stderr-{}] 发送日志事件失败: {}", tunnel_id_clone, e); - break; - } - } - eprintln!("[线程 stderr-{}] 结束监听", tunnel_id_clone); - }) { - Ok(_) => eprintln!("[隧道 {}] stderr 监听线程已启动", tunnel_id), - Err(e) => eprintln!("[隧道 {}] 创建 stderr 监听线程失败: {}", tunnel_id, e), - } - } - - { - let mut procs = processes - .processes - .lock() - .map_err(|e| format!("获取进程锁失败: {}", e))?; - eprintln!("[隧道 {}] 将进程 PID {} 存储到管理器", tunnel_id, pid); - procs.insert(tunnel_id, child); - eprintln!( - "[隧道 {}] 进程存储成功,当前管理的进程数: {}", - tunnel_id, - procs.len() - ); - } - - eprintln!("[隧道 {}] frpc 启动完成", tunnel_id); - Ok(format!("frpc 已启动 (PID: {})", pid)) -} - -#[tauri::command] -async fn stop_frpc(tunnel_id: i32, processes: State<'_, FrpcProcesses>) -> Result { - eprintln!("[隧道 {}] 请求停止进程", tunnel_id); - - let mut procs = processes - .processes - .lock() - .map_err(|e| format!("获取进程锁失败: {}", e))?; - - if let Some(mut child) = procs.remove(&tunnel_id) { - eprintln!("[隧道 {}] 找到进程,准备停止", tunnel_id); - match child.kill() { - Ok(_) => { - eprintln!("[隧道 {}] kill 信号已发送", tunnel_id); - match child.wait() { - Ok(status) => { - eprintln!("[隧道 {}] 进程已退出,状态: {:?}", tunnel_id, status); - Ok("frpc 已停止".to_string()) - } - Err(e) => { - eprintln!("[隧道 {}] 等待进程退出失败: {}", tunnel_id, e); - Ok("frpc 已停止".to_string()) - } - } - } - Err(e) => { - eprintln!("[隧道 {}] kill 失败: {}", tunnel_id, e); - // 即使 kill 失败,也尝试等待进程 - let _ = child.wait(); - Err(format!("停止进程失败: {}", e)) - } - } - } else { - eprintln!("[隧道 {}] 进程未找到", tunnel_id); - Err("该隧道未在运行".to_string()) - } -} - -#[tauri::command] -async fn is_frpc_running( - tunnel_id: i32, - processes: State<'_, FrpcProcesses>, -) -> Result { - let mut procs = processes.processes.lock().map_err(|e| { - eprintln!("[隧道 {}] 检查状态时获取锁失败: {}", tunnel_id, e); - format!("获取进程锁失败: {}", e) - })?; - - if let Some(child) = procs.get_mut(&tunnel_id) { - match child.try_wait() { - Ok(Some(status)) => { - eprintln!("[隧道 {}] 进程已退出,状态: {:?}", tunnel_id, status); - procs.remove(&tunnel_id); - Ok(false) - } - Ok(None) => { - Ok(true) - } - Err(e) => { - eprintln!("[隧道 {}] 检查进程状态失败: {}", tunnel_id, e); - procs.remove(&tunnel_id); - Ok(false) - } - } - } else { - Ok(false) - } -} - -#[tauri::command] -async fn test_log_event(app_handle: tauri::AppHandle, tunnel_id: i32) -> Result { - eprintln!("[测试] 发送测试日志事件"); - let timestamp = chrono::Local::now().format("%H:%M:%S").to_string(); - - match app_handle.emit( - "frpc-log", - LogMessage { - tunnel_id, - message: "这是一条测试日志".to_string(), - timestamp, - }, - ) { - Ok(_) => { - eprintln!("[测试] 测试日志事件发送成功"); - Ok("测试日志已发送".to_string()) - } - Err(e) => { - eprintln!("[测试] 测试日志事件发送失败: {}", e); - Err(format!("发送失败: {}", e)) - } - } -} - -#[tauri::command] -async fn get_running_tunnels(processes: State<'_, FrpcProcesses>) -> Result, String> { - let mut procs = processes - .processes - .lock() - .map_err(|e| format!("获取进程锁失败: {}", e))?; - - let mut running_tunnels = Vec::new(); - let mut stopped_tunnels = Vec::new(); - - for (tunnel_id, child) in procs.iter_mut() { - match child.try_wait() { - Ok(Some(_)) => { - stopped_tunnels.push(*tunnel_id); - } - Ok(None) => { - running_tunnels.push(*tunnel_id); - } - Err(_) => { - stopped_tunnels.push(*tunnel_id); - } - } - } - - for tunnel_id in stopped_tunnels { - procs.remove(&tunnel_id); - } - - Ok(running_tunnels) -} - -#[tauri::command] -async fn is_autostart_enabled( - state: tauri::State<'_, tauri_plugin_autostart::AutoLaunchManager>, -) -> Result { - state - .is_enabled() - .map_err(|e| format!("检查开机自启状态失败: {}", e)) -} - -#[tauri::command] -async fn set_autostart( - enabled: bool, - state: tauri::State<'_, tauri_plugin_autostart::AutoLaunchManager>, -) -> Result<(), String> { - if enabled { - state - .enable() - .map_err(|e| format!("启用开机自启失败: {}", e)) - } else { - state - .disable() - .map_err(|e| format!("禁用开机自启失败: {}", e)) - } -} - -#[derive(serde::Deserialize)] -struct HttpRequestOptions { - url: String, - method: String, - headers: Option>, - body: Option, - bypass_proxy: Option, -} - -#[tauri::command] -async fn http_request(options: HttpRequestOptions) -> Result { - let bypass_proxy = options.bypass_proxy.unwrap_or(true); - - let mut client_builder = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .user_agent("ChmlFrpLauncher/1.0"); - - // 如果绕过代理,使用自定义代理函数返回 None 来禁用代理 - if bypass_proxy { - client_builder = client_builder.proxy( - reqwest::Proxy::custom(move |_url| -> Option { None }) - ); - } - - let client = client_builder - .build() - .map_err(|e| format!("Failed to create client: {}", e))?; - - let mut request = match options.method.as_str() { - "GET" => client.get(&options.url), - "POST" => client.post(&options.url), - "PUT" => client.put(&options.url), - "DELETE" => client.delete(&options.url), - "PATCH" => client.patch(&options.url), - _ => return Err(format!("Unsupported method: {}", options.method)), - }; - - if let Some(headers) = options.headers { - for (key, value) in headers { - request = request.header(&key, &value); - } - } - - if let Some(body) = options.body { - request = request.body(body); - } - - let response = request - .send() - .await - .map_err(|e| format!("Request failed: {}", e))?; - - let status = response.status(); - let text = response - .text() - .await - .map_err(|e| format!("Failed to read response: {}", e))?; - - if !status.is_success() { - return Err(format!("HTTP {}: {}", status.as_u16(), text)); - } - - Ok(text) -} +use tauri::Manager; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -945,17 +46,17 @@ pub fn run() { }) .manage(FrpcProcesses::new()) .invoke_handler(tauri::generate_handler![ - check_frpc_exists, - get_download_url, - download_frpc, - start_frpc, - stop_frpc, - is_frpc_running, - get_running_tunnels, - test_log_event, - is_autostart_enabled, - set_autostart, - http_request + commands::check_frpc_exists, + commands::get_download_url, + commands::download_frpc, + commands::start_frpc, + commands::stop_frpc, + commands::is_frpc_running, + commands::get_running_tunnels, + commands::test_log_event, + commands::is_autostart_enabled, + commands::set_autostart, + commands::http_request ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs new file mode 100644 index 0000000..ecf389a --- /dev/null +++ b/src-tauri/src/models.rs @@ -0,0 +1,81 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::process::Child; +use std::sync::Mutex; + +// 下载进度结构 +#[derive(Serialize, Clone)] +pub struct DownloadProgress { + pub downloaded: u64, + pub total: u64, + pub percentage: f64, +} + +// API 响应数据结构 +#[derive(Deserialize, Debug)] +pub struct FrpcInfoResponse { + pub msg: String, + pub state: String, + pub code: u32, + pub data: FrpcInfoData, +} + +#[derive(Deserialize, Debug)] +pub struct FrpcInfoData { + pub downloads: Vec, + #[allow(dead_code)] + pub version: String, + #[allow(dead_code)] + pub release_notes: Vec, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct FrpcDownload { + pub hash: String, + pub os: String, + #[allow(dead_code)] + pub hash_type: String, + pub platform: String, + pub link: String, + pub arch: String, + pub size: u64, +} + +// 下载信息结构 +pub struct DownloadInfo { + pub url: String, + pub hash: String, + pub size: u64, +} + +// 存储运行中的frpc进程 +pub struct FrpcProcesses { + pub processes: Mutex>, +} + +impl FrpcProcesses { + pub fn new() -> Self { + Self { + processes: Mutex::new(HashMap::new()), + } + } +} + +// 日志消息结构 +#[derive(Serialize, Clone)] +pub struct LogMessage { + pub tunnel_id: i32, + pub message: String, + pub timestamp: String, +} + +// HTTP请求选项 +#[derive(Deserialize)] +pub struct HttpRequestOptions { + pub url: String, + pub method: String, + pub headers: Option>, + pub body: Option, + pub bypass_proxy: Option, +} + diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs new file mode 100644 index 0000000..b9feb27 --- /dev/null +++ b/src-tauri/src/utils.rs @@ -0,0 +1,32 @@ +// 隐藏用户日志里面的token +pub fn sanitize_log(message: &str, user_token: &str) -> String { + let mut result = message.to_string(); + + result = result.replace(user_token, "***TOKEN***"); + + if let Some(dot_pos) = user_token.find('.') { + let first_part = &user_token[..dot_pos]; + let second_part = &user_token[dot_pos + 1..]; + + if first_part.len() >= 6 { + result = result.replace(first_part, "***"); + } + if second_part.len() >= 6 { + result = result.replace(second_part, "***"); + } + } + + if user_token.len() >= 10 { + for window_size in (8..=user_token.len()).rev() { + if window_size <= user_token.len() { + let substr = &user_token[..window_size]; + if result.contains(substr) && substr.len() >= 8 { + result = result.replace(substr, "***"); + } + } + } + } + + result +} + From ee86296968376c1c13b0f41ecdda696fee2400ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cchaoji233=E2=80=9D?= Date: Tue, 13 Jan 2026 00:57:24 +0800 Subject: [PATCH 02/11] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=E9=9A=A7?= =?UTF-8?q?=E9=81=93=E9=A1=B5=E9=9D=A2=E4=BB=A3=E7=A0=81=EF=BC=8C=E4=BE=BF?= =?UTF-8?q?=E4=BA=8E=E7=BB=B4=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 2 +- src/components/pages/TunnelList.tsx | 722 ------------------ .../pages/TunnelList/TunnelList.tsx | 73 ++ src/components/pages/TunnelList/cache.ts | 10 + .../TunnelList/components/TunnelCard.tsx | 103 +++ .../pages/TunnelList/hooks/useTunnelList.ts | 100 +++ .../TunnelList/hooks/useTunnelProgress.ts | 442 +++++++++++ .../pages/TunnelList/hooks/useTunnelToggle.ts | 127 +++ src/components/pages/TunnelList/types.ts | 7 + src/components/pages/TunnelList/utils.ts | 52 ++ 10 files changed, 915 insertions(+), 723 deletions(-) delete mode 100644 src/components/pages/TunnelList.tsx create mode 100644 src/components/pages/TunnelList/TunnelList.tsx create mode 100644 src/components/pages/TunnelList/cache.ts create mode 100644 src/components/pages/TunnelList/components/TunnelCard.tsx create mode 100644 src/components/pages/TunnelList/hooks/useTunnelList.ts create mode 100644 src/components/pages/TunnelList/hooks/useTunnelProgress.ts create mode 100644 src/components/pages/TunnelList/hooks/useTunnelToggle.ts create mode 100644 src/components/pages/TunnelList/types.ts create mode 100644 src/components/pages/TunnelList/utils.ts diff --git a/src/App.tsx b/src/App.tsx index 323871c..7319489 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,7 @@ import { toast } from "sonner"; import { Sidebar } from "@/components/Sidebar"; import { TitleBar } from "@/components/TitleBar"; import { Home } from "@/components/pages/Home"; -import { TunnelList } from "@/components/pages/TunnelList"; +import { TunnelList } from "@/components/pages/TunnelList/TunnelList"; import { Logs } from "@/components/pages/Logs"; import { Settings } from "@/components/pages/Settings"; import { getStoredUser, type StoredUser } from "@/services/api"; diff --git a/src/components/pages/TunnelList.tsx b/src/components/pages/TunnelList.tsx deleted file mode 100644 index 97e9d24..0000000 --- a/src/components/pages/TunnelList.tsx +++ /dev/null @@ -1,722 +0,0 @@ -import { useState, useEffect, useRef, useCallback } from "react"; -import { toast } from "sonner"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Progress } from "@/components/ui/progress"; -import { - fetchTunnels, - type Tunnel, - getStoredUser, - offlineTunnel, -} from "@/services/api"; -import { frpcManager, type LogMessage } from "@/services/frpcManager"; -import { logStore } from "@/services/logStore"; - -// 模块级别的缓存,确保在组件卸载后数据仍然保留 -const tunnelListCache = { - tunnels: [] as Tunnel[], -}; - -interface TunnelProgress { - progress: number; // 0-100 - isError: boolean; // 是否错误状态(红色) - isSuccess: boolean; // 是否成功状态(绿色) - startTime?: number; // 启动时间戳 -} - -const tunnelProgressCache = new Map(); - -function restoreProgressFromLogs(logs: LogMessage[]): Map { - const progressMap = new Map(); - const logsByTunnel = new Map(); - for (const log of logs) { - if (!logsByTunnel.has(log.tunnel_id)) { - logsByTunnel.set(log.tunnel_id, []); - } - logsByTunnel.get(log.tunnel_id)!.push(log); - } - - for (const [tunnelId, tunnelLogs] of logsByTunnel) { - let progress = 0; - let isError = false; - let isSuccess = false; - - for (let i = tunnelLogs.length - 1; i >= 0; i--) { - const message = tunnelLogs[i].message; - - if (message.includes("映射启动成功")) { - progress = 100; - isError = false; - isSuccess = false; - break; - } else if (message.includes("已启动隧道")) { - progress = 80; - } else if (message.includes("成功登录至服务器")) { - progress = 60; - } else if (message.includes("已写入配置文件")) { - progress = 40; - } else if (message.includes("从ChmlFrp API获取配置文件")) { - progress = 20; - } else if (message.includes("frpc 进程已启动")) { - progress = 10; - break; - } - } - - if (progress > 0) { - progressMap.set(tunnelId, { progress, isError, isSuccess }); - tunnelProgressCache.set(tunnelId, { progress, isError, isSuccess }); - } - } - - return progressMap; -} - -export function TunnelList() { - const [tunnels, setTunnels] = useState(() => { - return tunnelListCache.tunnels; - }); - const [loading, setLoading] = useState(() => { - return tunnelListCache.tunnels.length === 0; - }); - const [error, setError] = useState(""); - const [runningTunnels, setRunningTunnels] = useState>(new Set()); - const [togglingTunnels, setTogglingTunnels] = useState>( - new Set(), - ); - const [fixingTunnels, setFixingTunnels] = useState>( - new Set(), - ); - const [tunnelProgress, setTunnelProgress] = useState< - Map - >(() => { - const cached = new Map(tunnelProgressCache); - const logs = logStore.getLogs(); - const restored = restoreProgressFromLogs(logs); - for (const [tunnelId, progress] of restored) { - cached.set(tunnelId, progress); - } - return cached; - }); - const timeoutRefs = useRef>>( - new Map(), - ); - const successTimeoutRefs = useRef>>( - new Map(), - ); - const processedErrorsRef = useRef>(new Set()); - - const loadTunnels = async () => { - if (tunnelListCache.tunnels.length > 0) { - setTunnels(tunnelListCache.tunnels); - setLoading(false); - - const running = new Set(); - for (const tunnel of tunnelListCache.tunnels) { - const isRunning = await frpcManager.isTunnelRunning(tunnel.id); - if (isRunning) { - running.add(tunnel.id); - } - } - setRunningTunnels(running); - } else { - setLoading(true); - } - - setError(""); - try { - const data = await fetchTunnels(); - setTunnels(data); - tunnelListCache.tunnels = data; - - const running = new Set(); - for (const tunnel of data) { - const isRunning = await frpcManager.isTunnelRunning(tunnel.id); - if (isRunning) { - running.add(tunnel.id); - } - } - setRunningTunnels(running); - } catch (err) { - const message = err instanceof Error ? err.message : "获取隧道列表失败"; - if ( - message.includes("登录") || - message.includes("token") || - message.includes("令牌") - ) { - tunnelListCache.tunnels = []; - setTunnels([]); - setError(message); - } else if (tunnelListCache.tunnels.length === 0) { - setTunnels([]); - setError(message); - } - console.error("获取隧道列表失败", err); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - const initializeProgress = async () => { - await loadTunnels(); - logStore.startListening(); - - const logs = logStore.getLogs(); - if (logs.length > 0) { - const restored = restoreProgressFromLogs(logs); - if (restored.size > 0) { - const runningTunnels = await frpcManager.getRunningTunnels(); - const runningSet = new Set(runningTunnels); - - setTunnelProgress((prev) => { - const merged = new Map(prev); - for (const [tunnelId, progress] of restored) { - if (!runningSet.has(tunnelId)) { - merged.set(tunnelId, { progress: 0, isError: false, isSuccess: false }); - tunnelProgressCache.set(tunnelId, { progress: 0, isError: false, isSuccess: false }); - } else { - merged.set(tunnelId, { ...progress, isSuccess: false }); - tunnelProgressCache.set(tunnelId, { ...progress, isSuccess: false }); - } - } - return merged; - }); - } - } - }; - - initializeProgress(); - }, []); - - const handleDuplicateTunnelError = useCallback( - async (tunnelId: number, tunnelName: string) => { - const user = getStoredUser(); - if (!user?.usertoken) { - toast.error("未找到用户令牌,请重新登录"); - return; - } - - if (fixingTunnels.has(tunnelId)) { - return; - } - - setFixingTunnels((prev) => new Set(prev).add(tunnelId)); - - toast.info("隧道重复启动导致隧道启动失败,自动修复中....", { - duration: 10000, - }); - - let cleanedTunnelName = tunnelName?.trim() || ""; - cleanedTunnelName = cleanedTunnelName.replace(/^\*\*\*TOKEN\*\*\*\./, ""); - - if (!cleanedTunnelName || cleanedTunnelName === "") { - console.error("隧道名称为空,无法修复", { tunnelId, tunnelName, cleanedTunnelName }); - toast.error("无法获取隧道名称,请手动处理"); - setFixingTunnels((prev) => { - const next = new Set(prev); - next.delete(tunnelId); - return next; - }); - return; - } - - try { - console.log("调用下线隧道API", { tunnelId, tunnelName: cleanedTunnelName }); - - await offlineTunnel(cleanedTunnelName, user.usertoken); - - await new Promise((resolve) => setTimeout(resolve, 8000)); - - const tunnel = tunnels.find((t) => t.id === tunnelId); - if (tunnel) { - setTunnelProgress((prev) => { - const next = new Map(prev); - const resetProgress = { progress: 0, isError: false, isSuccess: false }; - next.set(tunnelId, resetProgress); - tunnelProgressCache.set(tunnelId, resetProgress); - return next; - }); - - try { - await frpcManager.stopTunnel(tunnelId); - await new Promise((resolve) => setTimeout(resolve, 500)); - } catch { - // 忽略停止错误 - } - - await frpcManager.startTunnel(tunnelId, user.usertoken); - setRunningTunnels((prev) => new Set(prev).add(tunnelId)); - - let hasChecked = false; - let hasSuccess = false; - const errorCheckInterval = setInterval(() => { - if (hasChecked) { - clearInterval(errorCheckInterval); - return; - } - - const logs = logStore.getLogs(); - const successLogs = logs.filter( - (log) => - log.tunnel_id === tunnelId && - log.message.includes("映射启动成功"), - ); - - if (successLogs.length > 0) { - hasSuccess = true; - hasChecked = true; - clearInterval(errorCheckInterval); - toast.success("隧道自动修复成功,已重新启动", { - duration: 5000, - }); - return; - } - - const recentErrorLogs = logs.filter( - (log) => - log.tunnel_id === tunnelId && - log.message.includes("启动失败") && - log.message.includes("already exists"), - ); - - if (recentErrorLogs.length > 0 && !hasSuccess) { - hasChecked = true; - clearInterval(errorCheckInterval); - toast.error( - "因为隧道重复启动导致映射启动失败。系统自动修复失败,请更换外网端口或节点", - { duration: 8000 }, - ); - setTunnelProgress((prev) => { - const current = prev.get(tunnelId); - if (current) { - const errorProgress = { - ...current, - progress: 100, - isError: true, - isSuccess: false, - }; - tunnelProgressCache.set(tunnelId, errorProgress); - return new Map(prev).set(tunnelId, errorProgress); - } - return prev; - }); - } - }, 2000); - - setTimeout(() => { - if (!hasChecked) { - hasChecked = true; - clearInterval(errorCheckInterval); - } - }, 20000); - } - } catch (err) { - const message = - err instanceof Error ? err.message : "自动修复失败"; - toast.error(message, { duration: 5000 }); - setTunnelProgress((prev) => { - const current = prev.get(tunnelId); - if (current) { - const errorProgress = { - ...current, - progress: 100, - isError: true, - }; - tunnelProgressCache.set(tunnelId, errorProgress); - return new Map(prev).set(tunnelId, errorProgress); - } - return prev; - }); - } finally { - setFixingTunnels((prev) => { - const next = new Set(prev); - next.delete(tunnelId); - return next; - }); - } - }, - [tunnels, fixingTunnels], - ); - - useEffect(() => { - const unsubscribe = logStore.subscribe((logs: LogMessage[]) => { - if (logs.length === 0) return; - - const latestLog = logs[logs.length - 1]; - const tunnelId = latestLog.tunnel_id; - const message = latestLog.message; - - setTunnelProgress((prev) => { - const current = prev.get(tunnelId); - if (!current && !message.includes("frpc 进程已启动")) { - return prev; - } - - const newProgress = current || { progress: 0, isError: false, isSuccess: false }; - - if (message.includes("frpc 进程已启动")) { - newProgress.startTime = Date.now(); - newProgress.progress = 10; - if (timeoutRefs.current.has(tunnelId)) { - clearTimeout(timeoutRefs.current.get(tunnelId)!); - } - const timeout = setTimeout(() => { - setTunnelProgress((prev) => { - const current = prev.get(tunnelId); - if (current && current.progress < 100 && !current.isError) { - const errorProgress = { - ...current, - progress: 100, - isError: true, - }; - tunnelProgressCache.set(tunnelId, errorProgress); - return new Map(prev).set(tunnelId, errorProgress); - } - return prev; - }); - }, 10000); - timeoutRefs.current.set(tunnelId, timeout); - } - else if (message.includes("从ChmlFrp API获取配置文件")) { - newProgress.progress = 20; - } - else if (message.includes("已写入配置文件")) { - newProgress.progress = 40; - } - else if (message.includes("成功登录至服务器")) { - newProgress.progress = 60; - } - else if (message.includes("已启动隧道")) { - newProgress.progress = 80; - } - else if (message.includes("映射启动成功")) { - newProgress.progress = 100; - newProgress.isError = false; - newProgress.isSuccess = true; - if (timeoutRefs.current.has(tunnelId)) { - clearTimeout(timeoutRefs.current.get(tunnelId)!); - timeoutRefs.current.delete(tunnelId); - } - if (successTimeoutRefs.current.has(tunnelId)) { - clearTimeout(successTimeoutRefs.current.get(tunnelId)!); - } - const successTimeout = setTimeout(() => { - setTunnelProgress((prev) => { - const current = prev.get(tunnelId); - if (current) { - const updated = { - ...current, - isSuccess: false, - }; - tunnelProgressCache.set(tunnelId, updated); - return new Map(prev).set(tunnelId, updated); - } - return prev; - }); - successTimeoutRefs.current.delete(tunnelId); - }, 2000); - successTimeoutRefs.current.set(tunnelId, successTimeout); - } - else if ( - message.includes("启动失败") && - message.includes("already exists") - ) { - const errorKey = `${tunnelId}-${message}`; - - if (processedErrorsRef.current.has(errorKey)) { - return prev; - } - - if (!fixingTunnels.has(tunnelId)) { - const match = message.match(/\[([^\]]+)\]/g); - let tunnelName = ""; - - if (match && match.length > 0) { - tunnelName = match[match.length - 1].slice(1, -1); - tunnelName = tunnelName.replace(/^\*\*\*TOKEN\*\*\*\./, ""); - } - - if (!tunnelName || tunnelName.trim() === "") { - const tunnel = tunnels.find((t) => t.id === tunnelId); - if (tunnel) { - tunnelName = tunnel.name; - tunnelName = tunnelName.replace(/^\*\*\*TOKEN\*\*\*\./, ""); - } - } - - if (tunnelName && tunnelName.trim() !== "") { - processedErrorsRef.current.add(errorKey); - setTimeout(() => { - processedErrorsRef.current.delete(errorKey); - }, 5 * 60 * 1000); - - handleDuplicateTunnelError(tunnelId, tunnelName.trim()); - } else { - console.error("无法提取隧道名称", { tunnelId, message, matches: match }); - processedErrorsRef.current.add(errorKey); - } - } - } - - const updated = new Map(prev).set(tunnelId, { ...newProgress }); - tunnelProgressCache.set(tunnelId, { ...newProgress }); - return updated; - }); - }); - - return () => { - unsubscribe(); - }; - }, [fixingTunnels, handleDuplicateTunnelError, tunnels]); - - useEffect(() => { - const timeouts = timeoutRefs.current; - const successTimeouts = successTimeoutRefs.current; - return () => { - timeouts.forEach((timeout) => clearTimeout(timeout)); - timeouts.clear(); - successTimeouts.forEach((timeout) => clearTimeout(timeout)); - successTimeouts.clear(); - }; - }, []); - - useEffect(() => { - if (tunnels.length === 0) return; - - const checkRunningStatus = async () => { - const running = new Set(); - for (const tunnel of tunnels) { - const isRunning = await frpcManager.isTunnelRunning(tunnel.id); - if (isRunning) { - running.add(tunnel.id); - } else { - if (runningTunnels.has(tunnel.id)) { - setTunnelProgress((prev) => { - const current = prev.get(tunnel.id); - if (current && current.progress < 100) { - const errorProgress = { - ...current, - progress: 100, - isError: true, - isSuccess: false, - }; - tunnelProgressCache.set(tunnel.id, errorProgress); - return new Map(prev).set(tunnel.id, errorProgress); - } - return prev; - }); - } else { - setTunnelProgress((prev) => { - const current = prev.get(tunnel.id); - if (current && current.progress > 0) { - const cleared = { progress: 0, isError: false, isSuccess: false }; - tunnelProgressCache.set(tunnel.id, cleared); - return new Map(prev).set(tunnel.id, cleared); - } - return prev; - }); - if (successTimeoutRefs.current.has(tunnel.id)) { - clearTimeout(successTimeoutRefs.current.get(tunnel.id)!); - successTimeoutRefs.current.delete(tunnel.id); - } - } - } - } - setRunningTunnels(running); - }; - - const interval = setInterval(checkRunningStatus, 5000); - - return () => clearInterval(interval); - }, [tunnels, runningTunnels]); - - const handleToggle = async (tunnel: Tunnel, enabled: boolean) => { - const user = getStoredUser(); - if (!user?.usertoken) { - toast.error("未找到用户令牌,请重新登录"); - return; - } - - if (togglingTunnels.has(tunnel.id)) { - return; - } - - setTogglingTunnels((prev) => new Set(prev).add(tunnel.id)); - - try { - if (enabled) { - setTunnelProgress((prev) => { - const next = new Map(prev); - const resetProgress = { progress: 0, isError: false, isSuccess: false }; - next.set(tunnel.id, resetProgress); - tunnelProgressCache.set(tunnel.id, resetProgress); - return next; - }); - const message = await frpcManager.startTunnel( - tunnel.id, - user.usertoken, - ); - toast.success(message || `隧道 ${tunnel.name} 已启动`); - setRunningTunnels((prev) => new Set(prev).add(tunnel.id)); - } else { - const message = await frpcManager.stopTunnel(tunnel.id); - toast.success(message || `隧道 ${tunnel.name} 已停止`); - setRunningTunnels((prev) => { - const next = new Set(prev); - next.delete(tunnel.id); - return next; - }); - setTunnelProgress((prev) => { - const next = new Map(prev); - next.set(tunnel.id, { progress: 0, isError: false, isSuccess: false }); - tunnelProgressCache.set(tunnel.id, { progress: 0, isError: false, isSuccess: false }); - return next; - }); - if (timeoutRefs.current.has(tunnel.id)) { - clearTimeout(timeoutRefs.current.get(tunnel.id)!); - timeoutRefs.current.delete(tunnel.id); - } - if (successTimeoutRefs.current.has(tunnel.id)) { - clearTimeout(successTimeoutRefs.current.get(tunnel.id)!); - successTimeoutRefs.current.delete(tunnel.id); - } - } - } catch (err) { - const message = - err instanceof Error ? err.message : `${enabled ? "启动" : "停止"}失败`; - toast.error(message); - if (enabled) { - const errorProgress = { progress: 100, isError: true, isSuccess: false }; - setTunnelProgress((prev) => { - const next = new Map(prev); - next.set(tunnel.id, errorProgress); - return next; - }); - tunnelProgressCache.set(tunnel.id, errorProgress); - } - } finally { - setTogglingTunnels((prev) => { - const next = new Set(prev); - next.delete(tunnel.id); - return next; - }); - } - }; - - return ( -
-
-

隧道

- {!loading && !error && ( - - {tunnels.length} 个 - - )} -
- - {loading ? ( -
- 加载中... -
- ) : error ? ( -
- {error} -
- ) : ( - -
- {tunnels.map((tunnel) => { - const isRunning = runningTunnels.has(tunnel.id); - const isToggling = togglingTunnels.has(tunnel.id); - const progress = tunnelProgress.get(tunnel.id); - const progressValue = progress?.progress ?? 0; - const isError = progress?.isError ?? false; - const isSuccess = progress?.isSuccess ?? false; - return ( -
-
- div]:bg-destructive" - : isSuccess - ? "bg-green-500/20 [&>div]:bg-green-500" - : "" - }`} - /> -
-
-
-
-
-

- {tunnel.name} -

- - {tunnel.type} - -
-

- {tunnel.node} -

-
- -
- -
-
- 本地地址 - - {tunnel.localip}:{tunnel.nport} - -
-
- 链接地址 - - {tunnel.ip}:{tunnel.dorp} - -
- - {tunnel.nodestate && ( -
- 节点 - - {tunnel.nodestate === "online" ? "在线" : "离线"} - -
- )} -
-
-
- ); - })} -
-
- )} -
- ); -} diff --git a/src/components/pages/TunnelList/TunnelList.tsx b/src/components/pages/TunnelList/TunnelList.tsx new file mode 100644 index 0000000..75cd5f0 --- /dev/null +++ b/src/components/pages/TunnelList/TunnelList.tsx @@ -0,0 +1,73 @@ +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useTunnelList } from "./hooks/useTunnelList"; +import { useTunnelProgress } from "./hooks/useTunnelProgress"; +import { useTunnelToggle } from "./hooks/useTunnelToggle"; +import { TunnelCard } from "./components/TunnelCard"; + +export function TunnelList() { + const { + tunnels, + loading, + error, + runningTunnels, + setRunningTunnels, + } = useTunnelList(); + + const { + tunnelProgress, + setTunnelProgress, + timeoutRefs, + successTimeoutRefs, + } = useTunnelProgress(tunnels, runningTunnels, setRunningTunnels); + + const { togglingTunnels, handleToggle } = useTunnelToggle({ + setTunnelProgress, + setRunningTunnels, + timeoutRefs, + successTimeoutRefs, + }); + + return ( +
+
+

隧道

+ {!loading && !error && ( + + {tunnels.length} 个 + + )} +
+ + {loading ? ( +
+ 加载中... +
+ ) : error ? ( +
+ {error} +
+ ) : ( + +
+ {tunnels.map((tunnel) => { + const isRunning = runningTunnels.has(tunnel.id); + const isToggling = togglingTunnels.has(tunnel.id); + const progress = tunnelProgress.get(tunnel.id); + return ( + + ); + })} +
+
+ )} +
+ ); +} + diff --git a/src/components/pages/TunnelList/cache.ts b/src/components/pages/TunnelList/cache.ts new file mode 100644 index 0000000..1f6e0b7 --- /dev/null +++ b/src/components/pages/TunnelList/cache.ts @@ -0,0 +1,10 @@ +import type { Tunnel } from "@/services/api"; +import type { TunnelProgress } from "./types"; + +// 模块级别的缓存,确保在组件卸载后数据仍然保留 +export const tunnelListCache = { + tunnels: [] as Tunnel[], +}; + +export const tunnelProgressCache = new Map(); + diff --git a/src/components/pages/TunnelList/components/TunnelCard.tsx b/src/components/pages/TunnelList/components/TunnelCard.tsx new file mode 100644 index 0000000..7204145 --- /dev/null +++ b/src/components/pages/TunnelList/components/TunnelCard.tsx @@ -0,0 +1,103 @@ +import { Progress } from "@/components/ui/progress"; +import type { Tunnel } from "@/services/api"; +import type { TunnelProgress } from "../types"; + +interface TunnelCardProps { + tunnel: Tunnel; + isRunning: boolean; + isToggling: boolean; + progress: TunnelProgress | undefined; + onToggle: (tunnel: Tunnel, enabled: boolean) => void; +} + +export function TunnelCard({ + tunnel, + isRunning, + isToggling, + progress, + onToggle, +}: TunnelCardProps) { + const progressValue = progress?.progress ?? 0; + const isError = progress?.isError ?? false; + const isSuccess = progress?.isSuccess ?? false; + + return ( +
+
+ div]:bg-destructive" + : isSuccess + ? "bg-green-500/20 [&>div]:bg-green-500" + : "" + }`} + /> +
+
+
+
+
+

+ {tunnel.name} +

+ + {tunnel.type} + +
+

+ {tunnel.node} +

+
+ +
+ +
+
+ 本地地址 + + {tunnel.localip}:{tunnel.nport} + +
+
+ 链接地址 + + {tunnel.ip}:{tunnel.dorp} + +
+ + {tunnel.nodestate && ( +
+ 节点 + + {tunnel.nodestate === "online" ? "在线" : "离线"} + +
+ )} +
+
+
+ ); +} + diff --git a/src/components/pages/TunnelList/hooks/useTunnelList.ts b/src/components/pages/TunnelList/hooks/useTunnelList.ts new file mode 100644 index 0000000..bc9670e --- /dev/null +++ b/src/components/pages/TunnelList/hooks/useTunnelList.ts @@ -0,0 +1,100 @@ +import { useState, useEffect } from "react"; +import { fetchTunnels, type Tunnel } from "@/services/api"; +import { frpcManager } from "@/services/frpcManager"; +import { tunnelListCache } from "../cache"; + +export function useTunnelList() { + const [tunnels, setTunnels] = useState(() => { + return tunnelListCache.tunnels; + }); + const [loading, setLoading] = useState(() => { + return tunnelListCache.tunnels.length === 0; + }); + const [error, setError] = useState(""); + const [runningTunnels, setRunningTunnels] = useState>(new Set()); + + const loadTunnels = async () => { + if (tunnelListCache.tunnels.length > 0) { + setTunnels(tunnelListCache.tunnels); + setLoading(false); + + const running = new Set(); + for (const tunnel of tunnelListCache.tunnels) { + const isRunning = await frpcManager.isTunnelRunning(tunnel.id); + if (isRunning) { + running.add(tunnel.id); + } + } + setRunningTunnels(running); + } else { + setLoading(true); + } + + setError(""); + try { + const data = await fetchTunnels(); + setTunnels(data); + tunnelListCache.tunnels = data; + + const running = new Set(); + for (const tunnel of data) { + const isRunning = await frpcManager.isTunnelRunning(tunnel.id); + if (isRunning) { + running.add(tunnel.id); + } + } + setRunningTunnels(running); + } catch (err) { + const message = err instanceof Error ? err.message : "获取隧道列表失败"; + if ( + message.includes("登录") || + message.includes("token") || + message.includes("令牌") + ) { + tunnelListCache.tunnels = []; + setTunnels([]); + setError(message); + } else if (tunnelListCache.tunnels.length === 0) { + setTunnels([]); + setError(message); + } + console.error("获取隧道列表失败", err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadTunnels(); + }, []); + + // 定期检查运行状态 + useEffect(() => { + if (tunnels.length === 0) return; + + const checkRunningStatus = async () => { + const running = new Set(); + for (const tunnel of tunnels) { + const isRunning = await frpcManager.isTunnelRunning(tunnel.id); + if (isRunning) { + running.add(tunnel.id); + } + } + setRunningTunnels(running); + }; + + const interval = setInterval(checkRunningStatus, 5000); + + return () => clearInterval(interval); + }, [tunnels]); + + return { + tunnels, + loading, + error, + runningTunnels, + setRunningTunnels, + loadTunnels, + }; +} + diff --git a/src/components/pages/TunnelList/hooks/useTunnelProgress.ts b/src/components/pages/TunnelList/hooks/useTunnelProgress.ts new file mode 100644 index 0000000..5ed314a --- /dev/null +++ b/src/components/pages/TunnelList/hooks/useTunnelProgress.ts @@ -0,0 +1,442 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import { toast } from "sonner"; +import type { Tunnel } from "@/services/api"; +import { getStoredUser, offlineTunnel } from "@/services/api"; +import { frpcManager, type LogMessage } from "@/services/frpcManager"; +import { logStore } from "@/services/logStore"; +import type { TunnelProgress } from "../types"; +import { tunnelProgressCache } from "../cache"; +import { restoreProgressFromLogs } from "../utils"; + +export function useTunnelProgress( + tunnels: Tunnel[], + runningTunnels: Set, + setRunningTunnels: Dispatch>>, +) { + const [tunnelProgress, setTunnelProgress] = useState< + Map + >(() => { + const cached = new Map(tunnelProgressCache); + const logs = logStore.getLogs(); + const restored = restoreProgressFromLogs(logs); + for (const [tunnelId, progress] of restored) { + cached.set(tunnelId, progress); + } + return cached; + }); + const [fixingTunnels, setFixingTunnels] = useState>(new Set()); + const timeoutRefs = useRef>>( + new Map(), + ); + const successTimeoutRefs = useRef>>( + new Map(), + ); + const processedErrorsRef = useRef>(new Set()); + + const handleDuplicateTunnelError = useCallback( + async (tunnelId: number, tunnelName: string) => { + const user = getStoredUser(); + if (!user?.usertoken) { + toast.error("未找到用户令牌,请重新登录"); + return; + } + + if (fixingTunnels.has(tunnelId)) { + return; + } + + setFixingTunnels((prev) => new Set(prev).add(tunnelId)); + + toast.info("隧道重复启动导致隧道启动失败,自动修复中....", { + duration: 10000, + }); + + let cleanedTunnelName = tunnelName?.trim() || ""; + cleanedTunnelName = cleanedTunnelName.replace(/^\*\*\*TOKEN\*\*\*\./, ""); + + if (!cleanedTunnelName || cleanedTunnelName === "") { + console.error("隧道名称为空,无法修复", { + tunnelId, + tunnelName, + cleanedTunnelName, + }); + toast.error("无法获取隧道名称,请手动处理"); + setFixingTunnels((prev) => { + const next = new Set(prev); + next.delete(tunnelId); + return next; + }); + return; + } + + try { + console.log("调用下线隧道API", { + tunnelId, + tunnelName: cleanedTunnelName, + }); + + await offlineTunnel(cleanedTunnelName, user.usertoken); + + await new Promise((resolve) => setTimeout(resolve, 8000)); + + const tunnel = tunnels.find((t) => t.id === tunnelId); + if (tunnel) { + setTunnelProgress((prev) => { + const next = new Map(prev); + const resetProgress = { + progress: 0, + isError: false, + isSuccess: false, + }; + next.set(tunnelId, resetProgress); + tunnelProgressCache.set(tunnelId, resetProgress); + return next; + }); + + try { + await frpcManager.stopTunnel(tunnelId); + await new Promise((resolve) => setTimeout(resolve, 500)); + } catch { + // 忽略停止错误 + } + + await frpcManager.startTunnel(tunnelId, user.usertoken); + setRunningTunnels((prev) => new Set(prev).add(tunnelId)); + + let hasChecked = false; + let hasSuccess = false; + const errorCheckInterval = setInterval(() => { + if (hasChecked) { + clearInterval(errorCheckInterval); + return; + } + + const logs = logStore.getLogs(); + const successLogs = logs.filter( + (log) => + log.tunnel_id === tunnelId && + log.message.includes("映射启动成功"), + ); + + if (successLogs.length > 0) { + hasSuccess = true; + hasChecked = true; + clearInterval(errorCheckInterval); + toast.success("隧道自动修复成功,已重新启动", { + duration: 5000, + }); + return; + } + + const recentErrorLogs = logs.filter( + (log) => + log.tunnel_id === tunnelId && + log.message.includes("启动失败") && + log.message.includes("already exists"), + ); + + if (recentErrorLogs.length > 0 && !hasSuccess) { + hasChecked = true; + clearInterval(errorCheckInterval); + toast.error( + "因为隧道重复启动导致映射启动失败。系统自动修复失败,请更换外网端口或节点", + { duration: 8000 }, + ); + setTunnelProgress((prev) => { + const current = prev.get(tunnelId); + if (current) { + const errorProgress = { + ...current, + progress: 100, + isError: true, + isSuccess: false, + }; + tunnelProgressCache.set(tunnelId, errorProgress); + return new Map(prev).set(tunnelId, errorProgress); + } + return prev; + }); + } + }, 2000); + + setTimeout(() => { + if (!hasChecked) { + hasChecked = true; + clearInterval(errorCheckInterval); + } + }, 20000); + } + } catch (err) { + const message = + err instanceof Error ? err.message : "自动修复失败"; + toast.error(message, { duration: 5000 }); + setTunnelProgress((prev) => { + const current = prev.get(tunnelId); + if (current) { + const errorProgress = { + ...current, + progress: 100, + isError: true, + }; + tunnelProgressCache.set(tunnelId, errorProgress); + return new Map(prev).set(tunnelId, errorProgress); + } + return prev; + }); + } finally { + setFixingTunnels((prev) => { + const next = new Set(prev); + next.delete(tunnelId); + return next; + }); + } + }, + [tunnels, fixingTunnels, setRunningTunnels], + ); + + // 初始化进度 + useEffect(() => { + const initializeProgress = async () => { + logStore.startListening(); + + const logs = logStore.getLogs(); + if (logs.length > 0) { + const restored = restoreProgressFromLogs(logs); + if (restored.size > 0) { + const runningTunnelsList = await frpcManager.getRunningTunnels(); + const runningSet = new Set(runningTunnelsList); + + setTunnelProgress((prev) => { + const merged = new Map(prev); + for (const [tunnelId, progress] of restored) { + if (!runningSet.has(tunnelId)) { + merged.set(tunnelId, { + progress: 0, + isError: false, + isSuccess: false, + }); + tunnelProgressCache.set(tunnelId, { + progress: 0, + isError: false, + isSuccess: false, + }); + } else { + merged.set(tunnelId, { ...progress, isSuccess: false }); + tunnelProgressCache.set(tunnelId, { + ...progress, + isSuccess: false, + }); + } + } + return merged; + }); + } + } + }; + + initializeProgress(); + }, []); + + // 监听日志更新 + useEffect(() => { + const unsubscribe = logStore.subscribe((logs: LogMessage[]) => { + if (logs.length === 0) return; + + const latestLog = logs[logs.length - 1]; + const tunnelId = latestLog.tunnel_id; + const message = latestLog.message; + + setTunnelProgress((prev) => { + const current = prev.get(tunnelId); + if (!current && !message.includes("frpc 进程已启动")) { + return prev; + } + + const newProgress = + current || { progress: 0, isError: false, isSuccess: false }; + + if (message.includes("frpc 进程已启动")) { + newProgress.startTime = Date.now(); + newProgress.progress = 10; + if (timeoutRefs.current.has(tunnelId)) { + clearTimeout(timeoutRefs.current.get(tunnelId)!); + } + const timeout = setTimeout(() => { + setTunnelProgress((prev) => { + const current = prev.get(tunnelId); + if (current && current.progress < 100 && !current.isError) { + const errorProgress = { + ...current, + progress: 100, + isError: true, + }; + tunnelProgressCache.set(tunnelId, errorProgress); + return new Map(prev).set(tunnelId, errorProgress); + } + return prev; + }); + }, 10000); + timeoutRefs.current.set(tunnelId, timeout); + } else if (message.includes("从ChmlFrp API获取配置文件")) { + newProgress.progress = 20; + } else if (message.includes("已写入配置文件")) { + newProgress.progress = 40; + } else if (message.includes("成功登录至服务器")) { + newProgress.progress = 60; + } else if (message.includes("已启动隧道")) { + newProgress.progress = 80; + } else if (message.includes("映射启动成功")) { + newProgress.progress = 100; + newProgress.isError = false; + newProgress.isSuccess = true; + if (timeoutRefs.current.has(tunnelId)) { + clearTimeout(timeoutRefs.current.get(tunnelId)!); + timeoutRefs.current.delete(tunnelId); + } + if (successTimeoutRefs.current.has(tunnelId)) { + clearTimeout(successTimeoutRefs.current.get(tunnelId)!); + } + const successTimeout = setTimeout(() => { + setTunnelProgress((prev) => { + const current = prev.get(tunnelId); + if (current) { + const updated = { + ...current, + isSuccess: false, + }; + tunnelProgressCache.set(tunnelId, updated); + return new Map(prev).set(tunnelId, updated); + } + return prev; + }); + successTimeoutRefs.current.delete(tunnelId); + }, 2000); + successTimeoutRefs.current.set(tunnelId, successTimeout); + } else if ( + message.includes("启动失败") && + message.includes("already exists") + ) { + const errorKey = `${tunnelId}-${message}`; + + if (processedErrorsRef.current.has(errorKey)) { + return prev; + } + + if (!fixingTunnels.has(tunnelId)) { + const match = message.match(/\[([^\]]+)\]/g); + let tunnelName = ""; + + if (match && match.length > 0) { + tunnelName = match[match.length - 1].slice(1, -1); + tunnelName = tunnelName.replace(/^\*\*\*TOKEN\*\*\*\./, ""); + } + + if (!tunnelName || tunnelName.trim() === "") { + const tunnel = tunnels.find((t) => t.id === tunnelId); + if (tunnel) { + tunnelName = tunnel.name; + tunnelName = tunnelName.replace(/^\*\*\*TOKEN\*\*\*\./, ""); + } + } + + if (tunnelName && tunnelName.trim() !== "") { + processedErrorsRef.current.add(errorKey); + setTimeout(() => { + processedErrorsRef.current.delete(errorKey); + }, 5 * 60 * 1000); + + handleDuplicateTunnelError(tunnelId, tunnelName.trim()); + } else { + console.error("无法提取隧道名称", { + tunnelId, + message, + matches: match, + }); + processedErrorsRef.current.add(errorKey); + } + } + } + + const updated = new Map(prev).set(tunnelId, { ...newProgress }); + tunnelProgressCache.set(tunnelId, { ...newProgress }); + return updated; + }); + }); + + return () => { + unsubscribe(); + }; + }, [fixingTunnels, handleDuplicateTunnelError, tunnels]); + + // 清理定时器 + useEffect(() => { + const timeouts = timeoutRefs.current; + const successTimeouts = successTimeoutRefs.current; + return () => { + timeouts.forEach((timeout) => clearTimeout(timeout)); + timeouts.clear(); + successTimeouts.forEach((timeout) => clearTimeout(timeout)); + successTimeouts.clear(); + }; + }, []); + + // 监听运行状态变化,更新进度 + useEffect(() => { + if (tunnels.length === 0) return; + + const checkRunningStatus = async () => { + for (const tunnel of tunnels) { + const isRunning = await frpcManager.isTunnelRunning(tunnel.id); + if (!isRunning) { + if (runningTunnels.has(tunnel.id)) { + setTunnelProgress((prev) => { + const current = prev.get(tunnel.id); + if (current && current.progress < 100) { + const errorProgress = { + ...current, + progress: 100, + isError: true, + isSuccess: false, + }; + tunnelProgressCache.set(tunnel.id, errorProgress); + return new Map(prev).set(tunnel.id, errorProgress); + } + return prev; + }); + } else { + setTunnelProgress((prev) => { + const current = prev.get(tunnel.id); + if (current && current.progress > 0) { + const cleared = { + progress: 0, + isError: false, + isSuccess: false, + }; + tunnelProgressCache.set(tunnel.id, cleared); + return new Map(prev).set(tunnel.id, cleared); + } + return prev; + }); + if (successTimeoutRefs.current.has(tunnel.id)) { + clearTimeout(successTimeoutRefs.current.get(tunnel.id)!); + successTimeoutRefs.current.delete(tunnel.id); + } + } + } + } + }; + + const interval = setInterval(checkRunningStatus, 5000); + + return () => clearInterval(interval); + }, [tunnels, runningTunnels]); + + return { + tunnelProgress, + setTunnelProgress, + timeoutRefs, + successTimeoutRefs, + }; +} + diff --git a/src/components/pages/TunnelList/hooks/useTunnelToggle.ts b/src/components/pages/TunnelList/hooks/useTunnelToggle.ts new file mode 100644 index 0000000..74fadca --- /dev/null +++ b/src/components/pages/TunnelList/hooks/useTunnelToggle.ts @@ -0,0 +1,127 @@ +import { useState } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import { toast } from "sonner"; +import type { Tunnel } from "@/services/api"; +import { getStoredUser } from "@/services/api"; +import { frpcManager } from "@/services/frpcManager"; +import type { TunnelProgress } from "../types"; +import { tunnelProgressCache } from "../cache"; + +interface UseTunnelToggleProps { + setTunnelProgress: Dispatch< + SetStateAction> + >; + setRunningTunnels: Dispatch>>; + timeoutRefs: React.MutableRefObject< + Map> + >; + successTimeoutRefs: React.MutableRefObject< + Map> + >; +} + +export function useTunnelToggle({ + setTunnelProgress, + setRunningTunnels, + timeoutRefs, + successTimeoutRefs, +}: UseTunnelToggleProps) { + const [togglingTunnels, setTogglingTunnels] = useState>( + new Set(), + ); + + const handleToggle = async (tunnel: Tunnel, enabled: boolean) => { + const user = getStoredUser(); + if (!user?.usertoken) { + toast.error("未找到用户令牌,请重新登录"); + return; + } + + if (togglingTunnels.has(tunnel.id)) { + return; + } + + setTogglingTunnels((prev) => new Set(prev).add(tunnel.id)); + + try { + if (enabled) { + setTunnelProgress((prev) => { + const next = new Map(prev); + const resetProgress = { + progress: 0, + isError: false, + isSuccess: false, + }; + next.set(tunnel.id, resetProgress); + tunnelProgressCache.set(tunnel.id, resetProgress); + return next; + }); + const message = await frpcManager.startTunnel( + tunnel.id, + user.usertoken, + ); + toast.success(message || `隧道 ${tunnel.name} 已启动`); + setRunningTunnels((prev) => new Set(prev).add(tunnel.id)); + } else { + const message = await frpcManager.stopTunnel(tunnel.id); + toast.success(message || `隧道 ${tunnel.name} 已停止`); + setRunningTunnels((prev) => { + const next = new Set(prev); + next.delete(tunnel.id); + return next; + }); + setTunnelProgress((prev) => { + const next = new Map(prev); + next.set(tunnel.id, { + progress: 0, + isError: false, + isSuccess: false, + }); + tunnelProgressCache.set(tunnel.id, { + progress: 0, + isError: false, + isSuccess: false, + }); + return next; + }); + if (timeoutRefs.current.has(tunnel.id)) { + clearTimeout(timeoutRefs.current.get(tunnel.id)!); + timeoutRefs.current.delete(tunnel.id); + } + if (successTimeoutRefs.current.has(tunnel.id)) { + clearTimeout(successTimeoutRefs.current.get(tunnel.id)!); + successTimeoutRefs.current.delete(tunnel.id); + } + } + } catch (err) { + const message = + err instanceof Error ? err.message : `${enabled ? "启动" : "停止"}失败`; + toast.error(message); + if (enabled) { + const errorProgress = { + progress: 100, + isError: true, + isSuccess: false, + }; + setTunnelProgress((prev) => { + const next = new Map(prev); + next.set(tunnel.id, errorProgress); + return next; + }); + tunnelProgressCache.set(tunnel.id, errorProgress); + } + } finally { + setTogglingTunnels((prev) => { + const next = new Set(prev); + next.delete(tunnel.id); + return next; + }); + } + }; + + return { + togglingTunnels, + handleToggle, + }; +} + diff --git a/src/components/pages/TunnelList/types.ts b/src/components/pages/TunnelList/types.ts new file mode 100644 index 0000000..b0a7f61 --- /dev/null +++ b/src/components/pages/TunnelList/types.ts @@ -0,0 +1,7 @@ +export interface TunnelProgress { + progress: number; // 0-100 + isError: boolean; // 是否错误状态(红色) + isSuccess: boolean; // 是否成功状态(绿色) + startTime?: number; // 启动时间戳 +} + diff --git a/src/components/pages/TunnelList/utils.ts b/src/components/pages/TunnelList/utils.ts new file mode 100644 index 0000000..ed52fda --- /dev/null +++ b/src/components/pages/TunnelList/utils.ts @@ -0,0 +1,52 @@ +import type { LogMessage } from "@/services/frpcManager"; +import type { TunnelProgress } from "./types"; +import { tunnelProgressCache } from "./cache"; + +export function restoreProgressFromLogs( + logs: LogMessage[], +): Map { + const progressMap = new Map(); + const logsByTunnel = new Map(); + for (const log of logs) { + if (!logsByTunnel.has(log.tunnel_id)) { + logsByTunnel.set(log.tunnel_id, []); + } + logsByTunnel.get(log.tunnel_id)!.push(log); + } + + for (const [tunnelId, tunnelLogs] of logsByTunnel) { + let progress = 0; + let isError = false; + let isSuccess = false; + + for (let i = tunnelLogs.length - 1; i >= 0; i--) { + const message = tunnelLogs[i].message; + + if (message.includes("映射启动成功")) { + progress = 100; + isError = false; + isSuccess = false; + break; + } else if (message.includes("已启动隧道")) { + progress = 80; + } else if (message.includes("成功登录至服务器")) { + progress = 60; + } else if (message.includes("已写入配置文件")) { + progress = 40; + } else if (message.includes("从ChmlFrp API获取配置文件")) { + progress = 20; + } else if (message.includes("frpc 进程已启动")) { + progress = 10; + break; + } + } + + if (progress > 0) { + progressMap.set(tunnelId, { progress, isError, isSuccess }); + tunnelProgressCache.set(tunnelId, { progress, isError, isSuccess }); + } + } + + return progressMap; +} + From ecfb9e64ac9681ae3b6876f24c6df42c3854dde4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cchaoji233=E2=80=9D?= Date: Tue, 13 Jan 2026 01:06:23 +0800 Subject: [PATCH 03/11] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E9=9D=A2=E4=BB=A3=E7=A0=81=EF=BC=8C=E4=BE=BF?= =?UTF-8?q?=E4=BA=8E=E7=BB=B4=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 2 +- src/components/pages/Settings.tsx | 756 ------------------ src/components/pages/Settings/Settings.tsx | 117 +++ .../Settings/components/AppearanceSection.tsx | 251 ++++++ .../Settings/components/NetworkSection.tsx | 54 ++ .../Settings/components/SystemSection.tsx | 89 +++ .../Settings/components/UpdateSection.tsx | 85 ++ .../pages/Settings/hooks/useAutostart.ts | 47 ++ .../Settings/hooks/useBackgroundImage.ts | 98 +++ .../pages/Settings/hooks/useFrpcDownload.ts | 57 ++ .../pages/Settings/hooks/useTheme.ts | 66 ++ .../pages/Settings/hooks/useUpdate.ts | 89 +++ src/components/pages/Settings/types.ts | 2 + src/components/pages/Settings/utils.ts | 66 ++ 14 files changed, 1022 insertions(+), 757 deletions(-) delete mode 100644 src/components/pages/Settings.tsx create mode 100644 src/components/pages/Settings/Settings.tsx create mode 100644 src/components/pages/Settings/components/AppearanceSection.tsx create mode 100644 src/components/pages/Settings/components/NetworkSection.tsx create mode 100644 src/components/pages/Settings/components/SystemSection.tsx create mode 100644 src/components/pages/Settings/components/UpdateSection.tsx create mode 100644 src/components/pages/Settings/hooks/useAutostart.ts create mode 100644 src/components/pages/Settings/hooks/useBackgroundImage.ts create mode 100644 src/components/pages/Settings/hooks/useFrpcDownload.ts create mode 100644 src/components/pages/Settings/hooks/useTheme.ts create mode 100644 src/components/pages/Settings/hooks/useUpdate.ts create mode 100644 src/components/pages/Settings/types.ts create mode 100644 src/components/pages/Settings/utils.ts diff --git a/src/App.tsx b/src/App.tsx index 7319489..7ed3a01 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,7 @@ import { TitleBar } from "@/components/TitleBar"; import { Home } from "@/components/pages/Home"; import { TunnelList } from "@/components/pages/TunnelList/TunnelList"; import { Logs } from "@/components/pages/Logs"; -import { Settings } from "@/components/pages/Settings"; +import { Settings } from "@/components/pages/Settings/Settings"; import { getStoredUser, type StoredUser } from "@/services/api"; import { frpcDownloader } from "@/services/frpcDownloader.ts"; import { updateService } from "@/services/updateService"; diff --git a/src/components/pages/Settings.tsx b/src/components/pages/Settings.tsx deleted file mode 100644 index e64ee38..0000000 --- a/src/components/pages/Settings.tsx +++ /dev/null @@ -1,756 +0,0 @@ -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { frpcDownloader } from "@/services/frpcDownloader"; -import { autostartService } from "@/services/autostartService"; -import { updateService } from "@/services/updateService"; -import { Progress } from "@/components/ui/progress"; -import { - Item, - ItemContent, - ItemTitle, - ItemDescription, - ItemActions, -} from "@/components/ui/item"; -import { open } from "@tauri-apps/plugin-dialog"; -import { readFile } from "@tauri-apps/plugin-fs"; -import { Palette, Network, Settings2, Sparkles } from "lucide-react"; - -type ThemeMode = "light" | "dark"; - -const getInitialFollowSystem = (): boolean => { - if (typeof window === "undefined") return true; - const stored = localStorage.getItem("themeFollowSystem"); - return stored !== "false"; -}; - -const getInitialTheme = (): ThemeMode => { - if (typeof window === "undefined") return "light"; - const followSystem = getInitialFollowSystem(); - if (followSystem) { - const prefersDark = window.matchMedia( - "(prefers-color-scheme: dark)", - ).matches; - return prefersDark ? "dark" : "light"; - } - const stored = localStorage.getItem("theme") as ThemeMode | null; - if (stored === "light" || stored === "dark") return stored; - return "light"; -}; - -const getInitialBackgroundImage = (): string | null => { - if (typeof window === "undefined") return null; - return localStorage.getItem("backgroundImage"); -}; - -const getInitialBackgroundOverlayOpacity = (): number => { - if (typeof window === "undefined") return 80; - const stored = localStorage.getItem("backgroundOverlayOpacity"); - return stored ? parseInt(stored, 10) : 80; -}; - -const getInitialBackgroundBlur = (): number => { - if (typeof window === "undefined") return 4; - const stored = localStorage.getItem("backgroundBlur"); - return stored ? parseInt(stored, 10) : 4; -}; - -const getInitialBypassProxy = (): boolean => { - if (typeof window === "undefined") return true; - const stored = localStorage.getItem("bypassProxy"); - return stored !== "false"; -}; - -const getInitialShowTitleBar = (): boolean => { - if (typeof window === "undefined") return false; - const stored = localStorage.getItem("showTitleBar"); - // 如果从未设置过,默认返回 false(关闭) - if (stored === null) return false; - return stored === "true"; -}; - -export function Settings() { - const isMacOS = typeof navigator !== "undefined" && navigator.platform.toUpperCase().indexOf("MAC") >= 0; - const [followSystem, setFollowSystem] = useState(() => - getInitialFollowSystem(), - ); - const [theme, setTheme] = useState(() => getInitialTheme()); - const [isDownloading, setIsDownloading] = useState(false); - const [autostartEnabled, setAutostartEnabled] = useState(false); - const [autostartLoading, setAutostartLoading] = useState(false); - const [autoCheckUpdate, setAutoCheckUpdate] = useState(() => - updateService.getAutoCheckEnabled(), - ); - const [checkingUpdate, setCheckingUpdate] = useState(false); - const [currentVersion, setCurrentVersion] = useState(""); - const [backgroundImage, setBackgroundImage] = useState(() => - getInitialBackgroundImage(), - ); - const [isSelectingImage, setIsSelectingImage] = useState(false); - const [overlayOpacity, setOverlayOpacity] = useState(() => - getInitialBackgroundOverlayOpacity(), - ); - const [blur, setBlur] = useState(() => getInitialBackgroundBlur()); - const [bypassProxy, setBypassProxy] = useState(() => - getInitialBypassProxy(), - ); - const [showTitleBar, setShowTitleBar] = useState(() => - getInitialShowTitleBar(), - ); - - useEffect(() => { - localStorage.setItem("themeFollowSystem", followSystem.toString()); - }, [followSystem]); - - useEffect(() => { - if (followSystem) { - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); - const handleSystemThemeChange = (e: MediaQueryListEvent) => { - const newTheme = e.matches ? "dark" : "light"; - setTheme(newTheme); - }; - - const initialTheme = mediaQuery.matches ? "dark" : "light"; - setTheme(initialTheme); - - mediaQuery.addEventListener("change", handleSystemThemeChange); - return () => { - mediaQuery.removeEventListener("change", handleSystemThemeChange); - }; - } else { - const stored = localStorage.getItem("theme") as ThemeMode | null; - if (stored === "light" || stored === "dark") { - setTheme(stored); - } else { - const prefersDark = window.matchMedia( - "(prefers-color-scheme: dark)", - ).matches; - setTheme(prefersDark ? "dark" : "light"); - } - } - }, [followSystem]); - - useEffect(() => { - const root = document.documentElement; - if (theme === "dark") { - root.classList.add("dark"); - } else { - root.classList.remove("dark"); - } - if (!followSystem) { - localStorage.setItem("theme", theme); - } - window.dispatchEvent(new Event("themeChanged")); - }, [theme, followSystem]); - - useEffect(() => { - localStorage.setItem("backgroundImage", backgroundImage || ""); - window.dispatchEvent(new Event("backgroundImageChanged")); - }, [backgroundImage]); - - useEffect(() => { - localStorage.setItem("backgroundOverlayOpacity", overlayOpacity.toString()); - window.dispatchEvent(new Event("backgroundOverlayChanged")); - }, [overlayOpacity]); - - useEffect(() => { - localStorage.setItem("backgroundBlur", blur.toString()); - window.dispatchEvent(new Event("backgroundOverlayChanged")); - }, [blur]); - - useEffect(() => { - localStorage.setItem("bypassProxy", bypassProxy.toString()); - }, [bypassProxy]); - - useEffect(() => { - localStorage.setItem("showTitleBar", showTitleBar.toString()); - window.dispatchEvent(new Event("titleBarVisibilityChanged")); - }, [showTitleBar]); - - useEffect(() => { - const checkAutostart = async () => { - try { - const enabled = await autostartService.isEnabled(); - setAutostartEnabled(enabled); - } catch (error) { - console.error("检查开机自启状态失败:", error); - } - }; - checkAutostart(); - }, []); - - useEffect(() => { - const loadVersion = async () => { - try { - const version = await updateService.getCurrentVersion(); - setCurrentVersion(version); - } catch (error) { - console.error("获取版本失败:", error); - } - }; - loadVersion(); - }, []); - - const handleRedownloadFrpc = async () => { - if (isDownloading) return; - - setIsDownloading(true); - const toastId = toast.loading( -
-
正在下载 frpc 客户端...
- -
0.0%
-
, - { duration: Infinity }, - ); - - try { - await frpcDownloader.downloadFrpc((progress) => { - toast.loading( -
-
正在下载 frpc 客户端...
- -
- {progress.percentage.toFixed(1)}% ( - {(progress.downloaded / 1024 / 1024).toFixed(2)} MB /{" "} - {(progress.total / 1024 / 1024).toFixed(2)} MB) -
-
, - { id: toastId, duration: Infinity }, - ); - }); - - toast.success("frpc 客户端下载成功", { - id: toastId, - duration: 3000, - }); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - toast.error( -
-
下载失败
-
{errorMsg}
-
, - { id: toastId, duration: 8000 }, - ); - } finally { - setIsDownloading(false); - } - }; - - const handleToggleAutostart = async (enabled: boolean) => { - if (autostartLoading) return; - - setAutostartLoading(true); - try { - await autostartService.setEnabled(enabled); - setAutostartEnabled(enabled); - toast.success(enabled ? "已启用开机自启" : "已禁用开机自启", { - duration: 2000, - }); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - toast.error(`设置失败: ${errorMsg}`, { - duration: 3000, - }); - } finally { - setAutostartLoading(false); - } - }; - - const handleCheckUpdate = async () => { - if (checkingUpdate) return; - - setCheckingUpdate(true); - const toastId = toast.loading("正在检查更新...", { duration: Infinity }); - - try { - const result = await updateService.checkUpdate(); - - if (result.available) { - toast.success( -
-
- 发现新版本: {result.version} -
-
- 更新将在后台下载,完成后会提示您安装 -
-
, - { id: toastId, duration: 8000 }, - ); - - try { - await updateService.installUpdate(); - toast.success("更新已下载完成,应用将在重启后更新", { - duration: 5000, - }); - } catch (installError) { - const errorMsg = - installError instanceof Error - ? installError.message - : String(installError); - toast.error(`下载更新失败: ${errorMsg}`, { - duration: 5000, - }); - } - } else { - toast.success("当前已是最新版本", { - id: toastId, - duration: 3000, - }); - } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - toast.error(`检查更新失败: ${errorMsg}`, { - id: toastId, - duration: 5000, - }); - } finally { - setCheckingUpdate(false); - } - }; - - const handleToggleAutoCheckUpdate = (enabled: boolean) => { - updateService.setAutoCheckEnabled(enabled); - setAutoCheckUpdate(enabled); - toast.success( - enabled ? "已启用启动时自动检测更新" : "已禁用启动时自动检测更新", - { - duration: 2000, - }, - ); - }; - - const handleSelectBackgroundImage = async () => { - if (isSelectingImage) return; - - setIsSelectingImage(true); - try { - const selected = await open({ - multiple: false, - filters: [ - { - name: "图片", - extensions: ["png", "jpg", "jpeg", "gif", "webp", "bmp"], - }, - ], - }); - - if (selected && typeof selected === "string") { - const fileData = await readFile(selected); - const uint8Array = new Uint8Array(fileData); - - let binaryString = ""; - for (let i = 0; i < uint8Array.length; i++) { - binaryString += String.fromCharCode(uint8Array[i]); - } - - const base64 = btoa(binaryString); - const mimeType = getMimeType(selected); - const dataUrl = `data:${mimeType};base64,${base64}`; - - setBackgroundImage(dataUrl); - toast.success("背景图设置成功", { - duration: 2000, - }); - } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - toast.error(`选择图片失败: ${errorMsg}`, { - duration: 3000, - }); - } finally { - setIsSelectingImage(false); - } - }; - - const handleClearBackgroundImage = () => { - setBackgroundImage(null); - toast.success("已清除背景图", { - duration: 2000, - }); - }; - - const getMimeType = (filePath: string): string => { - const ext = filePath.split(".").pop()?.toLowerCase(); - const mimeTypes: Record = { - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - webp: "image/webp", - bmp: "image/bmp", - }; - return mimeTypes[ext || ""] || "image/png"; - }; - - return ( -
-
-

设置

-
- -
- {/* 外观设置 */} -
-
- - 外观 -
-
- - - 跟随系统主题 - - 自动跟随系统主题设置 - - - - - - - - {!followSystem && ( - - - 主题 - - 选择界面配色方案 - - - -
- - -
-
-
- )} - - {/* 只在 macOS 上显示顶部栏开关 */} - {isMacOS && ( - - - 显示顶部栏 - - 显示顶部标题栏(关闭时,三色按钮将显示在侧边栏顶部) - - - - - - - )} - - - - 背景图 - - 设置应用背景图片 - {backgroundImage && ( - (已设置) - )} - - - -
- - {backgroundImage && ( - - )} -
-
-
- - {backgroundImage && ( - <> - - - 遮罩透明度 - - 调整背景图遮罩的透明度 ({overlayOpacity}%) - - - -
- - setOverlayOpacity(parseInt(e.target.value, 10)) - } - className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-foreground" - style={{ - background: `linear-gradient(to right, var(--foreground) 0%, var(--foreground) ${overlayOpacity}%, var(--muted) ${overlayOpacity}%, var(--muted) 100%)`, - }} - /> - - {overlayOpacity}% - -
-
-
- - - - 模糊度 - - 调整背景图的模糊效果 ({blur}px) - - - -
- setBlur(parseInt(e.target.value, 10))} - className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-foreground" - style={{ - background: `linear-gradient(to right, var(--foreground) 0%, var(--foreground) ${(blur / 20) * 100}%, var(--muted) ${(blur / 20) * 100}%, var(--muted) 100%)`, - }} - /> - - {blur}px - -
-
-
- - )} -
-
- - {/* 网络设置 */} -
-
- - 网络 -
-
- - - 绕过代理 - - 网络请求绕过系统代理设置 - - - - - - -
-
- - {/* 系统设置 */} -
-
- - 系统 -
-
- - - 开机自启 - - 系统启动时自动运行应用 - - - - - - - - - - 启动时自动检测更新 - - 应用启动时自动检查是否有可用更新 - - - - - - -
-
- - {/* 更新与下载 */} -
-
- - 更新与下载 -
-
- - - 应用更新 - - 检查并安装应用更新 - {currentVersion && ( - 当前版本: v{currentVersion} - )} - - - - - - - - - - frpc 客户端 - - 重新下载 frpc 客户端程序 - - - - - - -
-
-
-
- ); -} diff --git a/src/components/pages/Settings/Settings.tsx b/src/components/pages/Settings/Settings.tsx new file mode 100644 index 0000000..1503ba5 --- /dev/null +++ b/src/components/pages/Settings/Settings.tsx @@ -0,0 +1,117 @@ +import { useState, useEffect } from "react"; +import { useTheme } from "./hooks/useTheme"; +import { useBackgroundImage } from "./hooks/useBackgroundImage"; +import { useAutostart } from "./hooks/useAutostart"; +import { useUpdate } from "./hooks/useUpdate"; +import { useFrpcDownload } from "./hooks/useFrpcDownload"; +import { getInitialBypassProxy, getInitialShowTitleBar } from "./utils"; +import { AppearanceSection } from "./components/AppearanceSection"; +import { NetworkSection } from "./components/NetworkSection"; +import { SystemSection } from "./components/SystemSection"; +import { UpdateSection } from "./components/UpdateSection"; + +export function Settings() { + const isMacOS = + typeof navigator !== "undefined" && + navigator.platform.toUpperCase().indexOf("MAC") >= 0; + + const { + followSystem, + setFollowSystem, + theme, + setTheme, + } = useTheme(); + + const { + backgroundImage, + isSelectingImage, + overlayOpacity, + setOverlayOpacity, + blur, + setBlur, + handleSelectBackgroundImage, + handleClearBackgroundImage, + } = useBackgroundImage(); + + const { + autostartEnabled, + autostartLoading, + handleToggleAutostart, + } = useAutostart(); + + const { + autoCheckUpdate, + checkingUpdate, + currentVersion, + handleCheckUpdate, + handleToggleAutoCheckUpdate, + } = useUpdate(); + + const { isDownloading, handleRedownloadFrpc } = useFrpcDownload(); + + const [bypassProxy, setBypassProxy] = useState(() => + getInitialBypassProxy(), + ); + const [showTitleBar, setShowTitleBar] = useState(() => + getInitialShowTitleBar(), + ); + + useEffect(() => { + localStorage.setItem("bypassProxy", bypassProxy.toString()); + }, [bypassProxy]); + + useEffect(() => { + localStorage.setItem("showTitleBar", showTitleBar.toString()); + window.dispatchEvent(new Event("titleBarVisibilityChanged")); + }, [showTitleBar]); + + return ( +
+
+

设置

+
+ +
+ + + + + + + +
+
+ ); +} + diff --git a/src/components/pages/Settings/components/AppearanceSection.tsx b/src/components/pages/Settings/components/AppearanceSection.tsx new file mode 100644 index 0000000..b4172be --- /dev/null +++ b/src/components/pages/Settings/components/AppearanceSection.tsx @@ -0,0 +1,251 @@ +import { Palette } from "lucide-react"; +import { + Item, + ItemContent, + ItemTitle, + ItemDescription, + ItemActions, +} from "@/components/ui/item"; +import type { ThemeMode } from "../types"; + +interface AppearanceSectionProps { + isMacOS: boolean; + followSystem: boolean; + setFollowSystem: (value: boolean) => void; + theme: ThemeMode; + setTheme: (theme: ThemeMode) => void; + showTitleBar: boolean; + setShowTitleBar: (value: boolean) => void; + backgroundImage: string | null; + isSelectingImage: boolean; + overlayOpacity: number; + setOverlayOpacity: (value: number) => void; + blur: number; + setBlur: (value: number) => void; + onSelectBackgroundImage: () => void; + onClearBackgroundImage: () => void; +} + +export function AppearanceSection({ + isMacOS, + followSystem, + setFollowSystem, + theme, + setTheme, + showTitleBar, + setShowTitleBar, + backgroundImage, + isSelectingImage, + overlayOpacity, + setOverlayOpacity, + blur, + setBlur, + onSelectBackgroundImage, + onClearBackgroundImage, +}: AppearanceSectionProps) { + return ( +
+
+ + 外观 +
+
+ + + 跟随系统主题 + + 自动跟随系统主题设置 + + + + + + + + {!followSystem && ( + + + 主题 + + 选择界面配色方案 + + + +
+ + +
+
+
+ )} + + {/* 只在 macOS 上显示顶部栏开关 */} + {isMacOS && ( + + + 显示顶部栏 + + 显示顶部标题栏(关闭时,三色按钮将显示在侧边栏顶部) + + + + + + + )} + + + + 背景图 + + 设置应用背景图片 + {backgroundImage && ( + (已设置) + )} + + + +
+ + {backgroundImage && ( + + )} +
+
+
+ + {backgroundImage && ( + <> + + + 遮罩透明度 + + 调整背景图遮罩的透明度 ({overlayOpacity}%) + + + +
+ + setOverlayOpacity(parseInt(e.target.value, 10)) + } + className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-foreground" + style={{ + background: `linear-gradient(to right, var(--foreground) 0%, var(--foreground) ${overlayOpacity}%, var(--muted) ${overlayOpacity}%, var(--muted) 100%)`, + }} + /> + + {overlayOpacity}% + +
+
+
+ + + + 模糊度 + + 调整背景图的模糊效果 ({blur}px) + + + +
+ setBlur(parseInt(e.target.value, 10))} + className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-foreground" + style={{ + background: `linear-gradient(to right, var(--foreground) 0%, var(--foreground) ${(blur / 20) * 100}%, var(--muted) ${(blur / 20) * 100}%, var(--muted) 100%)`, + }} + /> + + {blur}px + +
+
+
+ + )} +
+
+ ); +} + diff --git a/src/components/pages/Settings/components/NetworkSection.tsx b/src/components/pages/Settings/components/NetworkSection.tsx new file mode 100644 index 0000000..2a43243 --- /dev/null +++ b/src/components/pages/Settings/components/NetworkSection.tsx @@ -0,0 +1,54 @@ +import { Network } from "lucide-react"; +import { + Item, + ItemContent, + ItemTitle, + ItemDescription, + ItemActions, +} from "@/components/ui/item"; + +interface NetworkSectionProps { + bypassProxy: boolean; + setBypassProxy: (value: boolean) => void; +} + +export function NetworkSection({ + bypassProxy, + setBypassProxy, +}: NetworkSectionProps) { + return ( +
+
+ + 网络 +
+
+ + + 绕过代理 + + 网络请求绕过系统代理设置 + + + + + + +
+
+ ); +} + diff --git a/src/components/pages/Settings/components/SystemSection.tsx b/src/components/pages/Settings/components/SystemSection.tsx new file mode 100644 index 0000000..4f1b304 --- /dev/null +++ b/src/components/pages/Settings/components/SystemSection.tsx @@ -0,0 +1,89 @@ +import { Settings2 } from "lucide-react"; +import { + Item, + ItemContent, + ItemTitle, + ItemDescription, + ItemActions, +} from "@/components/ui/item"; + +interface SystemSectionProps { + autostartEnabled: boolean; + autostartLoading: boolean; + onToggleAutostart: (enabled: boolean) => void; + autoCheckUpdate: boolean; + onToggleAutoCheckUpdate: (enabled: boolean) => void; +} + +export function SystemSection({ + autostartEnabled, + autostartLoading, + onToggleAutostart, + autoCheckUpdate, + onToggleAutoCheckUpdate, +}: SystemSectionProps) { + return ( +
+
+ + 系统 +
+
+ + + 开机自启 + + 系统启动时自动运行应用 + + + + + + + + + + 启动时自动检测更新 + + 应用启动时自动检查是否有可用更新 + + + + + + +
+
+ ); +} + diff --git a/src/components/pages/Settings/components/UpdateSection.tsx b/src/components/pages/Settings/components/UpdateSection.tsx new file mode 100644 index 0000000..bb70c61 --- /dev/null +++ b/src/components/pages/Settings/components/UpdateSection.tsx @@ -0,0 +1,85 @@ +import { Sparkles } from "lucide-react"; +import { + Item, + ItemContent, + ItemTitle, + ItemDescription, + ItemActions, +} from "@/components/ui/item"; + +interface UpdateSectionProps { + checkingUpdate: boolean; + currentVersion: string; + onCheckUpdate: () => void; + isDownloading: boolean; + onRedownloadFrpc: () => void; +} + +export function UpdateSection({ + checkingUpdate, + currentVersion, + onCheckUpdate, + isDownloading, + onRedownloadFrpc, +}: UpdateSectionProps) { + return ( +
+
+ + 更新与下载 +
+
+ + + 应用更新 + + 检查并安装应用更新 + {currentVersion && ( + 当前版本: v{currentVersion} + )} + + + + + + + + + + frpc 客户端 + + 重新下载 frpc 客户端程序 + + + + + + +
+
+ ); +} + diff --git a/src/components/pages/Settings/hooks/useAutostart.ts b/src/components/pages/Settings/hooks/useAutostart.ts new file mode 100644 index 0000000..436049f --- /dev/null +++ b/src/components/pages/Settings/hooks/useAutostart.ts @@ -0,0 +1,47 @@ +import { useState, useEffect } from "react"; +import { toast } from "sonner"; +import { autostartService } from "@/services/autostartService"; + +export function useAutostart() { + const [autostartEnabled, setAutostartEnabled] = useState(false); + const [autostartLoading, setAutostartLoading] = useState(false); + + useEffect(() => { + const checkAutostart = async () => { + try { + const enabled = await autostartService.isEnabled(); + setAutostartEnabled(enabled); + } catch (error) { + console.error("检查开机自启状态失败:", error); + } + }; + checkAutostart(); + }, []); + + const handleToggleAutostart = async (enabled: boolean) => { + if (autostartLoading) return; + + setAutostartLoading(true); + try { + await autostartService.setEnabled(enabled); + setAutostartEnabled(enabled); + toast.success(enabled ? "已启用开机自启" : "已禁用开机自启", { + duration: 2000, + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + toast.error(`设置失败: ${errorMsg}`, { + duration: 3000, + }); + } finally { + setAutostartLoading(false); + } + }; + + return { + autostartEnabled, + autostartLoading, + handleToggleAutostart, + }; +} + diff --git a/src/components/pages/Settings/hooks/useBackgroundImage.ts b/src/components/pages/Settings/hooks/useBackgroundImage.ts new file mode 100644 index 0000000..c305336 --- /dev/null +++ b/src/components/pages/Settings/hooks/useBackgroundImage.ts @@ -0,0 +1,98 @@ +import { useState, useEffect } from "react"; +import { toast } from "sonner"; +import { open } from "@tauri-apps/plugin-dialog"; +import { readFile } from "@tauri-apps/plugin-fs"; +import { + getInitialBackgroundImage, + getInitialBackgroundOverlayOpacity, + getInitialBackgroundBlur, + getMimeType, +} from "../utils"; + +export function useBackgroundImage() { + const [backgroundImage, setBackgroundImage] = useState(() => + getInitialBackgroundImage(), + ); + const [isSelectingImage, setIsSelectingImage] = useState(false); + const [overlayOpacity, setOverlayOpacity] = useState(() => + getInitialBackgroundOverlayOpacity(), + ); + const [blur, setBlur] = useState(() => getInitialBackgroundBlur()); + + useEffect(() => { + localStorage.setItem("backgroundImage", backgroundImage || ""); + window.dispatchEvent(new Event("backgroundImageChanged")); + }, [backgroundImage]); + + useEffect(() => { + localStorage.setItem("backgroundOverlayOpacity", overlayOpacity.toString()); + window.dispatchEvent(new Event("backgroundOverlayChanged")); + }, [overlayOpacity]); + + useEffect(() => { + localStorage.setItem("backgroundBlur", blur.toString()); + window.dispatchEvent(new Event("backgroundOverlayChanged")); + }, [blur]); + + const handleSelectBackgroundImage = async () => { + if (isSelectingImage) return; + + setIsSelectingImage(true); + try { + const selected = await open({ + multiple: false, + filters: [ + { + name: "图片", + extensions: ["png", "jpg", "jpeg", "gif", "webp", "bmp"], + }, + ], + }); + + if (selected && typeof selected === "string") { + const fileData = await readFile(selected); + const uint8Array = new Uint8Array(fileData); + + let binaryString = ""; + for (let i = 0; i < uint8Array.length; i++) { + binaryString += String.fromCharCode(uint8Array[i]); + } + + const base64 = btoa(binaryString); + const mimeType = getMimeType(selected); + const dataUrl = `data:${mimeType};base64,${base64}`; + + setBackgroundImage(dataUrl); + toast.success("背景图设置成功", { + duration: 2000, + }); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + toast.error(`选择图片失败: ${errorMsg}`, { + duration: 3000, + }); + } finally { + setIsSelectingImage(false); + } + }; + + const handleClearBackgroundImage = () => { + setBackgroundImage(null); + toast.success("已清除背景图", { + duration: 2000, + }); + }; + + return { + backgroundImage, + isSelectingImage, + overlayOpacity, + setOverlayOpacity, + blur, + setBlur, + handleSelectBackgroundImage, + handleClearBackgroundImage, + }; +} + diff --git a/src/components/pages/Settings/hooks/useFrpcDownload.ts b/src/components/pages/Settings/hooks/useFrpcDownload.ts new file mode 100644 index 0000000..60d4918 --- /dev/null +++ b/src/components/pages/Settings/hooks/useFrpcDownload.ts @@ -0,0 +1,57 @@ +import { useState, useCallback } from "react"; +import { toast } from "sonner"; +import { frpcDownloader } from "@/services/frpcDownloader"; + +export interface DownloadProgress { + percentage: number; + downloaded: number; + total: number; +} + +export function useFrpcDownload() { + const [isDownloading, setIsDownloading] = useState(false); + const [progress, setProgress] = useState(null); + + const handleRedownloadFrpc = useCallback(async () => { + if (isDownloading) return; + + setIsDownloading(true); + setProgress({ percentage: 0, downloaded: 0, total: 0 }); + const toastId = toast.loading("正在下载 frpc 客户端...", { + duration: Infinity, + }); + + try { + await frpcDownloader.downloadFrpc((progressData) => { + setProgress(progressData); + const downloadedMB = (progressData.downloaded / 1024 / 1024).toFixed(2); + const totalMB = (progressData.total / 1024 / 1024).toFixed(2); + toast.loading( + `正在下载 frpc 客户端... ${progressData.percentage.toFixed(1)}% (${downloadedMB} MB / ${totalMB} MB)`, + { id: toastId, duration: Infinity }, + ); + }); + + toast.success("frpc 客户端下载成功", { + id: toastId, + duration: 3000, + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + toast.error(`下载失败: ${errorMsg}`, { + id: toastId, + duration: 8000, + }); + } finally { + setIsDownloading(false); + setProgress(null); + } + }, [isDownloading]); + + return { + isDownloading, + progress, + handleRedownloadFrpc, + }; +} + diff --git a/src/components/pages/Settings/hooks/useTheme.ts b/src/components/pages/Settings/hooks/useTheme.ts new file mode 100644 index 0000000..3ed9fec --- /dev/null +++ b/src/components/pages/Settings/hooks/useTheme.ts @@ -0,0 +1,66 @@ +import { useState, useEffect } from "react"; +import type { ThemeMode } from "../types"; +import { + getInitialFollowSystem, + getInitialTheme, +} from "../utils"; + +export function useTheme() { + const [followSystem, setFollowSystem] = useState(() => + getInitialFollowSystem(), + ); + const [theme, setTheme] = useState(() => getInitialTheme()); + + useEffect(() => { + localStorage.setItem("themeFollowSystem", followSystem.toString()); + }, [followSystem]); + + useEffect(() => { + if (followSystem) { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const handleSystemThemeChange = (e: MediaQueryListEvent) => { + const newTheme = e.matches ? "dark" : "light"; + setTheme(newTheme); + }; + + const initialTheme = mediaQuery.matches ? "dark" : "light"; + setTheme(initialTheme); + + mediaQuery.addEventListener("change", handleSystemThemeChange); + return () => { + mediaQuery.removeEventListener("change", handleSystemThemeChange); + }; + } else { + const stored = localStorage.getItem("theme") as ThemeMode | null; + if (stored === "light" || stored === "dark") { + setTheme(stored); + } else { + const prefersDark = window.matchMedia( + "(prefers-color-scheme: dark)", + ).matches; + setTheme(prefersDark ? "dark" : "light"); + } + } + }, [followSystem]); + + useEffect(() => { + const root = document.documentElement; + if (theme === "dark") { + root.classList.add("dark"); + } else { + root.classList.remove("dark"); + } + if (!followSystem) { + localStorage.setItem("theme", theme); + } + window.dispatchEvent(new Event("themeChanged")); + }, [theme, followSystem]); + + return { + followSystem, + setFollowSystem, + theme, + setTheme, + }; +} + diff --git a/src/components/pages/Settings/hooks/useUpdate.ts b/src/components/pages/Settings/hooks/useUpdate.ts new file mode 100644 index 0000000..7fdb3d7 --- /dev/null +++ b/src/components/pages/Settings/hooks/useUpdate.ts @@ -0,0 +1,89 @@ +import { useState, useEffect } from "react"; +import { toast } from "sonner"; +import { updateService } from "@/services/updateService"; + +export function useUpdate() { + const [autoCheckUpdate, setAutoCheckUpdate] = useState(() => + updateService.getAutoCheckEnabled(), + ); + const [checkingUpdate, setCheckingUpdate] = useState(false); + const [currentVersion, setCurrentVersion] = useState(""); + + useEffect(() => { + const loadVersion = async () => { + try { + const version = await updateService.getCurrentVersion(); + setCurrentVersion(version); + } catch (error) { + console.error("获取版本失败:", error); + } + }; + loadVersion(); + }, []); + + const handleCheckUpdate = async () => { + if (checkingUpdate) return; + + setCheckingUpdate(true); + const toastId = toast.loading("正在检查更新...", { duration: Infinity }); + + try { + const result = await updateService.checkUpdate(); + + if (result.available) { + toast.success( + `发现新版本: ${result.version}\n更新将在后台下载,完成后会提示您安装`, + { id: toastId, duration: 8000 }, + ); + + try { + await updateService.installUpdate(); + toast.success("更新已下载完成,应用将在重启后更新", { + duration: 5000, + }); + } catch (installError) { + const errorMsg = + installError instanceof Error + ? installError.message + : String(installError); + toast.error(`下载更新失败: ${errorMsg}`, { + duration: 5000, + }); + } + } else { + toast.success("当前已是最新版本", { + id: toastId, + duration: 3000, + }); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + toast.error(`检查更新失败: ${errorMsg}`, { + id: toastId, + duration: 5000, + }); + } finally { + setCheckingUpdate(false); + } + }; + + const handleToggleAutoCheckUpdate = (enabled: boolean) => { + updateService.setAutoCheckEnabled(enabled); + setAutoCheckUpdate(enabled); + toast.success( + enabled ? "已启用启动时自动检测更新" : "已禁用启动时自动检测更新", + { + duration: 2000, + }, + ); + }; + + return { + autoCheckUpdate, + checkingUpdate, + currentVersion, + handleCheckUpdate, + handleToggleAutoCheckUpdate, + }; +} + diff --git a/src/components/pages/Settings/types.ts b/src/components/pages/Settings/types.ts new file mode 100644 index 0000000..d0d3e8e --- /dev/null +++ b/src/components/pages/Settings/types.ts @@ -0,0 +1,2 @@ +export type ThemeMode = "light" | "dark"; + diff --git a/src/components/pages/Settings/utils.ts b/src/components/pages/Settings/utils.ts new file mode 100644 index 0000000..138830e --- /dev/null +++ b/src/components/pages/Settings/utils.ts @@ -0,0 +1,66 @@ +import type { ThemeMode } from "./types"; + +export const getInitialFollowSystem = (): boolean => { + if (typeof window === "undefined") return true; + const stored = localStorage.getItem("themeFollowSystem"); + return stored !== "false"; +}; + +export const getInitialTheme = (): ThemeMode => { + if (typeof window === "undefined") return "light"; + const followSystem = getInitialFollowSystem(); + if (followSystem) { + const prefersDark = window.matchMedia( + "(prefers-color-scheme: dark)", + ).matches; + return prefersDark ? "dark" : "light"; + } + const stored = localStorage.getItem("theme") as ThemeMode | null; + if (stored === "light" || stored === "dark") return stored; + return "light"; +}; + +export const getInitialBackgroundImage = (): string | null => { + if (typeof window === "undefined") return null; + return localStorage.getItem("backgroundImage"); +}; + +export const getInitialBackgroundOverlayOpacity = (): number => { + if (typeof window === "undefined") return 80; + const stored = localStorage.getItem("backgroundOverlayOpacity"); + return stored ? parseInt(stored, 10) : 80; +}; + +export const getInitialBackgroundBlur = (): number => { + if (typeof window === "undefined") return 4; + const stored = localStorage.getItem("backgroundBlur"); + return stored ? parseInt(stored, 10) : 4; +}; + +export const getInitialBypassProxy = (): boolean => { + if (typeof window === "undefined") return true; + const stored = localStorage.getItem("bypassProxy"); + return stored !== "false"; +}; + +export const getInitialShowTitleBar = (): boolean => { + if (typeof window === "undefined") return false; + const stored = localStorage.getItem("showTitleBar"); + // 如果从未设置过,默认返回 false(关闭) + if (stored === null) return false; + return stored === "true"; +}; + +export const getMimeType = (filePath: string): string => { + const ext = filePath.split(".").pop()?.toLowerCase(); + const mimeTypes: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + bmp: "image/bmp", + }; + return mimeTypes[ext || ""] || "image/png"; +}; + From 6b84397afdc89dbe893276e9a639515061e18019 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cchaoji233=E2=80=9D?= Date: Tue, 13 Jan 2026 01:10:39 +0800 Subject: [PATCH 04/11] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E4=BB=A3=E7=A0=81=EF=BC=8C=E6=8F=90=E5=8D=87=E5=8F=AF?= =?UTF-8?q?=E7=BB=B4=E6=8A=A4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 4 +- src/components/pages/Home.tsx | 687 ------------------ src/components/pages/Home/cache.ts | 9 + .../pages/Home/components/FAQSection.tsx | 49 ++ .../pages/Home/components/FeedbackCard.tsx | 35 + .../pages/Home/components/FlowDataCard.tsx | 49 ++ .../pages/Home/components/SignInInfoPopup.tsx | 102 +++ .../pages/Home/components/UserInfoCard.tsx | 38 + .../pages/Home/components/WelcomeHeader.tsx | 75 ++ .../pages/Home/hooks/useAnimatedNumber.ts | 125 ++++ .../pages/Home/hooks/useFlowData.ts | 61 ++ .../pages/Home/hooks/useSignInInfo.ts | 138 ++++ .../pages/Home/hooks/useUserInfo.ts | 101 +++ src/components/pages/Home/index.tsx | 73 ++ src/components/pages/Home/types.ts | 14 + .../Settings/{Settings.tsx => index.tsx} | 0 .../TunnelList/{TunnelList.tsx => index.tsx} | 0 17 files changed, 871 insertions(+), 689 deletions(-) delete mode 100644 src/components/pages/Home.tsx create mode 100644 src/components/pages/Home/cache.ts create mode 100644 src/components/pages/Home/components/FAQSection.tsx create mode 100644 src/components/pages/Home/components/FeedbackCard.tsx create mode 100644 src/components/pages/Home/components/FlowDataCard.tsx create mode 100644 src/components/pages/Home/components/SignInInfoPopup.tsx create mode 100644 src/components/pages/Home/components/UserInfoCard.tsx create mode 100644 src/components/pages/Home/components/WelcomeHeader.tsx create mode 100644 src/components/pages/Home/hooks/useAnimatedNumber.ts create mode 100644 src/components/pages/Home/hooks/useFlowData.ts create mode 100644 src/components/pages/Home/hooks/useSignInInfo.ts create mode 100644 src/components/pages/Home/hooks/useUserInfo.ts create mode 100644 src/components/pages/Home/index.tsx create mode 100644 src/components/pages/Home/types.ts rename src/components/pages/Settings/{Settings.tsx => index.tsx} (100%) rename src/components/pages/TunnelList/{TunnelList.tsx => index.tsx} (100%) diff --git a/src/App.tsx b/src/App.tsx index 7ed3a01..323871c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,9 +3,9 @@ import { toast } from "sonner"; import { Sidebar } from "@/components/Sidebar"; import { TitleBar } from "@/components/TitleBar"; import { Home } from "@/components/pages/Home"; -import { TunnelList } from "@/components/pages/TunnelList/TunnelList"; +import { TunnelList } from "@/components/pages/TunnelList"; import { Logs } from "@/components/pages/Logs"; -import { Settings } from "@/components/pages/Settings/Settings"; +import { Settings } from "@/components/pages/Settings"; import { getStoredUser, type StoredUser } from "@/services/api"; import { frpcDownloader } from "@/services/frpcDownloader.ts"; import { updateService } from "@/services/updateService"; diff --git a/src/components/pages/Home.tsx b/src/components/pages/Home.tsx deleted file mode 100644 index ec739f2..0000000 --- a/src/components/pages/Home.tsx +++ /dev/null @@ -1,687 +0,0 @@ -import { useEffect, useState, useRef } from "react"; -import { - fetchFlowLast7Days, - fetchUserInfo, - fetchSignInInfo, - type FlowPoint, - type UserInfo, - type SignInInfo, - getStoredUser, - clearStoredUser, - saveStoredUser, - type StoredUser, -} from "@/services/api"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; - -import { ExternalLinkIcon } from "lucide-react"; -import { - Item, - ItemActions, - ItemContent, - ItemDescription, - ItemTitle, -} from "../../components/ui/item"; - -// 数字计数动画 Hook -function useAnimatedNumber( - value: number, - duration: number = 500, - shouldAnimate: boolean = true, -) { - // 初始值设为实际值,如果不需要动画则直接显示 - const [displayValue, setDisplayValue] = useState(value); - const [isAnimating, setIsAnimating] = useState(false); - const startTimeRef = useRef(null); - const startValueRef = useRef(value); - const animationFrameRef = useRef(null); - const previousValueRef = useRef(value); - const previousShouldAnimateRef = useRef(shouldAnimate); - const displayValueRef = useRef(value); - const hasAnimatedRef = useRef(false); - - // 同步 displayValueRef - useEffect(() => { - displayValueRef.current = displayValue; - }, [displayValue]); - - useEffect(() => { - // 如果值没有变化且 shouldAnimate 也没有变化,不需要动画 - if ( - value === previousValueRef.current && - previousShouldAnimateRef.current === shouldAnimate - ) { - return; - } - - const wasAnimating = previousShouldAnimateRef.current; - previousValueRef.current = value; - previousShouldAnimateRef.current = shouldAnimate; - - // 取消之前的动画 - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; - } - - // 如果不应该动画,直接更新值 - if (!shouldAnimate) { - // 使用 setTimeout 避免同步 setState - setTimeout(() => { - setDisplayValue(value); - displayValueRef.current = value; - hasAnimatedRef.current = false; - }, 0); - return; - } - - // 如果 shouldAnimate 从 false 变为 true,从 0 开始动画 - if (!wasAnimating && shouldAnimate && !hasAnimatedRef.current) { - startValueRef.current = 0; - displayValueRef.current = 0; - // 使用 setTimeout 避免同步 setState - setTimeout(() => { - setDisplayValue(0); - }, 0); - hasAnimatedRef.current = true; - } else { - startValueRef.current = displayValueRef.current; - } - - // 使用 requestAnimationFrame 延迟状态更新,避免同步 setState - const startAnimation = () => { - setIsAnimating(true); - const startTime = performance.now(); - startTimeRef.current = startTime; - - const animate = (currentTime: number) => { - if (!startTimeRef.current) return; - - const elapsed = currentTime - startTimeRef.current; - const progress = Math.min(elapsed / duration, 1); - - // 使用缓动函数 - const easeOutCubic = 1 - Math.pow(1 - progress, 3); - const currentValue = Math.floor( - startValueRef.current + - (value - startValueRef.current) * easeOutCubic, - ); - - setDisplayValue(currentValue); - displayValueRef.current = currentValue; - - if (progress < 1) { - animationFrameRef.current = requestAnimationFrame(animate); - } else { - setDisplayValue(value); - displayValueRef.current = value; - setIsAnimating(false); - startTimeRef.current = null; - } - }; - - animationFrameRef.current = requestAnimationFrame(animate); - }; - - // 延迟启动动画,避免在 effect 中同步调用 setState - const timeoutId = setTimeout(() => { - requestAnimationFrame(startAnimation); - }, 0); - - return () => { - clearTimeout(timeoutId); - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; - } - }; - }, [value, duration, shouldAnimate]); - - // 当 shouldAnimate 变为 false 时,重置 hasAnimatedRef - useEffect(() => { - if (!shouldAnimate) { - hasAnimatedRef.current = false; - } - }, [shouldAnimate]); - - return { displayValue, isAnimating }; -} - -// 模块级别的缓存,确保在组件卸载后数据仍然保留 -const homePageCache = { - userInfo: null as UserInfo | null, - flowData: [] as FlowPoint[], - signInInfo: null as SignInInfo | null, -}; - -interface HomeProps { - user?: StoredUser | null; - onUserChange?: (user: StoredUser | null) => void; -} - -export function Home({ user, onUserChange }: HomeProps) { - const [userInfo, setUserInfo] = useState(null); - const onUserChangeRef = useRef(onUserChange); - - // 标记是否是第一次加载 - const isFirstLoadRef = useRef(true); - - // 保持回调引用最新 - useEffect(() => { - onUserChangeRef.current = onUserChange; - }, [onUserChange]); - - const [flowData, setFlowData] = useState(() => { - // 初始化时如果有缓存数据,先显示缓存数据 - return homePageCache.flowData; - }); - const [flowLoading, setFlowLoading] = useState(() => { - // 如果有缓存数据,不显示加载状态 - return homePageCache.flowData.length === 0; - }); - const [flowError, setFlowError] = useState(""); - - // 签到信息相关状态 - const [signInInfoHover, setSignInInfoHover] = useState(false); - const [signInInfo, setSignInInfo] = useState(() => { - // 初始化时如果有缓存数据,先显示缓存数据 - return homePageCache.signInInfo; - }); - const [signInInfoLoading, setSignInInfoLoading] = useState(false); - const [signInInfoError, setSignInInfoError] = useState(""); - const [signInInfoVisible, setSignInInfoVisible] = useState(false); - const [signInInfoClosing, setSignInInfoClosing] = useState(false); - - // 延迟关闭的 timeout 引用 - const closeTimeoutRef = useRef | null>(null); - - // 数字计数动画 - 只在菜单可见时触发动画 - const animatedTotalPoints = useAnimatedNumber( - signInInfo?.total_points || 0, - 800, - signInInfoVisible && !!signInInfo, - ); - const animatedTotalSignIns = useAnimatedNumber( - signInInfo?.total_sign_ins || 0, - 600, - signInInfoVisible && !!signInInfo, - ); - const animatedCountOfRecords = useAnimatedNumber( - signInInfo?.count_of_matching_records || 0, - 600, - signInInfoVisible && !!signInInfo, - ); - - // 清除关闭延迟 - const clearCloseTimeout = () => { - if (closeTimeoutRef.current) { - clearTimeout(closeTimeoutRef.current); - closeTimeoutRef.current = null; - } - }; - - // 延迟关闭悬浮菜单 - const handleMouseLeave = () => { - clearCloseTimeout(); - setSignInInfoClosing(true); - closeTimeoutRef.current = setTimeout(() => { - setSignInInfoHover(false); - setSignInInfoVisible(false); - setSignInInfoClosing(false); - // 重置动画状态,下次打开时从 0 开始动画 - // 通过将 shouldAnimate 变为 false 来触发重置 - }, 200); // 200ms 延迟,给用户足够时间移动鼠标 - }; - - // 鼠标进入时取消关闭 - const handleMouseEnter = () => { - clearCloseTimeout(); - setSignInInfoClosing(false); - setSignInInfoHover(true); - if (signInInfo) { - // 延迟一点显示,让弹出动画更流畅 - setTimeout(() => setSignInInfoVisible(true), 50); - } else { - setSignInInfoVisible(true); - } - }; - - // 初始化时如果有缓存数据,立即显示 - useEffect(() => { - const storedUser = getStoredUser(); - if ( - storedUser?.usertoken && - homePageCache.userInfo && - homePageCache.userInfo.usertoken === storedUser.usertoken - ) { - setUserInfo(homePageCache.userInfo); - isFirstLoadRef.current = false; - } - }, []); - - // 获取最新用户信息 - useEffect(() => { - const loadUserInfo = async () => { - const storedUser = getStoredUser(); - if (!storedUser?.usertoken) { - setUserInfo(null); - homePageCache.userInfo = null; - homePageCache.flowData = []; - homePageCache.signInInfo = null; - return; - } - - // 如果有缓存数据且 token 匹配,先显示缓存数据 - if ( - homePageCache.userInfo && - homePageCache.userInfo.usertoken === storedUser.usertoken - ) { - setUserInfo(homePageCache.userInfo); - isFirstLoadRef.current = false; - } else { - // token 不匹配或首次加载,清除相关缓存 - if (homePageCache.userInfo?.usertoken !== storedUser.usertoken) { - homePageCache.flowData = []; - homePageCache.signInInfo = null; - } - // 第一次加载,显示加载状态 - isFirstLoadRef.current = true; - } - - try { - const data = await fetchUserInfo(); - setUserInfo(data); - homePageCache.userInfo = data; - isFirstLoadRef.current = false; - // 更新本地存储的用户信息 - const updatedUser = { - username: data.username, - usergroup: data.usergroup, - userimg: data.userimg, - usertoken: data.usertoken, - tunnelCount: data.tunnelCount, - tunnel: data.tunnel, - }; - saveStoredUser(updatedUser); - } catch (err) { - // token 无效,清除本地信息和缓存 - clearStoredUser(); - setUserInfo(null); - homePageCache.userInfo = null; - homePageCache.flowData = []; - homePageCache.signInInfo = null; - // 通知父组件用户信息已清除 - onUserChangeRef.current?.(null); - console.error("获取用户信息失败", err); - } - }; - loadUserInfo(); - }, [user?.usertoken]); // 当 user 的 token 变化时重新获取 - - useEffect(() => { - const loadFlow = async () => { - if (!userInfo?.usertoken) { - setFlowLoading(false); - homePageCache.flowData = []; - return; - } - - // 如果有缓存数据,先显示缓存数据 - if (homePageCache.flowData.length > 0) { - setFlowData(homePageCache.flowData); - setFlowLoading(false); - } else { - // 第一次加载,显示加载状态 - setFlowLoading(true); - } - - setFlowError(""); - try { - const data = await fetchFlowLast7Days(); - setFlowData(data); - homePageCache.flowData = data; - } catch (err) { - // 如果加载失败且没有缓存数据,才显示错误 - if (homePageCache.flowData.length === 0) { - setFlowData([]); - setFlowError( - err instanceof Error ? err.message : "获取近7日流量失败", - ); - } - console.error("获取近7日流量失败", err); - } finally { - setFlowLoading(false); - } - }; - loadFlow(); - }, [userInfo]); - - // 当用户信息加载后,自动获取签到信息(用于判断是否显示签到按钮) - useEffect(() => { - if (!userInfo?.usertoken) { - setSignInInfo(null); - homePageCache.signInInfo = null; - return; - } - - // 如果有缓存数据,先显示缓存数据 - if (homePageCache.signInInfo) { - setSignInInfo(homePageCache.signInInfo); - } - - const loadSignInInfo = async () => { - try { - const data = await fetchSignInInfo(); - setSignInInfo(data); - homePageCache.signInInfo = data; - // 如果悬浮菜单已显示,触发数字动画 - if (signInInfoHover) { - setSignInInfoVisible(true); - } - } catch (err) { - // 如果加载失败且没有缓存数据,才清除 - if (!homePageCache.signInInfo) { - setSignInInfo(null); - } - console.error("获取签到信息失败", err); - } - }; - - loadSignInInfo(); - }, [userInfo?.usertoken, signInInfoHover]); - - // 当鼠标悬浮时获取签到信息 - useEffect(() => { - if (!signInInfoHover || !userInfo?.usertoken) { - return; - } - - // 如果已有数据,不重复加载,但触发动画 - if (signInInfo) { - setTimeout(() => setSignInInfoVisible(true), 50); - return; - } - - const loadSignInInfo = async () => { - setSignInInfoLoading(true); - setSignInInfoError(""); - try { - const data = await fetchSignInInfo(); - setSignInInfo(data); - homePageCache.signInInfo = data; - // 数据加载完成后触发动画 - setTimeout(() => setSignInInfoVisible(true), 50); - } catch (err) { - setSignInInfoError( - err instanceof Error ? err.message : "获取签到信息失败", - ); - console.error("获取签到信息失败", err); - } finally { - setSignInInfoLoading(false); - } - }; - - loadSignInInfo(); - }, [signInInfoHover, userInfo?.usertoken, signInInfo]); - - // 组件卸载时清理 timeout - useEffect(() => { - return () => { - clearCloseTimeout(); - }; - }, []); - - return ( -
-
-
-
-

ChmlFrp Launcher

-

- 欢迎回来{userInfo?.username ? `,${userInfo.username}` : ""} -

-
-
- {/* {!signInInfo?.is_signed_in_today && ( - - )} */} -
- - - {/* 悬浮菜单 */} - {signInInfoHover && userInfo && ( -
- {signInInfoLoading ? ( -
-
- - 加载中... - -
- ) : signInInfoError ? ( -
-

- {signInInfoError} -

-
- ) : signInInfo ? ( -
-
-
-

- 今日是否已签到 -

-

- {signInInfo.is_signed_in_today - ? "已签到" - : "未签到"} -

-
-
-

- 总签到积分 -

-

- {signInInfoVisible && - animatedTotalPoints.isAnimating - ? animatedTotalPoints.displayValue.toLocaleString() - : signInInfo.total_points.toLocaleString()} -

-
-
-

- 用户总签到次数 -

-

- {signInInfoVisible && - animatedTotalSignIns.isAnimating - ? animatedTotalSignIns.displayValue - : signInInfo.total_sign_ins} -

-
-
-

- 今日签到人数 -

-

- {signInInfoVisible && - animatedCountOfRecords.isAnimating - ? animatedCountOfRecords.displayValue - : signInInfo.count_of_matching_records} -

-
-
-
-

- 上次签到时间 -

-

- {signInInfo.last_sign_in_time || "暂无记录"} -

-
-
- ) : ( -
- 暂无数据 -
- )} -
- )} -
-
-
-
- -
-
-
-

账号状态

- - {userInfo ? "已登录" : "未登录"} - -
-
- {userInfo ? ( - <> -

你好,{userInfo.username}

-

用户组:{userInfo.usergroup || "未分组"}

-

- 隧道量:{userInfo.tunnelCount} / {userInfo.tunnel} -

- - ) : ( -

点击左侧头像即可登录账号,登录后会在这里显示用户信息。

- )} -
-
- -
-
-

近7日流量

-
- -
- {flowLoading ? ( -
加载中...
- ) : flowError ? ( -
{flowError}
- ) : flowData.length === 0 ? ( -
暂无数据
- ) : ( -
- {flowData.map((item) => ( -
-
- {item.time} -
-
- ↑ {item.traffic_in} -
-
- ↓ {item.traffic_out} -
-
- ))} -
- )} -
-
- -
-

- 常见问题 -

- - - 软件出现了BUG怎么办 - -

- 软件目前为公开测试版,使用途中遇见的任何问题,请在任意交流群中反馈问题,我们会尽快修复。 -

-
-
- - 我应该去哪注册账号 - -

- 这是ChmlFrp的官方启动器,如果您没有账户,您应该前往我们的官网{" "} - - https://www.chmlfrp.net - {" "} - 进行注册。 -

-
-
- - 关于映射延迟问题 - -

- 节点请尽量选择距离运行映射设备最近的节点。同时,您可以根据节点状态页中的节点负载选择负载较低的节点,这能够优化您的体验。 -

-
-
-
-
- - - - - 意见征集 - - 我们欢迎您提出任何意见和建议,帮助我们改进客户端。 - - - - - - - -
-
- ); -} diff --git a/src/components/pages/Home/cache.ts b/src/components/pages/Home/cache.ts new file mode 100644 index 0000000..6c9aceb --- /dev/null +++ b/src/components/pages/Home/cache.ts @@ -0,0 +1,9 @@ +import type { UserInfo, FlowPoint, SignInInfo } from "@/services/api"; + +// 模块级别的缓存,确保在组件卸载后数据仍然保留 +export const homePageCache = { + userInfo: null as UserInfo | null, + flowData: [] as FlowPoint[], + signInInfo: null as SignInInfo | null, +}; + diff --git a/src/components/pages/Home/components/FAQSection.tsx b/src/components/pages/Home/components/FAQSection.tsx new file mode 100644 index 0000000..51224f1 --- /dev/null +++ b/src/components/pages/Home/components/FAQSection.tsx @@ -0,0 +1,49 @@ +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; + +export function FAQSection() { + return ( +
+

常见问题

+ + + 软件出现了BUG怎么办 + +

+ 软件目前为公开测试版,使用途中遇见的任何问题,请在任意交流群中反馈问题,我们会尽快修复。 +

+
+
+ + 我应该去哪注册账号 + +

+ 这是ChmlFrp的官方启动器,如果您没有账户,您应该前往我们的官网{" "} + + https://www.chmlfrp.net + {" "} + 进行注册。 +

+
+
+ + 关于映射延迟问题 + +

+ 节点请尽量选择距离运行映射设备最近的节点。同时,您可以根据节点状态页中的节点负载选择负载较低的节点,这能够优化您的体验。 +

+
+
+
+
+ ); +} + diff --git a/src/components/pages/Home/components/FeedbackCard.tsx b/src/components/pages/Home/components/FeedbackCard.tsx new file mode 100644 index 0000000..2ffd986 --- /dev/null +++ b/src/components/pages/Home/components/FeedbackCard.tsx @@ -0,0 +1,35 @@ +import { ExternalLinkIcon } from "lucide-react"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemTitle, +} from "@/components/ui/item"; + +export function FeedbackCard() { + return ( + + + + 意见征集 + + 我们欢迎您提出任何意见和建议,帮助我们改进客户端。 + + + + + + + + ); +} + diff --git a/src/components/pages/Home/components/FlowDataCard.tsx b/src/components/pages/Home/components/FlowDataCard.tsx new file mode 100644 index 0000000..5c723a0 --- /dev/null +++ b/src/components/pages/Home/components/FlowDataCard.tsx @@ -0,0 +1,49 @@ +import type { FlowPoint } from "@/services/api"; + +interface FlowDataCardProps { + flowData: FlowPoint[]; + flowLoading: boolean; + flowError: string; +} + +export function FlowDataCard({ + flowData, + flowLoading, + flowError, +}: FlowDataCardProps) { + return ( +
+
+

近7日流量

+
+ +
+ {flowLoading ? ( +
加载中...
+ ) : flowError ? ( +
{flowError}
+ ) : flowData.length === 0 ? ( +
暂无数据
+ ) : ( +
+ {flowData.map((item) => ( +
+
{item.time}
+
+ ↑ {item.traffic_in} +
+
+ ↓ {item.traffic_out} +
+
+ ))} +
+ )} +
+
+ ); +} + diff --git a/src/components/pages/Home/components/SignInInfoPopup.tsx b/src/components/pages/Home/components/SignInInfoPopup.tsx new file mode 100644 index 0000000..42f7fc2 --- /dev/null +++ b/src/components/pages/Home/components/SignInInfoPopup.tsx @@ -0,0 +1,102 @@ +import type { SignInInfo } from "@/services/api"; + +interface SignInInfoPopupProps { + visible: boolean; + loading: boolean; + error: string; + signInInfo: SignInInfo | null; + closing: boolean; + onMouseEnter: () => void; + onMouseLeave: () => void; + signInInfoVisible: boolean; + animatedTotalPoints: { displayValue: number; isAnimating: boolean }; + animatedTotalSignIns: { displayValue: number; isAnimating: boolean }; + animatedCountOfRecords: { displayValue: number; isAnimating: boolean }; +} + +export function SignInInfoPopup({ + visible, + loading, + error, + signInInfo, + closing, + onMouseEnter, + onMouseLeave, + signInInfoVisible, + animatedTotalPoints, + animatedTotalSignIns, + animatedCountOfRecords, +}: SignInInfoPopupProps) { + if (!visible) return null; + + return ( +
+ {loading ? ( +
+
+ 加载中... +
+ ) : error ? ( +
+

{error}

+
+ ) : signInInfo ? ( +
+
+
+

今日是否已签到

+

+ {signInInfo.is_signed_in_today ? "已签到" : "未签到"} +

+
+
+

总签到积分

+

+ {signInInfoVisible && animatedTotalPoints.isAnimating + ? animatedTotalPoints.displayValue.toLocaleString() + : signInInfo.total_points.toLocaleString()} +

+
+
+

用户总签到次数

+

+ {signInInfoVisible && animatedTotalSignIns.isAnimating + ? animatedTotalSignIns.displayValue + : signInInfo.total_sign_ins} +

+
+
+

今日签到人数

+

+ {signInInfoVisible && animatedCountOfRecords.isAnimating + ? animatedCountOfRecords.displayValue + : signInInfo.count_of_matching_records} +

+
+
+
+

上次签到时间

+

+ {signInInfo.last_sign_in_time || "暂无记录"} +

+
+
+ ) : ( +
+ 暂无数据 +
+ )} +
+ ); +} + diff --git a/src/components/pages/Home/components/UserInfoCard.tsx b/src/components/pages/Home/components/UserInfoCard.tsx new file mode 100644 index 0000000..872cc63 --- /dev/null +++ b/src/components/pages/Home/components/UserInfoCard.tsx @@ -0,0 +1,38 @@ +import type { UserInfo } from "@/services/api"; + +interface UserInfoCardProps { + userInfo: UserInfo | null; +} + +export function UserInfoCard({ userInfo }: UserInfoCardProps) { + return ( +
+
+

账号状态

+ + {userInfo ? "已登录" : "未登录"} + +
+
+ {userInfo ? ( + <> +

你好,{userInfo.username}

+

用户组:{userInfo.usergroup || "未分组"}

+

+ 隧道量:{userInfo.tunnelCount} / {userInfo.tunnel} +

+ + ) : ( +

点击左侧头像即可登录账号,登录后会在这里显示用户信息。

+ )} +
+
+ ); +} + diff --git a/src/components/pages/Home/components/WelcomeHeader.tsx b/src/components/pages/Home/components/WelcomeHeader.tsx new file mode 100644 index 0000000..8f6bde0 --- /dev/null +++ b/src/components/pages/Home/components/WelcomeHeader.tsx @@ -0,0 +1,75 @@ +import type { UserInfo } from "@/services/api"; +import { SignInInfoPopup } from "./SignInInfoPopup"; +import type { SignInInfo } from "@/services/api"; + +interface WelcomeHeaderProps { + userInfo: UserInfo | null; + signInInfo: SignInInfo | null; + signInInfoHover: boolean; + signInInfoLoading: boolean; + signInInfoError: string; + signInInfoVisible: boolean; + signInInfoClosing: boolean; + onMouseEnter: () => void; + onMouseLeave: () => void; + animatedTotalPoints: { displayValue: number; isAnimating: boolean }; + animatedTotalSignIns: { displayValue: number; isAnimating: boolean }; + animatedCountOfRecords: { displayValue: number; isAnimating: boolean }; +} + +export function WelcomeHeader({ + userInfo, + signInInfo, + signInInfoHover, + signInInfoLoading, + signInInfoError, + signInInfoVisible, + signInInfoClosing, + onMouseEnter, + onMouseLeave, + animatedTotalPoints, + animatedTotalSignIns, + animatedCountOfRecords, +}: WelcomeHeaderProps) { + return ( +
+
+
+

ChmlFrp Launcher

+

+ 欢迎回来{userInfo?.username ? `,${userInfo.username}` : ""} +

+
+
+
+ + + +
+
+
+
+ ); +} + diff --git a/src/components/pages/Home/hooks/useAnimatedNumber.ts b/src/components/pages/Home/hooks/useAnimatedNumber.ts new file mode 100644 index 0000000..31948bd --- /dev/null +++ b/src/components/pages/Home/hooks/useAnimatedNumber.ts @@ -0,0 +1,125 @@ +import { useState, useEffect, useRef } from "react"; + +export function useAnimatedNumber( + value: number, + duration: number = 500, + shouldAnimate: boolean = true, +) { + // 初始值设为实际值,如果不需要动画则直接显示 + const [displayValue, setDisplayValue] = useState(value); + const [isAnimating, setIsAnimating] = useState(false); + const startTimeRef = useRef(null); + const startValueRef = useRef(value); + const animationFrameRef = useRef(null); + const previousValueRef = useRef(value); + const previousShouldAnimateRef = useRef(shouldAnimate); + const displayValueRef = useRef(value); + const hasAnimatedRef = useRef(false); + + // 同步 displayValueRef + useEffect(() => { + displayValueRef.current = displayValue; + }, [displayValue]); + + useEffect(() => { + // 如果值没有变化且 shouldAnimate 也没有变化,不需要动画 + if ( + value === previousValueRef.current && + previousShouldAnimateRef.current === shouldAnimate + ) { + return; + } + + const wasAnimating = previousShouldAnimateRef.current; + previousValueRef.current = value; + previousShouldAnimateRef.current = shouldAnimate; + + // 取消之前的动画 + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + + // 如果不应该动画,直接更新值 + if (!shouldAnimate) { + // 使用 setTimeout 避免同步 setState + setTimeout(() => { + setDisplayValue(value); + displayValueRef.current = value; + hasAnimatedRef.current = false; + }, 0); + return; + } + + // 如果 shouldAnimate 从 false 变为 true,从 0 开始动画 + if (!wasAnimating && shouldAnimate && !hasAnimatedRef.current) { + startValueRef.current = 0; + displayValueRef.current = 0; + // 使用 setTimeout 避免同步 setState + setTimeout(() => { + setDisplayValue(0); + }, 0); + hasAnimatedRef.current = true; + } else { + startValueRef.current = displayValueRef.current; + } + + // 使用 requestAnimationFrame 延迟状态更新,避免同步 setState + const startAnimation = () => { + setIsAnimating(true); + const startTime = performance.now(); + startTimeRef.current = startTime; + + const animate = (currentTime: number) => { + if (!startTimeRef.current) return; + + const elapsed = currentTime - startTimeRef.current; + const progress = Math.min(elapsed / duration, 1); + + // 使用缓动函数 + const easeOutCubic = 1 - Math.pow(1 - progress, 3); + const currentValue = Math.floor( + startValueRef.current + + (value - startValueRef.current) * easeOutCubic, + ); + + setDisplayValue(currentValue); + displayValueRef.current = currentValue; + + if (progress < 1) { + animationFrameRef.current = requestAnimationFrame(animate); + } else { + setDisplayValue(value); + displayValueRef.current = value; + setIsAnimating(false); + startTimeRef.current = null; + } + }; + + animationFrameRef.current = requestAnimationFrame(animate); + }; + + // 延迟启动动画,避免在 effect 中同步调用 setState + const timeoutId = setTimeout(() => { + requestAnimationFrame(startAnimation); + }, 0); + + return () => { + clearTimeout(timeoutId); + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + }; + }, [value, duration, shouldAnimate]); + + // 当 shouldAnimate 变为 false 时,重置 hasAnimatedRef + useEffect(() => { + if (!shouldAnimate) { + hasAnimatedRef.current = false; + } + }, [shouldAnimate]); + + return { displayValue, isAnimating }; +} + diff --git a/src/components/pages/Home/hooks/useFlowData.ts b/src/components/pages/Home/hooks/useFlowData.ts new file mode 100644 index 0000000..e51fb98 --- /dev/null +++ b/src/components/pages/Home/hooks/useFlowData.ts @@ -0,0 +1,61 @@ +import { useState, useEffect } from "react"; +import { fetchFlowLast7Days, type FlowPoint } from "@/services/api"; +import type { UserInfo } from "@/services/api"; +import { homePageCache } from "../cache"; + +export function useFlowData(userInfo: UserInfo | null) { + const [flowData, setFlowData] = useState(() => { + // 初始化时如果有缓存数据,先显示缓存数据 + return homePageCache.flowData; + }); + const [flowLoading, setFlowLoading] = useState(() => { + // 如果有缓存数据,不显示加载状态 + return homePageCache.flowData.length === 0; + }); + const [flowError, setFlowError] = useState(""); + + useEffect(() => { + const loadFlow = async () => { + if (!userInfo?.usertoken) { + setFlowLoading(false); + homePageCache.flowData = []; + return; + } + + // 如果有缓存数据,先显示缓存数据 + if (homePageCache.flowData.length > 0) { + setFlowData(homePageCache.flowData); + setFlowLoading(false); + } else { + // 第一次加载,显示加载状态 + setFlowLoading(true); + } + + setFlowError(""); + try { + const data = await fetchFlowLast7Days(); + setFlowData(data); + homePageCache.flowData = data; + } catch (err) { + // 如果加载失败且没有缓存数据,才显示错误 + if (homePageCache.flowData.length === 0) { + setFlowData([]); + setFlowError( + err instanceof Error ? err.message : "获取近7日流量失败", + ); + } + console.error("获取近7日流量失败", err); + } finally { + setFlowLoading(false); + } + }; + loadFlow(); + }, [userInfo]); + + return { + flowData, + flowLoading, + flowError, + }; +} + diff --git a/src/components/pages/Home/hooks/useSignInInfo.ts b/src/components/pages/Home/hooks/useSignInInfo.ts new file mode 100644 index 0000000..2c58cfc --- /dev/null +++ b/src/components/pages/Home/hooks/useSignInInfo.ts @@ -0,0 +1,138 @@ +import { useState, useEffect, useRef } from "react"; +import { fetchSignInInfo, type SignInInfo } from "@/services/api"; +import type { UserInfo } from "@/services/api"; +import { homePageCache } from "../cache"; + +export function useSignInInfo(userInfo: UserInfo | null) { + const [signInInfoHover, setSignInInfoHover] = useState(false); + const [signInInfo, setSignInInfo] = useState(() => { + // 初始化时如果有缓存数据,先显示缓存数据 + return homePageCache.signInInfo; + }); + const [signInInfoLoading, setSignInInfoLoading] = useState(false); + const [signInInfoError, setSignInInfoError] = useState(""); + const [signInInfoVisible, setSignInInfoVisible] = useState(false); + const [signInInfoClosing, setSignInInfoClosing] = useState(false); + + // 延迟关闭的 timeout 引用 + const closeTimeoutRef = useRef | null>(null); + + // 清除关闭延迟 + const clearCloseTimeout = () => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = null; + } + }; + + // 延迟关闭悬浮菜单 + const handleMouseLeave = () => { + clearCloseTimeout(); + setSignInInfoClosing(true); + closeTimeoutRef.current = setTimeout(() => { + setSignInInfoHover(false); + setSignInInfoVisible(false); + setSignInInfoClosing(false); + }, 200); // 200ms 延迟,给用户足够时间移动鼠标 + }; + + // 鼠标进入时取消关闭 + const handleMouseEnter = () => { + clearCloseTimeout(); + setSignInInfoClosing(false); + setSignInInfoHover(true); + if (signInInfo) { + // 延迟一点显示,让弹出动画更流畅 + setTimeout(() => setSignInInfoVisible(true), 50); + } else { + setSignInInfoVisible(true); + } + }; + + // 当用户信息加载后,自动获取签到信息(用于判断是否显示签到按钮) + useEffect(() => { + if (!userInfo?.usertoken) { + setSignInInfo(null); + homePageCache.signInInfo = null; + return; + } + + // 如果有缓存数据,先显示缓存数据 + if (homePageCache.signInInfo) { + setSignInInfo(homePageCache.signInInfo); + } + + const loadSignInInfo = async () => { + try { + const data = await fetchSignInInfo(); + setSignInInfo(data); + homePageCache.signInInfo = data; + // 如果悬浮菜单已显示,触发数字动画 + if (signInInfoHover) { + setSignInInfoVisible(true); + } + } catch (err) { + // 如果加载失败且没有缓存数据,才清除 + if (!homePageCache.signInInfo) { + setSignInInfo(null); + } + console.error("获取签到信息失败", err); + } + }; + + loadSignInInfo(); + }, [userInfo?.usertoken, signInInfoHover]); + + // 当鼠标悬浮时获取签到信息 + useEffect(() => { + if (!signInInfoHover || !userInfo?.usertoken) { + return; + } + + // 如果已有数据,不重复加载,但触发动画 + if (signInInfo) { + setTimeout(() => setSignInInfoVisible(true), 50); + return; + } + + const loadSignInInfo = async () => { + setSignInInfoLoading(true); + setSignInInfoError(""); + try { + const data = await fetchSignInInfo(); + setSignInInfo(data); + homePageCache.signInInfo = data; + // 数据加载完成后触发动画 + setTimeout(() => setSignInInfoVisible(true), 50); + } catch (err) { + setSignInInfoError( + err instanceof Error ? err.message : "获取签到信息失败", + ); + console.error("获取签到信息失败", err); + } finally { + setSignInInfoLoading(false); + } + }; + + loadSignInInfo(); + }, [signInInfoHover, userInfo?.usertoken, signInInfo]); + + // 组件卸载时清理 timeout + useEffect(() => { + return () => { + clearCloseTimeout(); + }; + }, []); + + return { + signInInfo, + signInInfoHover, + signInInfoLoading, + signInInfoError, + signInInfoVisible, + signInInfoClosing, + handleMouseEnter, + handleMouseLeave, + }; +} + diff --git a/src/components/pages/Home/hooks/useUserInfo.ts b/src/components/pages/Home/hooks/useUserInfo.ts new file mode 100644 index 0000000..b22a53a --- /dev/null +++ b/src/components/pages/Home/hooks/useUserInfo.ts @@ -0,0 +1,101 @@ +import { useState, useEffect, useRef } from "react"; +import { + fetchUserInfo, + getStoredUser, + clearStoredUser, + saveStoredUser, + type UserInfo, + type StoredUser, +} from "@/services/api"; +import { homePageCache } from "../cache"; + +export function useUserInfo( + user: StoredUser | null | undefined, + onUserChange?: (user: StoredUser | null) => void, +) { + const [userInfo, setUserInfo] = useState(null); + const onUserChangeRef = useRef(onUserChange); + const isFirstLoadRef = useRef(true); + + // 保持回调引用最新 + useEffect(() => { + onUserChangeRef.current = onUserChange; + }, [onUserChange]); + + // 初始化时如果有缓存数据,立即显示 + useEffect(() => { + const storedUser = getStoredUser(); + if ( + storedUser?.usertoken && + homePageCache.userInfo && + homePageCache.userInfo.usertoken === storedUser.usertoken + ) { + setUserInfo(homePageCache.userInfo); + isFirstLoadRef.current = false; + } + }, []); + + // 获取最新用户信息 + useEffect(() => { + const loadUserInfo = async () => { + const storedUser = getStoredUser(); + if (!storedUser?.usertoken) { + setUserInfo(null); + homePageCache.userInfo = null; + homePageCache.flowData = []; + homePageCache.signInInfo = null; + return; + } + + // 如果有缓存数据且 token 匹配,先显示缓存数据 + if ( + homePageCache.userInfo && + homePageCache.userInfo.usertoken === storedUser.usertoken + ) { + setUserInfo(homePageCache.userInfo); + isFirstLoadRef.current = false; + } else { + // token 不匹配或首次加载,清除相关缓存 + if (homePageCache.userInfo?.usertoken !== storedUser.usertoken) { + homePageCache.flowData = []; + homePageCache.signInInfo = null; + } + // 第一次加载,显示加载状态 + isFirstLoadRef.current = true; + } + + try { + const data = await fetchUserInfo(); + setUserInfo(data); + homePageCache.userInfo = data; + isFirstLoadRef.current = false; + // 更新本地存储的用户信息 + const updatedUser = { + username: data.username, + usergroup: data.usergroup, + userimg: data.userimg, + usertoken: data.usertoken, + tunnelCount: data.tunnelCount, + tunnel: data.tunnel, + }; + saveStoredUser(updatedUser); + } catch (err) { + // token 无效,清除本地信息和缓存 + clearStoredUser(); + setUserInfo(null); + homePageCache.userInfo = null; + homePageCache.flowData = []; + homePageCache.signInInfo = null; + // 通知父组件用户信息已清除 + onUserChangeRef.current?.(null); + console.error("获取用户信息失败", err); + } + }; + loadUserInfo(); + }, [user?.usertoken]); // 当 user 的 token 变化时重新获取 + + return { + userInfo, + }; +} + diff --git a/src/components/pages/Home/index.tsx b/src/components/pages/Home/index.tsx new file mode 100644 index 0000000..e234e1f --- /dev/null +++ b/src/components/pages/Home/index.tsx @@ -0,0 +1,73 @@ +import { useUserInfo } from "./hooks/useUserInfo"; +import { useFlowData } from "./hooks/useFlowData"; +import { useSignInInfo } from "./hooks/useSignInInfo"; +import { useAnimatedNumber } from "./hooks/useAnimatedNumber"; +import { WelcomeHeader } from "./components/WelcomeHeader"; +import { UserInfoCard } from "./components/UserInfoCard"; +import { FlowDataCard } from "./components/FlowDataCard"; +import { FAQSection } from "./components/FAQSection"; +import { FeedbackCard } from "./components/FeedbackCard"; +import type { HomeProps } from "./types"; + +export function Home({ user, onUserChange }: HomeProps) { + const { userInfo } = useUserInfo(user, onUserChange); + const { flowData, flowLoading, flowError } = useFlowData(userInfo); + const { + signInInfo, + signInInfoHover, + signInInfoLoading, + signInInfoError, + signInInfoVisible, + signInInfoClosing, + handleMouseEnter, + handleMouseLeave, + } = useSignInInfo(userInfo); + + // 数字计数动画 - 只在菜单可见时触发动画 + const animatedTotalPoints = useAnimatedNumber( + signInInfo?.total_points || 0, + 800, + signInInfoVisible && !!signInInfo, + ); + const animatedTotalSignIns = useAnimatedNumber( + signInInfo?.total_sign_ins || 0, + 600, + signInInfoVisible && !!signInInfo, + ); + const animatedCountOfRecords = useAnimatedNumber( + signInInfo?.count_of_matching_records || 0, + 600, + signInInfoVisible && !!signInInfo, + ); + + return ( +
+ + +
+ + + + +
+
+ ); +} + diff --git a/src/components/pages/Home/types.ts b/src/components/pages/Home/types.ts new file mode 100644 index 0000000..1c125a3 --- /dev/null +++ b/src/components/pages/Home/types.ts @@ -0,0 +1,14 @@ +import type { + UserInfo, + FlowPoint, + SignInInfo, + StoredUser, +} from "@/services/api"; + +export interface HomeProps { + user?: StoredUser | null; + onUserChange?: (user: StoredUser | null) => void; +} + +export type { UserInfo, FlowPoint, SignInInfo, StoredUser }; + diff --git a/src/components/pages/Settings/Settings.tsx b/src/components/pages/Settings/index.tsx similarity index 100% rename from src/components/pages/Settings/Settings.tsx rename to src/components/pages/Settings/index.tsx diff --git a/src/components/pages/TunnelList/TunnelList.tsx b/src/components/pages/TunnelList/index.tsx similarity index 100% rename from src/components/pages/TunnelList/TunnelList.tsx rename to src/components/pages/TunnelList/index.tsx From 5c71605837842c8cd13aa135c6abca6646e2fd44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cchaoji233=E2=80=9D?= Date: Tue, 13 Jan 2026 02:03:56 +0800 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=B5=81?= =?UTF-8?q?=E9=87=8F=E6=95=B0=E6=8D=AE=E5=9B=BE=E8=A1=A8=E5=92=8C=E5=B8=B8?= =?UTF-8?q?=E8=A7=81=E9=97=AE=E9=A2=98=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在首页中新增流量数据图表,使用 Recharts 库展示近7日流量情况。 - 优化常见问题模块的布局,提升用户体验。 - 引入 Chart 组件以支持图表的样式和交互功能。 --- package.json | 1 + pnpm-lock.yaml | 291 ++++++++++++++ .../pages/Home/components/FAQSection.tsx | 14 +- .../pages/Home/components/FlowDataCard.tsx | 143 +++++-- src/components/pages/Home/index.tsx | 2 +- src/components/ui/chart.tsx | 355 ++++++++++++++++++ 6 files changed, 773 insertions(+), 33 deletions(-) create mode 100644 src/components/ui/chart.tsx diff --git a/package.json b/package.json index 19a8d6a..9200e13 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "next-themes": "^0.4.6", "react": "^19.2.0", "react-dom": "^19.2.0", + "recharts": "2.15.4", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53d8430..76c9953 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: react-dom: specifier: ^19.2.0 version: 19.2.3(react@19.2.3) + recharts: + specifier: 2.15.4 + version: 2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -197,6 +200,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -1160,6 +1167,33 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1322,6 +1356,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1331,6 +1409,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1341,6 +1422,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -1414,9 +1498,16 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@5.4.0: + resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} + engines: {node: '>=6.0.0'} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -1501,6 +1592,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1626,6 +1721,13 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1664,6 +1766,10 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1708,6 +1814,9 @@ packages: engines: {node: '>=14'} hasBin: true + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1717,6 +1826,12 @@ packages: peerDependencies: react: ^19.2.3 + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -1741,6 +1856,12 @@ packages: '@types/react': optional: true + react-smooth@4.0.4: + resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -1751,10 +1872,26 @@ packages: '@types/react': optional: true + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + react@19.2.3: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} + recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + + recharts@2.15.4: + resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1812,6 +1949,9 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -1881,6 +2021,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + vite@7.3.0: resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2037,6 +2180,8 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/runtime@7.28.4': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -2825,6 +2970,30 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} @@ -3021,16 +3190,61 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.0: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + deep-is@0.1.4: {} detect-libc@2.1.2: {} detect-node-es@1.1.0: {} + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.28.4 + csstype: 3.2.3 + electron-to-chromium@1.5.267: {} enhanced-resolve@5.18.4: @@ -3154,8 +3368,12 @@ snapshots: esutils@2.0.3: {} + eventemitter3@4.0.7: {} + fast-deep-equal@3.1.3: {} + fast-equals@5.4.0: {} + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} @@ -3216,6 +3434,8 @@ snapshots: imurmurhash@0.1.4: {} + internmap@2.0.3: {} + is-extglob@2.1.1: {} is-glob@4.0.3: @@ -3306,6 +3526,12 @@ snapshots: lodash.merge@4.6.2: {} + lodash@4.17.21: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -3339,6 +3565,8 @@ snapshots: node-releases@2.0.27: {} + object-assign@4.1.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3378,6 +3606,12 @@ snapshots: prettier@3.7.4: {} + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + punycode@2.3.1: {} react-dom@19.2.3(react@19.2.3): @@ -3385,6 +3619,10 @@ snapshots: react: 19.2.3 scheduler: 0.27.0 + react-is@16.13.1: {} + + react-is@18.3.1: {} + react-refresh@0.18.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.3): @@ -3406,6 +3644,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 + react-smooth@4.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + fast-equals: 5.4.0 + prop-types: 15.8.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-transition-group: 4.4.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react-style-singleton@2.2.3(@types/react@19.2.7)(react@19.2.3): dependencies: get-nonce: 1.0.1 @@ -3414,8 +3660,34 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 + react-transition-group@4.4.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@babel/runtime': 7.28.4 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react@19.2.3: {} + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.17.21 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + resolve-from@4.0.0: {} rollup@4.54.0: @@ -3477,6 +3749,8 @@ snapshots: tapable@2.3.0: {} + tiny-invariant@1.3.3: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -3538,6 +3812,23 @@ snapshots: dependencies: react: 19.2.3 + victory-vendor@36.9.2: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: esbuild: 0.27.2 diff --git a/src/components/pages/Home/components/FAQSection.tsx b/src/components/pages/Home/components/FAQSection.tsx index 51224f1..1c89dac 100644 --- a/src/components/pages/Home/components/FAQSection.tsx +++ b/src/components/pages/Home/components/FAQSection.tsx @@ -7,8 +7,9 @@ import { export function FAQSection() { return ( -
-

常见问题

+
+

常见问题

+
软件出现了BUG怎么办 @@ -34,15 +35,8 @@ export function FAQSection() {

- - 关于映射延迟问题 - -

- 节点请尽量选择距离运行映射设备最近的节点。同时,您可以根据节点状态页中的节点负载选择负载较低的节点,这能够优化您的体验。 -

-
-
+
); } diff --git a/src/components/pages/Home/components/FlowDataCard.tsx b/src/components/pages/Home/components/FlowDataCard.tsx index 5c723a0..1a85c65 100644 --- a/src/components/pages/Home/components/FlowDataCard.tsx +++ b/src/components/pages/Home/components/FlowDataCard.tsx @@ -1,4 +1,11 @@ import type { FlowPoint } from "@/services/api"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart"; +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; interface FlowDataCardProps { flowData: FlowPoint[]; @@ -6,41 +13,133 @@ interface FlowDataCardProps { flowError: string; } +// 生成默认的7天数据 +function generateDefaultData() { + const data = []; + const today = new Date(); + + for (let i = 6; i >= 0; i--) { + const date = new Date(today); + date.setDate(today.getDate() - i); + const dateStr = `${date.getMonth() + 1}/${date.getDate()}`; + + data.push({ + date: dateStr, + 上传: 0, + 下载: 0, + }); + } + + return data; +} + export function FlowDataCard({ flowData, flowLoading, flowError, }: FlowDataCardProps) { + // 将流量数据转换为 MB + const chartData = flowData.length > 0 + ? flowData.map((item) => ({ + date: item.time, + 上传: typeof item.traffic_in === 'number' ? item.traffic_in / (1024 * 1024) : 0, + 下载: typeof item.traffic_out === 'number' ? item.traffic_out / (1024 * 1024) : 0, + })) + : generateDefaultData(); + + const chartConfig = { + 上传: { + label: "上传", + color: "hsl(var(--chart-1))" as const, + }, + 下载: { + label: "下载", + color: "hsl(var(--chart-2))" as const, + }, + } satisfies ChartConfig; + return ( -
+

近7日流量

-
+
{flowLoading ? ( -
加载中...
+
加载中...
) : flowError ? ( -
{flowError}
- ) : flowData.length === 0 ? ( -
暂无数据
+
{flowError}
) : ( -
- {flowData.map((item) => ( -
-
{item.time}
-
- ↑ {item.traffic_in} -
-
- ↓ {item.traffic_out} -
-
- ))} -
+ + + + + + + + + + + + + + value} + /> + `${value.toFixed(0)}MB`} + /> + `日期: ${value}`} + formatter={(value) => `${Number(value).toFixed(2)} MB`} + /> + } + /> + + + + )}
diff --git a/src/components/pages/Home/index.tsx b/src/components/pages/Home/index.tsx index e234e1f..d1316b9 100644 --- a/src/components/pages/Home/index.tsx +++ b/src/components/pages/Home/index.tsx @@ -59,12 +59,12 @@ export function Home({ user, onUserChange }: HomeProps) {
+ -
diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx new file mode 100644 index 0000000..48d2724 --- /dev/null +++ b/src/components/ui/chart.tsx @@ -0,0 +1,355 @@ +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +function ChartContainer({ + id, + className, + children, + config, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] +}) { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +