// subtitle-studio 前端逻辑 let selectedPath = ""; let tasksMap = {}; // ---- 文件浏览 ---- async function loadDrives() { const r = await fetch("/api/drives"); const d = await r.json(); const sel = document.getElementById("drive-select"); sel.innerHTML = d.drives.map(x => ``).join(""); sel.onchange = () => { document.getElementById("path-input").value = sel.value; browse(sel.value); }; if (d.drives.length) { document.getElementById("path-input").value = d.drives[0]; browse(d.drives[0]); } } async function browse(path) { path = cleanPath(path); const r = await fetch("/api/browse?path=" + encodeURIComponent(path)); const d = await r.json(); document.getElementById("path-input").value = d.cwd; const list = document.getElementById("browser-list"); list.innerHTML = ""; const entries = d.entries.filter(e => !e.name.startsWith("$")); for (const e of entries) { const div = document.createElement("div"); div.className = "browser-item" + (e.path === selectedPath ? " selected" : ""); div.innerHTML = `${e.is_dir ? "📁" : "🎬"}${e.name}` + (e.is_dir ? "" : '文件'); div.onclick = () => { selectedPath = e.path; updateSelection(e); document.querySelectorAll(".browser-item").forEach(x => x.classList.remove("selected")); div.classList.add("selected"); }; if (e.is_dir) { div.ondblclick = () => browse(e.path); } list.appendChild(div); } } function updateSelection(e) { const preview = document.getElementById("sel-preview"); if (e.is_dir) { preview.innerHTML = `已选择文件夹:${e.path}(将批量处理其中所有视频)`; loadFolderStatus(e.path); // 自动加载该文件夹视频状态 } else { preview.innerHTML = `已选择文件:${e.path}`; } } // 加载文件夹内所有视频的处理状态(含已完成/警告/续跑点) async function loadFolderStatus(path) { const route = document.querySelector('input[name="route"]:checked')?.value || "direct"; try { const r = await fetch(`/api/folder_status?path=${encodeURIComponent(path)}&route=${route}`); const d = await r.json(); if (d.error) { return; } renderFolderVideos(d.videos); } catch (e) {} } // 渲染文件夹视频状态为任务卡片列表 function renderFolderVideos(videos) { const list = document.getElementById("task-list"); if (!videos || !videos.length) { return; } folderVideos = videos; // 保存状态,供 mergeTaskProgress 更新 folderRendered = true; // 转成任务格式渲染(模拟一个总任务 + 子任务) const total = videos.length; const doneCount = videos.filter(v => v.status === "done").length; const subTasks = {}; videos.forEach(v => { let msg = v.message; if (v.warnings && v.warnings.length) { msg += ' ⚠️ ' + v.warnings[0].slice(0, 60); } subTasks[v.video] = { status: v.status, progress: v.progress, message: msg, started_at: null, elapsed: 0, warnings: v.warnings || [] }; }); const fakeTask = { id: "folder-" + Math.random().toString(36).slice(2, 8), source: videos[0].video.split(/[\\/]/).slice(0, -1).join("\\"), route: "direct", status: doneCount === total ? "done" : "pending", progress: Math.round(doneCount / total * 100), message: `文件夹状态:${doneCount}/${total} 已完成`, created: Date.now() / 1000, files: [], subtasks: subTasks, total_videos: total, processed_count: doneCount, failed_count: 0, }; // 临时切到 tasks 视图渲染 folder 卡片(renderTasks 有 guard 会挡 folder) currentView = "tasks"; renderTasks([fakeTask]); currentView = "folder"; // 渲染完成后切回 folder 视图,阻止任务轮询覆盖 } function goUp() { const cur = document.getElementById("path-input").value; const parent = cur.replace(/[\\/][^\\/]*[\\/]?$/, "") || cur; if (parent !== cur) browse(parent); } function refreshBrowse() { browse(document.getElementById("path-input").value); } // 清理路径:去除首尾双引号(用户从资源管理器复制)、多余空白、尾部斜杠 function cleanPath(p) { if (!p) return ""; let s = p.trim(); // 去掉首尾双引号(成对或单个) if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { s = s.slice(1, -1); } else if (s.startsWith('"')) { s = s.slice(1); } else if (s.endsWith('"')) { s = s.slice(0, -1); } // 去除尾部多余斜杠(保留盘符根如 C:\) s = s.replace(/[\\/]+$/, ""); if (/^[A-Za-z]:$/.test(s)) s += "\\"; return s.trim(); } // 手动输入路径(回车触发 change)——若是文件夹自动加载视频状态 document.getElementById("path-input").addEventListener("change", () => { const p = cleanPath(document.getElementById("path-input").value); document.getElementById("path-input").value = p; selectedPath = p; document.getElementById("sel-preview").innerHTML = `路径:${p}`; // 文件夹 → 自动加载任务列表(loadFolderStatus 内部会判断,文件路径不渲染) loadFolderStatus(p); }); // 回车键也触发(有些浏览器回车不触发 change) document.getElementById("path-input").addEventListener("keydown", (e) => { if (e.key === "Enter") { const p = cleanPath(document.getElementById("path-input").value); document.getElementById("path-input").value = p; selectedPath = p; document.getElementById("sel-preview").innerHTML = `路径:${p}`; loadFolderStatus(p); } }); // ---- 非阻塞提示 toast ---- function showToast(msg, type) { let toast = document.getElementById("toast"); if (!toast) { toast = document.createElement("div"); toast.id = "toast"; toast.style.cssText = "position:fixed;top:16px;right:16px;z-index:9999;max-width:380px;background:#1c212b;border:1px solid #2a2f3a;border-radius:8px;padding:10px 14px;font-size:13px;box-shadow:0 4px 16px rgba(0,0,0,.4);"; document.body.appendChild(toast); } toast.innerHTML = `${type === "warn" ? "⚠️" : type === "err" ? "❌" : "✅"}${msg}`; toast.style.borderColor = type === "warn" ? "#d29922" : type === "err" ? "#f85149" : "#3fb950"; clearTimeout(toast._t); toast._t = setTimeout(() => toast.remove(), 5000); } // ---- 区域折叠 ---- function collapseSections() { const s1 = document.getElementById("section-1"); const s2 = document.getElementById("section-2"); if (s1) s1.removeAttribute("open"); if (s2) s2.removeAttribute("open"); const s3 = document.getElementById("section-3"); if (s3 && !s3.hasAttribute("open")) s3.setAttribute("open", ""); } // ---- 任务创建 ---- async function createTask() { selectedPath = cleanPath(selectedPath); document.getElementById("path-input").value = selectedPath; if (!selectedPath) { showToast("请先选择文件或文件夹", "warn"); return; } const route = document.querySelector('input[name="route"]:checked').value; const language = document.getElementById("lang-select").value; const btn = document.getElementById("submit-btn"); btn.disabled = true; try { const r = await fetch("/api/tasks", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ source: selectedPath, route, language }), }); let data = null; try { data = await r.json(); } catch (e) { showToast("服务器错误: " + (r.status || "") , "err"); return; } if (data.error) { showToast(data.error, "err"); return; } if (data.skipped_all) { showToast(data.notice || "所选内容已处理过", "warn"); return; } if (data.notice) { showToast(data.notice, "warn"); } // 提交成功:折叠 1、2 区。若在 folder 视图则保持(实时进度会合并进来) collapseSections(); if (currentView === "folder") { loadFolderStatus(selectedPath); // 刷新 folder 状态(含新任务进度) } else { fetch("/api/tasks").then(r => r.json()).then(renderTasks); } } finally { btn.disabled = false; } } // ---- 耗时格式化 ---- function fmtDur(sec) { sec = Math.max(0, Math.round(sec || 0)); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; if (h > 0) return `${h}h${m}m`; if (m > 0) return `${m}m${s}s`; return `${s}s`; } // ---- 任务渲染 ---- const STATUS_LABEL = { pending: "等待中", transcribing: "转录中", fixing: "修正中", translating: "翻译中", srt: "生成字幕", done: "完成", error: "失败", cancelled: "已取消", }; // 当前视图:'tasks'=真实任务列表, 'folder'=文件夹状态视图 let currentView = "tasks"; let folderVideos = null; // 最近一次 folder_status 返回的视频状态 let folderRendered = false; // folder 视图是否已渲染 // 合并真实任务进度到 folder 视图(已完成的视频保留,运行中的更新进度) function mergeTaskProgress(tasks) { if (!folderVideos) return; let changed = false; for (const t of tasks) { if (!t.subtasks) continue; for (const [video, st] of Object.entries(t.subtasks)) { const fv = folderVideos.find(v => v.video === video); if (fv) { if (fv.status !== st.status || fv.progress !== st.progress) { fv.status = st.status; fv.progress = st.progress; fv.message = st.message || fv.message; changed = true; } } } } if (changed) renderFolderVideos(folderVideos); } function renderTasks(tasks) { // folder 视图下:合并真实任务进度到 folder 视图,不覆盖已完成的视频 if (currentView === "folder") { if (folderVideos && folderRendered) { mergeTaskProgress(tasks); } return; } const list = document.getElementById("task-list"); if (!tasks.length) { if (!list.querySelector(".empty")) { list.innerHTML = '