feat(desktop): add drag-and-drop upload for file view
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user