diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index 2d72959..e3dac72 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -15,7 +15,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 "../../styles/pages/ecommerce.css"; import "../../styles/pages/local-theme-parity.css"; import { ossAssets } from "../../data/ossAssets"; @@ -120,6 +120,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; @@ -721,6 +736,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); @@ -886,25 +921,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]; @@ -939,26 +975,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), @@ -1123,8 +1169,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); @@ -1164,7 +1214,7 @@ 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 [activeQuickTool, setActiveQuickTool] = useState<"cutout" | "set" | "detail" | "watermark" | "image-edit" | 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"); @@ -1178,16 +1228,31 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { 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(false); + const [isQuickPanelCollapsed, setIsQuickPanelCollapsed] = useState(false); const [openCloneModelSelect, setOpenCloneModelSelect] = useState(null); const [cloneModelSelectDropUp, setCloneModelSelectDropUp] = useState(false); const [cloneReferenceMode, setCloneReferenceMode] = useState("upload"); @@ -1211,10 +1276,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, @@ -1374,6 +1446,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("鏂板缓鍒涗綔"); @@ -1423,6 +1509,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); @@ -1614,6 +1701,355 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { 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 closeSmartCutoutTool = () => { runSmartCutoutPageTransition( { @@ -1767,6 +2203,79 @@ 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); + let canvasWidth = naturalWidth; + let canvasHeight = naturalHeight; + 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]); @@ -1840,6 +2349,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); @@ -1949,9 +2464,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) => { @@ -2006,6 +2526,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const normalizedPlatform = normalizePlatform(nextPlatform); setDetailPlatform(normalizedPlatform); setDetailLanguage(getPlatformDefaultLanguage(normalizedPlatform, detailMarket)); + setDetailRatio((current) => getQuickSetRatioValue(current)); }; const handleDetailMarketChange = (nextMarket: string) => { @@ -2091,7 +2612,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 ?? ""); @@ -2128,6 +2649,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; @@ -2137,7 +2662,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) { @@ -2312,23 +2837,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(); @@ -2337,6 +2866,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) { @@ -2760,9 +3305,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 = () => { @@ -2794,7 +3344,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), @@ -2859,6 +3409,10 @@ 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 pageLabel = isSetTool ? "鍟嗗搧濂楀浘" : isDetail ? "A+/璇︽儏椤?" : isTryOn ? "AI鏈嶉グ绌挎埓" : activeToolMeta?.label || "鍟嗗搧宸ュ叿"; const setPrimaryLabel = setImages.length === 0 @@ -2976,7 +3530,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); @@ -3051,6 +3605,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; @@ -3872,19 +4455,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: }, ].map((item) => ( - @@ -4145,7 +4736,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { ))}
- +
@@ -4154,6 +4745,736 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { ); + const imageWorkbenchPreview = ( +
+ + +