From cc707dda7547c4b8eef32601e089410fd1ff5fa2 Mon Sep 17 00:00:00 2001
From: catShark <1716967236@qq.com>
Date: Sun, 23 Aug 2026 16:25:11 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=B9=E9=87=8F=E5=A4=84=E7=90=86?=
=?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=B8=8E=E5=AF=BC=E8=88=AA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 web/batch.html + batch.js:目录树选择器(懒加载,本地后端
提供 roots/dirs 接口)、工作流下拉、进度与产物下载
- 四个页面导航栏增加"批量处理"入口;styles.css 补齐批量页样式
---
web/admin.html | 1 +
web/assets/batch.js | 345 ++++++++++++++++++++++++++++++++++++++++++
web/assets/styles.css | 147 ++++++++++++++++++
web/batch.html | 91 +++++++++++
web/index.html | 1 +
web/tasks.html | 1 +
web/workflow.html | 1 +
7 files changed, 587 insertions(+)
create mode 100644 web/assets/batch.js
create mode 100644 web/batch.html
diff --git a/web/admin.html b/web/admin.html
index b8ea96b..b473edb 100755
--- a/web/admin.html
+++ b/web/admin.html
@@ -13,6 +13,7 @@
diff --git a/web/assets/batch.js b/web/assets/batch.js
new file mode 100644
index 0000000..0adf5af
--- /dev/null
+++ b/web/assets/batch.js
@@ -0,0 +1,345 @@
+// VRSub 批量处理页脚本:创建/列表/暂停/继续/删除批量任务,展开查看每视频明细。
+// 复用 app.js 的 api/escapeHtml/badge/formatTime/formatElapsed/progressBar 辅助函数。
+
+// 已发布工作流缓存(id → 名称),用于任务列表展示工作流名。
+const WORKFLOW_NAMES = new Map();
+
+// 已展开明细的任务 ID 集合(防止轮询刷新时折叠用户展开的行)。
+const expandedJobs = new Set();
+
+// 加载已发布工作流填充下拉框;同时缓存 id → 名称供列表展示。
+async function loadBatchWorkflowOptions() {
+ const select = document.getElementById("batchWorkflow");
+ if (!select) return;
+ const apps = await api("/api/apps");
+ select.innerHTML = apps.length
+ ? apps
+ .map((app) => ``)
+ .join("")
+ : '';
+ apps.forEach((app) => WORKFLOW_NAMES.set(app.id, app.name));
+}
+
+// 创建批量任务:POST 文件夹路径与所选工作流,成功后清空输入并刷新列表。
+async function createBatchJob() {
+ const button = document.getElementById("batchStart");
+ const hint = document.getElementById("batchCreateHint");
+ const folder = document.getElementById("batchFolder").value.trim();
+ const workflowId = document.getElementById("batchWorkflow").value;
+ const recursive = document.getElementById("batchRecursive").checked;
+ if (!folder) {
+ hint.textContent = "请填写视频文件夹路径";
+ return;
+ }
+ if (!workflowId) {
+ hint.textContent = "请先在工作流页发布一个工作流";
+ return;
+ }
+ button.disabled = true;
+ hint.textContent = "正在扫描视频并创建任务…";
+ try {
+ const job = await api("/api/batch/jobs", {
+ method: "POST",
+ body: JSON.stringify({ folder, workflow_id: workflowId, recursive }),
+ });
+ hint.textContent = `已创建任务 ${job.id},共 ${job.videos.length} 个视频`;
+ document.getElementById("batchFolder").value = "";
+ } catch (error) {
+ hint.textContent = `创建失败:${error.message}`;
+ } finally {
+ button.disabled = false;
+ }
+ await loadBatchJobs();
+}
+
+// 渲染单个任务的进度:已完成/跳过与失败计入不同颜色,总数为任务视频数。
+function batchProgress(job) {
+ const total = job.total || 0;
+ const done = job.done || 0;
+ const failed = job.failed || 0;
+ if (!total) return "待扫描";
+ const percent = Math.round(((done + failed) / total) * 100);
+ // 有失败视频时追加红色失败计数,避免把"部分失败"误看成全部完成。
+ const failedText = failed > 0 ? ` ,失败 ${failed}` : "";
+ return `${progressBar(percent, job.status === "FAILED")} ${done}/${total} 完成 ${percent}%${failedText}`;
+}
+
+// 渲染任务的暂停/继续/删除/详情按钮。
+function batchActions(job) {
+ const id = encodeURIComponent(job.id);
+ const canPause = job.status === "RUNNING" || job.status === "QUEUED";
+ const canResume = job.status === "PAUSED";
+ return `
+ ${canPause ? `` : ""}
+ ${canResume ? `` : ""}
+
+
+ `;
+}
+
+// 加载批量任务列表并渲染;已展开的任务自动重新拉取明细(展开行内嵌)。
+async function loadBatchJobs() {
+ const tbody = document.getElementById("batchJobList");
+ if (!tbody) return;
+ const jobs = await api("/api/batch/jobs");
+ tbody.innerHTML = jobs.length
+ ? (
+ await Promise.all(
+ jobs.map(async (job) => {
+ const statusHtml =
+ job.status === "COMPLETED" && (job.failed || 0) > 0
+ ? `部分失败 ${job.failed} 个视频失败,展开详情查看`
+ : job.status === "RUNNING"
+ ? `${badge(job.status)} 处理中 ${formatElapsed(job.updated_at)}`
+ : job.status === "QUEUED"
+ ? `${badge(job.status)} 排队中`
+ : job.status === "PAUSED"
+ ? `${badge(job.status)} 已暂停 ${formatElapsed(job.updated_at)}`
+ : badge(job.status);
+ const current = job.current_video ? PathBase(job.current_video) : "-";
+ const row = `
+
+ | ${escapeHtml(job.id)} |
+ ${escapeHtml(PathBase(job.folder_path))} |
+ ${escapeHtml(WORKFLOW_NAMES.get(job.workflow_id) || job.workflow_id)} |
+ ${statusHtml} |
+ ${batchProgress(job)} |
+ ${escapeHtml(current)} |
+ ${escapeHtml(formatTime(job.created_at))} |
+ ${batchActions(job)} |
+
`;
+ // 展开中的任务在下方追加明细行(每视频状态与产物下载)。
+ const detailRow = expandedJobs.has(job.id)
+ ? `| ${await videoDetailHtml(job.id)} |
`
+ : "";
+ return row + detailRow;
+ }),
+ )
+ ).join("")
+ : '| 暂无批量任务,请在上方创建。 |
';
+}
+
+// 取路径的最后一段(兼容 Windows 反斜杠与 Unix 斜杠)。
+function PathBase(path) {
+ return String(path).split(/[\\/]/).pop() || path;
+}
+
+// 拉取任务详情并渲染视频明细表:文件、状态、错误与产物下载链接。
+async function videoDetailHtml(jobId) {
+ const job = await api(`/api/batch/jobs/${encodeURIComponent(jobId)}`);
+ const videos = job.videos || [];
+ if (!videos.length) return "(暂无视频)";
+ const rows = videos
+ .map((video) => {
+ const finals = video.finals || {};
+ const links = Object.keys(finals)
+ .map(
+ (alias) =>
+ `${escapeHtml(alias)}`,
+ )
+ .join(" ");
+ return `
+
+ | ${escapeHtml(PathBase(video.video_path))} |
+ ${badge(video.status)} |
+ ${escapeHtml(video.error || "-")} |
+ ${links || "-"} |
+
`;
+ })
+ .join("");
+ return ``;
+}
+
+// 暂停批量任务:当前 run 在分块/帧边界停下,后续视频不再开始。
+async function pauseBatchJob(jobId) {
+ try {
+ const result = await api(`/api/batch/jobs/${jobId}/pause`, { method: "POST" });
+ alert(`批量任务 ${result.id} 已暂停,继续后从未完成处接着处理`);
+ } catch (error) {
+ alert(`暂停失败:${error.message}`);
+ }
+ await loadBatchJobs();
+}
+
+// 继续批量任务:恢复排队,从上次断点(未完成视频)继续处理。
+async function resumeBatchJob(jobId) {
+ try {
+ const result = await api(`/api/batch/jobs/${jobId}/resume`, { method: "POST" });
+ alert(`批量任务 ${result.id} 已恢复执行`);
+ } catch (error) {
+ alert(`继续失败:${error.message}`);
+ }
+ await loadBatchJobs();
+}
+
+// 删除批量任务:只清理数据库记录,磁盘上的同名文件夹与产物保留。
+async function deleteBatchJob(jobId) {
+ if (!confirm("删除批量任务?磁盘上的产物文件会保留。")) return;
+ try {
+ await api(`/api/batch/jobs/${jobId}`, { method: "DELETE" });
+ } catch (error) {
+ alert(`删除失败:${error.message}`);
+ }
+ await loadBatchJobs();
+}
+
+// ---------------------------------------------------------------------------
+// 目录树选择器:点击"选择文件夹"按钮弹出,懒加载浏览本地目录后回填路径。
+// 浏览器拿不到文件夹绝对路径,目录列表由本地后端(/api/batch/roots、dirs)提供。
+// ---------------------------------------------------------------------------
+
+// 当前选中的目录绝对路径(未选中时为 null)。
+let dirPickerSelection = null;
+
+// 打开目录树选择器:显示模态框并加载可浏览根目录。
+async function openDirPicker() {
+ document.getElementById("dirPicker").classList.remove("hidden");
+ const tree = document.getElementById("dirTree");
+ tree.innerHTML = '加载中…
';
+ dirPickerSelection = null;
+ document.getElementById("dirPickerUse").disabled = true;
+ document.getElementById("dirPickerSelected").textContent = "";
+ try {
+ const roots = await api("/api/batch/roots");
+ tree.innerHTML = "";
+ roots.forEach((root) => tree.appendChild(dirNode(root, 0)));
+ } catch (error) {
+ tree.innerHTML = `加载失败:${escapeHtml(error.message)}
`;
+ }
+}
+
+// 关闭目录树选择器。
+function closeDirPicker() {
+ document.getElementById("dirPicker").classList.add("hidden");
+}
+
+// 渲染一个目录节点行:缩进 + 展开箭头 + 名称;点击行选中并切换展开。
+function dirNode(dir, depth) {
+ const row = document.createElement("div");
+ row.className = "dir-node";
+ row.dataset.path = dir.path;
+ row.dataset.expanded = "false";
+
+ const indent = document.createElement("span");
+ indent.className = "dir-indent";
+ indent.style.width = `${depth * 18}px`;
+ const arrow = document.createElement("span");
+ arrow.className = "dir-arrow";
+ arrow.textContent = "▸";
+ const name = document.createElement("span");
+ name.className = "dir-name";
+ name.textContent = dir.name;
+ name.title = dir.path;
+
+ row.append(indent, arrow, name);
+ row.addEventListener("click", (event) => {
+ event.stopPropagation();
+ selectDir(row);
+ // 首次点击展开子目录(懒加载),再次点击折叠。
+ if (row.dataset.expanded === "false") {
+ expandDir(row, depth);
+ } else {
+ collapseDir(row);
+ }
+ });
+ return row;
+}
+
+// 选中目录行:取消其它行高亮,更新底部展示与"使用此文件夹"按钮。
+function selectDir(row) {
+ document.querySelectorAll("#dirTree .dir-node.selected").forEach((item) => item.classList.remove("selected"));
+ row.classList.add("selected");
+ dirPickerSelection = row.dataset.path;
+ document.getElementById("dirPickerSelected").textContent = row.dataset.path;
+ document.getElementById("dirPickerUse").disabled = false;
+}
+
+// 展开目录:请求后端列出子目录,把子节点行插到当前行之后。
+async function expandDir(row, depth) {
+ const arrow = row.querySelector(".dir-arrow");
+ arrow.textContent = "…";
+ let children;
+ try {
+ const data = await api(`/api/batch/dirs?path=${encodeURIComponent(row.dataset.path)}`);
+ children = document.createElement("div");
+ children.className = "dir-children";
+ data.dirs.forEach((child) => children.appendChild(dirNode(child, depth + 1)));
+ if (!data.dirs.length) {
+ const empty = document.createElement("div");
+ empty.className = "dir-empty muted";
+ empty.textContent = "(无子目录)";
+ children.appendChild(empty);
+ }
+ } catch (error) {
+ children = document.createElement("div");
+ children.className = "dir-children";
+ const err = document.createElement("div");
+ err.className = "dir-empty muted";
+ err.textContent = `加载失败:${error.message}`;
+ children.appendChild(err);
+ }
+ row.dataset.expanded = "true";
+ arrow.textContent = "▾";
+ row.insertAdjacentElement("afterend", children);
+}
+
+// 折叠目录:移除已展开的子节点行。
+function collapseDir(row) {
+ row.dataset.expanded = "false";
+ row.querySelector(".dir-arrow").textContent = "▸";
+ const children = row.nextElementSibling;
+ if (children && children.classList.contains("dir-children")) {
+ children.remove();
+ }
+}
+
+// 使用当前选中目录:回填路径输入框并关闭选择器。
+function useDirPicker() {
+ if (!dirPickerSelection) return;
+ document.getElementById("batchFolder").value = dirPickerSelection;
+ closeDirPicker();
+}
+
+// 展开/折叠任务明细(事件委托处理列表内的按钮)。
+async function onBatchJobClick(event) {
+ const target = event.target;
+ const pause = target.dataset.batchPause;
+ const resume = target.dataset.batchResume;
+ const del = target.dataset.batchDelete;
+ const detail = target.dataset.batchDetail;
+ if (pause) return pauseBatchJob(pause);
+ if (resume) return resumeBatchJob(resume);
+ if (del) return deleteBatchJob(del);
+ if (detail) {
+ // 切换展开状态后刷新列表(展开行会拉取最新明细)。
+ if (expandedJobs.has(detail)) {
+ expandedJobs.delete(detail);
+ } else {
+ expandedJobs.add(detail);
+ }
+ await loadBatchJobs();
+ }
+}
+
+// 页面初始化:绑定事件并启动列表轮询(每 2 秒刷新一次进度)。
+document.addEventListener("DOMContentLoaded", async () => {
+ const startButton = document.getElementById("batchStart");
+ if (!startButton) return;
+ startButton.addEventListener("click", createBatchJob);
+ // 目录树选择器:打开/关闭/使用/点击遮罩关闭。
+ document.getElementById("batchBrowse").addEventListener("click", openDirPicker);
+ document.getElementById("dirPickerClose").addEventListener("click", closeDirPicker);
+ document.getElementById("dirPickerUse").addEventListener("click", useDirPicker);
+ document.getElementById("dirPicker").addEventListener("click", (event) => {
+ // 点击遮罩(面板外部)关闭选择器。
+ if (event.target.id === "dirPicker") closeDirPicker();
+ });
+ document.addEventListener("keydown", (event) => {
+ // Esc 关闭选择器。
+ if (event.key === "Escape") closeDirPicker();
+ });
+ document.getElementById("batchJobList").addEventListener("click", onBatchJobClick);
+ await loadBatchWorkflowOptions();
+ await loadBatchJobs();
+ setInterval(loadBatchJobs, 2000);
+});
diff --git a/web/assets/styles.css b/web/assets/styles.css
index 7d6fba8..28d8ecf 100755
--- a/web/assets/styles.css
+++ b/web/assets/styles.css
@@ -346,3 +346,150 @@ th {
pointer-events: auto;
cursor: crosshair;
}
+
+/* 批量处理页:明细行的内嵌表格样式。 */
+.batch-detail-row td {
+ background: #fafbfc;
+ padding: 8px 12px;
+}
+
+/* 内嵌表格:无边框、行距紧凑,嵌入批量任务明细行中。 */
+.inner-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.inner-table th {
+ text-align: left;
+ font-size: 12px;
+ color: #666;
+ padding: 4px 8px;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.inner-table td {
+ padding: 4px 8px;
+ font-size: 13px;
+ border-bottom: 1px solid #f0f1f3;
+}
+
+/* 批量页创建表单的复选框与标签同行展示。 */
+.form-row .inline {
+ margin-left: 16px;
+ font-weight: 400;
+}
+
+/* 批量页:路径输入框 + "选择文件夹"按钮同行。 */
+.path-picker {
+ display: flex;
+ gap: 8px;
+ flex: 1;
+}
+
+.path-picker input {
+ flex: 1;
+ background: #f7f8fa;
+}
+
+/* 目录树选择器:全屏遮罩 + 居中面板。 */
+.dir-picker {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.35);
+ z-index: 50;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.dir-picker.hidden {
+ display: none;
+}
+
+.dir-picker-panel {
+ width: min(560px, 92vw);
+ max-height: 70vh;
+ display: flex;
+ flex-direction: column;
+ background: #fff;
+ border-radius: 10px;
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
+ overflow: hidden;
+}
+
+.dir-picker-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+/* 关闭按钮(✕):无边框文本按钮。 */
+.dir-picker-head .plain {
+ border: none;
+ background: none;
+ font-size: 16px;
+ cursor: pointer;
+ color: #666;
+}
+
+.dir-tree {
+ flex: 1;
+ overflow: auto;
+ padding: 8px 0;
+ min-height: 160px;
+}
+
+/* 目录节点行:悬停高亮,选中项加深。 */
+.dir-node {
+ display: flex;
+ align-items: center;
+ padding: 6px 12px;
+ cursor: pointer;
+ font-size: 14px;
+}
+
+.dir-node:hover {
+ background: #f3f6fa;
+}
+
+.dir-node.selected {
+ background: #e3edf7;
+ font-weight: 600;
+}
+
+.dir-arrow {
+ width: 20px;
+ color: #888;
+ user-select: none;
+}
+
+.dir-name {
+ margin-left: 4px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.dir-empty {
+ padding: 4px 12px 4px 56px;
+ font-size: 13px;
+}
+
+.dir-picker-foot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 16px;
+ border-top: 1px solid #e5e7eb;
+}
+
+.dir-picker-foot .muted {
+ flex: 1;
+ font-size: 13px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
diff --git a/web/batch.html b/web/batch.html
new file mode 100644
index 0000000..1bfaab7
--- /dev/null
+++ b/web/batch.html
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+ VRSub - 批量处理
+
+
+
+
+
+
+ 批量处理
+
+ 直接读取所选文件夹下的全部视频(不上传副本),逐个执行所选流水线;
+ 每个视频的中间态数据与最终产物存放在视频旁边的同名文件夹(如 movie.mp4 →
+ movie/)。已处理过的视频自动跳过;暂停后重新开始时,从未完成的视频继续。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 任务 ID |
+ 文件夹 |
+ 工作流 |
+ 状态 |
+ 进度 |
+ 当前视频 |
+ 创建时间 |
+ 操作 |
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/index.html b/web/index.html
index f02a50f..d2ecf87 100755
--- a/web/index.html
+++ b/web/index.html
@@ -15,6 +15,7 @@
diff --git a/web/tasks.html b/web/tasks.html
index 2cca792..1352226 100644
--- a/web/tasks.html
+++ b/web/tasks.html
@@ -13,6 +13,7 @@
diff --git a/web/workflow.html b/web/workflow.html
index fcbf018..5c65673 100755
--- a/web/workflow.html
+++ b/web/workflow.html
@@ -13,6 +13,7 @@