From 9a9c7eb86d6b7d9335efeb201de8b6dc6d3d2991 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 15 Jun 2026 15:26:49 +0800 Subject: [PATCH] feat: optimize ecommerce hot clone UI --- src/features/ecommerce/EcommercePage.tsx | 519 +++++++++++++--- .../ecommerce/panels/EcommerceClonePanel.tsx | 102 ---- src/styles/ecommerce-standalone.css | 552 +++++++++++++++++- 3 files changed, 995 insertions(+), 178 deletions(-) diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index 7eb02d0..b5bb1ec 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -1,4 +1,4 @@ -import { +import { AppstoreOutlined, ClearOutlined, CloudUploadOutlined, @@ -16,6 +16,7 @@ MenuFoldOutlined, MenuUnfoldOutlined, PaperClipOutlined, + PlusOutlined, QuestionCircleOutlined, ReloadOutlined, ScissorOutlined, @@ -986,9 +987,8 @@ const productSetOutputOptions: Array<{ key: ProductSetOutputKey; label: string; { key: "model", label: "模特图", desc: "真人穿搭展示", icon: }, { key: "video", label: "短视频", desc: "分镜视频链路", icon: }, ]; -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: }, ]; const cloneSetCountOptions: Array<{ key: CloneSetCountKey; @@ -1370,6 +1370,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const garmentInputRef = useRef(null); const detailInputRef = useRef(null); const detailProgressRef = useRef(null); + const hotProgressRef = useRef(null); + const hotMaterialInputRef = useRef(null); const countHoldTimeoutRef = useRef(null); const countHoldIntervalRef = useRef(null); const isAuthenticated = Boolean((_props as Record).isAuthenticated); @@ -1403,7 +1405,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [selectedProductSetPreview, setSelectedProductSetPreview] = useState(null); const [showHostingModal, setShowHostingModal] = useState(false); const [productImages, setProductImages] = useState([]); - 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(null); const [smartCutoutBatchImages, setSmartCutoutBatchImages] = useState([]); const [smartCutoutBackgroundColor, setSmartCutoutBackgroundColor] = useState("#ffffff"); @@ -1738,24 +1740,25 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [detailStatus, setDetailStatus] = useState("idle"); const [detailResultUrl, setDetailResultUrl] = useState(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("idle"); + const [hotResultUrl, setHotResultUrl] = useState(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], @@ -1768,6 +1771,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}` })), @@ -1798,6 +1805,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( @@ -2662,6 +2670,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( { @@ -3020,6 +3038,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) => { const files = event.target.files; if (!files?.length) return; @@ -3124,9 +3153,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)); }; @@ -3135,9 +3162,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), ); }; @@ -3276,14 +3301,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) { @@ -4015,6 +4038,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) => { + 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) => ( +
+ {items.map((item) => ( +
handleHotMaterialMouseEnter(item.src, e)} + onMouseLeave={handleHotMaterialMouseLeave} + > + {item.name} + +
+ ))} +
+ ); + + const closeHotClonePage = () => { + stopHotProgress(); + setActiveQuickTool(null); + setHotStatus("idle"); + setHotResultUrl(null); + setHotProgress(0); + setHotRequirement(""); + setIsHotMaterialDragging(false); + setHotMaterialHoverZoom(null); + setComposerMenu(null); + }; + const resetTask = () => { setSetImages([]); setProductSetRequirement(""); @@ -4077,6 +4227,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 @@ -4354,6 +4505,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; @@ -4633,8 +4797,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { ? cloneModelPanelTab === "scene" ? "场景设置" : "模特设置" : cloneOutput === "video" ? String(cloneVideoDuration) + "秒 " + (cloneVideoQuality === "standard" ? "720P" : "1080P") - : cloneOutput === "hot" - ? cloneReplicateLevel === "style" ? "风格复刻" : "高度复刻" : "换装素材"; const renderComposerMenu = () => { @@ -4771,48 +4933,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { setCloneVideoDuration(clampCloneVideoDuration(Number(event.target.value)))} /> - ) : cloneOutput === "hot" ? ( - <> -
爆款复刻设置{cloneReferenceImages.length}/{maxCloneReferenceImages}
-
- -
- {cloneReplicateLevelOptions.map((option) => ( - - ))} -
-
- ) : ( <>
视频换装设置上传视频和服装参考
@@ -5311,6 +5431,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
{[ { label: "A+/详情页", tone: "detail", icon: , onClick: openQuickDetailPage }, + { label: "爆款复刻", tone: "hot", icon: , onClick: openHotClonePage }, { label: "图片修改", tone: "edit", icon: , onClick: openImageWorkbenchPage }, { label: "智能抠图", tone: "cutout", icon: , onClick: openSmartCutoutUpload }, { label: "去除水印", tone: "watermark", icon: , onClick: openWatermarkRemovalPage }, @@ -6328,6 +6449,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 = (
@@ -6525,6 +6647,255 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
); + const hotClonePreview = ( +
+
+