fix: 全站页面保活机制、登录拦截优化、UI修复与功能完善

- 移除未登录全页面拦截,改为浏览自由 + 功能使用时弹窗
- 修复PageTransition退出动画卡死导致黑屏的bug
- CanvasPage添加加载中状态避免首次访问黑屏假死
- 全站7个工具页添加页面保活机制,切页后台任务不中断
- 修复未登录时401误触发"用户已在别处登录"弹窗
- 删除MorePage模板板块、微信登录、EcommerceTemplates/SizeTemplate路由
- 剧本评分接入DashScope qwen3.7-max直连API
- 电商视频生成重构为3阶段可视管线(策划→生成图片→生成视频)
- 电商视频保活增强:异步函数直接写localStorage避免卸载丢失
- Workbench侧边栏移除mode过滤,三模式共用同一对话列表
- 首页更新轮播图/背景视频、按钮跳转修正、文案优化
- AppShell顶栏新增网站备案信息按钮
- 多个页面的terminate/cancel按钮覆盖、单镜头重试、批量保存下载

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-06-03 01:39:06 +08:00
parent 8f57e08004
commit 468d1d27dd
35 changed files with 1263 additions and 441 deletions
+407 -163
View File
@@ -1,4 +1,4 @@
import { Fragment, useCallback, useRef, useState } from "react";
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import {
CopyOutlined,
DownloadOutlined,
@@ -9,9 +9,10 @@ import {
SendOutlined,
StopOutlined,
} from "@ant-design/icons";
import { runVideoPlan, renderScene, buildSceneTasks } from "./ecommerceVideoService";
import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks } from "./ecommerceVideoService";
import {
PLAN_STEP_LABELS,
PLAN_STEPS_DISPLAY,
type EcommerceVideoStage,
type EcommerceVideoSceneTask,
type EcommerceVideoPlanResult,
@@ -21,6 +22,11 @@ import type { AdVideoUserConfig } from "../../api/adVideoPlanClient";
import { ServerRequestError } from "../../api/serverConnection";
import { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions";
import { useAppStore } from "../../stores";
import {
saveEcommerceVideoState,
loadEcommerceVideoState,
clearEcommerceVideoState,
} from "./ecommerceVideoKeepalive";
interface EcommerceVideoWorkspaceProps {
isAuthenticated: boolean;
@@ -56,12 +62,120 @@ export default function EcommerceVideoWorkspace({
const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null);
const [scenes, setScenes] = useState<EcommerceVideoSceneTask[]>([]);
const [completedSteps, setCompletedSteps] = useState<PlanStep[]>([]);
const [sourceImageUrls, setSourceImageUrls] = useState<string[]>([]);
const [currentStep, setCurrentStep] = useState<PlanStep | null>(null);
const [error, setError] = useState<string | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const renderAbortRef = useRef({ current: false });
const setView = useAppStore((s) => s.setView);
const keepaliveRestoredRef = useRef(false);
const keepalivePollingStartedRef = useRef(false);
// ── Keep-alive: restore saved state on mount ─────────────
useEffect(() => {
if (keepaliveRestoredRef.current) return;
keepaliveRestoredRef.current = true;
const saved = loadEcommerceVideoState();
if (!saved) return;
if (saved.stage === "idle" || saved.stage === "cancelled") return;
// Restore completed / in-progress states — results persist across page switches
setStage(saved.stage);
setCompletedSteps(saved.completedSteps || []);
setPlanResult(saved.planResult);
setScenes(saved.scenes || []);
setSourceImageUrls(saved.sourceImageUrls || saved.planResult?.imageUrls || []);
}, []);
// ── Keep-alive: save state on changes ───────────────────
useEffect(() => {
if (stage === "idle" || stage === "cancelled") return;
saveEcommerceVideoState({ stage, completedSteps, planResult, scenes, sourceImageUrls });
}, [stage, completedSteps, planResult, scenes, sourceImageUrls]);
// ── Keep-alive: resume polling for running tasks ──────────
useEffect(() => {
if (keepalivePollingStartedRef.current) return;
if (!scenes.length || stage === "idle" || stage === "cancelled" || stage === "completed") return;
const hasRunningScenes = scenes.some((s) => s.status === "running" || s.status === "pending");
if (!hasRunningScenes) return;
keepalivePollingStartedRef.current = true;
// Resume polling for image generation tasks
if (stage === "imaging") {
renderAbortRef.current = { current: false };
void (async () => {
for (const scene of scenes) {
if (renderAbortRef.current.current) break;
if (scene.status !== "running" && scene.status !== "pending") continue;
if (!scene.imageTaskId) continue;
try {
const { waitForTask } = await import("../../api/taskSubscription");
const resultUrl = await waitForTask(scene.imageTaskId, {
abortRef: renderAbortRef.current,
onProgress: (e) =>
setScenes((prev) => prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, progress: e.progress } : s))),
});
if (resultUrl) {
setScenes((prev) =>
prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "idle", progress: 100, imageUrl: resultUrl } : s)),
);
}
} catch {
setScenes((prev) =>
prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "idle", error: "恢复任务失败" } : s)),
);
}
}
setScenes((current) => {
const allImaged = current.every((s) => s.imageUrl);
if (allImaged) setStage("imaged");
return current;
});
})();
}
// Resume polling for video rendering tasks
if (stage === "rendering") {
renderAbortRef.current = { current: false };
void (async () => {
for (const scene of scenes) {
if (renderAbortRef.current.current) break;
if (scene.status !== "running" && scene.status !== "pending") continue;
if (!scene.taskId) continue;
try {
const { waitForTask } = await import("../../api/taskSubscription");
const resultUrl = await waitForTask(scene.taskId, {
abortRef: renderAbortRef.current,
onProgress: (e) =>
setScenes((prev) => prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, progress: e.progress } : s))),
});
if (resultUrl) {
setScenes((prev) =>
prev.map((s) =>
s.sceneId === scene.sceneId ? { ...s, status: "completed", progress: 100, resultUrl: resultUrl } : s,
),
);
}
} catch {
setScenes((prev) =>
prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "failed", error: "恢复任务失败" } : s)),
);
}
}
setScenes((current) => {
const hasFailed = current.some((s) => s.status === "failed");
const allDone = current.every((s) => s.status === "completed" || s.status === "failed");
if (allDone) setStage(hasFailed ? "partial_failed" : "completed");
return current;
});
})();
}
}, [scenes, stage]);
// Note: keep-alive is NOT cleared on completion — results persist across page switches.
// Only cleared when user explicitly starts a new plan via handlePlan.
const showNotice = (msg: string) => {
setActionNotice(msg);
@@ -71,166 +185,229 @@ export default function EcommerceVideoWorkspace({
const handleDownload = async (url: string) => {
try {
await saveToolResultToLocal({
url,
name: `ecommerce-video-${Date.now()}`,
type: "video",
isVideo: true,
tags: ["电商", "短视频", "生成视频"],
url, name: `ecommerce-video-${Date.now()}`, type: "video",
isVideo: true, tags: ["电商", "短视频", "生成视频"],
});
showNotice("下载完成");
} catch {
const a = document.createElement("a");
a.href = url;
a.download = "ecommerce-video.mp4";
a.click();
const a = document.createElement("a"); a.href = url; a.download = "ecommerce-video.mp4"; a.click();
}
};
const handleSaveAsset = async (url: string) => {
try {
const result = await addToolResultToAssetLibrary({
url,
name: `电商短视频-${Date.now()}.mp4`,
description: "电商广告视频生成结果",
type: "video",
isVideo: true,
tags: ["电商", "短视频", "广告视频"],
url, name: `电商短视频-${Date.now()}.mp4`, description: "电商广告视频生成结果",
type: "video", isVideo: true, tags: ["电商", "短视频", "广告视频"],
metadata: { source: "ecommerce-video", platform },
});
showNotice(result === "server" ? "已保存到资产库" : "已保存到本地资产库");
} catch {
showNotice("保存失败");
} catch { showNotice("保存失败"); }
};
const handleSaveAllAssets = async () => {
if (!completedScenes.length) return;
let saved = 0;
for (const scene of completedScenes) {
try {
await addToolResultToAssetLibrary({
url: scene.resultUrl!, name: `电商短视频-镜头${scene.sceneId}-${Date.now()}.mp4`,
description: `电商广告视频 - 镜头${scene.sceneId}`,
type: "video", isVideo: true, tags: ["电商", "短视频", "广告视频"],
metadata: { source: "ecommerce-video", platform, sceneId: scene.sceneId },
});
saved++;
} catch { /* continue */ }
}
showNotice(saved > 0 ? `已保存 ${saved}/${completedScenes.length} 个视频到资产库` : "保存失败");
};
const handleDownloadAll = async () => {
for (const scene of completedScenes) {
await new Promise((r) => setTimeout(r, 300));
const a = document.createElement("a");
a.href = scene.resultUrl!;
a.download = `ecommerce-video-scene-${scene.sceneId}.mp4`;
a.click();
}
showNotice(`正在下载 ${completedScenes.length} 个视频`);
};
const handleImportToCanvas = async (url: string) => {
try {
await addToolResultToAssetLibrary({
url,
name: `电商短视频-${Date.now()}.mp4`,
description: "电商广告视频 - 导入画布",
type: "video",
isVideo: true,
tags: ["电商", "短视频", "画布导入"],
url, name: `电商短视频-${Date.now()}.mp4`, description: "电商广告视频 - 导入画布",
type: "video", isVideo: true, tags: ["电商", "短视频", "画布导入"],
metadata: { source: "ecommerce-video", platform },
});
setView("canvas");
showNotice("已保存资产并跳转画布");
} catch {
showNotice("导入失败");
}
} catch { showNotice("导入失败"); }
};
const buildConfig = useCallback((): AdVideoUserConfig => ({
platform,
aspectRatio,
durationSeconds,
style: "痛点解决",
language: "中文",
market: "中国",
needVoiceover: true,
needSubtitle: true,
conversionFocus: "conversion",
platform, aspectRatio, durationSeconds,
style: "痛点解决", language: "中文", market: "中国",
needVoiceover: true, needSubtitle: true, conversionFocus: "conversion",
}), [platform, aspectRatio, durationSeconds]);
// ── Phase 1: Planning ──────────────────────────────────────
const handlePlan = async () => {
if (!isAuthenticated) { onRequestLogin?.(); return; }
if (!productImageDataUrls.length && !requirement.trim()) {
setError("请先上传产品图片或填写商品说明");
return;
setError("请先上传产品图片或填写商品说明"); return;
}
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setStage("planning");
setError(null);
setCompletedSteps([]);
setCurrentStep(null);
setPlanResult(null);
setScenes([]);
setStage("planning"); setError(null);
setCompletedSteps([]); setCurrentStep(null);
setPlanResult(null); setScenes([]); setSourceImageUrls([]);
try {
const result = await runVideoPlan(
productImageDataUrls, requirement, buildConfig(),
{
onStepStart: (step) => setCurrentStep(step),
onStepDone: (step) => setCompletedSteps((prev) => [...prev, step]),
onImagesUploaded: (urls) => { setSourceImageUrls(urls); saveEcommerceVideoState({ stage: "planning", completedSteps: ["upload"], planResult: null, scenes: [], sourceImageUrls: urls }); },
signal: controller.signal,
},
);
const builtScenes = buildSceneTasks(result);
setPlanResult(result);
setScenes(buildSceneTasks(result));
setScenes(builtScenes);
setStage("planned");
// Persist immediately — component may be unmounted by the time React re-renders
saveEcommerceVideoState({ stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, scenes: builtScenes, sourceImageUrls: result.imageUrls });
} catch (err) {
if ((err as Error).name === "AbortError") return;
setError(err instanceof Error ? err.message : "策划失败");
setStage("idle");
} finally {
setCurrentStep(null);
}
} finally { setCurrentStep(null); }
};
const handleRender = async () => {
if (!planResult || !scenes.length) return;
const imageUrl = planResult.imageUrls[0] || "";
setStage("rendering");
setError(null);
renderAbortRef.current = { current: false };
const quality = mapResolutionToQuality(resolution);
// ── Phase 2: Image generation per scene ──────────────────────
for (const scene of scenes) {
const handleGenerateImages = async () => {
if (!planResult || !scenes.length) return;
setStage("imaging"); setError(null);
renderAbortRef.current = { current: false };
const ratio = aspectRatio.includes("9:16") || aspectRatio.includes("916") ? "9:16"
: aspectRatio.includes("16:9") || aspectRatio.includes("169") ? "16:9"
: "1:1";
let currentScenes = [...scenes];
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
currentScenes = next;
setScenes(next);
saveEcommerceVideoState({ stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls });
};
for (const scene of currentScenes) {
if (renderAbortRef.current.current) break;
setScenes((prev) => prev.map((s) =>
s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
try {
await renderScene(
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl, aspectRatio, resolution: quality },
await renderSceneImage(
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio },
{
onSceneSubmitted: (id, taskId) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)),
onSceneProgress: (id, progress) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, progress } : s)),
onSceneCompleted: (id, url) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)),
onSceneFailed: (id, err2) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)),
onSceneImageSubmitted: (id, taskId) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s)),
onSceneImageProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
onSceneImageCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s)),
onSceneImageFailed: (id, err2) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", error: err2 } : s)),
},
renderAbortRef.current,
);
} catch (err) {
const message = err instanceof Error ? err.message : "生成失败";
const isPaymentError = err instanceof ServerRequestError && err.status === 402;
setScenes((prev) => prev.map((s) =>
s.sceneId === scene.sceneId ? { ...s, status: "failed", error: isPaymentError ? "余额不足,请充值后继续" : message } : s));
if (isPaymentError) {
setError("余额不足,请充值后再生成视频");
renderAbortRef.current.current = true;
break;
}
const message = err instanceof Error ? err.message : "图片生成失败";
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "idle", error: message } : s));
}
}
setScenes((current) => {
const hasFailed = current.some((s) => s.status === "failed");
const allDone = current.every((s) => s.status === "completed" || s.status === "failed");
if (allDone) setStage(hasFailed ? "partial_failed" : "completed");
return current;
});
const allHaveImages = currentScenes.every((s) => s.imageUrl);
const finalStage = allHaveImages ? "imaged" as const : "partial_failed" as const;
setStage(finalStage);
saveEcommerceVideoState({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
};
const handleCancel = () => {
abortControllerRef.current?.abort();
renderAbortRef.current.current = true;
setStage("cancelled");
// ── Phase 3: Video rendering from generated images ──────────
const handleRenderVideos = async () => {
if (!scenes.length) return;
const firstImage = scenes[0]?.imageUrl;
if (!firstImage) { setError("请先生成分镜图片"); return; }
setStage("rendering"); setError(null);
renderAbortRef.current = { current: false };
const quality = mapResolutionToQuality(resolution);
let currentScenes = [...scenes];
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
currentScenes = next;
setScenes(next);
saveEcommerceVideoState({ stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls });
};
for (const scene of currentScenes) {
if (renderAbortRef.current.current) break;
if (!scene.imageUrl) continue;
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
try {
await renderScene(
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, aspectRatio, resolution: quality },
{
onSceneSubmitted: (id, taskId) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)),
onSceneProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
onSceneCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)),
onSceneFailed: (id, err2) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)),
},
renderAbortRef.current,
);
} catch (err) {
const msg = err instanceof Error ? err.message : "生成失败";
const isPayment = err instanceof ServerRequestError && err.status === 402;
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "failed", error: isPayment ? "余额不足,请充值后继续" : msg } : s));
if (isPayment) { setError("余额不足,请充值后再生成视频"); renderAbortRef.current.current = true; break; }
}
}
const hasFailed = currentScenes.some((s) => s.status === "failed");
const allDone = currentScenes.every((s) => s.status === "completed" || s.status === "failed");
const finalStage = allDone ? (hasFailed ? "partial_failed" as const : "completed" as const) : "rendering" as const;
setScenes(currentScenes);
setStage(finalStage);
saveEcommerceVideoState({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
};
const handleCancel = () => { abortControllerRef.current?.abort(); renderAbortRef.current.current = true; setStage("cancelled"); };
const handleRetryScene = async (scene: EcommerceVideoSceneTask) => {
if (!scene.imageUrl) return;
setScenes((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
try {
await renderScene(
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl!, aspectRatio, resolution: mapResolutionToQuality(resolution) },
{
onSceneSubmitted: (id, taskId) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)),
onSceneProgress: (id, progress) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, progress } : s)),
onSceneCompleted: (id, url) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)),
onSceneFailed: (id, err2) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)),
},
renderAbortRef.current,
);
} catch (err) {
setScenes((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "failed", error: (err as Error).message } : s));
}
};
// ── Derived state ───────────────────────────────────────────
const completedScenes = scenes.filter((s) => s.status === "completed" && s.resultUrl);
const imagedScenes = scenes.filter((s) => s.imageUrl);
const primaryVideo = completedScenes[0]?.resultUrl;
const canRender = planResult?.compliance.allow_video_generation && stage === "planned";
const sourceImage = planResult?.imageUrls[0] || productImageDataUrls[0] || "";
const flowHasStarted = stage !== "idle" || completedSteps.length > 0 || scenes.length > 0;
const sourceImage = sourceImageUrls[0] || planResult?.imageUrls[0] || productImageDataUrls[0] || "";
const flowHasStarted = stage !== "idle" || completedSteps.length > 0;
const flowMeta = `${platform} / ${aspectRatio} / ${durationSeconds}s / ${resolution}`;
const planActionLabel = stage === "planning"
? "策划中"
: (stage === "planned" || stage === "completed" || stage === "partial_failed") ? "重新策划" : "一键策划";
const renderActionLabel = stage === "rendering" ? "生成中" : "确认生成";
const hasImaging = stage === "imaging" || stage === "imaged" || stage === "rendering" || stage === "completed" || stage === "partial_failed";
const hasRendering = stage === "rendering" || stage === "completed" || stage === "partial_failed";
const visiblePlanSteps = PLAN_STEPS_DISPLAY.filter((s) => completedSteps.includes(s));
return (
<div className="ecom-video-workspace" data-stage={stage}>
{/* ── Flow bar ──────────────────────────────────── */}
<header className="ecom-video-flowbar">
<div className="ecom-video-flowbar__title" aria-label={`短视频分镜流,${flowMeta}`} title={flowMeta}>
<span className={`ecom-video-flowbar__pulse${flowHasStarted ? " is-active" : ""}`} aria-hidden="true" />
@@ -241,111 +418,178 @@ export default function EcommerceVideoWorkspace({
{ALL_STEPS.map((step) => {
const isDone = completedSteps.includes(step);
const isActive = currentStep === step;
const cls = isDone ? "is-done" : isActive ? "is-active" : "";
return (
<span
key={step}
className={`ecom-video-step-dot ${cls}`}
title={PLAN_STEP_LABELS[step]}
aria-label={PLAN_STEP_LABELS[step]}
/>
);
return <span key={step} className={`ecom-video-step-dot ${isDone ? "is-done" : isActive ? "is-active" : ""}`} title={PLAN_STEP_LABELS[step]} />;
})}
</div>
<div className="ecom-video-flowbar__actions">
{error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null}
<button
type="button"
className="ecom-video-flow-action"
disabled={stage === "planning" || stage === "rendering"}
onClick={() => void handlePlan()}
aria-label={planActionLabel}
title={planActionLabel}
>
{stage === "planning" ? <LoadingOutlined /> : (stage === "planned" || stage === "completed" || stage === "partial_failed") ? <ReloadOutlined /> : <PlayCircleOutlined />}
</button>
{(stage === "rendering" || stage === "planned") ? (
<button
type="button"
className="ecom-video-flow-action ecom-video-flow-action--ghost"
disabled={!canRender}
onClick={() => void handleRender()}
aria-label={renderActionLabel}
title={renderActionLabel}
>
{stage === "rendering" ? <LoadingOutlined /> : <SendOutlined />}
{stage !== "planning" && stage !== "imaging" && stage !== "rendering" ? (
<button type="button" className="ecom-video-flow-action"
onClick={() => void handlePlan()} title="一键策划">
<PlayCircleOutlined />
</button>
) : null}
{stage === "planned" ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
onClick={() => void handleGenerateImages()} title="生成图片">
<SendOutlined />
</button>
) : null}
{stage === "imaged" ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
onClick={() => void handleRenderVideos()} title="生成视频">
<SendOutlined />
</button>
) : null}
{stage === "planning" ? (
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> </span>
) : null}
{stage === "imaging" ? (
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> </span>
) : null}
{stage === "rendering" ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--danger" onClick={handleCancel} aria-label="取消生成" title="取消生成">
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> </span>
) : null}
{stage === "planning" || stage === "imaging" || stage === "rendering" ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--danger" onClick={handleCancel} title="终止">
<StopOutlined />
</button>
) : null}
</div>
</header>
{/* ── Flow canvas ──────────────────────────────────── */}
<section className="ecom-video-flow-canvas" aria-label="视频分镜流程图">
{completedScenes.length === 0 && !sourceImage ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", color: "#697486", fontSize: 13 }}>
{!sourceImage ? (
<div className="ecom-video-empty">
<span>"一键策划"</span>
</div>
) : (
<div className="ecom-video-flow-map">
{sourceImage ? (
<article className="ecom-video-flow-node ecom-video-flow-node--source is-ready" aria-label="商品图节点">
<div className="ecom-video-flow-node__media">
<img src={sourceImage} alt="商品图" />
</div>
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
) : null}
{/* Source image node */}
<article className="ecom-video-flow-node ecom-video-flow-node--source is-ready" aria-label="商品图节点">
<div className="ecom-video-flow-node__media">
<img src={sourceImage} alt="商品图" />
</div>
<span className="ecom-video-flow-node__label"></span>
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
{sourceImage && completedScenes.length > 0 ? (
{/* Connector: source → plan text nodes */}
{visiblePlanSteps.length > 0 ? (
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
) : null}
<div className="ecom-video-scene-strip" aria-label="已完成分镜节点">
{completedScenes.map((scene, index) => (
<Fragment key={scene.sceneId}>
<article
className="ecom-video-flow-node ecom-video-flow-node--scene is-completed"
aria-label={`镜头 ${scene.sceneId},完成`}
title={`镜头 ${scene.sceneId}`}
>
<div className="ecom-video-flow-node__media">
<video src={scene.resultUrl!} muted playsInline loop autoPlay />
</div>
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
{index < completedScenes.length - 1 ? (
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
) : null}
</Fragment>
))}
</div>
{/* Plan text nodes — side by side */}
{visiblePlanSteps.length > 0 ? (
<div className="ecom-video-scene-strip ecom-video-scene-strip--text" aria-label="策划节点">
{visiblePlanSteps.map((step, idx) => (
<Fragment key={step}>
<article className={`ecom-video-flow-node ecom-video-flow-node--text is-completed${currentStep === step ? " is-pulsing" : ""}`}
aria-label={PLAN_STEP_LABELS[step]} title={PLAN_STEP_LABELS[step]}>
<span className="ecom-video-flow-node__text-icon">
{currentStep === step ? <LoadingOutlined /> : "✓"}
</span>
<span className="ecom-video-flow-node__label">{PLAN_STEP_LABELS[step]}</span>
</article>
{idx < visiblePlanSteps.length - 1 ? (
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
) : null}
</Fragment>
))}
</div>
) : null}
{completedScenes.length > 0 && primaryVideo ? (
{/* Connector: plan → images */}
{hasImaging ? (
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
) : null}
{primaryVideo ? (
<article className="ecom-video-flow-node ecom-video-flow-node--final is-completed" aria-label="成片节点,已完成">
<div className="ecom-video-flow-node__media">
<video src={primaryVideo} muted playsInline loop autoPlay />
</div>
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
{/* Storyboard image nodes — side by side per scene */}
{hasImaging ? (
<div className="ecom-video-scene-strip" aria-label="分镜图片节点">
{scenes.map((scene, idx) => {
const imgReady = !!scene.imageUrl;
const imgRunning = stage === "imaging" && (scene.status === "running" || scene.status === "pending") && !scene.imageUrl;
const cls = imgReady ? "is-completed" : imgRunning ? "is-active" : "";
return (
<Fragment key={`img-${scene.sceneId}`}>
<article className={`ecom-video-flow-node ecom-video-flow-node--image ${cls}`}
aria-label={`分镜 ${scene.sceneId}`} title={`分镜 ${scene.sceneId}`}>
<div className="ecom-video-flow-node__media">
{imgReady ? <img src={scene.imageUrl!} alt={`分镜${scene.sceneId}`} />
: imgRunning ? <div className="ecom-video-flow-node__placeholder"><LoadingOutlined /></div>
: <div className="ecom-video-flow-node__placeholder"></div>}
</div>
{imgRunning ? <span className="ecom-video-flow-node__progress">{scene.progress || 0}%</span> : null}
<span className="ecom-video-flow-node__label">{scene.sceneId}</span>
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
{idx < scenes.length - 1 ? (
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
) : null}
</Fragment>
);
})}
</div>
) : null}
{/* Connector: images → videos */}
{hasRendering ? (
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
) : null}
{/* Video nodes — side by side per scene */}
{hasRendering ? (
<div className="ecom-video-scene-strip" aria-label="视频分镜节点">
{scenes.map((scene, idx) => {
const vidReady = scene.status === "completed" && scene.resultUrl;
const vidRunning = stage === "rendering" && (scene.status === "running" || scene.status === "pending");
const vidFailed = scene.status === "failed";
const cls = vidReady ? "is-completed" : vidRunning ? "is-active" : vidFailed ? "is-failed" : "";
return (
<Fragment key={`vid-${scene.sceneId}`}>
<article className={`ecom-video-flow-node ecom-video-flow-node--video ${cls}`}
aria-label={`镜头 ${scene.sceneId}`} title={`镜头 ${scene.sceneId}`}>
<div className="ecom-video-flow-node__media">
{vidReady ? <video src={scene.resultUrl!} muted playsInline loop autoPlay />
: vidRunning ? <div className="ecom-video-flow-node__placeholder"><LoadingOutlined /></div>
: vidFailed ? <div className="ecom-video-flow-node__placeholder"></div>
: <div className="ecom-video-flow-node__placeholder"></div>}
</div>
{vidRunning ? <span className="ecom-video-flow-node__progress">{scene.progress || 0}%</span> : null}
<span className="ecom-video-flow-node__label">{scene.sceneId}</span>
{vidFailed ? (
<button type="button" className="ecom-video-flow-node__retry"
onClick={(e) => { e.stopPropagation(); void handleRetryScene(scene); }}
title="重试此镜头">
<ReloadOutlined />
</button>
) : null}
{vidFailed && scene.error ? (
<span className="ecom-video-flow-node__error" title={scene.error}>{scene.error.slice(0, 20)}</span>
) : null}
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
{idx < scenes.length - 1 ? (
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
) : null}
</Fragment>
);
})}
</div>
) : null}
</div>
)}
{/* ── Delivery dock ────────────────────────────── */}
{primaryVideo ? (
<div className="ecom-video-flow-dock" aria-label="视频交付操作">
<button type="button" aria-label="下载当前视频" title="下载当前视频" onClick={() => void handleDownload(primaryVideo)}><DownloadOutlined /></button>
<button type="button" aria-label="保存到资产库" title="保存到资产库" onClick={() => void handleSaveAsset(primaryVideo)}><FolderAddOutlined /></button>
<button type="button" aria-label="导入画布" title="导入画布" onClick={() => void handleImportToCanvas(primaryVideo)}><SendOutlined /></button>
<button type="button" aria-label="复制视频链接" title="复制视频链接" onClick={() => void navigator.clipboard.writeText(primaryVideo)}><CopyOutlined /></button>
<button type="button" onClick={() => void handleDownloadAll()} title={`下载全部 ${completedScenes.length} 个视频`}><DownloadOutlined /></button>
<button type="button" onClick={() => void handleSaveAllAssets()} title={`保存全部 ${completedScenes.length} 个视频到资产库`}><FolderAddOutlined /></button>
{primaryVideo ? <button type="button" onClick={() => void handleImportToCanvas(primaryVideo)} title="导入画布"><SendOutlined /></button> : null}
{primaryVideo ? <button type="button" onClick={() => void navigator.clipboard.writeText(primaryVideo)} title="复制链接"><CopyOutlined /></button> : null}
</div>
) : null}
{actionNotice ? <div className="ecom-video-flow-notice">{actionNotice}</div> : null}