Merge 9a9c7eb: optimize ecommerce hot clone UI (resolved conflicts + fixed unclosed block)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
ClearOutlined,
|
||||
CloudUploadOutlined,
|
||||
@@ -16,6 +16,7 @@
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
PaperClipOutlined,
|
||||
PlusOutlined,
|
||||
QuestionCircleOutlined,
|
||||
ReloadOutlined,
|
||||
ScissorOutlined,
|
||||
@@ -377,9 +378,8 @@ const productSetOutputOptions: Array<{ key: ProductSetOutputKey; label: string;
|
||||
{ key: "model", label: "模特图", desc: "真人穿搭展示", icon: <SkinOutlined /> },
|
||||
{ key: "video", label: "短视频", desc: "分镜视频链路", icon: <VideoCameraOutlined /> },
|
||||
];
|
||||
const cloneOutputOptions: Array<{ key: CloneOutputKey; label: string; desc: string; icon: ReactNode }> = [
|
||||
const cloneOutputOptions: Array<{ key: ProductSetOutputKey; label: string; desc: string; icon: ReactNode }> = [
|
||||
...productSetOutputOptions,
|
||||
{ key: "hot", label: "爆款复刻", desc: "参考图风格迁移", icon: <FireOutlined /> },
|
||||
];
|
||||
const cloneSetCountOptions: Array<{
|
||||
key: CloneSetCountKey;
|
||||
@@ -758,6 +758,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const garmentInputRef = useRef<HTMLInputElement>(null);
|
||||
const detailInputRef = useRef<HTMLInputElement>(null);
|
||||
const detailProgressRef = useRef<number | null>(null);
|
||||
const hotProgressRef = useRef<number | null>(null);
|
||||
const hotMaterialInputRef = useRef<HTMLInputElement>(null);
|
||||
const countHoldTimeoutRef = useRef<number | null>(null);
|
||||
const countHoldIntervalRef = useRef<number | null>(null);
|
||||
const isAuthenticated = Boolean((_props as Record<string, unknown>).isAuthenticated);
|
||||
@@ -791,7 +793,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const [selectedProductSetPreview, setSelectedProductSetPreview] = useState<ProductSetPreviewSelection | null>(null);
|
||||
const [showHostingModal, setShowHostingModal] = useState(false);
|
||||
const [productImages, setProductImages] = useState<CloneImageItem[]>([]);
|
||||
const [activeQuickTool, setActiveQuickTool] = useState<"cutout" | "detail" | "watermark" | "image-edit" | "translate" | null>(null);
|
||||
const [activeQuickTool, setActiveQuickTool] = useState<"cutout" | "detail" | "watermark" | "image-edit" | "translate" | "hot" | null>(null);
|
||||
const [smartCutoutImage, setSmartCutoutImage] = useState<SmartCutoutImageItem | null>(null);
|
||||
const [smartCutoutBatchImages, setSmartCutoutBatchImages] = useState<SmartCutoutImageItem[]>([]);
|
||||
const [smartCutoutBackgroundColor, setSmartCutoutBackgroundColor] = useState("#ffffff");
|
||||
@@ -1127,24 +1129,25 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const [detailStatus, setDetailStatus] = useState<DetailStatus>("idle");
|
||||
const [detailResultUrl, setDetailResultUrl] = useState<string | null>(null);
|
||||
const [detailProgress, setDetailProgress] = useState(0);
|
||||
const [hotRequirement, setHotRequirement] = useState("");
|
||||
const [isHotMaterialDragging, setIsHotMaterialDragging] = useState(false);
|
||||
const [hotMaterialHoverZoom, setHotMaterialHoverZoom] = useState<{ src: string; x: number; y: number; placement: "above" | "below" } | null>(null);
|
||||
const [hotPlatform, setHotPlatform] = useState(platformOptions[0]);
|
||||
const [hotMarket, setHotMarket] = useState(marketOptions[0]);
|
||||
const [hotLanguage, setHotLanguage] = useState(getPlatformDefaultLanguage(platformOptions[0], marketOptions[0]));
|
||||
const [hotRatio, setHotRatio] = useState(getQuickSetRatioValue(getPlatformDefaultRatio(platformOptions[0], "detail")));
|
||||
const [hotStatus, setHotStatus] = useState<DetailStatus>("idle");
|
||||
const [hotResultUrl, setHotResultUrl] = useState<string | null>(null);
|
||||
const [hotProgress, setHotProgress] = useState(0);
|
||||
const productSetRatioOptions = useMemo(
|
||||
() => getPlatformRatioOptions(productSetPlatform, productSetOutput),
|
||||
[productSetOutput, productSetPlatform],
|
||||
);
|
||||
const hotUploadedRatioOption = useMemo(
|
||||
() => cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null,
|
||||
[cloneOutput, cloneReferenceImages],
|
||||
);
|
||||
const baseCloneRatioOptions = useMemo(
|
||||
() => getPlatformRatioOptions(platform, cloneOutput),
|
||||
[cloneOutput, platform],
|
||||
);
|
||||
const cloneRatioOptions = useMemo(
|
||||
() => hotUploadedRatioOption
|
||||
? getUniqueRatioOptions([...baseCloneRatioOptions, hotUploadedRatioOption])
|
||||
: baseCloneRatioOptions,
|
||||
[baseCloneRatioOptions, hotUploadedRatioOption],
|
||||
);
|
||||
const cloneRatioOptions = baseCloneRatioOptions;
|
||||
const productSetLanguageOptions = useMemo(
|
||||
() => getPlatformLanguageOptions(productSetPlatform, productSetMarket),
|
||||
[productSetMarket, productSetPlatform],
|
||||
@@ -1157,6 +1160,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
() => getPlatformLanguageOptions(detailPlatform, detailMarket),
|
||||
[detailMarket, detailPlatform],
|
||||
);
|
||||
const hotLanguageOptions = useMemo(
|
||||
() => getPlatformLanguageOptions(hotPlatform, hotMarket),
|
||||
[hotMarket, hotPlatform],
|
||||
);
|
||||
const ecommerceMentionImages: MentionImageOption[] = [
|
||||
...productImages.map((image, index) => ({ ...image, label: `商品图 ${index + 1}` })),
|
||||
...cloneReferenceImages.map((image, index) => ({ ...image, label: `参考图 ${index + 1}` })),
|
||||
@@ -1187,6 +1194,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const canGenerate = productImages.length > 0 && status !== "generating";
|
||||
const canGenerateTryOn = garmentImages.length > 0 && tryOnStatus !== "generating" && tryOnStatus !== "modeling";
|
||||
const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating";
|
||||
const canGenerateHot = cloneReferenceImages.length > 0 && hotStatus !== "generating";
|
||||
const cloneVideoDurationProgress =
|
||||
((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100;
|
||||
const cloneVideoDurationStyle: CSSProperties = useMemo(
|
||||
@@ -2051,6 +2059,16 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
if (!selectedDetailModules.length) setSelectedDetailModules(defaultCloneDetailModuleIds);
|
||||
};
|
||||
|
||||
const openHotClonePage = () => {
|
||||
clearSmartCutoutTransition();
|
||||
setActiveQuickTool("hot");
|
||||
setComposerMenu(null);
|
||||
setIsCloneSettingsCollapsed(false);
|
||||
setIsQuickPanelCollapsed(false);
|
||||
setPreviewZoom(1);
|
||||
resetQuickSetSelectState();
|
||||
};
|
||||
|
||||
const closeSmartCutoutTool = () => {
|
||||
runSmartCutoutPageTransition(
|
||||
{
|
||||
@@ -2409,6 +2427,17 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const removeCloneReferenceImage = (imageId: string) => {
|
||||
setCloneReferenceImages((current) => {
|
||||
const next = current.filter((item) => item.id !== imageId);
|
||||
if (next.length === 0) {
|
||||
setHotStatus("idle");
|
||||
setHotResultUrl(null);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloneReferenceUpload = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (!files?.length) return;
|
||||
@@ -2513,9 +2542,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const normalizedPlatform = normalizePlatform(nextPlatform);
|
||||
setPlatform(normalizedPlatform);
|
||||
setRatio((current) =>
|
||||
cloneOutput === "hot" && current.startsWith("上传图片") && hotUploadedRatioOption
|
||||
? hotUploadedRatioOption
|
||||
: normalizeRatioForPlatform(normalizedPlatform, current, cloneOutput),
|
||||
normalizeRatioForPlatform(normalizedPlatform, current, cloneOutput),
|
||||
);
|
||||
setLanguage(getPlatformDefaultLanguage(normalizedPlatform, market));
|
||||
};
|
||||
@@ -2524,9 +2551,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
setCloneOutput(nextOutput);
|
||||
if (nextOutput !== "video") setIsVideoWorkspaceVisible(false);
|
||||
setRatio((current) =>
|
||||
nextOutput === "hot" && current.startsWith("上传图片") && hotUploadedRatioOption
|
||||
? hotUploadedRatioOption
|
||||
: normalizeRatioForPlatform(platform, current, nextOutput),
|
||||
normalizeRatioForPlatform(platform, current, nextOutput),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2665,14 +2690,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
useEffect(() => {
|
||||
setRatio((current) => {
|
||||
const platformRatios = getPlatformRatioOptions(platform, cloneOutput);
|
||||
const availableRatios = hotUploadedRatioOption ? getUniqueRatioOptions([...platformRatios, hotUploadedRatioOption]) : platformRatios;
|
||||
if (current.startsWith("上传图片") && hotUploadedRatioOption) return hotUploadedRatioOption;
|
||||
if (availableRatios.includes(current)) return current;
|
||||
if (platformRatios.includes(current)) return current;
|
||||
const normalizedRatio = normalizeRatioToken(current);
|
||||
const matchedRatio = availableRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio));
|
||||
const matchedRatio = platformRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio));
|
||||
return matchedRatio ?? getPlatformDefaultRatio(platform, cloneOutput);
|
||||
});
|
||||
}, [cloneOutput, hotUploadedRatioOption, platform]);
|
||||
}, [cloneOutput, platform]);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipInitialCloneAutoSaveRef.current) {
|
||||
@@ -3325,6 +3348,133 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
);
|
||||
};
|
||||
|
||||
const handleHotPlatformChange = (nextPlatform: string) => {
|
||||
const normalizedPlatform = normalizePlatform(nextPlatform);
|
||||
setHotPlatform(normalizedPlatform);
|
||||
setHotLanguage(getPlatformDefaultLanguage(normalizedPlatform, hotMarket));
|
||||
setHotRatio((current) => getQuickSetRatioValue(current));
|
||||
};
|
||||
|
||||
const handleHotMarketChange = (nextMarket: string) => {
|
||||
const normalizedMarket = normalizeMarket(nextMarket);
|
||||
setHotMarket(normalizedMarket);
|
||||
setHotLanguage(getPlatformDefaultLanguage(hotPlatform, normalizedMarket));
|
||||
};
|
||||
|
||||
const handleHotAiWrite = () => {
|
||||
setHotRequirement(
|
||||
"1.产品名称:便携式咖啡保温杯\n2.核心卖点:316不锈钢内胆、12小时长效保温、防漏便携、大容量\n3.参考风格:极简日系、暖光氛围、生活场景\n4.期望场景:办公桌面、户外通勤、运动健身\n5.具体参数:容量500ml、口径4.5cm、高度22cm",
|
||||
);
|
||||
};
|
||||
|
||||
const stopHotProgress = () => {
|
||||
if (hotProgressRef.current !== null) {
|
||||
window.clearInterval(hotProgressRef.current);
|
||||
hotProgressRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startHotProgress = () => {
|
||||
stopHotProgress();
|
||||
setHotProgress(0);
|
||||
hotProgressRef.current = window.setInterval(() => {
|
||||
setHotProgress((prev) => {
|
||||
if (prev >= 90) {
|
||||
stopHotProgress();
|
||||
return 90;
|
||||
}
|
||||
return prev + (90 - prev) * 0.06;
|
||||
});
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleHotGenerate = () => {
|
||||
if (!canGenerateHot) return;
|
||||
imageAbortRef.current = { current: false };
|
||||
lastFailedActionRef.current = null;
|
||||
startHotProgress();
|
||||
void generateEcommerceImage(
|
||||
"hot", cloneReferenceImages, hotRequirement,
|
||||
hotPlatform, hotRatio, hotLanguage, hotMarket,
|
||||
undefined,
|
||||
(s: string) => {
|
||||
setHotStatus(s as DetailStatus);
|
||||
if (s === "done") {
|
||||
stopHotProgress();
|
||||
setHotProgress(100);
|
||||
} else if (s === "failed" || s === "idle") {
|
||||
stopHotProgress();
|
||||
setHotProgress(0);
|
||||
}
|
||||
},
|
||||
(res) => setHotResultUrl(res[0]?.src ?? null),
|
||||
);
|
||||
};
|
||||
|
||||
const handleHotMaterialMouseEnter = (src: string, event: ReactMouseEvent<HTMLElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const previewHalfWidth = 150;
|
||||
const previewHeight = 360;
|
||||
const gap = 12;
|
||||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
const x = Math.min(
|
||||
Math.max(rect.left + rect.width / 2, previewHalfWidth + gap),
|
||||
Math.max(previewHalfWidth + gap, viewportWidth - previewHalfWidth - gap),
|
||||
);
|
||||
const showAbove = rect.top > previewHeight + gap;
|
||||
const y = showAbove
|
||||
? rect.top - gap
|
||||
: Math.min(rect.bottom + gap, viewportHeight - gap);
|
||||
setHotMaterialHoverZoom({ src, x, y, placement: showAbove ? "above" : "below" });
|
||||
};
|
||||
const handleHotMaterialMouseLeave = () => setHotMaterialHoverZoom(null);
|
||||
|
||||
const renderHotMaterialThumbs = (items: CloneImageItem[], onRemove: (imageId: string) => void) => (
|
||||
<div className="ecom-quick-upload-thumbs" aria-label="已上传商品素材">
|
||||
{items.map((item) => (
|
||||
<figure
|
||||
key={item.id}
|
||||
className="ecom-command-asset-thumb ecom-quick-upload-thumb"
|
||||
onMouseEnter={(e) => handleHotMaterialMouseEnter(item.src, e)}
|
||||
onMouseLeave={handleHotMaterialMouseLeave}
|
||||
>
|
||||
<img src={item.src} alt={item.name} />
|
||||
<button
|
||||
type="button"
|
||||
className="ecom-hot-material-delete"
|
||||
aria-label={`删除${item.name || "图片"}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setHotMaterialHoverZoom(null);
|
||||
onRemove(item.id);
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M9 6V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v1" />
|
||||
<path d="M5 6h14" />
|
||||
<path d="M8 6l1 14h6l1-14" />
|
||||
<path d="M10.5 10v6" />
|
||||
<path d="M13.5 10v6" />
|
||||
</svg>
|
||||
</button>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const closeHotClonePage = () => {
|
||||
stopHotProgress();
|
||||
setActiveQuickTool(null);
|
||||
setHotStatus("idle");
|
||||
setHotResultUrl(null);
|
||||
setHotProgress(0);
|
||||
setHotRequirement("");
|
||||
setIsHotMaterialDragging(false);
|
||||
setHotMaterialHoverZoom(null);
|
||||
setComposerMenu(null);
|
||||
};
|
||||
|
||||
const resetTask = () => {
|
||||
setSetImages([]);
|
||||
setProductSetRequirement("");
|
||||
@@ -3387,6 +3537,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const isWatermarkTool = isCloneTool && activeQuickTool === "watermark";
|
||||
const isImageEditTool = isCloneTool && activeQuickTool === "image-edit";
|
||||
const isTranslateTool = isCloneTool && activeQuickTool === "translate";
|
||||
const isHotCloneTool = isCloneTool && activeQuickTool === "hot";
|
||||
const pageLabel = isSetTool ? "商品套图" : isDetail ? "A+/详情页" : isTryOn ? "AI服饰穿戴" : activeToolMeta?.label || "商品工具";
|
||||
const setPrimaryLabel =
|
||||
setImages.length === 0
|
||||
@@ -3664,6 +3815,19 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
{ key: "ratio", label: "尺寸/比例", value: getQuickSetRatioValue(detailRatio), options: quickSetRatioOptions, onChange: setDetailRatio },
|
||||
];
|
||||
|
||||
const quickHotBasicSelects: Array<{
|
||||
key: CloneBasicSelectKey;
|
||||
label: string;
|
||||
value: string;
|
||||
options: string[];
|
||||
onChange: (value: string) => void;
|
||||
}> = [
|
||||
{ key: "platform", label: "平台", value: hotPlatform, options: platformOptions, onChange: handleHotPlatformChange },
|
||||
{ key: "market", label: "国家", value: hotMarket, options: marketOptions, onChange: handleHotMarketChange },
|
||||
{ key: "language", label: "语种", value: hotLanguage, options: hotLanguageOptions, onChange: setHotLanguage },
|
||||
{ key: "ratio", label: "尺寸/比例", value: getQuickSetRatioValue(hotRatio), options: quickSetRatioOptions, onChange: setHotRatio },
|
||||
];
|
||||
|
||||
const cloneModelSelects: Array<{
|
||||
key: CloneModelSelectKey;
|
||||
label: string;
|
||||
@@ -3943,8 +4107,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
? cloneModelPanelTab === "scene" ? "场景设置" : "模特设置"
|
||||
: cloneOutput === "video"
|
||||
? String(cloneVideoDuration) + "秒 " + (cloneVideoQuality === "standard" ? "720P" : "1080P")
|
||||
: cloneOutput === "hot"
|
||||
? cloneReplicateLevel === "style" ? "风格复刻" : "高度复刻"
|
||||
: "换装素材";
|
||||
|
||||
const renderComposerMenu = () => {
|
||||
@@ -4081,48 +4243,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
<input type="range" min={cloneVideoDurationMin} max={cloneVideoDurationMax} step={5} value={cloneVideoDuration} onChange={(event) => setCloneVideoDuration(clampCloneVideoDuration(Number(event.target.value)))} />
|
||||
</label>
|
||||
</>
|
||||
) : cloneOutput === "hot" ? (
|
||||
<>
|
||||
<header><strong>爆款复刻设置</strong><span>{cloneReferenceImages.length}/{maxCloneReferenceImages}</span></header>
|
||||
<div className="ecom-command-hot-layout">
|
||||
<button
|
||||
type="button"
|
||||
className={`ecom-command-hot-upload${isCloneReferenceDragging ? " is-dragging" : ""}${cloneReferenceImages.length ? " has-image" : ""}`}
|
||||
onClick={() => cloneReferenceInputRef.current?.click()}
|
||||
onDragOver={handleCloneReferenceDragOver}
|
||||
onDragLeave={handleCloneReferenceDragLeave}
|
||||
onDrop={handleCloneReferenceDrop}
|
||||
>
|
||||
{cloneReferenceImages[0]?.src ? (
|
||||
<>
|
||||
<span className="ecom-command-hot-thumb-grid">
|
||||
{cloneReferenceImages.map((image, index) => (
|
||||
<span key={image.id} className="ecom-command-hot-thumb">
|
||||
<img src={image.src} alt={`参考图 ${index + 1}`} />
|
||||
<span className="ecom-command-hot-zoom" aria-hidden="true">
|
||||
<img src={image.src} alt="" />
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span>已上传 {cloneReferenceImages.length} 张,点击继续上传</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>+ 上传参考图片</strong>
|
||||
<span>支持拖拽上传</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<div className="ecom-command-hot-levels">
|
||||
{cloneReplicateLevelOptions.map((option) => (
|
||||
<button key={option.key} type="button" className={cloneReplicateLevel === option.key ? "is-active" : ""} onClick={() => setCloneReplicateLevel(option.key)}>
|
||||
<strong>{option.title}</strong><span>{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<header><strong>视频换装设置</strong><span>上传视频和服装参考</span></header>
|
||||
@@ -4624,6 +4744,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
<section className="ecom-command-quick-board" aria-label="快捷功能">
|
||||
{[
|
||||
{ label: "A+/详情页", tone: "detail", icon: <LayoutOutlined />, onClick: openQuickDetailPage },
|
||||
{ label: "爆款复刻", tone: "hot", icon: <FireOutlined />, onClick: openHotClonePage },
|
||||
{ label: "图片修改", tone: "edit", icon: <EditOutlined />, onClick: openImageWorkbenchPage },
|
||||
{ label: "智能抠图", tone: "cutout", icon: <ScissorOutlined />, onClick: openSmartCutoutUpload },
|
||||
{ label: "去除水印", tone: "watermark", icon: <ClearOutlined />, onClick: openWatermarkRemovalPage },
|
||||
@@ -5641,6 +5762,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
);
|
||||
|
||||
const quickDetailVisibleSelect = quickDetailBasicSelects.find((item) => item.key === visibleQuickSetSelect) ?? null;
|
||||
const quickHotVisibleSelect = quickHotBasicSelects.find((item) => item.key === visibleQuickSetSelect) ?? null;
|
||||
|
||||
const quickDetailPreview = (
|
||||
<main key="quick-detail" className={`ecom-quick-set-page ecom-quick-detail-page ecom-tool-page-enter${isQuickPanelCollapsed ? " is-panel-collapsed" : ""}`} aria-label="A+详情页生成">
|
||||
@@ -5838,6 +5960,255 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
</main>
|
||||
);
|
||||
|
||||
const hotClonePreview = (
|
||||
<main key="quick-hot" className={`ecom-quick-set-page ecom-quick-hot-page ecom-tool-page-enter${isQuickPanelCollapsed ? " is-panel-collapsed" : ""}`} aria-label="爆款复刻生成">
|
||||
<div className="ecom-quick-set-body">
|
||||
<aside className="ecom-quick-set-panel" aria-label="爆款复刻设置" onWheel={handleQuickPanelWheel}>
|
||||
<header className="ecom-quick-set-panel-head">
|
||||
<strong className="ecom-quick-set-page-title">爆款复刻</strong>
|
||||
<button type="button" className="ecom-quick-set-back" onClick={closeHotClonePage}>
|
||||
首页
|
||||
</button>
|
||||
<button type="button" className="ecom-quick-set-back" onClick={closeHotClonePage}>
|
||||
上一页
|
||||
</button>
|
||||
</header>
|
||||
<section>
|
||||
<strong><FileImageOutlined /> 上传素材</strong>
|
||||
{productImages.length ? (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`ecom-quick-set-upload ecom-quick-hot-material has-images${isHotMaterialDragging ? " is-dragging" : ""}`}
|
||||
onClick={() => hotMaterialInputRef.current?.click()}
|
||||
onKeyDown={(event) => openQuickUploadWithKeyboard(event, hotMaterialInputRef)}
|
||||
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); if (event.dataTransfer.types.includes("Files")) setIsHotMaterialDragging(true); }}
|
||||
onDragLeave={(event) => { event.preventDefault(); event.stopPropagation(); if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) setIsHotMaterialDragging(false); }}
|
||||
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); setIsHotMaterialDragging(false); const files = Array.from(event.dataTransfer.files); if (files.length) addProductImages(files); }}
|
||||
>
|
||||
{renderHotMaterialThumbs(productImages, removeProductImage)}
|
||||
<button
|
||||
type="button"
|
||||
className="ecom-quick-hot-add-btn"
|
||||
aria-label="添加更多素材"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
hotMaterialInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<PlusOutlined />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`ecom-quick-set-upload ecom-quick-hot-material${isHotMaterialDragging ? " is-dragging" : ""}`}
|
||||
onClick={() => hotMaterialInputRef.current?.click()}
|
||||
onKeyDown={(event) => openQuickUploadWithKeyboard(event, hotMaterialInputRef)}
|
||||
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); if (event.dataTransfer.types.includes("Files")) setIsHotMaterialDragging(true); }}
|
||||
onDragLeave={(event) => { event.preventDefault(); event.stopPropagation(); if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) setIsHotMaterialDragging(false); }}
|
||||
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); setIsHotMaterialDragging(false); const files = Array.from(event.dataTransfer.files); if (files.length) addProductImages(files); }}
|
||||
>
|
||||
<FileImageOutlined />
|
||||
<span>拖拽或点击上传</span>
|
||||
<em>上传商品素材图,最多 {maxCloneProductImages} 张</em>
|
||||
<b>+ 上传图片</b>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={hotMaterialInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="ecom-command-hidden-file"
|
||||
onChange={handleProductUpload}
|
||||
aria-label="上传爆款复刻素材"
|
||||
/>
|
||||
</section>
|
||||
<section>
|
||||
<strong><FireOutlined /> 上传参考图片</strong>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`ecom-quick-set-upload${cloneReferenceImages.length ? " has-images" : ""}${isCloneReferenceDragging ? " is-dragging" : ""}`}
|
||||
onClick={() => cloneReferenceInputRef.current?.click()}
|
||||
onKeyDown={(event) => openQuickUploadWithKeyboard(event, cloneReferenceInputRef)}
|
||||
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); if (event.dataTransfer.types.includes("Files")) setIsCloneReferenceDragging(true); }}
|
||||
onDragLeave={(event) => { event.preventDefault(); event.stopPropagation(); if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) setIsCloneReferenceDragging(false); }}
|
||||
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); setIsCloneReferenceDragging(false); const files = Array.from(event.dataTransfer.files); if (files.length) addCloneReferenceImages(files); }}
|
||||
>
|
||||
<FileImageOutlined />
|
||||
<span>拖拽或点击上传</span>
|
||||
<em>参考图用于风格迁移,最多 {maxCloneReferenceImages} 张</em>
|
||||
<b>+ 上传图片</b>
|
||||
{cloneReferenceImages.length ? renderQuickUploadThumbs(cloneReferenceImages, removeCloneReferenceImage) : null}
|
||||
</div>
|
||||
<input
|
||||
ref={cloneReferenceInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="ecom-command-hidden-file"
|
||||
onChange={handleCloneReferenceUpload}
|
||||
aria-label="上传爆款复刻参考图"
|
||||
/>
|
||||
</section>
|
||||
<section>
|
||||
<strong>复刻强度</strong>
|
||||
<div className="ecom-quick-detail-modules">
|
||||
{cloneReplicateLevelOptions.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
className={cloneReplicateLevel === option.key ? "is-active" : ""}
|
||||
onClick={() => setCloneReplicateLevel(option.key)}
|
||||
>
|
||||
<strong>{option.title}</strong>
|
||||
<span>{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="ecom-quick-set-basic-section">
|
||||
<span className="ecom-quick-set-label">基础设置</span>
|
||||
<div className="ecom-quick-set-select-anchor">
|
||||
<div className="ecom-quick-set-selects">
|
||||
{quickHotBasicSelects.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={openQuickSetSelect === item.key ? "is-active" : ""}
|
||||
onClick={() => toggleQuickSetSelect(item.key)}
|
||||
>
|
||||
<span>{item.label}</span><strong>{formatRatioDisplayValue(item.value)}</strong><em>⌄</em>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{quickHotVisibleSelect ? (
|
||||
<div
|
||||
className={`ecom-quick-set-dropdown ecom-quick-set-dropdown--${quickHotVisibleSelect.key}${isQuickSetSelectClosing ? " is-closing" : ""}`}
|
||||
role="listbox"
|
||||
aria-label={quickHotVisibleSelect.label}
|
||||
>
|
||||
{quickHotVisibleSelect.options.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
className={quickHotVisibleSelect.value === option ? "is-active" : ""}
|
||||
onClick={() => {
|
||||
quickHotVisibleSelect.onChange(option);
|
||||
closeQuickSetSelect();
|
||||
}}
|
||||
>
|
||||
{formatRatioDisplayValue(option)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<section className="ecom-quick-hot-requirement">
|
||||
<div className="ecom-quick-hot-requirement__head">
|
||||
<strong>商品卖点 & 需求</strong>
|
||||
<button type="button" className="ecom-quick-hot-requirement__ai" onClick={handleHotAiWrite}>AI 帮写</button>
|
||||
</div>
|
||||
<div className="ecom-quick-hot-requirement__input">
|
||||
<textarea
|
||||
value={hotRequirement}
|
||||
onChange={(event) => setHotRequirement(event.target.value.slice(0, 500))}
|
||||
placeholder="建议包含以下信息:产品名称、核心卖点、参考风格、期望场景、具体参数"
|
||||
maxLength={500}
|
||||
/>
|
||||
<span>{hotRequirement.length}/500</span>
|
||||
</div>
|
||||
</section>
|
||||
<div className="ecom-quick-hot-actions">
|
||||
<button type="button" className="ecom-quick-set-primary ecom-quick-hot-generate" onClick={handleHotGenerate} disabled={!canGenerateHot}>
|
||||
{hotStatus === "generating" ? <LoadingOutlined /> : "✦"} 开始复刻
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ecom-quick-set-primary ecom-quick-set-primary--cancel${hotStatus !== "generating" ? " is-disabled" : ""}`}
|
||||
onClick={hotStatus === "generating" ? handleCancelGenerate : undefined}
|
||||
disabled={hotStatus !== "generating"}
|
||||
>
|
||||
取消复刻
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
{hotMaterialHoverZoom && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div
|
||||
className={`ecom-hot-material-zoom-portal is-${hotMaterialHoverZoom.placement}`}
|
||||
style={{ left: hotMaterialHoverZoom.x, top: hotMaterialHoverZoom.y }}
|
||||
>
|
||||
<img src={hotMaterialHoverZoom.src} alt="" />
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
<section className="ecom-quick-set-stage">
|
||||
<header className="ecom-quick-set-preview-head">
|
||||
<h1>预览</h1>
|
||||
<p>上传参考图,AI 按选定风格强度 <span>复刻同款视觉表现</span>,快速产出高转化素材。</p>
|
||||
<div>
|
||||
<button type="button" onClick={() => setPreviewZoom((value) => Math.max(0.25, value - 0.1))}>-</button>
|
||||
<strong>{Math.round(previewZoom * 100)}%</strong>
|
||||
<button type="button" onClick={() => setPreviewZoom((value) => Math.min(2, value + 0.1))}>+</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="ecom-quick-set-canvas" onWheel={handleQuickPreviewWheel}>
|
||||
{hotStatus === "done" && hotResultUrl ? (
|
||||
<section className="ecom-quick-detail-result" style={{ transform: `scale(${previewZoom})` }}>
|
||||
<img src={hotResultUrl} alt="爆款复刻结果" />
|
||||
<button
|
||||
type="button"
|
||||
className="ecom-quick-detail-download"
|
||||
onClick={() => {
|
||||
const link = document.createElement("a");
|
||||
link.href = hotResultUrl;
|
||||
link.download = `爆款复刻-${Date.now()}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}}
|
||||
>
|
||||
<CloudUploadOutlined />
|
||||
保存本地
|
||||
</button>
|
||||
</section>
|
||||
) : hotStatus === "generating" ? (
|
||||
<section className="ecom-quick-set-generating">
|
||||
<LoadingOutlined />
|
||||
<strong>正在生成爆款复刻</strong>
|
||||
<span>AI 正在根据参考图和复刻强度生成同款素材,请稍候...</span>
|
||||
<div className="ecom-quick-set-progress">
|
||||
<div className="ecom-quick-set-progress-bar" style={{ width: `${Math.round(hotProgress)}%` }} />
|
||||
</div>
|
||||
<em className="ecom-quick-set-progress-text">{Math.round(hotProgress)}%</em>
|
||||
</section>
|
||||
) : hotStatus === "failed" ? (
|
||||
<section className="ecom-quick-set-failed">
|
||||
<FrownOutlined />
|
||||
<strong>生成失败</strong>
|
||||
<span>请检查网络或重试,如余额不足请先充值。</span>
|
||||
<button type="button" onClick={handleHotGenerate} disabled={!canGenerateHot}>重新生成</button>
|
||||
</section>
|
||||
) : (
|
||||
<section className="ecom-quick-set-empty">
|
||||
<FileImageOutlined />
|
||||
<strong>等待生成</strong>
|
||||
<span>上传参考图片并选择复刻强度后,AI 将在这里展示生成结果。</span>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<button type="button" className="ecom-quick-set-help" aria-label="帮助" onClick={() => toast.info("上传参考图后,选择复刻强度和平台即可生成爆款同款。")}>?</button>
|
||||
</main>
|
||||
);
|
||||
|
||||
const detailPreview = (
|
||||
<main className="product-clone-preview product-clone-preview--detail" aria-label="A+详情预览" onWheel={handlePreviewWheel}>
|
||||
<div className="product-clone-preview__headline">
|
||||
@@ -5954,6 +6325,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
{quickDetailPreview}
|
||||
</div>
|
||||
)
|
||||
: isHotCloneTool
|
||||
? (
|
||||
<div key={`quick-${activeQuickTool}`} className="ecom-quick-page-wrap ecom-tool-page-enter">
|
||||
{hotClonePreview}
|
||||
</div>
|
||||
)
|
||||
: clonePreview
|
||||
: placeholderPreview;
|
||||
const isMainCloneWorkspace = isCloneTool && !isSmartCutoutTool && !isQuickDetailTool && !isWatermarkTool && !isTranslateTool && !isImageEditTool;
|
||||
@@ -5964,7 +6341,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`product-clone-page page-motion${isCloneTool && isCloneSettingsCollapsed ? " is-settings-collapsed" : ""}${isCloneTool && isCommandHistoryCollapsed ? " is-history-collapsed" : ""}${isRecordDetailWorkspace && isCloneConversationCollapsed ? " is-conversation-collapsed" : ""}${isRecordDetailWorkspace ? " is-history-detail" : ""}${isSmartCutoutTool ? " is-smart-cutout-page" : ""}${isQuickDetailTool ? " is-quick-set-page" : ""}${isWatermarkTool ? " is-watermark-page" : ""}${isTranslateTool ? " is-translate-page" : ""}${isImageEditTool ? " is-image-workbench-page" : ""}`}
|
||||
className={`product-clone-page page-motion${isCloneTool && isCloneSettingsCollapsed ? " is-settings-collapsed" : ""}${isCloneTool && isCommandHistoryCollapsed ? " is-history-collapsed" : ""}${isRecordDetailWorkspace && isCloneConversationCollapsed ? " is-conversation-collapsed" : ""}${isRecordDetailWorkspace ? " is-history-detail" : ""}${isSmartCutoutTool ? " is-smart-cutout-page" : ""}${isQuickDetailTool ? " is-quick-set-page" : ""}${isWatermarkTool ? " is-watermark-page" : ""}${isTranslateTool ? " is-translate-page" : ""}${isImageEditTool ? " is-image-workbench-page" : ""}${isHotCloneTool ? " is-hot-clone-page" : ""}`}
|
||||
data-tool={activeTool}
|
||||
aria-label={pageLabel}
|
||||
>
|
||||
@@ -5988,7 +6365,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
{isSetTool ? setPanel : isDetail ? detailPanel : isTryOn ? tryOnPanel : isCloneTool ? clonePanel : placeholderPanel}
|
||||
</aside>
|
||||
|
||||
{isCloneTool && !isSmartCutoutTool && !isQuickDetailTool && !isWatermarkTool && !isTranslateTool && !isImageEditTool ? (
|
||||
{isCloneTool && !isSmartCutoutTool && !isQuickDetailTool && !isWatermarkTool && !isTranslateTool && !isImageEditTool && !isHotCloneTool ? (
|
||||
<button
|
||||
type="button"
|
||||
className="clone-ai-settings-toggle"
|
||||
|
||||
@@ -381,108 +381,6 @@ export default function EcommerceClonePanel({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{cloneOutput === "hot" ? (
|
||||
<section className="clone-ai-replicate-panel" aria-label="爆款图复刻设置">
|
||||
<div className="clone-ai-dynamic-head">
|
||||
<strong>爆款图参考设置</strong>
|
||||
<span>随生成模式切换</span>
|
||||
</div>
|
||||
<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${isCloneReferenceDragging ? " is-dragging" : ""}${cloneReferenceImages.length ? " has-files" : ""}`}
|
||||
onClick={() => cloneReferenceInputRef.current?.click()}
|
||||
onDragOver={handleCloneReferenceDragOver}
|
||||
onDragLeave={handleCloneReferenceDragLeave}
|
||||
onDrop={handleCloneReferenceDrop}
|
||||
>
|
||||
{cloneReferenceImages.length ? (
|
||||
<>
|
||||
<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="" />
|
||||
</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>
|
||||
) : (
|
||||
<label className="clone-ai-replicate-link">
|
||||
<input placeholder="粘贴商品图或详情页链接" />
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={cloneReferenceInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
onChange={handleCloneReferenceUpload}
|
||||
aria-label="上传参考图片"
|
||||
/>
|
||||
</div>
|
||||
<div className="clone-ai-replicate-section">
|
||||
<span className="clone-ai-replicate-title">复刻程度</span>
|
||||
<div className="clone-ai-replicate-levels" role="toolbar" 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="套图图片数量">
|
||||
<div className="clone-ai-dynamic-head">
|
||||
|
||||
Reference in New Issue
Block a user