feat: professionalize control plane and standalone delivery
This commit is contained in:
@ -54,12 +54,18 @@ http://127.0.0.1:5173/ui/
|
||||
生产构建完成后,把输出放到 `frontend/dist/`,当前 Python API 会自动在以下地址托管它:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8787/ui/
|
||||
http://127.0.0.1:8787/
|
||||
```
|
||||
|
||||
## 下一步
|
||||
旧控制台回退入口:
|
||||
|
||||
- 迁移 `Settings`
|
||||
- 将任务表改为真正服务端驱动的分页/排序/筛选
|
||||
- 增加 React 路由和查询缓存
|
||||
- 最终替换当前 `src/biliup_next/app/static/` 入口
|
||||
```text
|
||||
http://127.0.0.1:8787/classic
|
||||
```
|
||||
|
||||
## 当前状态
|
||||
|
||||
- React 控制台已接管默认首页
|
||||
- 任务页已支持 `session context / bind full video / session merge / session rebind`
|
||||
- 高频任务操作已改为局部刷新
|
||||
- 旧原生控制台仍保留作回退路径
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,3 +1,13 @@
|
||||
const jsonCache = new Map();
|
||||
|
||||
function cacheKey(url, options = {}) {
|
||||
return JSON.stringify({
|
||||
url,
|
||||
method: options.method || "GET",
|
||||
headers: options.headers || {},
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchJson(url, options = {}) {
|
||||
const token = localStorage.getItem("biliup_next_token") || "";
|
||||
const headers = { ...(options.headers || {}) };
|
||||
@ -10,6 +20,34 @@ export async function fetchJson(url, options = {}) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function fetchJsonCached(url, options = {}, ttlMs = 8000) {
|
||||
const method = options.method || "GET";
|
||||
if (method !== "GET") {
|
||||
return fetchJson(url, options);
|
||||
}
|
||||
const key = cacheKey(url, options);
|
||||
const cached = jsonCache.get(key);
|
||||
if (cached && Date.now() - cached.time < ttlMs) {
|
||||
return cached.payload;
|
||||
}
|
||||
const payload = await fetchJson(url, options);
|
||||
jsonCache.set(key, { time: Date.now(), payload });
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function primeJsonCache(url, payload, options = {}) {
|
||||
const key = cacheKey(url, options);
|
||||
jsonCache.set(key, { time: Date.now(), payload });
|
||||
}
|
||||
|
||||
export function invalidateJsonCache(match) {
|
||||
for (const key of jsonCache.keys()) {
|
||||
if (typeof match === "string" ? key.includes(match) : match.test(key)) {
|
||||
jsonCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadFile(url, file) {
|
||||
const token = localStorage.getItem("biliup_next_token") || "";
|
||||
const form = new FormData();
|
||||
|
||||
@ -18,6 +18,7 @@ export default function LogsPanel({
|
||||
onToggleFilterCurrentTask,
|
||||
autoRefresh,
|
||||
onToggleAutoRefresh,
|
||||
busy,
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [lineFilter, setLineFilter] = useState("");
|
||||
@ -67,7 +68,9 @@ export default function LogsPanel({
|
||||
<p className="eyebrow">Log Detail</p>
|
||||
<h2>{selectedLogName || "选择一个日志"}</h2>
|
||||
</div>
|
||||
<button className="nav-btn" onClick={onRefreshLog}>刷新</button>
|
||||
<button className="nav-btn" onClick={onRefreshLog} disabled={busy}>
|
||||
{busy ? "刷新中..." : "刷新"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-grid compact-grid">
|
||||
<input value={lineFilter} onChange={(event) => setLineFilter(event.target.value)} placeholder="过滤日志行内容" />
|
||||
|
||||
@ -27,6 +27,7 @@ export default function OverviewPanel({
|
||||
onServiceAction,
|
||||
onStageImport,
|
||||
onStageUpload,
|
||||
busy,
|
||||
}) {
|
||||
const [stageSourcePath, setStageSourcePath] = useState("");
|
||||
const [stageFile, setStageFile] = useState(null);
|
||||
@ -65,13 +66,14 @@ export default function OverviewPanel({
|
||||
/>
|
||||
<button
|
||||
className="nav-btn compact-btn"
|
||||
disabled={busy === "stage_import"}
|
||||
onClick={async () => {
|
||||
if (!stageSourcePath.trim()) return;
|
||||
await onStageImport?.(stageSourcePath.trim());
|
||||
setStageSourcePath("");
|
||||
}}
|
||||
>
|
||||
复制到隔离 Stage
|
||||
{busy === "stage_import" ? "导入中..." : "复制到隔离 Stage"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="stage-input-grid upload-grid-react">
|
||||
@ -81,13 +83,14 @@ export default function OverviewPanel({
|
||||
/>
|
||||
<button
|
||||
className="nav-btn compact-btn strong-btn"
|
||||
disabled={!stageFile || busy === "stage_upload"}
|
||||
onClick={async () => {
|
||||
if (!stageFile) return;
|
||||
await onStageUpload?.(stageFile);
|
||||
setStageFile(null);
|
||||
}}
|
||||
>
|
||||
上传到隔离 Stage
|
||||
{busy === "stage_upload" ? "上传中..." : "上传到隔离 Stage"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted">只会导入到 `biliup-next/data/workspace/stage/`,不会移动原文件。</p>
|
||||
@ -96,7 +99,9 @@ export default function OverviewPanel({
|
||||
<article className="detail-card">
|
||||
<div className="card-head-inline">
|
||||
<h3>Runtime Services</h3>
|
||||
<button className="nav-btn compact-btn strong-btn" onClick={onRunOnce}>执行一轮 Worker</button>
|
||||
<button className="nav-btn compact-btn strong-btn" onClick={onRunOnce} disabled={busy === "run_once"}>
|
||||
{busy === "run_once" ? "执行中..." : "执行一轮 Worker"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="list-stack">
|
||||
{serviceItems.map((service) => (
|
||||
@ -107,9 +112,9 @@ export default function OverviewPanel({
|
||||
</div>
|
||||
<div className="service-actions">
|
||||
<StatusBadge tone={service.active_state === "active" ? "good" : "hot"}>{service.active_state}</StatusBadge>
|
||||
<button className="nav-btn compact-btn" onClick={() => onServiceAction?.(service.id, "start")}>start</button>
|
||||
<button className="nav-btn compact-btn" onClick={() => onServiceAction?.(service.id, "restart")}>restart</button>
|
||||
<button className="nav-btn compact-btn" onClick={() => onServiceAction?.(service.id, "stop")}>stop</button>
|
||||
<button className="nav-btn compact-btn" onClick={() => onServiceAction?.(service.id, "start")} disabled={busy === `service:${service.id}:start`}>start</button>
|
||||
<button className="nav-btn compact-btn" onClick={() => onServiceAction?.(service.id, "restart")} disabled={busy === `service:${service.id}:restart`}>restart</button>
|
||||
<button className="nav-btn compact-btn" onClick={() => onServiceAction?.(service.id, "stop")} disabled={busy === `service:${service.id}:stop`}>stop</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -120,7 +125,9 @@ export default function OverviewPanel({
|
||||
<article className="detail-card">
|
||||
<div className="card-head-inline">
|
||||
<h3>Scheduler Queue</h3>
|
||||
<button className="nav-btn compact-btn" onClick={onRefreshScheduler}>刷新</button>
|
||||
<button className="nav-btn compact-btn" onClick={onRefreshScheduler} disabled={busy === "refresh_scheduler"}>
|
||||
{busy === "refresh_scheduler" ? "刷新中..." : "刷新"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="list-stack">
|
||||
<div className="list-row"><span>scheduled</span><strong>{scheduled.length}</strong></div>
|
||||
@ -148,7 +155,9 @@ export default function OverviewPanel({
|
||||
<article className="detail-card">
|
||||
<div className="card-head-inline">
|
||||
<h3>Recent Actions</h3>
|
||||
<button className="nav-btn compact-btn" onClick={onRefreshHistory}>刷新</button>
|
||||
<button className="nav-btn compact-btn" onClick={onRefreshHistory} disabled={busy === "refresh_history"}>
|
||||
{busy === "refresh_history" ? "刷新中..." : "刷新"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="list-stack">
|
||||
{actionItems.slice(0, 8).map((item) => (
|
||||
|
||||
@ -1,7 +1,17 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import StatusBadge from "./StatusBadge.jsx";
|
||||
import { attentionLabel, deliveryLabel, formatDate, summarizeAttention, summarizeDelivery } from "../lib/format.js";
|
||||
import {
|
||||
actionAdvice,
|
||||
attentionLabel,
|
||||
currentStepLabel,
|
||||
deliveryLabel,
|
||||
formatDate,
|
||||
summarizeAttention,
|
||||
summarizeDelivery,
|
||||
recommendedAction,
|
||||
taskDisplayStatus,
|
||||
} from "../lib/format.js";
|
||||
|
||||
function SummaryRow({ label, value }) {
|
||||
return (
|
||||
@ -20,12 +30,43 @@ function suggestedStepName(steps) {
|
||||
|
||||
export default function TaskDetailCard({
|
||||
payload,
|
||||
session,
|
||||
loading,
|
||||
actionBusy,
|
||||
selectedStepName,
|
||||
onSelectStep,
|
||||
onRetryStep,
|
||||
onResetStep,
|
||||
onBindFullVideo,
|
||||
onOpenSessionTask,
|
||||
onSessionMerge,
|
||||
onSessionRebind,
|
||||
}) {
|
||||
const [fullVideoInput, setFullVideoInput] = useState("");
|
||||
const [sessionRebindInput, setSessionRebindInput] = useState("");
|
||||
const [sessionMergeInput, setSessionMergeInput] = useState("");
|
||||
const task = payload?.task;
|
||||
const steps = payload?.steps;
|
||||
const artifacts = payload?.artifacts;
|
||||
const history = payload?.history;
|
||||
const context = payload?.context;
|
||||
const delivery = task?.delivery_state || {};
|
||||
const latestAction = history?.items?.[0];
|
||||
const sessionContext = task?.session_context || context || {};
|
||||
const activeStepName = selectedStepName || suggestedStepName(steps);
|
||||
const splitUrl = sessionContext.video_links?.split_video_url;
|
||||
const fullUrl = sessionContext.video_links?.full_video_url;
|
||||
const nextAction = recommendedAction(task);
|
||||
|
||||
useEffect(() => {
|
||||
setFullVideoInput(sessionContext.full_video_bvid || "");
|
||||
}, [sessionContext.full_video_bvid, task?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setSessionRebindInput(session?.full_video_bvid || "");
|
||||
setSessionMergeInput("");
|
||||
}, [session?.full_video_bvid, session?.session_key]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<article className="panel detail-panel">
|
||||
@ -52,37 +93,45 @@ export default function TaskDetailCard({
|
||||
);
|
||||
}
|
||||
|
||||
const { task, steps, artifacts, history } = payload;
|
||||
const delivery = task.delivery_state || {};
|
||||
const latestAction = history?.items?.[0];
|
||||
const activeStepName = useMemo(
|
||||
() => selectedStepName || suggestedStepName(steps),
|
||||
[selectedStepName, steps],
|
||||
);
|
||||
|
||||
return (
|
||||
<article className="panel detail-panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Task Detail</p>
|
||||
<h2>{task.title}</h2>
|
||||
<p className="muted detail-lead">{actionAdvice(task)}</p>
|
||||
</div>
|
||||
<div className="status-row">
|
||||
<StatusBadge>{task.status}</StatusBadge>
|
||||
<StatusBadge>{taskDisplayStatus(task)}</StatusBadge>
|
||||
<StatusBadge>{attentionLabel(summarizeAttention(task))}</StatusBadge>
|
||||
<button className="nav-btn compact-btn" onClick={() => onRetryStep?.(activeStepName)} disabled={!activeStepName}>
|
||||
Retry Step
|
||||
<button className="nav-btn compact-btn" onClick={() => onRetryStep?.(activeStepName)} disabled={!activeStepName || actionBusy}>
|
||||
{actionBusy === "retry" ? "重试中..." : "重试当前步骤"}
|
||||
</button>
|
||||
<button className="nav-btn compact-btn strong-btn" onClick={() => onResetStep?.(activeStepName)} disabled={!activeStepName}>
|
||||
Reset To Step
|
||||
<button className="nav-btn compact-btn strong-btn" onClick={() => onResetStep?.(activeStepName)} disabled={!activeStepName || actionBusy}>
|
||||
{actionBusy === "reset" ? "重置中..." : "重置到此步骤"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-grid">
|
||||
<section className="detail-card">
|
||||
<h3>Recommended Next Step</h3>
|
||||
<SummaryRow label="Action" value={nextAction.label} />
|
||||
<p className="muted">{nextAction.detail}</p>
|
||||
<div className="row-actions" style={{ marginTop: 12 }}>
|
||||
{nextAction.action === "retry" ? (
|
||||
<button className="nav-btn compact-btn strong-btn" onClick={() => onRetryStep?.(activeStepName)} disabled={!activeStepName || actionBusy}>
|
||||
{actionBusy === "retry" ? "重试中..." : nextAction.label}
|
||||
</button>
|
||||
) : splitUrl ? (
|
||||
<a className="nav-btn compact-btn strong-btn" href={splitUrl} target="_blank" rel="noreferrer">打开当前结果</a>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<section className="detail-card">
|
||||
<h3>Current State</h3>
|
||||
<SummaryRow label="Task ID" value={task.id} />
|
||||
<SummaryRow label="Current Step" value={currentStepLabel(task, steps?.items || [])} />
|
||||
<SummaryRow label="Updated" value={formatDate(task.updated_at)} />
|
||||
<SummaryRow label="Next Retry" value={formatDate(task.retry_state?.next_retry_at)} />
|
||||
<SummaryRow label="Split Comment" value={deliveryLabel(delivery.split_comment || "pending")} />
|
||||
@ -104,6 +153,31 @@ export default function TaskDetailCard({
|
||||
</div>
|
||||
|
||||
<div className="detail-grid">
|
||||
<section className="detail-card">
|
||||
<h3>Delivery & Context</h3>
|
||||
<SummaryRow label="Split BV" value={sessionContext.split_bvid || "-"} />
|
||||
<SummaryRow label="Full BV" value={sessionContext.full_video_bvid || "-"} />
|
||||
<SummaryRow label="Session Key" value={sessionContext.session_key || "-"} />
|
||||
<SummaryRow label="Streamer" value={sessionContext.streamer || "-"} />
|
||||
<SummaryRow label="Context Source" value={sessionContext.context_source || "-"} />
|
||||
<div className="row-actions" style={{ marginTop: 12 }}>
|
||||
{splitUrl ? <a className="nav-btn compact-btn" href={splitUrl} target="_blank" rel="noreferrer">打开分P</a> : null}
|
||||
{fullUrl ? <a className="nav-btn compact-btn" href={fullUrl} target="_blank" rel="noreferrer">打开完整版</a> : null}
|
||||
</div>
|
||||
<div className="bind-block">
|
||||
<label className="muted">绑定完整版 BV</label>
|
||||
<input value={fullVideoInput} onChange={(event) => setFullVideoInput(event.target.value)} placeholder="BV1..." />
|
||||
<div className="row-actions">
|
||||
<button
|
||||
className="nav-btn compact-btn strong-btn"
|
||||
onClick={() => onBindFullVideo?.(fullVideoInput.trim())}
|
||||
disabled={actionBusy}
|
||||
>
|
||||
{actionBusy === "bind_full_video" ? "绑定中..." : "绑定完整版 BV"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="detail-card">
|
||||
<h3>Steps</h3>
|
||||
<div className="list-stack">
|
||||
@ -137,6 +211,60 @@ export default function TaskDetailCard({
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="detail-grid">
|
||||
<section className="detail-card session-card-full">
|
||||
<h3>Session Workspace</h3>
|
||||
{!session?.session_key ? (
|
||||
<p className="muted">当前任务如果已绑定 session_key,这里会显示同场片段和完整版绑定信息。</p>
|
||||
) : (
|
||||
<>
|
||||
<SummaryRow label="Session Key" value={session.session_key} />
|
||||
<SummaryRow label="Task Count" value={String(session.task_count || 0)} />
|
||||
<SummaryRow label="Session Full BV" value={session.full_video_bvid || "-"} />
|
||||
<div className="bind-block">
|
||||
<label className="muted">整个 Session 重绑 BV</label>
|
||||
<input value={sessionRebindInput} onChange={(event) => setSessionRebindInput(event.target.value)} placeholder="BV1..." />
|
||||
<div className="row-actions">
|
||||
<button
|
||||
className="nav-btn compact-btn"
|
||||
onClick={() => onSessionRebind?.(sessionRebindInput.trim())}
|
||||
disabled={actionBusy}
|
||||
>
|
||||
{actionBusy === "session_rebind" ? "重绑中..." : "Session 重绑 BV"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bind-block">
|
||||
<label className="muted">合并任务到当前 Session</label>
|
||||
<input value={sessionMergeInput} onChange={(event) => setSessionMergeInput(event.target.value)} placeholder="输入 task id,用逗号分隔" />
|
||||
<div className="row-actions">
|
||||
<button
|
||||
className="nav-btn compact-btn"
|
||||
onClick={() => onSessionMerge?.(sessionMergeInput)}
|
||||
disabled={actionBusy}
|
||||
>
|
||||
{actionBusy === "session_merge" ? "合并中..." : "合并到当前 Session"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="list-stack">
|
||||
{(session.tasks || []).map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="list-row selectable"
|
||||
onClick={() => onOpenSessionTask?.(item.id)}
|
||||
>
|
||||
<span>{item.title}</span>
|
||||
<StatusBadge>{taskDisplayStatus(item)}</StatusBadge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
import StatusBadge from "./StatusBadge.jsx";
|
||||
import { attentionLabel, deliveryLabel, formatDate, summarizeAttention, summarizeDelivery } from "../lib/format.js";
|
||||
import {
|
||||
attentionLabel,
|
||||
currentStepLabel,
|
||||
deliveryLabel,
|
||||
formatDate,
|
||||
summarizeAttention,
|
||||
summarizeDelivery,
|
||||
taskDisplayStatus,
|
||||
taskPrimaryActionLabel,
|
||||
} from "../lib/format.js";
|
||||
|
||||
function deliveryStateLabel(task) {
|
||||
const delivery = task.delivery_state || {};
|
||||
@ -12,73 +21,69 @@ function deliveryStateLabel(task) {
|
||||
|
||||
export default function TaskTable({ tasks, selectedTaskId, onSelectTask, onRunTask }) {
|
||||
return (
|
||||
<div className="table-wrap-react">
|
||||
<table className="task-table-react">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>任务</th>
|
||||
<th>状态</th>
|
||||
<th>关注</th>
|
||||
<th>纯享评论</th>
|
||||
<th>主视频评论</th>
|
||||
<th>清理</th>
|
||||
<th>下次重试</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((task) => {
|
||||
const delivery = deliveryStateLabel(task);
|
||||
return (
|
||||
<tr
|
||||
key={task.id}
|
||||
className={selectedTaskId === task.id ? "active" : ""}
|
||||
onClick={() => onSelectTask(task.id)}
|
||||
>
|
||||
<td>
|
||||
<div className="task-title">{task.title}</div>
|
||||
<div className="task-subtitle">{task.id}</div>
|
||||
</td>
|
||||
<td><StatusBadge>{task.status}</StatusBadge></td>
|
||||
<td><StatusBadge>{attentionLabel(summarizeAttention(task))}</StatusBadge></td>
|
||||
<td><StatusBadge>{delivery.splitComment}</StatusBadge></td>
|
||||
<td><StatusBadge>{delivery.fullComment}</StatusBadge></td>
|
||||
<td><StatusBadge>{delivery.cleanup}</StatusBadge></td>
|
||||
<td>
|
||||
<div>{formatDate(task.retry_state?.next_retry_at)}</div>
|
||||
{task.retry_state?.retry_remaining_seconds != null ? (
|
||||
<div className="task-subtitle">{task.retry_state.retry_remaining_seconds}s</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{formatDate(task.updated_at)}</td>
|
||||
<td>
|
||||
<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);
|
||||
}}
|
||||
>
|
||||
执行
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="task-cards-grid">
|
||||
{tasks.map((task) => {
|
||||
const delivery = deliveryStateLabel(task);
|
||||
return (
|
||||
<button
|
||||
key={task.id}
|
||||
type="button"
|
||||
className={selectedTaskId === task.id ? "task-card active" : "task-card"}
|
||||
onClick={() => onSelectTask(task.id)}
|
||||
onMouseEnter={() => onSelectTask?.(task.id, { prefetch: true })}
|
||||
>
|
||||
<div className="task-card-head">
|
||||
<StatusBadge>{taskDisplayStatus(task)}</StatusBadge>
|
||||
<StatusBadge>{attentionLabel(summarizeAttention(task))}</StatusBadge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="task-title">{task.title}</div>
|
||||
<div className="task-subtitle">{currentStepLabel(task)}</div>
|
||||
</div>
|
||||
<div className="task-card-metrics">
|
||||
<div className="task-metric">
|
||||
<span>纯享评论</span>
|
||||
<strong>{delivery.splitComment}</strong>
|
||||
</div>
|
||||
<div className="task-metric">
|
||||
<span>主视频评论</span>
|
||||
<strong>{delivery.fullComment}</strong>
|
||||
</div>
|
||||
<div className="task-metric">
|
||||
<span>清理</span>
|
||||
<strong>{delivery.cleanup}</strong>
|
||||
</div>
|
||||
<div className="task-metric">
|
||||
<span>下次重试</span>
|
||||
<strong>{formatDate(task.retry_state?.next_retry_at)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="task-card-foot">
|
||||
<div className="task-subtitle">更新于 {formatDate(task.updated_at)}</div>
|
||||
<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>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
export function statusClass(status) {
|
||||
if (["collection_synced", "published", "done", "resolved", "present"].includes(status)) return "good";
|
||||
if (["failed_manual"].includes(status)) return "hot";
|
||||
if (["failed_retryable", "pending", "legacy_untracked", "running", "retry_now", "waiting_retry", "manual_now"].includes(status)) return "warn";
|
||||
if (["failed_retryable", "pending", "running", "retry_now", "waiting_retry", "manual_now"].includes(status)) return "warn";
|
||||
return "";
|
||||
}
|
||||
|
||||
@ -31,7 +31,6 @@ export function attentionLabel(value) {
|
||||
}
|
||||
|
||||
export function summarizeDelivery(delivery = {}) {
|
||||
if (delivery.full_video_timeline_comment === "legacy_untracked") return "legacy_untracked";
|
||||
if (delivery.split_comment === "pending" || delivery.full_video_timeline_comment === "pending") return "pending_comment";
|
||||
if (delivery.source_video_present === false || delivery.split_videos_present === false) return "cleanup_removed";
|
||||
return "stable";
|
||||
@ -41,7 +40,6 @@ export function deliveryLabel(value) {
|
||||
return {
|
||||
done: "已发送",
|
||||
pending: "待处理",
|
||||
legacy_untracked: "历史未追踪",
|
||||
present: "保留",
|
||||
removed: "已清理",
|
||||
cleanup_removed: "已清理视频",
|
||||
@ -49,3 +47,96 @@ export function deliveryLabel(value) {
|
||||
stable: "正常",
|
||||
}[value] || value;
|
||||
}
|
||||
|
||||
export function taskDisplayStatus(task) {
|
||||
if (!task) return "-";
|
||||
if (task.status === "failed_manual") return "需人工处理";
|
||||
if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") return "等待B站可见";
|
||||
if (task.status === "failed_retryable") return "等待自动重试";
|
||||
return {
|
||||
created: "已接收",
|
||||
transcribed: "已转录",
|
||||
songs_detected: "已识歌",
|
||||
split_done: "已切片",
|
||||
published: "已上传",
|
||||
commented: "评论完成",
|
||||
collection_synced: "已完成",
|
||||
running: "处理中",
|
||||
}[task.status] || task.status || "-";
|
||||
}
|
||||
|
||||
export function stepLabel(stepName) {
|
||||
return {
|
||||
ingest: "接收视频",
|
||||
transcribe: "转录字幕",
|
||||
song_detect: "识别歌曲",
|
||||
split: "切分分P",
|
||||
publish: "上传分P",
|
||||
comment: "发布评论",
|
||||
collection_a: "加入完整版合集",
|
||||
collection_b: "加入分P合集",
|
||||
}[stepName] || stepName || "-";
|
||||
}
|
||||
|
||||
export function currentStepLabel(task, steps = []) {
|
||||
const running = steps.find((step) => step.status === "running");
|
||||
if (running) return stepLabel(running.step_name);
|
||||
if (task?.retry_state?.step_name) return `${stepLabel(task.retry_state.step_name)} · ${taskDisplayStatus(task)}`;
|
||||
const pending = steps.find((step) => step.status === "pending");
|
||||
if (pending) return stepLabel(pending.step_name);
|
||||
return {
|
||||
created: "转录字幕",
|
||||
transcribed: "识别歌曲",
|
||||
songs_detected: "切分分P",
|
||||
split_done: "上传分P",
|
||||
published: "评论与合集",
|
||||
commented: "同步合集",
|
||||
collection_synced: "链路完成",
|
||||
}[task?.status] || "-";
|
||||
}
|
||||
|
||||
export function taskPrimaryActionLabel(task) {
|
||||
if (!task) return "执行";
|
||||
if (task.status === "failed_manual") return "人工重跑";
|
||||
if (task.retry_state?.retry_due) return "立即重试";
|
||||
if (task.status === "failed_retryable") return "继续处理";
|
||||
if (task.status === "collection_synced") return "查看";
|
||||
return "执行";
|
||||
}
|
||||
|
||||
export function actionAdvice(task) {
|
||||
if (!task) return "";
|
||||
if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") {
|
||||
return "B站通常需要一段时间完成转码和审核,系统会自动重试评论。";
|
||||
}
|
||||
if (task.status === "failed_retryable") {
|
||||
return "当前错误可自动恢复,等到重试时间或手工触发即可。";
|
||||
}
|
||||
if (task.status === "failed_manual") {
|
||||
return "先看错误信息,再决定是重试步骤还是绑定完整版 BV。";
|
||||
}
|
||||
if (task.status === "collection_synced") {
|
||||
return "链路已完成,可以直接打开分P或完整版链接检查结果。";
|
||||
}
|
||||
return "系统会继续推进后续步骤,必要时可在这里手工干预。";
|
||||
}
|
||||
|
||||
export function recommendedAction(task) {
|
||||
if (!task) return { label: "查看任务", detail: "先打开详情,确认当前步骤和最近动作。", action: "open" };
|
||||
if (task.status === "failed_manual") {
|
||||
return { label: "处理失败步骤", detail: "这是需要人工介入的任务,优先查看错误并决定是否重试。", action: "retry" };
|
||||
}
|
||||
if (task.status === "failed_retryable" && task.retry_state?.step_name === "comment") {
|
||||
return { label: "等待平台可见", detail: "B站通常需要转码和审核,暂时不需要人工操作。", action: "wait" };
|
||||
}
|
||||
if (task.retry_state?.retry_due) {
|
||||
return { label: "立即重试", detail: "已经到达重试窗口,可以立即推进当前步骤。", action: "retry" };
|
||||
}
|
||||
if (task.status === "published") {
|
||||
return { label: "检查评论与合集", detail: "上传已经完成,下一步是确认评论和合集同步。", action: "open" };
|
||||
}
|
||||
if (task.status === "collection_synced") {
|
||||
return { label: "检查最终结果", detail: "链路已经完成,可直接打开视频或做清理确认。", action: "open" };
|
||||
}
|
||||
return { label: "继续观察", detail: "当前任务仍在正常推进,必要时可手工执行一轮。", action: "open" };
|
||||
}
|
||||
|
||||
@ -66,11 +66,20 @@ button {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.toast-stack {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
border-radius: 18px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.86);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-banner.good {
|
||||
@ -88,6 +97,13 @@ button {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.react-topbar {
|
||||
padding: 18px 22px;
|
||||
display: flex;
|
||||
@ -225,6 +241,11 @@ button {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.tasks-main-stack {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overview-stack-react {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
@ -259,49 +280,61 @@ button {
|
||||
background: rgba(255,255,255,0.92);
|
||||
}
|
||||
|
||||
.table-wrap-react {
|
||||
max-height: calc(100vh - 280px);
|
||||
overflow: auto;
|
||||
.task-cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
background: rgba(255,255,255,0.84);
|
||||
}
|
||||
|
||||
.task-table-react {
|
||||
width: 100%;
|
||||
min-width: 980px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.task-table-react th,
|
||||
.task-table-react td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.task-table-react th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(243, 239, 232, 0.96);
|
||||
.task-card.active {
|
||||
border-color: rgba(178, 75, 26, 0.28);
|
||||
background: linear-gradient(135deg, rgba(255, 248, 240, 0.98), rgba(249, 242, 234, 0.95));
|
||||
}
|
||||
|
||||
.task-card-head,
|
||||
.task-card-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-card-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.task-metric {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255,255,255,0.72);
|
||||
}
|
||||
|
||||
.task-metric span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.task-table-react tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease;
|
||||
}
|
||||
|
||||
.task-table-react tbody tr:hover {
|
||||
background: rgba(178, 75, 26, 0.06);
|
||||
}
|
||||
|
||||
.task-table-react tbody tr.active {
|
||||
background: linear-gradient(135deg, rgba(255, 248, 240, 0.98), rgba(249, 242, 234, 0.95));
|
||||
.task-metric strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
@ -315,6 +348,40 @@ button {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.focus-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.focus-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
background: rgba(255,255,255,0.84);
|
||||
text-align: left;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.focus-card.active {
|
||||
border-color: rgba(178, 75, 26, 0.28);
|
||||
background: linear-gradient(135deg, rgba(255, 248, 240, 0.98), rgba(249, 242, 234, 0.95));
|
||||
}
|
||||
|
||||
.focus-card p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.focus-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail-panel .detail-row,
|
||||
.list-row {
|
||||
display: flex;
|
||||
@ -351,6 +418,31 @@ button {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-lead {
|
||||
margin-top: 8px;
|
||||
max-width: 56ch;
|
||||
}
|
||||
|
||||
.bind-block {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.bind-block input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 11px 12px;
|
||||
background: rgba(255,255,255,0.96);
|
||||
}
|
||||
|
||||
.session-card-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.row-actions,
|
||||
.service-actions,
|
||||
.card-head-inline {
|
||||
@ -550,4 +642,34 @@ button {
|
||||
.toolbar-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.session-card-full {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.focus-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.task-cards-grid,
|
||||
.task-card-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.react-shell {
|
||||
width: min(100vw - 20px, 100%);
|
||||
margin: 10px auto 24px;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.react-topbar,
|
||||
.react-sidebar {
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user