feat: refine ecommerce smart cutout editor

This commit is contained in:
Codex
2026-06-10 17:52:01 +08:00
parent 89eeb68aee
commit 7636333978
2 changed files with 2029 additions and 15 deletions
+709 -15
View File
@@ -3,7 +3,9 @@
CloudUploadOutlined,
CloseOutlined,
FileImageOutlined,
FolderOpenOutlined,
FrownOutlined,
GlobalOutlined,
LoadingOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
@@ -11,8 +13,9 @@
ReloadOutlined,
SettingOutlined,
SkinOutlined,
TableOutlined,
} from "@ant-design/icons";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import "../../styles/pages/ecommerce.css";
import "../../styles/pages/local-theme-parity.css";
import { ossAssets } from "../../data/ossAssets";
@@ -35,6 +38,136 @@ import pinduoduoLogo from "../../assets/platform-logos/pinduoduo.webp";
import shopeeLogo from "../../assets/platform-logos/shopee.webp";
import taobaoLogo from "../../assets/platform-logos/taobao.webp";
import tiktokShopLogo from "../../assets/platform-logos/tiktok-shop.webp";
const smartCutoutColorPresets = [
"#ffffff",
"#111111",
"#ff3131",
"#ff7a1a",
"#f7c600",
"#29b34a",
"#25a9e0",
"#438df5",
"#9029d9",
"#8aa3ad",
"#6b7b86",
"#f46f7b",
"#ff9451",
"#f7d34f",
"#55c66f",
"#73c7f3",
"#6dabf5",
"#b45adb",
"#bcc8ce",
"#aeb7bd",
"#ffbec4",
"#ffd1ac",
"#f8e69d",
"#91de9e",
"#b7e5fb",
"#b9d9fb",
"#d7abe8",
"#dfe5e8",
"#d7dde0",
"#ffe2e4",
"#ffe5d1",
"#f8efcf",
"#c9efcf",
"#d8f0fb",
"#d8eafa",
"#ead2f1",
];
const smartCutoutSizeOptions = [
{ key: "original", label: "原尺寸", icon: "image", frameWidth: "min(520px, 78%)", frameAspect: "auto", imageMaxWidth: "78%", imageMaxHeight: "310px" },
{ key: "trim", label: "裁剪到边缘", icon: "crop", frameWidth: "min(420px, 70%)", frameAspect: "auto", imageMaxWidth: "92%", imageMaxHeight: "360px" },
{ key: "taobao-1-1", label: "淘宝1:1主图", icon: "shop", frameWidth: "min(430px, 72%)", frameAspect: "1 / 1", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "taobao-3-4", label: "淘宝3:4主图", icon: "shop", frameWidth: "min(330px, 56%)", frameAspect: "3 / 4", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "pdd-main", label: "拼多多主图", icon: "pdd", frameWidth: "min(430px, 72%)", frameAspect: "1 / 1", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "xiaohongshu-cover", label: "小红书封面", icon: "text", frameWidth: "min(330px, 56%)", frameAspect: "3 / 4", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "one-inch", label: "一寸头像", icon: "portrait", frameWidth: "min(290px, 50%)", frameAspect: "25 / 35", imageMaxWidth: "86%", imageMaxHeight: "86%" },
{ key: "two-inch", label: "二寸头像", icon: "portrait", frameWidth: "min(320px, 54%)", frameAspect: "35 / 49", imageMaxWidth: "86%", imageMaxHeight: "86%" },
{ key: "ratio-1-1", label: "1:1", icon: "square", frameWidth: "min(430px, 72%)", frameAspect: "1 / 1", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "ratio-3-2", label: "3:2", icon: "landscape", frameWidth: "min(520px, 78%)", frameAspect: "3 / 2", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "ratio-2-3", label: "2:3", icon: "portrait-ratio", frameWidth: "min(330px, 56%)", frameAspect: "2 / 3", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "ratio-4-3", label: "4:3", icon: "landscape", frameWidth: "min(520px, 78%)", frameAspect: "4 / 3", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "ratio-3-4", label: "3:4", icon: "portrait-ratio", frameWidth: "min(330px, 56%)", frameAspect: "3 / 4", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "ratio-16-9", label: "16:9", icon: "wide", frameWidth: "min(560px, 82%)", frameAspect: "16 / 9", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "ratio-9-16", label: "9:16", icon: "phone", frameWidth: "min(260px, 46%)", frameAspect: "9 / 16", imageMaxWidth: "82%", imageMaxHeight: "82%" },
] as const;
type SmartCutoutSizeKey = (typeof smartCutoutSizeOptions)[number]["key"];
const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const normalizeHexColor = (value: string) => {
const clean = value.trim().replace(/^#/, "");
if (!/^[0-9a-fA-F]{6}$/.test(clean)) return null;
return `#${clean.toLowerCase()}`;
};
const hexToRgb = (value: string) => {
const normalized = normalizeHexColor(value);
if (!normalized) return null;
const numeric = Number.parseInt(normalized.slice(1), 16);
return {
r: (numeric >> 16) & 255,
g: (numeric >> 8) & 255,
b: numeric & 255,
};
};
const rgbToHex = (r: number, g: number, b: number) =>
`#${[r, g, b].map((item) => clampNumber(Math.round(item), 0, 255).toString(16).padStart(2, "0")).join("")}`;
const hsvToRgb = (h: number, s: number, v: number) => {
const hue = ((h % 360) + 360) % 360;
const saturation = clampNumber(s, 0, 100) / 100;
const value = clampNumber(v, 0, 100) / 100;
const chroma = value * saturation;
const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
const match = value - chroma;
const [red, green, blue] =
hue < 60
? [chroma, x, 0]
: hue < 120
? [x, chroma, 0]
: hue < 180
? [0, chroma, x]
: hue < 240
? [0, x, chroma]
: hue < 300
? [x, 0, chroma]
: [chroma, 0, x];
return {
r: (red + match) * 255,
g: (green + match) * 255,
b: (blue + match) * 255,
};
};
const hexToHsv = (value: string) => {
const rgb = hexToRgb(value) ?? { r: 255, g: 255, b: 255 };
const red = rgb.r / 255;
const green = rgb.g / 255;
const blue = rgb.b / 255;
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
const delta = max - min;
const hue =
delta === 0
? 0
: max === red
? 60 * (((green - blue) / delta) % 6)
: max === green
? 60 * ((blue - red) / delta + 2)
: 60 * ((red - green) / delta + 4);
return {
h: Math.round((hue + 360) % 360),
s: max === 0 ? 0 : Math.round((delta / max) * 100),
v: Math.round(max * 100),
};
};
import tmallLogo from "../../assets/platform-logos/tmall.webp";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { ServerRequestError } from "../../api/serverConnection";
@@ -991,6 +1124,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const setInputRef = useRef<HTMLInputElement>(null);
const productInputRef = useRef<HTMLInputElement>(null);
const cloneReferenceInputRef = useRef<HTMLInputElement>(null);
const smartCutoutInputRef = useRef<HTMLInputElement>(null);
const smartCutoutTransitionTimeoutRef = useRef<number | null>(null);
const smartCutoutPendingUrlsRef = useRef<string[]>([]);
const smartCutoutPaletteRef = useRef<HTMLDivElement>(null);
const smartCutoutToolsRef = useRef<HTMLDivElement>(null);
const composerMenuCloseTimeoutRef = useRef<number | null>(null);
const requirementTextareaRef = useRef<HTMLTextAreaElement>(null);
const commandComposerWrapRef = useRef<HTMLElement | null>(null);
const garmentInputRef = useRef<HTMLInputElement>(null);
@@ -1025,12 +1164,28 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [selectedProductSetPreview, setSelectedProductSetPreview] = useState<{ src: string; label: string } | null>(null);
const [showHostingModal, setShowHostingModal] = useState(false);
const [productImages, setProductImages] = useState<CloneImageItem[]>([]);
const [activeQuickTool, setActiveQuickTool] = useState<"cutout" | null>(null);
const [smartCutoutImage, setSmartCutoutImage] = useState<{ src: string; name: string } | null>(null);
const [smartCutoutBatchImages, setSmartCutoutBatchImages] = useState<{ src: string; name: string }[]>([]);
const [smartCutoutBackgroundColor, setSmartCutoutBackgroundColor] = useState("#ffffff");
const [smartCutoutBackgroundAlpha, setSmartCutoutBackgroundAlpha] = useState(100);
const [smartCutoutHexDraft, setSmartCutoutHexDraft] = useState("#ffffff");
const [isSmartCutoutPaletteOpen, setIsSmartCutoutPaletteOpen] = useState(false);
const [smartCutoutSizeKey, setSmartCutoutSizeKey] = useState<SmartCutoutSizeKey>("original");
const [isSmartCutoutDragging, setIsSmartCutoutDragging] = useState(false);
const [isSmartCutoutTransitioning, setIsSmartCutoutTransitioning] = useState(false);
const [smartCutoutTransitionMessage, setSmartCutoutTransitionMessage] = useState({
title: "正在切换页面",
subtitle: "请稍候",
});
const [isProductUploadDragging, setIsProductUploadDragging] = useState(false);
const [cloneOutput, setCloneOutput] = useState<CloneOutputKey>(defaultCloneOutput);
const [videoHistoryVisible, setVideoHistoryVisible] = useState(false);
const [videoPlanTrigger, setVideoPlanTrigger] = useState(0);
const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState<CloneBasicSelectKey | null>(null);
const [composerMenu, setComposerMenu] = useState<ComposerMenuKey | null>(null);
const [visibleComposerMenu, setVisibleComposerMenu] = useState<ComposerMenuKey | null>(null);
const [isComposerMenuClosing, setIsComposerMenuClosing] = useState(false);
const [composerPopoverLeft, setComposerPopoverLeft] = useState(0);
const [isCommandHistoryCollapsed, setIsCommandHistoryCollapsed] = useState(false);
const [openCloneModelSelect, setOpenCloneModelSelect] = useState<CloneModelSelectKey | null>(null);
@@ -1414,6 +1569,219 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
if (files.length) void addSetImages(files);
};
const revokeSmartCutoutItems = (items: { src: string }[]) => {
items.forEach((item) => URL.revokeObjectURL(item.src));
};
const clearSmartCutoutTransition = () => {
if (smartCutoutTransitionTimeoutRef.current !== null) {
window.clearTimeout(smartCutoutTransitionTimeoutRef.current);
smartCutoutTransitionTimeoutRef.current = null;
}
if (smartCutoutPendingUrlsRef.current.length) {
smartCutoutPendingUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
smartCutoutPendingUrlsRef.current = [];
}
setIsSmartCutoutTransitioning(false);
};
const runSmartCutoutPageTransition = (message: { title: string; subtitle: string }, action: () => void, delay = 460) => {
clearSmartCutoutTransition();
setSmartCutoutTransitionMessage(message);
setIsSmartCutoutTransitioning(true);
smartCutoutTransitionTimeoutRef.current = window.setTimeout(() => {
smartCutoutTransitionTimeoutRef.current = null;
action();
setIsSmartCutoutTransitioning(false);
}, delay);
};
const openSmartCutoutUpload = () => {
clearSmartCutoutTransition();
setSmartCutoutTransitionMessage({
title: "正在进入智能抠图",
subtitle: "为你打开图片处理工具",
});
setActiveQuickTool("cutout");
setSmartCutoutBatchImages((current) => {
revokeSmartCutoutItems(current);
return [];
});
setSmartCutoutImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return null;
});
setComposerMenu(null);
};
const closeSmartCutoutTool = () => {
runSmartCutoutPageTransition(
{
title: "正在返回首页",
subtitle: "回到电商智能体",
},
() => {
setSmartCutoutBatchImages((current) => {
revokeSmartCutoutItems(current);
return [];
});
setSmartCutoutImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return null;
});
setActiveQuickTool(null);
setComposerMenu(null);
},
);
};
const goSmartCutoutPrevious = () => {
if (!smartCutoutImage) {
closeSmartCutoutTool();
return;
}
runSmartCutoutPageTransition(
{
title: "正在返回上一页",
subtitle: "回到图片上传页",
},
() => {
setSmartCutoutBatchImages((current) => {
revokeSmartCutoutItems(current);
return [];
});
setSmartCutoutImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return null;
});
},
);
};
const addSmartCutoutImage = (files: File[]) => {
const imageFiles = files.filter((file) => file.type.startsWith("image/"));
if (!imageFiles.length) {
toast.error("请上传图片文件");
return;
}
clearSmartCutoutTransition();
setSmartCutoutBatchImages((current) => {
revokeSmartCutoutItems(current);
return [];
});
setSmartCutoutImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return null;
});
const nextImages = imageFiles.map((file) => ({ src: URL.createObjectURL(file), name: file.name }));
smartCutoutPendingUrlsRef.current = nextImages.map((item) => item.src);
setActiveQuickTool("cutout");
setSmartCutoutSizeKey("original");
setSmartCutoutTransitionMessage({
title: imageFiles.length > 1 ? "正在批量抠图" : "正在智能抠图",
subtitle: imageFiles.length > 1 ? `正在处理 ${imageFiles.length} 张图片` : "即将进入图片编辑室",
});
setIsSmartCutoutTransitioning(true);
smartCutoutTransitionTimeoutRef.current = window.setTimeout(() => {
smartCutoutTransitionTimeoutRef.current = null;
smartCutoutPendingUrlsRef.current = [];
setSmartCutoutBatchImages(nextImages);
setSmartCutoutImage(nextImages[0]);
setIsSmartCutoutTransitioning(false);
}, 620);
};
const handleSmartCutoutUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
addSmartCutoutImage(Array.from(files));
event.target.value = "";
};
const handleSmartCutoutDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsSmartCutoutDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) addSmartCutoutImage(files);
};
const smartCutoutBackgroundValue = useMemo(() => {
const rgb = hexToRgb(smartCutoutBackgroundColor) ?? { r: 255, g: 255, b: 255 };
if (smartCutoutBackgroundAlpha >= 100) return smartCutoutBackgroundColor;
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${Math.round(smartCutoutBackgroundAlpha) / 100})`;
}, [smartCutoutBackgroundAlpha, smartCutoutBackgroundColor]);
const smartCutoutColorHsv = useMemo(() => hexToHsv(smartCutoutBackgroundColor), [smartCutoutBackgroundColor]);
const selectedSmartCutoutSize = useMemo(
() => smartCutoutSizeOptions.find((option) => option.key === smartCutoutSizeKey) ?? smartCutoutSizeOptions[0],
[smartCutoutSizeKey],
);
const smartCutoutFrameStyle = useMemo<CSSProperties>(
() => ({
"--smart-cutout-bg": smartCutoutBackgroundValue,
"--smart-cutout-frame-width": selectedSmartCutoutSize.frameWidth,
"--smart-cutout-frame-aspect": selectedSmartCutoutSize.frameAspect,
"--smart-cutout-image-max-width": selectedSmartCutoutSize.imageMaxWidth,
"--smart-cutout-image-max-height": selectedSmartCutoutSize.imageMaxHeight,
} as CSSProperties),
[selectedSmartCutoutSize, smartCutoutBackgroundValue],
);
const applySmartCutoutHsv = (h: number, s: number, v: number) => {
const rgb = hsvToRgb(h, s, v);
setSmartCutoutBackgroundColor(rgbToHex(rgb.r, rgb.g, rgb.b));
};
const updateSmartCutoutColorFromPoint = (element: HTMLElement, clientX: number, clientY: number) => {
const rect = element.getBoundingClientRect();
const saturation = clampNumber(((clientX - rect.left) / rect.width) * 100, 0, 100);
const value = clampNumber(100 - ((clientY - rect.top) / rect.height) * 100, 0, 100);
applySmartCutoutHsv(smartCutoutColorHsv.h, saturation, value);
};
const handleSmartCutoutColorPlanePointer = (event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
updateSmartCutoutColorFromPoint(event.currentTarget, event.clientX, event.clientY);
};
const handleSmartCutoutColorPlaneMove = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (event.buttons !== 1) return;
updateSmartCutoutColorFromPoint(event.currentTarget, event.clientX, event.clientY);
};
const handleSmartCutoutHexChange = (value: string) => {
const nextValue = value.startsWith("#") ? value : `#${value}`;
if (!/^#[0-9a-fA-F]{0,6}$/.test(nextValue)) return;
setSmartCutoutHexDraft(nextValue);
const normalized = normalizeHexColor(nextValue);
if (normalized) setSmartCutoutBackgroundColor(normalized);
};
const scrollSmartCutoutTools = (direction: -1 | 1) => {
smartCutoutToolsRef.current?.scrollBy({
left: direction * 340,
behavior: "smooth",
});
};
useEffect(() => {
setSmartCutoutHexDraft(smartCutoutBackgroundColor);
}, [smartCutoutBackgroundColor]);
useEffect(() => {
if (!isSmartCutoutPaletteOpen) return undefined;
const handlePointerDown = (event: PointerEvent) => {
if (!smartCutoutPaletteRef.current?.contains(event.target as Node)) {
setIsSmartCutoutPaletteOpen(false);
}
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [isSmartCutoutPaletteOpen]);
const removeSetImage = (imageId: string) => {
setSetImages((current) => {
const next = current.filter((item) => item.id !== imageId);
@@ -1869,6 +2237,34 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [composerMenu, isCommandComposerCompact, status]);
useEffect(() => {
if (composerMenuCloseTimeoutRef.current !== null) {
window.clearTimeout(composerMenuCloseTimeoutRef.current);
composerMenuCloseTimeoutRef.current = null;
}
if (composerMenu) {
setVisibleComposerMenu(composerMenu);
setIsComposerMenuClosing(false);
return;
}
if (!visibleComposerMenu) return;
setIsComposerMenuClosing(true);
composerMenuCloseTimeoutRef.current = window.setTimeout(() => {
composerMenuCloseTimeoutRef.current = null;
setVisibleComposerMenu(null);
setIsComposerMenuClosing(false);
}, 220);
}, [composerMenu, visibleComposerMenu]);
useEffect(
() => () => {
if (composerMenuCloseTimeoutRef.current !== null) {
window.clearTimeout(composerMenuCloseTimeoutRef.current);
}
},
[],
);
useEffect(() => {
if (!openCloneModelSelect) return undefined;
@@ -2462,6 +2858,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const isDetail = activeTool === "detail";
const isTryOn = activeTool === "wear";
const isCloneTool = activeTool === "clone";
const isSmartCutoutTool = isCloneTool && activeQuickTool === "cutout";
const pageLabel = isSetTool ? "鍟嗗搧濂楀浘" : isDetail ? "A+/璇︽儏椤?" : isTryOn ? "AI鏈嶉グ绌挎埓" : activeToolMeta?.label || "鍟嗗搧宸ュ叿";
const setPrimaryLabel =
setImages.length === 0
@@ -2948,10 +3345,13 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
countries: marketLanguageOptions.filter((option) => option.languages.includes(item)).map((option) => option.country),
}));
const composerPopoverStyle: CSSProperties = { left: composerPopoverLeft };
if (!composerMenu) return null;
if (composerMenu === "mode") {
const menuToRender = composerMenu ?? visibleComposerMenu;
if (!menuToRender) return null;
const popoverClosingClass = !composerMenu && isComposerMenuClosing ? " is-closing" : "";
const composerPopoverKey = `${menuToRender}-${cloneOutput}-${popoverClosingClass ? "closing" : "open"}`;
if (menuToRender === "mode") {
return (
<div className="ecom-command-popover ecom-command-popover--grid" style={composerPopoverStyle}>
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--grid${popoverClosingClass}`} style={composerPopoverStyle}>
{visibleCloneOutputOptions.map((option) => (
<button key={option.key} type="button" className={cloneOutput === option.key ? "is-active" : ""} onClick={() => { handleCloneOutputChange(option.key); setComposerMenu(null); }}>
{option.label}
@@ -2960,9 +3360,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</div>
);
}
if (composerMenu === "platform") {
if (menuToRender === "platform") {
return (
<div className="ecom-command-popover ecom-command-popover--list ecom-command-popover--ratio ecom-command-popover--platform" style={composerPopoverStyle}>
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--list ecom-command-popover--ratio ecom-command-popover--platform${popoverClosingClass}`} style={composerPopoverStyle}>
{platformOptions.map((option) => (
<button key={option} type="button" className={platform === option ? "is-active" : ""} onClick={() => { handleClonePlatformChange(option); setComposerMenu(null); }}>
{renderPlatformLogo(option)}
@@ -2972,9 +3372,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</div>
);
}
if (composerMenu === "language") {
if (menuToRender === "language") {
return (
<div className="ecom-command-popover ecom-command-popover--languages" style={composerPopoverStyle}>
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--languages${popoverClosingClass}`} style={composerPopoverStyle}>
{composerLanguageOptions.map((option) => (
<button key={option.language} type="button" className={language === option.language ? "is-active" : ""} onClick={() => { setLanguage(option.language); setComposerMenu(null); }}>
<strong>{option.language}</strong>
@@ -2984,9 +3384,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</div>
);
}
if (composerMenu === "ratio") {
if (menuToRender === "ratio") {
return (
<div className="ecom-command-popover ecom-command-popover--list" style={composerPopoverStyle}>
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--list${popoverClosingClass}`} style={composerPopoverStyle}>
{cloneRatioOptions.map((option) => (
<button key={option} type="button" className={ratio === option ? "is-active" : ""} onClick={() => { setRatio(option); setComposerMenu(null); }}>
<span className="ecom-command-button-text">{formatRatioDisplayValue(option)}</span>
@@ -2996,7 +3396,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
);
}
return (
<div className={`ecom-command-popover ecom-command-popover--settings ecom-command-popover--settings-${cloneOutput}`} style={composerPopoverStyle}>
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--settings ecom-command-popover--settings-${cloneOutput}${popoverClosingClass}`} style={composerPopoverStyle}>
{cloneOutput === "set" ? (
<>
<header><strong></strong><span> 1-16 </span></header>
@@ -3299,7 +3699,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</div>
</section>
</div>
) : (
) : status === "idle" || status === "ready" ? null : (
<section className="clone-ai-empty-state" aria-live="polite">
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <FrownOutlined /> : <FileImageOutlined />}
<strong>{status === "generating" ? "正在生成" : status === "failed" ? "生成失败" : "等待生成"}</strong>
@@ -3346,6 +3746,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
className="ecom-command-hidden-file"
onChange={handleComposerAssetUpload}
/>
<input
ref={smartCutoutInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleSmartCutoutUpload}
/>
<div className="clone-ai-input-wrapper ecom-command-composer">
<button
type="button"
@@ -3432,18 +3840,23 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<strong></strong>
</button>
<button type="button" className={composerMenu === "mode" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("mode", event)}>
<span className="ecom-command-option-icon" aria-hidden="true"><AppstoreOutlined /></span>
{selectedCloneOutput.label}<span></span>
</button>
<button type="button" className={composerMenu === "platform" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("platform", event)}>
<span className="ecom-command-option-icon" aria-hidden="true"><GlobalOutlined /></span>
<span></span>{platform}
</button>
<button type="button" className={composerMenu === "language" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("language", event)}>
<span className="ecom-command-option-icon" aria-hidden="true"><FileImageOutlined /></span>
<span></span>{language}
</button>
<button type="button" className={composerMenu === "ratio" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("ratio", event)}>
<span className="ecom-command-option-icon" aria-hidden="true"><TableOutlined /></span>
<span></span>{formatRatioDisplayValue(ratio)}
</button>
<button type="button" className={composerMenu === "settings" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("settings", event)}>
<span className="ecom-command-option-icon" aria-hidden="true"><SettingOutlined /></span>
<span></span>{composerSettingLabel}
</button>
</div>
@@ -3455,11 +3868,292 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</div>
{renderComposerMenu()}
</div>
{status !== "done" ? (
<section className="ecom-command-quick-board" aria-label="快捷功能">
{[
{ label: "智能抠图", icon: <FileImageOutlined />, onClick: openSmartCutoutUpload },
{ label: "商品套图", icon: <AppstoreOutlined /> },
{ label: "图片修改", icon: <SettingOutlined /> },
{ label: "增/去水印", icon: <FileImageOutlined /> },
{ label: "图片批处理", icon: <FolderOpenOutlined /> },
{ label: "一键翻译", icon: <FileImageOutlined /> },
{ label: "A+/详情页", icon: <FileImageOutlined /> },
{ label: "变清晰", icon: <FileImageOutlined /> },
{ label: "AI消除", icon: <SettingOutlined /> },
{ label: "证件照", icon: <SkinOutlined /> },
{ label: "爆款视频", icon: <CloudUploadOutlined /> },
{ label: "拼图", icon: <TableOutlined /> },
].map((item) => (
<button key={item.label} type="button" onClick={item.onClick}>
<span aria-hidden="true">{item.icon}</span>
<strong>{item.label}</strong>
</button>
))}
</section>
) : null}
<span className="clone-ai-char-count">{requirement.length}/500</span>
</section>
</main>
);
const smartCutoutPreview = (
<main className={`ecom-smart-cutout-page${smartCutoutImage ? " is-editor" : " is-upload"}${isSmartCutoutTransitioning ? " is-transitioning" : ""}`} aria-label="智能抠图">
<input
ref={smartCutoutInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleSmartCutoutUpload}
/>
<nav className="ecom-smart-cutout-nav" aria-label="智能抠图导航">
<button type="button" onClick={closeSmartCutoutTool}>
</button>
<button type="button" onClick={goSmartCutoutPrevious}>
</button>
</nav>
{isSmartCutoutTransitioning ? (
<div className="ecom-smart-cutout-transition" role="status" aria-live="polite">
<span aria-hidden="true" />
<strong>{smartCutoutTransitionMessage.title}</strong>
<em>{smartCutoutTransitionMessage.subtitle}</em>
</div>
) : null}
{!smartCutoutImage ? (
<section className="ecom-smart-cutout-upload">
<div className="ecom-smart-cutout-head">
<strong></strong>
<span>3s </span>
</div>
<div className="ecom-smart-cutout-upload__body">
<div className="ecom-smart-cutout-demo" aria-hidden="true">
<div className="ecom-smart-cutout-demo__tile ecom-smart-cutout-demo__tile--flower" />
<div className="ecom-smart-cutout-demo__tile ecom-smart-cutout-demo__tile--product" />
<div className="ecom-smart-cutout-demo__tile ecom-smart-cutout-demo__tile--poster" />
<div className="ecom-smart-cutout-demo__tile ecom-smart-cutout-demo__tile--object" />
</div>
<div
className={`ecom-smart-cutout-upload-box${isSmartCutoutDragging ? " is-dragging" : ""}`}
role="button"
tabIndex={0}
onClick={() => smartCutoutInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
smartCutoutInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsSmartCutoutDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsSmartCutoutDragging(false)}
onDrop={handleSmartCutoutDrop}
>
<button type="button" className="ecom-smart-cutout-upload__primary">
<CloudUploadOutlined />
</button>
<button type="button" className="ecom-smart-cutout-upload__secondary">
<FolderOpenOutlined />
</button>
<span> &gt;</span>
</div>
</div>
</section>
) : (
<section className="ecom-smart-editor">
<div className="ecom-smart-editor__workspace">
<div className="ecom-smart-editor__canvas">
<div className={`ecom-smart-editor__checker is-size-${smartCutoutSizeKey}`} style={smartCutoutFrameStyle}>
<div className="ecom-smart-editor__background-layer" aria-hidden="true" />
<img src={smartCutoutImage.src} alt={smartCutoutImage.name} />
</div>
<div className="ecom-smart-editor__canvas-actions">
<button type="button"></button>
<button type="button"></button>
</div>
</div>
<div className="ecom-smart-editor__tools-shell">
<button type="button" className="ecom-smart-editor__tools-nav" onClick={() => scrollSmartCutoutTools(-1)} aria-label="查看上一组尺寸">
</button>
<div className="ecom-smart-editor__tools" ref={smartCutoutToolsRef}>
{smartCutoutSizeOptions.map((item) => (
<button
key={item.key}
type="button"
className={smartCutoutSizeKey === item.key ? "is-active" : ""}
onClick={() => setSmartCutoutSizeKey(item.key)}
aria-pressed={smartCutoutSizeKey === item.key}
>
<span className={`ecom-smart-editor__tool-icon ecom-smart-editor__tool-icon--${item.icon}`} aria-hidden="true" />
<span>{item.label}</span>
</button>
))}
</div>
<button type="button" className="ecom-smart-editor__tools-nav" onClick={() => scrollSmartCutoutTools(1)} aria-label="查看更多尺寸">
</button>
</div>
{smartCutoutBatchImages.length > 1 ? (
<section className="ecom-smart-editor__batch" aria-label="批量图片">
<header>
<strong></strong>
<span>{smartCutoutBatchImages.length} </span>
</header>
<div>
{smartCutoutBatchImages.map((image, index) => (
<button
key={`${image.src}-${index}`}
type="button"
className={smartCutoutImage.src === image.src ? "is-active" : ""}
onClick={() => setSmartCutoutImage(image)}
>
<img src={image.src} alt={image.name || `上传图片 ${index + 1}`} />
<span>{index + 1}</span>
</button>
))}
</div>
</section>
) : null}
<section className="ecom-smart-editor__gallery">
<header><strong></strong><button type="button"> &gt;</button></header>
<div className="ecom-smart-editor__swatches">
{["#ffffff", "#eeeae3", "#f2e3cf", "#000000", "#a89682", "#c9c9c9"].map((color) => (
<button
key={color}
type="button"
className={smartCutoutBackgroundColor.toLowerCase() === color ? "is-active" : ""}
style={{ "--smart-cutout-swatch-bg": color } as CSSProperties}
onClick={() => {
setSmartCutoutBackgroundColor(color);
setSmartCutoutBackgroundAlpha(100);
}}
>
<span className="ecom-smart-editor__swatch-bg" aria-hidden="true" />
<img src={smartCutoutImage.src} alt="" />
</button>
))}
</div>
<header><strong>AI换背景</strong><button type="button"> &gt;</button></header>
<div className="ecom-smart-editor__scenes">
<button type="button" className="ecom-smart-editor__generate"><SettingOutlined /></button>
{["客厅陈列", "桌面日光", "香氛产品", "绿植窗边", "居家空间"].map((item) => (
<button key={item} type="button"><span>{item}</span></button>
))}
</div>
</section>
</div>
<aside className="ecom-smart-editor__side">
<strong></strong>
<div className="ecom-smart-editor__color-row">
<div className="ecom-smart-editor__color-wrap" ref={smartCutoutPaletteRef} style={{ "--smart-cutout-bg": smartCutoutBackgroundValue } as CSSProperties}>
<button
type="button"
className={`ecom-smart-editor__custom-color${isSmartCutoutPaletteOpen ? " is-active" : ""}`}
style={{ background: smartCutoutBackgroundColor }}
onClick={() => setIsSmartCutoutPaletteOpen((open) => !open)}
aria-label="打开背景调色盘"
>
<span></span>
</button>
{isSmartCutoutPaletteOpen ? (
<div className="ecom-smart-color-picker" role="dialog" aria-label="背景调色盘">
<button
type="button"
className="ecom-smart-color-picker__plane"
style={{ background: `linear-gradient(to top, #000000, transparent), linear-gradient(to right, #ffffff, hsl(${smartCutoutColorHsv.h} 100% 50%))` }}
onPointerDown={handleSmartCutoutColorPlanePointer}
onPointerMove={handleSmartCutoutColorPlaneMove}
aria-label="选择颜色明度和饱和度"
>
<span style={{ left: `${smartCutoutColorHsv.s}%`, top: `${100 - smartCutoutColorHsv.v}%` }} />
</button>
<div className="ecom-smart-color-picker__slider ecom-smart-color-picker__slider--hue">
<span style={{ background: `hsl(${smartCutoutColorHsv.h} 100% 50%)` }} />
<input
type="range"
min={0}
max={360}
value={smartCutoutColorHsv.h}
onChange={(event) => applySmartCutoutHsv(Number(event.target.value), smartCutoutColorHsv.s, smartCutoutColorHsv.v)}
aria-label="色相"
/>
</div>
<div className="ecom-smart-color-picker__slider ecom-smart-color-picker__slider--alpha">
<span style={{ background: smartCutoutBackgroundValue }} />
<input
type="range"
min={0}
max={100}
value={smartCutoutBackgroundAlpha}
onChange={(event) => setSmartCutoutBackgroundAlpha(Number(event.target.value))}
aria-label="透明度"
/>
</div>
<div className="ecom-smart-color-picker__fields">
<span aria-hidden="true"></span>
<input
value={smartCutoutHexDraft}
onChange={(event) => handleSmartCutoutHexChange(event.target.value)}
onBlur={() => setSmartCutoutHexDraft(smartCutoutBackgroundColor)}
aria-label="HEX 色值"
/>
<input
value={Math.round(smartCutoutBackgroundAlpha)}
onChange={(event) => setSmartCutoutBackgroundAlpha(clampNumber(Number(event.target.value) || 0, 0, 100))}
aria-label="透明度百分比"
/>
<strong>%</strong>
</div>
<p></p>
<div className="ecom-smart-color-picker__presets">
{smartCutoutColorPresets.map((color) => (
<button
key={color}
type="button"
className={smartCutoutBackgroundColor.toLowerCase() === color ? "is-active" : ""}
style={{ background: color }}
onClick={() => {
setSmartCutoutBackgroundColor(color);
setSmartCutoutBackgroundAlpha(100);
}}
aria-label={`选择颜色 ${color}`}
/>
))}
</div>
</div>
) : null}
</div>
{["#ffffff", "#f8f9fa", "#000000", "#bdbdbd"].map((color) => (
<button
key={color}
type="button"
className={smartCutoutBackgroundColor.toLowerCase() === color ? "is-active" : ""}
style={{ background: color }}
onClick={() => {
setSmartCutoutBackgroundColor(color);
setSmartCutoutBackgroundAlpha(100);
}}
aria-label={`背景颜色 ${color}`}
/>
))}
</div>
<div className="ecom-smart-editor__side-actions">
<button type="button" className="ecom-smart-editor__download"></button>
<button type="button" onClick={() => smartCutoutInputRef.current?.click()}></button>
</div>
</aside>
</section>
)}
</main>
);
const detailPreview = (
<main className="product-clone-preview product-clone-preview--detail" aria-label="A+详情预览" onWheel={handlePreviewWheel}>
<div className="product-clone-preview__headline">
@@ -3557,7 +4251,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
return (
<section
className={`product-clone-page page-motion${isCloneTool && isCloneSettingsCollapsed ? " is-settings-collapsed" : ""}${isCloneTool && isCommandHistoryCollapsed ? " is-history-collapsed" : ""}`}
className={`product-clone-page page-motion${isCloneTool && isCloneSettingsCollapsed ? " is-settings-collapsed" : ""}${isCloneTool && isCommandHistoryCollapsed ? " is-history-collapsed" : ""}${isSmartCutoutTool ? " is-smart-cutout-page" : ""}`}
data-tool={activeTool}
aria-label={pageLabel}
>
@@ -3581,7 +4275,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
{isSetTool ? setPanel : isDetail ? detailPanel : isTryOn ? tryOnPanel : isCloneTool ? clonePanel : placeholderPanel}
</aside>
{isCloneTool ? (
{isCloneTool && !isSmartCutoutTool ? (
<button
type="button"
className="clone-ai-settings-toggle"
@@ -3595,7 +4289,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</button>
) : null}
{isSetTool ? setPreview : isDetail ? detailPreview : isTryOn ? tryOnPreview : isCloneTool ? (false ? (
{isSetTool ? setPreview : isDetail ? detailPreview : isTryOn ? tryOnPreview : isCloneTool ? (isSmartCutoutTool ? smartCutoutPreview : false ? (
<main className="product-clone-preview product-clone-preview--video" style={{ padding: 0 }}>
<EcommerceVideoWorkspace
isAuthenticated={isAuthenticated}
File diff suppressed because it is too large Load Diff