// 2 · 拆解爆款 — 任务列表 + 实时进度 + 拆解结果 // 选片与发起拆解已下沉到 /collect;本页只承担「执行 + 查看」。 const fmtElapsed = (startISO, endISO) => { if (!startISO || !endISO) return null; const ms = new Date(endISO) - new Date(startISO); if (!isFinite(ms) || ms < 0) return null; const sec = Math.round(ms / 1000); if (sec < 60) return `${sec}s`; const m = Math.floor(sec / 60); const s = sec % 60; if (m < 60) return s ? `${m}m${s}s` : `${m}m`; const h = Math.floor(m / 60); const mm = m % 60; return mm ? `${h}h${mm}m` : `${h}h`; }; const fmtFinished = (iso) => { if (!iso) return null; const d = new Date(iso); if (isNaN(d.getTime())) return null; const M = String(d.getMonth() + 1).padStart(2, "0"); const D = String(d.getDate()).padStart(2, "0"); const h = String(d.getHours()).padStart(2, "0"); const m = String(d.getMinutes()).padStart(2, "0"); return `${M}-${D} ${h}:${m}`; }; const TeardownDetail = () => { // ─── 文案二创独立链路(URL 带 ?text_rewrite_job=xxx 时显示进度卡)─── const trJobId = useTextRewriteJobIdFromQuery(); // ─── 任务面板 ─── const [jobs, setJobs] = React.useState([]); const [jobsLoading, setJobsLoading] = React.useState(false); const [activeJobId, setActiveJobId] = React.useState(null); const [jobProgress, setJobProgress] = React.useState(null); // { step, pct, message } const [jobStatus, setJobStatus] = React.useState(""); // pending|running|completed|failed const disposeRef = React.useRef(null); // ─── 结果内嵌折叠面板 ─── const [resultExpanded, setResultExpanded] = React.useState(false); // ─── 重试 failed 任务 ─── const [retryBusy, setRetryBusy] = React.useState(false); const [retryHint, setRetryHint] = React.useState(null); // {kind, msg} const retryActive = async () => { if (!activeJobId || retryBusy) return; setRetryBusy(true); setRetryHint(null); try { await API.jobs.retry(activeJobId); setRetryHint({ kind: "ok", msg: "已重新调度,几秒后会回到 running" }); // 自动刷一下任务列表 + 进度状态 await loadJobs(); } catch (e) { setRetryHint({ kind: "err", msg: String(e.message || e).slice(0, 80) }); } finally { setRetryBusy(false); // 6 秒后清掉提示 setTimeout(() => setRetryHint(null), 6000); } }; // ─── 初始化加载 ─── React.useEffect(() => { loadJobs(); const t = setInterval(loadJobs, 5000); return () => clearInterval(t); }, []); async function loadJobs() { setJobsLoading(true); try { const d = await API.jobs.list(); setJobs(d.items || []); } catch (_) {} finally { setJobsLoading(false); } } // 当前任务失败后不要继续霸占中栏:只要队列里还有 running / pending, // 页面就自动跟过去。失败任务本身已经是终态,不会阻塞后端并发。 React.useEffect(() => { if (!jobs.length) return; const live = jobs.find(j => j.status === "running" || j.status === "pending"); const active = activeJobId ? jobs.find(j => j.job_id === activeJobId) : null; if (!activeJobId) { if (live) setActiveJobId(live.job_id); return; } if (!active) { setActiveJobId(live ? live.job_id : null); return; } if (active.status === "failed" && live && live.job_id !== activeJobId) { setActiveJobId(live.job_id); setResultExpanded(false); } }, [jobs, activeJobId]); // 当前 activeJobId 是否对应一个已完成的任务 —— 用于决定是否在中栏渲染内联结果折叠卡。 // 优先看本地 jobStatus(SSE 实时同步),其次回退到 jobs 列表里的快照。 const resultJobId = React.useMemo(() => { if (!activeJobId) return null; if (jobStatus === "completed") return activeJobId; const j = jobs.find(x => x.job_id === activeJobId); return j && j.status === "completed" ? activeJobId : null; }, [activeJobId, jobStatus, jobs]); // ─── SSE 订阅当前任务 ─── React.useEffect(() => { if (!activeJobId) { if (disposeRef.current) { disposeRef.current(); disposeRef.current = null; } setJobProgress(null); setJobStatus(""); return; } if (disposeRef.current) { disposeRef.current(); disposeRef.current = null; } // 用 jobs 列表里的快照 init,避免每次切换都闪一下"任务已排队"; // SSE 第一帧 status / progress 会很快覆盖。 const known = jobs.find(j => j.job_id === activeJobId); setJobProgress(known?.progress || { step: "queued", pct: 0, message: "任务已排队" }); setJobStatus(known?.status || "pending"); disposeRef.current = subscribeJobProgress(activeJobId, (kind, payload) => { if (kind === "progress") { setJobProgress(payload); setJobStatus(prev => (prev === "completed" || prev === "failed") ? prev : "running"); } if (kind === "status") { setJobStatus(payload.status || "running"); } if (kind === "done") { setJobStatus(payload.status || "completed"); setJobProgress(prev => ({ ...prev, step: payload.status, pct: 100, message: payload.error || "已完成" })); loadJobs(); if (disposeRef.current) { disposeRef.current(); disposeRef.current = null; } } }); return () => { if (disposeRef.current) { disposeRef.current(); disposeRef.current = null; } }; // jobs 故意不进依赖:5s 轮询会频繁变更,加进来会让 SSE 反复重连。 // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeJobId]); const cancelActive = async () => { if (!activeJobId) return; try { await API.jobs.cancel(activeJobId); setJobStatus("failed"); setJobProgress(prev => ({ ...prev, step: "failed", message: "用户取消" })); loadJobs(); } catch (_) {} }; const openResult = (jobId) => { if (jobId && jobId !== activeJobId) setActiveJobId(jobId); setResultExpanded(true); }; const activeJob = jobs.find(j => j.job_id === activeJobId); return ( <> {jobs.length} 个任务 } />
{/* 中:进度 + 结果 */}
{/* 文案二创独立链路进度(URL 带 ?text_rewrite_job=xxx 时显示)*/} {trJobId && } {!activeJobId && (
请在右侧任务列表选择一个任务
运行中显示进度,已完成显示拆解报告
新发起的拆解请前往 收集爆款 页面
)} {/* 拆解进度(仅在有 active job 时显示)*/} {activeJobId && jobProgress && (
拆解进度 实时同步 · 任务 {activeJobId.slice(0, 8)}… {activeJob?.title && ` · ${activeJob.title.slice(0, 40)}`}
{jobProgress.message || "—"} {jobStatus==="completed"?"已完成":jobStatus==="failed"?"已终止":jobStatus==="running"?"进行中":"排队中"}
{(jobStatus === "running" || jobStatus === "pending" || jobStatus === "completed") && (
{(jobStatus === "running" || jobStatus === "pending") && ( )} {jobStatus === "completed" && ( )}
)} {jobStatus === "failed" && (
{retryHint && ( {retryHint.msg} )}
已自动终止;点"重试"会保留已下载的视频和已抽出的音频,从失败 stage 续跑。
)}
)} {/* 拆解结果(内联折叠面板)*/} {resultJobId && (
setResultExpanded(e => !e)} style={{ cursor: "pointer", userSelect: "none" }} title={resultExpanded ? "点击折叠" : "点击展开查看完整拆解报告"} > 拆解结果 任务 {resultJobId.slice(0, 8)}… · 点击{resultExpanded ? "折叠" : "展开"}
{resultExpanded && (
)}
)}
{/* 右:任务列表 */}
任务列表 {jobs.length} 条
{jobs.slice(0, 50).map(j => (
setActiveJobId(j.job_id)} title={j.status === "failed" ? "失败任务已终止,不会阻塞其他任务;点击查看错误" : "点击查看实时进度 / 拆解结果"} className="col gap-4" style={{ padding: 8, borderRadius: 5, cursor: "pointer", background: j.job_id === activeJobId ? "color-mix(in oklch, var(--cyan) 6%, var(--bg-1))" : "var(--bg-1)", border: `1px solid ${j.job_id === activeJobId ? "var(--cyan)" : "var(--line-1)"}`, }}>
{j.job_id.slice(0, 10)}… {j.status==="completed"?"完成":j.status==="failed"?"终止":j.status==="running"?"进行中":"排队"}
{j.title || j.video_filename || "未命名"}
{j.source} {j.industry || "general"}
{(() => { const isTerminal = j.status === "completed" || j.status === "failed"; const elapsed = isTerminal ? fmtElapsed(j.created_at, j.updated_at) : null; const finished = fmtFinished(j.updated_at); if (!elapsed && !finished) return null; return (
{elapsed && 用时 {elapsed}} {elapsed && finished && ·} {finished && {finished}}
); })()} {j.status === "completed" && j.has_result && ( )} {j.error && (
{j.error.slice(0, 80)}
)}
))} {!jobs.length && !jobsLoading && (
暂无拆解任务
请前往 收集爆款 发起
)}
); }; Object.assign(window, { TeardownDetail });