let selectedTaskId = null; let selectedStepName = null; let currentTasks = []; let currentSettings = {}; let currentSettingsSchema = null; let currentView = "overview"; function statusClass(status) { if (["collection_synced", "published", "commented", "succeeded", "active"].includes(status)) return "good"; if (["failed_manual", "failed_retryable", "inactive"].includes(status)) return "hot"; if (["running", "activating", "songs_detected", "split_done", "transcribed", "created", "pending"].includes(status)) return "warn"; return ""; } function showBanner(message, kind) { const el = document.getElementById("banner"); el.textContent = message; el.className = `banner show ${kind}`; } function escapeHtml(text) { return String(text) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">"); } function formatDate(value) { if (!value) return "-"; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleString("zh-CN", { hour12: false }); } function formatDuration(seconds) { if (seconds == null || Number.isNaN(Number(seconds))) return "-"; const total = Math.max(0, Number(seconds)); const h = Math.floor(total / 3600); const m = Math.floor((total % 3600) / 60); const s = total % 60; if (h > 0) return `${h}h ${m}m ${s}s`; if (m > 0) return `${m}m ${s}s`; return `${s}s`; } function setView(view) { currentView = view; document.querySelectorAll(".nav-btn").forEach((button) => { button.classList.toggle("active", button.dataset.view === view); }); document.querySelectorAll(".view").forEach((section) => { section.classList.toggle("active", section.dataset.view === view); }); const titleMap = { overview: "Overview", tasks: "Tasks", settings: "Settings", logs: "Logs", }; document.getElementById("viewTitle").textContent = titleMap[view] || "Control"; } async function fetchJson(url, options) { const token = localStorage.getItem("biliup_next_token") || ""; const opts = options ? { ...options } : {}; opts.headers = { ...(opts.headers || {}) }; if (token) opts.headers["X-Biliup-Token"] = token; const res = await fetch(url, opts); const data = await res.json(); if (!res.ok) throw new Error(data.message || data.error || JSON.stringify(data)); return data; } function buildHistoryUrl() { const params = new URLSearchParams(); params.set("limit", "20"); const status = document.getElementById("historyStatusFilter")?.value || ""; const actionName = document.getElementById("historyActionFilter")?.value.trim() || ""; const currentOnly = document.getElementById("historyCurrentTask")?.checked; if (status) params.set("status", status); if (actionName) params.set("action_name", actionName); if (currentOnly && selectedTaskId) params.set("task_id", selectedTaskId); return `/history?${params.toString()}`; } function filteredTasks() { const search = (document.getElementById("taskSearchInput")?.value || "").trim().toLowerCase(); const status = document.getElementById("taskStatusFilter")?.value || ""; const sort = document.getElementById("taskSortSelect")?.value || "updated_desc"; let items = currentTasks.filter((task) => { const haystack = `${task.id} ${task.title}`.toLowerCase(); if (search && !haystack.includes(search)) return false; if (status && task.status !== status) return false; return true; }); const statusRank = { failed_manual: 0, failed_retryable: 1, running: 2, created: 3, transcribed: 4, songs_detected: 5, split_done: 6, published: 7, collection_synced: 8, }; items = [...items].sort((a, b) => { if (sort === "updated_asc") return String(a.updated_at).localeCompare(String(b.updated_at)); if (sort === "title_asc") return String(a.title).localeCompare(String(b.title)); if (sort === "status_group") { const diff = (statusRank[a.status] ?? 99) - (statusRank[b.status] ?? 99); if (diff !== 0) return diff; return String(b.updated_at).localeCompare(String(a.updated_at)); } return String(b.updated_at).localeCompare(String(a.updated_at)); }); return items; } async function loadOverview() { const historyUrl = buildHistoryUrl(); const [health, doctor, tasks, modules, settings, settingsSchema, services, logs, history] = await Promise.all([ fetchJson("/health"), fetchJson("/doctor"), fetchJson("/tasks?limit=100"), fetchJson("/modules"), fetchJson("/settings"), fetchJson("/settings/schema"), fetchJson("/runtime/services"), fetchJson("/logs"), fetchJson(historyUrl), ]); currentTasks = tasks.items; currentSettings = settings; currentSettingsSchema = settingsSchema; document.getElementById("tokenInput").value = localStorage.getItem("biliup_next_token") || ""; document.getElementById("healthValue").textContent = health.ok ? "OK" : "FAIL"; document.getElementById("doctorValue").textContent = doctor.ok ? "OK" : "FAIL"; document.getElementById("tasksValue").textContent = tasks.items.length; document.getElementById("overviewHealthValue").textContent = health.ok ? "OK" : "FAIL"; document.getElementById("overviewDoctorValue").textContent = doctor.ok ? "OK" : "FAIL"; document.getElementById("overviewTasksValue").textContent = tasks.items.length; renderSettingsForm(); syncSettingsEditorFromState(); renderTasks(); renderModules(modules.items); renderDoctor(doctor.checks); renderServices(services.items); renderLogsList(logs.items); renderRecentActions(history.items); if (!selectedTaskId && currentTasks.length) selectedTaskId = currentTasks[0].id; if (selectedTaskId) await loadTaskDetail(selectedTaskId); } function renderTasks() { const wrap = document.getElementById("taskList"); wrap.innerHTML = ""; const items = filteredTasks(); if (!items.length) { wrap.innerHTML = `
没有匹配任务
调整搜索、状态或排序条件后重试。
`; return; } for (const item of items) { const el = document.createElement("div"); el.className = `task-card ${item.id === selectedTaskId ? "active" : ""}`; const retryText = item.retry_state?.next_retry_at ? `next retry ${formatDate(item.retry_state.next_retry_at)}` : ""; el.innerHTML = `
${escapeHtml(item.title)}
${escapeHtml(item.status)} ${escapeHtml(item.id)}
${escapeHtml(formatDate(item.updated_at))}
${retryText ? `
${escapeHtml(retryText)}
` : ""} `; el.onclick = async () => { selectedTaskId = item.id; setView("tasks"); renderTasks(); await loadTaskDetail(item.id); }; wrap.appendChild(el); } } function renderTaskHero(task, steps) { const wrap = document.getElementById("taskHero"); const succeeded = steps.items.filter((step) => step.status === "succeeded").length; const running = steps.items.filter((step) => step.status === "running").length; const failed = steps.items.filter((step) => step.status.startsWith("failed")).length; wrap.className = "task-hero"; wrap.innerHTML = `
${escapeHtml(task.title)}
${escapeHtml(task.id)} · ${escapeHtml(task.source_path)}
Task Status
${escapeHtml(task.status)}
Succeeded Steps
${succeeded}/${steps.items.length}
Running / Failed
${running} / ${failed}
`; } function renderRetryPanel(task) { const wrap = document.getElementById("taskRetryPanel"); const retry = task.retry_state; if (!retry || !retry.next_retry_at) { wrap.className = "retry-banner"; wrap.style.display = "none"; wrap.textContent = ""; return; } wrap.style.display = "block"; wrap.className = `retry-banner show ${retry.retry_due ? "good" : "warn"}`; wrap.innerHTML = ` ${escapeHtml(retry.step_name)} ${retry.retry_due ? " 已到重试时间" : " 正在等待下一次重试"}
next retry at ${escapeHtml(formatDate(retry.next_retry_at))} · remaining ${escapeHtml(formatDuration(retry.retry_remaining_seconds))} · wait ${escapeHtml(formatDuration(retry.retry_wait_seconds))}
`; } async function loadTaskDetail(taskId) { const [task, steps, artifacts, history, timeline] = await Promise.all([ fetchJson(`/tasks/${taskId}`), fetchJson(`/tasks/${taskId}/steps`), fetchJson(`/tasks/${taskId}/artifacts`), fetchJson(`/tasks/${taskId}/history`), fetchJson(`/tasks/${taskId}/timeline`), ]); renderTaskHero(task, steps); renderRetryPanel(task); const detail = document.getElementById("taskDetail"); detail.innerHTML = ""; const pairs = [ ["Task ID", task.id], ["Status", task.status], ["Created", formatDate(task.created_at)], ["Updated", formatDate(task.updated_at)], ["Source", task.source_path], ["Next Retry", task.retry_state?.next_retry_at ? formatDate(task.retry_state.next_retry_at) : "-"], ]; for (const [key, value] of pairs) { const k = document.createElement("div"); k.className = "detail-key"; k.textContent = key; const v = document.createElement("div"); v.textContent = value || "-"; detail.appendChild(k); detail.appendChild(v); } let summaryText = "暂无最近结果"; const latestAction = history.items[0]; if (latestAction) { summaryText = `最近动作: ${latestAction.action_name} / ${latestAction.status} / ${latestAction.summary}`; } else { const priority = ["failed_manual", "failed_retryable", "running", "succeeded", "pending"]; const sortedSteps = [...steps.items].sort((a, b) => priority.indexOf(a.status) - priority.indexOf(b.status)); const summaryStep = sortedSteps.find((step) => step.status !== "pending") || steps.items[0]; if (summaryStep) { summaryText = summaryStep.error_message ? `最近异常: ${summaryStep.step_name} / ${summaryStep.error_code || "ERROR"} / ${summaryStep.error_message}` : `最近结果: ${summaryStep.step_name} / ${summaryStep.status}`; } } document.getElementById("taskSummary").textContent = summaryText; const stepWrap = document.getElementById("stepList"); stepWrap.innerHTML = ""; for (const step of steps.items) { const row = document.createElement("div"); row.className = `row-card ${selectedStepName === step.step_name ? "active" : ""}`; row.style.cursor = "pointer"; const retryBlock = step.next_retry_at ? `
Next Retry ${escapeHtml(formatDate(step.next_retry_at))}
Remaining ${escapeHtml(formatDuration(step.retry_remaining_seconds))}
Wait Policy ${escapeHtml(formatDuration(step.retry_wait_seconds))}
` : ""; row.innerHTML = `
${escapeHtml(step.step_name)} ${escapeHtml(step.status)} retry ${step.retry_count}
${escapeHtml(step.error_code || "")} ${escapeHtml(step.error_message || "")}
Started ${escapeHtml(formatDate(step.started_at))}
Finished ${escapeHtml(formatDate(step.finished_at))}
${retryBlock} `; row.onclick = () => { selectedStepName = step.step_name; loadTaskDetail(taskId).catch((err) => showBanner(`任务详情刷新失败: ${err}`, "err")); }; stepWrap.appendChild(row); } const artifactWrap = document.getElementById("artifactList"); artifactWrap.innerHTML = ""; for (const artifact of artifacts.items) { const row = document.createElement("div"); row.className = "row-card"; row.innerHTML = `
${escapeHtml(artifact.artifact_type)}
${escapeHtml(artifact.path)}
${escapeHtml(formatDate(artifact.created_at))}
`; artifactWrap.appendChild(row); } const historyWrap = document.getElementById("historyList"); historyWrap.innerHTML = ""; for (const item of history.items) { let details = ""; try { details = JSON.stringify(JSON.parse(item.details_json || "{}"), null, 2); } catch { details = item.details_json || ""; } const row = document.createElement("div"); row.className = "row-card"; row.innerHTML = `
${escapeHtml(item.action_name)} ${escapeHtml(item.status)}
${escapeHtml(item.summary)}
${escapeHtml(formatDate(item.created_at))}
${escapeHtml(details)}
`; historyWrap.appendChild(row); } const timelineWrap = document.getElementById("timelineList"); timelineWrap.innerHTML = ""; for (const item of timeline.items) { const retryNote = item.retry_state?.next_retry_at ? `
Next Retry ${escapeHtml(formatDate(item.retry_state.next_retry_at))} · remaining ${escapeHtml(formatDuration(item.retry_state.retry_remaining_seconds))}
` : ""; const row = document.createElement("div"); row.className = "timeline-card"; row.innerHTML = `
${escapeHtml(item.title)} ${escapeHtml(item.status)} ${escapeHtml(item.kind)}
${escapeHtml(item.summary || "-")}
Time ${escapeHtml(formatDate(item.time))}
${retryNote}
`; timelineWrap.appendChild(row); } } function syncSettingsEditorFromState() { document.getElementById("settingsEditor").value = JSON.stringify(currentSettings, null, 2); } function getGroupOrder(groupName) { return Number(currentSettingsSchema.group_ui?.[groupName]?.order || 9999); } function compareFieldEntries(a, b) { const orderA = Number(a[1].ui_order || 9999); const orderB = Number(b[1].ui_order || 9999); if (orderA !== orderB) return orderA - orderB; return String(a[0]).localeCompare(String(b[0])); } function createSettingsField(groupName, fieldName, fieldSchema) { const row = document.createElement("div"); row.className = "settings-field"; const label = document.createElement("label"); label.className = "settings-label"; label.textContent = fieldSchema.title || `${groupName}.${fieldName}`; if (fieldSchema.ui_widget) { const badge = document.createElement("span"); badge.className = "settings-badge"; badge.textContent = fieldSchema.ui_widget; label.appendChild(badge); } if (fieldSchema.ui_featured === true) { const badge = document.createElement("span"); badge.className = "settings-badge"; badge.textContent = "featured"; label.appendChild(badge); } row.appendChild(label); const value = currentSettings[groupName]?.[fieldName]; let input; if (fieldSchema.type === "boolean") { input = document.createElement("input"); input.type = "checkbox"; input.checked = Boolean(value); } else if (Array.isArray(fieldSchema.enum)) { input = document.createElement("select"); for (const optionValue of fieldSchema.enum) { const option = document.createElement("option"); option.value = String(optionValue); option.textContent = String(optionValue); if (value === optionValue) option.selected = true; input.appendChild(option); } } else if (fieldSchema.type === "array") { input = document.createElement("textarea"); input.style.minHeight = "96px"; input.value = JSON.stringify(value ?? [], null, 2); } else { input = document.createElement("input"); input.type = fieldSchema.sensitive ? "password" : (fieldSchema.type === "integer" ? "number" : "text"); input.value = value ?? ""; if (fieldSchema.type === "integer") { if (typeof fieldSchema.minimum === "number") input.min = String(fieldSchema.minimum); input.step = "1"; } if (fieldSchema.ui_placeholder) input.placeholder = fieldSchema.ui_placeholder; } input.dataset.group = groupName; input.dataset.field = fieldName; input.onchange = handleSettingsFieldChange; row.appendChild(input); if (fieldSchema.description || fieldSchema.sensitive) { const hint = document.createElement("div"); hint.className = "hint"; let text = fieldSchema.description || ""; if (fieldSchema.sensitive) text = `${text ? `${text} ` : ""}Sensitive`; hint.textContent = text; row.appendChild(hint); } return row; } function createSettingsGroup(groupName, fields, featured) { const entries = Object.entries(fields); if (!entries.length) return null; const group = document.createElement("div"); group.className = `settings-group ${featured ? "featured" : ""}`.trim(); const title = document.createElement("h3"); title.textContent = currentSettingsSchema.group_ui?.[groupName]?.title || groupName; group.appendChild(title); const descText = currentSettingsSchema.group_ui?.[groupName]?.description; if (descText) { const desc = document.createElement("div"); desc.className = "group-desc"; desc.textContent = descText; group.appendChild(desc); } const fieldWrap = document.createElement("div"); fieldWrap.className = "settings-fields"; for (const [fieldName, fieldSchema] of entries) { fieldWrap.appendChild(createSettingsField(groupName, fieldName, fieldSchema)); } group.appendChild(fieldWrap); return group; } function renderSettingsForm() { const wrap = document.getElementById("settingsForm"); wrap.innerHTML = ""; if (!currentSettingsSchema?.groups) return; const search = (document.getElementById("settingsSearch")?.value || "").trim().toLowerCase(); const featuredContainer = document.createElement("div"); featuredContainer.className = "settings-groups"; const advancedDetails = document.createElement("details"); advancedDetails.className = "settings-advanced"; const advancedSummary = document.createElement("summary"); advancedSummary.textContent = "Advanced Settings"; advancedDetails.appendChild(advancedSummary); const advancedContainer = document.createElement("div"); advancedContainer.className = "settings-groups"; const groupEntries = Object.entries(currentSettingsSchema.groups).sort((a, b) => getGroupOrder(a[0]) - getGroupOrder(b[0])); for (const [groupName, fields] of groupEntries) { const featuredFields = {}; const advancedFields = {}; const fieldEntries = Object.entries(fields).sort((a, b) => compareFieldEntries(a, b)); for (const [fieldName, fieldSchema] of fieldEntries) { const key = `${groupName}.${fieldName}`.toLowerCase(); if (search && !key.includes(search) && !(fieldSchema.description || "").toLowerCase().includes(search)) continue; if (fieldSchema.ui_featured === true) featuredFields[fieldName] = fieldSchema; else advancedFields[fieldName] = fieldSchema; } const featuredGroup = createSettingsGroup(groupName, featuredFields, true); if (featuredGroup) featuredContainer.appendChild(featuredGroup); const advancedGroup = createSettingsGroup(groupName, advancedFields, false); if (advancedGroup) advancedContainer.appendChild(advancedGroup); } if (!featuredContainer.children.length && !advancedContainer.children.length) { wrap.innerHTML = `
没有匹配的配置项
调整搜索关键字后重试。
`; return; } if (featuredContainer.children.length) wrap.appendChild(featuredContainer); if (advancedContainer.children.length) { advancedDetails.appendChild(advancedContainer); wrap.appendChild(advancedDetails); } } function handleSettingsFieldChange(event) { const input = event.target; const group = input.dataset.group; const field = input.dataset.field; const fieldSchema = currentSettingsSchema.groups[group][field]; let value; if (fieldSchema.type === "boolean") value = input.checked; else if (fieldSchema.type === "integer") value = Number(input.value); else if (fieldSchema.type === "array") { try { value = JSON.parse(input.value || "[]"); if (!Array.isArray(value)) throw new Error("not array"); } catch { showBanner(`${group}.${field} 必须是 JSON 数组`, "warn"); return; } } else value = input.value; if (!currentSettings[group]) currentSettings[group] = {}; currentSettings[group][field] = value; syncSettingsEditorFromState(); } function renderRecentActions(items) { const wrap = document.getElementById("recentActionList"); wrap.innerHTML = ""; for (const item of items) { const row = document.createElement("div"); row.className = "row-card"; row.innerHTML = `
${escapeHtml(item.action_name)} ${escapeHtml(item.status)}
${escapeHtml(item.task_id || "global")} / ${escapeHtml(item.summary)}
${escapeHtml(formatDate(item.created_at))}
`; wrap.appendChild(row); } } function renderModules(items) { const wrap = document.getElementById("moduleList"); wrap.innerHTML = ""; for (const item of items) { const row = document.createElement("div"); row.className = "row-card"; row.innerHTML = `
${escapeHtml(item.id)}${escapeHtml(item.provider_type)}
${escapeHtml(item.entrypoint)}
`; wrap.appendChild(row); } } function renderDoctor(checks) { const wrap = document.getElementById("doctorChecks"); wrap.innerHTML = ""; for (const check of checks) { const row = document.createElement("div"); row.className = "row-card"; row.innerHTML = `
${escapeHtml(check.name)}${check.ok ? "ok" : "fail"}
${escapeHtml(check.detail)}
`; wrap.appendChild(row); } } function renderServices(items) { const wrap = document.getElementById("serviceList"); wrap.innerHTML = ""; for (const item of items) { const row = document.createElement("div"); row.className = "service-card"; row.innerHTML = `
${escapeHtml(item.id)} ${escapeHtml(item.active_state)} ${escapeHtml(item.sub_state)}
${escapeHtml(item.fragment_path || item.description || "")}
`; wrap.appendChild(row); } wrap.querySelectorAll("button[data-service]").forEach((btn) => { btn.onclick = async () => { if (["stop", "restart"].includes(btn.dataset.action)) { const ok = window.confirm(`确认执行 ${btn.dataset.action} ${btn.dataset.service} ?`); if (!ok) return; } try { const payload = await fetchJson(`/runtime/services/${btn.dataset.service}/${btn.dataset.action}`, { method: "POST" }); await loadOverview(); showBanner(`${payload.id} ${payload.action} 完成`, payload.command_ok ? "ok" : "warn"); } catch (err) { showBanner(`service 操作失败: ${err}`, "err"); } }; }); } function renderLogsList(items) { const select = document.getElementById("logSelect"); if (!select.options.length) { for (const item of items) { const option = document.createElement("option"); option.value = item.name; option.textContent = item.name; select.appendChild(option); } } refreshLog().catch((err) => showBanner(`日志刷新失败: ${err}`, "err")); } async function refreshLog() { const name = document.getElementById("logSelect").value; if (!name) return; let url = `/logs?name=${encodeURIComponent(name)}&lines=200`; if (document.getElementById("filterCurrentTask").checked && selectedTaskId) { const currentTask = currentTasks.find((item) => item.id === selectedTaskId); if (currentTask?.title) url += `&contains=${encodeURIComponent(currentTask.title)}`; } const payload = await fetchJson(url); document.getElementById("logPath").textContent = payload.path; document.getElementById("logContent").textContent = payload.content || ""; } document.querySelectorAll(".nav-btn").forEach((button) => { button.onclick = () => setView(button.dataset.view); }); document.getElementById("refreshBtn").onclick = async () => { await loadOverview(); showBanner("视图已刷新", "ok"); }; document.getElementById("runOnceBtn").onclick = async () => { try { const result = await fetchJson("/worker/run-once", { method: "POST" }); await loadOverview(); showBanner(`Worker 已执行一轮,processed=${result.processed.length}`, "ok"); } catch (err) { showBanner(String(err), "err"); } }; document.getElementById("saveSettingsBtn").onclick = async () => { try { const payload = JSON.parse(document.getElementById("settingsEditor").value); await fetchJson("/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); await loadOverview(); showBanner("Settings 已保存", "ok"); } catch (err) { showBanner(`保存失败: ${err}`, "err"); } }; document.getElementById("syncFormToJsonBtn").onclick = () => { syncSettingsEditorFromState(); showBanner("表单已同步到 JSON", "ok"); }; document.getElementById("syncJsonToFormBtn").onclick = () => { try { currentSettings = JSON.parse(document.getElementById("settingsEditor").value); renderSettingsForm(); syncSettingsEditorFromState(); showBanner("JSON 已重绘到表单", "ok"); } catch (err) { showBanner(`JSON 解析失败: ${err}`, "err"); } }; document.getElementById("settingsSearch").oninput = () => renderSettingsForm(); document.getElementById("taskSearchInput").oninput = () => renderTasks(); document.getElementById("taskStatusFilter").onchange = () => renderTasks(); document.getElementById("taskSortSelect").onchange = () => renderTasks(); document.getElementById("importStageBtn").onclick = async () => { const sourcePath = document.getElementById("stageSourcePath").value.trim(); if (!sourcePath) return showBanner("请先输入本地文件绝对路径", "warn"); try { const result = await fetchJson("/stage/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ source_path: sourcePath }), }); document.getElementById("stageSourcePath").value = ""; showBanner(`已导入到 stage: ${result.target_path}`, "ok"); } catch (err) { showBanner(`导入失败: ${err}`, "err"); } }; document.getElementById("uploadStageBtn").onclick = async () => { const input = document.getElementById("stageFileInput"); if (!input.files?.length) return showBanner("请先选择一个本地文件", "warn"); try { const form = new FormData(); form.append("file", input.files[0]); const res = await fetch("/stage/upload", { method: "POST", body: form }); const data = await res.json(); if (!res.ok) throw new Error(data.error || JSON.stringify(data)); input.value = ""; showBanner(`已上传到 stage: ${data.target_path}`, "ok"); } catch (err) { showBanner(`上传失败: ${err}`, "err"); } }; document.getElementById("refreshLogBtn").onclick = () => refreshLog().then(() => showBanner("日志已刷新", "ok")).catch((err) => showBanner(`日志刷新失败: ${err}`, "err")); document.getElementById("logSelect").onchange = () => refreshLog().catch((err) => showBanner(`日志刷新失败: ${err}`, "err")); document.getElementById("refreshHistoryBtn").onclick = () => loadOverview().then(() => showBanner("动作流已刷新", "ok")).catch((err) => showBanner(`动作流刷新失败: ${err}`, "err")); document.getElementById("saveTokenBtn").onclick = async () => { const token = document.getElementById("tokenInput").value.trim(); localStorage.setItem("biliup_next_token", token); try { await loadOverview(); showBanner("Token 已保存并生效", "ok"); } catch (err) { showBanner(`Token 验证失败: ${err}`, "err"); } }; document.getElementById("runTaskBtn").onclick = async () => { if (!selectedTaskId) return showBanner("当前没有选中的任务", "warn"); try { const result = await fetchJson(`/tasks/${selectedTaskId}/actions/run`, { method: "POST" }); await loadOverview(); showBanner(`任务已推进,processed=${result.processed.length}`, "ok"); } catch (err) { showBanner(`任务执行失败: ${err}`, "err"); } }; document.getElementById("retryStepBtn").onclick = async () => { if (!selectedTaskId) return showBanner("当前没有选中的任务", "warn"); if (!selectedStepName) return showBanner("请先在 Steps 区域选中一个 step", "warn"); try { const result = await fetchJson(`/tasks/${selectedTaskId}/actions/retry-step`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ step_name: selectedStepName }), }); await loadOverview(); showBanner(`已重试 step=${selectedStepName},processed=${result.processed.length}`, "ok"); } catch (err) { showBanner(`重试失败: ${err}`, "err"); } }; document.getElementById("resetStepBtn").onclick = async () => { if (!selectedTaskId) return showBanner("当前没有选中的任务", "warn"); if (!selectedStepName) return showBanner("请先在 Steps 区域选中一个 step", "warn"); const ok = window.confirm(`确认重置到 step=${selectedStepName} 并清理其后的产物吗?`); if (!ok) return; try { const result = await fetchJson(`/tasks/${selectedTaskId}/actions/reset-to-step`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ step_name: selectedStepName }), }); await loadOverview(); showBanner(`已重置并重跑 step=${selectedStepName},processed=${result.run.processed.length}`, "ok"); } catch (err) { showBanner(`重置失败: ${err}`, "err"); } }; setView("overview"); loadOverview().catch((err) => showBanner(`初始化失败: ${err}`, "err"));