diff --git a/desktop-client/src-tauri/Cargo.lock b/desktop-client/src-tauri/Cargo.lock index ea668e2..d67d112 100644 --- a/desktop-client/src-tauri/Cargo.lock +++ b/desktop-client/src-tauri/Cargo.lock @@ -2050,6 +2050,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3085,6 +3095,7 @@ dependencies = [ "cookie", "cookie_store", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -3093,6 +3104,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", @@ -4457,6 +4469,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/desktop-client/src-tauri/Cargo.toml b/desktop-client/src-tauri/Cargo.toml index fd03661..15ee5bc 100644 --- a/desktop-client/src-tauri/Cargo.toml +++ b/desktop-client/src-tauri/Cargo.toml @@ -22,5 +22,5 @@ tauri = { version = "2", features = [] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -reqwest = { version = "0.12", default-features = false, features = ["json", "cookies", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "cookies", "multipart", "rustls-tls"] } urlencoding = "2.1" diff --git a/desktop-client/src-tauri/src/lib.rs b/desktop-client/src-tauri/src/lib.rs index 9e529c4..73c15f5 100644 --- a/desktop-client/src-tauri/src/lib.rs +++ b/desktop-client/src-tauri/src/lib.rs @@ -595,6 +595,82 @@ async fn api_native_download( }) } +#[tauri::command] +async fn api_upload_file( + state: tauri::State<'_, ApiState>, + base_url: String, + file_path: String, + target_path: String, +) -> 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 file_name = source_path + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.to_string()) + .ok_or_else(|| "无法识别文件名".to_string())?; + + let file_bytes = fs::read(&source_path).map_err(|err| format!("读取文件失败: {}", err))?; + let normalized_target = if target_path.trim().is_empty() { + "/".to_string() + } else { + target_path + }; + + let csrf_token = fetch_csrf_token(&state.client, &base_url).await?; + let upload_url = join_api_url(&base_url, "/api/upload"); + if upload_url.trim().is_empty() { + return Err("API 地址不能为空".to_string()); + } + + let multipart = reqwest::multipart::Form::new() + .text("path", normalized_target) + .part("file", reqwest::multipart::Part::bytes(file_bytes).file_name(file_name)); + + let mut request = state + .client + .post(&upload_url) + .header("Accept", "application/json") + .timeout(Duration::from_secs(60 * 30)) + .multipart(multipart); + + if let Some(csrf) = csrf_token { + request = request.header("X-CSRF-Token", csrf); + } + + let response = request + .send() + .await + .map_err(|err| format!("上传请求失败: {}", err))?; + + 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, + }) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let client = reqwest::Client::builder() @@ -620,7 +696,8 @@ pub fn run() { api_create_share, api_delete_share, api_create_direct_link, - api_native_download + api_native_download, + api_upload_file ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/desktop-client/src/App.vue b/desktop-client/src/App.vue index df8a11a..e27bd34 100644 --- a/desktop-client/src/App.vue +++ b/desktop-client/src/App.vue @@ -2,6 +2,8 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue"; import { invoke } from "@tauri-apps/api/core"; import { openUrl } from "@tauri-apps/plugin-opener"; +import type { UnlistenFn } from "@tauri-apps/api/event"; +import { getCurrentWebview } from "@tauri-apps/api/webview"; type NavKey = "files" | "transfers" | "shares"; @@ -76,6 +78,14 @@ const contextMenu = reactive({ y: 0, item: null as FileItem | null, }); +const dropState = reactive({ + active: false, + uploading: false, + total: 0, + done: 0, + failed: 0, +}); +let unlistenDragDrop: UnlistenFn | null = null; const toast = reactive({ visible: false, @@ -698,6 +708,109 @@ function handleGlobalKey(event: KeyboardEvent) { } } +function extractFileNameFromPath(filePath: string) { + const trimmed = String(filePath || "").trim(); + if (!trimmed) return ""; + const normalized = trimmed.replace(/\\/g, "/"); + const segments = normalized.split("/").filter(Boolean); + return segments[segments.length - 1] || normalized; +} + +function canUseDragUpload() { + return authenticated.value && nav.value === "files"; +} + +async function uploadDroppedFiles(paths: string[]) { + const uniquePaths = [...new Set((paths || []).map((item) => String(item || "").trim()).filter(Boolean))]; + if (uniquePaths.length === 0) { + showToast("未识别到可上传文件", "info"); + return; + } + + if (dropState.uploading) { + showToast("已有上传任务进行中,请稍后再试", "info"); + return; + } + + dropState.uploading = true; + dropState.total = uniquePaths.length; + dropState.done = 0; + dropState.failed = 0; + + let successCount = 0; + for (let index = 0; index < uniquePaths.length; index += 1) { + const filePath = uniquePaths[index]; + const displayName = extractFileNameFromPath(filePath) || `文件${index + 1}`; + const taskId = `U-${Date.now()}-${index}`; + + prependTransferTask({ + id: taskId, + name: displayName, + speed: "上传中", + progress: 8, + status: "uploading", + }); + + const response = await invokeBridge("api_upload_file", { + baseUrl: appConfig.baseUrl, + filePath, + targetPath: pathState.currentPath, + }); + + if (response.ok && response.data?.success) { + successCount += 1; + dropState.done += 1; + updateTransferTask(taskId, { + speed: "-", + progress: 100, + status: "done", + }); + } else { + dropState.failed += 1; + updateTransferTask(taskId, { + speed: "-", + progress: 0, + status: "failed", + }); + } + } + + dropState.uploading = false; + const failedMessage = dropState.failed > 0 ? `,失败 ${dropState.failed} 个` : ""; + showToast(`上传完成:成功 ${dropState.done} 个${failedMessage}`, dropState.failed > 0 ? "info" : "success"); + if (successCount > 0) { + await loadFiles(pathState.currentPath); + } +} + +async function registerDragDropListener() { + try { + const currentWebview = getCurrentWebview(); + unlistenDragDrop = await currentWebview.onDragDropEvent((event) => { + const payload = event.payload; + if (payload.type === "enter" || payload.type === "over") { + if (canUseDragUpload()) { + dropState.active = true; + } + return; + } + + if (payload.type === "leave") { + dropState.active = false; + return; + } + + if (payload.type === "drop") { + dropState.active = false; + if (!canUseDragUpload()) return; + void uploadDroppedFiles(payload.paths || []); + } + }); + } catch (error) { + console.error("register drag drop listener failed", error); + } +} + watch(nav, async (next) => { if (next === "shares" && authenticated.value) { await loadShares(); @@ -707,12 +820,17 @@ watch(nav, async (next) => { onMounted(async () => { window.addEventListener("click", handleGlobalClick); window.addEventListener("keydown", handleGlobalKey); + await registerDragDropListener(); await restoreSession(); }); onBeforeUnmount(() => { window.removeEventListener("click", handleGlobalClick); window.removeEventListener("keydown", handleGlobalKey); + if (unlistenDragDrop) { + unlistenDragDrop(); + unlistenDragDrop = null; + } }); @@ -835,25 +953,35 @@ onBeforeUnmount(() => { {{ pathState.mode === "search" ? "搜索结果" : "当前目录" }} {{ pathState.currentPath }} -
正在加载目录...
-
{{ pathState.error }}
-
当前目录暂无文件
-
- +
+
正在加载目录...
+
{{ pathState.error }}
+
当前目录暂无文件
+
+ +
+
+
+ 拖拽到此处上传到当前目录 + 正在上传 {{ dropState.done + dropState.failed }}/{{ dropState.total }} + 仅支持文件,文件夹会自动跳过 + 成功 {{ dropState.done }} 个,失败 {{ dropState.failed }} 个 +
+
@@ -1410,6 +1538,16 @@ select:focus { padding: 2px 4px 2px 2px; } +.file-drop-surface { + position: relative; + min-height: 0; + flex: 1; +} + +.file-drop-surface.active .icon-grid { + filter: saturate(1.04) blur(0.2px); +} + .file-card { border: 1px solid #d8e1ee; border-radius: 14px; @@ -1710,6 +1848,44 @@ select:focus { color: #cc4242; } +.drop-overlay { + position: absolute; + inset: 0; + z-index: 12; + border-radius: 12px; + background: rgba(29, 111, 255, 0.08); + border: 1px dashed rgba(29, 111, 255, 0.6); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + pointer-events: none; +} + +.drop-overlay-card { + min-width: 260px; + max-width: 380px; + border-radius: 12px; + padding: 14px 16px; + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(141, 164, 196, 0.4); + box-shadow: 0 10px 20px rgba(22, 44, 73, 0.12); + text-align: center; +} + +.drop-overlay-card strong { + display: block; + margin-bottom: 4px; + font-size: 14px; + color: #1d3f73; +} + +.drop-overlay-card span { + display: block; + font-size: 12px; + color: #5f7896; +} + .toast { position: fixed; right: 24px;