feat: professionalize control plane and standalone delivery

This commit is contained in:
theshy
2026-04-07 10:46:30 +08:00
parent d0cf1fd0df
commit 862db502b0
100 changed files with 8313 additions and 1483 deletions

View File

@ -1,122 +1,246 @@
import { useEffect, useState, useDeferredValue, startTransition } from "react";
import { useRef } from "react";
import { fetchJson, uploadFile } from "./api/client.js";
import { fetchJson, fetchJsonCached, invalidateJsonCache, primeJsonCache, uploadFile } from "./api/client.js";
import LogsPanel from "./components/LogsPanel.jsx";
import OverviewPanel from "./components/OverviewPanel.jsx";
import SettingsPanel from "./components/SettingsPanel.jsx";
import TaskTable from "./components/TaskTable.jsx";
import TaskDetailCard from "./components/TaskDetailCard.jsx";
import { summarizeAttention, summarizeDelivery } from "./lib/format.js";
import {
attentionLabel,
currentStepLabel,
summarizeAttention,
summarizeDelivery,
taskDisplayStatus,
taskPrimaryActionLabel,
} from "./lib/format.js";
const NAV_ITEMS = ["Overview", "Tasks", "Settings", "Logs"];
function PlaceholderView({ title, description }) {
function buildTasksUrl(query) {
const params = new URLSearchParams();
params.set("limit", String(query.limit || 24));
params.set("offset", String(query.offset || 0));
params.set("sort", String(query.sort || "updated_desc"));
if (query.status) params.set("status", query.status);
if (query.search) params.set("search", query.search);
if (query.attention) params.set("attention", query.attention);
if (query.delivery) params.set("delivery", query.delivery);
return `/tasks?${params.toString()}`;
}
function parseHashState() {
const raw = window.location.hash.replace(/^#/, "");
const [viewPart, queryPart = ""] = raw.split("?");
const params = new URLSearchParams(queryPart);
return {
view: NAV_ITEMS.includes(viewPart) ? viewPart : "Tasks",
taskId: params.get("task") || "",
};
}
function syncHashState(view, taskId) {
const params = new URLSearchParams();
if (taskId && view === "Tasks") params.set("task", taskId);
const suffix = params.toString() ? `?${params.toString()}` : "";
window.history.replaceState(null, "", `#${view}${suffix}`);
}
function FocusQueue({ tasks, selectedTaskId, onSelectTask, onRunTask }) {
const focusItems = tasks
.filter((task) => ["manual_now", "retry_now", "waiting_retry"].includes(summarizeAttention(task)))
.sort((a, b) => {
const score = { manual_now: 0, retry_now: 1, waiting_retry: 2 };
const diff = score[summarizeAttention(a)] - score[summarizeAttention(b)];
if (diff !== 0) return diff;
return String(b.updated_at).localeCompare(String(a.updated_at));
})
.slice(0, 6);
if (!focusItems.length) return null;
return (
<section className="placeholder-view">
<h2>{title}</h2>
<p>{description}</p>
</section>
<article className="panel">
<div className="panel-head">
<div>
<p className="eyebrow">Priority Queue</p>
<h2>需要优先处理的任务</h2>
</div>
<div className="panel-meta">{focusItems.length} tasks</div>
</div>
<div className="focus-grid">
{focusItems.map((task) => (
<button
key={task.id}
type="button"
className={selectedTaskId === task.id ? "focus-card active" : "focus-card"}
onClick={() => onSelectTask(task.id)}
onMouseEnter={() => onSelectTask(task.id, { prefetch: true })}
>
<div className="focus-card-head">
<span className="status-badge">{attentionLabel(summarizeAttention(task))}</span>
<span className="status-badge">{taskDisplayStatus(task)}</span>
</div>
<strong>{task.title}</strong>
<p>{currentStepLabel(task)}</p>
<div className="row-actions">
<button
className="nav-btn compact-btn"
onClick={(event) => {
event.stopPropagation();
onSelectTask(task.id);
}}
>
打开详情
</button>
<button
className="nav-btn compact-btn strong-btn"
onClick={(event) => {
event.stopPropagation();
onRunTask?.(task.id);
}}
>
{taskPrimaryActionLabel(task)}
</button>
</div>
</button>
))}
</div>
</article>
);
}
function TasksView({
tasks,
taskTotal,
taskQuery,
selectedTaskId,
onSelectTask,
onRunTask,
taskDetail,
session,
loading,
detailLoading,
actionBusy,
selectedStepName,
onSelectStep,
onRetryStep,
onResetStep,
onBindFullVideo,
onOpenSessionTask,
onSessionMerge,
onSessionRebind,
onTaskQueryChange,
}) {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [attentionFilter, setAttentionFilter] = useState("");
const [deliveryFilter, setDeliveryFilter] = useState("");
const [sort, setSort] = useState("updated_desc");
const deferredSearch = useDeferredValue(search);
const deferredSearch = useDeferredValue(taskQuery.search);
const filtered = tasks
.filter((task) => {
const haystack = `${task.id} ${task.title}`.toLowerCase();
if (deferredSearch && !haystack.includes(deferredSearch.toLowerCase())) return false;
if (statusFilter && task.status !== statusFilter) return false;
if (attentionFilter && summarizeAttention(task) !== attentionFilter) return false;
if (deliveryFilter && summarizeDelivery(task.delivery_state) !== deliveryFilter) return false;
return true;
})
.sort((a, b) => {
if (sort === "title_asc") return String(a.title).localeCompare(String(b.title), "zh-CN");
if (sort === "title_desc") return String(b.title).localeCompare(String(a.title), "zh-CN");
if (sort === "attention") return summarizeAttention(a).localeCompare(summarizeAttention(b), "zh-CN");
return String(b.updated_at).localeCompare(String(a.updated_at), "zh-CN");
});
const filtered = tasks.filter((task) => {
const haystack = `${task.id} ${task.title}`.toLowerCase();
if (deferredSearch && !haystack.includes(deferredSearch.toLowerCase())) return false;
return true;
});
const pageStart = taskTotal ? taskQuery.offset + 1 : 0;
const pageEnd = taskQuery.offset + tasks.length;
const canPrev = taskQuery.offset > 0;
const canNext = taskQuery.offset + taskQuery.limit < taskTotal;
return (
<section className="tasks-layout-react">
<article className="panel">
<div className="panel-head">
<div>
<p className="eyebrow">Tasks Workspace</p>
<h2>Task Table</h2>
<div className="tasks-main-stack">
<FocusQueue tasks={tasks} selectedTaskId={selectedTaskId} onSelectTask={onSelectTask} onRunTask={onRunTask} />
<article className="panel">
<div className="panel-head">
<div>
<p className="eyebrow">Tasks Workspace</p>
<h2>Task Table</h2>
</div>
<div className="panel-meta">{loading ? "syncing..." : `${pageStart}-${pageEnd} / ${taskTotal}`}</div>
</div>
<div className="panel-meta">{loading ? "syncing..." : `${filtered.length} visible`}</div>
</div>
<div className="toolbar-grid">
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索任务标题或 task id"
/>
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}>
<option value="">全部状态</option>
<option value="running">处理中</option>
<option value="failed_retryable">重试</option>
<option value="failed_manual">待人工</option>
<option value="published">待收尾</option>
<option value="collection_synced">已完成</option>
</select>
<select value={attentionFilter} onChange={(event) => setAttentionFilter(event.target.value)}>
<option value="">全部关注状态</option>
<option value="manual_now">仅看需人工</option>
<option value="retry_now">仅看到点重试</option>
<option value="waiting_retry">仅看等待重试</option>
</select>
<select value={deliveryFilter} onChange={(event) => setDeliveryFilter(event.target.value)}>
<option value="">全部交付状态</option>
<option value="legacy_untracked">主视频评论未追踪</option>
<option value="pending_comment">评论待完成</option>
<option value="cleanup_removed">已清理视频</option>
</select>
<select value={sort} onChange={(event) => setSort(event.target.value)}>
<option value="updated_desc">最近更新</option>
<option value="title_asc">标题 A-Z</option>
<option value="title_desc">标题 Z-A</option>
<option value="attention">按关注状态</option>
</select>
</div>
<TaskTable tasks={filtered} selectedTaskId={selectedTaskId} onSelectTask={onSelectTask} onRunTask={onRunTask} />
</article>
<div className="toolbar-grid">
<input
value={taskQuery.search}
onChange={(event) => onTaskQueryChange({ search: event.target.value, offset: 0 })}
placeholder="搜索任务标题或 task id"
/>
<select value={taskQuery.status} onChange={(event) => onTaskQueryChange({ status: event.target.value, offset: 0 })}>
<option value="">全部状态</option>
<option value="running">处理中</option>
<option value="failed_retryable">待重试</option>
<option value="failed_manual">待人工</option>
<option value="published">收尾</option>
<option value="collection_synced">已完成</option>
</select>
<select value={taskQuery.attention} onChange={(event) => onTaskQueryChange({ attention: event.target.value, offset: 0 })}>
<option value="">全部关注状态</option>
<option value="manual_now">仅看需人工</option>
<option value="retry_now">仅看到点重试</option>
<option value="waiting_retry">仅看等待重试</option>
</select>
<select value={taskQuery.delivery} onChange={(event) => onTaskQueryChange({ delivery: event.target.value, offset: 0 })}>
<option value="">全部交付状态</option>
<option value="pending_comment">评论待完成</option>
<option value="cleanup_removed">已清理视频</option>
</select>
<select value={taskQuery.sort} onChange={(event) => onTaskQueryChange({ sort: event.target.value, offset: 0 })}>
<option value="updated_desc">最近更新</option>
<option value="updated_asc">最早更新</option>
<option value="title_asc">标题 A-Z</option>
<option value="title_desc">标题 Z-A</option>
<option value="status_asc">按状态</option>
</select>
<select value={String(taskQuery.limit)} onChange={(event) => onTaskQueryChange({ limit: Number(event.target.value), offset: 0 })}>
<option value="12">12 / </option>
<option value="24">24 / </option>
<option value="48">48 / </option>
</select>
</div>
<div className="row-actions" style={{ marginBottom: 12 }}>
<button className="nav-btn compact-btn" onClick={() => onTaskQueryChange({ offset: Math.max(0, taskQuery.offset - taskQuery.limit) })} disabled={!canPrev || loading}>
上一页
</button>
<button className="nav-btn compact-btn" onClick={() => onTaskQueryChange({ offset: taskQuery.offset + taskQuery.limit })} disabled={!canNext || loading}>
下一页
</button>
</div>
<TaskTable tasks={filtered} selectedTaskId={selectedTaskId} onSelectTask={onSelectTask} onRunTask={onRunTask} />
</article>
</div>
<TaskDetailCard
payload={taskDetail}
session={session}
loading={detailLoading}
actionBusy={actionBusy}
selectedStepName={selectedStepName}
onSelectStep={onSelectStep}
onRetryStep={onRetryStep}
onResetStep={onResetStep}
onBindFullVideo={onBindFullVideo}
onOpenSessionTask={onOpenSessionTask}
onSessionMerge={onSessionMerge}
onSessionRebind={onSessionRebind}
/>
</section>
);
}
export default function App() {
const [view, setView] = useState("Tasks");
const initialLocation = parseHashState();
const [view, setView] = useState(initialLocation.view);
const [health, setHealth] = useState(false);
const [doctorOk, setDoctorOk] = useState(false);
const [tasks, setTasks] = useState([]);
const [taskTotal, setTaskTotal] = useState(0);
const [taskQuery, setTaskQuery] = useState({
search: "",
status: "",
attention: "",
delivery: "",
sort: "updated_desc",
limit: 24,
offset: 0,
});
const [services, setServices] = useState({ items: [] });
const [scheduler, setScheduler] = useState(null);
const [history, setHistory] = useState({ items: [] });
@ -127,21 +251,34 @@ export default function App() {
const [autoRefreshLogs, setAutoRefreshLogs] = useState(false);
const [settings, setSettings] = useState({});
const [settingsSchema, setSettingsSchema] = useState(null);
const [selectedTaskId, setSelectedTaskId] = useState("");
const [selectedTaskId, setSelectedTaskId] = useState(initialLocation.taskId);
const [selectedStepName, setSelectedStepName] = useState("");
const [taskDetail, setTaskDetail] = useState(null);
const [currentSession, setCurrentSession] = useState(null);
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [overviewLoading, setOverviewLoading] = useState(false);
const [logLoading, setLogLoading] = useState(false);
const [settingsLoading, setSettingsLoading] = useState(false);
const [banner, setBanner] = useState(null);
const [actionBusy, setActionBusy] = useState("");
const [panelBusy, setPanelBusy] = useState("");
const [toasts, setToasts] = useState([]);
const detailCacheRef = useRef(new Map());
function pushToast(kind, text) {
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
setToasts((current) => [...current, { id, kind, text }]);
}
function removeToast(id) {
setToasts((current) => current.filter((item) => item.id !== id));
}
async function loadOverviewPanels() {
const [servicesPayload, schedulerPayload, historyPayload] = await Promise.all([
fetchJson("/runtime/services"),
fetchJson("/scheduler/preview"),
fetchJson("/history?limit=20"),
fetchJsonCached("/runtime/services"),
fetchJsonCached("/scheduler/preview"),
fetchJsonCached("/history?limit=20"),
]);
setServices(servicesPayload);
setScheduler(schedulerPayload);
@ -152,13 +289,14 @@ export default function App() {
setLoading(true);
try {
const [healthPayload, doctorPayload, taskPayload] = await Promise.all([
fetchJson("/health"),
fetchJson("/doctor"),
fetchJson("/tasks?limit=100"),
fetchJsonCached("/health"),
fetchJsonCached("/doctor"),
fetchJson(buildTasksUrl(taskQuery)),
]);
setHealth(Boolean(healthPayload.ok));
setDoctorOk(Boolean(doctorPayload.ok));
setTasks(taskPayload.items || []);
setTaskTotal(taskPayload.total || 0);
startTransition(() => {
if (!selectedTaskId && taskPayload.items?.length) {
setSelectedTaskId(taskPayload.items[0].id);
@ -169,17 +307,53 @@ export default function App() {
}
}
async function loadTasksOnly(query = taskQuery) {
const url = buildTasksUrl(query);
const taskPayload = await fetchJson(url);
primeJsonCache(url, taskPayload);
setTasks(taskPayload.items || []);
setTaskTotal(taskPayload.total || 0);
return taskPayload.items || [];
}
async function loadSessionDetail(sessionKey) {
if (!sessionKey) {
setCurrentSession(null);
return null;
}
const payload = await fetchJson(`/sessions/${encodeURIComponent(sessionKey)}`);
primeJsonCache(`/sessions/${encodeURIComponent(sessionKey)}`, payload);
setCurrentSession(payload);
return payload;
}
async function loadTaskDetail(taskId) {
const cached = detailCacheRef.current.get(taskId);
if (cached) {
setTaskDetail(cached);
void loadSessionDetail(cached.context?.session_key);
setDetailLoading(false);
}
setDetailLoading(true);
try {
const [task, steps, artifacts, history, timeline] = await Promise.all([
fetchJson(`/tasks/${encodeURIComponent(taskId)}`),
fetchJson(`/tasks/${encodeURIComponent(taskId)}/steps`),
fetchJson(`/tasks/${encodeURIComponent(taskId)}/artifacts`),
fetchJson(`/tasks/${encodeURIComponent(taskId)}/history`),
fetchJson(`/tasks/${encodeURIComponent(taskId)}/timeline`),
const [task, steps, artifacts, history, timeline, context] = await Promise.all([
fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}`),
fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/steps`),
fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/artifacts`),
fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/history`),
fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/timeline`),
fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/context`),
]);
setTaskDetail({ task, steps, artifacts, history, timeline });
const payload = { task, steps, artifacts, history, timeline, context };
detailCacheRef.current.set(taskId, payload);
primeJsonCache(`/tasks/${encodeURIComponent(taskId)}`, task);
primeJsonCache(`/tasks/${encodeURIComponent(taskId)}/steps`, steps);
primeJsonCache(`/tasks/${encodeURIComponent(taskId)}/artifacts`, artifacts);
primeJsonCache(`/tasks/${encodeURIComponent(taskId)}/history`, history);
primeJsonCache(`/tasks/${encodeURIComponent(taskId)}/timeline`, timeline);
primeJsonCache(`/tasks/${encodeURIComponent(taskId)}/context`, context);
setTaskDetail(payload);
await loadSessionDetail(context?.session_key);
if (!selectedStepName) {
const suggested = steps.items?.find((step) => ["failed_retryable", "failed_manual", "running"].includes(step.status))?.step_name
|| steps.items?.find((step) => step.status !== "succeeded")?.step_name
@ -191,15 +365,87 @@ export default function App() {
}
}
async function refreshSelectedTask(taskId = selectedTaskId, { refreshTasks = true } = {}) {
if (refreshTasks) {
const refreshedTasks = await loadTasksOnly();
if (!taskId && refreshedTasks.length) {
taskId = refreshedTasks[0].id;
}
}
if (!taskId) {
setTaskDetail(null);
setCurrentSession(null);
return;
}
await loadTaskDetail(taskId);
}
function invalidateTaskCaches(taskId) {
invalidateJsonCache("/tasks?");
if (taskId) {
detailCacheRef.current.delete(taskId);
invalidateJsonCache(`/tasks/${encodeURIComponent(taskId)}`);
}
}
function invalidateSessionCaches(sessionKey) {
if (!sessionKey) return;
invalidateJsonCache(`/sessions/${encodeURIComponent(sessionKey)}`);
}
async function prefetchTaskDetail(taskId) {
if (!taskId || detailCacheRef.current.has(taskId)) return;
try {
const [task, steps, artifacts, history, timeline, context] = await Promise.all([
fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}`),
fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/steps`),
fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/artifacts`),
fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/history`),
fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/timeline`),
fetchJsonCached(`/tasks/${encodeURIComponent(taskId)}/context`),
]);
detailCacheRef.current.set(taskId, { task, steps, artifacts, history, timeline, context });
} catch {
// Ignore prefetch failures; normal navigation will surface the actual error.
}
}
useEffect(() => {
let cancelled = false;
loadShell().catch((error) => {
if (!cancelled) setBanner({ kind: "hot", text: `初始化失败: ${error}` });
if (!cancelled) pushToast("hot", `初始化失败: ${error}`);
});
return () => {
cancelled = true;
};
}, [selectedTaskId]);
}, []);
useEffect(() => {
syncHashState(view, selectedTaskId);
}, [view, selectedTaskId]);
useEffect(() => {
if (view !== "Tasks") return;
loadTasksOnly(taskQuery).catch((error) => pushToast("hot", `任务列表加载失败: ${error}`));
}, [taskQuery, view]);
useEffect(() => {
if (!toasts.length) return undefined;
const timer = window.setTimeout(() => setToasts((current) => current.slice(1)), 3200);
return () => window.clearTimeout(timer);
}, [toasts]);
useEffect(() => {
function handleHashChange() {
const next = parseHashState();
setView(next.view);
if (next.taskId) {
setSelectedTaskId(next.taskId);
}
}
window.addEventListener("hashchange", handleHashChange);
return () => window.removeEventListener("hashchange", handleHashChange);
}, []);
useEffect(() => {
if (view !== "Overview") return;
@ -208,9 +454,9 @@ export default function App() {
setOverviewLoading(true);
try {
const [servicesPayload, schedulerPayload, historyPayload] = await Promise.all([
fetchJson("/runtime/services"),
fetchJson("/scheduler/preview"),
fetchJson("/history?limit=20"),
fetchJsonCached("/runtime/services"),
fetchJsonCached("/scheduler/preview"),
fetchJsonCached("/history?limit=20"),
]);
if (cancelled) return;
setServices(servicesPayload);
@ -230,7 +476,7 @@ export default function App() {
if (!selectedTaskId) return;
let cancelled = false;
loadTaskDetail(selectedTaskId).catch((error) => {
if (!cancelled) setBanner({ kind: "hot", text: `任务详情加载失败: ${error}` });
if (!cancelled) pushToast("hot", `任务详情加载失败: ${error}`);
});
return () => {
cancelled = true;
@ -279,7 +525,7 @@ export default function App() {
if (view !== "Logs" || !selectedLogName) return;
let cancelled = false;
loadCurrentLogContent(selectedLogName).catch((error) => {
if (!cancelled) setBanner({ kind: "hot", text: `日志加载失败: ${error}` });
if (!cancelled) pushToast("hot", `日志加载失败: ${error}`);
});
return () => {
cancelled = true;
@ -301,8 +547,8 @@ export default function App() {
setSettingsLoading(true);
try {
const [settingsPayload, schemaPayload] = await Promise.all([
fetchJson("/settings"),
fetchJson("/settings/schema"),
fetchJsonCached("/settings"),
fetchJsonCached("/settings/schema"),
]);
if (cancelled) return;
setSettings(settingsPayload);
@ -329,42 +575,77 @@ export default function App() {
history={history}
loading={overviewLoading}
onRefreshScheduler={async () => {
const payload = await fetchJson("/scheduler/preview");
setScheduler(payload);
setBanner({ kind: "good", text: "Scheduler 已刷新" });
setPanelBusy("refresh_scheduler");
try {
const payload = await fetchJson("/scheduler/preview");
setScheduler(payload);
pushToast("good", "Scheduler 已刷新");
} finally {
setPanelBusy("");
}
}}
onRefreshHistory={async () => {
const payload = await fetchJson("/history?limit=20");
setHistory(payload);
setBanner({ kind: "good", text: "Recent Actions 已刷新" });
setPanelBusy("refresh_history");
try {
const payload = await fetchJson("/history?limit=20");
setHistory(payload);
pushToast("good", "Recent Actions 已刷新");
} finally {
setPanelBusy("");
}
}}
onStageImport={async (sourcePath) => {
const result = await fetchJson("/stage/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source_path: sourcePath }),
});
await loadShell();
setBanner({ kind: "good", text: `已导入到 stage: ${result.target_path}` });
setPanelBusy("stage_import");
try {
const result = await fetchJson("/stage/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source_path: sourcePath }),
});
await loadTasksOnly();
pushToast("good", `已导入到 stage: ${result.target_path}`);
} finally {
setPanelBusy("");
}
}}
onStageUpload={async (file) => {
const result = await uploadFile("/stage/upload", file);
await loadShell();
setBanner({ kind: "good", text: `已上传到 stage: ${result.target_path}` });
setPanelBusy("stage_upload");
try {
const result = await uploadFile("/stage/upload", file);
await loadTasksOnly();
pushToast("good", `已上传到 stage: ${result.target_path}`);
} finally {
setPanelBusy("");
}
}}
onRunOnce={async () => {
await fetchJson("/worker/run-once", { method: "POST" });
await loadShell();
setBanner({ kind: "good", text: "Worker 已执行一轮" });
setPanelBusy("run_once");
try {
await fetchJson("/worker/run-once", { method: "POST" });
invalidateJsonCache("/tasks?");
await loadTasksOnly();
if (selectedTaskId) await refreshSelectedTask(selectedTaskId, { refreshTasks: false });
pushToast("good", "Worker 已执行一轮");
} finally {
setPanelBusy("");
}
}}
onServiceAction={async (serviceId, action) => {
await fetchJson(`/runtime/services/${serviceId}/${action}`, { method: "POST" });
await loadShell();
if (view === "Overview") {
await loadOverviewPanels();
const busyKey = `service:${serviceId}:${action}`;
setPanelBusy(busyKey);
try {
await fetchJson(`/runtime/services/${serviceId}/${action}`, { method: "POST" });
invalidateJsonCache("/runtime/services");
await loadShell();
if (view === "Overview") {
await loadOverviewPanels();
}
pushToast("good", `${serviceId} ${action} 完成`);
} finally {
setPanelBusy("");
}
setBanner({ kind: "good", text: `${serviceId} ${action} 完成` });
}}
busy={panelBusy}
/>
);
}
@ -372,46 +653,139 @@ export default function App() {
return (
<TasksView
tasks={tasks}
taskTotal={taskTotal}
taskQuery={taskQuery}
selectedTaskId={selectedTaskId}
onSelectTask={(taskId) => {
onSelectTask={(taskId, options = {}) => {
if (options.prefetch) {
prefetchTaskDetail(taskId);
return;
}
startTransition(() => {
setSelectedTaskId(taskId);
setSelectedStepName("");
});
}}
onRunTask={async (taskId) => {
const result = await fetchJson(`/tasks/${encodeURIComponent(taskId)}/actions/run`, { method: "POST" });
await loadShell();
await loadTaskDetail(taskId);
setBanner({ kind: "good", text: `任务已推进: ${taskId} / processed=${result.processed.length}` });
setActionBusy("run");
try {
const result = await fetchJson(`/tasks/${encodeURIComponent(taskId)}/actions/run`, { method: "POST" });
invalidateTaskCaches(taskId);
invalidateSessionCaches(taskDetail?.context?.session_key);
await refreshSelectedTask(taskId);
pushToast("good", `任务已推进: ${taskId} / processed=${result.processed.length}`);
} finally {
setActionBusy("");
}
}}
taskDetail={taskDetail}
session={currentSession}
loading={loading}
detailLoading={detailLoading}
actionBusy={actionBusy}
selectedStepName={selectedStepName}
onSelectStep={setSelectedStepName}
onRetryStep={async (stepName) => {
if (!selectedTaskId || !stepName) return;
const result = await fetchJson(`/tasks/${encodeURIComponent(selectedTaskId)}/actions/retry-step`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step_name: stepName }),
});
await loadShell();
await loadTaskDetail(selectedTaskId);
setBanner({ kind: "good", text: `已重试 ${stepName} / processed=${result.processed.length}` });
setActionBusy("retry");
try {
const result = await fetchJson(`/tasks/${encodeURIComponent(selectedTaskId)}/actions/retry-step`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step_name: stepName }),
});
invalidateTaskCaches(selectedTaskId);
invalidateSessionCaches(taskDetail?.context?.session_key);
await refreshSelectedTask(selectedTaskId);
pushToast("good", `已重试 ${stepName} / processed=${result.processed.length}`);
} finally {
setActionBusy("");
}
}}
onResetStep={async (stepName) => {
if (!selectedTaskId || !stepName) return;
if (!window.confirm(`确认重置到 step=${stepName} 并清理其后的产物吗?`)) return;
const result = await fetchJson(`/tasks/${encodeURIComponent(selectedTaskId)}/actions/reset-to-step`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step_name: stepName }),
setActionBusy("reset");
try {
const result = await fetchJson(`/tasks/${encodeURIComponent(selectedTaskId)}/actions/reset-to-step`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step_name: stepName }),
});
invalidateTaskCaches(selectedTaskId);
invalidateSessionCaches(taskDetail?.context?.session_key);
await refreshSelectedTask(selectedTaskId);
pushToast("good", `已重置到 ${stepName} / processed=${result.run.processed.length}`);
} finally {
setActionBusy("");
}
}}
onBindFullVideo={async (fullVideoBvid) => {
if (!selectedTaskId || !fullVideoBvid) return;
setActionBusy("bind_full_video");
try {
await fetchJson(`/tasks/${encodeURIComponent(selectedTaskId)}/bind-full-video`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ full_video_bvid: fullVideoBvid }),
});
invalidateTaskCaches(selectedTaskId);
invalidateSessionCaches(taskDetail?.context?.session_key);
await refreshSelectedTask(selectedTaskId);
pushToast("good", `已绑定完整版 BV: ${fullVideoBvid}`);
} finally {
setActionBusy("");
}
}}
onOpenSessionTask={(taskId) => {
startTransition(() => {
setSelectedTaskId(taskId);
setSelectedStepName("");
});
await loadShell();
await loadTaskDetail(selectedTaskId);
setBanner({ kind: "good", text: `已重置到 ${stepName} / processed=${result.run.processed.length}` });
}}
onSessionMerge={async (rawTaskIds) => {
const sessionKey = currentSession?.session_key || taskDetail?.context?.session_key;
const taskIds = String(rawTaskIds)
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (!sessionKey || !taskIds.length) return;
setActionBusy("session_merge");
try {
await fetchJson(`/sessions/${encodeURIComponent(sessionKey)}/merge`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task_ids: taskIds }),
});
invalidateJsonCache("/tasks?");
invalidateSessionCaches(sessionKey);
taskIds.forEach((taskId) => invalidateTaskCaches(taskId));
await refreshSelectedTask(selectedTaskId);
pushToast("good", `已合并 ${taskIds.length} 个任务到 session ${sessionKey}`);
} finally {
setActionBusy("");
}
}}
onSessionRebind={async (fullVideoBvid) => {
const sessionKey = currentSession?.session_key || taskDetail?.context?.session_key;
if (!sessionKey || !fullVideoBvid) return;
setActionBusy("session_rebind");
try {
await fetchJson(`/sessions/${encodeURIComponent(sessionKey)}/rebind`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ full_video_bvid: fullVideoBvid }),
});
invalidateSessionCaches(sessionKey);
if (selectedTaskId) invalidateTaskCaches(selectedTaskId);
await refreshSelectedTask(selectedTaskId);
pushToast("good", `已为 session ${sessionKey} 绑定完整版 BV`);
} finally {
setActionBusy("");
}
}}
onTaskQueryChange={(patch) => {
setTaskQuery((current) => ({ ...current, ...patch }));
}}
/>
);
@ -428,9 +802,11 @@ export default function App() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
invalidateJsonCache("/settings");
invalidateJsonCache("/settings/schema");
const refreshed = await fetchJson("/settings");
setSettings(refreshed);
setBanner({ kind: "good", text: "Settings 已保存并刷新" });
pushToast("good", "Settings 已保存并刷新");
return refreshed;
}}
/>
@ -450,9 +826,15 @@ export default function App() {
onToggleAutoRefresh={setAutoRefreshLogs}
onRefreshLog={async () => {
if (!selectedLogName) return;
await loadCurrentLogContent(selectedLogName);
setBanner({ kind: "good", text: "日志已刷新" });
setPanelBusy("refresh_log");
try {
await loadCurrentLogContent(selectedLogName);
pushToast("good", "日志已刷新");
} finally {
setPanelBusy("");
}
}}
busy={panelBusy === "refresh_log"}
/>
);
})();
@ -484,10 +866,19 @@ export default function App() {
<div className="status-row">
<span className={`status-badge ${health ? "good" : "hot"}`}>API {health ? "ok" : "down"}</span>
<span className={`status-badge ${doctorOk ? "good" : "warn"}`}>Doctor {doctorOk ? "ready" : "warn"}</span>
<span className="status-badge">{tasks.length} tasks</span>
<span className="status-badge">{taskTotal} tasks</span>
</div>
</header>
{banner ? <div className={`status-banner ${banner.kind}`}>{banner.text}</div> : null}
{toasts.length ? (
<div className="toast-stack">
{toasts.map((toast) => (
<div key={toast.id} className={`status-banner ${toast.kind}`}>
<span>{toast.text}</span>
<button className="toast-close" onClick={() => removeToast(toast.id)}>关闭</button>
</div>
))}
</div>
) : null}
{currentView}
</main>
</div>