feat(desktop): add drag-and-drop upload for file view

This commit is contained in:
2026-02-18 19:46:11 +08:00
parent 2b36275c4a
commit 09043e8059
4 changed files with 292 additions and 21 deletions

View File

@@ -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"

View File

@@ -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"

View File

@@ -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)]
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");

View File

@@ -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;
}
});
</script>
@@ -835,25 +953,35 @@ onBeforeUnmount(() => {
<span>{{ pathState.mode === "search" ? "搜索结果" : "当前目录" }} {{ pathState.currentPath }}</span>
</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="filteredFiles.length === 0" class="empty-tip">当前目录暂无文件</div>
<div v-else class="icon-grid">
<button
v-for="item in filteredFiles"
:key="item.name"
type="button"
class="file-card"
:class="{ selected: selectedFileName === item.name }"
@click="selectFile(item)"
@dblclick="openItem(item)"
@contextmenu.prevent="openContextMenu($event, item)"
>
<div class="file-icon-glyph">{{ fileIcon(item) }}</div>
<div class="file-name" :title="item.displayName || item.name">{{ item.displayName || item.name }}</div>
<div class="file-meta">{{ fileTypeLabel(item) }} · {{ item.isDirectory ? "-" : (item.sizeFormatted || formatBytes(item.size)) }}</div>
<div class="file-time">{{ formatDate(item.modifiedAt) }}</div>
</button>
<div class="file-drop-surface" :class="{ active: dropState.active || dropState.uploading }">
<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="filteredFiles.length === 0" class="empty-tip">当前目录暂无文件</div>
<div v-else class="icon-grid">
<button
v-for="item in filteredFiles"
:key="item.name"
type="button"
class="file-card"
:class="{ selected: selectedFileName === item.name }"
@click="selectFile(item)"
@dblclick="openItem(item)"
@contextmenu.prevent="openContextMenu($event, item)"
>
<div class="file-icon-glyph">{{ fileIcon(item) }}</div>
<div class="file-name" :title="item.displayName || item.name">{{ item.displayName || item.name }}</div>
<div class="file-meta">{{ fileTypeLabel(item) }} · {{ item.isDirectory ? "-" : (item.sizeFormatted || formatBytes(item.size)) }}</div>
<div class="file-time">{{ formatDate(item.modifiedAt) }}</div>
</button>
</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>
@@ -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;