feat(desktop): add drag-and-drop upload for file view
This commit is contained in:
18
desktop-client/src-tauri/Cargo.lock
generated
18
desktop-client/src-tauri/Cargo.lock
generated
@@ -2050,6 +2050,16 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
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]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -3085,6 +3095,7 @@ dependencies = [
|
|||||||
"cookie",
|
"cookie",
|
||||||
"cookie_store",
|
"cookie_store",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -3093,6 +3104,7 @@ dependencies = [
|
|||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime_guess",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
@@ -4457,6 +4469,12 @@ dependencies = [
|
|||||||
"unic-common",
|
"unic-common",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
|
|||||||
@@ -22,5 +22,5 @@ tauri = { version = "2", features = [] }
|
|||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
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"
|
urlencoding = "2.1"
|
||||||
|
|||||||
@@ -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<BridgeResponse, String> {
|
||||||
|
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::<Value>(&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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
@@ -620,7 +696,8 @@ pub fn run() {
|
|||||||
api_create_share,
|
api_create_share,
|
||||||
api_delete_share,
|
api_delete_share,
|
||||||
api_create_direct_link,
|
api_create_direct_link,
|
||||||
api_native_download
|
api_native_download,
|
||||||
|
api_upload_file
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
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";
|
type NavKey = "files" | "transfers" | "shares";
|
||||||
|
|
||||||
@@ -76,6 +78,14 @@ const contextMenu = reactive({
|
|||||||
y: 0,
|
y: 0,
|
||||||
item: null as FileItem | null,
|
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({
|
const toast = reactive({
|
||||||
visible: false,
|
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) => {
|
watch(nav, async (next) => {
|
||||||
if (next === "shares" && authenticated.value) {
|
if (next === "shares" && authenticated.value) {
|
||||||
await loadShares();
|
await loadShares();
|
||||||
@@ -707,12 +820,17 @@ watch(nav, async (next) => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
window.addEventListener("click", handleGlobalClick);
|
window.addEventListener("click", handleGlobalClick);
|
||||||
window.addEventListener("keydown", handleGlobalKey);
|
window.addEventListener("keydown", handleGlobalKey);
|
||||||
|
await registerDragDropListener();
|
||||||
await restoreSession();
|
await restoreSession();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener("click", handleGlobalClick);
|
window.removeEventListener("click", handleGlobalClick);
|
||||||
window.removeEventListener("keydown", handleGlobalKey);
|
window.removeEventListener("keydown", handleGlobalKey);
|
||||||
|
if (unlistenDragDrop) {
|
||||||
|
unlistenDragDrop();
|
||||||
|
unlistenDragDrop = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -835,6 +953,7 @@ onBeforeUnmount(() => {
|
|||||||
<span>{{ pathState.mode === "search" ? "搜索结果" : "当前目录" }} {{ pathState.currentPath }}</span>
|
<span>{{ pathState.mode === "search" ? "搜索结果" : "当前目录" }} {{ pathState.currentPath }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="file-drop-surface" :class="{ active: dropState.active || dropState.uploading }">
|
||||||
<div v-if="pathState.loading" class="empty-tip">正在加载目录...</div>
|
<div v-if="pathState.loading" class="empty-tip">正在加载目录...</div>
|
||||||
<div v-else-if="pathState.error" class="empty-tip error">{{ pathState.error }}</div>
|
<div v-else-if="pathState.error" class="empty-tip error">{{ pathState.error }}</div>
|
||||||
<div v-else-if="filteredFiles.length === 0" class="empty-tip">当前目录暂无文件</div>
|
<div v-else-if="filteredFiles.length === 0" class="empty-tip">当前目录暂无文件</div>
|
||||||
@@ -855,6 +974,15 @@ onBeforeUnmount(() => {
|
|||||||
<div class="file-time">{{ formatDate(item.modifiedAt) }}</div>
|
<div class="file-time">{{ formatDate(item.modifiedAt) }}</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="dropState.active || dropState.uploading" class="drop-overlay">
|
||||||
|
<div class="drop-overlay-card">
|
||||||
|
<strong v-if="!dropState.uploading">拖拽到此处上传到当前目录</strong>
|
||||||
|
<strong v-else>正在上传 {{ dropState.done + dropState.failed }}/{{ dropState.total }}</strong>
|
||||||
|
<span v-if="!dropState.uploading">仅支持文件,文件夹会自动跳过</span>
|
||||||
|
<span v-else>成功 {{ dropState.done }} 个,失败 {{ dropState.failed }} 个</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="nav === 'transfers'">
|
<template v-else-if="nav === 'transfers'">
|
||||||
@@ -1410,6 +1538,16 @@ select:focus {
|
|||||||
padding: 2px 4px 2px 2px;
|
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 {
|
.file-card {
|
||||||
border: 1px solid #d8e1ee;
|
border: 1px solid #d8e1ee;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -1710,6 +1848,44 @@ select:focus {
|
|||||||
color: #cc4242;
|
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 {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 24px;
|
right: 24px;
|
||||||
|
|||||||
Reference in New Issue
Block a user