887 lines
33 KiB
JavaScript
887 lines
33 KiB
JavaScript
import { useEffect, useState, useDeferredValue, startTransition } from "react";
|
||
import { useRef } from "react";
|
||
|
||
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 {
|
||
attentionLabel,
|
||
currentStepLabel,
|
||
summarizeAttention,
|
||
summarizeDelivery,
|
||
taskDisplayStatus,
|
||
taskPrimaryActionLabel,
|
||
} from "./lib/format.js";
|
||
|
||
const NAV_ITEMS = ["Overview", "Tasks", "Settings", "Logs"];
|
||
|
||
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 (
|
||
<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 deferredSearch = useDeferredValue(taskQuery.search);
|
||
|
||
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">
|
||
<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="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 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: [] });
|
||
const [logs, setLogs] = useState({ items: [] });
|
||
const [selectedLogName, setSelectedLogName] = useState("");
|
||
const [logContent, setLogContent] = useState(null);
|
||
const [filterCurrentTaskLogs, setFilterCurrentTaskLogs] = useState(false);
|
||
const [autoRefreshLogs, setAutoRefreshLogs] = useState(false);
|
||
const [settings, setSettings] = useState({});
|
||
const [settingsSchema, setSettingsSchema] = useState(null);
|
||
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 [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([
|
||
fetchJsonCached("/runtime/services"),
|
||
fetchJsonCached("/scheduler/preview"),
|
||
fetchJsonCached("/history?limit=20"),
|
||
]);
|
||
setServices(servicesPayload);
|
||
setScheduler(schedulerPayload);
|
||
setHistory(historyPayload);
|
||
}
|
||
|
||
async function loadShell() {
|
||
setLoading(true);
|
||
try {
|
||
const [healthPayload, doctorPayload, taskPayload] = await Promise.all([
|
||
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);
|
||
}
|
||
});
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
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, 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`),
|
||
]);
|
||
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
|
||
|| "";
|
||
setSelectedStepName(suggested);
|
||
}
|
||
} finally {
|
||
setDetailLoading(false);
|
||
}
|
||
}
|
||
|
||
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) pushToast("hot", `初始化失败: ${error}`);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
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;
|
||
let cancelled = false;
|
||
async function loadOverviewView() {
|
||
setOverviewLoading(true);
|
||
try {
|
||
const [servicesPayload, schedulerPayload, historyPayload] = await Promise.all([
|
||
fetchJsonCached("/runtime/services"),
|
||
fetchJsonCached("/scheduler/preview"),
|
||
fetchJsonCached("/history?limit=20"),
|
||
]);
|
||
if (cancelled) return;
|
||
setServices(servicesPayload);
|
||
setScheduler(schedulerPayload);
|
||
setHistory(historyPayload);
|
||
} finally {
|
||
if (!cancelled) setOverviewLoading(false);
|
||
}
|
||
}
|
||
loadOverviewView();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [view]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedTaskId) return;
|
||
let cancelled = false;
|
||
loadTaskDetail(selectedTaskId).catch((error) => {
|
||
if (!cancelled) pushToast("hot", `任务详情加载失败: ${error}`);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [selectedTaskId]);
|
||
|
||
async function loadCurrentLogContent(logName = selectedLogName) {
|
||
if (!logName) return;
|
||
setLogLoading(true);
|
||
try {
|
||
const currentTask = tasks.find((item) => item.id === selectedTaskId);
|
||
let url = `/logs?name=${encodeURIComponent(logName)}&lines=200`;
|
||
if (filterCurrentTaskLogs && currentTask?.title) {
|
||
url += `&contains=${encodeURIComponent(currentTask.title)}`;
|
||
}
|
||
const payload = await fetchJson(url);
|
||
setLogContent(payload);
|
||
} finally {
|
||
setLogLoading(false);
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (view !== "Logs") return;
|
||
let cancelled = false;
|
||
async function loadLogsIndex() {
|
||
setLogLoading(true);
|
||
try {
|
||
const logsPayload = await fetchJson("/logs");
|
||
if (cancelled) return;
|
||
setLogs(logsPayload);
|
||
if (!selectedLogName && logsPayload.items?.length) {
|
||
startTransition(() => setSelectedLogName(logsPayload.items[0].name));
|
||
}
|
||
} finally {
|
||
if (!cancelled) setLogLoading(false);
|
||
}
|
||
}
|
||
loadLogsIndex();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [view]);
|
||
|
||
useEffect(() => {
|
||
if (view !== "Logs" || !selectedLogName) return;
|
||
let cancelled = false;
|
||
loadCurrentLogContent(selectedLogName).catch((error) => {
|
||
if (!cancelled) pushToast("hot", `日志加载失败: ${error}`);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [view, selectedLogName, filterCurrentTaskLogs, selectedTaskId, tasks]);
|
||
|
||
useEffect(() => {
|
||
if (view !== "Logs" || !selectedLogName || !autoRefreshLogs) return;
|
||
const timer = window.setInterval(() => {
|
||
loadCurrentLogContent(selectedLogName).catch(() => {});
|
||
}, 5000);
|
||
return () => window.clearInterval(timer);
|
||
}, [view, selectedLogName, autoRefreshLogs, filterCurrentTaskLogs, selectedTaskId, tasks]);
|
||
|
||
useEffect(() => {
|
||
if (view !== "Settings") return;
|
||
let cancelled = false;
|
||
async function loadSettingsView() {
|
||
setSettingsLoading(true);
|
||
try {
|
||
const [settingsPayload, schemaPayload] = await Promise.all([
|
||
fetchJsonCached("/settings"),
|
||
fetchJsonCached("/settings/schema"),
|
||
]);
|
||
if (cancelled) return;
|
||
setSettings(settingsPayload);
|
||
setSettingsSchema(schemaPayload);
|
||
} finally {
|
||
if (!cancelled) setSettingsLoading(false);
|
||
}
|
||
}
|
||
loadSettingsView();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [view]);
|
||
|
||
const currentView = (() => {
|
||
if (view === "Overview") {
|
||
return (
|
||
<OverviewPanel
|
||
health={health}
|
||
doctorOk={doctorOk}
|
||
tasks={{ items: tasks }}
|
||
services={services}
|
||
scheduler={scheduler}
|
||
history={history}
|
||
loading={overviewLoading}
|
||
onRefreshScheduler={async () => {
|
||
setPanelBusy("refresh_scheduler");
|
||
try {
|
||
const payload = await fetchJson("/scheduler/preview");
|
||
setScheduler(payload);
|
||
pushToast("good", "Scheduler 已刷新");
|
||
} finally {
|
||
setPanelBusy("");
|
||
}
|
||
}}
|
||
onRefreshHistory={async () => {
|
||
setPanelBusy("refresh_history");
|
||
try {
|
||
const payload = await fetchJson("/history?limit=20");
|
||
setHistory(payload);
|
||
pushToast("good", "Recent Actions 已刷新");
|
||
} finally {
|
||
setPanelBusy("");
|
||
}
|
||
}}
|
||
onStageImport={async (sourcePath) => {
|
||
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) => {
|
||
setPanelBusy("stage_upload");
|
||
try {
|
||
const result = await uploadFile("/stage/upload", file);
|
||
await loadTasksOnly();
|
||
pushToast("good", `已上传到 stage: ${result.target_path}`);
|
||
} finally {
|
||
setPanelBusy("");
|
||
}
|
||
}}
|
||
onRunOnce={async () => {
|
||
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) => {
|
||
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("");
|
||
}
|
||
}}
|
||
busy={panelBusy}
|
||
/>
|
||
);
|
||
}
|
||
if (view === "Tasks") {
|
||
return (
|
||
<TasksView
|
||
tasks={tasks}
|
||
taskTotal={taskTotal}
|
||
taskQuery={taskQuery}
|
||
selectedTaskId={selectedTaskId}
|
||
onSelectTask={(taskId, options = {}) => {
|
||
if (options.prefetch) {
|
||
prefetchTaskDetail(taskId);
|
||
return;
|
||
}
|
||
startTransition(() => {
|
||
setSelectedTaskId(taskId);
|
||
setSelectedStepName("");
|
||
});
|
||
}}
|
||
onRunTask={async (taskId) => {
|
||
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;
|
||
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;
|
||
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("");
|
||
});
|
||
}}
|
||
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 }));
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
if (view === "Settings") {
|
||
return (
|
||
<SettingsPanel
|
||
settings={settings}
|
||
schema={settingsSchema}
|
||
loading={settingsLoading}
|
||
onSave={async (payload) => {
|
||
await fetchJson("/settings", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
invalidateJsonCache("/settings");
|
||
invalidateJsonCache("/settings/schema");
|
||
const refreshed = await fetchJson("/settings");
|
||
setSettings(refreshed);
|
||
pushToast("good", "Settings 已保存并刷新");
|
||
return refreshed;
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<LogsPanel
|
||
logs={logs.items || []}
|
||
selectedLogName={selectedLogName}
|
||
onSelectLog={(name) => startTransition(() => setSelectedLogName(name))}
|
||
logContent={logContent}
|
||
loading={logLoading}
|
||
currentTaskTitle={tasks.find((item) => item.id === selectedTaskId)?.title || ""}
|
||
filterCurrentTask={filterCurrentTaskLogs}
|
||
onToggleFilterCurrentTask={setFilterCurrentTaskLogs}
|
||
autoRefresh={autoRefreshLogs}
|
||
onToggleAutoRefresh={setAutoRefreshLogs}
|
||
onRefreshLog={async () => {
|
||
if (!selectedLogName) return;
|
||
setPanelBusy("refresh_log");
|
||
try {
|
||
await loadCurrentLogContent(selectedLogName);
|
||
pushToast("good", "日志已刷新");
|
||
} finally {
|
||
setPanelBusy("");
|
||
}
|
||
}}
|
||
busy={panelBusy === "refresh_log"}
|
||
/>
|
||
);
|
||
})();
|
||
|
||
return (
|
||
<div className="react-shell">
|
||
<aside className="react-sidebar">
|
||
<p className="eyebrow">Biliup Next</p>
|
||
<h1>Frontend</h1>
|
||
<p className="sidebar-copy">React + Vite 控制台迁移骨架,先接管任务工作台。</p>
|
||
<nav className="sidebar-nav-react">
|
||
{NAV_ITEMS.map((item) => (
|
||
<button
|
||
key={item}
|
||
className={view === item ? "nav-btn active" : "nav-btn"}
|
||
onClick={() => setView(item)}
|
||
>
|
||
{item}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
</aside>
|
||
<main className="react-main">
|
||
<header className="react-topbar">
|
||
<div>
|
||
<p className="eyebrow">Migration Workspace</p>
|
||
<h2>{view}</h2>
|
||
</div>
|
||
<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">{taskTotal} tasks</span>
|
||
</div>
|
||
</header>
|
||
{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>
|
||
);
|
||
}
|