feat: 电商页面 KeepAlive 保活机制,切换页面不再丢失生成状态
通过 display:none 模式实现轻量 KeepAlive,电商页面首次访问后保持挂载, 切换到其他页面再切回时所有右侧面板状态(上传图片、生成进度、结果)完整保留。 同时清理项目中的临时文件和本地冗余图片。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2064,47 +2064,168 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{status === "done" ? (
|
||||
<section className="clone-ai-preview-showcase" aria-label="生成结果">
|
||||
<button type="button" className="clone-ai-main-result" onClick={() => openProductSetPreview(cloneOutput === "set" ? clonePreviewCards[0] : results[0])}>
|
||||
<img src={productImages[0]?.src ?? (cloneOutput === "set" ? clonePreviewCards[0].src : results[0]?.src ?? "")} alt="上传商品原图" />
|
||||
<span>原图素材</span>
|
||||
</button>
|
||||
<div className="clone-ai-flow-arrow" aria-hidden="true" />
|
||||
<div className="clone-ai-result-grid result-reveal">
|
||||
{cloneOutput === "set" ? (
|
||||
clonePreviewCards.map((card) => (
|
||||
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
|
||||
<img src={card.src} alt={card.label} />
|
||||
<span>{card.label}</span>
|
||||
</button>
|
||||
))
|
||||
) : results[0]?.src ? (
|
||||
<button type="button" onClick={() => openProductSetPreview(results[0])}>
|
||||
<img src={results[0].src} alt={selectedCloneOutput.label} />
|
||||
<span>{selectedCloneOutput.label}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="clone-ai-empty-state" aria-live="polite">
|
||||
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <FrownOutlined /> : <FileImageOutlined />}
|
||||
<strong>{status === "generating" ? "正在生成" : status === "failed" ? "生成失败" : "等待生成"}</strong>
|
||||
{status === "generating" ? <EcommerceProgressBar status="generating" label={`${selectedCloneOutput.label}生成`} /> : null}
|
||||
<span>
|
||||
{status === "generating"
|
||||
? `AI 正在为 ${platform} / ${market} 整理${selectedCloneOutput.label}。`
|
||||
: status === "failed"
|
||||
? "请检查网络后点击下方重试"
|
||||
: "上传商品原图并填写信息后,AI 将在这里展示生成结果。"}
|
||||
</span>
|
||||
{status === "failed" && lastFailedActionRef.current ? (
|
||||
<button type="button" className="clone-ai-retry-btn" onClick={lastFailedActionRef.current}>
|
||||
<ReloadOutlined /> 重试
|
||||
</button>
|
||||
{cloneOutput === "video" ? (
|
||||
<>
|
||||
<section className="clone-ai-flow-pipeline" aria-label="生成流程">
|
||||
{/* Source Node — 原图素材 */}
|
||||
<div className="clone-ai-flow-source">
|
||||
<div className="clone-ai-flow-node clone-ai-flow-node--source">
|
||||
{productImages[0]?.src ? (
|
||||
<img src={productImages[0].src} alt="商品原图" />
|
||||
) : (
|
||||
<div className="clone-ai-flow-node__placeholder">
|
||||
<FileImageOutlined />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="clone-ai-flow-node__label">附件原图</span>
|
||||
</div>
|
||||
|
||||
{/* Connector — 分支连接线 */}
|
||||
<div className="clone-ai-flow-connector" aria-hidden="true">
|
||||
<div className="clone-ai-flow-connector__trunk" />
|
||||
<div className="clone-ai-flow-connector__branches">
|
||||
<div className="clone-ai-flow-connector__branch" />
|
||||
<div className="clone-ai-flow-connector__branch" />
|
||||
<div className="clone-ai-flow-connector__branch" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Branches — 生成路径分支 */}
|
||||
{status === "done" ? (
|
||||
<div className="clone-ai-flow-branches">
|
||||
{results[0]?.src ? (
|
||||
<div className="clone-ai-flow-branch">
|
||||
<div className="clone-ai-flow-node clone-ai-flow-node--text">
|
||||
<div className="clone-ai-flow-node__text-content">
|
||||
<span className="clone-ai-flow-node__text-title">{selectedCloneOutput.label}</span>
|
||||
<span className="clone-ai-flow-node__text-desc">{requirement || "AI智能生成"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="clone-ai-flow-arrow" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
className="clone-ai-flow-node clone-ai-flow-node--result"
|
||||
onClick={() => openProductSetPreview(results[0])}
|
||||
>
|
||||
<img src={results[0].src} alt={selectedCloneOutput.label} />
|
||||
<span className="clone-ai-flow-node__tag">{selectedCloneOutput.label}</span>
|
||||
</button>
|
||||
<div className="clone-ai-flow-arrow" aria-hidden="true" />
|
||||
<div className="clone-ai-flow-node clone-ai-flow-node--video">
|
||||
<img src={results[0].src} alt="分镜视频" />
|
||||
<span className="clone-ai-flow-node__tag clone-ai-flow-node__tag--accent">分镜视频</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="clone-ai-flow-branches clone-ai-flow-branches--empty">
|
||||
{[1, 2, 3].map((branchIndex) => (
|
||||
<div
|
||||
key={branchIndex}
|
||||
className={`clone-ai-flow-branch${status === "generating" ? " is-generating" : ""}${status === "failed" ? " is-failed" : ""}`}
|
||||
>
|
||||
<div className="clone-ai-flow-node clone-ai-flow-node--text">
|
||||
<div className="clone-ai-flow-node__text-content">
|
||||
<span className="clone-ai-flow-node__text-title">分镜文本{branchIndex}</span>
|
||||
<span className="clone-ai-flow-node__text-desc">
|
||||
{status === "generating" ? "AI 解析中..." : "等待生成"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="clone-ai-flow-arrow" aria-hidden="true" />
|
||||
<div className="clone-ai-flow-node clone-ai-flow-node--result">
|
||||
<div className="clone-ai-flow-node__placeholder">
|
||||
{status === "generating" ? <LoadingOutlined /> : <FileImageOutlined />}
|
||||
</div>
|
||||
<span className="clone-ai-flow-node__tag">分镜图{branchIndex}</span>
|
||||
</div>
|
||||
<div className="clone-ai-flow-arrow" aria-hidden="true" />
|
||||
<div className="clone-ai-flow-node clone-ai-flow-node--video">
|
||||
<div className="clone-ai-flow-node__placeholder">
|
||||
{status === "generating" ? <LoadingOutlined /> : <FileImageOutlined />}
|
||||
</div>
|
||||
<span className="clone-ai-flow-node__tag">分镜视频{branchIndex}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Status Overlay — 生成状态覆盖层 */}
|
||||
{status !== "done" ? (
|
||||
<section className="clone-ai-flow-status" aria-live="polite">
|
||||
{status === "generating" ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ fontSize: 28 }} />
|
||||
<strong>正在生成</strong>
|
||||
<EcommerceProgressBar status="generating" label={`${selectedCloneOutput.label}生成`} />
|
||||
<span>AI 正在为 {platform} / {market} 整理{selectedCloneOutput.label}。</span>
|
||||
</>
|
||||
) : status === "failed" ? (
|
||||
<>
|
||||
<FrownOutlined style={{ fontSize: 28 }} />
|
||||
<strong>生成失败</strong>
|
||||
<span>请检查网络后点击下方重试</span>
|
||||
{lastFailedActionRef.current ? (
|
||||
<button type="button" className="clone-ai-retry-btn" onClick={lastFailedActionRef.current}>
|
||||
<ReloadOutlined /> 重试
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<span>上传商品原图并填写信息后,AI 将在这里展示生成结果。</span>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{status === "done" ? (
|
||||
<section className="clone-ai-preview-showcase" aria-label="生成结果">
|
||||
<button type="button" className="clone-ai-main-result" onClick={() => openProductSetPreview(cloneOutput === "set" ? clonePreviewCards[0] : results[0])}>
|
||||
<img src={productImages[0]?.src ?? (cloneOutput === "set" ? clonePreviewCards[0].src : results[0]?.src ?? "")} alt="上传商品原图" />
|
||||
<span>原图素材</span>
|
||||
</button>
|
||||
<div className="clone-ai-flow-arrow" aria-hidden="true" />
|
||||
<div className="clone-ai-result-grid result-reveal">
|
||||
{cloneOutput === "set" ? (
|
||||
clonePreviewCards.map((card) => (
|
||||
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
|
||||
<img src={card.src} alt={card.label} />
|
||||
<span>{card.label}</span>
|
||||
</button>
|
||||
))
|
||||
) : results[0]?.src ? (
|
||||
<button type="button" onClick={() => openProductSetPreview(results[0])}>
|
||||
<img src={results[0].src} alt={selectedCloneOutput.label} />
|
||||
<span>{selectedCloneOutput.label}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="clone-ai-empty-state" aria-live="polite">
|
||||
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <FrownOutlined /> : <FileImageOutlined />}
|
||||
<strong>{status === "generating" ? "正在生成" : status === "failed" ? "生成失败" : "等待生成"}</strong>
|
||||
{status === "generating" ? <EcommerceProgressBar status="generating" label={`${selectedCloneOutput.label}生成`} /> : null}
|
||||
<span>
|
||||
{status === "generating"
|
||||
? `AI 正在为 ${platform} / ${market} 整理${selectedCloneOutput.label}。`
|
||||
: status === "failed"
|
||||
? "请检查网络后点击下方重试"
|
||||
: "上传商品原图并填写信息后,AI 将在这里展示生成结果。"}
|
||||
</span>
|
||||
{status === "failed" && lastFailedActionRef.current ? (
|
||||
<button type="button" className="clone-ai-retry-btn" onClick={lastFailedActionRef.current}>
|
||||
<ReloadOutlined /> 重试
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<section className="clone-ai-bottom-input" aria-label="信息详情">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export const ECOMMERCE_SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
||||
export const ECOMMERCE_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export interface EcommerceImageValidationResult {
|
||||
accepted: File[];
|
||||
rejected: Array<{ name: string; reason: string }>;
|
||||
}
|
||||
|
||||
export function validateEcommerceImageFiles(files: File[]): EcommerceImageValidationResult {
|
||||
const accepted: File[] = [];
|
||||
const rejected: EcommerceImageValidationResult["rejected"] = [];
|
||||
|
||||
files.forEach((file) => {
|
||||
if (!ECOMMERCE_SUPPORTED_IMAGE_TYPES.has(file.type)) {
|
||||
rejected.push({ name: file.name, reason: "不支持的图片格式" });
|
||||
return;
|
||||
}
|
||||
if (file.size > ECOMMERCE_MAX_IMAGE_BYTES) {
|
||||
rejected.push({ name: file.name, reason: "图片超过 10MB" });
|
||||
return;
|
||||
}
|
||||
accepted.push(file);
|
||||
});
|
||||
|
||||
return { accepted, rejected };
|
||||
}
|
||||
|
||||
export function summarizeRejectedImages(rejected: EcommerceImageValidationResult["rejected"]): string {
|
||||
if (!rejected.length) return "";
|
||||
const first = rejected[0];
|
||||
const suffix = rejected.length > 1 ? ` 等 ${rejected.length} 个文件` : "";
|
||||
return `${first.name}${suffix} 已跳过:${first.reason}`;
|
||||
}
|
||||
|
||||
export function normalizeEcommerceImageMime(type: string): string {
|
||||
return ECOMMERCE_SUPPORTED_IMAGE_TYPES.has(type) ? type : "image/png";
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
import {
|
||||
CloudUploadOutlined,
|
||||
CloseOutlined,
|
||||
FileImageOutlined,
|
||||
LoadingOutlined,
|
||||
QuestionCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SettingOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ChangeEvent, DragEvent, MutableRefObject, RefObject } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
type CloneOutputKey = string;
|
||||
type CloneSetCountKey = string;
|
||||
type CloneModelPanelTab = "scene" | "model";
|
||||
type CloneReferenceMode = "upload" | "link";
|
||||
type CloneReplicateLevelKey = string;
|
||||
type CloneVideoQualityKey = string;
|
||||
|
||||
interface CloneImageItem {
|
||||
id: string;
|
||||
src: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CloneBasicSelectItem {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
options: string[];
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
interface CloneModelSelectItem {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
options: string[];
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
interface CloneSetCountOption {
|
||||
key: CloneSetCountKey;
|
||||
title: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
interface CloneOutputOption {
|
||||
key: CloneOutputKey;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface CloneReplicateLevelOption {
|
||||
key: CloneReplicateLevelKey;
|
||||
title: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
interface CloneVideoQualityOption {
|
||||
key: CloneVideoQualityKey;
|
||||
label: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
interface CloneDetailModule {
|
||||
id: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
interface EcommerceClonePanelProps {
|
||||
productInputRef: RefObject<HTMLInputElement>;
|
||||
cloneReferenceInputRef: RefObject<HTMLInputElement>;
|
||||
productImages: CloneImageItem[];
|
||||
isProductUploadDragging: boolean;
|
||||
cloneOutput: CloneOutputKey;
|
||||
cloneOutputOptions: CloneOutputOption[];
|
||||
cloneBasicSelects: CloneBasicSelectItem[];
|
||||
openCloneBasicSelect: string | null;
|
||||
cloneReferenceMode: CloneReferenceMode;
|
||||
cloneReferenceImages: CloneImageItem[];
|
||||
maxCloneReferenceImages: number;
|
||||
cloneReplicateLevel: CloneReplicateLevelKey;
|
||||
cloneReplicateLevelOptions: CloneReplicateLevelOption[];
|
||||
cloneSetCounts: Record<CloneSetCountKey, number>;
|
||||
cloneSetCountOptions: CloneSetCountOption[];
|
||||
cloneSetTotal: number;
|
||||
minCloneSetTotal: number;
|
||||
maxCloneSetTotal: number;
|
||||
selectedCloneDetailModules: string[];
|
||||
cloneDetailModules: CloneDetailModule[];
|
||||
cloneModelPanelTab: CloneModelPanelTab;
|
||||
tryOnScenes: string[];
|
||||
selectedCloneModelScenes: string[];
|
||||
cloneModelCustomScene: string;
|
||||
cloneModelSelects: CloneModelSelectItem[];
|
||||
openCloneModelSelect: string | null;
|
||||
cloneModelSelectDropUp: boolean;
|
||||
cloneModelAppearance: string;
|
||||
cloneVideoQuality: CloneVideoQualityKey;
|
||||
cloneVideoQualityOptions: CloneVideoQualityOption[];
|
||||
cloneVideoDuration: number;
|
||||
cloneVideoDurationMin: number;
|
||||
cloneVideoDurationMax: number;
|
||||
cloneVideoDurationStyle: { [key: string]: number | string };
|
||||
cloneVideoSmart: boolean;
|
||||
canGenerate: boolean;
|
||||
status: string;
|
||||
lastFailedActionRef: MutableRefObject<(() => void) | null>;
|
||||
setIsProductUploadDragging: (value: boolean) => void;
|
||||
handleProductDrop: (event: DragEvent<HTMLElement>) => void;
|
||||
removeProductImage: (id: string) => void;
|
||||
handleProductUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
handleCloneOutputChange: (value: CloneOutputKey) => void;
|
||||
setOpenCloneBasicSelect: (value: string | null) => void;
|
||||
setCloneReferenceMode: (value: CloneReferenceMode) => void;
|
||||
handleCloneReferenceUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
setCloneReplicateLevel: (value: CloneReplicateLevelKey) => void;
|
||||
startCloneSetCountHold: (key: CloneSetCountKey, delta: number, disabled: boolean) => void;
|
||||
clearCloneSetCountHold: () => void;
|
||||
toggleCloneDetailModule: (id: string) => void;
|
||||
setCloneModelPanelTab: (value: CloneModelPanelTab) => void;
|
||||
toggleCloneModelScene: (scene: string) => void;
|
||||
setCloneModelCustomScene: (value: string) => void;
|
||||
setOpenCloneModelSelect: (value: string | null) => void;
|
||||
setCloneModelSelectDropUp: (value: boolean) => void;
|
||||
setCloneModelAppearance: (value: string) => void;
|
||||
setCloneVideoQuality: (value: CloneVideoQualityKey) => void;
|
||||
setCloneVideoDuration: (value: number) => void;
|
||||
clampCloneVideoDuration: (value: number) => number;
|
||||
setCloneVideoSmart: (updater: (current: boolean) => boolean) => void;
|
||||
handleGenerate: () => void;
|
||||
formatRatioDisplayValue: (value: string) => string;
|
||||
setVideoOutfitFiles?: (video: File | null, ref: File | null) => void;
|
||||
}
|
||||
|
||||
export default function EcommerceClonePanel({
|
||||
productInputRef,
|
||||
cloneReferenceInputRef,
|
||||
productImages,
|
||||
isProductUploadDragging,
|
||||
cloneOutput,
|
||||
cloneOutputOptions,
|
||||
cloneBasicSelects,
|
||||
openCloneBasicSelect,
|
||||
cloneReferenceMode,
|
||||
cloneReferenceImages,
|
||||
maxCloneReferenceImages,
|
||||
cloneReplicateLevel,
|
||||
cloneReplicateLevelOptions,
|
||||
cloneSetCounts,
|
||||
cloneSetCountOptions,
|
||||
cloneSetTotal,
|
||||
minCloneSetTotal,
|
||||
maxCloneSetTotal,
|
||||
selectedCloneDetailModules,
|
||||
cloneDetailModules,
|
||||
cloneModelPanelTab,
|
||||
tryOnScenes,
|
||||
selectedCloneModelScenes,
|
||||
cloneModelCustomScene,
|
||||
cloneModelSelects,
|
||||
openCloneModelSelect,
|
||||
cloneModelSelectDropUp,
|
||||
cloneModelAppearance,
|
||||
cloneVideoQuality,
|
||||
cloneVideoQualityOptions,
|
||||
cloneVideoDuration,
|
||||
cloneVideoDurationMin,
|
||||
cloneVideoDurationMax,
|
||||
cloneVideoDurationStyle,
|
||||
cloneVideoSmart,
|
||||
canGenerate,
|
||||
status,
|
||||
lastFailedActionRef,
|
||||
setIsProductUploadDragging,
|
||||
handleProductDrop,
|
||||
removeProductImage,
|
||||
handleProductUpload,
|
||||
handleCloneOutputChange,
|
||||
setOpenCloneBasicSelect,
|
||||
setCloneReferenceMode,
|
||||
handleCloneReferenceUpload,
|
||||
setCloneReplicateLevel,
|
||||
startCloneSetCountHold,
|
||||
clearCloneSetCountHold,
|
||||
toggleCloneDetailModule,
|
||||
setCloneModelPanelTab,
|
||||
toggleCloneModelScene,
|
||||
setCloneModelCustomScene,
|
||||
setOpenCloneModelSelect,
|
||||
setCloneModelSelectDropUp,
|
||||
setCloneModelAppearance,
|
||||
setCloneVideoQuality,
|
||||
setCloneVideoDuration,
|
||||
clampCloneVideoDuration,
|
||||
setCloneVideoSmart,
|
||||
handleGenerate,
|
||||
formatRatioDisplayValue,
|
||||
setVideoOutfitFiles,
|
||||
}: EcommerceClonePanelProps) {
|
||||
const videoOutfitVideoRef = useRef<HTMLInputElement>(null);
|
||||
const videoOutfitRefRef = useRef<HTMLInputElement>(null);
|
||||
const [videoOutfitVideoUrl, setVideoOutfitVideoUrl] = useState<string | null>(null);
|
||||
const [videoOutfitRefUrl, setVideoOutfitRefUrl] = useState<string | null>(null);
|
||||
|
||||
const handleVideoOutfitVideoChange = () => {
|
||||
const file = videoOutfitVideoRef.current?.files?.[0] || null;
|
||||
if (file) setVideoOutfitVideoUrl(URL.createObjectURL(file));
|
||||
setVideoOutfitFiles?.(file, videoOutfitRefRef.current?.files?.[0] || null);
|
||||
};
|
||||
|
||||
const handleVideoOutfitRefChange = () => {
|
||||
const file = videoOutfitRefRef.current?.files?.[0] || null;
|
||||
if (file) setVideoOutfitRefUrl(URL.createObjectURL(file));
|
||||
setVideoOutfitFiles?.(videoOutfitVideoRef.current?.files?.[0] || null, file);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="product-clone-panel__scroll clone-ai-panel">
|
||||
<header className="clone-ai-logo">
|
||||
<span className="clone-ai-logo__mark">AI</span>
|
||||
<strong>电商生成</strong>
|
||||
</header>
|
||||
|
||||
<section className="clone-ai-card">
|
||||
<h2>
|
||||
<CloudUploadOutlined />
|
||||
上传商品原图
|
||||
</h2>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`clone-ai-upload-zone${isProductUploadDragging ? " is-dragging" : ""}`}
|
||||
onClick={() => productInputRef.current?.click()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
productInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault();
|
||||
setIsProductUploadDragging(true);
|
||||
}}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDragLeave={() => setIsProductUploadDragging(false)}
|
||||
onDrop={handleProductDrop}
|
||||
>
|
||||
<div className="clone-ai-upload-main">
|
||||
<span className="clone-ai-upload-icon">
|
||||
<FileImageOutlined />
|
||||
</span>
|
||||
<span className="clone-ai-upload-title">拖拽或点击上传</span>
|
||||
<strong>
|
||||
<span aria-hidden="true">+</span>
|
||||
上传图片
|
||||
</strong>
|
||||
<span className="clone-ai-upload-hint">同一产品,最多 7 张</span>
|
||||
</div>
|
||||
{productImages.length ? (
|
||||
<div className="clone-ai-uploaded-files" aria-label="已上传商品原图">
|
||||
{productImages.map((item) => (
|
||||
<figure key={item.id} className="clone-ai-uploaded-file">
|
||||
<img src={item.src} alt={item.name} />
|
||||
<span className="uploaded-image-zoom" aria-hidden="true">
|
||||
<img src={item.src} alt="" />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
removeProductImage(item.id);
|
||||
}}
|
||||
aria-label={`删除${item.name}`}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<input ref={productInputRef} type="file" accept="image/*" multiple onChange={handleProductUpload} />
|
||||
</section>
|
||||
|
||||
<section className="clone-ai-card">
|
||||
<h2>
|
||||
<SettingOutlined />
|
||||
生成设置
|
||||
</h2>
|
||||
<div className="clone-ai-settings-section">
|
||||
<span className="clone-ai-settings-label">生成内容</span>
|
||||
<div className="clone-ai-tag-group" role="radiogroup" aria-label="生成内容">
|
||||
{cloneOutputOptions.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
className={cloneOutput === option.key ? "is-active" : ""}
|
||||
aria-pressed={cloneOutput === option.key}
|
||||
onClick={() => handleCloneOutputChange(option.key)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="clone-ai-settings-section">
|
||||
<span className="clone-ai-settings-label">基础设置</span>
|
||||
<div className="clone-ai-select-group">
|
||||
{cloneBasicSelects.map((item) => {
|
||||
const hasMultipleOptions = item.options.length > 1;
|
||||
const isOpen = hasMultipleOptions && openCloneBasicSelect === item.key;
|
||||
return (
|
||||
<div key={item.key} className="clone-ai-basic-select" data-clone-basic-select>
|
||||
<button
|
||||
type="button"
|
||||
className={`${isOpen ? "is-open" : ""}${hasMultipleOptions ? "" : " is-static"}`}
|
||||
aria-expanded={hasMultipleOptions ? isOpen : undefined}
|
||||
aria-haspopup={hasMultipleOptions ? "listbox" : undefined}
|
||||
aria-controls={hasMultipleOptions ? `clone-basic-select-${item.key}` : undefined}
|
||||
onClick={() => setOpenCloneBasicSelect(hasMultipleOptions ? (isOpen ? null : item.key) : null)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<strong>{item.key === "ratio" ? formatRatioDisplayValue(item.value) : item.value}</strong>
|
||||
{hasMultipleOptions ? <i aria-hidden="true" /> : null}
|
||||
</button>
|
||||
{hasMultipleOptions && isOpen ? (
|
||||
<div id={`clone-basic-select-${item.key}`} className="clone-ai-basic-select__menu" role="listbox">
|
||||
{item.options.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
className={item.value === option ? "is-active" : ""}
|
||||
role="option"
|
||||
aria-selected={item.value === option}
|
||||
onClick={() => {
|
||||
item.onChange(option);
|
||||
setOpenCloneBasicSelect(null);
|
||||
}}
|
||||
>
|
||||
{item.key === "ratio" ? formatRatioDisplayValue(option) : option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{cloneOutput === "hot" ? (
|
||||
<section className="clone-ai-replicate-panel" aria-label="爆款图复刻设置">
|
||||
<div className="clone-ai-replicate-section">
|
||||
<span className="clone-ai-replicate-title">参考内容</span>
|
||||
<div className="clone-ai-replicate-tabs" role="tablist" aria-label="参考内容来源">
|
||||
<button
|
||||
type="button"
|
||||
className={cloneReferenceMode === "upload" ? "is-active" : ""}
|
||||
aria-selected={cloneReferenceMode === "upload"}
|
||||
onClick={() => setCloneReferenceMode("upload")}
|
||||
>
|
||||
上传参考图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cloneReferenceMode === "link" ? "is-active" : ""}
|
||||
aria-selected={cloneReferenceMode === "link"}
|
||||
onClick={() => setCloneReferenceMode("link")}
|
||||
>
|
||||
导入链接
|
||||
</button>
|
||||
</div>
|
||||
{cloneReferenceMode === "upload" ? (
|
||||
<button type="button" className="clone-ai-replicate-upload" onClick={() => cloneReferenceInputRef.current?.click()}>
|
||||
<span>
|
||||
<CloudUploadOutlined />
|
||||
<span className="clone-ai-replicate-upload-text">添加图片</span>
|
||||
</span>
|
||||
<em>{cloneReferenceImages.length ? `已选 ${cloneReferenceImages.length}/${maxCloneReferenceImages}` : `最多 ${maxCloneReferenceImages} 张`}</em>
|
||||
{cloneReferenceImages.length ? (
|
||||
<div className="clone-ai-replicate-preview" aria-hidden="true">
|
||||
{cloneReferenceImages.slice(0, 4).map((item) => (
|
||||
<figure key={item.id}>
|
||||
<img src={item.src} alt="" />
|
||||
<span className="uploaded-image-zoom">
|
||||
<img src={item.src} alt="" />
|
||||
</span>
|
||||
</figure>
|
||||
))}
|
||||
{cloneReferenceImages.length > 4 ? <b>+{cloneReferenceImages.length - 4}</b> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
) : (
|
||||
<label className="clone-ai-replicate-link">
|
||||
<input placeholder="粘贴商品图或详情页链接" />
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={cloneReferenceInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
onChange={handleCloneReferenceUpload}
|
||||
/>
|
||||
</div>
|
||||
<div className="clone-ai-replicate-section">
|
||||
<span className="clone-ai-replicate-title">复刻程度</span>
|
||||
<div className="clone-ai-replicate-levels" role="radiogroup" aria-label="复刻程度">
|
||||
{cloneReplicateLevelOptions.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
className={cloneReplicateLevel === option.key ? "is-active" : ""}
|
||||
aria-pressed={cloneReplicateLevel === option.key}
|
||||
onClick={() => setCloneReplicateLevel(option.key)}
|
||||
>
|
||||
<strong>{option.title}</strong>
|
||||
<span>{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{cloneOutput === "set" ? (
|
||||
<section className="clone-ai-count-panel" aria-label="套图图片数量">
|
||||
<p>可自由调整各类型图片数量,总数 1-16 张</p>
|
||||
<div className="clone-ai-count-list">
|
||||
{cloneSetCountOptions.map((item) => {
|
||||
const count = cloneSetCounts[item.key];
|
||||
const decrementDisabled = count <= 0 || cloneSetTotal <= minCloneSetTotal;
|
||||
const incrementDisabled = cloneSetTotal >= maxCloneSetTotal;
|
||||
return (
|
||||
<div key={item.key} className="clone-ai-count-row">
|
||||
<div className="clone-ai-count-copy">
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.desc}</span>
|
||||
</div>
|
||||
<div className="clone-ai-count-stepper" aria-label={`${item.title}数量`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={decrementDisabled}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
startCloneSetCountHold(item.key, -1, decrementDisabled);
|
||||
}}
|
||||
onPointerUp={clearCloneSetCountHold}
|
||||
onPointerLeave={clearCloneSetCountHold}
|
||||
onPointerCancel={clearCloneSetCountHold}
|
||||
onBlur={clearCloneSetCountHold}
|
||||
aria-label={`减少${item.title}`}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<b>{count}</b>
|
||||
<button
|
||||
type="button"
|
||||
disabled={incrementDisabled}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
startCloneSetCountHold(item.key, 1, incrementDisabled);
|
||||
}}
|
||||
onPointerUp={clearCloneSetCountHold}
|
||||
onPointerLeave={clearCloneSetCountHold}
|
||||
onPointerCancel={clearCloneSetCountHold}
|
||||
onBlur={clearCloneSetCountHold}
|
||||
aria-label={`增加${item.title}`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{cloneOutput === "detail" ? (
|
||||
<section className="clone-ai-module-panel" aria-label="详情图包含模块">
|
||||
<p>
|
||||
包含模块(多选)
|
||||
<QuestionCircleOutlined />
|
||||
</p>
|
||||
<div className="clone-ai-module-list">
|
||||
{cloneDetailModules.map((module) => {
|
||||
const isSelected = selectedCloneDetailModules.includes(module.id);
|
||||
return (
|
||||
<button
|
||||
key={module.id}
|
||||
type="button"
|
||||
className={isSelected ? "is-active" : ""}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => toggleCloneDetailModule(module.id)}
|
||||
>
|
||||
<strong>{module.title}</strong>
|
||||
<span>{module.desc}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{cloneOutput === "model" ? (
|
||||
<section className="clone-ai-model-panel" aria-label="模特图设置">
|
||||
<div className="clone-ai-model-tabs" role="tablist" aria-label="模特图设置类型">
|
||||
<button
|
||||
type="button"
|
||||
className={cloneModelPanelTab === "scene" ? "is-active" : ""}
|
||||
aria-selected={cloneModelPanelTab === "scene"}
|
||||
onClick={() => setCloneModelPanelTab("scene")}
|
||||
>
|
||||
拍摄场景
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cloneModelPanelTab === "model" ? "is-active" : ""}
|
||||
aria-selected={cloneModelPanelTab === "model"}
|
||||
onClick={() => setCloneModelPanelTab("model")}
|
||||
>
|
||||
模特形象
|
||||
</button>
|
||||
</div>
|
||||
<div className="clone-ai-model-scroll">
|
||||
{cloneModelPanelTab === "scene" ? (
|
||||
<div className="clone-ai-model-scenes">
|
||||
<div className="clone-ai-model-scene-grid">
|
||||
{tryOnScenes.map((scene) => {
|
||||
const isSelected = selectedCloneModelScenes.includes(scene);
|
||||
return (
|
||||
<button
|
||||
key={scene}
|
||||
type="button"
|
||||
className={isSelected ? "is-active" : ""}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => toggleCloneModelScene(scene)}
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
{scene}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<label className="clone-ai-model-textarea">
|
||||
<strong>或自定义描述场景(可选)</strong>
|
||||
<textarea
|
||||
value={cloneModelCustomScene}
|
||||
onChange={(event) => setCloneModelCustomScene(event.target.value)}
|
||||
placeholder="描述你想要的场景:如秋季枫叶小径、暖色调午后阳光、模特倚靠树干..."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="clone-ai-model-profile">
|
||||
<div className="clone-ai-model-select-grid">
|
||||
{cloneModelSelects.map((item) => {
|
||||
const isOpen = openCloneModelSelect === item.key;
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`clone-ai-model-select${isOpen ? " is-open" : ""}${
|
||||
isOpen && cloneModelSelectDropUp ? " is-drop-up" : ""
|
||||
}`}
|
||||
data-clone-model-select
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={isOpen ? "is-open" : ""}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={`clone-model-select-${item.key}`}
|
||||
onClick={(event) => {
|
||||
setOpenCloneBasicSelect(null);
|
||||
if (!isOpen) {
|
||||
event.currentTarget.scrollIntoView({ block: "center", inline: "nearest" });
|
||||
const triggerRect = event.currentTarget.getBoundingClientRect();
|
||||
const scrollRect = event.currentTarget.closest(".clone-ai-model-scroll")?.getBoundingClientRect();
|
||||
const lowerBoundary = Math.min(window.innerHeight, scrollRect?.bottom ?? window.innerHeight);
|
||||
const upperBoundary = Math.max(0, scrollRect?.top ?? 0);
|
||||
const estimatedMenuHeight = Math.min(150, item.options.length * 36 + 12);
|
||||
const belowSpace = lowerBoundary - triggerRect.bottom;
|
||||
const aboveSpace = triggerRect.top - upperBoundary;
|
||||
setCloneModelSelectDropUp(belowSpace < estimatedMenuHeight && aboveSpace > belowSpace);
|
||||
} else {
|
||||
setCloneModelSelectDropUp(false);
|
||||
}
|
||||
setOpenCloneModelSelect(isOpen ? null : item.key);
|
||||
}}
|
||||
>
|
||||
<strong>{item.value}</strong>
|
||||
<i aria-hidden="true" />
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div id={`clone-model-select-${item.key}`} className="clone-ai-model-select__menu" role="listbox">
|
||||
{item.options.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
className={item.value === option ? "is-active" : ""}
|
||||
role="option"
|
||||
aria-selected={item.value === option}
|
||||
onClick={() => {
|
||||
item.onChange(option);
|
||||
setOpenCloneModelSelect(null);
|
||||
setCloneModelSelectDropUp(false);
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<label className="clone-ai-model-textarea">
|
||||
<strong>外貌细节(可选)</strong>
|
||||
<textarea
|
||||
value={cloneModelAppearance}
|
||||
onChange={(event) => setCloneModelAppearance(event.target.value)}
|
||||
placeholder="例如:小麦色皮肤、齐刘海、眼角有泪痣..."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{cloneOutput === "video" ? (
|
||||
<section className="clone-ai-video-panel" aria-label="短视频设置">
|
||||
<div className="clone-ai-video-section">
|
||||
<span className="clone-ai-video-title">视频画质</span>
|
||||
<div className="clone-ai-video-options">
|
||||
{cloneVideoQualityOptions.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
className={cloneVideoQuality === option.key ? "is-active" : ""}
|
||||
aria-pressed={cloneVideoQuality === option.key}
|
||||
onClick={() => setCloneVideoQuality(option.key)}
|
||||
>
|
||||
<strong>{option.label}</strong>
|
||||
<span>{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="clone-ai-video-section">
|
||||
<div className="clone-ai-video-title-row">
|
||||
<span className="clone-ai-video-title">时间设置</span>
|
||||
<strong>{cloneVideoDuration}秒</strong>
|
||||
</div>
|
||||
<div className="clone-ai-duration-control" style={cloneVideoDurationStyle}>
|
||||
<input
|
||||
type="range"
|
||||
min={cloneVideoDurationMin}
|
||||
max={cloneVideoDurationMax}
|
||||
step={1}
|
||||
value={cloneVideoDuration}
|
||||
onChange={(event) => setCloneVideoDuration(clampCloneVideoDuration(Number(event.target.value)))}
|
||||
aria-label="短视频时长"
|
||||
/>
|
||||
<div className="clone-ai-duration-scale" aria-hidden="true">
|
||||
<span>5秒</span>
|
||||
<span>10秒</span>
|
||||
<span>15秒</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`clone-ai-video-smart${cloneVideoSmart ? " is-on" : ""}`}
|
||||
aria-pressed={cloneVideoSmart}
|
||||
onClick={() => setCloneVideoSmart((current) => !current)}
|
||||
>
|
||||
<span>
|
||||
<strong>智能选择</strong>
|
||||
<em>根据平台、商品图和尺寸自动匹配推荐参数</em>
|
||||
</span>
|
||||
<i aria-hidden="true" />
|
||||
</button>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{cloneOutput === "video-outfit" ? (
|
||||
<section className="clone-ai-video-panel" aria-label="视频换装">
|
||||
<div className="clone-ai-video-section">
|
||||
<span className="clone-ai-video-title">上传原始视频</span>
|
||||
<div className="clone-ai-video-outfit-upload">
|
||||
<input
|
||||
ref={videoOutfitVideoRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
onChange={handleVideoOutfitVideoChange}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitVideoRef.current?.click()}>
|
||||
{videoOutfitVideoUrl ? "重新选择视频" : "选择视频文件"}
|
||||
</button>
|
||||
{videoOutfitVideoUrl ? <span className="clone-ai-video-outfit-info">已选择视频</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="clone-ai-video-section">
|
||||
<span className="clone-ai-video-title">上传参考图(素材/服装)</span>
|
||||
<div className="clone-ai-video-outfit-upload">
|
||||
<input
|
||||
ref={videoOutfitRefRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleVideoOutfitRefChange}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitRefRef.current?.click()}>
|
||||
{videoOutfitRefUrl ? "重新选择参考图" : "选择参考图"}
|
||||
</button>
|
||||
{videoOutfitRefUrl ? <span className="clone-ai-video-outfit-info">已选择参考图</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<button type="button" className="clone-ai-generate" disabled={!canGenerate || cloneOutput === "video"} onClick={status === "failed" && lastFailedActionRef.current ? lastFailedActionRef.current : handleGenerate} style={cloneOutput === "video" ? { display: "none" } : undefined}>
|
||||
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <ReloadOutlined /> : null}
|
||||
{status === "generating" ? "生成中..." : status === "failed" ? "重新生成" : cloneOutput === "video-outfit" ? "✦ 开始换装" : "✦ 开始生成"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons";
|
||||
import type { ChangeEvent, RefObject } from "react";
|
||||
import { EcommerceProgressBar } from "../EcommerceProgressBar";
|
||||
|
||||
interface EcommerceDetailPanelProps {
|
||||
detailInputRef: RefObject<HTMLInputElement>;
|
||||
detailProductImages: Array<{ id: string; src: string; name: string }>;
|
||||
detailPlatform: string;
|
||||
detailMarket: string;
|
||||
detailLanguage: string;
|
||||
detailType: string;
|
||||
detailRequirement: string;
|
||||
selectedDetailModules: string[];
|
||||
detailStatus: string;
|
||||
canGenerateDetail: boolean;
|
||||
detailPrimaryLabel: string;
|
||||
platformOptions: string[];
|
||||
marketOptions: string[];
|
||||
detailLanguageOptions: string[];
|
||||
detailTypeOptions: string[];
|
||||
detailModules: Array<{ id: string; title: string; desc: string }>;
|
||||
handleDetailUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
handleDetailPlatformChange: (value: string) => void;
|
||||
handleDetailMarketChange: (value: string) => void;
|
||||
setDetailLanguage: (value: string) => void;
|
||||
setDetailType: (value: string) => void;
|
||||
setDetailRequirement: (value: string) => void;
|
||||
handleDetailAiWrite: () => void;
|
||||
toggleDetailModule: (id: string) => void;
|
||||
handleDetailGenerate: () => void;
|
||||
}
|
||||
|
||||
export default function EcommerceDetailPanel({
|
||||
detailInputRef,
|
||||
detailProductImages,
|
||||
detailPlatform,
|
||||
detailMarket,
|
||||
detailLanguage,
|
||||
detailType,
|
||||
detailRequirement,
|
||||
selectedDetailModules,
|
||||
detailStatus,
|
||||
canGenerateDetail,
|
||||
detailPrimaryLabel,
|
||||
platformOptions,
|
||||
marketOptions,
|
||||
detailLanguageOptions,
|
||||
detailTypeOptions,
|
||||
detailModules,
|
||||
handleDetailUpload,
|
||||
handleDetailPlatformChange,
|
||||
handleDetailMarketChange,
|
||||
setDetailLanguage,
|
||||
setDetailType,
|
||||
setDetailRequirement,
|
||||
handleDetailAiWrite,
|
||||
toggleDetailModule,
|
||||
handleDetailGenerate,
|
||||
}: EcommerceDetailPanelProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="product-clone-panel__scroll">
|
||||
<section className="product-clone-field">
|
||||
<h2>
|
||||
商品原图
|
||||
<QuestionCircleOutlined />
|
||||
</h2>
|
||||
<button type="button" className="product-clone-upload-zone product-detail-upload" onClick={() => detailInputRef.current?.click()}>
|
||||
<strong>
|
||||
<CloudUploadOutlined />
|
||||
上传图片
|
||||
</strong>
|
||||
<span>同一产品,最多3张。</span>
|
||||
</button>
|
||||
<input ref={detailInputRef} type="file" accept="image/*" multiple onChange={handleDetailUpload} />
|
||||
{detailProductImages.length ? (
|
||||
<div className="product-clone-thumb-row" aria-label="已上传商品原图">
|
||||
{detailProductImages.map((item) => (
|
||||
<figure key={item.id} className="product-clone-uploaded-thumb">
|
||||
<img src={item.src} alt={item.name} />
|
||||
<span className="uploaded-image-zoom" aria-hidden="true">
|
||||
<img src={item.src} alt="" />
|
||||
</span>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="product-clone-field">
|
||||
<h2>生成设置</h2>
|
||||
<div className="product-detail-settings-grid">
|
||||
<select value={detailPlatform} onChange={(event) => handleDetailPlatformChange(event.target.value)}>
|
||||
{platformOptions.map((item) => (
|
||||
<option key={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={detailMarket} onChange={(event) => handleDetailMarketChange(event.target.value)}>
|
||||
{marketOptions.map((item) => (
|
||||
<option key={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={detailLanguage} onChange={(event) => setDetailLanguage(event.target.value)}>
|
||||
{detailLanguageOptions.map((item) => (
|
||||
<option key={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={detailType} onChange={(event) => setDetailType(event.target.value)}>
|
||||
{detailTypeOptions.map((item) => (
|
||||
<option key={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="product-clone-field product-detail-requirement">
|
||||
<h2>
|
||||
商品卖点&要求
|
||||
<QuestionCircleOutlined />
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleDetailAiWrite();
|
||||
}}
|
||||
>
|
||||
AI 帮写
|
||||
</button>
|
||||
</h2>
|
||||
<textarea
|
||||
value={detailRequirement}
|
||||
onChange={(event) => setDetailRequirement(event.target.value)}
|
||||
placeholder={"建议包含以下信息生成更精准:\n1.产品名称\n2.核心卖点\n3.适用人群\n4.期望场景\n5.具体参数"}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="product-clone-field">
|
||||
<h2>
|
||||
包含模块(多选)
|
||||
<QuestionCircleOutlined />
|
||||
</h2>
|
||||
<div className="product-detail-module-grid">
|
||||
{detailModules.map((module) => (
|
||||
<button
|
||||
key={module.id}
|
||||
type="button"
|
||||
className={selectedDetailModules.includes(module.id) ? "is-active" : ""}
|
||||
onClick={() => toggleDetailModule(module.id)}
|
||||
>
|
||||
<strong>{module.title}</strong>
|
||||
<span>{module.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer className="product-clone-panel__footer">
|
||||
{detailStatus === "generating" ? <EcommerceProgressBar status="generating" label="A+详情页" /> : null}
|
||||
<button type="button" className="product-clone-primary" disabled={!canGenerateDetail} onClick={handleDetailGenerate}>
|
||||
{detailStatus === "generating" ? <LoadingOutlined /> : null}
|
||||
{detailPrimaryLabel}
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { CloudUploadOutlined, CloseOutlined, FileImageOutlined, SettingOutlined } from "@ant-design/icons";
|
||||
import type { ChangeEvent, DragEvent, RefObject } from "react";
|
||||
|
||||
interface EcommerceSetPanelProps {
|
||||
setInputRef: RefObject<HTMLInputElement>;
|
||||
setImages: Array<{ id: string; src: string; name: string }>;
|
||||
isSetUploadDragging: boolean;
|
||||
productSetOutputOptions: Array<{ key: string; label: string }>;
|
||||
productSetOutput: string;
|
||||
platformOptions: string[];
|
||||
marketOptions: string[];
|
||||
productSetLanguageOptions: string[];
|
||||
productSetRatioOptions: string[];
|
||||
productSetPlatform: string;
|
||||
productSetMarket: string;
|
||||
productSetLanguage: string;
|
||||
productSetRatio: string;
|
||||
setIsSetUploadDragging: (value: boolean) => void;
|
||||
handleSetDrop: (event: DragEvent<HTMLElement>) => void;
|
||||
handleSetUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
removeSetImage: (id: string) => void;
|
||||
handleProductSetOutputChange: (value: string) => void;
|
||||
handleProductSetPlatformChange: (value: string) => void;
|
||||
handleProductSetMarketChange: (value: string) => void;
|
||||
setProductSetLanguage: (value: string) => void;
|
||||
setProductSetRatio: (value: string) => void;
|
||||
formatRatioDisplayValue: (value: string) => string;
|
||||
}
|
||||
|
||||
export default function EcommerceSetPanel({
|
||||
setInputRef,
|
||||
setImages,
|
||||
isSetUploadDragging,
|
||||
productSetOutputOptions,
|
||||
productSetOutput,
|
||||
platformOptions,
|
||||
marketOptions,
|
||||
productSetLanguageOptions,
|
||||
productSetRatioOptions,
|
||||
productSetPlatform,
|
||||
productSetMarket,
|
||||
productSetLanguage,
|
||||
productSetRatio,
|
||||
setIsSetUploadDragging,
|
||||
handleSetDrop,
|
||||
handleSetUpload,
|
||||
removeSetImage,
|
||||
handleProductSetOutputChange,
|
||||
handleProductSetPlatformChange,
|
||||
handleProductSetMarketChange,
|
||||
setProductSetLanguage,
|
||||
setProductSetRatio,
|
||||
formatRatioDisplayValue,
|
||||
}: EcommerceSetPanelProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="product-clone-panel__scroll">
|
||||
<section className="product-clone-field product-set-upload-section">
|
||||
<h2>
|
||||
上传商品原图
|
||||
<CloudUploadOutlined />
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className={`product-clone-upload-zone product-set-upload${isSetUploadDragging ? " is-dragging" : ""}`}
|
||||
onClick={() => setInputRef.current?.click()}
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault();
|
||||
setIsSetUploadDragging(true);
|
||||
}}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDragLeave={() => setIsSetUploadDragging(false)}
|
||||
onDrop={handleSetDrop}
|
||||
>
|
||||
<span className="product-set-upload-icon">
|
||||
<FileImageOutlined />
|
||||
</span>
|
||||
<span className="product-set-upload-title">拖拽或点击上传</span>
|
||||
<strong>
|
||||
<span aria-hidden="true">+</span>
|
||||
上传图片
|
||||
</strong>
|
||||
<span className="product-set-upload-note">同一产品,最多 3 张</span>
|
||||
</button>
|
||||
<input ref={setInputRef} type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={handleSetUpload} />
|
||||
{setImages.length ? (
|
||||
<div className="product-clone-thumb-row product-set-thumb-row" aria-label="已上传商品原图">
|
||||
{setImages.map((item) => (
|
||||
<figure key={item.id} className="product-set-thumb">
|
||||
<img src={item.src} alt={item.name} />
|
||||
<span className="uploaded-image-zoom" aria-hidden="true">
|
||||
<img src={item.src} alt="" />
|
||||
</span>
|
||||
<button type="button" onClick={() => removeSetImage(item.id)} aria-label={`删除${item.name}`}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="product-clone-field product-set-settings-section">
|
||||
<h2>
|
||||
生成设置
|
||||
<SettingOutlined />
|
||||
</h2>
|
||||
<div className="product-set-setting-block">
|
||||
<span className="product-set-setting-title">生成内容</span>
|
||||
<div className="product-set-output-grid" role="radiogroup" aria-label="生成内容">
|
||||
{productSetOutputOptions.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
className={productSetOutput === option.key ? "is-active" : ""}
|
||||
aria-pressed={productSetOutput === option.key}
|
||||
onClick={() => handleProductSetOutputChange(option.key)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="product-set-setting-block">
|
||||
<span className="product-set-setting-title">基础设置</span>
|
||||
<div className="product-set-field-grid">
|
||||
<label>
|
||||
<span>平台</span>
|
||||
<select value={productSetPlatform} onChange={(event) => handleProductSetPlatformChange(event.target.value)}>
|
||||
{platformOptions.map((item) => (
|
||||
<option key={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>国家</span>
|
||||
<select value={productSetMarket} onChange={(event) => handleProductSetMarketChange(event.target.value)}>
|
||||
{marketOptions.map((item) => (
|
||||
<option key={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>语言</span>
|
||||
<select value={productSetLanguage} onChange={(event) => setProductSetLanguage(event.target.value)}>
|
||||
{productSetLanguageOptions.map((item) => (
|
||||
<option key={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>尺寸/比例</span>
|
||||
<select
|
||||
value={productSetRatio}
|
||||
onChange={(event) => setProductSetRatio(event.target.value)}
|
||||
disabled={productSetRatioOptions.length <= 1}
|
||||
>
|
||||
{productSetRatioOptions.map((item) => (
|
||||
<option key={item} value={item}>{formatRatioDisplayValue(item)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons";
|
||||
import type { ChangeEvent, RefObject } from "react";
|
||||
import { EcommerceProgressBar } from "../EcommerceProgressBar";
|
||||
|
||||
interface EcommerceTryOnPanelProps {
|
||||
garmentInputRef: RefObject<HTMLInputElement>;
|
||||
garmentImages: Array<{ id: string; src: string; name: string }>;
|
||||
modelSource: string;
|
||||
modelGender: string;
|
||||
modelAge: string;
|
||||
modelEthnicity: string;
|
||||
modelBody: string;
|
||||
appearance: string;
|
||||
selectedScenes: string[];
|
||||
customScene: string;
|
||||
smartScene: boolean;
|
||||
tryOnRatio: string;
|
||||
tryOnStatus: string;
|
||||
canGenerateTryOn: boolean;
|
||||
tryOnPrimaryLabel: string;
|
||||
tryOnModelOptions: { gender: string[]; age: string[]; ethnicity: string[]; body: string[] };
|
||||
tryOnAssets: { modelWoman: string; modelMan: string; modelAsian: string };
|
||||
tryOnScenes: string[];
|
||||
tryOnRatioOptions: string[];
|
||||
handleGarmentUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
setModelSource: (value: "ai" | "library") => void;
|
||||
setModelGender: (value: string) => void;
|
||||
setModelAge: (value: string) => void;
|
||||
setModelEthnicity: (value: string) => void;
|
||||
setModelBody: (value: string) => void;
|
||||
setAppearance: (value: string) => void;
|
||||
handleGenerateModel: () => void;
|
||||
toggleScene: (scene: string) => void;
|
||||
setCustomScene: (value: string) => void;
|
||||
setSmartScene: (updater: (current: boolean) => boolean) => void;
|
||||
setTryOnRatio: (value: string) => void;
|
||||
handleTryOnGenerate: () => void;
|
||||
}
|
||||
|
||||
export default function EcommerceTryOnPanel({
|
||||
garmentInputRef,
|
||||
garmentImages,
|
||||
modelSource,
|
||||
modelGender,
|
||||
modelAge,
|
||||
modelEthnicity,
|
||||
modelBody,
|
||||
appearance,
|
||||
selectedScenes,
|
||||
customScene,
|
||||
smartScene,
|
||||
tryOnRatio,
|
||||
tryOnStatus,
|
||||
canGenerateTryOn,
|
||||
tryOnPrimaryLabel,
|
||||
tryOnModelOptions,
|
||||
tryOnAssets,
|
||||
tryOnScenes,
|
||||
tryOnRatioOptions,
|
||||
handleGarmentUpload,
|
||||
setModelSource,
|
||||
setModelGender,
|
||||
setModelAge,
|
||||
setModelEthnicity,
|
||||
setModelBody,
|
||||
setAppearance,
|
||||
handleGenerateModel,
|
||||
toggleScene,
|
||||
setCustomScene,
|
||||
setSmartScene,
|
||||
setTryOnRatio,
|
||||
handleTryOnGenerate,
|
||||
}: EcommerceTryOnPanelProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="product-clone-panel__scroll">
|
||||
<section className="product-clone-field">
|
||||
<h2>服装图片</h2>
|
||||
<button type="button" className="product-clone-upload-zone product-try-on-upload" onClick={() => garmentInputRef.current?.click()}>
|
||||
<strong>
|
||||
<CloudUploadOutlined />
|
||||
服装图片
|
||||
</strong>
|
||||
<span>整套搭配或同一件服装不同角度图,最多5张。</span>
|
||||
</button>
|
||||
<input ref={garmentInputRef} type="file" accept="image/*" multiple onChange={handleGarmentUpload} />
|
||||
{garmentImages.length ? (
|
||||
<div className="product-clone-thumb-row product-try-on-thumb-row" aria-label="已上传服装图片">
|
||||
{garmentImages.map((item) => (
|
||||
<figure key={item.id} className="product-clone-uploaded-thumb">
|
||||
<img src={item.src} alt={item.name} />
|
||||
<span className="uploaded-image-zoom" aria-hidden="true">
|
||||
<img src={item.src} alt="" />
|
||||
</span>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="product-clone-field">
|
||||
<h2>模特形象</h2>
|
||||
<div className="product-clone-segment" role="tablist" aria-label="模特来源">
|
||||
<button type="button" className={modelSource === "ai" ? "is-active" : ""} onClick={() => setModelSource("ai")}>
|
||||
AI 生成
|
||||
</button>
|
||||
<button type="button" className={modelSource === "library" ? "is-active" : ""} onClick={() => setModelSource("library")}>
|
||||
模特库
|
||||
<QuestionCircleOutlined />
|
||||
</button>
|
||||
</div>
|
||||
{modelSource === "ai" ? (
|
||||
<>
|
||||
<div className="product-clone-model-grid">
|
||||
<select value={modelGender} onChange={(event) => setModelGender(event.target.value)}>
|
||||
{tryOnModelOptions.gender.map((item) => (
|
||||
<option key={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={modelAge} onChange={(event) => setModelAge(event.target.value)}>
|
||||
{tryOnModelOptions.age.map((item) => (
|
||||
<option key={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={modelEthnicity} onChange={(event) => setModelEthnicity(event.target.value)}>
|
||||
{tryOnModelOptions.ethnicity.map((item) => (
|
||||
<option key={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={modelBody} onChange={(event) => setModelBody(event.target.value)}>
|
||||
{tryOnModelOptions.body.map((item) => (
|
||||
<option key={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<label className="product-try-on-textarea-label">
|
||||
<span>外貌细节(可选)</span>
|
||||
<textarea
|
||||
value={appearance}
|
||||
onChange={(event) => setAppearance(event.target.value)}
|
||||
placeholder="例如:小麦色皮肤、齐刘海、眼角有泪痣..."
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="product-clone-model-button" onClick={handleGenerateModel} disabled={tryOnStatus === "modeling"}>
|
||||
{tryOnStatus === "modeling" ? <LoadingOutlined /> : null}
|
||||
{tryOnStatus === "modeling" ? "生成中..." : "生成基准模特"}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="product-try-on-library" aria-label="模特库">
|
||||
{[tryOnAssets.modelWoman, tryOnAssets.modelMan, tryOnAssets.modelAsian].map((src, index) => (
|
||||
<button key={src} type="button" className={index === 0 ? "is-active" : ""}>
|
||||
<img src={src} alt={`模特 ${index + 1}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="product-clone-field">
|
||||
<h2>拍摄场景</h2>
|
||||
<div className="product-clone-scene-grid">
|
||||
{tryOnScenes.map((scene) => (
|
||||
<button
|
||||
key={scene}
|
||||
type="button"
|
||||
className={selectedScenes.includes(scene) ? "is-active" : ""}
|
||||
onClick={() => toggleScene(scene)}
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
{scene}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<label className="product-clone-field product-try-on-scene-field">
|
||||
<h2>或自定义描述场景(可选)</h2>
|
||||
<textarea
|
||||
value={customScene}
|
||||
onChange={(event) => setCustomScene(event.target.value)}
|
||||
placeholder="描述你想要的场景:如秋季枫叶小径、暖色调午后阳光、模特倚靠树干..."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<section className="product-clone-field">
|
||||
<button type="button" className="product-clone-switch-row" onClick={() => setSmartScene((current) => !current)}>
|
||||
<span>
|
||||
<strong>智能推荐场景</strong>
|
||||
<em>根据服装自动匹配最佳场景</em>
|
||||
</span>
|
||||
<span className={`product-clone-switch${smartScene ? " is-on" : ""}`} role="switch" aria-checked={smartScene}>
|
||||
<span />
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="product-clone-field">
|
||||
<h2>图片比例</h2>
|
||||
<div className="product-clone-ratio-row">
|
||||
{tryOnRatioOptions.map((item) => (
|
||||
<button key={item} type="button" className={tryOnRatio === item ? "is-active" : ""} onClick={() => setTryOnRatio(item)}>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer className="product-clone-panel__footer">
|
||||
{tryOnStatus === "generating" ? <EcommerceProgressBar status="generating" label="服饰穿戴图" /> : null}
|
||||
<button type="button" className="product-clone-primary" disabled={!canGenerateTryOn} onClick={handleTryOnGenerate}>
|
||||
{tryOnStatus === "generating" ? <LoadingOutlined /> : null}
|
||||
{tryOnPrimaryLabel}
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user