Files
biliup-next/frontend/src/App.jsx

887 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

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>
);
}