Files
vrsub/web/assets/app.js
T
cat-shark 8645e440f4 feat: 日语转写(翻译前归档)也作为产物放到视频旁
翻译前的日语转写只有中间态,重跑字幕或回看译文时无据可查;把它按语言归档到
视频旁,与中文译文、双目字幕并列。

- `learn-translate` 的 `final_outputs` 增加 `ja_srt: asr.srt_uri`(清洗后的转写)。
- `_sidecar_product_name` 改为**按 final_outputs 别名**映射语言后缀
  (`ja_srt`→`.JA.srt`、`cn_srt`→`.CN.srt`、`ass`→`.CN_dual_eye.ass`),未登记别名
  时按扩展名兜底。此前两份 `.srt` 都映射成 `.CN.srt` 会互相覆盖(真实日志:
  "产物已放视频旁: movie.CN.srt, movie.CN.srt")。
- 任务管理的产物列补"日语 SRT"下载链接。
- 测试:按别名区分语言变体 + 端到端验证两份产物都落地且不覆盖。
2026-09-18 22:27:38 +08:00

790 lines
29 KiB
JavaScript

// VRSub 静态前端公共脚本:所有页面共用的 API 封装、渲染函数与事件绑定。
// 首页只负责发起任务;任务管理页展示全部任务的进度、产物下载与失败重试。
// 演示"视频字幕生成"工作流的 DAG 定义,预填在工作流编排页。
const DEMO_WORKFLOW = {
name: "视频字幕生成",
version: 1,
nodes: [
{
id: "extract",
node_type: "ffmpeg-extract",
params: { sample_rate: 16000, channels: 1 },
inputs: { video_uri: "input.video_uri" },
},
{
id: "asr",
node_type: "faster-whisper",
params: { language: "ja" },
inputs: { audio_uri: "extract.audio_uri" },
},
{
id: "translate",
node_type: "llm-translate",
params: { target_language: "zh-CN" },
inputs: { srt_uri: "asr.srt_uri" },
},
{
id: "ass",
node_type: "srt-to-dual-eye-ass",
params: { resolution: "3840x1920" },
inputs: { cn_srt_uri: "translate.cn_srt_uri" },
},
],
edges: [
{ from: "extract", to: "asr" },
{ from: "asr", to: "translate" },
{ from: "translate", to: "ass" },
],
entry_inputs: { video_uri: "file" },
final_outputs: {
cn_srt: "translate.cn_srt_uri",
ass: "ass.ass_uri",
},
};
// 统一封装 fetch:自动携带 JSON 头、解析响应并在失败时抛出可读错误。
async function api(path, options = {}) {
const response = await fetch(path, {
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
...options,
});
const data = await response.json().catch(() => null);
if (!response.ok) {
// FastAPI 的校验错误 detail 可能是数组,统一序列化为字符串展示。
const detail = data && data.detail ? JSON.stringify(data.detail) : response.statusText;
throw new Error(`${response.status} ${detail}`);
}
return data;
}
// 转义用户可控文本,防止 XSS 注入到表格或状态 HTML 中。
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
// 根据状态生成带语义颜色的徽章 HTML。
function badge(status) {
// 统一转小写比较,兼容后端返回的不同大小写。
const value = String(status).toLowerCase();
const className =
value === "completed" || value === "ok"
? "ok"
: value === "error" || value === "failed"
? "error"
: "warn";
return `<span class="badge ${className}">${escapeHtml(status)}</span>`;
}
// 把 ISO 时间格式化为本地时间;非法值原样返回。
function formatTime(value) {
if (!value) return "";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
// 计算距给定时间的流逝时长,用于任务页展示已运行时间。
function formatElapsed(value) {
if (!value) return "";
const seconds = Math.floor((Date.now() - new Date(value).getTime()) / 1000);
if (Number.isNaN(seconds) || seconds < 0) return "";
if (seconds < 60) return `${seconds} 秒`;
return `${Math.floor(seconds / 60)} 分钟`;
}
// 健康检查失败时在页面顶部展示后端不可用提示。
async function loadHealth() {
try {
await api("/health");
} catch (error) {
const banner = document.createElement("div");
banner.className = "result";
banner.textContent = `后端不可用:${error.message}`;
document.body.prepend(banner);
}
}
// 加载工作流列表并渲染发布状态与操作按钮。
async function loadWorkflows() {
const workflows = await api("/api/admin/workflows");
const tbody = document.getElementById("workflowList");
if (!tbody) return;
tbody.innerHTML = workflows.length
? workflows
.map(
(workflow) => `
<tr>
<td>${escapeHtml(workflow.id)}</td>
<td>${escapeHtml(workflow.name)}</td>
<td>${escapeHtml(workflow.latest_version)}</td>
<td>${workflow.published ? badge("ok") : badge("draft")}</td>
<td>
<button data-edit-workflow="${escapeHtml(workflow.id)}">编辑</button>
<button data-versions-workflow="${escapeHtml(workflow.id)}">版本</button>
<button class="danger" data-delete-workflow="${escapeHtml(workflow.id)}">删除</button>
${workflow.published ? "" : `<button data-publish-workflow="${escapeHtml(workflow.id)}">发布</button>`}
</td>
</tr>`,
)
.join("")
: '<tr><td colspan="5">暂无工作流</td></tr>';
}
// 加载已发布的工作流(应用),填充首页的下拉选择框。
// 已发布工作流缓存(id → {name, definition}),供按需切换 OCR 面板。
let workflowApps = [];
// 需要 crop 的节点 ID(如 frame-extract)与用户框选的 crop 值。
let cropNodeId = null;
let selectedCrop = null;
// 框选模式:开启后才让画布接收鼠标事件,平时不拦截视频控件。
let drawMode = false;
// 当前选择的视频文件:change 时保存,提交时使用(避免被重置清空 input 丢失)。
let selectedVideoFile = null;
async function loadWorkflowOptions() {
const select = document.getElementById("workflowSelect");
if (!select) return;
workflowApps = await api("/api/apps");
select.innerHTML = workflowApps.length
? workflowApps
.map(
(app) =>
`<option value="${escapeHtml(app.id)}">${escapeHtml(app.name)}</option>`,
)
.join("")
: '<option value="">暂无可用工作流</option>';
// 切换工作流时按需展示框选面板。
select.addEventListener("change", onWorkflowChange);
await onWorkflowChange();
}
// 切换工作流:若 DAG 中存在带 crop 参数的节点(字幕 OCR 流程),
// 切换到框选面板;否则使用标准上传。
async function onWorkflowChange() {
const select = document.getElementById("workflowSelect");
const ocrCard = document.getElementById("ocrCard");
const standardCard = document.getElementById("standardCard");
if (!select || !ocrCard || !standardCard) return;
cropNodeId = null;
selectedCrop = null;
const app = workflowApps.find((item) => item.id === select.value);
// 数据驱动判断:节点 params 中声明了 crop 即需要框选。
const cropNode = (app?.definition?.nodes || []).find(
(node) => node.params && node.params.crop !== undefined,
);
const needsCrop = Boolean(cropNode);
cropNodeId = needsCrop ? cropNode.id : null;
ocrCard.hidden = !needsCrop;
standardCard.hidden = needsCrop;
resetOcrPanel();
if (needsCrop) {
document.getElementById("ocrProgress").textContent = "请选择视频并框选字幕区域";
}
}
// 重置 OCR 面板:清除视频、画布与框选状态。
function resetOcrPanel() {
const video = document.getElementById("ocrVideo");
const canvas = document.getElementById("ocrCanvas");
const fileInput = document.getElementById("ocrVideoFile");
const cropValue = document.getElementById("cropValue");
const submit = document.getElementById("ocrSubmit");
selectedCrop = null;
if (video) video.removeAttribute("src");
if (canvas) {
canvas.width = 0;
canvas.height = 0;
}
if (fileInput) fileInput.value = "";
if (cropValue) cropValue.value = "";
if (submit) submit.disabled = true;
selectedVideoFile = null;
exitDrawMode();
}
// 用 crop.js 把画布上的框选矩形归一化为 crop 比例并展示。
function applyCropRect(rect) {
const video = document.getElementById("ocrVideo");
const box = document.getElementById("videoBox");
if (!video.videoWidth || !box) return;
const crop = rectToCrop(
rect,
video.videoWidth,
video.videoHeight,
box.clientWidth,
box.clientHeight,
);
selectedCrop = crop;
document.getElementById("cropValue").value = crop.join(", ");
document.getElementById("ocrSubmit").disabled = false;
document.getElementById("ocrProgress").textContent =
`已框选 crop=[${crop.join(", ")}],可提交任务`;
}
// 进入框选模式:暂停视频、隐藏原生控件、启用画布绘制。
function enterDrawMode() {
const video = document.getElementById("ocrVideo");
const canvas = document.getElementById("ocrCanvas");
const toggle = document.getElementById("ocrDrawMode");
if (!video.videoWidth) {
document.getElementById("ocrProgress").textContent = "请先选择视频并定位到有字幕的画面";
return;
}
drawMode = true;
video.pause();
video.removeAttribute("controls"); // 隐藏进度条,避免遮挡框选操作。
canvas.classList.add("drawable");
if (toggle) toggle.textContent = "退出框选模式";
document.getElementById("ocrProgress").textContent = "请拖动框选字幕区域";
}
// 退出框选模式:恢复原生控件,画布不再拦截指针。
function exitDrawMode() {
const video = document.getElementById("ocrVideo");
const canvas = document.getElementById("ocrCanvas");
const toggle = document.getElementById("ocrDrawMode");
drawMode = false;
if (video) video.setAttribute("controls", "");
if (canvas) canvas.classList.remove("drawable");
if (toggle) toggle.textContent = "进入框选模式";
}
// 初始化 OCR 面板:视频预览 + 画布拖动框选。
function setupOcrPanel() {
const fileInput = document.getElementById("ocrVideoFile");
const video = document.getElementById("ocrVideo");
const canvas = document.getElementById("ocrCanvas");
const resetButton = document.getElementById("ocrReset");
if (!fileInput || !video || !canvas) return;
// 选择视频后显示预览并设置画布尺寸与坐标换算。
// 注意顺序:必须先 reset(会清空旧 src),再设置新 src,否则被清掉。
fileInput.addEventListener("change", () => {
const file = fileInput.files[0];
if (!file) return;
resetOcrPanel();
// 重置会清空 input,这里把文件引用保存下来供提交使用。
selectedVideoFile = file;
video.src = URL.createObjectURL(file);
video.load();
video.onloadedmetadata = () => {
const box = document.getElementById("videoBox");
canvas.width = box.clientWidth;
canvas.height = box.clientHeight;
document.getElementById("ocrProgress").textContent = "请在预览中拖动框选字幕区域";
};
});
// 框选模式开关:进入/退出。
const drawToggle = document.getElementById("ocrDrawMode");
if (drawToggle) {
drawToggle.addEventListener("click", () => {
if (drawMode) {
exitDrawMode();
} else {
enterDrawMode();
}
});
}
// 拖动绘制框选矩形:mousedown 起点 → mousemove 更新 → mouseup 生成 crop。
let startX = 0;
let startY = 0;
let drawing = false;
canvas.addEventListener("mousedown", (event) => {
if (!drawMode) return;
const rect = canvas.getBoundingClientRect();
startX = event.clientX - rect.left;
startY = event.clientY - rect.top;
drawing = true;
});
canvas.addEventListener("mousemove", (event) => {
if (!drawing || !drawMode) return;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = "#ff5252";
ctx.lineWidth = 2;
ctx.strokeRect(Math.min(startX, x), Math.min(startY, y), Math.abs(x - startX), Math.abs(y - startY));
});
canvas.addEventListener("mouseup", (event) => {
if (!drawing || !drawMode) return;
drawing = false;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const w = Math.abs(x - startX);
const h = Math.abs(y - startY);
if (w < 5 || h < 5) return;
applyCropRect({
x: Math.min(startX, x),
y: Math.min(startY, y),
w,
h,
});
});
// 清除框选:清空画布与 crop,禁用提交。
resetButton.addEventListener("click", () => {
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
selectedCrop = null;
document.getElementById("cropValue").value = "";
document.getElementById("ocrSubmit").disabled = true;
document.getElementById("ocrProgress").textContent = "请在预览中拖动框选字幕区域";
});
}
// 提交字幕 OCR 任务:携带 crop 覆盖参数,框选完成才可用。
async function submitOcr() {
const fileInput = document.getElementById("ocrVideoFile");
const progress = document.getElementById("ocrProgress");
if (!selectedCrop) {
progress.textContent = "请先框选字幕区域";
return;
}
const file = selectedVideoFile;
if (!file) {
progress.textContent = "请先选择视频文件";
return;
}
const workflowId = document.getElementById("workflowSelect").value;
const form = new FormData();
form.append("file", file);
// 把框选的 crop 传给需要它的节点(如 frame-extract)。
form.append("params", JSON.stringify({ [cropNodeId]: { crop: selectedCrop } }));
progress.textContent = "上传中...";
try {
const response = await fetch(`/api/apps/${encodeURIComponent(workflowId)}/runs`, {
method: "POST",
body: form,
});
const data = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(data && data.detail ? JSON.stringify(data.detail) : response.statusText);
}
progress.textContent = `任务 ${data.id} 已创建,正在跳转到任务管理...`;
window.location.href = `/tasks.html?run=${encodeURIComponent(data.id)}`;
} catch (error) {
progress.textContent = `创建失败:${error.message}`;
}
}
// 渲染单个任务的进度条 HTML;失败任务用红色填充。
function progressBar(percent, failed) {
return `<div class="progress"><div class="progress-bar ${failed ? "error" : ""}" style="width:${Math.min(100, percent)}%"></div></div>`;
}
// 为已完成任务生成最终产物下载链接(ja_srt / cn_srt / ass)。
// 日语转写是翻译前的中间产物,归档下来便于回看与对照译文。
function artifactLinks(runId, status) {
if (status !== "COMPLETED") return "-";
return `
<div class="downloads-inline">
<a class="download-link" href="/api/runs/${encodeURIComponent(runId)}/artifacts/ja_srt">日语 SRT</a>
<a class="download-link" href="/api/runs/${encodeURIComponent(runId)}/artifacts/cn_srt">中文 SRT</a>
<a class="download-link" href="/api/runs/${encodeURIComponent(runId)}/artifacts/ass">VR ASS</a>
</div>`;
}
// 加载最近任务并渲染任务表格:状态、当前节点、进度条、产物与重试。
async function loadRuns() {
const tbody = document.getElementById("runList");
if (!tbody) return;
const runs = await api("/api/runs");
tbody.innerHTML = runs.length
? runs
.map((run) => {
const percent = Math.round((run.progress || 0) * 100);
const failed = run.status === "FAILED";
// RUNNING/QUEUED 附加耗时或排队提示,其余状态只显示徽章。
const statusHtml =
run.status === "RUNNING"
? `${badge(run.status)} <span class="muted">已运行 ${formatElapsed(run.updated_at)}</span>`
: run.status === "QUEUED"
? `${badge(run.status)} <span class="muted">排队中</span>`
: run.status === "PAUSED"
? `${badge(run.status)} <span class="muted">已暂停 ${formatElapsed(run.updated_at)}</span>`
: badge(run.status);
// 失败任务提供重试,所有任务均可删除。
// 排队/运行中可暂停,暂停后可继续,失败可重试,所有任务可删除。
const canPause = run.status === "RUNNING" || run.status === "QUEUED";
const canResume = run.status === "PAUSED";
const actions = `
${canPause ? `<button class="warn" data-pause-run="${escapeHtml(run.id)}">暂停</button>` : ""}
${canResume ? `<button class="warn" data-resume-run="${escapeHtml(run.id)}">继续</button>` : ""}
${failed ? `<button class="danger" data-retry-run="${escapeHtml(run.id)}">重试</button>` : ""}
<button class="danger" data-delete-run="${escapeHtml(run.id)}">删除</button>
`;
return `
<tr>
<td title="${escapeHtml(run.error || "")}">${escapeHtml(run.id)}</td>
<td>${escapeHtml(run.workflow_id)}</td>
<td>${statusHtml}</td>
<td>${escapeHtml(run.current_node_id || "-")}</td>
<td>${progressBar(percent, failed)} ${percent}%</td>
<td>${escapeHtml(formatTime(run.created_at))}</td>
<td>${artifactLinks(run.id, run.status)}</td>
<td>${actions}</td>
</tr>`;
})
.join("")
: '<tr><td colspan="8">暂无任务,请到首页发起。</td></tr>';
}
// 请求后端重试失败任务,成功后刷新列表。
async function retryRun(runId) {
try {
const result = await api(`/api/runs/${runId}/retry`, { method: "POST" });
alert(`任务 ${result.id} 已重新排队`);
await loadRuns();
} catch (error) {
alert(`重试失败:${error.message}`);
}
}
// 暂停任务:排队或运行中的任务置为 PAUSED,运行中的任务在节点边界停下。
async function pauseRun(runId) {
try {
const result = await api(`/api/runs/${runId}/pause`, { method: "POST" });
alert(`任务 ${result.id} 已暂停`);
await loadRuns();
} catch (error) {
alert(`暂停失败:${error.message}`);
}
}
// 继续任务:PAUSED 恢复排队,由调度器从断点继续执行。
async function resumeRun(runId) {
try {
const result = await api(`/api/runs/${runId}/resume`, { method: "POST" });
alert(`任务 ${result.id} 已恢复执行`);
await loadRuns();
} catch (error) {
alert(`继续失败:${error.message}`);
}
}
// 删除任务:二次确认后调用后端删除接口并刷新列表。
async function deleteRun(runId) {
if (!window.confirm(`确认删除任务 ${runId}?相关产物文件将一并删除。`)) {
return;
}
try {
await api(`/api/runs/${runId}`, { method: "DELETE" });
await loadRuns();
} catch (error) {
alert(`删除失败:${error.message}`);
}
}
// 当前编辑模式:null=新建;否则为正在编辑的工作流 ID(保存追加新版本)。
let editingWorkflowId = null;
// 切换编辑器模式:id 为空进入新建,否则锁定 ID 并提示保存将追加新版本。
function setEditMode(id) {
editingWorkflowId = id;
const idInput = document.getElementById("workflowId");
const mode = document.getElementById("editMode");
const save = document.getElementById("saveWorkflow");
if (!idInput || !mode || !save) return;
if (id) {
idInput.value = id;
idInput.disabled = true; // 编辑模式下锁定 ID,防止误建新工作流。
mode.textContent = `编辑中:${id} —— 保存将追加为新版本。`;
save.textContent = "保存为新版本";
} else {
idInput.disabled = false;
mode.textContent = "新建工作流:填写 ID 与 DAG 后保存。";
save.textContent = "创建工作流";
}
}
// 新建模式:清空表单并预填演示 DAG 模板。
function resetWorkflowForm() {
document.getElementById("workflowId").value = "";
document.getElementById("workflowName").value = "";
document.getElementById("workflowDescription").value = "";
document.getElementById("workflowDefinition").value = JSON.stringify(DEMO_WORKFLOW, null, 2);
setEditMode(null);
}
// 加载指定工作流的最新定义到编辑器(列表"编辑"按钮)。
// 当前页面没有编辑器表单(如管理后台列表)时跳转到编排页并自动加载。
async function loadWorkflowForEdit(workflowId) {
if (!document.getElementById("workflowName")) {
window.location.href = `/workflow.html?edit=${encodeURIComponent(workflowId)}`;
return;
}
try {
const workflow = await api(`/api/admin/workflows/${workflowId}`);
document.getElementById("workflowName").value = workflow.name || "";
document.getElementById("workflowDescription").value = workflow.description || "";
const latest = workflow.latest_version_data;
document.getElementById("workflowDefinition").value = latest
? JSON.stringify(latest.definition, null, 2)
: JSON.stringify({ name: "", version: 1, nodes: [], edges: [], entry_inputs: {}, final_outputs: {} }, null, 2);
setEditMode(workflowId);
} catch (error) {
alert(`加载失败:${error.message}`);
}
}
// 展示指定工作流的版本历史面板。
async function loadWorkflowVersions(workflowId) {
try {
const versions = await api(`/api/admin/workflows/${workflowId}/versions`);
const tbody = document.getElementById("versionList");
document.getElementById("versionPanelTitle").textContent = `版本历史:${workflowId}`;
tbody.innerHTML = versions.length
? versions
.map(
(version) => `
<tr>
<td>v${escapeHtml(version.version)}</td>
<td>${escapeHtml(formatTime(version.created_at))}</td>
<td><button class="ghost" data-load-version="${escapeHtml(workflowId)}:${escapeHtml(version.version)}">加载到编辑器</button></td>
</tr>`,
)
.join("")
: '<tr><td colspan="3">暂无版本</td></tr>';
document.getElementById("versionPanel").hidden = false;
} catch (error) {
alert(`版本加载失败:${error.message}`);
}
}
// 关闭版本历史面板。
function closeVersionPanel() {
const panel = document.getElementById("versionPanel");
if (panel) panel.hidden = true;
}
// 把指定工作流的某历史版本定义加载回编辑器(可对比、回滚后保存为新版本)。
async function loadVersionIntoEditor(workflowId, version) {
try {
const versions = await api(`/api/admin/workflows/${workflowId}/versions`);
const target = versions.find((item) => item.version === Number(version));
if (!target) {
alert(`版本 v${version} 不存在`);
return;
}
const workflow = await api(`/api/admin/workflows/${workflowId}`);
document.getElementById("workflowName").value = workflow.name || "";
document.getElementById("workflowDescription").value = workflow.description || "";
document.getElementById("workflowDefinition").value = JSON.stringify(target.definition, null, 2);
setEditMode(workflowId);
closeVersionPanel();
} catch (error) {
alert(`版本加载失败:${error.message}`);
}
}
// 创建或保存工作流:新建时创建首个版本;编辑时(ID 已锁定)追加新版本。
async function createWorkflow() {
const workflowId = document.getElementById("workflowId").value.trim();
const name = document.getElementById("workflowName").value.trim();
const description = document.getElementById("workflowDescription").value.trim();
let definition;
try {
definition = JSON.parse(document.getElementById("workflowDefinition").value);
} catch (error) {
alert(`DAG JSON 无效:${error.message}`);
return;
}
try {
const result = await api("/api/admin/workflows", {
method: "POST",
body: JSON.stringify({
id: workflowId || undefined,
name,
description,
definition,
}),
});
alert(`工作流已保存:${result.id} v${result.latest_version}`);
await loadWorkflows();
// 编辑模式保持编辑状态并刷新最新版本;新建模式回到空表单。
if (editingWorkflowId) {
await loadWorkflowForEdit(editingWorkflowId);
} else {
resetWorkflowForm();
}
} catch (error) {
alert(`保存失败:${error.message}`);
}
}
// 发布指定工作流,使其出现在用户应用中心。
async function publishWorkflow(workflowId) {
try {
await api(`/api/admin/workflows/${workflowId}/publish`, { method: "POST" });
await loadWorkflows();
} catch (error) {
alert(`发布失败:${error.message}`);
}
}
// 删除指定工作流。
async function deleteWorkflow(workflowId) {
try {
await api(`/api/admin/workflows/${workflowId}`, { method: "DELETE" });
await loadWorkflows();
} catch (error) {
alert(`删除失败:${error.message}`);
}
}
// 首页发起任务:选择工作流并上传视频,创建任务后跳转到任务管理页查看进度。
async function uploadVideo() {
const workflowId = document.getElementById("workflowSelect").value;
const fileInput = document.getElementById("videoFile");
const progress = document.getElementById("runProgress");
if (!workflowId) {
progress.textContent = "暂无可用工作流";
return;
}
if (!fileInput.files.length) {
progress.textContent = "请先选择视频文件";
return;
}
const form = new FormData();
form.append("file", fileInput.files[0]);
progress.textContent = "上传中...";
try {
const response = await fetch(`/api/apps/${encodeURIComponent(workflowId)}/runs`, {
method: "POST",
body: form,
});
const data = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(data && data.detail ? JSON.stringify(data.detail) : response.statusText);
}
progress.textContent = `任务 ${data.id} 已创建,正在跳转到任务管理...`;
// 首页只负责发起任务,进度展示交给任务管理页。
window.location.href = `/tasks.html?run=${encodeURIComponent(data.id)}`;
} catch (error) {
progress.textContent = `创建失败:${error.message}`;
}
}
// 全局点击委托:按按钮上的 data-* 属性分发到对应操作。
document.addEventListener("click", (event) => {
const editButton = event.target.closest("[data-edit-workflow]");
if (editButton) {
loadWorkflowForEdit(editButton.dataset.editWorkflow);
return;
}
const versionsButton = event.target.closest("[data-versions-workflow]");
if (versionsButton) {
loadWorkflowVersions(versionsButton.dataset.versionsWorkflow);
return;
}
const loadVersionButton = event.target.closest("[data-load-version]");
if (loadVersionButton) {
const [workflowId, version] = loadVersionButton.dataset.loadVersion.split(":");
loadVersionIntoEditor(workflowId, version);
return;
}
const deleteWorkflowButton = event.target.closest("[data-delete-workflow]");
if (deleteWorkflowButton) {
deleteWorkflow(deleteWorkflowButton.dataset.deleteWorkflow);
return;
}
const publishWorkflowButton = event.target.closest("[data-publish-workflow]");
if (publishWorkflowButton) {
publishWorkflow(publishWorkflowButton.dataset.publishWorkflow);
return;
}
const retryButton = event.target.closest("[data-retry-run]");
if (retryButton) {
retryRun(retryButton.dataset.retryRun);
return;
}
const pauseButton = event.target.closest("[data-pause-run]");
if (pauseButton) {
pauseRun(pauseButton.dataset.pauseRun);
return;
}
const resumeButton = event.target.closest("[data-resume-run]");
if (resumeButton) {
resumeRun(resumeButton.dataset.resumeRun);
return;
}
const deleteRunButton = event.target.closest("[data-delete-run]");
if (deleteRunButton) {
deleteRun(deleteRunButton.dataset.deleteRun);
}
});
// 页面初始化:预填 DAG、绑定按钮事件并加载对应页面数据。
document.addEventListener("DOMContentLoaded", async () => {
// 工作流编辑器:默认进入新建模式并预填演示模板;
// 支持 URL 参数 ?edit=<id> 直接加载指定工作流进行编辑。
if (document.getElementById("workflowDefinition")) {
resetWorkflowForm();
const params = new URLSearchParams(window.location.search);
const editId = params.get("edit");
if (editId) {
await loadWorkflowForEdit(editId);
}
}
const saveWorkflowButton = document.getElementById("saveWorkflow");
if (saveWorkflowButton) {
saveWorkflowButton.addEventListener("click", createWorkflow);
}
const resetWorkflowButton = document.getElementById("resetWorkflow");
if (resetWorkflowButton) {
resetWorkflowButton.addEventListener("click", resetWorkflowForm);
}
const closeVersionsButton = document.getElementById("closeVersions");
if (closeVersionsButton) {
closeVersionsButton.addEventListener("click", closeVersionPanel);
}
const publishWorkflowButton = document.getElementById("publishWorkflow");
if (publishWorkflowButton) {
publishWorkflowButton.addEventListener("click", () => {
publishWorkflow(document.getElementById("workflowId").value.trim());
});
}
const uploadButton = document.getElementById("uploadVideo");
if (uploadButton) {
uploadButton.addEventListener("click", uploadVideo);
// 首页加载已发布工作流供用户选择。
await loadWorkflowOptions();
// 字幕 OCR 面板:视频预览 + 拖动框选(仅需 crop 的工作流展示)。
setupOcrPanel();
}
const ocrSubmitButton = document.getElementById("ocrSubmit");
if (ocrSubmitButton) {
ocrSubmitButton.addEventListener("click", submitOcr);
}
await loadHealth();
if (document.getElementById("workflowList")) {
await loadWorkflows();
}
// 任务管理页:每 3 秒刷新一次全部任务的进度,支持高亮跳转参数 run=ID。
if (document.getElementById("runList")) {
await loadRuns();
const params = new URLSearchParams(window.location.search);
const target = params.get("run");
if (target) {
const row = [...document.querySelectorAll("#runList tr")].find((item) =>
item.textContent.includes(target),
);
if (row) {
row.scrollIntoView({ block: "center" });
row.style.background = "#fff7db";
}
}
setInterval(loadRuns, 3000);
}
});