add: 添加选择文件夹功能
This commit is contained in:
@@ -6,7 +6,7 @@ import { onRoute } from "./state.js";
|
|||||||
import { t, currentLocale, setLocale, onLocale } from "./i18n.js";
|
import { t, currentLocale, setLocale, onLocale } from "./i18n.js";
|
||||||
import { renderRemotes } from "./views/remotes.js";
|
import { renderRemotes } from "./views/remotes.js";
|
||||||
import { renderBrowse } from "./views/browser.js";
|
import { renderBrowse } from "./views/browser.js";
|
||||||
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=jobs-details-1";
|
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=jobs-picker-restore-1";
|
||||||
import {
|
import {
|
||||||
renderConfigureNew,
|
renderConfigureNew,
|
||||||
renderConfigureEdit,
|
renderConfigureEdit,
|
||||||
|
|||||||
@@ -169,6 +169,11 @@ const STRINGS = {
|
|||||||
"jobs.dest_remote": "Destination location",
|
"jobs.dest_remote": "Destination location",
|
||||||
"jobs.dest_path": "Destination path",
|
"jobs.dest_path": "Destination path",
|
||||||
"jobs.path_placeholder": "folder/subfolder or /local/path",
|
"jobs.path_placeholder": "folder/subfolder or /local/path",
|
||||||
|
"jobs.pick_folder": "Choose",
|
||||||
|
"jobs.pick_source_title": "Choose source folder",
|
||||||
|
"jobs.pick_dest_title": "Choose destination folder",
|
||||||
|
"jobs.pick_current": "Use current folder",
|
||||||
|
"jobs.pick_empty": "No folders here.",
|
||||||
"jobs.start": "Start Job",
|
"jobs.start": "Start Job",
|
||||||
"jobs.no_remotes_option": "(no remotes)",
|
"jobs.no_remotes_option": "(no remotes)",
|
||||||
"jobs.empty_title": "No jobs yet",
|
"jobs.empty_title": "No jobs yet",
|
||||||
@@ -341,6 +346,11 @@ const STRINGS = {
|
|||||||
"jobs.dest_remote": "目标位置",
|
"jobs.dest_remote": "目标位置",
|
||||||
"jobs.dest_path": "目标路径",
|
"jobs.dest_path": "目标路径",
|
||||||
"jobs.path_placeholder": "文件夹/子文件夹 或 /local/path",
|
"jobs.path_placeholder": "文件夹/子文件夹 或 /local/path",
|
||||||
|
"jobs.pick_folder": "选择",
|
||||||
|
"jobs.pick_source_title": "选择源文件夹",
|
||||||
|
"jobs.pick_dest_title": "选择目标文件夹",
|
||||||
|
"jobs.pick_current": "使用当前文件夹",
|
||||||
|
"jobs.pick_empty": "这里没有文件夹。",
|
||||||
"jobs.start": "开始任务",
|
"jobs.start": "开始任务",
|
||||||
"jobs.no_remotes_option": "(无远程存储)",
|
"jobs.no_remotes_option": "(无远程存储)",
|
||||||
"jobs.empty_title": "尚无任务",
|
"jobs.empty_title": "尚无任务",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ let pollTimer = null;
|
|||||||
const WEBGUI_JOB_GROUP_PREFIX = "webgui/transfer";
|
const WEBGUI_JOB_GROUP_PREFIX = "webgui/transfer";
|
||||||
const LOCAL_FS_VALUE = "__local__";
|
const LOCAL_FS_VALUE = "__local__";
|
||||||
const expandedJobs = new Set();
|
const expandedJobs = new Set();
|
||||||
|
const LOCAL_PICKER_ROOT = "/root";
|
||||||
|
|
||||||
export async function renderJobs() {
|
export async function renderJobs() {
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
@@ -86,7 +87,10 @@ export async function renderNewJob() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>${t("jobs.source_path")}</label>
|
<label>${t("jobs.source_path")}</label>
|
||||||
|
<div class="input-action">
|
||||||
<input class="input" name="srcPath" placeholder="${t("jobs.path_placeholder")}" autocomplete="off">
|
<input class="input" name="srcPath" placeholder="${t("jobs.path_placeholder")}" autocomplete="off">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" data-pick="src">${t("jobs.pick_folder")}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>${t("jobs.dest_remote")}</label>
|
<label>${t("jobs.dest_remote")}</label>
|
||||||
@@ -96,7 +100,10 @@ export async function renderNewJob() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>${t("jobs.dest_path")}</label>
|
<label>${t("jobs.dest_path")}</label>
|
||||||
|
<div class="input-action">
|
||||||
<input class="input" name="dstPath" placeholder="${t("jobs.path_placeholder")}" autocomplete="off">
|
<input class="input" name="dstPath" placeholder="${t("jobs.path_placeholder")}" autocomplete="off">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" data-pick="dst">${t("jobs.pick_folder")}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field field-full">
|
<div class="field field-full">
|
||||||
<span class="field-help">${t("jobs.local_path_help")}</span>
|
<span class="field-help">${t("jobs.local_path_help")}</span>
|
||||||
@@ -114,6 +121,21 @@ export async function renderNewJob() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const form = document.getElementById("new-job-form");
|
const form = document.getElementById("new-job-form");
|
||||||
|
form.querySelector('[data-pick="src"]').addEventListener("click", () => {
|
||||||
|
openFolderPicker({
|
||||||
|
location: form.elements.srcRemote.value,
|
||||||
|
pathInput: form.elements.srcPath,
|
||||||
|
title: t("jobs.pick_source_title"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
form.querySelector('[data-pick="dst"]').addEventListener("click", () => {
|
||||||
|
openFolderPicker({
|
||||||
|
location: form.elements.dstRemote.value,
|
||||||
|
pathInput: form.elements.dstPath,
|
||||||
|
title: t("jobs.pick_dest_title"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
form.addEventListener("submit", async (e) => {
|
form.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const action = form.elements.action.value;
|
const action = form.elements.action.value;
|
||||||
@@ -159,6 +181,124 @@ function buildJobFs(location, rawPath) {
|
|||||||
return remotePath ? `${location}${remotePath}` : location;
|
return remotePath ? `${location}${remotePath}` : location;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openFolderPicker({ location, pathInput, title }) {
|
||||||
|
const root = document.getElementById("modal-root");
|
||||||
|
let current = initialPickerPath(location, pathInput.value);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
root.innerHTML = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const choose = () => {
|
||||||
|
pathInput.value = current;
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const render = async () => {
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="modal-overlay">
|
||||||
|
<div class="modal folder-picker">
|
||||||
|
<h3>${escapeHtml(title)}</h3>
|
||||||
|
<div class="folder-picker-path">
|
||||||
|
<span>${escapeHtml(displayPickerPath(location, current))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="folder-picker-list">
|
||||||
|
<p class="empty">${t("loading.title")}…</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" data-cancel>${t("browser.modal.cancel")}</button>
|
||||||
|
<button type="button" class="btn btn-primary" data-select>${t("jobs.pick_current")}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const overlay = root.querySelector(".modal-overlay");
|
||||||
|
const list = root.querySelector(".folder-picker-list");
|
||||||
|
overlay.addEventListener("click", (e) => {
|
||||||
|
if (e.target === overlay) close();
|
||||||
|
});
|
||||||
|
root.querySelector("[data-cancel]").addEventListener("click", close);
|
||||||
|
root.querySelector("[data-select]").addEventListener("click", choose);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dirs = await listDirs(location, current);
|
||||||
|
const parent = parentPickerPath(location, current);
|
||||||
|
list.innerHTML = `
|
||||||
|
<button type="button" class="folder-picker-row" data-path="${escapeAttr(parent ?? "")}" ${parent == null ? "disabled" : ""}>${t("browser.up")}</button>
|
||||||
|
${dirs.length === 0 ? `<p class="empty">${t("jobs.pick_empty")}</p>` : dirs.map((dir) => `
|
||||||
|
<button type="button" class="folder-picker-row" data-path="${escapeAttr(joinPickerPath(current, dir.Name))}">
|
||||||
|
${escapeHtml(dir.Name)}
|
||||||
|
</button>
|
||||||
|
`).join("")}
|
||||||
|
`;
|
||||||
|
list.querySelectorAll("[data-path]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
if (btn.disabled) return;
|
||||||
|
current = btn.dataset.path;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<div class="empty"><h3>${t("error.couldnt_list")}</h3><p>${escapeHtml(e.message)}</p></div>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listDirs(location, current) {
|
||||||
|
const isLocal = location === LOCAL_FS_VALUE;
|
||||||
|
const body = isLocal
|
||||||
|
? { fs: current || LOCAL_PICKER_ROOT, remote: "" }
|
||||||
|
: { fs: location, remote: trimRemotePath(current) };
|
||||||
|
const res = await post("operations/list", {
|
||||||
|
...body,
|
||||||
|
opt: { dirsOnly: true, noModTime: true, noMimeType: true, showHash: false },
|
||||||
|
});
|
||||||
|
const items = (res && res.list) || [];
|
||||||
|
return items
|
||||||
|
.filter((item) => item.IsDir)
|
||||||
|
.sort((a, b) => a.Name.localeCompare(b.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialPickerPath(location, rawPath) {
|
||||||
|
const value = rawPath.trim();
|
||||||
|
if (location === LOCAL_FS_VALUE) {
|
||||||
|
return value || LOCAL_PICKER_ROOT;
|
||||||
|
}
|
||||||
|
return trimRemotePath(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayPickerPath(location, current) {
|
||||||
|
if (location === LOCAL_FS_VALUE) {
|
||||||
|
return current || "/";
|
||||||
|
}
|
||||||
|
return current ? `${location}${current}` : location;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentPickerPath(location, current) {
|
||||||
|
if (location === LOCAL_FS_VALUE) {
|
||||||
|
if (!current || current === "/") return null;
|
||||||
|
const normalized = current.replace(/\/+$/g, "");
|
||||||
|
const parent = normalized.slice(0, normalized.lastIndexOf("/")) || "/";
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
if (!current) return null;
|
||||||
|
const parts = trimRemotePath(current).split("/").filter(Boolean);
|
||||||
|
parts.pop();
|
||||||
|
return parts.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinPickerPath(current, name) {
|
||||||
|
if (!current || current === "/") return current === "/" ? `/${name}` : name;
|
||||||
|
return `${current.replace(/\/+$/g, "")}/${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimRemotePath(path) {
|
||||||
|
return path.trim().replace(/^\/+|\/+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshJobs() {
|
async function refreshJobs() {
|
||||||
const card = document.getElementById("jobs-card");
|
const card = document.getElementById("jobs-card");
|
||||||
if (!card) return false;
|
if (!card) return false;
|
||||||
@@ -431,6 +571,10 @@ function escapeHtml(s) {
|
|||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeAttr(s) {
|
||||||
|
return escapeHtml(s);
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(value) {
|
function formatDateTime(value) {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
|
|||||||
@@ -303,6 +303,21 @@
|
|||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-action .input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-action .btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Badges ---------- */
|
/* ---------- Badges ---------- */
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -580,6 +595,49 @@
|
|||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.folder-picker {
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-picker-path {
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
background-color: var(--color-surface-dark-elevated);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-on-dark-soft);
|
||||||
|
font: var(--typo-code);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-picker-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-picker-row {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
background-color: var(--color-surface-dark-elevated);
|
||||||
|
color: var(--color-on-dark);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
text-align: left;
|
||||||
|
font: var(--typo-body-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-picker-row:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-picker-row:disabled {
|
||||||
|
color: var(--color-muted-soft);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Footer ---------- */
|
/* ---------- Footer ---------- */
|
||||||
.footer {
|
.footer {
|
||||||
background-color: var(--color-surface-dark);
|
background-color: var(--color-surface-dark);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||||||
<link rel="stylesheet" href="/assets/styles/tokens.css">
|
<link rel="stylesheet" href="/assets/styles/tokens.css">
|
||||||
<link rel="stylesheet" href="/assets/styles/base.css">
|
<link rel="stylesheet" href="/assets/styles/base.css">
|
||||||
<link rel="stylesheet" href="/assets/styles/components.css?v=jobs-details-1">
|
<link rel="stylesheet" href="/assets/styles/components.css?v=jobs-picker-restore-1">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="top-nav">
|
<header class="top-nav">
|
||||||
@@ -80,6 +80,6 @@
|
|||||||
<div id="toast-stack" class="toast-stack"></div>
|
<div id="toast-stack" class="toast-stack"></div>
|
||||||
<div id="modal-root"></div>
|
<div id="modal-root"></div>
|
||||||
|
|
||||||
<script type="module" src="/assets/js/app.js?v=jobs-details-1"></script>
|
<script type="module" src="/assets/js/app.js?v=jobs-picker-restore-1"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user