Files
biliup-next/src/biliup_next/app/static/dashboard.js
2026-04-01 00:44:58 +08:00

806 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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("<", "&lt;")
.replaceAll(">", "&gt;");
}
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 = `<div class="row-card"><strong>没有匹配任务</strong><div class="muted-note">调整搜索、状态或排序条件后重试。</div></div>`;
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 = `
<div class="task-title">${escapeHtml(item.title)}</div>
<div class="meta-row">
<span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
<span class="pill">${escapeHtml(item.id)}</span>
</div>
<div class="muted-note">${escapeHtml(formatDate(item.updated_at))}</div>
${retryText ? `<div class="muted-note">${escapeHtml(retryText)}</div>` : ""}
`;
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 = `
<div class="task-hero-title">${escapeHtml(task.title)}</div>
<div class="task-hero-subtitle">${escapeHtml(task.id)} · ${escapeHtml(task.source_path)}</div>
<div class="hero-meta-grid">
<div class="mini-stat">
<div class="mini-stat-label">Task Status</div>
<div class="mini-stat-value"><span class="pill ${statusClass(task.status)}">${escapeHtml(task.status)}</span></div>
</div>
<div class="mini-stat">
<div class="mini-stat-label">Succeeded Steps</div>
<div class="mini-stat-value">${succeeded}/${steps.items.length}</div>
</div>
<div class="mini-stat">
<div class="mini-stat-label">Running / Failed</div>
<div class="mini-stat-value">${running} / ${failed}</div>
</div>
</div>
`;
}
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 = `
<strong>${escapeHtml(retry.step_name)}</strong>
${retry.retry_due ? " 已到重试时间" : " 正在等待下一次重试"}
<div class="muted-note">next retry at ${escapeHtml(formatDate(retry.next_retry_at))} · remaining ${escapeHtml(formatDuration(retry.retry_remaining_seconds))} · wait ${escapeHtml(formatDuration(retry.retry_wait_seconds))}</div>
`;
}
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 ? `
<div class="step-card-metrics">
<div class="step-metric"><strong>Next Retry</strong> ${escapeHtml(formatDate(step.next_retry_at))}</div>
<div class="step-metric"><strong>Remaining</strong> ${escapeHtml(formatDuration(step.retry_remaining_seconds))}</div>
<div class="step-metric"><strong>Wait Policy</strong> ${escapeHtml(formatDuration(step.retry_wait_seconds))}</div>
</div>
` : "";
row.innerHTML = `
<div class="step-card-title">
<strong>${escapeHtml(step.step_name)}</strong>
<span class="pill ${statusClass(step.status)}">${escapeHtml(step.status)}</span>
<span class="pill">retry ${step.retry_count}</span>
</div>
<div class="muted-note">${escapeHtml(step.error_code || "")} ${escapeHtml(step.error_message || "")}</div>
<div class="step-card-metrics">
<div class="step-metric"><strong>Started</strong> ${escapeHtml(formatDate(step.started_at))}</div>
<div class="step-metric"><strong>Finished</strong> ${escapeHtml(formatDate(step.finished_at))}</div>
</div>
${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 = `
<div class="step-card-title"><strong>${escapeHtml(artifact.artifact_type)}</strong></div>
<div class="artifact-path">${escapeHtml(artifact.path)}</div>
<div class="muted-note">${escapeHtml(formatDate(artifact.created_at))}</div>
`;
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 = `
<div class="step-card-title">
<strong>${escapeHtml(item.action_name)}</strong>
<span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
</div>
<div class="muted-note">${escapeHtml(item.summary)}</div>
<div class="muted-note">${escapeHtml(formatDate(item.created_at))}</div>
<pre>${escapeHtml(details)}</pre>
`;
historyWrap.appendChild(row);
}
const timelineWrap = document.getElementById("timelineList");
timelineWrap.innerHTML = "";
for (const item of timeline.items) {
const retryNote = item.retry_state?.next_retry_at
? `<div class="timeline-meta-line"><strong>Next Retry</strong> ${escapeHtml(formatDate(item.retry_state.next_retry_at))} · remaining ${escapeHtml(formatDuration(item.retry_state.retry_remaining_seconds))}</div>`
: "";
const row = document.createElement("div");
row.className = "timeline-card";
row.innerHTML = `
<div class="timeline-title">
<strong>${escapeHtml(item.title)}</strong>
<span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
<span class="pill">${escapeHtml(item.kind)}</span>
</div>
<div class="timeline-meta">
<div class="timeline-meta-line">${escapeHtml(item.summary || "-")}</div>
<div class="timeline-meta-line"><strong>Time</strong> ${escapeHtml(formatDate(item.time))}</div>
${retryNote}
</div>
`;
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 = `<div class="row-card"><strong>没有匹配的配置项</strong><div class="muted-note">调整搜索关键字后重试。</div></div>`;
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 = `
<div class="step-card-title">
<strong>${escapeHtml(item.action_name)}</strong>
<span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
</div>
<div class="muted-note">${escapeHtml(item.task_id || "global")} / ${escapeHtml(item.summary)}</div>
<div class="muted-note">${escapeHtml(formatDate(item.created_at))}</div>
`;
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 = `
<div class="step-card-title"><strong>${escapeHtml(item.id)}</strong><span class="pill">${escapeHtml(item.provider_type)}</span></div>
<div class="muted-note">${escapeHtml(item.entrypoint)}</div>
`;
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 = `
<div class="step-card-title"><strong>${escapeHtml(check.name)}</strong><span class="pill ${check.ok ? "good" : "hot"}">${check.ok ? "ok" : "fail"}</span></div>
<div class="muted-note">${escapeHtml(check.detail)}</div>
`;
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 = `
<div class="step-card-title">
<strong>${escapeHtml(item.id)}</strong>
<span class="pill ${statusClass(item.active_state)}">${escapeHtml(item.active_state)}</span>
<span class="pill">${escapeHtml(item.sub_state)}</span>
</div>
<div class="muted-note">${escapeHtml(item.fragment_path || item.description || "")}</div>
<div class="button-row" style="margin-top:12px;">
<button class="secondary compact" data-service="${item.id}" data-action="start">start</button>
<button class="secondary compact" data-service="${item.id}" data-action="restart">restart</button>
<button class="secondary compact" data-service="${item.id}" data-action="stop">stop</button>
</div>
`;
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"));