feat: refine generation workspace experience
This commit is contained in:
@@ -518,6 +518,18 @@ const formatRatioDisplayValue = (value: string) => {
|
||||
const ratio = value.match(/\d+(?:\.\d+)?\s*[::]\s*\d+(?:\.\d+)?/u)?.[0]?.replace(/\s+/g, "").replace(/:/g, ":") ?? "";
|
||||
return size && ratio ? `${size}\u00a0\u00a0\u00a0${ratio}` : value.replace(/^套图[::]\s*/, "");
|
||||
};
|
||||
/** Extract CSS aspect-ratio from a ratio string like "1000x1000px 1:1" -> "1 / 1" */
|
||||
const parseRatioToAspectCss = (ratioStr: string): string => {
|
||||
const match = ratioStr.match(/(\d+)\s*[::]\s*(\d+)/u);
|
||||
if (!match) return "1 / 1";
|
||||
return `${match[1]} / ${match[2]}`;
|
||||
};
|
||||
/** Normalize ratio display string ("1000×1000px 1:1") to API format ("1:1") */
|
||||
const normalizeRatioForApi = (ratioStr: string): string => {
|
||||
const match = ratioStr.match(/(\d+)\s*[::]\s*(\d+)/u);
|
||||
if (!match) return "1:1";
|
||||
return `${match[1]}:${match[2]}`;
|
||||
};
|
||||
const greatestCommonDivisor = (left: number, right: number): number => {
|
||||
let a = Math.abs(left);
|
||||
let b = Math.abs(right);
|
||||
@@ -869,6 +881,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const [videoOutfitVideoFile, setVideoOutfitVideoFile] = useState<File | null>(null);
|
||||
const [videoOutfitRefFile, setVideoOutfitRefFile] = useState<File | null>(null);
|
||||
const [isCloneSettingsCollapsed, setIsCloneSettingsCollapsed] = useState(false);
|
||||
const [previewZoom, setPreviewZoom] = useState(1);
|
||||
const [requirement, setRequirement] = useState("");
|
||||
const [requirementImageMentionQuery, setRequirementImageMentionQuery] = useState<string | null>(null);
|
||||
const [cloneSettingName, setCloneSettingName] = useState("新建创作");
|
||||
@@ -1126,6 +1139,32 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
const [isCloneReferenceDragging, setIsCloneReferenceDragging] = useState(false);
|
||||
|
||||
const handleCloneReferenceDragOver = (event: DragEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.dataTransfer.types.includes("Files")) {
|
||||
setIsCloneReferenceDragging(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloneReferenceDragLeave = (event: DragEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) {
|
||||
setIsCloneReferenceDragging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloneReferenceDrop = (event: DragEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsCloneReferenceDragging(false);
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
if (files.length) addCloneReferenceImages(files);
|
||||
};
|
||||
|
||||
const updateCloneSetCount = (key: CloneSetCountKey, delta: -1 | 1) => {
|
||||
setCloneSetCounts((current) => {
|
||||
const total = Object.values(current).reduce((sum, value) => sum + value, 0);
|
||||
@@ -1637,6 +1676,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const stamp = Date.now();
|
||||
|
||||
for (const countKey of cloneSetCountKeys) {
|
||||
if (imageAbortRef.current.current) break;
|
||||
const count = counts[countKey];
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (imageAbortRef.current.current) break;
|
||||
@@ -1646,7 +1686,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const { taskId } = await aiGenerationClient.createImageTask({
|
||||
model: IMAGE_MODEL,
|
||||
prompt: fullPrompt,
|
||||
ratio: pRatio,
|
||||
ratio: normalizeRatioForApi(pRatio),
|
||||
quality: pRatio.includes("720") ? "720P" : "1080P",
|
||||
gridMode: "single",
|
||||
referenceUrls,
|
||||
@@ -1683,7 +1723,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
return;
|
||||
}
|
||||
setResultFn(generatedUrls);
|
||||
setStatusFn(generatedUrls.some(Boolean) ? "done" : "idle");
|
||||
setStatusFn(generatedUrls.some(Boolean) ? "done" : "failed");
|
||||
} catch (err) {
|
||||
if (imageAbortRef.current.current) {
|
||||
setStatusFn("idle");
|
||||
@@ -1730,7 +1770,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const { taskId } = await aiGenerationClient.createImageTask({
|
||||
model: IMAGE_MODEL,
|
||||
prompt,
|
||||
ratio: pRatio,
|
||||
ratio: normalizeRatioForApi(pRatio),
|
||||
quality: pRatio.includes("720") ? "720P" : "1080P",
|
||||
gridMode: "single",
|
||||
referenceUrls,
|
||||
@@ -2059,7 +2099,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
clonePreviewCards.push({
|
||||
id: `${countKey}-${i}`,
|
||||
src: results[cloneIndex]?.src || productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src || "",
|
||||
src: productSetResultImages[cloneIndex] || productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src || "",
|
||||
label: `${info.label}${count > 1 ? ` ${i + 1}` : ""}`,
|
||||
});
|
||||
cloneIndex++;
|
||||
@@ -2179,6 +2219,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
setOpenCloneBasicSelect={setOpenCloneBasicSelect}
|
||||
setCloneReferenceMode={setCloneReferenceMode}
|
||||
handleCloneReferenceUpload={handleCloneReferenceUpload}
|
||||
isCloneReferenceDragging={isCloneReferenceDragging}
|
||||
handleCloneReferenceDragOver={handleCloneReferenceDragOver}
|
||||
handleCloneReferenceDragLeave={handleCloneReferenceDragLeave}
|
||||
handleCloneReferenceDrop={handleCloneReferenceDrop}
|
||||
setCloneReplicateLevel={setCloneReplicateLevel}
|
||||
startCloneSetCountHold={startCloneSetCountHold}
|
||||
clearCloneSetCountHold={clearCloneSetCountHold}
|
||||
@@ -2362,6 +2406,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
<span>
|
||||
上传商品图,AI 即刻生成 <b>符合多电商平台规范</b> 的高转化率商品素材。
|
||||
</span>
|
||||
<div className="clone-ai-preview-zoom">
|
||||
<button type="button" onClick={() => setPreviewZoom((z) => Math.max(0.25, z - 0.1))} disabled={previewZoom <= 0.25} aria-label="缩小">−</button>
|
||||
<span>{Math.round(previewZoom * 100)}%</span>
|
||||
<button type="button" onClick={() => setPreviewZoom((z) => Math.min(2, z + 0.1))} disabled={previewZoom >= 2} aria-label="放大">+</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{cloneOutput === "video" ? (
|
||||
@@ -2484,8 +2533,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
) : (
|
||||
<>
|
||||
{status === "done" ? (
|
||||
<div className="clone-ai-preview-zoom-wrap" style={{ zoom: previewZoom }}>
|
||||
<section className="clone-ai-preview-showcase" aria-label="生成结果">
|
||||
<button type="button" className="clone-ai-main-result" onClick={() => openProductSetPreview(cloneOutput === "set" ? clonePreviewCards[0] : results[0])}>
|
||||
<button type="button" className="clone-ai-main-result" style={{ aspectRatio: parseRatioToAspectCss(ratio) }} 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>
|
||||
@@ -2493,19 +2543,20 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
<div className="clone-ai-result-grid result-reveal">
|
||||
{cloneOutput === "set" ? (
|
||||
clonePreviewCards.map((card) => (
|
||||
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
|
||||
<button key={card.id} type="button" style={{ aspectRatio: parseRatioToAspectCss(ratio) }} 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])}>
|
||||
<button type="button" style={{ aspectRatio: parseRatioToAspectCss(ratio) }} onClick={() => openProductSetPreview(results[0])}>
|
||||
<img src={results[0].src} alt={selectedCloneOutput.label} />
|
||||
<span>{selectedCloneOutput.label}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<section className="clone-ai-empty-state" aria-live="polite">
|
||||
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <FrownOutlined /> : <FileImageOutlined />}
|
||||
@@ -2694,7 +2745,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
) : null}
|
||||
|
||||
{isSetTool ? setPreview : isDetail ? detailPreview : isTryOn ? tryOnPreview : isCloneTool ? (cloneOutput === "video" ? (
|
||||
<main className="product-clone-preview product-clone-preview--video" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<main className="product-clone-preview product-clone-preview--video" style={{ padding: 0 }}>
|
||||
<EcommerceVideoWorkspace
|
||||
isAuthenticated={Boolean((_props as Record<string, unknown>).isAuthenticated)}
|
||||
productImageDataUrls={ecommerceVideoImageDataUrls}
|
||||
|
||||
@@ -120,6 +120,7 @@ export default function EcommerceVideoWorkspace({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
||||
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null);
|
||||
const [flowZoom, setFlowZoom] = useState(1);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const renderAbortRef = useRef({ current: false });
|
||||
const actionNoticeTimerRef = useRef<number | null>(null);
|
||||
@@ -616,6 +617,12 @@ export default function EcommerceVideoWorkspace({
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="ecom-video-flowbar__zoom">
|
||||
<button type="button" onClick={() => setFlowZoom((z) => Math.max(0.25, z - 0.1))} disabled={flowZoom <= 0.25} aria-label="缩小">−</button>
|
||||
<span>{Math.round(flowZoom * 100)}%</span>
|
||||
<button type="button" onClick={() => setFlowZoom((z) => Math.min(2, z + 0.1))} disabled={flowZoom >= 2} aria-label="放大">+</button>
|
||||
</div>
|
||||
|
||||
<div className="ecom-video-flowbar__actions">
|
||||
{onOpenHistory ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost" onClick={onOpenHistory} title="生成记录">
|
||||
@@ -660,9 +667,10 @@ export default function EcommerceVideoWorkspace({
|
||||
|
||||
{/* ── Flow canvas ──────────────────────────────────── */}
|
||||
<section className="ecom-video-flow-canvas" aria-label="视频分镜流程图">
|
||||
<div style={{ zoom: flowZoom, flexShrink: 0, display: "flex", alignItems: "flex-start", justifyContent: "center", minWidth: "max-content" }}>
|
||||
{!sourceImage ? (
|
||||
<div className="ecom-video-empty">
|
||||
<span>上传商品图并点击“一键策划”开始</span>
|
||||
<span>上传商品图并点击"一键策划"开始</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ecom-video-tree">
|
||||
@@ -778,6 +786,7 @@ export default function EcommerceVideoWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Delivery dock ────────────────────────────── */}
|
||||
{primaryVideo ? (
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ReloadOutlined,
|
||||
SettingOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { CSSProperties, ChangeEvent, DragEvent, MutableRefObject, RefObject } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
@@ -118,6 +119,10 @@ interface EcommerceClonePanelProps {
|
||||
setOpenCloneBasicSelect: (value: CloneBasicSelectKey | null) => void;
|
||||
setCloneReferenceMode: (value: CloneReferenceMode) => void;
|
||||
handleCloneReferenceUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
isCloneReferenceDragging: boolean;
|
||||
handleCloneReferenceDragOver: (event: DragEvent<HTMLButtonElement>) => void;
|
||||
handleCloneReferenceDragLeave: (event: DragEvent<HTMLButtonElement>) => void;
|
||||
handleCloneReferenceDrop: (event: DragEvent<HTMLButtonElement>) => void;
|
||||
setCloneReplicateLevel: (value: CloneReplicateLevelKey) => void;
|
||||
startCloneSetCountHold: (key: CloneSetCountKey, delta: -1 | 1, disabled: boolean) => void;
|
||||
clearCloneSetCountHold: () => void;
|
||||
@@ -186,6 +191,10 @@ export default function EcommerceClonePanel({
|
||||
setOpenCloneBasicSelect,
|
||||
setCloneReferenceMode,
|
||||
handleCloneReferenceUpload,
|
||||
isCloneReferenceDragging,
|
||||
handleCloneReferenceDragOver,
|
||||
handleCloneReferenceDragLeave,
|
||||
handleCloneReferenceDrop,
|
||||
setCloneReplicateLevel,
|
||||
startCloneSetCountHold,
|
||||
clearCloneSetCountHold,
|
||||
@@ -210,6 +219,14 @@ export default function EcommerceClonePanel({
|
||||
const videoOutfitRefRef = useRef<HTMLInputElement>(null);
|
||||
const [videoOutfitVideoUrl, setVideoOutfitVideoUrl] = useState<string | null>(null);
|
||||
const [videoOutfitRefUrl, setVideoOutfitRefUrl] = useState<string | null>(null);
|
||||
const [zoomImage, setZoomImage] = useState<{ src: string; x: number; y: number } | null>(null);
|
||||
|
||||
const handleFileMouseEnter = (src: string, event: React.MouseEvent<HTMLElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setZoomImage({ src, x: rect.left + rect.width / 2, y: rect.top });
|
||||
};
|
||||
|
||||
const handleFileMouseLeave = () => setZoomImage(null);
|
||||
|
||||
const handleVideoOutfitVideoChange = () => {
|
||||
const file = videoOutfitVideoRef.current?.files?.[0] || null;
|
||||
@@ -383,23 +400,44 @@ export default function EcommerceClonePanel({
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
className={`clone-ai-replicate-upload${isCloneReferenceDragging ? " is-dragging" : ""}${cloneReferenceImages.length ? " has-files" : ""}`}
|
||||
onClick={() => cloneReferenceInputRef.current?.click()}
|
||||
onDragOver={handleCloneReferenceDragOver}
|
||||
onDragLeave={handleCloneReferenceDragLeave}
|
||||
onDrop={handleCloneReferenceDrop}
|
||||
>
|
||||
{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">
|
||||
<>
|
||||
<div className="clone-ai-replicate-files">
|
||||
{cloneReferenceImages.map((item) => (
|
||||
<figure
|
||||
key={item.id}
|
||||
className="clone-ai-replicate-file"
|
||||
onMouseEnter={(e) => handleFileMouseEnter(item.src, e)}
|
||||
onMouseLeave={handleFileMouseLeave}
|
||||
>
|
||||
<img src={item.src} alt="" />
|
||||
</span>
|
||||
</figure>
|
||||
))}
|
||||
{cloneReferenceImages.length > 4 ? <b>+{cloneReferenceImages.length - 4}</b> : null}
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
<span className="clone-ai-replicate-add-more">
|
||||
<CloudUploadOutlined />
|
||||
点击继续上传文件
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>
|
||||
<CloudUploadOutlined />
|
||||
<span className="clone-ai-replicate-upload-text">拖拽或点击上传参考图</span>
|
||||
</span>
|
||||
)}
|
||||
<em>{cloneReferenceImages.length ? `已选 ${cloneReferenceImages.length}/${maxCloneReferenceImages}` : `最多 ${maxCloneReferenceImages} 张`}</em>
|
||||
{isCloneReferenceDragging ? (
|
||||
<div className="clone-ai-replicate-upload-overlay">
|
||||
<CloudUploadOutlined />
|
||||
<span>释放文件以上传</span>
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
@@ -754,6 +792,18 @@ export default function EcommerceClonePanel({
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{zoomImage
|
||||
? createPortal(
|
||||
<div
|
||||
className="clone-ai-zoom-portal"
|
||||
style={{ left: zoomImage.x, top: zoomImage.y } as CSSProperties}
|
||||
onMouseLeave={handleFileMouseLeave}
|
||||
>
|
||||
<img src={zoomImage.src} alt="" />
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user