feat: package docker deployment and publish flow
This commit is contained in:
1772
frontend/src/App.jsx
1772
frontend/src/App.jsx
File diff suppressed because it is too large
Load Diff
@ -1,67 +1,67 @@
|
||||
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 || {}) };
|
||||
if (token) headers["X-Biliup-Token"] = token;
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message || payload.error || JSON.stringify(payload));
|
||||
}
|
||||
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();
|
||||
form.append("file", file);
|
||||
const headers = {};
|
||||
if (token) headers["X-Biliup-Token"] = token;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: form,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message || payload.error || JSON.stringify(payload));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
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 || {}) };
|
||||
if (token) headers["X-Biliup-Token"] = token;
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message || payload.error || JSON.stringify(payload));
|
||||
}
|
||||
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();
|
||||
form.append("file", file);
|
||||
const headers = {};
|
||||
if (token) headers["X-Biliup-Token"] = token;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: form,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message || payload.error || JSON.stringify(payload));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
@ -1,90 +1,90 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
function buildFilteredLines(lines, query) {
|
||||
if (!query) return lines;
|
||||
const needle = query.toLowerCase();
|
||||
return lines.filter((line) => String(line).toLowerCase().includes(needle));
|
||||
}
|
||||
|
||||
export default function LogsPanel({
|
||||
logs,
|
||||
selectedLogName,
|
||||
onSelectLog,
|
||||
logContent,
|
||||
loading,
|
||||
onRefreshLog,
|
||||
currentTaskTitle,
|
||||
filterCurrentTask,
|
||||
onToggleFilterCurrentTask,
|
||||
autoRefresh,
|
||||
onToggleAutoRefresh,
|
||||
busy,
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [lineFilter, setLineFilter] = useState("");
|
||||
|
||||
const visibleLogs = useMemo(() => {
|
||||
if (!search) return logs;
|
||||
const needle = search.toLowerCase();
|
||||
return logs.filter((item) => `${item.name} ${item.path}`.toLowerCase().includes(needle));
|
||||
}, [logs, search]);
|
||||
|
||||
const filteredLines = useMemo(
|
||||
() => buildFilteredLines(logContent?.lines || [], lineFilter),
|
||||
[logContent?.lines, lineFilter],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="logs-layout-react">
|
||||
<article className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Logs Workspace</p>
|
||||
<h2>Log Index</h2>
|
||||
</div>
|
||||
<div className="panel-meta">{visibleLogs.length} logs</div>
|
||||
</div>
|
||||
<div className="toolbar-grid compact-grid">
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索日志文件" />
|
||||
</div>
|
||||
<div className="log-index-list">
|
||||
{visibleLogs.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
className={selectedLogName === item.name ? "log-index-item active" : "log-index-item"}
|
||||
onClick={() => onSelectLog(item.name)}
|
||||
>
|
||||
<strong>{item.name}</strong>
|
||||
<span>{item.path}</span>
|
||||
</button>
|
||||
))}
|
||||
{!visibleLogs.length ? <p className="muted">{loading ? "loading..." : "暂无日志文件"}</p> : null}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="panel detail-panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Log Detail</p>
|
||||
<h2>{selectedLogName || "选择一个日志"}</h2>
|
||||
</div>
|
||||
<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="过滤日志行内容" />
|
||||
<label className="toggle-row">
|
||||
<input type="checkbox" checked={filterCurrentTask} onChange={(event) => onToggleFilterCurrentTask?.(event.target.checked)} />
|
||||
<span>按当前任务过滤{currentTaskTitle ? ` · ${currentTaskTitle}` : ""}</span>
|
||||
</label>
|
||||
<label className="toggle-row">
|
||||
<input type="checkbox" checked={autoRefresh} onChange={(event) => onToggleAutoRefresh?.(event.target.checked)} />
|
||||
<span>自动刷新</span>
|
||||
</label>
|
||||
</div>
|
||||
<pre className="log-pre">{filteredLines.join("\n") || (loading ? "loading..." : "暂无日志内容")}</pre>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
function buildFilteredLines(lines, query) {
|
||||
if (!query) return lines;
|
||||
const needle = query.toLowerCase();
|
||||
return lines.filter((line) => String(line).toLowerCase().includes(needle));
|
||||
}
|
||||
|
||||
export default function LogsPanel({
|
||||
logs,
|
||||
selectedLogName,
|
||||
onSelectLog,
|
||||
logContent,
|
||||
loading,
|
||||
onRefreshLog,
|
||||
currentTaskTitle,
|
||||
filterCurrentTask,
|
||||
onToggleFilterCurrentTask,
|
||||
autoRefresh,
|
||||
onToggleAutoRefresh,
|
||||
busy,
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [lineFilter, setLineFilter] = useState("");
|
||||
|
||||
const visibleLogs = useMemo(() => {
|
||||
if (!search) return logs;
|
||||
const needle = search.toLowerCase();
|
||||
return logs.filter((item) => `${item.name} ${item.path}`.toLowerCase().includes(needle));
|
||||
}, [logs, search]);
|
||||
|
||||
const filteredLines = useMemo(
|
||||
() => buildFilteredLines(logContent?.lines || [], lineFilter),
|
||||
[logContent?.lines, lineFilter],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="logs-layout-react">
|
||||
<article className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Logs Workspace</p>
|
||||
<h2>Log Index</h2>
|
||||
</div>
|
||||
<div className="panel-meta">{visibleLogs.length} logs</div>
|
||||
</div>
|
||||
<div className="toolbar-grid compact-grid">
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索日志文件" />
|
||||
</div>
|
||||
<div className="log-index-list">
|
||||
{visibleLogs.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
className={selectedLogName === item.name ? "log-index-item active" : "log-index-item"}
|
||||
onClick={() => onSelectLog(item.name)}
|
||||
>
|
||||
<strong>{item.name}</strong>
|
||||
<span>{item.path}</span>
|
||||
</button>
|
||||
))}
|
||||
{!visibleLogs.length ? <p className="muted">{loading ? "loading..." : "暂无日志文件"}</p> : null}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="panel detail-panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Log Detail</p>
|
||||
<h2>{selectedLogName || "选择一个日志"}</h2>
|
||||
</div>
|
||||
<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="过滤日志行内容" />
|
||||
<label className="toggle-row">
|
||||
<input type="checkbox" checked={filterCurrentTask} onChange={(event) => onToggleFilterCurrentTask?.(event.target.checked)} />
|
||||
<span>按当前任务过滤{currentTaskTitle ? ` · ${currentTaskTitle}` : ""}</span>
|
||||
</label>
|
||||
<label className="toggle-row">
|
||||
<input type="checkbox" checked={autoRefresh} onChange={(event) => onToggleAutoRefresh?.(event.target.checked)} />
|
||||
<span>自动刷新</span>
|
||||
</label>
|
||||
</div>
|
||||
<pre className="log-pre">{filteredLines.join("\n") || (loading ? "loading..." : "暂无日志内容")}</pre>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,175 +1,175 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import StatusBadge from "./StatusBadge.jsx";
|
||||
import { attentionLabel, summarizeAttention } from "../lib/format.js";
|
||||
|
||||
function SummaryCard({ label, value, tone = "" }) {
|
||||
return (
|
||||
<article className="summary-card">
|
||||
<span className="eyebrow">{label}</span>
|
||||
<strong>{value}</strong>
|
||||
{tone ? <StatusBadge tone={tone}>{tone}</StatusBadge> : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OverviewPanel({
|
||||
health,
|
||||
doctorOk,
|
||||
tasks,
|
||||
services,
|
||||
scheduler,
|
||||
history,
|
||||
loading,
|
||||
onRefreshScheduler,
|
||||
onRefreshHistory,
|
||||
onRunOnce,
|
||||
onServiceAction,
|
||||
onStageImport,
|
||||
onStageUpload,
|
||||
busy,
|
||||
}) {
|
||||
const [stageSourcePath, setStageSourcePath] = useState("");
|
||||
const [stageFile, setStageFile] = useState(null);
|
||||
const taskItems = tasks?.items || [];
|
||||
const serviceItems = services?.items || [];
|
||||
const actionItems = history?.items || [];
|
||||
const scheduled = scheduler?.scheduled || [];
|
||||
const deferred = scheduler?.deferred || [];
|
||||
const attentionCounts = taskItems.reduce(
|
||||
(acc, task) => {
|
||||
const key = summarizeAttention(task);
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="overview-stack-react">
|
||||
<div className="overview-grid">
|
||||
<SummaryCard label="Health" value={health ? "ok" : "down"} tone={health ? "good" : "hot"} />
|
||||
<SummaryCard label="Doctor" value={doctorOk ? "ready" : "warn"} tone={doctorOk ? "good" : "warn"} />
|
||||
<SummaryCard label="Tasks" value={String(taskItems.length)} />
|
||||
</div>
|
||||
|
||||
<div className="detail-grid">
|
||||
<article className="detail-card">
|
||||
<div className="card-head-inline">
|
||||
<h3>Import To Stage</h3>
|
||||
</div>
|
||||
<div className="stage-input-grid">
|
||||
<input
|
||||
value={stageSourcePath}
|
||||
onChange={(event) => setStageSourcePath(event.target.value)}
|
||||
placeholder="/absolute/path/to/video.mp4"
|
||||
/>
|
||||
<button
|
||||
className="nav-btn compact-btn"
|
||||
disabled={busy === "stage_import"}
|
||||
onClick={async () => {
|
||||
if (!stageSourcePath.trim()) return;
|
||||
await onStageImport?.(stageSourcePath.trim());
|
||||
setStageSourcePath("");
|
||||
}}
|
||||
>
|
||||
{busy === "stage_import" ? "导入中..." : "复制到隔离 Stage"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="stage-input-grid upload-grid-react">
|
||||
<input
|
||||
type="file"
|
||||
onChange={(event) => setStageFile(event.target.files?.[0] || null)}
|
||||
/>
|
||||
<button
|
||||
className="nav-btn compact-btn strong-btn"
|
||||
disabled={!stageFile || busy === "stage_upload"}
|
||||
onClick={async () => {
|
||||
if (!stageFile) return;
|
||||
await onStageUpload?.(stageFile);
|
||||
setStageFile(null);
|
||||
}}
|
||||
>
|
||||
{busy === "stage_upload" ? "上传中..." : "上传到隔离 Stage"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted">只会导入到 `biliup-next/data/workspace/stage/`,不会移动原文件。</p>
|
||||
</article>
|
||||
|
||||
<article className="detail-card">
|
||||
<div className="card-head-inline">
|
||||
<h3>Runtime Services</h3>
|
||||
<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) => (
|
||||
<div className="list-row" key={service.id}>
|
||||
<div>
|
||||
<strong>{service.id}</strong>
|
||||
<div className="muted">{service.description}</div>
|
||||
</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")} 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>
|
||||
))}
|
||||
{!serviceItems.length ? <p className="muted">{loading ? "loading..." : "暂无服务数据"}</p> : null}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="detail-card">
|
||||
<div className="card-head-inline">
|
||||
<h3>Scheduler Queue</h3>
|
||||
<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>
|
||||
<div className="list-row"><span>deferred</span><strong>{deferred.length}</strong></div>
|
||||
<div className="list-row"><span>scanned</span><strong>{scheduler?.summary?.scanned_count ?? 0}</strong></div>
|
||||
<div className="list-row"><span>truncated</span><strong>{scheduler?.summary?.truncated_count ?? 0}</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="detail-grid">
|
||||
<article className="detail-card">
|
||||
<h3>Attention Summary</h3>
|
||||
<div className="list-stack">
|
||||
{Object.entries(attentionCounts).map(([key, count]) => (
|
||||
<div className="list-row" key={key}>
|
||||
<span>{attentionLabel(key)}</span>
|
||||
<strong>{count}</strong>
|
||||
</div>
|
||||
))}
|
||||
{!Object.keys(attentionCounts).length ? <p className="muted">暂无任务摘要</p> : null}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="detail-card">
|
||||
<div className="card-head-inline">
|
||||
<h3>Recent Actions</h3>
|
||||
<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) => (
|
||||
<div className="list-row" key={`${item.created_at}:${item.action_name}`}>
|
||||
<span>{item.action_name}</span>
|
||||
<StatusBadge tone={item.status === "error" ? "hot" : item.status === "warn" ? "warn" : "good"}>{item.status}</StatusBadge>
|
||||
</div>
|
||||
))}
|
||||
{!actionItems.length ? <p className="muted">{loading ? "loading..." : "暂无动作记录"}</p> : null}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
|
||||
import StatusBadge from "./StatusBadge.jsx";
|
||||
import { attentionLabel, summarizeAttention } from "../lib/format.js";
|
||||
|
||||
function SummaryCard({ label, value, tone = "" }) {
|
||||
return (
|
||||
<article className="summary-card">
|
||||
<span className="eyebrow">{label}</span>
|
||||
<strong>{value}</strong>
|
||||
{tone ? <StatusBadge tone={tone}>{tone}</StatusBadge> : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OverviewPanel({
|
||||
health,
|
||||
doctorOk,
|
||||
tasks,
|
||||
services,
|
||||
scheduler,
|
||||
history,
|
||||
loading,
|
||||
onRefreshScheduler,
|
||||
onRefreshHistory,
|
||||
onRunOnce,
|
||||
onServiceAction,
|
||||
onStageImport,
|
||||
onStageUpload,
|
||||
busy,
|
||||
}) {
|
||||
const [stageSourcePath, setStageSourcePath] = useState("");
|
||||
const [stageFile, setStageFile] = useState(null);
|
||||
const taskItems = tasks?.items || [];
|
||||
const serviceItems = services?.items || [];
|
||||
const actionItems = history?.items || [];
|
||||
const scheduled = scheduler?.scheduled || [];
|
||||
const deferred = scheduler?.deferred || [];
|
||||
const attentionCounts = taskItems.reduce(
|
||||
(acc, task) => {
|
||||
const key = summarizeAttention(task);
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="overview-stack-react">
|
||||
<div className="overview-grid">
|
||||
<SummaryCard label="Health" value={health ? "ok" : "down"} tone={health ? "good" : "hot"} />
|
||||
<SummaryCard label="Doctor" value={doctorOk ? "ready" : "warn"} tone={doctorOk ? "good" : "warn"} />
|
||||
<SummaryCard label="Tasks" value={String(taskItems.length)} />
|
||||
</div>
|
||||
|
||||
<div className="detail-grid">
|
||||
<article className="detail-card">
|
||||
<div className="card-head-inline">
|
||||
<h3>Import To Stage</h3>
|
||||
</div>
|
||||
<div className="stage-input-grid">
|
||||
<input
|
||||
value={stageSourcePath}
|
||||
onChange={(event) => setStageSourcePath(event.target.value)}
|
||||
placeholder="/absolute/path/to/video.mp4"
|
||||
/>
|
||||
<button
|
||||
className="nav-btn compact-btn"
|
||||
disabled={busy === "stage_import"}
|
||||
onClick={async () => {
|
||||
if (!stageSourcePath.trim()) return;
|
||||
await onStageImport?.(stageSourcePath.trim());
|
||||
setStageSourcePath("");
|
||||
}}
|
||||
>
|
||||
{busy === "stage_import" ? "导入中..." : "复制到隔离 Stage"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="stage-input-grid upload-grid-react">
|
||||
<input
|
||||
type="file"
|
||||
onChange={(event) => setStageFile(event.target.files?.[0] || null)}
|
||||
/>
|
||||
<button
|
||||
className="nav-btn compact-btn strong-btn"
|
||||
disabled={!stageFile || busy === "stage_upload"}
|
||||
onClick={async () => {
|
||||
if (!stageFile) return;
|
||||
await onStageUpload?.(stageFile);
|
||||
setStageFile(null);
|
||||
}}
|
||||
>
|
||||
{busy === "stage_upload" ? "上传中..." : "上传到隔离 Stage"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted">只会导入到 `biliup-next/data/workspace/stage/`,不会移动原文件。</p>
|
||||
</article>
|
||||
|
||||
<article className="detail-card">
|
||||
<div className="card-head-inline">
|
||||
<h3>Runtime Services</h3>
|
||||
<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) => (
|
||||
<div className="list-row" key={service.id}>
|
||||
<div>
|
||||
<strong>{service.id}</strong>
|
||||
<div className="muted">{service.description}</div>
|
||||
</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")} 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>
|
||||
))}
|
||||
{!serviceItems.length ? <p className="muted">{loading ? "loading..." : "暂无服务数据"}</p> : null}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="detail-card">
|
||||
<div className="card-head-inline">
|
||||
<h3>Scheduler Queue</h3>
|
||||
<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>
|
||||
<div className="list-row"><span>deferred</span><strong>{deferred.length}</strong></div>
|
||||
<div className="list-row"><span>scanned</span><strong>{scheduler?.summary?.scanned_count ?? 0}</strong></div>
|
||||
<div className="list-row"><span>truncated</span><strong>{scheduler?.summary?.truncated_count ?? 0}</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="detail-grid">
|
||||
<article className="detail-card">
|
||||
<h3>Attention Summary</h3>
|
||||
<div className="list-stack">
|
||||
{Object.entries(attentionCounts).map(([key, count]) => (
|
||||
<div className="list-row" key={key}>
|
||||
<span>{attentionLabel(key)}</span>
|
||||
<strong>{count}</strong>
|
||||
</div>
|
||||
))}
|
||||
{!Object.keys(attentionCounts).length ? <p className="muted">暂无任务摘要</p> : null}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="detail-card">
|
||||
<div className="card-head-inline">
|
||||
<h3>Recent Actions</h3>
|
||||
<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) => (
|
||||
<div className="list-row" key={`${item.created_at}:${item.action_name}`}>
|
||||
<span>{item.action_name}</span>
|
||||
<StatusBadge tone={item.status === "error" ? "hot" : item.status === "warn" ? "warn" : "good"}>{item.status}</StatusBadge>
|
||||
</div>
|
||||
))}
|
||||
{!actionItems.length ? <p className="muted">{loading ? "loading..." : "暂无动作记录"}</p> : null}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,310 +1,310 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
const SECRET_PLACEHOLDER = "__BILIUP_NEXT_SECRET__";
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function fieldKey(groupName, fieldName) {
|
||||
return `${groupName}.${fieldName}`;
|
||||
}
|
||||
|
||||
function compareEntries(a, b) {
|
||||
const orderA = Number(a[1].ui_order || 9999);
|
||||
const orderB = Number(b[1].ui_order || 9999);
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return String(a[0]).localeCompare(String(b[0]), "zh-CN");
|
||||
}
|
||||
|
||||
function stableStringify(value) {
|
||||
return JSON.stringify(value ?? {}, null, 2);
|
||||
}
|
||||
|
||||
function FieldInput({ groupName, fieldName, schema, value, onChange }) {
|
||||
if (schema.type === "boolean") {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
onChange={(event) => onChange(groupName, fieldName, event.target.checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (Array.isArray(schema.enum)) {
|
||||
return (
|
||||
<select value={String(value ?? "")} onChange={(event) => onChange(groupName, fieldName, event.target.value)}>
|
||||
{schema.enum.map((optionValue) => (
|
||||
<option key={String(optionValue)} value={String(optionValue)}>
|
||||
{String(optionValue)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
if (schema.type === "array") {
|
||||
return (
|
||||
<textarea
|
||||
value={JSON.stringify(value ?? [], null, 2)}
|
||||
onChange={(event) => onChange(groupName, fieldName, event.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
type={schema.sensitive ? "password" : schema.type === "integer" ? "number" : "text"}
|
||||
value={value ?? ""}
|
||||
min={schema.type === "integer" && typeof schema.minimum === "number" ? schema.minimum : undefined}
|
||||
step={schema.type === "integer" ? 1 : undefined}
|
||||
placeholder={schema.ui_placeholder || ""}
|
||||
onChange={(event) => onChange(groupName, fieldName, event.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeFieldValue(schema, rawValue) {
|
||||
if (schema.type === "boolean") return Boolean(rawValue);
|
||||
if (schema.type === "integer") {
|
||||
if (rawValue === "" || Number.isNaN(Number(rawValue))) return { error: "必须填写整数" };
|
||||
const value = Number(rawValue);
|
||||
if (typeof schema.minimum === "number" && value < schema.minimum) {
|
||||
return { error: `最小值为 ${schema.minimum}` };
|
||||
}
|
||||
return { value };
|
||||
}
|
||||
if (schema.type === "array") {
|
||||
try {
|
||||
const value = typeof rawValue === "string" ? JSON.parse(rawValue || "[]") : rawValue;
|
||||
if (!Array.isArray(value)) return { error: "必须是 JSON 数组" };
|
||||
return { value };
|
||||
} catch {
|
||||
return { error: "必须是 JSON 数组" };
|
||||
}
|
||||
}
|
||||
return { value: rawValue };
|
||||
}
|
||||
|
||||
export default function SettingsPanel({ settings, schema, onSave, loading }) {
|
||||
const [draft, setDraft] = useState({});
|
||||
const [rawDraft, setRawDraft] = useState("{}");
|
||||
const [search, setSearch] = useState("");
|
||||
const [errors, setErrors] = useState({});
|
||||
const [dirty, setDirty] = useState({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState("");
|
||||
const [jsonError, setJsonError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const nextDraft = clone(settings || {});
|
||||
setDraft(nextDraft);
|
||||
setRawDraft(stableStringify(nextDraft));
|
||||
setErrors({});
|
||||
setDirty({});
|
||||
setJsonError("");
|
||||
setSaveMessage("");
|
||||
}, [settings]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const entries = Object.entries(schema?.groups || {}).sort((a, b) => {
|
||||
const orderA = Number(schema?.group_ui?.[a[0]]?.order || 9999);
|
||||
const orderB = Number(schema?.group_ui?.[b[0]]?.order || 9999);
|
||||
return orderA - orderB;
|
||||
});
|
||||
const needle = search.trim().toLowerCase();
|
||||
return entries
|
||||
.map(([groupName, fields]) => {
|
||||
const featured = [];
|
||||
const advanced = [];
|
||||
Object.entries(fields)
|
||||
.sort(compareEntries)
|
||||
.forEach(([fieldName, fieldSchema]) => {
|
||||
const haystack = `${groupName}.${fieldName} ${fieldSchema.title || ""} ${fieldSchema.description || ""}`.toLowerCase();
|
||||
if (needle && !haystack.includes(needle)) return;
|
||||
(fieldSchema.ui_featured ? featured : advanced).push([fieldName, fieldSchema]);
|
||||
});
|
||||
return [groupName, featured, advanced];
|
||||
})
|
||||
.filter(([, featured, advanced]) => featured.length || advanced.length);
|
||||
}, [schema, search]);
|
||||
|
||||
function handleFieldChange(groupName, fieldName, rawValue) {
|
||||
const fieldSchema = schema.groups[groupName][fieldName];
|
||||
const result = normalizeFieldValue(fieldSchema, rawValue);
|
||||
setDraft((current) => {
|
||||
const next = clone(current);
|
||||
next[groupName] ??= {};
|
||||
next[groupName][fieldName] = result.value ?? rawValue;
|
||||
setRawDraft(stableStringify(next));
|
||||
return next;
|
||||
});
|
||||
const key = fieldKey(groupName, fieldName);
|
||||
setDirty((current) => ({ ...current, [key]: true }));
|
||||
setSaveMessage("");
|
||||
setErrors((current) => {
|
||||
const next = { ...current };
|
||||
if (result.error) next[key] = result.error;
|
||||
else delete next[key];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleRevertField(groupName, fieldName) {
|
||||
const key = fieldKey(groupName, fieldName);
|
||||
const originalValue = settings?.[groupName]?.[fieldName];
|
||||
const fieldSchema = schema.groups[groupName][fieldName];
|
||||
const rawValue = fieldSchema.type === "array" ? clone(originalValue ?? []) : originalValue ?? "";
|
||||
setDraft((current) => {
|
||||
const next = clone(current);
|
||||
next[groupName] ??= {};
|
||||
next[groupName][fieldName] = rawValue;
|
||||
setRawDraft(stableStringify(next));
|
||||
return next;
|
||||
});
|
||||
setDirty((current) => {
|
||||
const next = { ...current };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
setErrors((current) => {
|
||||
const next = { ...current };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
setSaveMessage("");
|
||||
}
|
||||
|
||||
function syncJsonToForm() {
|
||||
try {
|
||||
const parsed = JSON.parse(rawDraft || "{}");
|
||||
setDraft(parsed);
|
||||
setJsonError("");
|
||||
setErrors({});
|
||||
setDirty({});
|
||||
setSaveMessage("JSON 已同步到表单");
|
||||
} catch {
|
||||
setJsonError("JSON 格式无效,无法同步到表单");
|
||||
}
|
||||
}
|
||||
|
||||
function syncFormToJson() {
|
||||
setRawDraft(stableStringify(draft));
|
||||
setJsonError("");
|
||||
setSaveMessage("表单已同步到 JSON");
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (Object.keys(errors).length || jsonError) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = await onSave(draft);
|
||||
const nextDraft = clone(saved || draft);
|
||||
setDraft(nextDraft);
|
||||
setRawDraft(stableStringify(nextDraft));
|
||||
setDirty({});
|
||||
setErrors({});
|
||||
setSaveMessage("Settings 已保存");
|
||||
setJsonError("");
|
||||
} catch (error) {
|
||||
setSaveMessage(`保存失败: ${error}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="settings-layout-react">
|
||||
<article className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Settings Workspace</p>
|
||||
<h2>Schema Form</h2>
|
||||
</div>
|
||||
<button className="nav-btn" onClick={handleSave} disabled={saving || Boolean(Object.keys(errors).length)}>
|
||||
{saving ? "保存中..." : "保存 Settings"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-grid compact-grid">
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索配置项" />
|
||||
</div>
|
||||
<div className="settings-note-stack">
|
||||
<p className="muted">
|
||||
敏感字段显示为 <code>{SECRET_PLACEHOLDER}</code>。保留占位符表示不改原值,改成空字符串表示清空。
|
||||
</p>
|
||||
{saveMessage ? <div className="status-inline-note">{saveMessage}</div> : null}
|
||||
{jsonError ? <div className="status-inline-note error">{jsonError}</div> : null}
|
||||
</div>
|
||||
<div className="settings-react-groups">
|
||||
{groups.map(([groupName, featured, advanced]) => (
|
||||
<section className="detail-card" key={groupName}>
|
||||
<div className="settings-group-head">
|
||||
<div>
|
||||
<p className="eyebrow">{groupName}</p>
|
||||
<h3>{schema.group_ui?.[groupName]?.title || groupName}</h3>
|
||||
</div>
|
||||
{schema.group_ui?.[groupName]?.description ? (
|
||||
<p className="muted">{schema.group_ui[groupName].description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="settings-field-grid">
|
||||
{[...featured, ...advanced].map(([fieldName, fieldSchema]) => {
|
||||
const key = fieldKey(groupName, fieldName);
|
||||
return (
|
||||
<label className={`settings-field-card ${dirty[key] ? "dirty" : ""} ${errors[key] ? "error" : ""}`} key={key}>
|
||||
<div className="settings-field-head">
|
||||
<strong>{fieldSchema.title || key}</strong>
|
||||
<div className="status-row">
|
||||
{fieldSchema.ui_widget ? <span className="status-badge">{fieldSchema.ui_widget}</span> : null}
|
||||
{fieldSchema.ui_featured ? <span className="status-badge warn">featured</span> : null}
|
||||
{dirty[key] ? (
|
||||
<button
|
||||
type="button"
|
||||
className="nav-btn compact-btn"
|
||||
onClick={() => handleRevertField(groupName, fieldName)}
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{fieldSchema.description ? <span className="muted">{fieldSchema.description}</span> : null}
|
||||
<FieldInput
|
||||
groupName={groupName}
|
||||
fieldName={fieldName}
|
||||
schema={fieldSchema}
|
||||
value={draft[groupName]?.[fieldName]}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
{errors[key] ? <span className="field-error-react">{errors[key]}</span> : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{!groups.length ? <article className="detail-card"><p className="muted">{loading ? "loading..." : "没有匹配配置项"}</p></article> : null}
|
||||
</div>
|
||||
<section className="detail-card">
|
||||
<div className="card-head-inline">
|
||||
<div>
|
||||
<p className="eyebrow">Advanced</p>
|
||||
<h3>Raw JSON</h3>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button type="button" className="nav-btn compact-btn" onClick={syncFormToJson}>表单同步到 JSON</button>
|
||||
<button type="button" className="nav-btn compact-btn" onClick={syncJsonToForm}>JSON 重绘表单</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
className="settings-json-editor"
|
||||
value={rawDraft}
|
||||
onChange={(event) => {
|
||||
setRawDraft(event.target.value);
|
||||
setJsonError("");
|
||||
setSaveMessage("");
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
const SECRET_PLACEHOLDER = "__BILIUP_NEXT_SECRET__";
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function fieldKey(groupName, fieldName) {
|
||||
return `${groupName}.${fieldName}`;
|
||||
}
|
||||
|
||||
function compareEntries(a, b) {
|
||||
const orderA = Number(a[1].ui_order || 9999);
|
||||
const orderB = Number(b[1].ui_order || 9999);
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return String(a[0]).localeCompare(String(b[0]), "zh-CN");
|
||||
}
|
||||
|
||||
function stableStringify(value) {
|
||||
return JSON.stringify(value ?? {}, null, 2);
|
||||
}
|
||||
|
||||
function FieldInput({ groupName, fieldName, schema, value, onChange }) {
|
||||
if (schema.type === "boolean") {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
onChange={(event) => onChange(groupName, fieldName, event.target.checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (Array.isArray(schema.enum)) {
|
||||
return (
|
||||
<select value={String(value ?? "")} onChange={(event) => onChange(groupName, fieldName, event.target.value)}>
|
||||
{schema.enum.map((optionValue) => (
|
||||
<option key={String(optionValue)} value={String(optionValue)}>
|
||||
{String(optionValue)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
if (schema.type === "array") {
|
||||
return (
|
||||
<textarea
|
||||
value={JSON.stringify(value ?? [], null, 2)}
|
||||
onChange={(event) => onChange(groupName, fieldName, event.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
type={schema.sensitive ? "password" : schema.type === "integer" ? "number" : "text"}
|
||||
value={value ?? ""}
|
||||
min={schema.type === "integer" && typeof schema.minimum === "number" ? schema.minimum : undefined}
|
||||
step={schema.type === "integer" ? 1 : undefined}
|
||||
placeholder={schema.ui_placeholder || ""}
|
||||
onChange={(event) => onChange(groupName, fieldName, event.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeFieldValue(schema, rawValue) {
|
||||
if (schema.type === "boolean") return Boolean(rawValue);
|
||||
if (schema.type === "integer") {
|
||||
if (rawValue === "" || Number.isNaN(Number(rawValue))) return { error: "必须填写整数" };
|
||||
const value = Number(rawValue);
|
||||
if (typeof schema.minimum === "number" && value < schema.minimum) {
|
||||
return { error: `最小值为 ${schema.minimum}` };
|
||||
}
|
||||
return { value };
|
||||
}
|
||||
if (schema.type === "array") {
|
||||
try {
|
||||
const value = typeof rawValue === "string" ? JSON.parse(rawValue || "[]") : rawValue;
|
||||
if (!Array.isArray(value)) return { error: "必须是 JSON 数组" };
|
||||
return { value };
|
||||
} catch {
|
||||
return { error: "必须是 JSON 数组" };
|
||||
}
|
||||
}
|
||||
return { value: rawValue };
|
||||
}
|
||||
|
||||
export default function SettingsPanel({ settings, schema, onSave, loading }) {
|
||||
const [draft, setDraft] = useState({});
|
||||
const [rawDraft, setRawDraft] = useState("{}");
|
||||
const [search, setSearch] = useState("");
|
||||
const [errors, setErrors] = useState({});
|
||||
const [dirty, setDirty] = useState({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState("");
|
||||
const [jsonError, setJsonError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const nextDraft = clone(settings || {});
|
||||
setDraft(nextDraft);
|
||||
setRawDraft(stableStringify(nextDraft));
|
||||
setErrors({});
|
||||
setDirty({});
|
||||
setJsonError("");
|
||||
setSaveMessage("");
|
||||
}, [settings]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const entries = Object.entries(schema?.groups || {}).sort((a, b) => {
|
||||
const orderA = Number(schema?.group_ui?.[a[0]]?.order || 9999);
|
||||
const orderB = Number(schema?.group_ui?.[b[0]]?.order || 9999);
|
||||
return orderA - orderB;
|
||||
});
|
||||
const needle = search.trim().toLowerCase();
|
||||
return entries
|
||||
.map(([groupName, fields]) => {
|
||||
const featured = [];
|
||||
const advanced = [];
|
||||
Object.entries(fields)
|
||||
.sort(compareEntries)
|
||||
.forEach(([fieldName, fieldSchema]) => {
|
||||
const haystack = `${groupName}.${fieldName} ${fieldSchema.title || ""} ${fieldSchema.description || ""}`.toLowerCase();
|
||||
if (needle && !haystack.includes(needle)) return;
|
||||
(fieldSchema.ui_featured ? featured : advanced).push([fieldName, fieldSchema]);
|
||||
});
|
||||
return [groupName, featured, advanced];
|
||||
})
|
||||
.filter(([, featured, advanced]) => featured.length || advanced.length);
|
||||
}, [schema, search]);
|
||||
|
||||
function handleFieldChange(groupName, fieldName, rawValue) {
|
||||
const fieldSchema = schema.groups[groupName][fieldName];
|
||||
const result = normalizeFieldValue(fieldSchema, rawValue);
|
||||
setDraft((current) => {
|
||||
const next = clone(current);
|
||||
next[groupName] ??= {};
|
||||
next[groupName][fieldName] = result.value ?? rawValue;
|
||||
setRawDraft(stableStringify(next));
|
||||
return next;
|
||||
});
|
||||
const key = fieldKey(groupName, fieldName);
|
||||
setDirty((current) => ({ ...current, [key]: true }));
|
||||
setSaveMessage("");
|
||||
setErrors((current) => {
|
||||
const next = { ...current };
|
||||
if (result.error) next[key] = result.error;
|
||||
else delete next[key];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleRevertField(groupName, fieldName) {
|
||||
const key = fieldKey(groupName, fieldName);
|
||||
const originalValue = settings?.[groupName]?.[fieldName];
|
||||
const fieldSchema = schema.groups[groupName][fieldName];
|
||||
const rawValue = fieldSchema.type === "array" ? clone(originalValue ?? []) : originalValue ?? "";
|
||||
setDraft((current) => {
|
||||
const next = clone(current);
|
||||
next[groupName] ??= {};
|
||||
next[groupName][fieldName] = rawValue;
|
||||
setRawDraft(stableStringify(next));
|
||||
return next;
|
||||
});
|
||||
setDirty((current) => {
|
||||
const next = { ...current };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
setErrors((current) => {
|
||||
const next = { ...current };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
setSaveMessage("");
|
||||
}
|
||||
|
||||
function syncJsonToForm() {
|
||||
try {
|
||||
const parsed = JSON.parse(rawDraft || "{}");
|
||||
setDraft(parsed);
|
||||
setJsonError("");
|
||||
setErrors({});
|
||||
setDirty({});
|
||||
setSaveMessage("JSON 已同步到表单");
|
||||
} catch {
|
||||
setJsonError("JSON 格式无效,无法同步到表单");
|
||||
}
|
||||
}
|
||||
|
||||
function syncFormToJson() {
|
||||
setRawDraft(stableStringify(draft));
|
||||
setJsonError("");
|
||||
setSaveMessage("表单已同步到 JSON");
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (Object.keys(errors).length || jsonError) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = await onSave(draft);
|
||||
const nextDraft = clone(saved || draft);
|
||||
setDraft(nextDraft);
|
||||
setRawDraft(stableStringify(nextDraft));
|
||||
setDirty({});
|
||||
setErrors({});
|
||||
setSaveMessage("Settings 已保存");
|
||||
setJsonError("");
|
||||
} catch (error) {
|
||||
setSaveMessage(`保存失败: ${error}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="settings-layout-react">
|
||||
<article className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Settings Workspace</p>
|
||||
<h2>Schema Form</h2>
|
||||
</div>
|
||||
<button className="nav-btn" onClick={handleSave} disabled={saving || Boolean(Object.keys(errors).length)}>
|
||||
{saving ? "保存中..." : "保存 Settings"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-grid compact-grid">
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索配置项" />
|
||||
</div>
|
||||
<div className="settings-note-stack">
|
||||
<p className="muted">
|
||||
敏感字段显示为 <code>{SECRET_PLACEHOLDER}</code>。保留占位符表示不改原值,改成空字符串表示清空。
|
||||
</p>
|
||||
{saveMessage ? <div className="status-inline-note">{saveMessage}</div> : null}
|
||||
{jsonError ? <div className="status-inline-note error">{jsonError}</div> : null}
|
||||
</div>
|
||||
<div className="settings-react-groups">
|
||||
{groups.map(([groupName, featured, advanced]) => (
|
||||
<section className="detail-card" key={groupName}>
|
||||
<div className="settings-group-head">
|
||||
<div>
|
||||
<p className="eyebrow">{groupName}</p>
|
||||
<h3>{schema.group_ui?.[groupName]?.title || groupName}</h3>
|
||||
</div>
|
||||
{schema.group_ui?.[groupName]?.description ? (
|
||||
<p className="muted">{schema.group_ui[groupName].description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="settings-field-grid">
|
||||
{[...featured, ...advanced].map(([fieldName, fieldSchema]) => {
|
||||
const key = fieldKey(groupName, fieldName);
|
||||
return (
|
||||
<label className={`settings-field-card ${dirty[key] ? "dirty" : ""} ${errors[key] ? "error" : ""}`} key={key}>
|
||||
<div className="settings-field-head">
|
||||
<strong>{fieldSchema.title || key}</strong>
|
||||
<div className="status-row">
|
||||
{fieldSchema.ui_widget ? <span className="status-badge">{fieldSchema.ui_widget}</span> : null}
|
||||
{fieldSchema.ui_featured ? <span className="status-badge warn">featured</span> : null}
|
||||
{dirty[key] ? (
|
||||
<button
|
||||
type="button"
|
||||
className="nav-btn compact-btn"
|
||||
onClick={() => handleRevertField(groupName, fieldName)}
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{fieldSchema.description ? <span className="muted">{fieldSchema.description}</span> : null}
|
||||
<FieldInput
|
||||
groupName={groupName}
|
||||
fieldName={fieldName}
|
||||
schema={fieldSchema}
|
||||
value={draft[groupName]?.[fieldName]}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
{errors[key] ? <span className="field-error-react">{errors[key]}</span> : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{!groups.length ? <article className="detail-card"><p className="muted">{loading ? "loading..." : "没有匹配配置项"}</p></article> : null}
|
||||
</div>
|
||||
<section className="detail-card">
|
||||
<div className="card-head-inline">
|
||||
<div>
|
||||
<p className="eyebrow">Advanced</p>
|
||||
<h3>Raw JSON</h3>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button type="button" className="nav-btn compact-btn" onClick={syncFormToJson}>表单同步到 JSON</button>
|
||||
<button type="button" className="nav-btn compact-btn" onClick={syncJsonToForm}>JSON 重绘表单</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
className="settings-json-editor"
|
||||
value={rawDraft}
|
||||
onChange={(event) => {
|
||||
setRawDraft(event.target.value);
|
||||
setJsonError("");
|
||||
setSaveMessage("");
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { statusClass } from "../lib/format.js";
|
||||
|
||||
export default function StatusBadge({ children, tone }) {
|
||||
const klass = tone || statusClass(String(children));
|
||||
return <span className={`status-badge ${klass}`.trim()}>{children}</span>;
|
||||
}
|
||||
import { statusClass } from "../lib/format.js";
|
||||
|
||||
export default function StatusBadge({ children, tone }) {
|
||||
const klass = tone || statusClass(String(children));
|
||||
return <span className={`status-badge ${klass}`.trim()}>{children}</span>;
|
||||
}
|
||||
|
||||
@ -1,270 +1,270 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import StatusBadge from "./StatusBadge.jsx";
|
||||
import {
|
||||
actionAdvice,
|
||||
attentionLabel,
|
||||
currentStepLabel,
|
||||
deliveryLabel,
|
||||
formatDate,
|
||||
summarizeAttention,
|
||||
summarizeDelivery,
|
||||
recommendedAction,
|
||||
taskDisplayStatus,
|
||||
} from "../lib/format.js";
|
||||
|
||||
function SummaryRow({ label, value }) {
|
||||
return (
|
||||
<div className="detail-row">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function suggestedStepName(steps) {
|
||||
const items = steps?.items || [];
|
||||
const retryable = items.find((step) => ["failed_retryable", "failed_manual", "running"].includes(step.status));
|
||||
return retryable?.step_name || items.find((step) => step.status !== "succeeded")?.step_name || "";
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Task Detail</p>
|
||||
<h2>Loading...</h2>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
if (!payload?.task) {
|
||||
return (
|
||||
<article className="panel detail-panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Task Detail</p>
|
||||
<h2>选择一个任务</h2>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
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>{taskDisplayStatus(task)}</StatusBadge>
|
||||
<StatusBadge>{attentionLabel(summarizeAttention(task))}</StatusBadge>
|
||||
<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 || 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")} />
|
||||
<SummaryRow label="Full Timeline" value={deliveryLabel(delivery.full_video_timeline_comment || "pending")} />
|
||||
<SummaryRow label="Cleanup" value={deliveryLabel(summarizeDelivery(delivery) === "cleanup_removed" ? "cleanup_removed" : "present")} />
|
||||
</section>
|
||||
<section className="detail-card">
|
||||
<h3>Latest Action</h3>
|
||||
{latestAction ? (
|
||||
<>
|
||||
<SummaryRow label="Action" value={latestAction.action_name} />
|
||||
<SummaryRow label="Status" value={latestAction.status} />
|
||||
<SummaryRow label="Summary" value={latestAction.summary || "-"} />
|
||||
</>
|
||||
) : (
|
||||
<p className="muted">暂无动作记录</p>
|
||||
)}
|
||||
</section>
|
||||
</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">
|
||||
{steps?.items?.map((step) => (
|
||||
<button
|
||||
type="button"
|
||||
key={step.step_name}
|
||||
className={activeStepName === step.step_name ? "list-row selectable active" : "list-row selectable"}
|
||||
onClick={() => onSelectStep?.(step.step_name)}
|
||||
>
|
||||
<span>{step.step_name}</span>
|
||||
<StatusBadge>{step.status}</StatusBadge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{activeStepName ? (
|
||||
<div className="selected-step-note">
|
||||
当前选中 step: <strong>{activeStepName}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
<section className="detail-card">
|
||||
<h3>Artifacts</h3>
|
||||
<div className="list-stack">
|
||||
{artifacts?.items?.slice(0, 8).map((artifact) => (
|
||||
<div key={`${artifact.artifact_type}:${artifact.path}`} className="list-row">
|
||||
<span>{artifact.artifact_type}</span>
|
||||
<span className="muted">{artifact.path}</span>
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import StatusBadge from "./StatusBadge.jsx";
|
||||
import {
|
||||
actionAdvice,
|
||||
attentionLabel,
|
||||
currentStepLabel,
|
||||
deliveryLabel,
|
||||
formatDate,
|
||||
summarizeAttention,
|
||||
summarizeDelivery,
|
||||
recommendedAction,
|
||||
taskDisplayStatus,
|
||||
} from "../lib/format.js";
|
||||
|
||||
function SummaryRow({ label, value }) {
|
||||
return (
|
||||
<div className="detail-row">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function suggestedStepName(steps) {
|
||||
const items = steps?.items || [];
|
||||
const retryable = items.find((step) => ["failed_retryable", "failed_manual", "running"].includes(step.status));
|
||||
return retryable?.step_name || items.find((step) => step.status !== "succeeded")?.step_name || "";
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Task Detail</p>
|
||||
<h2>Loading...</h2>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
if (!payload?.task) {
|
||||
return (
|
||||
<article className="panel detail-panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Task Detail</p>
|
||||
<h2>选择一个任务</h2>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
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>{taskDisplayStatus(task)}</StatusBadge>
|
||||
<StatusBadge>{attentionLabel(summarizeAttention(task))}</StatusBadge>
|
||||
<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 || 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")} />
|
||||
<SummaryRow label="Full Timeline" value={deliveryLabel(delivery.full_video_timeline_comment || "pending")} />
|
||||
<SummaryRow label="Cleanup" value={deliveryLabel(summarizeDelivery(delivery) === "cleanup_removed" ? "cleanup_removed" : "present")} />
|
||||
</section>
|
||||
<section className="detail-card">
|
||||
<h3>Latest Action</h3>
|
||||
{latestAction ? (
|
||||
<>
|
||||
<SummaryRow label="Action" value={latestAction.action_name} />
|
||||
<SummaryRow label="Status" value={latestAction.status} />
|
||||
<SummaryRow label="Summary" value={latestAction.summary || "-"} />
|
||||
</>
|
||||
) : (
|
||||
<p className="muted">暂无动作记录</p>
|
||||
)}
|
||||
</section>
|
||||
</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">
|
||||
{steps?.items?.map((step) => (
|
||||
<button
|
||||
type="button"
|
||||
key={step.step_name}
|
||||
className={activeStepName === step.step_name ? "list-row selectable active" : "list-row selectable"}
|
||||
onClick={() => onSelectStep?.(step.step_name)}
|
||||
>
|
||||
<span>{step.step_name}</span>
|
||||
<StatusBadge>{step.status}</StatusBadge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{activeStepName ? (
|
||||
<div className="selected-step-note">
|
||||
当前选中 step: <strong>{activeStepName}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
<section className="detail-card">
|
||||
<h3>Artifacts</h3>
|
||||
<div className="list-stack">
|
||||
{artifacts?.items?.slice(0, 8).map((artifact) => (
|
||||
<div key={`${artifact.artifact_type}:${artifact.path}`} className="list-row">
|
||||
<span>{artifact.artifact_type}</span>
|
||||
<span className="muted">{artifact.path}</span>
|
||||
</div>
|
||||
))}
|
||||
</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,89 +1,89 @@
|
||||
import StatusBadge from "./StatusBadge.jsx";
|
||||
import {
|
||||
attentionLabel,
|
||||
currentStepLabel,
|
||||
deliveryLabel,
|
||||
formatDate,
|
||||
summarizeAttention,
|
||||
summarizeDelivery,
|
||||
taskDisplayStatus,
|
||||
taskPrimaryActionLabel,
|
||||
} from "../lib/format.js";
|
||||
|
||||
function deliveryStateLabel(task) {
|
||||
const delivery = task.delivery_state || {};
|
||||
return {
|
||||
splitComment: deliveryLabel(delivery.split_comment || "pending"),
|
||||
fullComment: deliveryLabel(delivery.full_video_timeline_comment || "pending"),
|
||||
cleanup: deliveryLabel(summarizeDelivery(delivery) === "cleanup_removed" ? "cleanup_removed" : "present"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function TaskTable({ tasks, selectedTaskId, onSelectTask, onRunTask }) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
import StatusBadge from "./StatusBadge.jsx";
|
||||
import {
|
||||
attentionLabel,
|
||||
currentStepLabel,
|
||||
deliveryLabel,
|
||||
formatDate,
|
||||
summarizeAttention,
|
||||
summarizeDelivery,
|
||||
taskDisplayStatus,
|
||||
taskPrimaryActionLabel,
|
||||
} from "../lib/format.js";
|
||||
|
||||
function deliveryStateLabel(task) {
|
||||
const delivery = task.delivery_state || {};
|
||||
return {
|
||||
splitComment: deliveryLabel(delivery.split_comment || "pending"),
|
||||
fullComment: deliveryLabel(delivery.full_video_timeline_comment || "pending"),
|
||||
cleanup: deliveryLabel(summarizeDelivery(delivery) === "cleanup_removed" ? "cleanup_removed" : "present"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function TaskTable({ tasks, selectedTaskId, onSelectTask, onRunTask }) {
|
||||
return (
|
||||
<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,142 +1,142 @@
|
||||
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", "running", "retry_now", "waiting_retry", "manual_now"].includes(status)) return "warn";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function formatDate(value) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
export function summarizeAttention(task) {
|
||||
if (task.status === "failed_manual") return "manual_now";
|
||||
if (task.retry_state?.retry_due) return "retry_now";
|
||||
if (task.status === "failed_retryable" && task.retry_state?.next_retry_at) return "waiting_retry";
|
||||
if (task.status === "running") return "running";
|
||||
return "stable";
|
||||
}
|
||||
|
||||
export function attentionLabel(value) {
|
||||
return {
|
||||
manual_now: "需人工",
|
||||
retry_now: "立即重试",
|
||||
waiting_retry: "等待重试",
|
||||
running: "处理中",
|
||||
stable: "正常",
|
||||
}[value] || value;
|
||||
}
|
||||
|
||||
export function summarizeDelivery(delivery = {}) {
|
||||
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";
|
||||
}
|
||||
|
||||
export function deliveryLabel(value) {
|
||||
return {
|
||||
done: "已发送",
|
||||
pending: "待处理",
|
||||
present: "保留",
|
||||
removed: "已清理",
|
||||
cleanup_removed: "已清理视频",
|
||||
pending_comment: "评论待完成",
|
||||
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" };
|
||||
}
|
||||
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", "running", "retry_now", "waiting_retry", "manual_now"].includes(status)) return "warn";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function formatDate(value) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
export function summarizeAttention(task) {
|
||||
if (task.status === "failed_manual") return "manual_now";
|
||||
if (task.retry_state?.retry_due) return "retry_now";
|
||||
if (task.status === "failed_retryable" && task.retry_state?.next_retry_at) return "waiting_retry";
|
||||
if (task.status === "running") return "running";
|
||||
return "stable";
|
||||
}
|
||||
|
||||
export function attentionLabel(value) {
|
||||
return {
|
||||
manual_now: "需人工",
|
||||
retry_now: "立即重试",
|
||||
waiting_retry: "等待重试",
|
||||
running: "处理中",
|
||||
stable: "正常",
|
||||
}[value] || value;
|
||||
}
|
||||
|
||||
export function summarizeDelivery(delivery = {}) {
|
||||
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";
|
||||
}
|
||||
|
||||
export function deliveryLabel(value) {
|
||||
return {
|
||||
done: "已发送",
|
||||
pending: "待处理",
|
||||
present: "保留",
|
||||
removed: "已清理",
|
||||
cleanup_removed: "已清理视频",
|
||||
pending_comment: "评论待完成",
|
||||
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" };
|
||||
}
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import App from "./App.jsx";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import App from "./App.jsx";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user