perf(desktop): stream drag-upload and improve transfer status UX
This commit is contained in:
17
desktop-client/src-tauri/Cargo.lock
generated
17
desktop-client/src-tauri/Cargo.lock
generated
@@ -3116,12 +3116,14 @@ dependencies = [
|
|||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
|
"wasm-streams 0.4.2",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"webpki-roots",
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
@@ -3156,7 +3158,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
"wasm-streams 0.5.0",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4716,6 +4718,19 @@ dependencies = [
|
|||||||
"wasmparser",
|
"wasmparser",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-streams"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-streams"
|
name = "wasm-streams"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
|
|||||||
@@ -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", "multipart", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "cookies", "multipart", "stream", "rustls-tls"] }
|
||||||
urlencoding = "2.1"
|
urlencoding = "2.1"
|
||||||
|
|||||||
@@ -620,8 +620,6 @@ async fn api_upload_file(
|
|||||||
.and_then(|name| name.to_str())
|
.and_then(|name| name.to_str())
|
||||||
.map(|name| name.to_string())
|
.map(|name| name.to_string())
|
||||||
.ok_or_else(|| "无法识别文件名".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() {
|
let normalized_target = if target_path.trim().is_empty() {
|
||||||
"/".to_string()
|
"/".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -634,9 +632,15 @@ async fn api_upload_file(
|
|||||||
return Err("API 地址不能为空".to_string());
|
return Err("API 地址不能为空".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用流式 multipart 上传,避免大文件整块读入内存导致占用暴涨。
|
||||||
|
let file_part = reqwest::multipart::Part::file(&source_path)
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("读取文件失败: {}", err))?
|
||||||
|
.file_name(file_name);
|
||||||
|
|
||||||
let multipart = reqwest::multipart::Form::new()
|
let multipart = reqwest::multipart::Form::new()
|
||||||
.text("path", normalized_target)
|
.text("path", normalized_target)
|
||||||
.part("file", reqwest::multipart::Part::bytes(file_bytes).file_name(file_name));
|
.part("file", file_part);
|
||||||
|
|
||||||
let mut request = state
|
let mut request = state
|
||||||
.client
|
.client
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ const selectedFileName = ref("");
|
|||||||
const searchKeyword = ref("");
|
const searchKeyword = ref("");
|
||||||
const shares = ref<ShareItem[]>([]);
|
const shares = ref<ShareItem[]>([]);
|
||||||
|
|
||||||
const transferTasks = ref<{ id: string; name: string; speed: string; progress: number; status: string }[]>([]);
|
const transferTasks = ref<{ id: string; name: string; speed: string; progress: number; status: string; note?: string }[]>([]);
|
||||||
const sharesLoading = ref(false);
|
const sharesLoading = ref(false);
|
||||||
const contextMenu = reactive({
|
const contextMenu = reactive({
|
||||||
visible: false,
|
visible: false,
|
||||||
@@ -280,17 +280,25 @@ function showToast(message: string, type = "info") {
|
|||||||
}, 2500);
|
}, 2500);
|
||||||
}
|
}
|
||||||
|
|
||||||
function prependTransferTask(task: { id: string; name: string; speed: string; progress: number; status: string }) {
|
function prependTransferTask(task: { id: string; name: string; speed: string; progress: number; status: string; note?: string }) {
|
||||||
transferTasks.value = [task, ...transferTasks.value.slice(0, 49)];
|
transferTasks.value = [task, ...transferTasks.value.slice(0, 49)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTransferTask(
|
function updateTransferTask(
|
||||||
id: string,
|
id: string,
|
||||||
patch: Partial<{ name: string; speed: string; progress: number; status: string }>,
|
patch: Partial<{ name: string; speed: string; progress: number; status: string; note?: string }>,
|
||||||
) {
|
) {
|
||||||
transferTasks.value = transferTasks.value.map((task) => (task.id === id ? { ...task, ...patch } : task));
|
transferTasks.value = transferTasks.value.map((task) => (task.id === id ? { ...task, ...patch } : task));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTaskStatusLabel(status: string) {
|
||||||
|
if (status === "uploading") return "上传中";
|
||||||
|
if (status === "downloading") return "下载中";
|
||||||
|
if (status === "done") return "已完成";
|
||||||
|
if (status === "failed") return "失败";
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
async function invokeBridge(command: string, payload: Record<string, any>) {
|
async function invokeBridge(command: string, payload: Record<string, any>) {
|
||||||
try {
|
try {
|
||||||
return await invoke<BridgeResponse>(command, payload);
|
return await invoke<BridgeResponse>(command, payload);
|
||||||
@@ -624,14 +632,15 @@ async function downloadSelected(target?: FileItem | null) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (nativeResponse.ok && nativeResponse.data?.success) {
|
if (nativeResponse.ok && nativeResponse.data?.success) {
|
||||||
updateTransferTask(taskId, { speed: "-", progress: 100, status: "done" });
|
updateTransferTask(taskId, { speed: "-", progress: 100, status: "done", note: "下载成功" });
|
||||||
const savedPath = nativeResponse.data?.savePath ? `\n${nativeResponse.data.savePath}` : "";
|
const savedPath = nativeResponse.data?.savePath ? `\n${nativeResponse.data.savePath}` : "";
|
||||||
showToast(`下载完成${savedPath}`, "success");
|
showToast(`下载完成${savedPath}`, "success");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTransferTask(taskId, { speed: "-", progress: 0, status: "failed" });
|
const message = String(nativeResponse.data?.message || "原生下载失败");
|
||||||
showToast(nativeResponse.data?.message || "原生下载失败", "error");
|
updateTransferTask(taskId, { speed: "-", progress: 0, status: "failed", note: message });
|
||||||
|
showToast(message, "error");
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectFile(item: FileItem) {
|
function selectFile(item: FileItem) {
|
||||||
@@ -764,13 +773,16 @@ async function uploadDroppedFiles(paths: string[]) {
|
|||||||
speed: "-",
|
speed: "-",
|
||||||
progress: 100,
|
progress: 100,
|
||||||
status: "done",
|
status: "done",
|
||||||
|
note: "上传成功",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
dropState.failed += 1;
|
dropState.failed += 1;
|
||||||
|
const message = String(response.data?.message || "上传失败");
|
||||||
updateTransferTask(taskId, {
|
updateTransferTask(taskId, {
|
||||||
speed: "-",
|
speed: "-",
|
||||||
progress: 0,
|
progress: 0,
|
||||||
status: "failed",
|
status: "failed",
|
||||||
|
note: message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -802,7 +814,12 @@ async function registerDragDropListener() {
|
|||||||
|
|
||||||
if (payload.type === "drop") {
|
if (payload.type === "drop") {
|
||||||
dropState.active = false;
|
dropState.active = false;
|
||||||
if (!canUseDragUpload()) return;
|
if (!canUseDragUpload()) {
|
||||||
|
if (authenticated.value) {
|
||||||
|
showToast("请先切换到“全部文件”页面再上传", "info");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
void uploadDroppedFiles(payload.paths || []);
|
void uploadDroppedFiles(payload.paths || []);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -995,7 +1012,8 @@ onBeforeUnmount(() => {
|
|||||||
<div v-for="task in transferTasks" :key="task.id" class="task-row">
|
<div v-for="task in transferTasks" :key="task.id" class="task-row">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ task.name }}</strong>
|
<strong>{{ task.name }}</strong>
|
||||||
<small>{{ task.id }} · {{ task.status }}</small>
|
<small>{{ task.id }} · {{ getTaskStatusLabel(task.status) }}</small>
|
||||||
|
<small v-if="task.note" class="task-note" :class="{ error: task.status === 'failed' }">{{ task.note }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-right">
|
<div class="task-right">
|
||||||
<span>{{ task.speed }}</span>
|
<span>{{ task.speed }}</span>
|
||||||
@@ -1639,9 +1657,19 @@ select:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.task-row small {
|
.task-row small {
|
||||||
|
display: block;
|
||||||
color: #647b94;
|
color: #647b94;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-note {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #5f7895;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-note.error {
|
||||||
|
color: #c24747;
|
||||||
|
}
|
||||||
|
|
||||||
.task-right {
|
.task-right {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
Reference in New Issue
Block a user