2026-06-10 14:06:16 +08:00
|
|
|
|
import {
|
|
|
|
|
|
CloudUploadOutlined,
|
|
|
|
|
|
CloseOutlined,
|
|
|
|
|
|
FileImageOutlined,
|
|
|
|
|
|
LoadingOutlined,
|
|
|
|
|
|
QuestionCircleOutlined,
|
|
|
|
|
|
ReloadOutlined,
|
|
|
|
|
|
SettingOutlined,
|
|
|
|
|
|
} from "@ant-design/icons";
|
|
|
|
|
|
import { createPortal } from "react-dom";
|
|
|
|
|
|
import type { CSSProperties, ChangeEvent, DragEvent, MutableRefObject, RefObject } from "react";
|
2026-06-12 11:12:55 +08:00
|
|
|
|
import { useState } from "react";
|
2026-06-10 14:06:16 +08:00
|
|
|
|
|
|
|
|
|
|
type ProductSetOutputKey = "set" | "detail" | "model" | "video";
|
2026-06-12 11:12:55 +08:00
|
|
|
|
type CloneOutputKey = ProductSetOutputKey | "hot";
|
2026-06-10 14:06:16 +08:00
|
|
|
|
type CloneSetCountKey = "selling" | "white" | "scene";
|
|
|
|
|
|
type CloneModelPanelTab = "scene" | "model";
|
|
|
|
|
|
type CloneReferenceMode = "upload" | "link";
|
|
|
|
|
|
type CloneReplicateLevelKey = "style" | "high";
|
|
|
|
|
|
type CloneVideoQualityKey = "standard" | "high" | "ultra";
|
|
|
|
|
|
type CloneBasicSelectKey = "platform" | "market" | "language" | "ratio";
|
|
|
|
|
|
type CloneModelSelectKey = "gender" | "age" | "ethnicity" | "body";
|
|
|
|
|
|
|
|
|
|
|
|
interface CloneImageItem {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
src: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface CloneBasicSelectItem {
|
|
|
|
|
|
key: CloneBasicSelectKey;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
value: string;
|
|
|
|
|
|
options: string[];
|
|
|
|
|
|
onChange: (value: string) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface CloneModelSelectItem {
|
|
|
|
|
|
key: CloneModelSelectKey;
|
|
|
|
|
|
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: CloneBasicSelectKey | 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: CloneModelSelectKey | null;
|
|
|
|
|
|
cloneModelSelectDropUp: boolean;
|
|
|
|
|
|
cloneVideoQuality: CloneVideoQualityKey;
|
|
|
|
|
|
cloneVideoQualityOptions: CloneVideoQualityOption[];
|
|
|
|
|
|
cloneVideoDuration: number;
|
|
|
|
|
|
cloneVideoDurationMin: number;
|
|
|
|
|
|
cloneVideoDurationMax: number;
|
|
|
|
|
|
cloneVideoDurationStyle: CSSProperties;
|
|
|
|
|
|
cloneVideoSmart: boolean;
|
|
|
|
|
|
canGenerate: boolean;
|
|
|
|
|
|
status: string;
|
|
|
|
|
|
lastFailedActionRef: MutableRefObject<(() => void) | null>;
|
|
|
|
|
|
setIsProductUploadDragging: (value: boolean) => void;
|
|
|
|
|
|
handleProductDrop: (event: DragEvent<HTMLDivElement>) => void;
|
|
|
|
|
|
removeProductImage: (id: string) => void;
|
|
|
|
|
|
handleProductUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
|
|
|
|
|
handleCloneOutputChange: (value: CloneOutputKey) => void;
|
|
|
|
|
|
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;
|
|
|
|
|
|
toggleCloneDetailModule: (id: string) => void;
|
|
|
|
|
|
setCloneModelPanelTab: (value: CloneModelPanelTab) => void;
|
|
|
|
|
|
toggleCloneModelScene: (scene: string) => void;
|
|
|
|
|
|
setCloneModelCustomScene: (value: string) => void;
|
|
|
|
|
|
setOpenCloneModelSelect: (value: CloneModelSelectKey | null) => void;
|
|
|
|
|
|
setCloneModelSelectDropUp: (value: boolean) => void;
|
|
|
|
|
|
setCloneVideoQuality: (value: CloneVideoQualityKey) => void;
|
|
|
|
|
|
setCloneVideoDuration: (value: number) => void;
|
|
|
|
|
|
clampCloneVideoDuration: (value: number) => number;
|
|
|
|
|
|
setCloneVideoSmart: (updater: (current: boolean) => boolean) => void;
|
|
|
|
|
|
handleGenerate: () => void;
|
|
|
|
|
|
onCancelGenerate: () => void;
|
|
|
|
|
|
formatRatioDisplayValue: (value: string) => string;
|
|
|
|
|
|
onStartVideoPlan?: () => 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,
|
|
|
|
|
|
cloneVideoQuality,
|
|
|
|
|
|
cloneVideoQualityOptions,
|
|
|
|
|
|
cloneVideoDuration,
|
|
|
|
|
|
cloneVideoDurationMin,
|
|
|
|
|
|
cloneVideoDurationMax,
|
|
|
|
|
|
cloneVideoDurationStyle,
|
|
|
|
|
|
cloneVideoSmart,
|
|
|
|
|
|
canGenerate,
|
|
|
|
|
|
status,
|
|
|
|
|
|
lastFailedActionRef,
|
|
|
|
|
|
setIsProductUploadDragging,
|
|
|
|
|
|
handleProductDrop,
|
|
|
|
|
|
removeProductImage,
|
|
|
|
|
|
handleProductUpload,
|
|
|
|
|
|
handleCloneOutputChange,
|
|
|
|
|
|
setOpenCloneBasicSelect,
|
|
|
|
|
|
setCloneReferenceMode,
|
|
|
|
|
|
handleCloneReferenceUpload,
|
|
|
|
|
|
isCloneReferenceDragging,
|
|
|
|
|
|
handleCloneReferenceDragOver,
|
|
|
|
|
|
handleCloneReferenceDragLeave,
|
|
|
|
|
|
handleCloneReferenceDrop,
|
|
|
|
|
|
setCloneReplicateLevel,
|
|
|
|
|
|
startCloneSetCountHold,
|
|
|
|
|
|
clearCloneSetCountHold,
|
|
|
|
|
|
toggleCloneDetailModule,
|
|
|
|
|
|
setCloneModelPanelTab,
|
|
|
|
|
|
toggleCloneModelScene,
|
|
|
|
|
|
setCloneModelCustomScene,
|
|
|
|
|
|
setOpenCloneModelSelect,
|
|
|
|
|
|
setCloneModelSelectDropUp,
|
|
|
|
|
|
setCloneVideoQuality,
|
|
|
|
|
|
setCloneVideoDuration,
|
|
|
|
|
|
clampCloneVideoDuration,
|
|
|
|
|
|
setCloneVideoSmart,
|
|
|
|
|
|
handleGenerate,
|
|
|
|
|
|
onCancelGenerate,
|
|
|
|
|
|
formatRatioDisplayValue,
|
|
|
|
|
|
onStartVideoPlan,
|
|
|
|
|
|
}: EcommerceClonePanelProps) {
|
|
|
|
|
|
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 platformSelect = cloneBasicSelects.find((item) => item.key === "platform");
|
|
|
|
|
|
const languageSelects = cloneBasicSelects.filter((item) => item.key === "market" || item.key === "language");
|
|
|
|
|
|
const ratioSelect = cloneBasicSelects.find((item) => item.key === "ratio");
|
|
|
|
|
|
|
|
|
|
|
|
const renderBasicSelect = (item: CloneBasicSelectItem) => {
|
|
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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>
|
2026-06-12 11:12:55 +08:00
|
|
|
|
<input ref={productInputRef} type="file" accept="image/*" multiple onChange={handleProductUpload} aria-label="上传商品图片" />
|
2026-06-10 14:06:16 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section className="clone-ai-card clone-ai-settings-card clone-ai-settings-card--mode">
|
|
|
|
|
|
<h2>
|
|
|
|
|
|
<SettingOutlined />
|
|
|
|
|
|
生成设置
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
<div className="clone-ai-settings-section">
|
|
|
|
|
|
<span className="clone-ai-settings-label">生成内容</span>
|
2026-06-12 11:12:55 +08:00
|
|
|
|
<div className="clone-ai-tag-group" role="toolbar" aria-label="生成内容">
|
2026-06-10 14:06:16 +08:00
|
|
|
|
{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>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
{platformSelect ? (
|
|
|
|
|
|
<section className="clone-ai-card clone-ai-settings-card clone-ai-settings-card--platform">
|
|
|
|
|
|
<span className="clone-ai-settings-label">平台</span>
|
|
|
|
|
|
<div className="clone-ai-select-group clone-ai-select-group--single">
|
|
|
|
|
|
{renderBasicSelect(platformSelect)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{languageSelects.length ? (
|
|
|
|
|
|
<section className="clone-ai-card clone-ai-settings-card clone-ai-settings-card--language">
|
|
|
|
|
|
<span className="clone-ai-settings-label">语种</span>
|
|
|
|
|
|
<div className="clone-ai-select-group clone-ai-select-group--language">
|
|
|
|
|
|
{languageSelects.map(renderBasicSelect)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{ratioSelect ? (
|
|
|
|
|
|
<section className="clone-ai-card clone-ai-settings-card clone-ai-settings-card--ratio">
|
|
|
|
|
|
<span className="clone-ai-settings-label">尺寸比例</span>
|
|
|
|
|
|
<div className="clone-ai-select-group clone-ai-select-group--single">
|
|
|
|
|
|
{renderBasicSelect(ratioSelect)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{cloneOutput === "set" ? (
|
|
|
|
|
|
<section className="clone-ai-count-panel" aria-label="套图图片数量">
|
|
|
|
|
|
<div className="clone-ai-dynamic-head">
|
|
|
|
|
|
<strong>套图分类设置</strong>
|
|
|
|
|
|
<span>按类型分配图片张数</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<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="详情图包含模块">
|
|
|
|
|
|
<div className="clone-ai-dynamic-head">
|
|
|
|
|
|
<strong>详情模块设置</strong>
|
|
|
|
|
|
<span>选择要生成的详情页内容</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<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-dynamic-head">
|
|
|
|
|
|
<strong>模特图设置</strong>
|
|
|
|
|
|
<span>选择场景或模特形象</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{cloneOutput === "video" ? (
|
|
|
|
|
|
<section className="clone-ai-video-panel" aria-label="短视频设置">
|
|
|
|
|
|
<div className="clone-ai-dynamic-head">
|
|
|
|
|
|
<strong>短视频设置</strong>
|
|
|
|
|
|
<span>设置视频质量与时长</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<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={5}
|
|
|
|
|
|
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>15秒</span>
|
|
|
|
|
|
<span>30秒</span>
|
|
|
|
|
|
<span>45秒</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" && onStartVideoPlan ? (
|
|
|
|
|
|
<button type="button" className="clone-ai-generate" onClick={onStartVideoPlan}>
|
|
|
|
|
|
✦ 一键策划
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : 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}
|
2026-06-12 11:12:55 +08:00
|
|
|
|
{status === "generating" ? "生成中..." : status === "failed" ? "重新生成" : "✦ 开始生成"}
|
2026-06-10 14:06:16 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
{status === "generating" && cloneOutput !== "video" ? (
|
|
|
|
|
|
<button type="button" className="clone-ai-generate clone-ai-generate--cancel" onClick={onCancelGenerate}>
|
|
|
|
|
|
{"\u53d6\u6d88\u751f\u6210"}
|
|
|
|
|
|
</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}
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|