feat(desktop): native download and working context menu actions

This commit is contained in:
2026-02-18 19:25:52 +08:00
parent 9da90f38cc
commit 24ac734503
2 changed files with 200 additions and 21 deletions

View File

@@ -2,6 +2,10 @@ use reqwest::Method;
use reqwest::StatusCode;
use serde::Serialize;
use serde_json::{Map, Value};
use std::env;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Duration;
struct ApiState {
@@ -42,6 +46,68 @@ fn fallback_json(status: StatusCode, text: &str) -> Value {
Value::Object(data)
}
fn sanitize_file_name(name: &str) -> String {
let raw = name.trim();
let mut cleaned = String::with_capacity(raw.len());
for ch in raw.chars() {
if matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' | '\0') {
cleaned.push('_');
} else {
cleaned.push(ch);
}
}
let normalized = cleaned.trim().trim_matches('.').to_string();
if normalized.is_empty() {
"download.bin".to_string()
} else {
normalized
}
}
fn resolve_download_dir() -> PathBuf {
if let Some(home) = env::var_os("USERPROFILE") {
return PathBuf::from(home).join("Downloads");
}
if let Some(home) = env::var_os("HOME") {
return PathBuf::from(home).join("Downloads");
}
PathBuf::from(".")
}
fn split_file_name(name: &str) -> (String, String) {
if let Some(index) = name.rfind('.') {
if index > 0 && index < name.len() - 1 {
let stem = name[..index].to_string();
let ext = name[index + 1..].to_string();
return (stem, ext);
}
}
(name.to_string(), String::new())
}
fn alloc_download_path(download_dir: &Path, preferred_name: &str) -> PathBuf {
let safe_name = sanitize_file_name(preferred_name);
let first = download_dir.join(&safe_name);
if !first.exists() {
return first;
}
let (stem, ext) = split_file_name(&safe_name);
for index in 1..10000 {
let candidate_name = if ext.is_empty() {
format!("{} ({})", stem, index)
} else {
format!("{} ({}).{}", stem, index, ext)
};
let candidate = download_dir.join(candidate_name);
if !candidate.exists() {
return candidate;
}
}
first
}
async fn request_json(
client: &reqwest::Client,
method: Method,
@@ -451,6 +517,84 @@ async fn api_create_direct_link(
.await
}
#[tauri::command]
async fn api_native_download(
state: tauri::State<'_, ApiState>,
url: String,
file_name: Option<String>,
) -> Result<BridgeResponse, String> {
let trimmed_url = url.trim().to_string();
if trimmed_url.is_empty() {
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())
.filter(|name| !name.is_empty())
.unwrap_or("download.bin");
let download_dir = resolve_download_dir();
if !download_dir.exists() {
fs::create_dir_all(&download_dir)
.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 mut downloaded_bytes: u64 = 0;
let mut stream = response;
while let Some(chunk) = stream
.chunk()
.await
.map_err(|err| format!("读取下载流失败: {}", err))?
{
target_file
.write_all(&chunk)
.map_err(|err| format!("写入文件失败: {}", err))?;
downloaded_bytes += chunk.len() as u64;
}
target_file
.flush()
.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(downloaded_bytes)),
);
Ok(BridgeResponse {
ok: true,
status: 200,
data: Value::Object(data),
})
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let client = reqwest::Client::builder()
@@ -475,7 +619,8 @@ pub fn run() {
api_get_my_shares,
api_create_share,
api_delete_share,
api_create_direct_link
api_create_direct_link,
api_native_download
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -270,6 +270,17 @@ function showToast(message: string, type = "info") {
}, 2500);
}
function prependTransferTask(task: { id: string; name: string; speed: string; progress: number; status: string }) {
transferTasks.value = [task, ...transferTasks.value.slice(0, 49)];
}
function updateTransferTask(
id: string,
patch: Partial<{ name: string; speed: string; progress: number; status: string }>,
) {
transferTasks.value = transferTasks.value.map((task) => (task.id === id ? { ...task, ...patch } : task));
}
async function invokeBridge(command: string, payload: Record<string, any>) {
try {
return await invoke<BridgeResponse>(command, payload);
@@ -328,6 +339,20 @@ async function loadShares(silent = false) {
if (!silent) sharesLoading.value = false;
}
async function getSignedUrlForItem(item: FileItem, mode: "download" | "preview") {
const targetPath = buildItemPath(item);
const response = await invokeBridge("api_get_download_url", {
baseUrl: appConfig.baseUrl,
path: targetPath,
mode,
});
if (response.ok && response.data?.success && response.data?.downloadUrl) {
return String(response.data.downloadUrl);
}
showToast(response.data?.message || "获取链接失败", "error");
return "";
}
async function createShareForItem(current: FileItem) {
const response = await invokeBridge("api_create_share", {
baseUrl: appConfig.baseUrl,
@@ -571,28 +596,32 @@ async function downloadSelected(target?: FileItem | null) {
showToast("当前仅支持下载文件", "info");
return;
}
const targetPath = buildItemPath(current);
const response = await invokeBridge("api_get_download_url", {
baseUrl: appConfig.baseUrl,
path: targetPath,
mode: "download",
});
if (response.ok && response.data?.success && response.data?.downloadUrl) {
await openUrl(response.data.downloadUrl);
showToast("已调用系统浏览器开始下载", "success");
transferTasks.value = [
{
id: `D-${Date.now()}`,
const signedUrl = await getSignedUrlForItem(current, "download");
if (!signedUrl) return;
const taskId = `D-${Date.now()}`;
prependTransferTask({
id: taskId,
name: current.displayName || current.name,
speed: "直连",
progress: 100,
status: "dispatched",
},
...transferTasks.value.slice(0, 29),
];
speed: "原生下载",
progress: 1,
status: "downloading",
});
const nativeResponse = await invokeBridge("api_native_download", {
url: signedUrl,
fileName: current.displayName || current.name,
});
if (nativeResponse.ok && nativeResponse.data?.success) {
updateTransferTask(taskId, { speed: "-", progress: 100, status: "done" });
const savedPath = nativeResponse.data?.savePath ? `\n${nativeResponse.data.savePath}` : "";
showToast(`下载完成${savedPath}`, "success");
return;
}
showToast(response.data?.message || "获取下载链接失败", "error");
updateTransferTask(taskId, { speed: "-", progress: 0, status: "failed" });
showToast(nativeResponse.data?.message || "原生下载失败", "error");
}
function selectFile(item: FileItem) {
@@ -604,6 +633,11 @@ async function openItem(item: FileItem) {
if (item.isDirectory || item.type === "directory") {
const nextPath = buildItemPath(item);
await loadFiles(nextPath);
return;
}
const previewUrl = await getSignedUrlForItem(item, "preview");
if (previewUrl) {
await openUrl(previewUrl);
}
}
@@ -921,7 +955,7 @@ onBeforeUnmount(() => {
@click.stop
>
<button class="context-item" @click="executeContextAction('open')">
{{ contextMenu.item.isDirectory ? "打开文件夹" : "打开/下载" }}
{{ contextMenu.item.isDirectory ? "打开文件夹" : "打开预览" }}
</button>
<button v-if="!contextMenu.item.isDirectory" class="context-item" @click="executeContextAction('download')">下载文件</button>
<button class="context-item" @click="executeContextAction('rename')">重命名</button>