feat: 电商页面 KeepAlive 保活机制,切换页面不再丢失生成状态
通过 display:none 模式实现轻量 KeepAlive,电商页面首次访问后保持挂载, 切换到其他页面再切回时所有右侧面板状态(上传图片、生成进度、结果)完整保留。 同时清理项目中的临时文件和本地冗余图片。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
@@ -619,123 +619,126 @@ export default function EcommerceVideoWorkspace({
|
||||
<section className="ecom-video-flow-canvas" aria-label="视频分镜流程图">
|
||||
{!sourceImage ? (
|
||||
<div className="ecom-video-empty">
|
||||
<span>上传商品图并点击"一键策划"开始</span>
|
||||
<span>上传商品图并点击“一键策划”开始</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ecom-video-flow-map">
|
||||
{/* 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 className="ecom-video-tree">
|
||||
{/* Source Node — 附件原图 */}
|
||||
<div className="ecom-video-tree__source">
|
||||
<article className="ecom-video-tree-node ecom-video-tree-node--source">
|
||||
<img src={sourceImage} alt="商品原图" />
|
||||
</article>
|
||||
<span className="ecom-video-tree-node__label">附件原图</span>
|
||||
</div>
|
||||
|
||||
{/* Branch Connector — 分支连接线 */}
|
||||
<div className="ecom-video-tree__trunk" aria-hidden="true">
|
||||
<div className="ecom-video-tree__trunk-line" />
|
||||
<div className="ecom-video-tree__branches-line">
|
||||
{scenes.length > 0 ? scenes.map((s) => (
|
||||
<div key={`trunk-${s.sceneId}`} className="ecom-video-tree__branch-tap" />
|
||||
)) : (
|
||||
<>
|
||||
<div className="ecom-video-tree__branch-tap" />
|
||||
<div className="ecom-video-tree__branch-tap" />
|
||||
<div className="ecom-video-tree__branch-tap" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="ecom-video-flow-node__label">商品原图</span>
|
||||
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
|
||||
</article>
|
||||
|
||||
{/* Connector: source → plan text nodes */}
|
||||
{visiblePlanSteps.length > 0 ? (
|
||||
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
|
||||
) : null}
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
|
||||
{/* Branches — 每个场景一条分支 */}
|
||||
<div className="ecom-video-tree__rows">
|
||||
{scenes.length > 0 ? scenes.map((scene, idx) => {
|
||||
const planDone = completedSteps.length >= ALL_STEPS.length;
|
||||
const imgReady = !!scene.imageUrl;
|
||||
const imgRunning = stage === "imaging" && (scene.status === "running" || scene.status === "pending") && !scene.imageUrl;
|
||||
const vidReady = scene.status === "completed" && scene.resultUrl;
|
||||
const vidRunning = stage === "rendering" && (scene.status === "running" || scene.status === "pending");
|
||||
const vidFailed = scene.status === "failed";
|
||||
|
||||
return (
|
||||
<div key={scene.sceneId} className="ecom-video-tree__row" style={{ animationDelay: `${idx * 120}ms` }}>
|
||||
<article className={`ecom-video-tree-node ecom-video-tree-node--text${planDone ? " is-completed" : currentStep ? " is-active" : ""}`}>
|
||||
<div className="ecom-video-tree-node__inner">
|
||||
<span className="ecom-video-tree-node__title">分镜文本{scene.sceneId}</span>
|
||||
<span className="ecom-video-tree-node__desc">
|
||||
{planDone ? "已完成" : stage === "planning" ? "策划中..." : "等待策划"}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
{idx < visiblePlanSteps.length - 1 ? (
|
||||
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Connector: plan → images */}
|
||||
{hasImaging ? (
|
||||
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
|
||||
) : null}
|
||||
|
||||
{/* 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 className="ecom-video-tree__arrow" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</div>
|
||||
|
||||
<article className={`ecom-video-tree-node ecom-video-tree-node--image${imgReady ? " is-completed" : imgRunning ? " is-active" : ""}`}>
|
||||
{imgReady ? (
|
||||
<img src={scene.imageUrl!} alt={`分镜${scene.sceneId}`} />
|
||||
) : (
|
||||
<div className="ecom-video-tree-node__placeholder">
|
||||
{imgRunning ? <LoadingOutlined /> : <span>待生成</span>}
|
||||
</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>}
|
||||
)}
|
||||
{imgRunning ? <span className="ecom-video-tree-node__progress">{scene.progress || 0}%</span> : null}
|
||||
<span className="ecom-video-tree-node__tag">分镜图{scene.sceneId}</span>
|
||||
</article>
|
||||
|
||||
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</div>
|
||||
|
||||
<article className={`ecom-video-tree-node ecom-video-tree-node--video${vidReady ? " is-completed" : vidRunning ? " is-active" : vidFailed ? " is-failed" : ""}`}>
|
||||
{vidReady ? (
|
||||
<video src={scene.resultUrl!} muted playsInline loop autoPlay />
|
||||
) : (
|
||||
<div className="ecom-video-tree-node__placeholder">
|
||||
{vidRunning ? <LoadingOutlined /> : vidFailed ? <span>失败</span> : <span>待生成</span>}
|
||||
</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>
|
||||
)}
|
||||
{vidRunning ? <span className="ecom-video-tree-node__progress">{scene.progress || 0}%</span> : null}
|
||||
<span className="ecom-video-tree-node__tag">分镜视频{scene.sceneId}</span>
|
||||
{vidFailed ? (
|
||||
<button type="button" className="ecom-video-tree-node__retry"
|
||||
onClick={(e) => { e.stopPropagation(); void handleRetryScene(scene); }}
|
||||
title="重试此镜头">
|
||||
<ReloadOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
[1, 2, 3].map((n) => (
|
||||
<div key={n} className={`ecom-video-tree__row ecom-video-tree__row--empty${stage === "planning" ? " is-planning" : ""}`} style={{ animationDelay: `${n * 120}ms` }}>
|
||||
<article className="ecom-video-tree-node ecom-video-tree-node--text">
|
||||
<div className="ecom-video-tree-node__inner">
|
||||
<span className="ecom-video-tree-node__title">分镜文本{n}</span>
|
||||
<span className="ecom-video-tree-node__desc">{stage === "planning" ? "策划中..." : "等待策划"}</span>
|
||||
</div>
|
||||
</article>
|
||||
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</div>
|
||||
<article className="ecom-video-tree-node ecom-video-tree-node--image">
|
||||
<div className="ecom-video-tree-node__placeholder">
|
||||
{stage === "planning" ? <LoadingOutlined /> : <span>待生成</span>}
|
||||
</div>
|
||||
<span className="ecom-video-tree-node__tag">分镜图{n}</span>
|
||||
</article>
|
||||
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</div>
|
||||
<article className="ecom-video-tree-node ecom-video-tree-node--video">
|
||||
<div className="ecom-video-tree-node__placeholder">
|
||||
{stage === "planning" ? <LoadingOutlined /> : <span>待生成</span>}
|
||||
</div>
|
||||
<span className="ecom-video-tree-node__tag">分镜视频{n}</span>
|
||||
</article>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user