diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index a5db2c8..ebb95be 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -16,7 +16,7 @@ SkinOutlined, TableOutlined, } from "@ant-design/icons"; -import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { useTypewriter } from "../../hooks/useTypewriter"; import "../../styles/pages/ecommerce.css"; import "../../styles/pages/local-theme-parity.css"; @@ -83,12 +83,12 @@ const smartCutoutColorPresets = [ 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: "one-inch", label: "一寸头像", sizeLabel: "295*413", icon: "portrait", frameWidth: "min(290px, 50%)", frameAspect: "295 / 413", imageMaxWidth: "86%", imageMaxHeight: "86%", outputWidth: 295, outputHeight: 413 }, + { key: "two-inch", label: "二寸头像", sizeLabel: "413*579", icon: "portrait", frameWidth: "min(320px, 54%)", frameAspect: "413 / 579", imageMaxWidth: "86%", imageMaxHeight: "86%", outputWidth: 413, outputHeight: 579 }, + { key: "taobao-1-1", label: "淘宝1:1主图", sizeLabel: "800*800", icon: "shop", frameWidth: "min(430px, 72%)", frameAspect: "800 / 800", imageMaxWidth: "82%", imageMaxHeight: "82%", outputWidth: 800, outputHeight: 800 }, + { key: "taobao-3-4", label: "淘宝3:4主图", sizeLabel: "750*1000", icon: "shop", frameWidth: "min(330px, 56%)", frameAspect: "750 / 1000", imageMaxWidth: "82%", imageMaxHeight: "82%", outputWidth: 750, outputHeight: 1000 }, + { key: "pdd-main", label: "拼多多主图", sizeLabel: "800*800", icon: "pdd", frameWidth: "min(430px, 72%)", frameAspect: "800 / 800", imageMaxWidth: "82%", imageMaxHeight: "82%", outputWidth: 800, outputHeight: 800 }, + { key: "xiaohongshu-cover", label: "小红书封面", sizeLabel: "1242*1660", icon: "text", frameWidth: "min(330px, 56%)", frameAspect: "1242 / 1660", imageMaxWidth: "82%", imageMaxHeight: "82%", outputWidth: 1242, outputHeight: 1660 }, { 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%" }, @@ -99,6 +99,7 @@ const smartCutoutSizeOptions = [ ] as const; type SmartCutoutSizeKey = (typeof smartCutoutSizeOptions)[number]["key"]; +type SmartCutoutImageItem = { src: string; name: string; originalSrc?: string }; const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); @@ -122,6 +123,21 @@ const hexToRgb = (value: string) => { 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 parseSmartCutoutAspect = (aspect: string) => { + const match = aspect.match(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/); + if (!match) return null; + const width = Number(match[1]); + const height = Number(match[2]); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null; + return width / height; +}; + +const parseSmartCutoutPercent = (value: string, fallback: number) => { + const numeric = Number(value.replace("%", "")); + if (!Number.isFinite(numeric)) return fallback; + return clampNumber(numeric / 100, 0.05, 1); +}; + const hsvToRgb = (h: number, s: number, v: number) => { const hue = ((h % 360) + 360) % 360; const saturation = clampNumber(s, 0, 100) / 100; @@ -723,6 +739,26 @@ const normalizeRatioForPlatform = (platformValue: string, ratioValue: string, mo const matchedRatio = platformRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio)); return matchedRatio ?? getPlatformDefaultRatio(platformValue, mode); }; +const quickSetRatioOptions = ["1:1", "3:4", "9:16", "16:9"]; +const getQuickSetRatioValue = (value: string) => { + const normalizedValue = normalizeRatioToken(value); + if (quickSetRatioOptions.includes(normalizedValue)) return normalizedValue; + const sizeMatch = normalizedValue.match(/(\d+)\s*[×xX]\s*(\d+)/u); + if (sizeMatch) { + const width = Number(sizeMatch[1]); + const height = Number(sizeMatch[2]); + if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { + const aspect = formatAspectRatio(width, height); + if (quickSetRatioOptions.includes(aspect)) return aspect; + } + } + const ratioMatch = normalizedValue.match(/(\d+)\s*[::]\s*(\d+)/u); + if (ratioMatch) { + const aspect = `${Number(ratioMatch[1])}:${Number(ratioMatch[2])}`; + if (quickSetRatioOptions.includes(aspect)) return aspect; + } + return quickSetRatioOptions[0]!; +}; const formatRatioDisplayValue = (value: string) => { const normalizedValue = normalizeRatioToken(value); const sizeMatch = normalizedValue.match(/(\d+)\s*[×xX]\s*(\d+)\s*px?/u); @@ -888,25 +924,26 @@ const tryOnCards = [ const detailTypeOptions = ["普通A+", "品牌A+", "标准详情页", "移动端长图"]; const detailModules = [ - { id: "hero", title: "首屏主视觉", desc: "传递核心价值" }, - { id: "selling", title: "核心卖点图", desc: "突出卖点优势" }, - { id: "usage", title: "使用场景图", desc: "呈现真实使用场景" }, - { id: "angle", title: "多角度图", desc: "多角度呈现外观" }, - { id: "scene", title: "场景氛围图", desc: "展示使用场景" }, - { id: "detail", title: "商品细节图", desc: "放大材质与工艺" }, - { id: "story", title: "品牌故事图", desc: "传达品牌理念" }, - { id: "size", title: "尺寸/容量/尺码图", desc: "展示规格信息" }, - { id: "compare", title: "效果对比图", desc: "使用前后效果对比" }, - { id: "spec", title: "详细规格/参数表", desc: "展示详细商品数据" }, - { id: "craft", title: "工艺制作图", desc: "展示工艺制作过程" }, - { id: "gift", title: "配件/赠品图", desc: "明确收货的所有物品" }, - { id: "series", title: "系列展示图", desc: "多色或多SKU展示" }, - { id: "ingredient", title: "商品成分图", desc: "展示配方/材质/成分" }, - { id: "service", title: "售后保障图", desc: "说明质保退换政策" }, - { id: "tips", title: "使用建议图", desc: "商品使用注意事项" }, + { id: "hero", title: "首页焦点图", desc: "集中呈现核心利益点" }, + { id: "selling", title: "卖点强化图", desc: "放大产品优势" }, + { id: "usage", title: "使用情境图", desc: "还原实际使用画面" }, + { id: "angle", title: "外观角度图", desc: "展示不同视角造型" }, + { id: "scene", title: "氛围场景图", desc: "营造产品应用环境" }, + { id: "detail", title: "细节特写图", desc: "突出材质和做工" }, + { id: "story", title: "品牌理念图", desc: "表达品牌主张" }, + { id: "size", title: "规格尺寸图", desc: "说明尺寸容量尺码" }, + { id: "compare", title: "效果对照图", desc: "呈现前后差异" }, + { id: "spec", title: "参数信息表", desc: "整理商品关键数据" }, + { id: "craft", title: "工艺流程图", desc: "说明制作与处理步骤" }, + { id: "gift", title: "清单配件图", desc: "展示包装内全部内容" }, + { id: "series", title: "SKU组合图", desc: "呈现颜色款式组合" }, + { id: "ingredient", title: "成分材质图", desc: "说明配方或材料构成" }, + { id: "service", title: "保障说明图", desc: "传达质保退换承诺" }, + { id: "tips", title: "使用提示图", desc: "提醒操作与保养要点" }, ]; const defaultDetailModuleIds: string[] = []; const defaultCloneDetailModuleIds = ["hero", "selling", "usage", "angle", "scene", "detail"]; +const maxDetailModuleSelection = 6; const cloneDetailModules = detailModules; const detailAssets = ossAssets.ecommerce.detail; const detailProductSamples = [detailAssets.productA, detailAssets.productB, detailAssets.productC]; @@ -941,26 +978,36 @@ async function createUploadedImageItems(files: File[], limit: number, prefix: st const items = await Promise.all(selectedFiles.map(async (file, index) => { const localPreviewUrl = URL.createObjectURL(file); + let src = localPreviewUrl; + let ossKey: string | undefined; + let shouldRevokeLocalPreview = false; let dimensions: { width?: number; height?: number } = {}; try { dimensions = await readImageDimensions(localPreviewUrl); } catch { dimensions = {}; - } finally { - URL.revokeObjectURL(localPreviewUrl); } const mimeType = normalizeEcommerceImageMime(file.type); - const uploadBlob = file.type === mimeType ? file : new Blob([file], { type: mimeType }); - const { url, ossKey } = await aiGenerationClient.uploadAssetBinary(uploadBlob, { - name: file.name, - mimeType, - scope: "ecommerce-product", - }); + try { + const uploadBlob = file.type === mimeType ? file : new Blob([file], { type: mimeType }); + const uploaded = await aiGenerationClient.uploadAssetBinary(uploadBlob, { + name: file.name, + mimeType, + scope: "ecommerce-product", + }); + src = uploaded.url; + ossKey = uploaded.ossKey; + shouldRevokeLocalPreview = true; + } catch { + src = localPreviewUrl; + } finally { + if (shouldRevokeLocalPreview) URL.revokeObjectURL(localPreviewUrl); + } return { id: `${prefix}-${stamp}-${index}`, - src: url, + src, name: file.name, file, format: getImageFileFormat(file), @@ -1125,8 +1172,12 @@ function clampCloneVideoDuration(value: number) { function ProductClonePage(_props: ProductClonePageProps = {}) { const setInputRef = useRef(null); const productInputRef = useRef(null); + const quickProductInputRef = useRef(null); const cloneReferenceInputRef = useRef(null); const smartCutoutInputRef = useRef(null); + const imageWorkbenchInputRef = useRef(null); + const watermarkInputRef = useRef(null); + const watermarkProcessTimeoutRef = useRef(null); const smartCutoutTransitionTimeoutRef = useRef(null); const smartCutoutPendingUrlsRef = useRef([]); const smartCutoutPaletteRef = useRef(null); @@ -1166,30 +1217,46 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [selectedProductSetPreview, setSelectedProductSetPreview] = useState<{ src: string; label: string } | null>(null); const [showHostingModal, setShowHostingModal] = useState(false); const [productImages, setProductImages] = useState([]); - 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 [activeQuickTool, setActiveQuickTool] = useState<"cutout" | "set" | "detail" | "watermark" | "image-edit" | "hot-video" | null>(null); + const [smartCutoutImage, setSmartCutoutImage] = useState(null); + const [smartCutoutBatchImages, setSmartCutoutBatchImages] = useState([]); const [smartCutoutBackgroundColor, setSmartCutoutBackgroundColor] = useState("#ffffff"); const [smartCutoutBackgroundAlpha, setSmartCutoutBackgroundAlpha] = useState(100); const [smartCutoutHexDraft, setSmartCutoutHexDraft] = useState("#ffffff"); const [isSmartCutoutPaletteOpen, setIsSmartCutoutPaletteOpen] = useState(false); const [smartCutoutSizeKey, setSmartCutoutSizeKey] = useState("original"); const [isSmartCutoutDragging, setIsSmartCutoutDragging] = useState(false); + const [isSmartCutoutComparing, setIsSmartCutoutComparing] = useState(false); const [isSmartCutoutTransitioning, setIsSmartCutoutTransitioning] = useState(false); const [smartCutoutTransitionMessage, setSmartCutoutTransitionMessage] = useState({ title: "正在切换页面", subtitle: "请稍候", }); + const [watermarkImage, setWatermarkImage] = useState<{ src: string; name: string; format: string } | null>(null); + const [watermarkStatus, setWatermarkStatus] = useState<"idle" | "processing" | "done">("idle"); + const [isWatermarkDragging, setIsWatermarkDragging] = useState(false); + const [imageWorkbenchImage, setImageWorkbenchImage] = useState<{ src: string; name: string; format: string } | null>(null); + const [imageWorkbenchPrompt, setImageWorkbenchPrompt] = useState(""); + const [imageWorkbenchBrushSize, setImageWorkbenchBrushSize] = useState(50); + const [imageWorkbenchRatio, setImageWorkbenchRatio] = useState("1:1"); + const [imageWorkbenchStatus, setImageWorkbenchStatus] = useState<"idle" | "processing" | "done">("idle"); + const [isImageWorkbenchDragging, setIsImageWorkbenchDragging] = useState(false); + const [imageWorkbenchMaskStrokes, setImageWorkbenchMaskStrokes] = useState }>>([]); + const [imageWorkbenchBrushCursor, setImageWorkbenchBrushCursor] = useState<{ x: number; y: number } | null>(null); const [isProductUploadDragging, setIsProductUploadDragging] = useState(false); const [cloneOutput, setCloneOutput] = useState(defaultCloneOutput); const [videoHistoryVisible, setVideoHistoryVisible] = useState(false); const [videoPlanTrigger, setVideoPlanTrigger] = useState(0); const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState(null); + const [openQuickSetSelect, setOpenQuickSetSelect] = useState(null); + const [visibleQuickSetSelect, setVisibleQuickSetSelect] = useState(null); + const [isQuickSetSelectClosing, setIsQuickSetSelectClosing] = useState(false); const [composerMenu, setComposerMenu] = useState(null); const [visibleComposerMenu, setVisibleComposerMenu] = useState(null); const [isComposerMenuClosing, setIsComposerMenuClosing] = useState(false); const [composerPopoverLeft, setComposerPopoverLeft] = useState(0); const [isCommandHistoryCollapsed, setIsCommandHistoryCollapsed] = useState(() => (typeof window !== "undefined" ? window.innerWidth <= 1180 : false)); + const [isQuickPanelCollapsed, setIsQuickPanelCollapsed] = useState(false); const [openCloneModelSelect, setOpenCloneModelSelect] = useState(null); const [cloneModelSelectDropUp, setCloneModelSelectDropUp] = useState(false); const [cloneReferenceMode, setCloneReferenceMode] = useState("upload"); @@ -1213,10 +1280,17 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const videoOutfitPreviewUrl = useMemo(() => (videoOutfitVideoFile ? URL.createObjectURL(videoOutfitVideoFile) : ""), [videoOutfitVideoFile]); const [isCloneSettingsCollapsed, setIsCloneSettingsCollapsed] = useState(false); const [previewZoom, setPreviewZoom] = useState(1); + const quickSetSelectTimerRef = useRef(null); + const openQuickSetSelectRef = useRef(null); + const visibleQuickSetSelectRef = useRef(null); const [previewOffset, setPreviewOffset] = useState({ x: 0, y: 0 }); const previewSurfaceRef = useRef(null); const previewZoomRef = useRef(previewZoom); const previewOffsetRef = useRef(previewOffset); + const imageWorkbenchMaskPaintingRef = useRef(false); + const imageWorkbenchActiveStrokeIdRef = useRef(null); + const imageWorkbenchMaskCanvasRef = useRef(null); + const imageWorkbenchLastMaskPointRef = useRef<{ x: number; y: number } | null>(null); const previewPanRef = useRef<{ active: boolean; startX: number; startY: number; offsetX: number; offsetY: number }>({ active: false, startX: 0, @@ -1387,6 +1461,20 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }); }; + const handleQuickPreviewWheel = (event: React.WheelEvent) => { + event.preventDefault(); + event.stopPropagation(); + const zoomDelta = event.deltaY < 0 ? 1.08 : 0.92; + setPreviewZoom((value) => Math.min(2, Math.max(0.25, value * zoomDelta))); + }; + + const handleQuickPanelWheel = (event: React.WheelEvent) => { + const panel = event.currentTarget; + if (panel.scrollHeight <= panel.clientHeight) return; + event.stopPropagation(); + panel.scrollTop += event.deltaY; + }; + const [requirement, setRequirement] = useState(""); const [requirementImageMentionQuery, setRequirementImageMentionQuery] = useState(null); const [cloneSettingName, setCloneSettingName] = useState("鏂板缓鍒涗綔"); @@ -1436,6 +1524,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [detailPlatform, setDetailPlatform] = useState(platformOptions[0]); const [detailMarket, setDetailMarket] = useState(marketOptions[0]); const [detailLanguage, setDetailLanguage] = useState(getPlatformDefaultLanguage(platformOptions[0], marketOptions[0])); + const [detailRatio, setDetailRatio] = useState(getQuickSetRatioValue(getPlatformDefaultRatio(platformOptions[0], "detail"))); const [detailType, setDetailType] = useState(detailTypeOptions[0]); const [detailRequirement, setDetailRequirement] = useState(""); const [selectedDetailModules, setSelectedDetailModules] = useState(defaultDetailModuleIds); @@ -1582,8 +1671,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { if (files.length) void addSetImages(files); }; - const revokeSmartCutoutItems = (items: { src: string }[]) => { - items.forEach((item) => URL.revokeObjectURL(item.src)); + const revokeSmartCutoutItem = (item: SmartCutoutImageItem | null) => { + if (!item) return; + URL.revokeObjectURL(item.src); + if (item.originalSrc && item.originalSrc !== item.src) URL.revokeObjectURL(item.originalSrc); + }; + + const revokeSmartCutoutItems = (items: SmartCutoutImageItem[]) => { + items.forEach(revokeSmartCutoutItem); }; const clearSmartCutoutTransition = () => { @@ -1621,10 +1716,375 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return []; }); setSmartCutoutImage((current) => { + revokeSmartCutoutItem(current); + return null; + }); + setIsSmartCutoutComparing(false); + setComposerMenu(null); + }; + + const openWatermarkRemovalPage = () => { + clearSmartCutoutTransition(); + setActiveQuickTool("watermark"); + setComposerMenu(null); + setIsCloneSettingsCollapsed(false); + }; + + const closeWatermarkRemovalPage = () => { + if (watermarkProcessTimeoutRef.current !== null) { + window.clearTimeout(watermarkProcessTimeoutRef.current); + watermarkProcessTimeoutRef.current = null; + } + setActiveQuickTool(null); + setWatermarkStatus("idle"); + setWatermarkImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return null; }); + }; + + const addWatermarkImage = (file: File) => { + const nextImage = { + src: URL.createObjectURL(file), + name: file.name, + format: getImageFileFormat(file) || "PNG / JPG / WebP", + }; + setWatermarkImage((current) => { + if (current?.src) URL.revokeObjectURL(current.src); + return nextImage; + }); + setWatermarkStatus("idle"); + setActiveQuickTool("watermark"); + }; + + const removeWatermarkImage = () => { + if (watermarkProcessTimeoutRef.current !== null) { + window.clearTimeout(watermarkProcessTimeoutRef.current); + watermarkProcessTimeoutRef.current = null; + } + setWatermarkStatus("idle"); + setWatermarkImage((current) => { + if (current?.src) URL.revokeObjectURL(current.src); + return null; + }); + }; + + const handleWatermarkUpload = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + addWatermarkImage(file); + event.target.value = ""; + }; + + const handleWatermarkDrop = (event: DragEvent) => { + event.preventDefault(); + setIsWatermarkDragging(false); + const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/")); + if (file) addWatermarkImage(file); + }; + + const handleWatermarkGenerate = () => { + if (!watermarkImage || watermarkStatus === "processing") return; + if (watermarkProcessTimeoutRef.current !== null) window.clearTimeout(watermarkProcessTimeoutRef.current); + setWatermarkStatus("processing"); + watermarkProcessTimeoutRef.current = window.setTimeout(() => { + watermarkProcessTimeoutRef.current = null; + setWatermarkStatus("done"); + toast.success("去水印处理完成"); + }, 900); + }; + + const handleWatermarkDownload = () => { + if (!watermarkImage || watermarkStatus !== "done") { + toast.info("请先完成去水印"); + return; + } + const link = document.createElement("a"); + const safeName = (watermarkImage.name || "watermark-result").replace(/\.[^.]+$/, "").replace(/[\\/:*?"<>|]+/g, "-"); + link.href = watermarkImage.src; + link.download = `${safeName || "watermark-result"}-去水印.png`; + document.body.appendChild(link); + link.click(); + link.remove(); + }; + + const openImageWorkbenchPage = () => { + clearSmartCutoutTransition(); + setActiveQuickTool("image-edit"); setComposerMenu(null); + setIsCloneSettingsCollapsed(false); + setImageWorkbenchStatus("idle"); + }; + + const closeImageWorkbenchPage = () => { + setActiveQuickTool(null); + setImageWorkbenchStatus("idle"); + setImageWorkbenchPrompt(""); + setImageWorkbenchMaskStrokes([]); + setImageWorkbenchBrushCursor(null); + clearImageWorkbenchMaskCanvas(); + imageWorkbenchMaskPaintingRef.current = false; + imageWorkbenchActiveStrokeIdRef.current = null; + setImageWorkbenchImage((current) => { + if (current?.src) URL.revokeObjectURL(current.src); + return null; + }); + }; + + const addImageWorkbenchImage = (file: File) => { + if (!file.type.startsWith("image/")) { + toast.error("请上传图片文件"); + return; + } + const nextImage = { + src: URL.createObjectURL(file), + name: file.name, + format: getImageFileFormat(file) || "PNG / JPG / WebP", + }; + setImageWorkbenchImage((current) => { + if (current?.src) URL.revokeObjectURL(current.src); + return nextImage; + }); + setImageWorkbenchStatus("idle"); + setImageWorkbenchMaskStrokes([]); + setImageWorkbenchBrushCursor(null); + clearImageWorkbenchMaskCanvas(); + imageWorkbenchActiveStrokeIdRef.current = null; + setActiveQuickTool("image-edit"); + }; + + const removeImageWorkbenchImage = () => { + setImageWorkbenchStatus("idle"); + setImageWorkbenchMaskStrokes([]); + setImageWorkbenchBrushCursor(null); + clearImageWorkbenchMaskCanvas(); + imageWorkbenchMaskPaintingRef.current = false; + imageWorkbenchActiveStrokeIdRef.current = null; + setImageWorkbenchImage((current) => { + if (current?.src) URL.revokeObjectURL(current.src); + return null; + }); + }; + + const handleImageWorkbenchUpload = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + addImageWorkbenchImage(file); + event.target.value = ""; + }; + + const handleImageWorkbenchDrop = (event: DragEvent) => { + event.preventDefault(); + setIsImageWorkbenchDragging(false); + const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/")); + if (file) addImageWorkbenchImage(file); + }; + + const handleImageWorkbenchGenerate = () => { + if (!imageWorkbenchImage) { + toast.info("请先上传图片"); + return; + } + setImageWorkbenchStatus("processing"); + window.setTimeout(() => { + setImageWorkbenchStatus("done"); + toast.success("局部重绘已完成"); + }, 900); + }; + + const syncImageWorkbenchMaskCanvas = () => { + const canvas = imageWorkbenchMaskCanvasRef.current; + if (!canvas) return null; + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const width = Math.max(1, Math.round(rect.width * dpr)); + const height = Math.max(1, Math.round(rect.height * dpr)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + const context = canvas.getContext("2d"); + if (!context) return null; + context.setTransform(dpr, 0, 0, dpr, 0, 0); + context.lineCap = "round"; + context.lineJoin = "round"; + context.strokeStyle = "rgba(30, 189, 219, 0.52)"; + context.fillStyle = "rgba(30, 189, 219, 0.52)"; + context.lineWidth = imageWorkbenchBrushSize; + return { canvas, context, rect }; + }; + + const clearImageWorkbenchMaskCanvas = () => { + const canvas = imageWorkbenchMaskCanvasRef.current; + if (!canvas) return; + const context = canvas.getContext("2d"); + if (!context) return; + context.setTransform(1, 0, 0, 1, 0, 0); + context.clearRect(0, 0, canvas.width, canvas.height); + }; + + const getImageWorkbenchMaskPoint = (event: ReactPointerEvent) => { + const canvas = imageWorkbenchMaskCanvasRef.current; + const rect = (canvas ?? event.currentTarget).getBoundingClientRect(); + const x = clampNumber(event.clientX - rect.left, 0, rect.width); + const y = clampNumber(event.clientY - rect.top, 0, rect.height); + return { x, y }; + }; + + const appendImageWorkbenchMaskPoint = (event: ReactPointerEvent) => { + const point = getImageWorkbenchMaskPoint(event); + const rect = imageWorkbenchMaskCanvasRef.current?.getBoundingClientRect(); + setImageWorkbenchBrushCursor(rect ? { x: (point.x / Math.max(rect.width, 1)) * 100, y: (point.y / Math.max(rect.height, 1)) * 100 } : null); + setImageWorkbenchStatus((current) => (current === "done" ? "idle" : current)); + const activeStrokeId = imageWorkbenchActiveStrokeIdRef.current; + if (!activeStrokeId) return; + const lastPoint = imageWorkbenchLastMaskPointRef.current; + if (lastPoint && Math.hypot(point.x - lastPoint.x, point.y - lastPoint.y) < 1.5) return; + const synced = syncImageWorkbenchMaskCanvas(); + if (!synced) return; + synced.context.beginPath(); + synced.context.moveTo(lastPoint?.x ?? point.x, lastPoint?.y ?? point.y); + synced.context.lineTo(point.x, point.y); + synced.context.stroke(); + imageWorkbenchLastMaskPointRef.current = point; + }; + + const handleImageWorkbenchMaskPointerDown = (event: ReactPointerEvent) => { + if (!imageWorkbenchImage || event.button !== 0) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + syncImageWorkbenchMaskCanvas(); + const point = getImageWorkbenchMaskPoint(event); + const rect = imageWorkbenchMaskCanvasRef.current?.getBoundingClientRect(); + const strokeId = `mask-${Date.now()}`; + imageWorkbenchMaskPaintingRef.current = true; + imageWorkbenchActiveStrokeIdRef.current = strokeId; + imageWorkbenchLastMaskPointRef.current = point; + setImageWorkbenchBrushCursor(rect ? { x: (point.x / Math.max(rect.width, 1)) * 100, y: (point.y / Math.max(rect.height, 1)) * 100 } : null); + setImageWorkbenchStatus((current) => (current === "done" ? "idle" : current)); + setImageWorkbenchMaskStrokes((current) => [...current, { id: strokeId, size: imageWorkbenchBrushSize, points: [] }]); + const synced = syncImageWorkbenchMaskCanvas(); + if (synced) { + synced.context.beginPath(); + synced.context.arc(point.x, point.y, imageWorkbenchBrushSize / 2, 0, Math.PI * 2); + synced.context.fill(); + } + }; + + const handleImageWorkbenchMaskPointerMove = (event: ReactPointerEvent) => { + if (!imageWorkbenchImage) return; + const point = getImageWorkbenchMaskPoint(event); + const rect = imageWorkbenchMaskCanvasRef.current?.getBoundingClientRect(); + setImageWorkbenchBrushCursor(rect ? { x: (point.x / Math.max(rect.width, 1)) * 100, y: (point.y / Math.max(rect.height, 1)) * 100 } : null); + if (!imageWorkbenchMaskPaintingRef.current) return; + appendImageWorkbenchMaskPoint(event); + }; + + const stopImageWorkbenchMaskPainting = () => { + imageWorkbenchMaskPaintingRef.current = false; + imageWorkbenchActiveStrokeIdRef.current = null; + imageWorkbenchLastMaskPointRef.current = null; + }; + + const clearQuickSetSelectTimer = () => { + if (quickSetSelectTimerRef.current) { + window.clearTimeout(quickSetSelectTimerRef.current); + quickSetSelectTimerRef.current = null; + } + }; + + const resetQuickSetSelectState = () => { + clearQuickSetSelectTimer(); + openQuickSetSelectRef.current = null; + visibleQuickSetSelectRef.current = null; + setOpenQuickSetSelect(null); + setVisibleQuickSetSelect(null); + setIsQuickSetSelectClosing(false); + }; + + const closeQuickSetSelect = () => { + if (!visibleQuickSetSelectRef.current) { + openQuickSetSelectRef.current = null; + setOpenQuickSetSelect(null); + return; + } + clearQuickSetSelectTimer(); + openQuickSetSelectRef.current = null; + setOpenQuickSetSelect(null); + setIsQuickSetSelectClosing(true); + quickSetSelectTimerRef.current = window.setTimeout(() => { + visibleQuickSetSelectRef.current = null; + setVisibleQuickSetSelect(null); + setIsQuickSetSelectClosing(false); + quickSetSelectTimerRef.current = null; + }, 420); + }; + + const showQuickSetSelect = (key: CloneBasicSelectKey) => { + clearQuickSetSelectTimer(); + if (visibleQuickSetSelectRef.current && visibleQuickSetSelectRef.current !== key && openQuickSetSelectRef.current) { + openQuickSetSelectRef.current = null; + setOpenQuickSetSelect(null); + setIsQuickSetSelectClosing(true); + quickSetSelectTimerRef.current = window.setTimeout(() => { + visibleQuickSetSelectRef.current = key; + openQuickSetSelectRef.current = key; + setVisibleQuickSetSelect(key); + setOpenQuickSetSelect(key); + setIsQuickSetSelectClosing(false); + quickSetSelectTimerRef.current = null; + }, 180); + return; + } + visibleQuickSetSelectRef.current = key; + openQuickSetSelectRef.current = key; + setVisibleQuickSetSelect(key); + setOpenQuickSetSelect(key); + setIsQuickSetSelectClosing(false); + }; + + const toggleQuickSetSelect = (key: CloneBasicSelectKey) => { + if (openQuickSetSelectRef.current === key) { + closeQuickSetSelect(); + return; + } + showQuickSetSelect(key); + }; + + const openQuickProductSetPage = () => { + clearSmartCutoutTransition(); + setActiveQuickTool("set"); + setComposerMenu(null); + setIsCloneSettingsCollapsed(false); + setIsQuickPanelCollapsed(false); + setPreviewZoom(1); + resetQuickSetSelectState(); + }; + + const openQuickDetailPage = () => { + clearSmartCutoutTransition(); + setActiveQuickTool("detail"); + setComposerMenu(null); + setIsCloneSettingsCollapsed(false); + setIsQuickPanelCollapsed(false); + setPreviewZoom(1); + resetQuickSetSelectState(); + if (!selectedDetailModules.length) setSelectedDetailModules(defaultCloneDetailModuleIds); + }; + + const openHotVideoPage = () => { + clearSmartCutoutTransition(); + setActiveQuickTool("hot-video"); + setComposerMenu(null); + setIsCloneSettingsCollapsed(true); + setIsCommandHistoryCollapsed(true); + }; + + const closeHotVideoPage = () => { + setActiveQuickTool(null); + setComposerMenu(null); + setIsCloneSettingsCollapsed(false); + setIsCommandHistoryCollapsed(false); }; const closeSmartCutoutTool = () => { @@ -1639,9 +2099,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return []; }); setSmartCutoutImage((current) => { - if (current?.src) URL.revokeObjectURL(current.src); + revokeSmartCutoutItem(current); return null; }); + setIsSmartCutoutComparing(false); setActiveQuickTool(null); setComposerMenu(null); }, @@ -1664,9 +2125,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return []; }); setSmartCutoutImage((current) => { - if (current?.src) URL.revokeObjectURL(current.src); + revokeSmartCutoutItem(current); return null; }); + setIsSmartCutoutComparing(false); }, ); }; @@ -1683,10 +2145,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return []; }); setSmartCutoutImage((current) => { - if (current?.src) URL.revokeObjectURL(current.src); + revokeSmartCutoutItem(current); return null; }); - const nextImages = imageFiles.map((file) => ({ src: URL.createObjectURL(file), name: file.name })); + setIsSmartCutoutComparing(false); + const nextImages = imageFiles.map((file) => { + const originalSrc = URL.createObjectURL(file); + return { src: originalSrc, originalSrc, name: file.name }; + }); smartCutoutPendingUrlsRef.current = nextImages.map((item) => item.src); setActiveQuickTool("cutout"); setSmartCutoutSizeKey("original"); @@ -1731,17 +2197,30 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { [smartCutoutSizeKey], ); + const previewSmartCutoutSize = isSmartCutoutComparing ? smartCutoutSizeOptions[0] : selectedSmartCutoutSize; + const previewSmartCutoutSizeKey = isSmartCutoutComparing ? "original" : smartCutoutSizeKey; + const previewSmartCutoutImageSrc = isSmartCutoutComparing ? smartCutoutImage?.originalSrc ?? smartCutoutImage?.src : smartCutoutImage?.src; + const smartCutoutFrameStyle = useMemo( () => ({ "--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, + "--smart-cutout-frame-width": previewSmartCutoutSize.frameWidth, + "--smart-cutout-frame-aspect": previewSmartCutoutSize.frameAspect, + "--smart-cutout-image-max-width": previewSmartCutoutSize.imageMaxWidth, + "--smart-cutout-image-max-height": previewSmartCutoutSize.imageMaxHeight, } as CSSProperties), - [selectedSmartCutoutSize, smartCutoutBackgroundValue], + [previewSmartCutoutSize, smartCutoutBackgroundValue], ); + const showSmartCutoutOriginalCompare = (event: ReactPointerEvent) => { + event.currentTarget.setPointerCapture(event.pointerId); + setIsSmartCutoutComparing(true); + }; + + const hideSmartCutoutOriginalCompare = () => { + setIsSmartCutoutComparing(false); + }; + const applySmartCutoutHsv = (h: number, s: number, v: number) => { const rgb = hsvToRgb(h, s, v); setSmartCutoutBackgroundColor(rgbToHex(rgb.r, rgb.g, rgb.b)); @@ -1780,6 +2259,84 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }); }; + const handleSmartCutoutDownload = async () => { + if (!smartCutoutImage) { + toast.error("请先上传图片"); + return; + } + try { + const image = new Image(); + image.decoding = "async"; + const imageLoaded = new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error("图片加载失败")); + }); + image.src = smartCutoutImage.src; + await imageLoaded; + + const aspect = parseSmartCutoutAspect(selectedSmartCutoutSize.frameAspect); + const naturalWidth = Math.max(1, image.naturalWidth || image.width || 1200); + const naturalHeight = Math.max(1, image.naturalHeight || image.height || 900); + const outputWidth = "outputWidth" in selectedSmartCutoutSize ? selectedSmartCutoutSize.outputWidth : undefined; + const outputHeight = "outputHeight" in selectedSmartCutoutSize ? selectedSmartCutoutSize.outputHeight : undefined; + let canvasWidth = naturalWidth; + let canvasHeight = naturalHeight; + if (outputWidth && outputHeight) { + canvasWidth = outputWidth; + canvasHeight = outputHeight; + } else if (aspect) { + const longSide = 1600; + if (aspect >= 1) { + canvasWidth = longSide; + canvasHeight = Math.round(longSide / aspect); + } else { + canvasHeight = longSide; + canvasWidth = Math.round(longSide * aspect); + } + } else { + const maxSide = 1600; + const scale = Math.min(1, maxSide / Math.max(naturalWidth, naturalHeight)); + canvasWidth = Math.max(1, Math.round(naturalWidth * scale)); + canvasHeight = Math.max(1, Math.round(naturalHeight * scale)); + } + + const canvas = document.createElement("canvas"); + canvas.width = canvasWidth; + canvas.height = canvasHeight; + const context = canvas.getContext("2d"); + if (!context) throw new Error("无法生成图片"); + + context.clearRect(0, 0, canvasWidth, canvasHeight); + context.fillStyle = smartCutoutBackgroundValue; + context.fillRect(0, 0, canvasWidth, canvasHeight); + + const maxWidthRatio = parseSmartCutoutPercent(selectedSmartCutoutSize.imageMaxWidth, 0.82); + const maxHeightRatio = parseSmartCutoutPercent(selectedSmartCutoutSize.imageMaxHeight, 0.82); + const fitScale = Math.min((canvasWidth * maxWidthRatio) / naturalWidth, (canvasHeight * maxHeightRatio) / naturalHeight, 1); + const drawWidth = Math.round(naturalWidth * fitScale); + const drawHeight = Math.round(naturalHeight * fitScale); + const drawX = Math.round((canvasWidth - drawWidth) / 2); + const drawY = Math.round((canvasHeight - drawHeight) / 2); + context.drawImage(image, drawX, drawY, drawWidth, drawHeight); + + const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); + if (!blob) throw new Error("图片导出失败"); + + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement("a"); + const safeName = (smartCutoutImage.name || "smart-cutout").replace(/\.[^.]+$/, "").replace(/[\\/:*?"<>|]+/g, "-"); + link.href = objectUrl; + link.download = `${safeName || "smart-cutout"}-${selectedSmartCutoutSize.label}.png`; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); + toast.success("已下载图片"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "下载图片失败"); + } + }; + useEffect(() => { setSmartCutoutHexDraft(smartCutoutBackgroundColor); }, [smartCutoutBackgroundColor]); @@ -1853,6 +2410,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { if (files.length) void addProductImages(files); }; + const handleQuickProductDrop = (event: DragEvent) => { + event.preventDefault(); + const files = Array.from(event.dataTransfer.files); + if (files.length) void addProductImages(files); + }; + const removeProductImage = (imageId: string) => { setProductImages((current) => { const next = current.filter((item) => item.id !== imageId); @@ -1962,9 +2525,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }; const toggleCloneDetailModule = (moduleId: string) => { - setSelectedCloneDetailModules((current) => - current.includes(moduleId) ? current.filter((item) => item !== moduleId) : [...current, moduleId], - ); + setSelectedCloneDetailModules((current) => { + if (current.includes(moduleId)) return current.filter((item) => item !== moduleId); + if (current.length >= maxDetailModuleSelection) { + toast.info(`最多选择 ${maxDetailModuleSelection} 个模块`); + return current; + } + return [...current, moduleId]; + }); }; const toggleCloneModelScene = (scene: string) => { @@ -2019,6 +2587,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const normalizedPlatform = normalizePlatform(nextPlatform); setDetailPlatform(normalizedPlatform); setDetailLanguage(getPlatformDefaultLanguage(normalizedPlatform, detailMarket)); + setDetailRatio((current) => getQuickSetRatioValue(current)); }; const handleDetailMarketChange = (nextMarket: string) => { @@ -2104,7 +2673,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { setLanguage(normalizeLanguageForPlatform(nextPlatform, nextMarket, setting.language)); setRatio(normalizeRatioForPlatform(nextPlatform, setting.ratio, nextOutput)); setCloneSetCounts(nextCounts); - setSelectedCloneDetailModules(setting.detailModules?.length ? setting.detailModules : defaultCloneDetailModuleIds); + setSelectedCloneDetailModules((setting.detailModules?.length ? setting.detailModules : defaultCloneDetailModuleIds).slice(0, maxDetailModuleSelection)); setCloneModelPanelTab(setting.modelPanelTab === "model" ? "model" : "scene"); setSelectedCloneModelScenes(normalizeCloneModelSceneSelection(setting.modelScenes)); setCloneModelCustomScene(setting.modelCustomScene ?? ""); @@ -2141,6 +2710,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }, [productSetOutput, productSetPlatform]); useEffect(() => { + if (activeQuickTool === "set") { + setRatio((current) => getQuickSetRatioValue(current)); + return; + } setRatio((current) => { const platformRatios = getPlatformRatioOptions(platform, cloneOutput); const availableRatios = hotUploadedRatioOption ? getUniqueRatioOptions([...platformRatios, hotUploadedRatioOption]) : platformRatios; @@ -2150,7 +2723,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const matchedRatio = availableRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio)); return matchedRatio ?? getPlatformDefaultRatio(platform, cloneOutput); }); - }, [cloneOutput, hotUploadedRatioOption, platform]); + }, [activeQuickTool, cloneOutput, hotUploadedRatioOption, platform]); useEffect(() => { if (skipInitialCloneAutoSaveRef.current) { @@ -2325,23 +2898,27 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const handleDetailUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; - const uploadedFiles = notifyRejectedImages(Array.from(files)); - if (!uploadedFiles.length) { - event.target.value = ""; - return; - } - void (async () => { - try { - const nextImages = await createUploadedImageItems(uploadedFiles, 3 - detailProductImages.length, "detail"); - setDetailProductImages((current) => [...current, ...nextImages].slice(0, 3)); - setDetailStatus("ready"); - } catch (err) { - toast.error(err instanceof Error ? err.message : "璇︽儏鍥句笂浼犲け璐?"); - } - })(); + void addDetailImages(Array.from(files)); event.target.value = ""; }; + const handleDetailDrop = (event: DragEvent) => { + event.preventDefault(); + const files = Array.from(event.dataTransfer.files); + if (files.length) void addDetailImages(files); + }; + + const removeDetailImage = (imageId: string) => { + setDetailProductImages((current) => { + const next = current.filter((item) => item.id !== imageId); + if (next.length === 0) { + setDetailStatus("idle"); + setDetailResultUrl(null); + } + return next; + }); + }; + const blobToDataUrl = (blob: Blob): Promise => new Promise((resolve, reject) => { const reader = new FileReader(); @@ -2350,6 +2927,22 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { reader.readAsDataURL(blob); }); + const addDetailImages = async (files: File[]) => { + const uploadedFiles = notifyRejectedImages(files); + if (!uploadedFiles.length) return; + try { + const nextImages = await createUploadedImageItems(uploadedFiles, 3 - detailProductImages.length, "detail"); + setDetailProductImages((current) => { + if (current.length >= 3) return current; + return nextImages.length ? [...current, ...nextImages].slice(0, 3) : current; + }); + setDetailStatus("ready"); + setDetailResultUrl(null); + } catch (err) { + toast.error(err instanceof Error ? err.message : "璇︽儏鍥句笂浼犲け璐?"); + } + }; + const uploadCloneImages = async (images: CloneImageItem[]): Promise => { const urls: string[] = []; for (const item of images) { @@ -2773,9 +3366,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }; const toggleDetailModule = (moduleId: string) => { - setSelectedDetailModules((current) => - current.includes(moduleId) ? current.filter((item) => item !== moduleId) : [...current, moduleId], - ); + setSelectedDetailModules((current) => { + if (current.includes(moduleId)) return current.filter((item) => item !== moduleId); + if (current.length >= maxDetailModuleSelection) { + toast.info(`最多选择 ${maxDetailModuleSelection} 个模块`); + return current; + } + return [...current, moduleId]; + }); }; const handleSetGenerate = () => { @@ -2807,7 +3405,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { lastFailedActionRef.current = null; void generateEcommerceImage( "detail", detailProductImages, detailRequirement, - detailPlatform, getPlatformDefaultRatio(detailPlatform), detailLanguage, detailMarket, + detailPlatform, detailRatio, detailLanguage, detailMarket, { detailModules: selectedDetailModules }, (s: string) => setDetailStatus(s as DetailStatus), (res) => setDetailResultUrl(res[0]?.src ?? null), @@ -2872,6 +3470,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const isTryOn = activeTool === "wear"; const isCloneTool = activeTool === "clone"; const isSmartCutoutTool = isCloneTool && activeQuickTool === "cutout"; + const isQuickSetTool = isCloneTool && activeQuickTool === "set"; + const isQuickDetailTool = isCloneTool && activeQuickTool === "detail"; + const isWatermarkTool = isCloneTool && activeQuickTool === "watermark"; + const isImageEditTool = isCloneTool && activeQuickTool === "image-edit"; + const isHotVideoTool = isCloneTool && activeQuickTool === "hot-video"; const pageLabel = isSetTool ? "鍟嗗搧濂楀浘" : isDetail ? "A+/璇︽儏椤?" : isTryOn ? "AI鏈嶉グ绌挎埓" : activeToolMeta?.label || "鍟嗗搧宸ュ叿"; const setPrimaryLabel = setImages.length === 0 @@ -2989,7 +3592,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { setRequirement(record.requirement); setProductImages(record.productImages); setCloneSetCounts(record.setCounts); - setSelectedCloneDetailModules(record.detailModules); + setSelectedCloneDetailModules(record.detailModules.slice(0, maxDetailModuleSelection)); setSelectedCloneModelScenes(record.modelScenes); setCloneReferenceImages(record.referenceImages); setCloneReplicateLevel(record.replicateLevel); @@ -3064,6 +3667,35 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { { key: "language", label: "语种", value: language, options: cloneLanguageOptions, onChange: setLanguage }, { key: "ratio", label: "尺寸/比例", value: ratio, options: cloneRatioOptions, onChange: setRatio }, ]; + const handleQuickSetPlatformChange = (nextPlatform: string) => { + const normalizedPlatform = normalizePlatform(nextPlatform); + setPlatform(normalizedPlatform); + setLanguage(getPlatformDefaultLanguage(normalizedPlatform, market)); + }; + const quickSetBasicSelects: Array<{ + key: CloneBasicSelectKey; + label: string; + value: string; + options: string[]; + onChange: (value: string) => void; + }> = [ + { key: "platform", label: "平台", value: platform, options: platformOptions, onChange: handleQuickSetPlatformChange }, + { key: "market", label: "国家", value: market, options: marketOptions, onChange: handleCloneMarketChange }, + { key: "language", label: "语种", value: language, options: cloneLanguageOptions, onChange: setLanguage }, + { key: "ratio", label: "尺寸/比例", value: getQuickSetRatioValue(ratio), options: quickSetRatioOptions, onChange: setRatio }, + ]; + const quickDetailBasicSelects: Array<{ + key: CloneBasicSelectKey; + label: string; + value: string; + options: string[]; + onChange: (value: string) => void; + }> = [ + { key: "platform", label: "平台", value: detailPlatform, options: platformOptions, onChange: handleDetailPlatformChange }, + { key: "market", label: "国家", value: detailMarket, options: marketOptions, onChange: handleDetailMarketChange }, + { key: "language", label: "语种", value: detailLanguage, options: detailLanguageOptions, onChange: setDetailLanguage }, + { key: "ratio", label: "尺寸/比例", value: getQuickSetRatioValue(detailRatio), options: quickSetRatioOptions, onChange: setDetailRatio }, + ]; const cloneModelSelects: Array<{ key: CloneModelSelectKey; @@ -3892,19 +4524,27 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
{[ { label: "智能抠图", icon: , onClick: openSmartCutoutUpload }, - { label: "商品套图", icon: }, - { label: "图片修改", icon: }, - { label: "增/去水印", icon: }, + { label: "商品套图", icon: , onClick: openQuickProductSetPage }, + { label: "图片修改", icon: , onClick: openImageWorkbenchPage }, + { label: "增/去水印", icon: , onClick: openWatermarkRemovalPage }, { label: "图片批处理", icon: }, { label: "一键翻译", icon: }, - { label: "A+/详情页", icon: }, + { label: "A+/详情页", icon: , onClick: openQuickDetailPage }, { label: "变清晰", icon: }, { label: "AI消除", icon: }, { label: "证件照", icon: }, - { label: "爆款视频", icon: }, + { label: "爆款视频", icon: , onClick: openHotVideoPage }, { label: "拼图", icon: }, ].map((item) => ( - @@ -3989,31 +4629,48 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
-
+
- - +
+ 功能
{smartCutoutSizeOptions.map((item) => ( - +
+ + + {item.label} + {"sizeLabel" in item ? {item.sizeLabel} : null} + +
))}
- +
@@ -4174,6 +4831,900 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { ); + const imageWorkbenchPreview = ( +
+ + +