ui: 上传入口去掉二级菜单,新增拖拽上传

- 上传按钮拆为两个一步入口:主按钮选文件、箭头选文件夹,
  删除 split 下拉菜单(含 .split-menu 样式与 toggleUploadMenu)。
- 单选压缩包时询问一次是否解压,取代原「上传单文件并解压」菜单项;
  识别范围放宽到 7z / 分卷(复用 isExtractableArchive)。
- 新增拖拽上传:webkitGetAsEntry 递归收集文件与文件夹,
  全屏遮罩提示「松开即上传」。
This commit is contained in:
Songlx516 committed 2026-09-20 13:19:20 +08:00
1 parent 575f94cd39
commit 70eaa1027d
5 files changed
+186 -74

No files matched your search

+6 -7
View File
@@ -54,13 +54,9 @@
</div>
<div class="tool-right">
<button id="refreshBtn" data-i18n="refresh"></button>
<div id="uploadMenu" class="split-button remote-only">
<div id="uploadGroup" class="split-button remote-only">
<button id="uploadBtn" class="split-main" data-i18n="upload"></button>
<button id="uploadMenuBtn" class="split-arrow" type="button" aria-label="Upload menu"></button>
<div class="split-menu">
<button id="uploadFolderBtn" type="button" data-i18n="uploadFolder"></button>
<button id="uploadAndExtractBtn" type="button" data-i18n="extractUpload"></button>
</div>
<button id="uploadFolderBtn" class="split-arrow" type="button" aria-label="Upload folder"></button>
</div>
<button id="newTextBtn" data-i18n="newText"></button>
<button id="mkdirBtn" data-i18n="mkdir"></button>
@@ -68,7 +64,6 @@
</section>
<input id="uploadFiles" type="file" multiple hidden>
<input id="uploadFolder" type="file" multiple webkitdirectory hidden>
<input id="uploadZip" type="file" accept=".zip,.rar,application/zip,application/x-zip-compressed,application/vnd.rar,application/x-rar-compressed" hidden>
<section id="content" class="content">
<table>
@@ -186,6 +181,10 @@
</section>
</div>
<div id="dropUploadOverlay" class="drop-upload-overlay remote-only" hidden>
<div class="drop-upload-message" data-i18n="dropUpload"></div>
</div>
<div id="taskOverlay" class="task-overlay" hidden>
<div class="task-panel">
<div id="tasks" class="tasks"></div>
+2
View File
@@ -6,6 +6,7 @@ window.WFM_LANG = {
download: "Download",
upload: "Upload",
uploadFolder: "Upload Folder",
dropUpload: "Release to upload into this folder",
copying: "Copying",
moving: "Moving",
deleting: "Deleting",
@@ -103,6 +104,7 @@ window.WFM_LANG = {
extractOverwriteAsk: "If a file or folder with the same name already exists in the target:\n\nOK = overwrite same-name files (folders still merge)\nCancel = fail if the target already exists",
extractPasswordAsk: "This archive may be encrypted (e.g. 7zAES).\n\nEnter the password to extract, or leave it empty to try without one.",
extractUploadConfirm: "Upload and extract {name}?\n\nTarget folder: {path}\nThe uploaded archive will be deleted after success.",
extractUploadAsk: "{name} is an archive.\n\nOK: upload and extract here\nCancel: upload only",
extractStarted: "Extraction started: {name}",
extractDone: "Extraction complete: {name}",
extractProgress: "{done} / {total} files",
+2
View File
@@ -6,6 +6,7 @@ window.WFM_LANG = {
download: "下载",
upload: "上传",
uploadFolder: "上传文件夹",
dropUpload: "松开即上传到当前目录",
copying: "复制",
moving: "移动",
deleting: "删除",
@@ -103,6 +104,7 @@ window.WFM_LANG = {
extractOverwriteAsk: "若目标已存在同名文件或目录:\n\n确定 = 覆盖同名文件(目录仍会合并)\n取消 = 若目标已存在则失败",
extractPasswordAsk: "此压缩包可能加密了(如 7zAES)。\n\n输入密码后解压,留空则尝试无密码解压。",
extractUploadConfirm: "上传并解压 {name}?\n\n目标目录:{path}\n成功后将删除上传的压缩包。",
extractUploadAsk: "这是压缩包 {name}。\n\n确定:上传后自动解压到当前目录\n取消:仅上传,不解压",
extractStarted: "已开始解压 {name}",
extractDone: "解压完成:{name}",
extractProgress: "{done} / {total} 个文件",
+32 -31
View File
@@ -286,28 +286,6 @@ body {
border-top: 7px solid #f2f5f7;
}
.split-menu {
position: absolute;
top: 100%;
right: 0;
z-index: 40;
display: none;
min-width: 150px;
padding-top: 6px;
}
.split-menu button {
width: 100%;
min-width: 150px;
margin: 0;
white-space: nowrap;
}
.split-button:hover .split-menu,
.split-button.open .split-menu {
display: block;
}
button,
.button {
height: 54px;
@@ -1057,6 +1035,38 @@ input[type="checkbox"] {
margin-left: 14px;
}
/* Shown while a file drag hovers the window. pointer-events stays off so the
drop still lands on the window listener rather than on this element, and the
z-index sits above the task overlay so the hint is visible during a running
task (the drop itself is ignored while busy). */
.drop-upload-overlay {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 19000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(8, 11, 14, .88);
box-shadow: inset 0 0 0 4px #6fb1ff;
pointer-events: none;
}
.drop-upload-message {
max-width: calc(100% - 48px);
padding: 26px 34px;
border: 1px solid #46515f;
border-radius: 8px;
background: #1b2026;
box-shadow: 0 18px 54px rgba(0, 0, 0, .5);
color: #edf0f2;
font-size: 24px;
line-height: 1.4;
text-align: center;
}
.task-overlay {
position: fixed;
top: 0;
@@ -1349,15 +1359,6 @@ input[type="checkbox"] {
width: 34px;
}
.split-menu {
min-width: 126px;
padding-top: 4px;
}
.split-menu button {
min-width: 126px;
}
.paste-action {
max-width: 100%;
}
+144 -36
View File
@@ -72,14 +72,11 @@ const installPkgBtn = document.getElementById("installPkgBtn");
const extractBtn = document.getElementById("extractBtn");
const clearClipboardBtn = document.getElementById("clearClipboardBtn");
const downloadBtn = document.getElementById("downloadBtn");
const uploadMenuEl = document.getElementById("uploadMenu");
const uploadBtn = document.getElementById("uploadBtn");
const uploadMenuBtn = document.getElementById("uploadMenuBtn");
const uploadFolderBtn = document.getElementById("uploadFolderBtn");
const uploadAndExtractBtn = document.getElementById("uploadAndExtractBtn");
const uploadFilesEl = document.getElementById("uploadFiles");
const uploadFolderEl = document.getElementById("uploadFolder");
const uploadZipEl = document.getElementById("uploadZip");
const dropUploadOverlayEl = document.getElementById("dropUploadOverlay");
const initLoadingEl = document.getElementById("initLoading");
const exitBtn = document.getElementById("exitBtn");
const textEditorOverlayEl = document.getElementById("textEditorOverlay");
@@ -182,6 +179,8 @@ function applyStaticText() {
}
exitBtn.title = t("exit");
exitBtn.setAttribute("aria-label", t("exit"));
uploadFolderBtn.title = t("uploadFolder");
uploadFolderBtn.setAttribute("aria-label", t("uploadFolder"));
parentBtn.title = t("parent");
parentBtn.setAttribute("aria-label", t("parent"));
versionEl.textContent = APP_VERSION_FALLBACK;
@@ -822,6 +821,14 @@ function isExtractableArchive(item) {
return false;
}
// The upload path knows a filename but has no directory entry to inspect, so
// reuse the toolbar's archive test on a synthetic item. Keeps a dropped
// .7z.001 or .part1.rar recognised as "offer to extract" without duplicating
// the rules.
function isExtractableName(name) {
return isExtractableArchive({ type: "-", name: String(name || "") });
}
function isSpecialDirectory(item) {
if (item.type !== "d") return false;
if (item.path === "/data") return true;
@@ -1462,9 +1469,7 @@ function updateButtons() {
downloadBtn.disabled = locked || items.length === 0;
document.getElementById("refreshBtn").disabled = locked;
uploadBtn.disabled = locked;
uploadMenuBtn.disabled = locked;
uploadFolderBtn.disabled = locked;
uploadAndExtractBtn.disabled = locked;
document.getElementById("mkdirBtn").disabled = locked;
newTextBtn.disabled = locked;
for (const button of filesEl.querySelectorAll(".row-action, .mode-action")) button.disabled = locked;
@@ -2031,7 +2036,6 @@ function renderTasks(tasks) {
const isDelete = task.op === "delete";
const isDownload = task.op === "download";
const isChmod = task.op === "chmod";
const isExtract = task.op === "extract";
const isPreparing = (task.op === "copy" || task.op === "move" || isChmod) &&
task.state === "running" && done === 0;
const isFinishing = !isDelete && !isDownload && task.state === "running" && total > 0 && done >= total;
@@ -2064,9 +2068,15 @@ function renderTasks(tasks) {
speedItem.textContent = t("speedLabel") + ": " + (isChmod ?
t("itemsPerSecond", { count: Math.round(speed) }) : formatSpeed(speed));
const progressItem = document.createElement("div");
progressItem.textContent = t("progressLabel") + ": " + (isExtract && Number(task.entries_total) ?
t("extractProgress", { done: Number(task.entries_done || 0), total: Number(task.entries_total || 0) }) :
isChmod ? t("permissionProgress", { done, total }) : formatSize(done) + " / " + formatSize(total));
// Bytes only, for every task type including extract. An entry counter was
// tried here and read as a hang: the archives that matter are a handful of
// huge entries (a game blob split across volumes), so entries_done sits at 0
// for the whole of the first one while bytes are plainly moving. The current
// file name is already shown on its own line, so the entry index added
// nothing a user could act on. Benchmarked: reporting is not a cost -- see
// tests/bench_progress.c --mode -- so this is about clarity, not speed.
progressItem.textContent = t("progressLabel") + ": " + (isChmod ?
t("permissionProgress", { done, total }) : formatSize(done) + " / " + formatSize(total));
const etaItem = document.createElement("div");
etaItem.textContent = t("etaLabel") + ": " + averageEta(task, done, total);
appendChildren(meta, speedItem, progressItem, etaItem);
@@ -2289,7 +2299,7 @@ function uploadFileRequest(taskId, file, rel, overwrite, index) {
});
}
async function uploadFiles(files) {
async function uploadFiles(files, relativeNames) {
if (busy || loadingPath || !files.length) return;
const useLoading = files.length >= SELECT_ALL_LOADING_THRESHOLD;
let list;
@@ -2306,7 +2316,9 @@ async function uploadFiles(files) {
list = Array.prototype.slice.call(files);
for (let i = 0; i < list.length; i++) {
const file = list[i];
rels.push(uploadRelativeName(file));
// Dropped items carry no webkitRelativePath, so the drop collector
// rebuilds the path itself and passes it in.
rels.push(relativeNames && relativeNames[i] ? relativeNames[i] : uploadRelativeName(file));
sizes.push(String(file.size || 0));
total += Number(file.size || 0);
}
@@ -2315,6 +2327,17 @@ async function uploadFiles(files) {
if (useLoading) hideContentLoading();
}
// A lone archive used to need the "upload and extract" menu entry. Ask once
// here instead, so the plain Upload button covers it too; declining is an
// ordinary upload, and the toolbar Extract button still works afterwards.
if (list.length === 1 && isExtractableName(rels[0])) {
uploadFilesEl.value = "";
uploadFolderEl.value = "";
if (confirm(t("extractUploadAsk", { name: rels[0] }))) {
return uploadAndExtractFile(list[0], rels[0], true);
}
}
const overwrite = conflicts.length &&
confirm(t("uploadOverwriteConfirm", { names: conflictText(conflicts) }));
if (conflicts.length && !overwrite) return;
@@ -2374,38 +2397,133 @@ async function uploadFiles(files) {
}
}
// Two one-click entries rather than a menu: the main button picks files, the
// arrow picks a folder. A native file dialog is either file-only or
// folder-only (webkitdirectory), so a single dialog cannot offer both; the
// drop target below is the one gesture that accepts either.
function actionUploadFiles() {
if (busy || loadingPath) return;
uploadMenuEl.classList.remove("open");
uploadFilesEl.click();
}
function actionUploadFolder() {
if (busy || loadingPath) return;
uploadMenuEl.classList.remove("open");
uploadFolderEl.click();
}
function toggleUploadMenu() {
if (busy || loadingPath) return;
uploadMenuEl.classList.toggle("open");
// A dropped directory arrives as a FileSystemEntry, which has no recursive
// listing of its own: readEntries() hands back one batch at a time and an
// empty batch marks the end, so it has to be driven until it drains.
async function readDroppedDirectory(directory) {
const reader = directory.createReader();
const out = [];
for (;;) {
const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
if (!batch.length) return out;
for (const entry of batch) out.push(entry);
}
}
function actionUploadAndExtract() {
if (busy || loadingPath) return;
uploadMenuEl.classList.remove("open");
uploadZipEl.click();
async function collectDroppedEntry(entry, prefix, files, relativeNames) {
if (entry.isFile) {
const file = await new Promise((resolve, reject) => entry.file(resolve, reject));
files.push(file);
relativeNames.push(prefix + file.name);
return;
}
if (!entry.isDirectory) return;
const children = await readDroppedDirectory(entry);
const childPrefix = prefix + entry.name + "/";
for (const child of children) {
await collectDroppedEntry(child, childPrefix, files, relativeNames);
}
}
async function uploadAndExtractFile(file) {
async function uploadDroppedItems(dataTransfer) {
const files = [];
const relativeNames = [];
const dirEntries = [];
const items = dataTransfer.items;
if (items && items.length) {
for (let i = 0; i < items.length; i++) {
if (items[i].kind !== "file") continue;
const entry = items[i].webkitGetAsEntry ? items[i].webkitGetAsEntry() : null;
if (entry) {
dirEntries.push(entry);
continue;
}
const file = items[i].getAsFile();
if (file) {
files.push(file);
relativeNames.push(file.name);
}
}
} else {
for (let i = 0; i < dataTransfer.files.length; i++) {
const file = dataTransfer.files[i];
files.push(file);
relativeNames.push(uploadRelativeName(file));
}
}
for (const entry of dirEntries) {
await collectDroppedEntry(entry, "", files, relativeNames);
}
await uploadFiles(files, relativeNames);
}
function dataTransferHasFiles(dataTransfer) {
const types = dataTransfer && dataTransfer.types;
if (!types) return false;
for (let i = 0; i < types.length; i++) {
if (types[i] === "Files") return true;
}
return false;
}
function setupDropUpload() {
// The console browser has no drag source of its own, so the listeners would
// only ever raise a hint that nothing can dismiss.
if (isPlayStationBrowser()) return;
let dragDepth = 0;
window.addEventListener("dragenter", event => {
if (!dataTransferHasFiles(event.dataTransfer)) return;
event.preventDefault();
dragDepth++;
if (!busy && !loadingPath) dropUploadOverlayEl.hidden = false;
});
window.addEventListener("dragover", event => {
if (!dataTransferHasFiles(event.dataTransfer)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
});
window.addEventListener("dragleave", () => {
dragDepth = Math.max(0, dragDepth - 1);
if (!dragDepth) dropUploadOverlayEl.hidden = true;
});
window.addEventListener("drop", event => {
if (!dataTransferHasFiles(event.dataTransfer)) return;
event.preventDefault();
dragDepth = 0;
dropUploadOverlayEl.hidden = true;
if (busy || loadingPath) return;
uploadDroppedItems(event.dataTransfer).catch(err => {
const message = t("uploadFailed", { error: err.message || t("backendError") });
setStatus(message);
alert(message);
});
});
}
async function uploadAndExtractFile(file, relativeName, alreadyAsked) {
if (busy || loadingPath) return;
if (!/\.(zip|rar)$/i.test(file.name || "")) {
if (!isExtractableName(relativeName || file.name || "")) {
alert(t("err_extract_unsupported", { arg: file.name }));
return;
}
const rel = uploadRelativeName(file);
const rel = relativeName || uploadRelativeName(file);
const zipPath = pathJoin(cwd, rel);
if (!confirm(t("extractUploadConfirm", { name: rel, path: displayPath(cwd) }))) return;
if (!alreadyAsked &&
!confirm(t("extractUploadConfirm", { name: rel, path: displayPath(cwd) }))) return;
const conflict = confirm(t("extractOverwriteAsk")) ? "overwrite" : "fail";
let taskId = 0;
@@ -2452,7 +2570,6 @@ async function uploadAndExtractFile(file) {
}
} finally {
uploadXhr = null;
uploadZipEl.value = "";
if (!trackedTask) setBusy(false);
}
}
@@ -2485,15 +2602,10 @@ document.getElementById("renameBtn").addEventListener("click", actionRename);
downloadBtn.addEventListener("click", actionDownload);
document.getElementById("deleteBtn").addEventListener("click", actionDelete);
uploadBtn.addEventListener("click", actionUploadFiles);
uploadMenuBtn.addEventListener("click", toggleUploadMenu);
uploadFolderBtn.addEventListener("click", actionUploadFolder);
uploadAndExtractBtn.addEventListener("click", actionUploadAndExtract);
uploadFilesEl.addEventListener("change", () => uploadFiles(uploadFilesEl.files));
uploadFolderEl.addEventListener("change", () => uploadFiles(uploadFolderEl.files));
uploadZipEl.addEventListener("change", () => {
if (uploadZipEl.files.length === 1) uploadAndExtractFile(uploadZipEl.files[0]);
else uploadZipEl.value = "";
});
setupDropUpload();
exitBtn.addEventListener("click", actionExit);
parentBtn.addEventListener("click", actionParentDirectory);
textEditorCloseBtn.addEventListener("click", requestCloseTextEditor);
@@ -2542,10 +2654,6 @@ window.addEventListener("popstate", event => {
loadAndReveal(path, false, false, true, null, revealPath);
}
});
document.addEventListener("click", event => {
if (!uploadMenuEl || uploadMenuEl.contains(event.target)) return;
uploadMenuEl.classList.remove("open");
});
document.querySelector("thead").addEventListener("click", event => {
if (busy || loadingPath) return;
if (parentBtn.contains(event.target)) return;