feat: professionalize control plane and standalone delivery
This commit is contained in:
@ -9,13 +9,14 @@ import {
|
||||
setTaskPageSize,
|
||||
state,
|
||||
} from "./state.js";
|
||||
import { showBanner, syncSettingsEditorFromState } from "./utils.js";
|
||||
import { showBanner, syncSettingsEditorFromState, withButtonBusy } from "./utils.js";
|
||||
import { renderSettingsForm } from "./views/settings.js";
|
||||
import { renderTasks } from "./views/tasks.js";
|
||||
|
||||
export function bindActions({
|
||||
loadOverview,
|
||||
loadTaskDetail,
|
||||
refreshSelectedTaskOnly,
|
||||
refreshLog,
|
||||
handleSettingsFieldChange,
|
||||
}) {
|
||||
@ -170,29 +171,33 @@ export function bindActions({
|
||||
|
||||
document.getElementById("runTaskBtn").onclick = async () => {
|
||||
if (!state.selectedTaskId) return showBanner("当前没有选中的任务", "warn");
|
||||
try {
|
||||
const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/run`, { method: "POST" });
|
||||
await loadOverview();
|
||||
showBanner(`任务已推进,processed=${result.processed.length}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(`任务执行失败: ${err}`, "err");
|
||||
}
|
||||
await withButtonBusy(document.getElementById("runTaskBtn"), "执行中…", async () => {
|
||||
try {
|
||||
const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/run`, { method: "POST" });
|
||||
await refreshSelectedTaskOnly(state.selectedTaskId);
|
||||
showBanner(`任务已推进,processed=${result.processed.length}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(`任务执行失败: ${err}`, "err");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
document.getElementById("retryStepBtn").onclick = async () => {
|
||||
if (!state.selectedTaskId) return showBanner("当前没有选中的任务", "warn");
|
||||
if (!state.selectedStepName) return showBanner("请先在 Steps 区域选中一个 step", "warn");
|
||||
try {
|
||||
const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/retry-step`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ step_name: state.selectedStepName }),
|
||||
});
|
||||
await loadOverview();
|
||||
showBanner(`已重试 step=${state.selectedStepName},processed=${result.processed.length}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(`重试失败: ${err}`, "err");
|
||||
}
|
||||
await withButtonBusy(document.getElementById("retryStepBtn"), "重试中…", async () => {
|
||||
try {
|
||||
const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/retry-step`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ step_name: state.selectedStepName }),
|
||||
});
|
||||
await refreshSelectedTaskOnly(state.selectedTaskId);
|
||||
showBanner(`已重试 step=${state.selectedStepName},processed=${result.processed.length}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(`重试失败: ${err}`, "err");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
document.getElementById("resetStepBtn").onclick = async () => {
|
||||
@ -200,16 +205,18 @@ export function bindActions({
|
||||
if (!state.selectedStepName) return showBanner("请先在 Steps 区域选中一个 step", "warn");
|
||||
const ok = window.confirm(`确认重置到 step=${state.selectedStepName} 并清理其后的产物吗?`);
|
||||
if (!ok) return;
|
||||
try {
|
||||
const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/reset-to-step`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ step_name: state.selectedStepName }),
|
||||
});
|
||||
await loadOverview();
|
||||
showBanner(`已重置并重跑 step=${state.selectedStepName},processed=${result.run.processed.length}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(`重置失败: ${err}`, "err");
|
||||
}
|
||||
await withButtonBusy(document.getElementById("resetStepBtn"), "重置中…", async () => {
|
||||
try {
|
||||
const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/reset-to-step`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ step_name: state.selectedStepName }),
|
||||
});
|
||||
await refreshSelectedTaskOnly(state.selectedTaskId);
|
||||
showBanner(`已重置并重跑 step=${state.selectedStepName},processed=${result.run.processed.length}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(`重置失败: ${err}`, "err");
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@ -40,13 +40,22 @@ export async function loadOverviewPayload() {
|
||||
return { health, doctor, tasks, modules, settings, settingsSchema, services, logs, history, scheduler };
|
||||
}
|
||||
|
||||
export async function loadTasksPayload(limit = 100) {
|
||||
return fetchJson(`/tasks?limit=${limit}`);
|
||||
}
|
||||
|
||||
export async function loadTaskPayload(taskId) {
|
||||
const [task, steps, artifacts, history, timeline] = await Promise.all([
|
||||
const [task, steps, artifacts, history, timeline, context] = await Promise.all([
|
||||
fetchJson(`/tasks/${taskId}`),
|
||||
fetchJson(`/tasks/${taskId}/steps`),
|
||||
fetchJson(`/tasks/${taskId}/artifacts`),
|
||||
fetchJson(`/tasks/${taskId}/history`),
|
||||
fetchJson(`/tasks/${taskId}/timeline`),
|
||||
fetchJson(`/tasks/${taskId}/context`).catch(() => null),
|
||||
]);
|
||||
return { task, steps, artifacts, history, timeline };
|
||||
return { task, steps, artifacts, history, timeline, context };
|
||||
}
|
||||
|
||||
export async function loadSessionPayload(sessionKey) {
|
||||
return fetchJson(`/sessions/${encodeURIComponent(sessionKey)}`);
|
||||
}
|
||||
|
||||
70
src/biliup_next/app/static/app/components/session-panel.js
Normal file
70
src/biliup_next/app/static/app/components/session-panel.js
Normal file
@ -0,0 +1,70 @@
|
||||
import { escapeHtml, taskDisplayStatus } from "../utils.js";
|
||||
|
||||
export function renderSessionPanel(session, actions = {}) {
|
||||
const wrap = document.getElementById("sessionPanel");
|
||||
const stateEl = document.getElementById("sessionWorkspaceState");
|
||||
if (!wrap || !stateEl) return;
|
||||
if (!session) {
|
||||
stateEl.className = "task-workspace-state show";
|
||||
stateEl.textContent = "当前任务如果已绑定 session_key,这里会显示同场片段和完整版绑定信息。";
|
||||
wrap.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
stateEl.className = "task-workspace-state";
|
||||
const tasks = session.tasks || [];
|
||||
wrap.innerHTML = `
|
||||
<div class="session-hero">
|
||||
<div>
|
||||
<div class="summary-title">Session Key</div>
|
||||
<div class="session-key">${escapeHtml(session.session_key || "-")}</div>
|
||||
</div>
|
||||
<div class="session-meta-strip">
|
||||
<span class="pill">${escapeHtml(`tasks ${session.task_count || tasks.length || 0}`)}</span>
|
||||
<span class="pill">${escapeHtml(`full BV ${session.full_video_bvid || "-"}`)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-actions-grid">
|
||||
<div class="bind-form">
|
||||
<div class="summary-title">Session Rebind</div>
|
||||
<input id="sessionRebindInput" value="${escapeHtml(session.full_video_bvid || "")}" placeholder="BV1..." />
|
||||
<div class="button-row">
|
||||
<button id="sessionRebindBtn" class="secondary compact">整个 Session 重绑 BV</button>
|
||||
${session.full_video_url ? `<a class="detail-link session-link-btn" href="${escapeHtml(session.full_video_url)}" target="_blank" rel="noreferrer">打开完整版</a>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bind-form">
|
||||
<div class="summary-title">Merge Tasks</div>
|
||||
<input id="sessionMergeInput" placeholder="输入 task id,用逗号分隔" />
|
||||
<div class="button-row">
|
||||
<button id="sessionMergeBtn" class="secondary compact">合并到当前 Session</button>
|
||||
</div>
|
||||
<div class="muted-note">适用于同一场直播断流后产生的多个片段。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-title" style="margin-top:14px;">Session Tasks</div>
|
||||
<div class="stack-list">
|
||||
${tasks.map((task) => `
|
||||
<div class="row-card session-task-card" data-session-task-id="${escapeHtml(task.id)}">
|
||||
<div class="step-card-title">
|
||||
<strong>${escapeHtml(task.title)}</strong>
|
||||
<span class="pill">${escapeHtml(taskDisplayStatus(task))}</span>
|
||||
</div>
|
||||
<div class="muted-note">${escapeHtml(task.session_context?.split_bvid || "-")} · ${escapeHtml(task.session_context?.full_video_bvid || "-")}</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const rebindBtn = document.getElementById("sessionRebindBtn");
|
||||
if (rebindBtn) {
|
||||
rebindBtn.onclick = () => actions.onRebind?.(session.session_key, document.getElementById("sessionRebindInput")?.value || "");
|
||||
}
|
||||
const mergeBtn = document.getElementById("sessionMergeBtn");
|
||||
if (mergeBtn) {
|
||||
mergeBtn.onclick = () => actions.onMerge?.(session.session_key, document.getElementById("sessionMergeInput")?.value || "");
|
||||
}
|
||||
wrap.querySelectorAll("[data-session-task-id]").forEach((node) => {
|
||||
node.onclick = () => actions.onSelectTask?.(node.dataset.sessionTaskId);
|
||||
});
|
||||
}
|
||||
@ -1,22 +1,41 @@
|
||||
import { escapeHtml, statusClass } from "../utils.js";
|
||||
|
||||
function displayTaskStatus(task) {
|
||||
if (task.status === "failed_manual") return "需人工处理";
|
||||
if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") return "等待B站可见";
|
||||
if (task.status === "failed_retryable") return "等待自动重试";
|
||||
return {
|
||||
created: "已接收",
|
||||
transcribed: "已转录",
|
||||
songs_detected: "已识歌",
|
||||
split_done: "已切片",
|
||||
published: "已上传",
|
||||
collection_synced: "已完成",
|
||||
running: "处理中",
|
||||
}[task.status] || task.status || "-";
|
||||
}
|
||||
|
||||
export 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;
|
||||
const delivery = task.delivery_state || {};
|
||||
const sessionContext = task.session_context || {};
|
||||
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">Task Status</div><div class="mini-stat-value"><span class="pill ${statusClass(task.status)}">${escapeHtml(displayTaskStatus(task))}</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>
|
||||
<div class="task-hero-delivery muted-note">
|
||||
split comment=${escapeHtml(delivery.split_comment || "-")} · full timeline=${escapeHtml(delivery.full_video_timeline_comment || "-")} · source=${delivery.source_video_present ? "present" : "removed"} · split videos=${delivery.split_videos_present ? "present" : "removed"}
|
||||
</div>
|
||||
<div class="task-hero-delivery muted-note">
|
||||
session=${escapeHtml(sessionContext.session_key || "-")} · split_bv=${escapeHtml(sessionContext.split_bvid || "-")} · full_bv=${escapeHtml(sessionContext.full_video_bvid || "-")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { fetchJson, loadOverviewPayload, loadTaskPayload } from "./api.js";
|
||||
import { fetchJson, loadOverviewPayload, loadSessionPayload, loadTaskPayload, loadTasksPayload } from "./api.js";
|
||||
import { bindActions } from "./actions.js";
|
||||
import { currentRoute, initRouter, navigate } from "./router.js";
|
||||
import {
|
||||
@ -11,11 +11,12 @@ import {
|
||||
setSelectedLog,
|
||||
setSelectedStep,
|
||||
setSelectedTask,
|
||||
setCurrentSession,
|
||||
setTaskDetailStatus,
|
||||
setTaskListLoading,
|
||||
state,
|
||||
} from "./state.js";
|
||||
import { settingsFieldKey, showBanner } from "./utils.js";
|
||||
import { settingsFieldKey, showBanner, withButtonBusy } from "./utils.js";
|
||||
import {
|
||||
renderDoctor,
|
||||
renderModules,
|
||||
@ -27,6 +28,7 @@ import {
|
||||
import { renderLogContent, renderLogsList } from "./views/logs.js";
|
||||
import { renderSettingsForm } from "./views/settings.js";
|
||||
import { renderTaskDetail, renderTasks, renderTaskWorkspaceState } from "./views/tasks.js";
|
||||
import { renderSessionPanel } from "./components/session-panel.js";
|
||||
|
||||
async function refreshLog() {
|
||||
const name = state.selectedLogName;
|
||||
@ -56,7 +58,41 @@ async function loadTaskDetail(taskId) {
|
||||
renderTaskDetail(payload, async (stepName) => {
|
||||
setSelectedStep(stepName);
|
||||
await loadTaskDetail(taskId);
|
||||
}, {
|
||||
onBindFullVideo: async (currentTaskId, fullVideoBvid) => {
|
||||
const button = document.getElementById("bindFullVideoBtn");
|
||||
const bvid = String(fullVideoBvid || "").trim();
|
||||
if (!/^BV[0-9A-Za-z]+$/.test(bvid)) {
|
||||
showBanner("请输入合法的 BV 号", "warn");
|
||||
return;
|
||||
}
|
||||
await withButtonBusy(button, "绑定中…", async () => {
|
||||
try {
|
||||
await fetchJson(`/tasks/${currentTaskId}/bind-full-video`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ full_video_bvid: bvid }),
|
||||
});
|
||||
await refreshSelectedTaskOnly(currentTaskId);
|
||||
showBanner(`已绑定完整版 BV: ${bvid}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(`绑定完整版失败: ${err}`, "err");
|
||||
}
|
||||
});
|
||||
},
|
||||
onOpenSession: async (sessionKey) => {
|
||||
if (!sessionKey) {
|
||||
showBanner("当前任务没有可用的 session_key", "warn");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await loadSessionDetail(sessionKey);
|
||||
} catch (err) {
|
||||
showBanner(`读取 Session 失败: ${err}`, "err");
|
||||
}
|
||||
},
|
||||
});
|
||||
await loadSessionDetail(payload.task.session_context?.session_key || payload.context?.session_key || null);
|
||||
setTaskDetailStatus("ready");
|
||||
renderTaskWorkspaceState("ready");
|
||||
} catch (err) {
|
||||
@ -67,6 +103,79 @@ async function loadTaskDetail(taskId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSessionDetail(sessionKey) {
|
||||
if (!sessionKey) {
|
||||
setCurrentSession(null);
|
||||
renderSessionPanel(null);
|
||||
return;
|
||||
}
|
||||
const session = await loadSessionPayload(sessionKey);
|
||||
setCurrentSession(session);
|
||||
renderSessionPanel(session, {
|
||||
onSelectTask: async (taskId) => {
|
||||
if (!taskId) return;
|
||||
taskSelectHandler(taskId);
|
||||
},
|
||||
onRebind: async (currentSessionKey, fullVideoBvid) => {
|
||||
const button = document.getElementById("sessionRebindBtn");
|
||||
const bvid = String(fullVideoBvid || "").trim();
|
||||
if (!/^BV[0-9A-Za-z]+$/.test(bvid)) {
|
||||
showBanner("请输入合法的 BV 号", "warn");
|
||||
return;
|
||||
}
|
||||
await withButtonBusy(button, "重绑中…", async () => {
|
||||
try {
|
||||
await fetchJson(`/sessions/${encodeURIComponent(currentSessionKey)}/rebind`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ full_video_bvid: bvid }),
|
||||
});
|
||||
await refreshSelectedTaskOnly();
|
||||
showBanner(`Session 已重绑完整版 BV: ${bvid}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(`Session 重绑失败: ${err}`, "err");
|
||||
}
|
||||
});
|
||||
},
|
||||
onMerge: async (currentSessionKey, rawTaskIds) => {
|
||||
const button = document.getElementById("sessionMergeBtn");
|
||||
const taskIds = String(rawTaskIds || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
if (!taskIds.length) {
|
||||
showBanner("请先输入至少一个 task id", "warn");
|
||||
return;
|
||||
}
|
||||
await withButtonBusy(button, "合并中…", async () => {
|
||||
try {
|
||||
await fetchJson(`/sessions/${encodeURIComponent(currentSessionKey)}/merge`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ task_ids: taskIds }),
|
||||
});
|
||||
await refreshSelectedTaskOnly();
|
||||
showBanner(`已合并 ${taskIds.length} 个任务到当前 Session`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(`Session 合并失败: ${err}`, "err");
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshTaskListOnly() {
|
||||
const payload = await loadTasksPayload(100);
|
||||
state.currentTasks = payload.items || [];
|
||||
renderTasks(taskSelectHandler, taskRowActionHandler);
|
||||
}
|
||||
|
||||
async function refreshSelectedTaskOnly(taskId = state.selectedTaskId) {
|
||||
if (!taskId) return;
|
||||
await refreshTaskListOnly();
|
||||
await loadTaskDetail(taskId);
|
||||
}
|
||||
|
||||
function taskSelectHandler(taskId) {
|
||||
setSelectedTask(taskId);
|
||||
setSelectedStep(null);
|
||||
@ -79,7 +188,7 @@ async function taskRowActionHandler(action, taskId) {
|
||||
if (action !== "run") return;
|
||||
try {
|
||||
const result = await fetchJson(`/tasks/${taskId}/actions/run`, { method: "POST" });
|
||||
await loadOverview();
|
||||
await refreshSelectedTaskOnly(taskId);
|
||||
showBanner(`任务已推进: ${taskId} / processed=${result.processed.length}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(`任务执行失败: ${err}`, "err");
|
||||
@ -201,6 +310,7 @@ async function handleRouteChange(route) {
|
||||
bindActions({
|
||||
loadOverview,
|
||||
loadTaskDetail,
|
||||
refreshSelectedTaskOnly,
|
||||
refreshLog,
|
||||
handleSettingsFieldChange,
|
||||
});
|
||||
|
||||
@ -13,6 +13,7 @@ export const state = {
|
||||
taskListLoading: true,
|
||||
taskDetailStatus: "idle",
|
||||
taskDetailError: "",
|
||||
currentSession: null,
|
||||
currentLogs: [],
|
||||
selectedLogName: null,
|
||||
logListLoading: true,
|
||||
@ -74,6 +75,10 @@ export function setTaskDetailStatus(status, error = "") {
|
||||
state.taskDetailError = error;
|
||||
}
|
||||
|
||||
export function setCurrentSession(session) {
|
||||
state.currentSession = session;
|
||||
}
|
||||
|
||||
export function setLogs(logs) {
|
||||
state.currentLogs = logs;
|
||||
}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { state } from "./state.js";
|
||||
|
||||
let bannerTimer = null;
|
||||
|
||||
export function statusClass(status) {
|
||||
if (["collection_synced", "published", "commented", "succeeded", "active"].includes(status)) return "good";
|
||||
if (["done", "resolved", "present"].includes(status)) return "good";
|
||||
if (["legacy_untracked", "pending", "unresolved"].includes(status)) return "warn";
|
||||
if (["pending", "unresolved"].includes(status)) return "warn";
|
||||
if (["removed", "disabled"].includes(status)) return "";
|
||||
if (["failed_manual", "failed_retryable", "inactive"].includes(status)) return "hot";
|
||||
if (["running", "activating", "songs_detected", "split_done", "transcribed", "created", "pending"].includes(status)) return "warn";
|
||||
@ -14,6 +16,11 @@ export function showBanner(message, kind) {
|
||||
const el = document.getElementById("banner");
|
||||
el.textContent = message;
|
||||
el.className = `banner show ${kind}`;
|
||||
if (bannerTimer) window.clearTimeout(bannerTimer);
|
||||
bannerTimer = window.setTimeout(() => {
|
||||
el.className = "banner";
|
||||
el.textContent = "";
|
||||
}, kind === "err" ? 6000 : 3200);
|
||||
}
|
||||
|
||||
export function escapeHtml(text) {
|
||||
@ -59,3 +66,92 @@ export function compareFieldEntries(a, b) {
|
||||
export function settingsFieldKey(group, field) {
|
||||
return `${group}.${field}`;
|
||||
}
|
||||
|
||||
export function taskDisplayStatus(task) {
|
||||
if (!task) return "-";
|
||||
if (task.status === "failed_manual") return "需人工处理";
|
||||
if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") return "等待B站可见";
|
||||
if (task.status === "failed_retryable") return "等待自动重试";
|
||||
return {
|
||||
created: "已接收",
|
||||
transcribed: "已转录",
|
||||
songs_detected: "已识歌",
|
||||
split_done: "已切片",
|
||||
published: "已上传",
|
||||
commented: "评论完成",
|
||||
collection_synced: "已完成",
|
||||
running: "处理中",
|
||||
}[task.status] || task.status || "-";
|
||||
}
|
||||
|
||||
export function taskPrimaryActionLabel(task) {
|
||||
if (!task) return "执行";
|
||||
if (task.status === "failed_manual") return "人工重跑";
|
||||
if (task.retry_state?.retry_due) return "立即重试";
|
||||
if (task.status === "failed_retryable") return "继续等待";
|
||||
if (task.status === "collection_synced") return "查看结果";
|
||||
return "执行";
|
||||
}
|
||||
|
||||
export function taskCurrentStep(task, steps = []) {
|
||||
const running = steps.find((step) => step.status === "running");
|
||||
if (running) return stepLabel(running.step_name);
|
||||
if (task?.retry_state?.step_name) return `${stepLabel(task.retry_state.step_name)}: ${taskDisplayStatus(task)}`;
|
||||
const pending = steps.find((step) => step.status === "pending");
|
||||
if (pending) return stepLabel(pending.step_name);
|
||||
return {
|
||||
created: "转录字幕",
|
||||
transcribed: "识别歌曲",
|
||||
songs_detected: "切分分P",
|
||||
split_done: "上传分P",
|
||||
published: "评论与合集",
|
||||
commented: "同步合集",
|
||||
collection_synced: "链路完成",
|
||||
}[task?.status] || "-";
|
||||
}
|
||||
|
||||
export function stepLabel(stepName) {
|
||||
return {
|
||||
ingest: "接收视频",
|
||||
transcribe: "转录字幕",
|
||||
song_detect: "识别歌曲",
|
||||
split: "切分分P",
|
||||
publish: "上传分P",
|
||||
comment: "发布评论",
|
||||
collection_a: "加入完整版合集",
|
||||
collection_b: "加入分P合集",
|
||||
}[stepName] || stepName || "-";
|
||||
}
|
||||
|
||||
export function actionAdvice(task) {
|
||||
if (!task) return "";
|
||||
if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") {
|
||||
return "B站通常需要一段时间完成转码和审核,系统会自动重试评论。";
|
||||
}
|
||||
if (task.status === "failed_retryable") {
|
||||
return "当前错误可自动恢复,等到重试时间或手工触发即可。";
|
||||
}
|
||||
if (task.status === "failed_manual") {
|
||||
return "这个任务需要人工判断,先看错误信息,再决定是重试当前步骤还是绑定完整版 BV。";
|
||||
}
|
||||
if (task.status === "collection_synced") {
|
||||
return "链路已完成,可以直接打开分P链接检查结果。";
|
||||
}
|
||||
return "系统会继续推进后续步骤,必要时可在这里手工干预。";
|
||||
}
|
||||
|
||||
export async function withButtonBusy(button, loadingText, fn) {
|
||||
if (!button) return fn();
|
||||
const originalHtml = button.innerHTML;
|
||||
const originalDisabled = button.disabled;
|
||||
button.disabled = true;
|
||||
button.classList.add("is-busy");
|
||||
if (loadingText) button.textContent = loadingText;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
button.disabled = originalDisabled;
|
||||
button.classList.remove("is-busy");
|
||||
button.innerHTML = originalHtml;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
import { state, setTaskPage } from "../state.js";
|
||||
import { escapeHtml, formatDate, formatDuration, statusClass } from "../utils.js";
|
||||
import {
|
||||
actionAdvice,
|
||||
escapeHtml,
|
||||
formatDate,
|
||||
formatDuration,
|
||||
statusClass,
|
||||
taskCurrentStep,
|
||||
taskDisplayStatus,
|
||||
taskPrimaryActionLabel,
|
||||
} from "../utils.js";
|
||||
import { renderArtifactList } from "../components/artifact-list.js";
|
||||
import { renderHistoryList } from "../components/history-list.js";
|
||||
import { renderRetryPanel } from "../components/retry-banner.js";
|
||||
@ -8,13 +17,13 @@ import { renderTaskHero } from "../components/task-hero.js";
|
||||
import { renderTimelineList } from "../components/timeline-list.js";
|
||||
|
||||
const STATUS_LABELS = {
|
||||
created: "待转录",
|
||||
transcribed: "待识歌",
|
||||
songs_detected: "待切歌",
|
||||
split_done: "待上传",
|
||||
published: "待收尾",
|
||||
created: "已接收",
|
||||
transcribed: "已转录",
|
||||
songs_detected: "已识歌",
|
||||
split_done: "已切片",
|
||||
published: "已上传",
|
||||
collection_synced: "已完成",
|
||||
failed_retryable: "待重试",
|
||||
failed_retryable: "等待重试",
|
||||
failed_manual: "待人工",
|
||||
running: "处理中",
|
||||
};
|
||||
@ -22,15 +31,17 @@ const STATUS_LABELS = {
|
||||
const DELIVERY_LABELS = {
|
||||
done: "已发送",
|
||||
pending: "待处理",
|
||||
legacy_untracked: "历史未追踪",
|
||||
resolved: "已定位",
|
||||
unresolved: "未定位",
|
||||
present: "保留",
|
||||
removed: "已清理",
|
||||
};
|
||||
|
||||
function displayStatus(status) {
|
||||
return STATUS_LABELS[status] || status || "-";
|
||||
function displayTaskStatus(task) {
|
||||
if (task.status === "failed_manual") return "需人工处理";
|
||||
if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") return "等待B站可见";
|
||||
if (task.status === "failed_retryable") return "等待自动重试";
|
||||
return taskDisplayStatus(task);
|
||||
}
|
||||
|
||||
function displayDelivery(status) {
|
||||
@ -162,7 +173,6 @@ export function filteredTasks() {
|
||||
if (search && !haystack.includes(search)) return false;
|
||||
if (status && task.status !== status) return false;
|
||||
const deliveryState = task.delivery_state || {};
|
||||
if (delivery === "legacy_untracked" && deliveryState.full_video_timeline_comment !== "legacy_untracked") return false;
|
||||
if (delivery === "pending_comment" && deliveryState.split_comment !== "pending" && deliveryState.full_video_timeline_comment !== "pending") return false;
|
||||
if (delivery === "cleanup_removed" && deliveryState.source_video_present !== false && deliveryState.split_videos_present !== false) return false;
|
||||
if (attention && attentionState(task) !== attention) return false;
|
||||
@ -304,9 +314,9 @@ export function renderTasks(onSelect, onRowAction = null) {
|
||||
row.innerHTML = `
|
||||
<td>
|
||||
<div class="task-cell-title">${escapeHtml(item.title)}</div>
|
||||
<div class="task-cell-subtitle">${escapeHtml(item.id)}</div>
|
||||
<div class="task-cell-subtitle">${escapeHtml(taskCurrentStep(item))}</div>
|
||||
</td>
|
||||
<td><span class="pill ${statusClass(item.status)}">${escapeHtml(displayStatus(item.status))}</span></td>
|
||||
<td><span class="pill ${statusClass(item.status)}">${escapeHtml(displayTaskStatus(item))}</span></td>
|
||||
<td><span class="pill ${attentionClass(attention)}">${escapeHtml(displayAttention(attention))}</span></td>
|
||||
<td><span class="pill ${statusClass(delivery.split_comment || "")}">${escapeHtml(displayDelivery(delivery.split_comment || "-"))}</span></td>
|
||||
<td><span class="pill ${statusClass(delivery.full_video_timeline_comment || "")}">${escapeHtml(displayDelivery(delivery.full_video_timeline_comment || "-"))}</span></td>
|
||||
@ -321,7 +331,7 @@ export function renderTasks(onSelect, onRowAction = null) {
|
||||
</td>
|
||||
<td class="task-table-actions">
|
||||
<button class="secondary compact inline-action-btn" data-task-action="open">打开</button>
|
||||
<button class="compact inline-action-btn" data-task-action="run">${attention === "manual_now" || attention === "retry_now" ? "重跑" : "执行"}</button>
|
||||
<button class="compact inline-action-btn" data-task-action="run">${escapeHtml(taskPrimaryActionLabel(item))}</button>
|
||||
</td>
|
||||
`;
|
||||
row.onclick = () => onSelect(item.id);
|
||||
@ -346,7 +356,7 @@ export function renderTasks(onSelect, onRowAction = null) {
|
||||
wrap.appendChild(table);
|
||||
}
|
||||
|
||||
export function renderTaskDetail(payload, onStepSelect) {
|
||||
export function renderTaskDetail(payload, onStepSelect, actions = {}) {
|
||||
const { task, steps, artifacts, history, timeline } = payload;
|
||||
renderTaskHero(task, steps);
|
||||
renderRetryPanel(task);
|
||||
@ -355,7 +365,8 @@ export function renderTaskDetail(payload, onStepSelect) {
|
||||
detail.innerHTML = "";
|
||||
[
|
||||
["Task ID", task.id],
|
||||
["Status", task.status],
|
||||
["Status", displayTaskStatus(task)],
|
||||
["Current Step", taskCurrentStep(task, steps.items)],
|
||||
["Created", formatDate(task.created_at)],
|
||||
["Updated", formatDate(task.updated_at)],
|
||||
["Source", task.source_path],
|
||||
@ -385,10 +396,40 @@ export function renderTaskDetail(payload, onStepSelect) {
|
||||
}
|
||||
}
|
||||
const delivery = task.delivery_state || {};
|
||||
const sessionContext = task.session_context || {};
|
||||
const splitVideoUrl = sessionContext.video_links?.split_video_url;
|
||||
const fullVideoUrl = sessionContext.video_links?.full_video_url;
|
||||
const summaryEl = document.getElementById("taskSummary");
|
||||
summaryEl.innerHTML = `
|
||||
<div class="summary-title">Recent Result</div>
|
||||
<div class="summary-text">${escapeHtml(summaryText)}</div>
|
||||
<div class="summary-title" style="margin-top:14px;">Recommended Next Step</div>
|
||||
<div class="summary-text">${escapeHtml(actionAdvice(task))}</div>
|
||||
<div class="summary-title" style="margin-top:14px;">Delivery Links</div>
|
||||
<div class="delivery-grid">
|
||||
${renderDeliveryState("Split BV", sessionContext.split_bvid || "-", "")}
|
||||
${renderDeliveryState("Full BV", sessionContext.full_video_bvid || "-", "")}
|
||||
${renderLinkState("Split Video", splitVideoUrl)}
|
||||
${renderLinkState("Full Video", fullVideoUrl)}
|
||||
</div>
|
||||
<div class="summary-title" style="margin-top:14px;">Session Context</div>
|
||||
<div class="delivery-grid">
|
||||
${renderDeliveryState("Session Key", sessionContext.session_key || "-", "")}
|
||||
${renderDeliveryState("Streamer", sessionContext.streamer || "-", "")}
|
||||
${renderDeliveryState("Room ID", sessionContext.room_id || "-", "")}
|
||||
${renderDeliveryState("Context Source", sessionContext.context_source || "-", "")}
|
||||
${renderDeliveryState("Segment Start", sessionContext.segment_started_at ? formatDate(sessionContext.segment_started_at) : "-", "")}
|
||||
${renderDeliveryState("Segment Duration", sessionContext.segment_duration_seconds != null ? formatDuration(sessionContext.segment_duration_seconds) : "-", "")}
|
||||
</div>
|
||||
<div class="summary-title" style="margin-top:14px;">Bind Full Video BV</div>
|
||||
<div class="bind-form">
|
||||
<input id="bindFullVideoInput" value="${escapeHtml(sessionContext.full_video_bvid || "")}" placeholder="BV1..." />
|
||||
<div class="button-row">
|
||||
<button id="bindFullVideoBtn" class="secondary compact">绑定完整版 BV</button>
|
||||
${sessionContext.session_key ? `<button id="openSessionBtn" class="secondary compact">查看 Session</button>` : ""}
|
||||
</div>
|
||||
<div class="muted-note">用于修复评论 / 合集查不到完整版视频的问题。</div>
|
||||
</div>
|
||||
<div class="summary-title" style="margin-top:14px;">Delivery State</div>
|
||||
<div class="delivery-grid">
|
||||
${renderDeliveryState("Split Comment", delivery.split_comment || "-")}
|
||||
@ -403,6 +444,14 @@ export function renderTaskDetail(payload, onStepSelect) {
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
const bindBtn = document.getElementById("bindFullVideoBtn");
|
||||
if (bindBtn) {
|
||||
bindBtn.onclick = () => actions.onBindFullVideo?.(task.id, document.getElementById("bindFullVideoInput")?.value || "");
|
||||
}
|
||||
const openSessionBtn = document.getElementById("openSessionBtn");
|
||||
if (openSessionBtn) {
|
||||
openSessionBtn.onclick = () => actions.onOpenSession?.(sessionContext.session_key);
|
||||
}
|
||||
|
||||
renderStepList(steps, onStepSelect);
|
||||
renderArtifactList(artifacts);
|
||||
@ -420,8 +469,21 @@ function renderDeliveryState(label, value, forcedClass = null) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLinkState(label, url) {
|
||||
return `
|
||||
<div class="delivery-card">
|
||||
<div class="delivery-label">${escapeHtml(label)}</div>
|
||||
<div class="delivery-value">
|
||||
${url ? `<a class="detail-link" href="${escapeHtml(url)}" target="_blank" rel="noreferrer">打开</a>` : `<span class="muted-note">-</span>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderTaskWorkspaceState(mode, message = "") {
|
||||
const stateEl = document.getElementById("taskWorkspaceState");
|
||||
const sessionStateEl = document.getElementById("sessionWorkspaceState");
|
||||
const sessionPanel = document.getElementById("sessionPanel");
|
||||
const hero = document.getElementById("taskHero");
|
||||
const retry = document.getElementById("taskRetryPanel");
|
||||
const detail = document.getElementById("taskDetail");
|
||||
@ -459,4 +521,11 @@ export function renderTaskWorkspaceState(mode, message = "") {
|
||||
artifactList.innerHTML = "";
|
||||
historyList.innerHTML = "";
|
||||
timelineList.innerHTML = "";
|
||||
if (sessionStateEl) {
|
||||
sessionStateEl.className = "task-workspace-state show";
|
||||
sessionStateEl.textContent = mode === "error"
|
||||
? "Session 区域暂不可用。"
|
||||
: "当前任务如果已绑定 session_key,这里会显示同场片段和完整版绑定信息。";
|
||||
}
|
||||
if (sessionPanel) sessionPanel.innerHTML = "";
|
||||
}
|
||||
|
||||
@ -134,6 +134,11 @@ button.compact {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
button.is-busy {
|
||||
opacity: 0.72;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
@ -258,6 +263,79 @@ button.compact {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.task-cell-subtitle {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bind-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.bind-form input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.detail-link {
|
||||
color: var(--accent-2);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.session-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.session-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.session-key {
|
||||
margin-top: 6px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.session-meta-strip,
|
||||
.session-actions-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.session-actions-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.session-task-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.session-task-card:hover {
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.session-link-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255,255,255,0.78);
|
||||
}
|
||||
|
||||
.delivery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
Reference in New Issue
Block a user