diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index a075454..c1ac3e4 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -921,7 +921,8 @@ export function App() { onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(pipelineProject.id, { video_segment_id: segmentId, prompt }), "视频片段已提交,生成中…")} onSubmitAllVideos={(prompt) => action(async () => { - const targets = pipelineProject.video_segments.filter((segment) => !["running", "succeeded"].includes(segment.status)); + // 「全部重跑」要把已完成的也重跑(否则全成功的项目点了等于没点);只跳过在途(running/queued)避免重复提交 + const targets = pipelineProject.video_segments.filter((segment) => !["running", "queued"].includes(segment.status)); for (const segment of targets) { await api.submitVideo(pipelineProject.id, { video_segment_id: segment.id, diff --git a/core/frontend/src/pipeline-page.css b/core/frontend/src/pipeline-page.css index 89c63cc..c197086 100644 --- a/core/frontend/src/pipeline-page.css +++ b/core/frontend/src/pipeline-page.css @@ -466,6 +466,12 @@ .video-card-head .pill { flex: 0 0 auto; } .video-meta { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; margin-top: 5px; } .video-actions { margin-top: auto; padding-top: 12px; display: flex; align-items: center; gap: 10px; } + /* 按钮内联小 spinner(重跑「提交中/生成中」转圈),配合文字;在 btn-primary 上转白色可见 */ + .btn-spin { width: 12px; height: 12px; border-width: 2px; flex: 0 0 auto; margin-right: 5px; vertical-align: -1px; } + .btn-primary .btn-spin { border-color: color-mix(in srgb, currentColor 35%, transparent); border-top-color: currentColor; } + /* 多版本入口小标识「共 N 版 ›」—— 不是按钮、是可点 mono 标识,hover 转主橙 */ + .video-ver-badge { background: none; border: 0; padding: 0; cursor: pointer; font-family: var(--font-mono); font-size: 12px; letter-spacing: .02em; color: var(--black-alpha-48); transition: color var(--t-base); } + .video-ver-badge:hover { color: var(--heat); } /* ============= STAGE 5 · 拼接编辑器 ============= */ .content--fh .stage[data-stage-pane="5"].active > .editor { flex: 1 1 0; min-height: 0; overflow-y: auto; height: auto; } diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index 468640f..61a4b08 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -483,8 +483,8 @@ export function PipelinePage(props: { onRenameActor?: (assetId: string, name: string) => Promise; onGenerateStoryboard: (prompt: string) => void | Promise; onSkipStoryboard: () => Promise; - onSubmitVideo: (segmentId: string, prompt: string) => void; - onSubmitAllVideos: (prompt: string) => void; + onSubmitVideo: (segmentId: string, prompt: string) => void | Promise; + onSubmitAllVideos: (prompt: string) => void | Promise; onPollVideosQuiet: () => void | Promise; exportResult: ExportPoll | null; onRefreshExport: () => void; @@ -874,6 +874,38 @@ export function PipelinePage(props: { setVdVerId(null); setVdPrompt(""); } + // 重跑「提交中」乐观态:点下立刻禁用+转圈,撑到该段真的进 running(busy 接管),堵掉空窗连点 → 防双倍扣费 + const [rerunPending, setRerunPending] = useState>(() => new Set()); + const clearRerunPending = (segId: string) => + setRerunPending((s) => { if (!s.has(segId)) return s; const n = new Set(s); n.delete(segId); return n; }); + function submitVideoOptimistic(segId: string, prompt: string) { + setRerunPending((s) => new Set(s).add(segId)); + Promise.resolve(onSubmitVideo(segId, prompt)) + .then((res) => { if (res == null) clearRerunPending(segId); }) // 失败(action 返回 null)→ 立即解禁可重试;成功交给下方 effect + .catch(() => clearRerunPending(segId)); + window.setTimeout(() => clearRerunPending(segId), 20000); // 兜底:异常下也别永久禁用 + } + // 「全部重跑」乐观态:把将被提交的段(非在途)全部立刻标 pending → 每张卡马上转圈,不必等状态回传 + function submitAllVideosOptimistic() { + const ids = segments.filter((s) => !["running", "queued"].includes(s.status)).map((s) => s.id); + if (ids.length === 0) return; + setRerunPending((s) => { const n = new Set(s); ids.forEach((id) => n.add(id)); return n; }); + Promise.resolve(onSubmitAllVideos(videoPrompt)) + .then((res) => { if (res == null) ids.forEach(clearRerunPending); }) // 失败 → 解禁;成功交给上面的 effect 逐个摘除 + .catch(() => ids.forEach(clearRerunPending)); + ids.forEach((id) => window.setTimeout(() => clearRerunPending(id), 20000)); + } + // 段状态变化时,把已真正进入 running/queued 的段从 pending 摘掉(此后由 busy 驱动 UI) + const segStatusKey = segments.map((s) => `${s.id}:${s.status}`).join("|"); + useEffect(() => { + setRerunPending((prev) => { + if (prev.size === 0) return prev; + const next = new Set(prev); + for (const seg of segments) if (next.has(seg.id) && ["running", "queued"].includes(seg.status)) next.delete(seg.id); + return next.size === prev.size ? prev : next; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [segStatusKey]); // Esc 关闭详情弹窗 useEffect(() => { if (!vdSegId) return; @@ -3085,7 +3117,7 @@ export function PipelinePage(props: {
{pct}% - + + + {/* 多版本入口:N>1 才显示,点开详情弹窗看历史/切换/采用(数据全留着,不吞历史) */} + {verCount > 1 && ( + + )} {/* AI 生成片段不需单卡上传(自定义替换走 queue-bar 全局上传);移除多余「上传」按钮 */} {url @@ -3524,6 +3564,7 @@ export function PipelinePage(props: { {/* ── Stage 4 · 视频详情弹窗:大预览 + 历史版本(可切/可采用) + 本场提示词 + 重跑 ── */} {vdSeg && (() => { const busy = ["running", "queued"].includes(vdSeg.status); + const showBusy = busy || rerunPending.has(vdSeg.id); const hint = (shots[vdSeg.sort_order]?.visual_prompt || shots[vdSeg.sort_order]?.narration || "").trim(); const rerunPrompt = vdPrompt.trim() || `${videoPrompt} 第 ${vdSeg.sort_order + 1} 段,时长 ${vdSeg.target_duration_seconds} 秒`; return ( @@ -3589,8 +3630,8 @@ export function PipelinePage(props: {
{vdVer?.asset_url && 下载} -