feat(desktop): native download and working context menu actions
This commit is contained in:
@@ -2,6 +2,10 @@ use reqwest::Method;
|
|||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
struct ApiState {
|
struct ApiState {
|
||||||
@@ -42,6 +46,68 @@ fn fallback_json(status: StatusCode, text: &str) -> Value {
|
|||||||
Value::Object(data)
|
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(
|
async fn request_json(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
method: Method,
|
method: Method,
|
||||||
@@ -451,6 +517,84 @@ async fn api_create_direct_link(
|
|||||||
.await
|
.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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
@@ -475,7 +619,8 @@ pub fn run() {
|
|||||||
api_get_my_shares,
|
api_get_my_shares,
|
||||||
api_create_share,
|
api_create_share,
|
||||||
api_delete_share,
|
api_delete_share,
|
||||||
api_create_direct_link
|
api_create_direct_link,
|
||||||
|
api_native_download
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -270,6 +270,17 @@ function showToast(message: string, type = "info") {
|
|||||||
}, 2500);
|
}, 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>) {
|
async function invokeBridge(command: string, payload: Record<string, any>) {
|
||||||
try {
|
try {
|
||||||
return await invoke<BridgeResponse>(command, payload);
|
return await invoke<BridgeResponse>(command, payload);
|
||||||
@@ -328,6 +339,20 @@ async function loadShares(silent = false) {
|
|||||||
if (!silent) sharesLoading.value = 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) {
|
async function createShareForItem(current: FileItem) {
|
||||||
const response = await invokeBridge("api_create_share", {
|
const response = await invokeBridge("api_create_share", {
|
||||||
baseUrl: appConfig.baseUrl,
|
baseUrl: appConfig.baseUrl,
|
||||||
@@ -571,28 +596,32 @@ async function downloadSelected(target?: FileItem | null) {
|
|||||||
showToast("当前仅支持下载文件", "info");
|
showToast("当前仅支持下载文件", "info");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const targetPath = buildItemPath(current);
|
const signedUrl = await getSignedUrlForItem(current, "download");
|
||||||
const response = await invokeBridge("api_get_download_url", {
|
if (!signedUrl) return;
|
||||||
baseUrl: appConfig.baseUrl,
|
|
||||||
path: targetPath,
|
const taskId = `D-${Date.now()}`;
|
||||||
mode: "download",
|
prependTransferTask({
|
||||||
|
id: taskId,
|
||||||
|
name: current.displayName || current.name,
|
||||||
|
speed: "原生下载",
|
||||||
|
progress: 1,
|
||||||
|
status: "downloading",
|
||||||
});
|
});
|
||||||
if (response.ok && response.data?.success && response.data?.downloadUrl) {
|
|
||||||
await openUrl(response.data.downloadUrl);
|
const nativeResponse = await invokeBridge("api_native_download", {
|
||||||
showToast("已调用系统浏览器开始下载", "success");
|
url: signedUrl,
|
||||||
transferTasks.value = [
|
fileName: current.displayName || current.name,
|
||||||
{
|
});
|
||||||
id: `D-${Date.now()}`,
|
|
||||||
name: current.displayName || current.name,
|
if (nativeResponse.ok && nativeResponse.data?.success) {
|
||||||
speed: "直连",
|
updateTransferTask(taskId, { speed: "-", progress: 100, status: "done" });
|
||||||
progress: 100,
|
const savedPath = nativeResponse.data?.savePath ? `\n${nativeResponse.data.savePath}` : "";
|
||||||
status: "dispatched",
|
showToast(`下载完成${savedPath}`, "success");
|
||||||
},
|
|
||||||
...transferTasks.value.slice(0, 29),
|
|
||||||
];
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showToast(response.data?.message || "获取下载链接失败", "error");
|
|
||||||
|
updateTransferTask(taskId, { speed: "-", progress: 0, status: "failed" });
|
||||||
|
showToast(nativeResponse.data?.message || "原生下载失败", "error");
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectFile(item: FileItem) {
|
function selectFile(item: FileItem) {
|
||||||
@@ -604,6 +633,11 @@ async function openItem(item: FileItem) {
|
|||||||
if (item.isDirectory || item.type === "directory") {
|
if (item.isDirectory || item.type === "directory") {
|
||||||
const nextPath = buildItemPath(item);
|
const nextPath = buildItemPath(item);
|
||||||
await loadFiles(nextPath);
|
await loadFiles(nextPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previewUrl = await getSignedUrlForItem(item, "preview");
|
||||||
|
if (previewUrl) {
|
||||||
|
await openUrl(previewUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,7 +955,7 @@ onBeforeUnmount(() => {
|
|||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<button class="context-item" @click="executeContextAction('open')">
|
<button class="context-item" @click="executeContextAction('open')">
|
||||||
{{ contextMenu.item.isDirectory ? "打开文件夹" : "打开/下载" }}
|
{{ contextMenu.item.isDirectory ? "打开文件夹" : "打开预览" }}
|
||||||
</button>
|
</button>
|
||||||
<button v-if="!contextMenu.item.isDirectory" class="context-item" @click="executeContextAction('download')">下载文件</button>
|
<button v-if="!contextMenu.item.isDirectory" class="context-item" @click="executeContextAction('download')">下载文件</button>
|
||||||
<button class="context-item" @click="executeContextAction('rename')">重命名</button>
|
<button class="context-item" @click="executeContextAction('rename')">重命名</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user