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

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