feat(desktop): align requested 4/5/6/8 with resume queue batch and one-click update

This commit is contained in:
2026-02-18 20:26:16 +08:00
parent 32a66e6c77
commit c668c88f7f
2 changed files with 711 additions and 74 deletions

View File

@@ -5,6 +5,7 @@ use serde_json::{Map, Value};
use std::env; use std::env;
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{Duration, UNIX_EPOCH}; use std::time::{Duration, UNIX_EPOCH};
@@ -108,6 +109,35 @@ fn alloc_download_path(download_dir: &Path, preferred_name: &str) -> PathBuf {
first first
} }
fn build_download_resume_temp_path(download_dir: &Path, preferred_name: &str, url: &str) -> PathBuf {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
use std::hash::{Hash, Hasher};
preferred_name.hash(&mut hasher);
url.hash(&mut hasher);
let digest = format!("{:016x}", hasher.finish());
let safe_name = sanitize_file_name(preferred_name);
let temp_name = format!(".{}.{}.part", safe_name, digest);
download_dir.join(temp_name)
}
async fn parse_response_as_bridge(response: reqwest::Response) -> Result<BridgeResponse, String> {
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,
})
}
async fn request_json( async fn request_json(
client: &reqwest::Client, client: &reqwest::Client,
method: Method, method: Method,
@@ -528,22 +558,6 @@ async fn api_native_download(
return Err("下载地址不能为空".to_string()); 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 let preferred_name = file_name
.as_deref() .as_deref()
.map(|name| name.trim()) .map(|name| name.trim())
@@ -556,11 +570,75 @@ async fn api_native_download(
.map_err(|err| format!("创建下载目录失败: {}", err))?; .map_err(|err| format!("创建下载目录失败: {}", err))?;
} }
let save_path = alloc_download_path(&download_dir, preferred_name); let resume_temp_path = build_download_resume_temp_path(&download_dir, preferred_name, &trimmed_url);
let mut target_file = let existing_size = if resume_temp_path.exists() {
fs::File::create(&save_path).map_err(|err| format!("创建文件失败: {}", err))?; fs::metadata(&resume_temp_path)
.ok()
.map(|meta| meta.len())
.unwrap_or(0)
} else {
0
};
let mut downloaded_bytes: u64 = 0; let mut request = state.client.get(&trimmed_url);
if existing_size > 0 {
request = request.header("Range", format!("bytes={}-", existing_size));
}
let response = request
.send()
.await
.map_err(|err| format!("下载请求失败: {}", err))?;
let status = response.status();
if status == reqwest::StatusCode::RANGE_NOT_SATISFIABLE && existing_size > 0 {
let save_path = alloc_download_path(&download_dir, preferred_name);
fs::rename(&resume_temp_path, &save_path)
.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(existing_size)),
);
data.insert(
"resumedBytes".to_string(),
Value::Number(serde_json::Number::from(existing_size)),
);
return Ok(BridgeResponse {
ok: true,
status: 200,
data: Value::Object(data),
});
}
if !status.is_success() {
return Ok(BridgeResponse {
ok: false,
status: status.as_u16(),
data: fallback_json(status, "下载失败"),
});
}
let append_mode = existing_size > 0 && status == reqwest::StatusCode::PARTIAL_CONTENT;
if !append_mode && resume_temp_path.exists() {
fs::remove_file(&resume_temp_path)
.map_err(|err| format!("重置断点下载文件失败: {}", err))?;
}
let mut target_file = fs::OpenOptions::new()
.create(true)
.write(true)
.append(append_mode)
.truncate(!append_mode)
.open(&resume_temp_path)
.map_err(|err| format!("创建文件失败: {}", err))?;
let mut downloaded_bytes: u64 = if append_mode { existing_size } else { 0 };
let mut stream = response; let mut stream = response;
while let Some(chunk) = stream while let Some(chunk) = stream
.chunk() .chunk()
@@ -577,6 +655,10 @@ async fn api_native_download(
.flush() .flush()
.map_err(|err| format!("刷新文件失败: {}", err))?; .map_err(|err| format!("刷新文件失败: {}", err))?;
let save_path = alloc_download_path(&download_dir, preferred_name);
fs::rename(&resume_temp_path, &save_path)
.map_err(|err| format!("保存下载文件失败: {}", err))?;
let mut data = Map::new(); let mut data = Map::new();
data.insert("success".to_string(), Value::Bool(true)); data.insert("success".to_string(), Value::Bool(true));
data.insert( data.insert(
@@ -587,6 +669,10 @@ async fn api_native_download(
"downloadedBytes".to_string(), "downloadedBytes".to_string(),
Value::Number(serde_json::Number::from(downloaded_bytes)), Value::Number(serde_json::Number::from(downloaded_bytes)),
); );
data.insert(
"resumedBytes".to_string(),
Value::Number(serde_json::Number::from(if append_mode { existing_size } else { 0 })),
);
Ok(BridgeResponse { Ok(BridgeResponse {
ok: true, ok: true,
@@ -711,6 +797,162 @@ async fn api_list_local_files(dir_path: String) -> Result<BridgeResponse, String
}) })
} }
#[tauri::command]
async fn api_upload_file_resumable(
state: tauri::State<'_, ApiState>,
base_url: String,
file_path: String,
target_path: String,
chunk_size: Option<u64>,
) -> 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 metadata = fs::metadata(&source_path).map_err(|err| format!("读取文件信息失败: {}", err))?;
let file_size = metadata.len();
if file_size == 0 {
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 normalized_target = if target_path.trim().is_empty() {
"/".to_string()
} else {
target_path
};
let effective_chunk = chunk_size.unwrap_or(4 * 1024 * 1024).clamp(256 * 1024, 32 * 1024 * 1024);
let csrf_token = fetch_csrf_token(&state.client, &base_url).await?;
let mut init_body = Map::new();
init_body.insert("filename".to_string(), Value::String(file_name.clone()));
init_body.insert("path".to_string(), Value::String(normalized_target));
init_body.insert(
"size".to_string(),
Value::Number(serde_json::Number::from(file_size)),
);
init_body.insert(
"chunk_size".to_string(),
Value::Number(serde_json::Number::from(effective_chunk)),
);
let init_resp = request_json(
&state.client,
Method::POST,
join_api_url(&base_url, "/api/upload/resumable/init"),
Some(Value::Object(init_body)),
csrf_token.clone(),
)
.await?;
if !init_resp.ok || !init_resp.data.get("success").and_then(Value::as_bool).unwrap_or(false) {
return Ok(init_resp);
}
let session_id = init_resp
.data
.get("session_id")
.and_then(Value::as_str)
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.ok_or_else(|| "分片上传会话创建失败".to_string())?;
let server_chunk_size = init_resp
.data
.get("chunk_size")
.and_then(Value::as_u64)
.unwrap_or(effective_chunk)
.max(1);
let total_chunks = init_resp
.data
.get("total_chunks")
.and_then(Value::as_u64)
.unwrap_or_else(|| ((file_size + server_chunk_size - 1) / server_chunk_size).max(1));
let uploaded_chunks: std::collections::HashSet<u64> = init_resp
.data
.get("uploaded_chunks")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(Value::as_u64)
.collect::<std::collections::HashSet<u64>>()
})
.unwrap_or_default();
let mut source = fs::File::open(&source_path).map_err(|err| format!("打开文件失败: {}", err))?;
for chunk_index in 0..total_chunks {
if uploaded_chunks.contains(&chunk_index) {
continue;
}
let offset = chunk_index * server_chunk_size;
let remaining = file_size.saturating_sub(offset);
if remaining == 0 {
break;
}
let read_size = std::cmp::min(remaining, server_chunk_size) as usize;
source
.seek(SeekFrom::Start(offset))
.map_err(|err| format!("读取分片失败: {}", err))?;
let mut buf = vec![0_u8; read_size];
source
.read_exact(&mut buf)
.map_err(|err| format!("读取分片失败: {}", err))?;
let chunk_part_name = format!("{}.part{}", file_name, chunk_index);
let multipart = reqwest::multipart::Form::new()
.text("session_id", session_id.clone())
.text("chunk_index", chunk_index.to_string())
.part(
"chunk",
reqwest::multipart::Part::bytes(buf).file_name(chunk_part_name),
);
let mut request = state
.client
.post(join_api_url(&base_url, "/api/upload/resumable/chunk"))
.header("Accept", "application/json")
.timeout(Duration::from_secs(60 * 10))
.multipart(multipart);
if let Some(token) = csrf_token.clone() {
request = request.header("X-CSRF-Token", token);
}
let chunk_resp = request
.send()
.await
.map_err(|err| format!("上传分片失败: {}", err))?;
let chunk_bridge = parse_response_as_bridge(chunk_resp).await?;
if !chunk_bridge.ok || !chunk_bridge.data.get("success").and_then(Value::as_bool).unwrap_or(false) {
return Ok(chunk_bridge);
}
}
let mut complete_body = Map::new();
complete_body.insert("session_id".to_string(), Value::String(session_id));
request_json(
&state.client,
Method::POST,
join_api_url(&base_url, "/api/upload/resumable/complete"),
Some(Value::Object(complete_body)),
csrf_token,
)
.await
}
#[tauri::command] #[tauri::command]
async fn api_upload_file( async fn api_upload_file(
state: tauri::State<'_, ApiState>, state: tauri::State<'_, ApiState>,
@@ -820,6 +1062,7 @@ pub fn run() {
api_native_download, api_native_download,
api_check_client_update, api_check_client_update,
api_list_local_files, api_list_local_files,
api_upload_file_resumable,
api_upload_file api_upload_file
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
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 { openPath, openUrl } from "@tauri-apps/plugin-opener";
import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { getVersion } from "@tauri-apps/api/app"; import { getVersion } from "@tauri-apps/api/app";
import type { UnlistenFn } from "@tauri-apps/api/event"; import type { UnlistenFn } from "@tauri-apps/api/event";
@@ -47,6 +47,22 @@ type LocalSyncFileItem = {
modifiedMs: number; modifiedMs: number;
}; };
type TransferTaskKind = "upload" | "download";
type TransferTaskStatus = "queued" | "uploading" | "downloading" | "done" | "failed";
type TransferTask = {
id: string;
kind: TransferTaskKind;
name: string;
speed: string;
progress: number;
status: TransferTaskStatus;
note?: string;
filePath?: string;
targetPath?: string;
downloadUrl?: string;
fileName?: string;
};
const nav = ref<NavKey>("files"); const nav = ref<NavKey>("files");
const authenticated = ref(false); const authenticated = ref(false);
const user = ref<Record<string, any> | null>(null); const user = ref<Record<string, any> | null>(null);
@@ -78,9 +94,14 @@ const files = ref<FileItem[]>([]);
const selectedFileName = ref(""); const selectedFileName = ref("");
const searchKeyword = ref(""); const searchKeyword = ref("");
const shares = ref<ShareItem[]>([]); const shares = ref<ShareItem[]>([]);
const batchMode = ref(false);
const batchSelectedNames = ref<string[]>([]);
const transferTasks = ref<{ id: string; name: string; speed: string; progress: number; status: string; note?: string }[]>([]); const transferTasks = ref<TransferTask[]>([]);
const sharesLoading = ref(false); const sharesLoading = ref(false);
const transferQueue = reactive({
paused: false,
});
const fileViewState = reactive({ const fileViewState = reactive({
filter: "all", filter: "all",
sortBy: "modifiedAt", sortBy: "modifiedAt",
@@ -240,6 +261,11 @@ const selectedFile = computed(() => {
return files.value.find((item) => item.name === selectedFileName.value) || null; return files.value.find((item) => item.name === selectedFileName.value) || null;
}); });
const batchSelectedItems = computed(() => {
const selectedSet = new Set(batchSelectedNames.value);
return files.value.filter((item) => selectedSet.has(item.name));
});
const fileStats = computed(() => { const fileStats = computed(() => {
const folders = files.value.filter((item) => item.isDirectory || item.type === "directory").length; const folders = files.value.filter((item) => item.isDirectory || item.type === "directory").length;
const docs = files.value.length - folders; const docs = files.value.length - folders;
@@ -393,18 +419,19 @@ function showToast(message: string, type = "info") {
}, 2500); }, 2500);
} }
function prependTransferTask(task: { id: string; name: string; speed: string; progress: number; status: string; note?: string }) { function prependTransferTask(task: TransferTask) {
transferTasks.value = [task, ...transferTasks.value.slice(0, 49)]; transferTasks.value = [task, ...transferTasks.value.slice(0, 119)];
} }
function updateTransferTask( function updateTransferTask(
id: string, id: string,
patch: Partial<{ name: string; speed: string; progress: number; status: string; note?: string }>, patch: Partial<TransferTask>,
) { ) {
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) { function getTaskStatusLabel(status: string) {
if (status === "queued") return "排队中";
if (status === "uploading") return "上传中"; if (status === "uploading") return "上传中";
if (status === "downloading") return "下载中"; if (status === "downloading") return "下载中";
if (status === "done") return "已完成"; if (status === "done") return "已完成";
@@ -412,6 +439,52 @@ function getTaskStatusLabel(status: string) {
return status; return status;
} }
function isTaskRunning(status: string) {
return status === "uploading" || status === "downloading";
}
function removeTransferTask(taskId: string) {
transferTasks.value = transferTasks.value.filter((task) => task.id !== taskId);
}
function clearCompletedTransferTasks() {
transferTasks.value = transferTasks.value.filter((task) => task.status !== "done" && task.status !== "failed");
}
function toggleTransferQueuePause() {
transferQueue.paused = !transferQueue.paused;
showToast(transferQueue.paused ? "传输队列已暂停(进行中的任务会跑完)" : "传输队列已恢复", "info");
}
async function waitForTransferQueue() {
while (transferQueue.paused) {
await new Promise((resolve) => setTimeout(resolve, 240));
}
}
function isBatchSelected(name: string) {
return batchSelectedNames.value.includes(name);
}
function toggleBatchMode() {
batchMode.value = !batchMode.value;
batchSelectedNames.value = [];
}
function clearBatchSelection() {
batchSelectedNames.value = [];
}
function toggleBatchSelection(item: FileItem) {
const name = item.name;
if (!name) return;
if (isBatchSelected(name)) {
batchSelectedNames.value = batchSelectedNames.value.filter((v) => v !== name);
return;
}
batchSelectedNames.value = [...batchSelectedNames.value, name];
}
function normalizeRelativePath(rawPath: string) { function normalizeRelativePath(rawPath: string) {
return String(rawPath || "").replace(/\\/g, "/").replace(/^\/+/, ""); return String(rawPath || "").replace(/\\/g, "/").replace(/^\/+/, "");
} }
@@ -563,12 +636,58 @@ async function checkClientUpdate(showResultToast = true) {
} }
} }
async function openUpdateDownloadUrl() { async function installLatestUpdate() {
if (!updateState.downloadUrl) { if (!updateState.downloadUrl) {
showToast("当前没有可用的更新下载地址", "info"); showToast("当前没有可用的更新下载地址", "info");
return; return;
} }
await openUrl(updateState.downloadUrl);
const taskId = `UPD-${Date.now()}`;
const installerName = `wanwan-cloud-desktop_v${updateState.latestVersion || updateState.currentVersion}.exe`;
prependTransferTask({
id: taskId,
kind: "download",
name: installerName,
speed: "更新包下载",
progress: 2,
status: "downloading",
note: "正在下载升级安装包",
downloadUrl: updateState.downloadUrl,
fileName: installerName,
});
const response = await invokeBridge("api_native_download", {
url: updateState.downloadUrl,
fileName: installerName,
});
if (response.ok && response.data?.success) {
updateTransferTask(taskId, {
speed: "-",
progress: 100,
status: "done",
note: "更新包已下载,准备启动安装",
});
const savePath = String(response.data?.savePath || "").trim();
if (savePath) {
try {
await openPath(savePath);
} catch (error) {
console.error("open installer failed", error);
}
}
showToast("更新包已下载,已尝试启动安装程序", "success");
return;
}
const message = String(response.data?.message || "下载更新包失败");
updateTransferTask(taskId, {
speed: "-",
progress: 0,
status: "failed",
note: message,
});
showToast(message, "error");
} }
async function chooseSyncDirectory() { async function chooseSyncDirectory() {
@@ -587,6 +706,34 @@ async function chooseSyncDirectory() {
} }
} }
async function uploadFileWithResume(filePath: string, targetPath: string) {
const resumableResponse = await invokeBridge("api_upload_file_resumable", {
baseUrl: appConfig.baseUrl,
filePath,
targetPath,
chunkSize: 4 * 1024 * 1024,
});
if (resumableResponse.ok && resumableResponse.data?.success) {
return resumableResponse;
}
const message = String(resumableResponse.data?.message || "");
if (
message.includes("当前存储模式不支持分片上传")
|| message.includes("分片上传会话")
|| message.includes("上传会话")
) {
return await invokeBridge("api_upload_file", {
baseUrl: appConfig.baseUrl,
filePath,
targetPath,
});
}
return resumableResponse;
}
function rebuildSyncScheduler() { function rebuildSyncScheduler() {
clearSyncScheduler(); clearSyncScheduler();
if (!authenticated.value || !syncState.autoEnabled || !syncState.localDir.trim()) { if (!authenticated.value || !syncState.autoEnabled || !syncState.localDir.trim()) {
@@ -665,6 +812,7 @@ async function runSyncOnce(trigger: "manual" | "auto" = "manual") {
const successPaths = new Set<string>(); const successPaths = new Set<string>();
for (let index = 0; index < changedItems.length; index += 1) { for (let index = 0; index < changedItems.length; index += 1) {
await waitForTransferQueue();
const item = changedItems[index]; const item = changedItems[index];
const relPath = normalizeRelativePath(item.relativePath); const relPath = normalizeRelativePath(item.relativePath);
const fileName = extractFileNameFromPath(relPath) || `同步文件${index + 1}`; const fileName = extractFileNameFromPath(relPath) || `同步文件${index + 1}`;
@@ -675,18 +823,19 @@ async function runSyncOnce(trigger: "manual" | "auto" = "manual") {
prependTransferTask({ prependTransferTask({
id: taskId, id: taskId,
kind: "upload",
name: fileName, name: fileName,
speed: trigger === "auto" ? "自动同步" : "手动同步", speed: trigger === "auto" ? "自动同步" : "手动同步",
progress: 10, progress: 2,
status: "uploading", status: "queued",
note: targetPath, note: targetPath,
});
const resp = await invokeBridge("api_upload_file", {
baseUrl: appConfig.baseUrl,
filePath: item.path, filePath: item.path,
targetPath, targetPath,
fileName,
}); });
updateTransferTask(taskId, { status: "uploading", speed: "上传中", progress: 10 });
const resp = await uploadFileWithResume(item.path, targetPath);
if (resp.ok && resp.data?.success) { if (resp.ok && resp.data?.success) {
syncState.uploadedCount += 1; syncState.uploadedCount += 1;
@@ -751,6 +900,7 @@ async function loadFiles(targetPath = pathState.currentPath) {
pathState.currentPath = normalizePath(response.data.path || normalizedPath); pathState.currentPath = normalizePath(response.data.path || normalizedPath);
pathState.mode = "directory"; pathState.mode = "directory";
selectedFileName.value = files.value[0]?.name || ""; selectedFileName.value = files.value[0]?.name || "";
batchSelectedNames.value = [];
} else { } else {
pathState.error = response.data?.message || "读取文件列表失败"; pathState.error = response.data?.message || "读取文件列表失败";
showToast(pathState.error, "error"); showToast(pathState.error, "error");
@@ -785,7 +935,7 @@ async function getSignedUrlForItem(item: FileItem, mode: "download" | "preview")
return ""; return "";
} }
async function createShareForItem(current: FileItem) { async function createShareForItem(current: FileItem, silent = false) {
const response = await invokeBridge("api_create_share", { const response = await invokeBridge("api_create_share", {
baseUrl: appConfig.baseUrl, baseUrl: appConfig.baseUrl,
shareType: current.isDirectory || current.type === "directory" ? "directory" : "file", shareType: current.isDirectory || current.type === "directory" ? "directory" : "file",
@@ -796,22 +946,29 @@ async function createShareForItem(current: FileItem) {
}); });
if (response.ok && response.data?.success) { if (response.ok && response.data?.success) {
showToast("分享创建成功", "success"); if (!silent) {
showToast("分享创建成功", "success");
}
await loadShares(true); await loadShares(true);
const shareUrl = String(response.data.share_url || ""); const shareUrl = String(response.data.share_url || "");
if (shareUrl) { if (!silent && shareUrl) {
await copyText(shareUrl, "分享链接已复制"); await copyText(shareUrl, "分享链接已复制");
} }
return; return;
} }
showToast(response.data?.message || "创建分享失败", "error"); if (!silent) {
showToast(response.data?.message || "创建分享失败", "error");
}
throw new Error(String(response.data?.message || "创建分享失败"));
} }
async function createDirectLinkForItem(current: FileItem) { async function createDirectLinkForItem(current: FileItem, silent = false) {
if (current.isDirectory || current.type === "directory") { if (current.isDirectory || current.type === "directory") {
showToast("文件夹不支持生成直链", "info"); if (!silent) {
return; showToast("文件夹不支持生成直链", "info");
}
throw new Error("文件夹不支持生成直链");
} }
const response = await invokeBridge("api_create_direct_link", { const response = await invokeBridge("api_create_direct_link", {
@@ -822,15 +979,20 @@ async function createDirectLinkForItem(current: FileItem) {
}); });
if (response.ok && response.data?.success) { if (response.ok && response.data?.success) {
showToast("直链创建成功", "success"); if (!silent) {
showToast("直链创建成功", "success");
}
const directUrl = String(response.data.direct_url || ""); const directUrl = String(response.data.direct_url || "");
if (directUrl) { if (!silent && directUrl) {
await copyText(directUrl, "直链已复制"); await copyText(directUrl, "直链已复制");
} }
return; return;
} }
showToast(response.data?.message || "创建直链失败", "error"); if (!silent) {
showToast(response.data?.message || "创建直链失败", "error");
}
throw new Error(String(response.data?.message || "创建直链失败"));
} }
async function deleteShare(share: ShareItem) { async function deleteShare(share: ShareItem) {
@@ -959,6 +1121,8 @@ async function handleLogout() {
user.value = null; user.value = null;
files.value = []; files.value = [];
selectedFileName.value = ""; selectedFileName.value = "";
batchMode.value = false;
batchSelectedNames.value = [];
loginForm.password = ""; loginForm.password = "";
nav.value = "files"; nav.value = "files";
syncState.localDir = ""; syncState.localDir = "";
@@ -1015,14 +1179,16 @@ async function renameSelected(target?: FileItem | null) {
showToast(response.data?.message || "重命名失败", "error"); showToast(response.data?.message || "重命名失败", "error");
} }
async function deleteSelected(target?: FileItem | null) { async function deleteSelected(target?: FileItem | null, silent = false) {
const current = target || selectedFile.value; const current = target || selectedFile.value;
if (!current) { if (!current) {
showToast("请先选中文件或文件夹", "info"); if (!silent) showToast("请先选中文件或文件夹", "info");
return; return;
} }
const confirmed = window.confirm(`确认删除「${current.displayName || current.name}」吗?`); if (!silent) {
if (!confirmed) return; const confirmed = window.confirm(`确认删除「${current.displayName || current.name}」吗?`);
if (!confirmed) return;
}
const response = await invokeBridge("api_delete_file", { const response = await invokeBridge("api_delete_file", {
baseUrl: appConfig.baseUrl, baseUrl: appConfig.baseUrl,
@@ -1030,11 +1196,16 @@ async function deleteSelected(target?: FileItem | null) {
fileName: current.name, fileName: current.name,
}); });
if (response.ok && response.data?.success) { if (response.ok && response.data?.success) {
showToast("删除成功", "success"); if (!silent) {
await loadFiles(pathState.currentPath); showToast("删除成功", "success");
await loadFiles(pathState.currentPath);
}
return; return;
} }
showToast(response.data?.message || "删除失败", "error"); if (!silent) {
showToast(response.data?.message || "删除失败", "error");
}
throw new Error(String(response.data?.message || "删除失败"));
} }
async function downloadSelected(target?: FileItem | null) { async function downloadSelected(target?: FileItem | null) {
@@ -1053,11 +1224,17 @@ async function downloadSelected(target?: FileItem | null) {
const taskId = `D-${Date.now()}`; const taskId = `D-${Date.now()}`;
prependTransferTask({ prependTransferTask({
id: taskId, id: taskId,
kind: "download",
name: current.displayName || current.name, name: current.displayName || current.name,
speed: "原生下载", speed: "等待下载",
progress: 1, progress: 1,
status: "downloading", status: "queued",
downloadUrl: signedUrl,
fileName: current.displayName || current.name,
note: "支持断点续传",
}); });
await waitForTransferQueue();
updateTransferTask(taskId, { speed: "原生下载", status: "downloading", progress: 10, note: "下载中" });
const nativeResponse = await invokeBridge("api_native_download", { const nativeResponse = await invokeBridge("api_native_download", {
url: signedUrl, url: signedUrl,
@@ -1065,7 +1242,9 @@ 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", note: "下载成功" }); const resumedBytes = Number(nativeResponse.data?.resumedBytes || 0);
const resumeText = resumedBytes > 0 ? `,已续传 ${formatBytes(resumedBytes)}` : "";
updateTransferTask(taskId, { speed: "-", progress: 100, status: "done", note: `下载成功${resumeText}` });
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;
@@ -1077,10 +1256,15 @@ async function downloadSelected(target?: FileItem | null) {
} }
function selectFile(item: FileItem) { function selectFile(item: FileItem) {
if (batchMode.value) {
toggleBatchSelection(item);
return;
}
selectedFileName.value = item.name; selectedFileName.value = item.name;
} }
async function openItem(item: FileItem) { async function openItem(item: FileItem) {
if (batchMode.value) return;
selectFile(item); selectFile(item);
if (item.isDirectory || item.type === "directory") { if (item.isDirectory || item.type === "directory") {
const nextPath = buildItemPath(item); const nextPath = buildItemPath(item);
@@ -1103,6 +1287,7 @@ function closeContextMenu() {
} }
function openContextMenu(event: MouseEvent, item: FileItem) { function openContextMenu(event: MouseEvent, item: FileItem) {
if (batchMode.value) return;
selectFile(item); selectFile(item);
contextMenu.item = item; contextMenu.item = item;
const maxX = window.innerWidth - 220; const maxX = window.innerWidth - 220;
@@ -1117,27 +1302,155 @@ async function executeContextAction(action: "open" | "download" | "rename" | "de
closeContextMenu(); closeContextMenu();
if (!item) return; if (!item) return;
if (action === "open") { try {
await openItem(item); if (action === "open") {
await openItem(item);
return;
}
if (action === "download") {
await downloadSelected(item);
return;
}
if (action === "rename") {
await renameSelected(item);
return;
}
if (action === "delete") {
await deleteSelected(item);
return;
}
if (action === "share") {
await createShareForItem(item);
return;
}
await createDirectLinkForItem(item);
} catch {
// errors already handled by action method
}
}
async function retryTransferTask(taskId: string) {
const task = transferTasks.value.find((item) => item.id === taskId);
if (!task) return;
if (isTaskRunning(task.status)) return;
if (task.status === "done") return;
if (task.kind === "upload") {
if (!task.filePath || !task.targetPath) {
updateTransferTask(taskId, { status: "failed", note: "缺少上传任务参数" });
return;
}
await waitForTransferQueue();
updateTransferTask(taskId, { status: "uploading", speed: "重试上传", progress: 10, note: "正在重试" });
const response = await uploadFileWithResume(task.filePath, task.targetPath);
if (response.ok && response.data?.success) {
updateTransferTask(taskId, { status: "done", speed: "-", progress: 100, note: "重试成功" });
if (nav.value === "files") {
await loadFiles(pathState.currentPath);
}
return;
}
updateTransferTask(taskId, {
status: "failed",
speed: "-",
progress: 0,
note: String(response.data?.message || "重试上传失败"),
});
return; return;
} }
if (action === "download") {
await downloadSelected(item); if (!task.downloadUrl) {
updateTransferTask(taskId, { status: "failed", note: "缺少下载地址" });
return; return;
} }
if (action === "rename") { await waitForTransferQueue();
await renameSelected(item); updateTransferTask(taskId, { status: "downloading", speed: "重试下载", progress: 10, note: "正在重试" });
const response = await invokeBridge("api_native_download", {
url: task.downloadUrl,
fileName: task.fileName || task.name,
});
if (response.ok && response.data?.success) {
const resumedBytes = Number(response.data?.resumedBytes || 0);
const resumeText = resumedBytes > 0 ? `,已续传 ${formatBytes(resumedBytes)}` : "";
updateTransferTask(taskId, { status: "done", speed: "-", progress: 100, note: `下载成功${resumeText}` });
return; return;
} }
if (action === "delete") { updateTransferTask(taskId, {
await deleteSelected(item); status: "failed",
speed: "-",
progress: 0,
note: String(response.data?.message || "重试下载失败"),
});
}
async function batchDeleteSelected() {
if (!batchMode.value || batchSelectedItems.value.length === 0) {
showToast("请先勾选批量文件", "info");
return; return;
} }
if (action === "share") { const confirmed = window.confirm(`确认批量删除 ${batchSelectedItems.value.length} 个项目吗?`);
await createShareForItem(item); if (!confirmed) return;
let success = 0;
let failed = 0;
const items = [...batchSelectedItems.value];
for (const item of items) {
try {
await deleteSelected(item, true);
success += 1;
} catch {
failed += 1;
}
}
await loadFiles(pathState.currentPath);
clearBatchSelection();
const failedText = failed > 0 ? `,失败 ${failed}` : "";
showToast(`批量删除完成:成功 ${success}${failedText}`, failed > 0 ? "info" : "success");
}
async function batchShareSelected() {
if (!batchMode.value || batchSelectedItems.value.length === 0) {
showToast("请先勾选批量文件", "info");
return; return;
} }
await createDirectLinkForItem(item);
let success = 0;
let failed = 0;
const items = [...batchSelectedItems.value];
for (const item of items) {
try {
await createShareForItem(item, true);
success += 1;
} catch {
failed += 1;
}
}
await loadShares(true);
const failedText = failed > 0 ? `,失败 ${failed}` : "";
showToast(`批量分享完成:成功 ${success}${failedText}`, failed > 0 ? "info" : "success");
}
async function batchDirectLinkSelected() {
if (!batchMode.value || batchSelectedItems.value.length === 0) {
showToast("请先勾选批量文件", "info");
return;
}
let success = 0;
let failed = 0;
const items = [...batchSelectedItems.value];
for (const item of items) {
try {
await createDirectLinkForItem(item, true);
success += 1;
} catch {
failed += 1;
}
}
await loadShares(true);
const failedText = failed > 0 ? `,失败 ${failed}` : "";
showToast(`批量直链完成:成功 ${success}${failedText}`, failed > 0 ? "info" : "success");
} }
function handleGlobalClick() { function handleGlobalClick() {
@@ -1181,23 +1494,25 @@ async function uploadDroppedFiles(paths: string[]) {
let successCount = 0; let successCount = 0;
for (let index = 0; index < uniquePaths.length; index += 1) { for (let index = 0; index < uniquePaths.length; index += 1) {
await waitForTransferQueue();
const filePath = uniquePaths[index]; const filePath = uniquePaths[index];
const displayName = extractFileNameFromPath(filePath) || `文件${index + 1}`; const displayName = extractFileNameFromPath(filePath) || `文件${index + 1}`;
const taskId = `U-${Date.now()}-${index}`; const taskId = `U-${Date.now()}-${index}`;
prependTransferTask({ prependTransferTask({
id: taskId, id: taskId,
kind: "upload",
name: displayName, name: displayName,
speed: "上传", speed: "等待上传",
progress: 8, progress: 2,
status: "uploading", status: "queued",
});
const response = await invokeBridge("api_upload_file", {
baseUrl: appConfig.baseUrl,
filePath, filePath,
targetPath: pathState.currentPath, targetPath: pathState.currentPath,
fileName: displayName,
}); });
updateTransferTask(taskId, { status: "uploading", speed: "上传中", progress: 10 });
const response = await uploadFileWithResume(filePath, pathState.currentPath);
if (response.ok && response.data?.success) { if (response.ok && response.data?.success) {
successCount += 1; successCount += 1;
@@ -1411,10 +1726,19 @@ onBeforeUnmount(() => {
<option v-for="opt in fileSortOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option> <option v-for="opt in fileSortOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select> </select>
<button class="action-btn" @click="toggleFileSortOrder">{{ fileViewState.sortOrder === "asc" ? "升序" : "降序" }}</button> <button class="action-btn" @click="toggleFileSortOrder">{{ fileViewState.sortOrder === "asc" ? "升序" : "降序" }}</button>
<button class="action-btn" :class="{ active: batchMode }" @click="toggleBatchMode">{{ batchMode ? "退出批量" : "批量选择" }}</button>
<button v-if="batchMode" class="action-btn" @click="clearBatchSelection">清空勾选</button>
<button v-if="batchMode" class="action-btn danger" :disabled="batchSelectedItems.length === 0" @click="batchDeleteSelected">批量删除</button>
<button v-if="batchMode" class="action-btn" :disabled="batchSelectedItems.length === 0" @click="batchShareSelected">批量分享</button>
<button v-if="batchMode" class="action-btn" :disabled="batchSelectedItems.length === 0" @click="batchDirectLinkSelected">批量直链</button>
<button class="action-btn" @click="runGlobalSearch">搜索</button> <button class="action-btn" @click="runGlobalSearch">搜索</button>
<button class="action-btn" @click="createFolder">新建文件夹</button> <button class="action-btn" @click="createFolder">新建文件夹</button>
<button class="action-btn" @click="loadFiles(pathState.currentPath)">刷新</button> <button class="action-btn" @click="loadFiles(pathState.currentPath)">刷新</button>
</template> </template>
<template v-else-if="nav === 'transfers'">
<button class="action-btn" @click="toggleTransferQueuePause">{{ transferQueue.paused ? "继续队列" : "暂停队列" }}</button>
<button class="action-btn" @click="clearCompletedTransferTasks">清理已结束</button>
</template>
<template v-else-if="nav === 'shares'"> <template v-else-if="nav === 'shares'">
<button class="action-btn" @click="loadShares()">刷新分享</button> <button class="action-btn" @click="loadShares()">刷新分享</button>
</template> </template>
@@ -1428,7 +1752,7 @@ onBeforeUnmount(() => {
<button class="action-btn" :disabled="updateState.checking" @click="checkClientUpdate()"> <button class="action-btn" :disabled="updateState.checking" @click="checkClientUpdate()">
{{ updateState.checking ? "检查中..." : "检查更新" }} {{ updateState.checking ? "检查中..." : "检查更新" }}
</button> </button>
<button class="action-btn" :disabled="!updateState.available || !updateState.downloadUrl" @click="openUpdateDownloadUrl">立即更新</button> <button class="action-btn" :disabled="!updateState.available || !updateState.downloadUrl" @click="installLatestUpdate">立即更新</button>
</template> </template>
</div> </div>
</header> </header>
@@ -1451,11 +1775,14 @@ onBeforeUnmount(() => {
:key="item.name" :key="item.name"
type="button" type="button"
class="file-card" class="file-card"
:class="{ selected: selectedFileName === item.name }" :class="{ selected: selectedFileName === item.name, batchSelected: isBatchSelected(item.name) }"
@click="selectFile(item)" @click="selectFile(item)"
@dblclick="openItem(item)" @dblclick="openItem(item)"
@contextmenu.prevent="openContextMenu($event, item)" @contextmenu.prevent="openContextMenu($event, item)"
> >
<div v-if="batchMode" class="batch-check" :class="{ active: isBatchSelected(item.name) }">
{{ isBatchSelected(item.name) ? "" : "" }}
</div>
<div class="file-icon-glyph">{{ fileIcon(item) }}</div> <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-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-meta">{{ fileTypeLabel(item) }} · {{ item.isDirectory ? "-" : (item.sizeFormatted || formatBytes(item.size)) }}</div>
@@ -1476,7 +1803,7 @@ onBeforeUnmount(() => {
<template v-else-if="nav === 'transfers'"> <template v-else-if="nav === 'transfers'">
<div class="panel-head"> <div class="panel-head">
<h3>传输任务</h3> <h3>传输任务</h3>
<span>上传/下载队列</span> <span>{{ transferQueue.paused ? "队列已暂停(进行中任务会继续)" : "上传/下载队列" }}</span>
</div> </div>
<div v-if="transferTasks.length === 0" class="empty-tip">暂无传输任务</div> <div v-if="transferTasks.length === 0" class="empty-tip">暂无传输任务</div>
<div v-else class="task-list"> <div v-else class="task-list">
@@ -1491,6 +1818,10 @@ onBeforeUnmount(() => {
<div class="progress"> <div class="progress">
<div class="bar" :style="{ width: `${task.progress}%` }" /> <div class="bar" :style="{ width: `${task.progress}%` }" />
</div> </div>
<div class="task-actions">
<button v-if="task.status === 'failed'" class="mini-btn" @click="retryTransferTask(task.id)">重试</button>
<button v-if="task.status === 'done' || task.status === 'failed'" class="mini-btn ghost" @click="removeTransferTask(task.id)">移除</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1654,6 +1985,11 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<div v-if="batchMode" class="selected-info">
<h4>批量操作</h4>
<p>已勾选{{ batchSelectedItems.length }} </p>
<p>可执行批量删除批量分享批量直链</p>
</div>
<div v-if="selectedFile" class="selected-info"> <div v-if="selectedFile" class="selected-info">
<h4>{{ selectedFile.displayName || selectedFile.name }}</h4> <h4>{{ selectedFile.displayName || selectedFile.name }}</h4>
<p>类型{{ fileTypeLabel(selectedFile) }}</p> <p>类型{{ fileTypeLabel(selectedFile) }}</p>
@@ -2164,6 +2500,12 @@ select:focus {
cursor: not-allowed; cursor: not-allowed;
} }
.action-btn.active {
border-color: #6a99de;
background: #eaf3ff;
color: #1d4f93;
}
.action-btn.danger { .action-btn.danger {
border-color: #efc3c3; border-color: #efc3c3;
color: #b53f3f; color: #b53f3f;
@@ -2243,6 +2585,7 @@ select:focus {
width: 144px; width: 144px;
height: 144px; height: 144px;
overflow: hidden; overflow: hidden;
position: relative;
} }
.file-card:hover { .file-card:hover {
@@ -2256,6 +2599,32 @@ select:focus {
box-shadow: 0 0 0 2px rgba(29, 111, 255, 0.1); box-shadow: 0 0 0 2px rgba(29, 111, 255, 0.1);
} }
.file-card.batchSelected {
border-color: #3f84ec;
box-shadow: 0 0 0 2px rgba(63, 132, 236, 0.14);
}
.batch-check {
position: absolute;
right: 8px;
top: 8px;
width: 18px;
height: 18px;
border-radius: 6px;
border: 1px solid #c3d3e7;
background: #fff;
color: #2a5da6;
display: grid;
place-items: center;
font-size: 12px;
font-weight: 700;
}
.batch-check.active {
border-color: #3f84ec;
background: #e6f0ff;
}
.file-icon-glyph { .file-icon-glyph {
width: 48px; width: 48px;
height: 48px; height: 48px;
@@ -2342,6 +2711,31 @@ select:focus {
color: #4a6381; color: #4a6381;
} }
.task-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.mini-btn {
height: 26px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid #c5d4e8;
background: #fff;
color: #2f4f74;
cursor: pointer;
font-size: 12px;
}
.mini-btn:hover {
background: #f3f8ff;
}
.mini-btn.ghost {
color: #6882a0;
}
.progress { .progress {
height: 8px; height: 8px;
background: #e4edf9; background: #e4edf9;