init biliup-next
This commit is contained in:
495
frontend/src/App.jsx
Normal file
495
frontend/src/App.jsx
Normal file
@ -0,0 +1,495 @@
|
||||
import { useEffect, useState, useDeferredValue, startTransition } from "react";
|
||||
|
||||
import { fetchJson, 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";
|
||||
|
||||
const NAV_ITEMS = ["Overview", "Tasks", "Settings", "Logs"];
|
||||
|
||||
function PlaceholderView({ title, description }) {
|
||||
return (
|
||||
<section className="placeholder-view">
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TasksView({
|
||||
tasks,
|
||||
selectedTaskId,
|
||||
onSelectTask,
|
||||
onRunTask,
|
||||
taskDetail,
|
||||
loading,
|
||||
detailLoading,
|
||||
selectedStepName,
|
||||
onSelectStep,
|
||||
onRetryStep,
|
||||
onResetStep,
|
||||
}) {
|
||||
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 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");
|
||||
});
|
||||
|
||||
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>
|
||||
<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>
|
||||
<TaskDetailCard
|
||||
payload={taskDetail}
|
||||
loading={detailLoading}
|
||||
selectedStepName={selectedStepName}
|
||||
onSelectStep={onSelectStep}
|
||||
onRetryStep={onRetryStep}
|
||||
onResetStep={onResetStep}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [view, setView] = useState("Tasks");
|
||||
const [health, setHealth] = useState(false);
|
||||
const [doctorOk, setDoctorOk] = useState(false);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
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("");
|
||||
const [selectedStepName, setSelectedStepName] = useState("");
|
||||
const [taskDetail, setTaskDetail] = 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);
|
||||
|
||||
async function loadOverviewPanels() {
|
||||
const [servicesPayload, schedulerPayload, historyPayload] = await Promise.all([
|
||||
fetchJson("/runtime/services"),
|
||||
fetchJson("/scheduler/preview"),
|
||||
fetchJson("/history?limit=20"),
|
||||
]);
|
||||
setServices(servicesPayload);
|
||||
setScheduler(schedulerPayload);
|
||||
setHistory(historyPayload);
|
||||
}
|
||||
|
||||
async function loadShell() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [healthPayload, doctorPayload, taskPayload] = await Promise.all([
|
||||
fetchJson("/health"),
|
||||
fetchJson("/doctor"),
|
||||
fetchJson("/tasks?limit=100"),
|
||||
]);
|
||||
setHealth(Boolean(healthPayload.ok));
|
||||
setDoctorOk(Boolean(doctorPayload.ok));
|
||||
setTasks(taskPayload.items || []);
|
||||
startTransition(() => {
|
||||
if (!selectedTaskId && taskPayload.items?.length) {
|
||||
setSelectedTaskId(taskPayload.items[0].id);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTaskDetail(taskId) {
|
||||
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`),
|
||||
]);
|
||||
setTaskDetail({ task, steps, artifacts, history, timeline });
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadShell().catch((error) => {
|
||||
if (!cancelled) setBanner({ kind: "hot", text: `初始化失败: ${error}` });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedTaskId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== "Overview") return;
|
||||
let cancelled = false;
|
||||
async function loadOverviewView() {
|
||||
setOverviewLoading(true);
|
||||
try {
|
||||
const [servicesPayload, schedulerPayload, historyPayload] = await Promise.all([
|
||||
fetchJson("/runtime/services"),
|
||||
fetchJson("/scheduler/preview"),
|
||||
fetchJson("/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) setBanner({ kind: "hot", text: `任务详情加载失败: ${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) setBanner({ kind: "hot", text: `日志加载失败: ${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([
|
||||
fetchJson("/settings"),
|
||||
fetchJson("/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 () => {
|
||||
const payload = await fetchJson("/scheduler/preview");
|
||||
setScheduler(payload);
|
||||
setBanner({ kind: "good", text: "Scheduler 已刷新" });
|
||||
}}
|
||||
onRefreshHistory={async () => {
|
||||
const payload = await fetchJson("/history?limit=20");
|
||||
setHistory(payload);
|
||||
setBanner({ kind: "good", text: "Recent Actions 已刷新" });
|
||||
}}
|
||||
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}` });
|
||||
}}
|
||||
onStageUpload={async (file) => {
|
||||
const result = await uploadFile("/stage/upload", file);
|
||||
await loadShell();
|
||||
setBanner({ kind: "good", text: `已上传到 stage: ${result.target_path}` });
|
||||
}}
|
||||
onRunOnce={async () => {
|
||||
await fetchJson("/worker/run-once", { method: "POST" });
|
||||
await loadShell();
|
||||
setBanner({ kind: "good", text: "Worker 已执行一轮" });
|
||||
}}
|
||||
onServiceAction={async (serviceId, action) => {
|
||||
await fetchJson(`/runtime/services/${serviceId}/${action}`, { method: "POST" });
|
||||
await loadShell();
|
||||
if (view === "Overview") {
|
||||
await loadOverviewPanels();
|
||||
}
|
||||
setBanner({ kind: "good", text: `${serviceId} ${action} 完成` });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (view === "Tasks") {
|
||||
return (
|
||||
<TasksView
|
||||
tasks={tasks}
|
||||
selectedTaskId={selectedTaskId}
|
||||
onSelectTask={(taskId) => {
|
||||
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}` });
|
||||
}}
|
||||
taskDetail={taskDetail}
|
||||
loading={loading}
|
||||
detailLoading={detailLoading}
|
||||
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}` });
|
||||
}}
|
||||
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 }),
|
||||
});
|
||||
await loadShell();
|
||||
await loadTaskDetail(selectedTaskId);
|
||||
setBanner({ kind: "good", text: `已重置到 ${stepName} / processed=${result.run.processed.length}` });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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),
|
||||
});
|
||||
const refreshed = await fetchJson("/settings");
|
||||
setSettings(refreshed);
|
||||
setBanner({ kind: "good", text: "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;
|
||||
await loadCurrentLogContent(selectedLogName);
|
||||
setBanner({ kind: "good", text: "日志已刷新" });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})();
|
||||
|
||||
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">{tasks.length} tasks</span>
|
||||
</div>
|
||||
</header>
|
||||
{banner ? <div className={`status-banner ${banner.kind}`}>{banner.text}</div> : null}
|
||||
{currentView}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user