From c668c88f7f393b6056bc6c4c1a212ae405e8f6d8 Mon Sep 17 00:00:00 2001 From: yuyx <237899745@qq.com> Date: Wed, 18 Feb 2026 20:26:16 +0800 Subject: [PATCH] feat(desktop): align requested 4/5/6/8 with resume queue batch and one-click update --- desktop-client/src-tauri/src/lib.rs | 283 ++++++++++++++-- desktop-client/src/App.vue | 502 +++++++++++++++++++++++++--- 2 files changed, 711 insertions(+), 74 deletions(-) diff --git a/desktop-client/src-tauri/src/lib.rs b/desktop-client/src-tauri/src/lib.rs index c487d8a..2c34c1a 100644 --- a/desktop-client/src-tauri/src/lib.rs +++ b/desktop-client/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ use serde_json::{Map, Value}; use std::env; use std::fs; use std::io::Write; +use std::io::{Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::time::{Duration, UNIX_EPOCH}; @@ -108,6 +109,35 @@ fn alloc_download_path(download_dir: &Path, preferred_name: &str) -> PathBuf { first } +fn build_download_resume_temp_path(download_dir: &Path, preferred_name: &str, url: &str) -> PathBuf { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + use std::hash::{Hash, Hasher}; + preferred_name.hash(&mut hasher); + url.hash(&mut hasher); + let digest = format!("{:016x}", hasher.finish()); + let safe_name = sanitize_file_name(preferred_name); + let temp_name = format!(".{}.{}.part", safe_name, digest); + download_dir.join(temp_name) +} + +async fn parse_response_as_bridge(response: reqwest::Response) -> Result { + let status = response.status(); + let text = response + .text() + .await + .map_err(|err| format!("读取响应失败: {}", err))?; + let data = match serde_json::from_str::(&text) { + Ok(parsed) => parsed, + Err(_) => fallback_json(status, &text), + }; + + Ok(BridgeResponse { + ok: status.is_success(), + status: status.as_u16(), + data, + }) +} + async fn request_json( client: &reqwest::Client, method: Method, @@ -528,22 +558,6 @@ async fn api_native_download( return Err("下载地址不能为空".to_string()); } - let response = state - .client - .get(&trimmed_url) - .send() - .await - .map_err(|err| format!("下载请求失败: {}", err))?; - - let status = response.status(); - if !status.is_success() { - return Ok(BridgeResponse { - ok: false, - status: status.as_u16(), - data: fallback_json(status, "下载失败"), - }); - } - let preferred_name = file_name .as_deref() .map(|name| name.trim()) @@ -556,11 +570,75 @@ async fn api_native_download( .map_err(|err| format!("创建下载目录失败: {}", err))?; } - let save_path = alloc_download_path(&download_dir, preferred_name); - let mut target_file = - fs::File::create(&save_path).map_err(|err| format!("创建文件失败: {}", err))?; + let resume_temp_path = build_download_resume_temp_path(&download_dir, preferred_name, &trimmed_url); + let existing_size = if resume_temp_path.exists() { + fs::metadata(&resume_temp_path) + .ok() + .map(|meta| meta.len()) + .unwrap_or(0) + } else { + 0 + }; - let mut downloaded_bytes: u64 = 0; + let mut request = state.client.get(&trimmed_url); + if existing_size > 0 { + request = request.header("Range", format!("bytes={}-", existing_size)); + } + + let response = request + .send() + .await + .map_err(|err| format!("下载请求失败: {}", err))?; + let status = response.status(); + + if status == reqwest::StatusCode::RANGE_NOT_SATISFIABLE && existing_size > 0 { + let save_path = alloc_download_path(&download_dir, preferred_name); + fs::rename(&resume_temp_path, &save_path) + .map_err(|err| format!("完成断点下载失败: {}", err))?; + let mut data = Map::new(); + data.insert("success".to_string(), Value::Bool(true)); + data.insert( + "savePath".to_string(), + Value::String(save_path.to_string_lossy().to_string()), + ); + data.insert( + "downloadedBytes".to_string(), + Value::Number(serde_json::Number::from(existing_size)), + ); + data.insert( + "resumedBytes".to_string(), + Value::Number(serde_json::Number::from(existing_size)), + ); + return Ok(BridgeResponse { + ok: true, + status: 200, + data: Value::Object(data), + }); + } + + if !status.is_success() { + return Ok(BridgeResponse { + ok: false, + status: status.as_u16(), + data: fallback_json(status, "下载失败"), + }); + } + + let append_mode = existing_size > 0 && status == reqwest::StatusCode::PARTIAL_CONTENT; + if !append_mode && resume_temp_path.exists() { + fs::remove_file(&resume_temp_path) + .map_err(|err| format!("重置断点下载文件失败: {}", err))?; + } + + let mut target_file = fs::OpenOptions::new() + .create(true) + .write(true) + .append(append_mode) + .truncate(!append_mode) + .open(&resume_temp_path) + .map_err(|err| format!("创建文件失败: {}", err))?; + + let mut downloaded_bytes: u64 = if append_mode { existing_size } else { 0 }; let mut stream = response; while let Some(chunk) = stream .chunk() @@ -577,6 +655,10 @@ async fn api_native_download( .flush() .map_err(|err| format!("刷新文件失败: {}", err))?; + let save_path = alloc_download_path(&download_dir, preferred_name); + fs::rename(&resume_temp_path, &save_path) + .map_err(|err| format!("保存下载文件失败: {}", err))?; + let mut data = Map::new(); data.insert("success".to_string(), Value::Bool(true)); data.insert( @@ -587,6 +669,10 @@ async fn api_native_download( "downloadedBytes".to_string(), Value::Number(serde_json::Number::from(downloaded_bytes)), ); + data.insert( + "resumedBytes".to_string(), + Value::Number(serde_json::Number::from(if append_mode { existing_size } else { 0 })), + ); Ok(BridgeResponse { ok: true, @@ -711,6 +797,162 @@ async fn api_list_local_files(dir_path: String) -> Result, + base_url: String, + file_path: String, + target_path: String, + chunk_size: Option, +) -> Result { + let trimmed_path = file_path.trim().to_string(); + if trimmed_path.is_empty() { + return Err("上传文件路径不能为空".to_string()); + } + + let source_path = PathBuf::from(&trimmed_path); + if !source_path.exists() { + return Err("上传文件不存在".to_string()); + } + if !source_path.is_file() { + return Err("仅支持上传文件,不支持文件夹".to_string()); + } + + let metadata = fs::metadata(&source_path).map_err(|err| format!("读取文件信息失败: {}", err))?; + let file_size = metadata.len(); + if file_size == 0 { + return Err("空文件不支持分片上传".to_string()); + } + + let file_name = source_path + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.to_string()) + .ok_or_else(|| "无法识别文件名".to_string())?; + let normalized_target = if target_path.trim().is_empty() { + "/".to_string() + } else { + target_path + }; + let effective_chunk = chunk_size.unwrap_or(4 * 1024 * 1024).clamp(256 * 1024, 32 * 1024 * 1024); + + let csrf_token = fetch_csrf_token(&state.client, &base_url).await?; + let mut init_body = Map::new(); + init_body.insert("filename".to_string(), Value::String(file_name.clone())); + init_body.insert("path".to_string(), Value::String(normalized_target)); + init_body.insert( + "size".to_string(), + Value::Number(serde_json::Number::from(file_size)), + ); + init_body.insert( + "chunk_size".to_string(), + Value::Number(serde_json::Number::from(effective_chunk)), + ); + + let init_resp = request_json( + &state.client, + Method::POST, + join_api_url(&base_url, "/api/upload/resumable/init"), + Some(Value::Object(init_body)), + csrf_token.clone(), + ) + .await?; + + if !init_resp.ok || !init_resp.data.get("success").and_then(Value::as_bool).unwrap_or(false) { + return Ok(init_resp); + } + + let session_id = init_resp + .data + .get("session_id") + .and_then(Value::as_str) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .ok_or_else(|| "分片上传会话创建失败".to_string())?; + let server_chunk_size = init_resp + .data + .get("chunk_size") + .and_then(Value::as_u64) + .unwrap_or(effective_chunk) + .max(1); + let total_chunks = init_resp + .data + .get("total_chunks") + .and_then(Value::as_u64) + .unwrap_or_else(|| ((file_size + server_chunk_size - 1) / server_chunk_size).max(1)); + let uploaded_chunks: std::collections::HashSet = init_resp + .data + .get("uploaded_chunks") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(Value::as_u64) + .collect::>() + }) + .unwrap_or_default(); + + let mut source = fs::File::open(&source_path).map_err(|err| format!("打开文件失败: {}", err))?; + for chunk_index in 0..total_chunks { + if uploaded_chunks.contains(&chunk_index) { + continue; + } + + let offset = chunk_index * server_chunk_size; + let remaining = file_size.saturating_sub(offset); + if remaining == 0 { + break; + } + let read_size = std::cmp::min(remaining, server_chunk_size) as usize; + + source + .seek(SeekFrom::Start(offset)) + .map_err(|err| format!("读取分片失败: {}", err))?; + let mut buf = vec![0_u8; read_size]; + source + .read_exact(&mut buf) + .map_err(|err| format!("读取分片失败: {}", err))?; + + let chunk_part_name = format!("{}.part{}", file_name, chunk_index); + let multipart = reqwest::multipart::Form::new() + .text("session_id", session_id.clone()) + .text("chunk_index", chunk_index.to_string()) + .part( + "chunk", + reqwest::multipart::Part::bytes(buf).file_name(chunk_part_name), + ); + + let mut request = state + .client + .post(join_api_url(&base_url, "/api/upload/resumable/chunk")) + .header("Accept", "application/json") + .timeout(Duration::from_secs(60 * 10)) + .multipart(multipart); + if let Some(token) = csrf_token.clone() { + request = request.header("X-CSRF-Token", token); + } + + let chunk_resp = request + .send() + .await + .map_err(|err| format!("上传分片失败: {}", err))?; + let chunk_bridge = parse_response_as_bridge(chunk_resp).await?; + if !chunk_bridge.ok || !chunk_bridge.data.get("success").and_then(Value::as_bool).unwrap_or(false) { + return Ok(chunk_bridge); + } + } + + let mut complete_body = Map::new(); + complete_body.insert("session_id".to_string(), Value::String(session_id)); + request_json( + &state.client, + Method::POST, + join_api_url(&base_url, "/api/upload/resumable/complete"), + Some(Value::Object(complete_body)), + csrf_token, + ) + .await +} + #[tauri::command] async fn api_upload_file( state: tauri::State<'_, ApiState>, @@ -820,6 +1062,7 @@ pub fn run() { api_native_download, api_check_client_update, api_list_local_files, + api_upload_file_resumable, api_upload_file ]) .run(tauri::generate_context!()) diff --git a/desktop-client/src/App.vue b/desktop-client/src/App.vue index 8c5490b..5b07677 100644 --- a/desktop-client/src/App.vue +++ b/desktop-client/src/App.vue @@ -1,7 +1,7 @@