import { AppstoreAddOutlined, BorderOuterOutlined, ClearOutlined, CloudUploadOutlined, CloseOutlined, DeleteOutlined, EditOutlined, FireOutlined, FileImageOutlined, FileTextOutlined, FolderOpenOutlined, FrownOutlined, GlobalOutlined, HighlightOutlined, LoadingOutlined, MenuFoldOutlined, MenuUnfoldOutlined, PaperClipOutlined, PlayCircleOutlined, PlusOutlined, QuestionCircleOutlined, ReloadOutlined, ScissorOutlined, SettingOutlined, TableOutlined, TranslationOutlined, } from "@ant-design/icons"; import { ArrowsCounterClockwise, MagicWand, PaperPlaneRight, } from "@phosphor-icons/react"; import { Fragment, 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 { createPortal } from "react-dom"; import { useTypewriter } from "../../hooks/useTypewriter"; import "../../styles/pages/ecommerce.css"; import "../../styles/pages/local-theme-parity.css"; import { EcommerceProgressBar } from "./EcommerceProgressBar"; import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu"; import EcommerceVideoWorkspace from "./EcommerceVideoWorkspace"; import EcommerceVideoHistoryPanel from "./panels/EcommerceVideoHistoryPanel"; import ProductSetHostingModal from "./panels/ProductSetHostingModal"; import ProductSetPreviewModal, { type ProductSetPreviewSelection } from "./panels/ProductSetPreviewModal"; import CommandHistorySidebar from "./panels/CommandHistorySidebar"; import WatermarkToolPage from "./panels/WatermarkToolPage"; import EcommerceDetailPanel from "./panels/EcommerceDetailPanel"; import EcommerceSetPanel from "./panels/EcommerceSetPanel"; import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel"; import EcommerceClonePanel from "./panels/EcommerceClonePanel"; import EcommerceCopywritingPanel from "./panels/EcommerceCopywritingPanel"; import EcommerceOneClickVideoPanel from "./panels/EcommerceOneClickVideoPanel"; import { ecommerceOssScopes, listEcommerceGenerationHistory, saveUnifiedEcommerceGenerationRecord, deleteEcommerceGenerationRecord } from "./ecommerceGenerationPersistence"; import { downloadResultAsset } from "../workbench/workbenchDownload"; import { defaultCloneOutput, defaultEcommercePlatform, defaultProductSetOutput, getPlatformDefaultLanguage, getPlatformDefaultRatio, getPlatformLanguageOptions, getPlatformRatioOptions, marketLanguageOptions, marketOptions, normalizeLanguageForPlatform, normalizeMarket, normalizePlatform, normalizeRatioForPlatform, platformOptions, } from "./utils/platformRules"; import type { CloneOutputKey, ProductSetOutputKey, } from "./utils/platformRules"; import { buildHistoryTurnFromRecord, cloneLatestSettingStorageKey, defaultCloneDetailModuleIds, defaultCloneSetCounts, ecommerceHistoryStorageKey, getEcommerceHistoryUserBucket, getTurnResults, normalizeEcommerceHistoryRecord, normalizeEcommerceHistoryTurn, readEcommerceHistoryRecords, writeCloneLatestSetting, writeEcommerceHistoryRecords, } from "./utils/clonePersistence"; import type { CloneImageItem, CloneModelPanelTab, CloneReferenceMode, CloneReplicateLevelKey, CloneResult, CloneSavedSetting, CloneSetCountKey, CloneVideoQualityKey, EcommerceHistoryRecord, EcommerceHistoryStatus, EcommerceHistoryTurn, } from "./utils/clonePersistence"; import { aiGenerationClient } from "../../api/aiGenerationClient"; import { listEcommerceTemplates } from "../../api/ecommerceTemplateClient"; import { ServerRequestError } from "../../api/serverConnection"; import { waitForTask } from "../../api/taskSubscription"; import { toast } from "../../components/toast/toastStore"; import { useGenerationTasks } from "../../hooks/useGenerationTasks"; import { useAppStore } from "../../stores"; import { normalizeEcommerceImageMime, } from "./ecommerceImageValidation"; import { clampNumber, hexToHsv, hexToRgb, hsvToRgb, normalizeHexColor, parseSmartCutoutAspect, parseSmartCutoutPercent, rgbToHex, } from "./utils/colorUtils"; import { formatRatioDisplayValue, getQuickSetRatioValue, getRatioDisplayParts, normalizeRatioForApi, normalizeRatioToken, parseRatioToAspectCss, quickSetRatioOptions, } from "./utils/ratioUtils"; import type { SmartCutoutImageItem, ProductClonePageProps, ProductCloneStatus, CommerceScenarioKey, CommerceDefaultImageScenarioKey, CommerceDefaultIntent, ProductSetStatus, ProductKitToolKey, ComposerMenuKey, ComposerAssetTabKey, ComposerWorkModeKey, CloneBasicSelectKey, CloneModelSelectKey, CloneTemplateAsset, CommerceScenarioTemplate, TryOnModelSource, TryOnStatus, DetailStatus, CanvasNode, PreviewTouchPoint, PreviewTouchGesture, EcommerceImagePromptOptions, } from "./ecommerceTypes"; import { smartCutoutColorPresets, smartCutoutSizeOptions, type SmartCutoutSizeKey, buildInspirationPrompt, primaryCommerceScenarioKeys, scenarioSettingsKeys, scenarioAdvancedSettingsKeys, commerceScenarioOutputMap, mapRemoteTemplateToScenarioTemplate, commerceScenarioGenerationKind, cloneSetCountOptions, cloneSetCountKeys, minCloneSetTotal, maxCloneSetTotal, maxCloneProductImages, maxCloneReferenceImages, cloneVideoDurationMin, cloneVideoDurationMax, composerDurationOptions, cloneVideoQualityOptions, cloneReplicateLevelOptions, tryOnRatioOptions, tryOnScenes, normalizeCloneModelSceneSelection, tryOnModelOptions, detailTypeOptions, detailModules, defaultDetailModuleIds, maxDetailModuleSelection, cloneDetailModules, getImageFileFormat, getRemoteImageFormat, getRemoteImageName, readImageDimensions, clampCloneVideoDuration, mergeEcommerceHistoryRecords, } from "./ecommerceConstants"; import { sideTools, productSetOutputOptions, cloneOutputOptions, commerceScenarioOptions, } from "./ecommerceJsxConstants"; import { ecommerceInspirationRows, productSetPreviewCards, tryOnAssets, tryOnCards, detailAssets, detailProductSamples, detailGridSamples, commerceScenarioTemplates, } from "./ecommerceAssets"; import { createLocalImageItems, uploadImageItem, persistGeneratedImageUrl, notifyRejectedImages, } from "./ecommerceImagePipeline"; import { classifyDefaultCommerceIntent } from "./ecommerceIntentClassifier"; function ProductClonePage(_props: ProductClonePageProps = {}) { const { onWorkspaceChromeChange } = _props; const setInputRef = useRef(null); const productInputRef = useRef(null); const cloneReferenceInputRef = useRef(null); const smartCutoutInputRef = useRef(null); const imageWorkbenchInputRef = useRef(null); const imageWorkbenchUrlInputRef = useRef(null); const imageWorkbenchProgressRef = useRef(null); const watermarkInputRef = useRef(null); const watermarkUrlInputRef = useRef(null); const watermarkProcessTimeoutRef = useRef(null); const translateInputRef = useRef(null); const translateUrlInputRef = useRef(null); const translateProcessTimeoutRef = useRef(null); const smartCutoutTransitionTimeoutRef = useRef(null); const smartCutoutPendingUrlsRef = useRef([]); const smartCutoutPaletteRef = useRef(null); const smartCutoutToolsRef = useRef(null); const composerMenuCloseTimeoutRef = useRef(null); const requirementTextareaRef = useRef(null); const commandComposerWrapRef = useRef(null); const templateStripRef = useRef(null); 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); const requestLogin = () => { const handler = (_props as Record).onRequireLogin; if (typeof handler === "function") handler(); }; const imageGen = useGenerationTasks({ sourceView: "ecommerce" }); const appUsage = useAppStore((s) => s.usage); const latestCloneSettingRef = useRef(null); const skipInitialCloneAutoSaveRef = useRef(true); const skipNextCloneAutoSaveRef = useRef(false); const [activeTool, setActiveTool] = useState("clone"); useEffect(() => { setPreviewZoom(1); setIsCommandComposerCompact(false); }, [activeTool]); const [setImages, setSetImages] = useState([]); const [productSetPlatform, setProductSetPlatform] = useState(defaultEcommercePlatform); const [productSetMarket, setProductSetMarket] = useState(marketOptions[0]); const [productSetLanguage, setProductSetLanguage] = useState(getPlatformDefaultLanguage(defaultEcommercePlatform, marketOptions[0])); const [productSetRatio, setProductSetRatio] = useState(getPlatformDefaultRatio(defaultEcommercePlatform, defaultProductSetOutput)); const [productSetRequirement, setProductSetRequirement] = useState(""); const [productSetOutput, setProductSetOutput] = useState(defaultProductSetOutput); const [productSetStatus, setProductSetStatus] = useState("idle"); // 套图/图像生成的真实进度(0-100):多张串行生成时按"已完成张数 + 当前张子进度"推进, // 替代进度条原先写死 50 导致卡在 75% 的假进度。 const [generationProgress, setGenerationProgress] = useState(0); const [productSetResultImages, setProductSetResultImages] = useState([]); const [isSetUploadDragging, setIsSetUploadDragging] = useState(false); const [selectedProductSetPreview, setSelectedProductSetPreview] = useState(null); const [showHostingModal, setShowHostingModal] = useState(false); const [productImages, setProductImages] = useState([]); const [activeQuickTool, setActiveQuickTool] = useState<"cutout" | "detail" | "watermark" | "image-edit" | "translate" | "hot" | "quick-set" | "copywriting" | "oneClickVideo" | 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" | "failed">("idle"); const [isWatermarkDragging, setIsWatermarkDragging] = useState(false); const [watermarkResultUrl, setWatermarkResultUrl] = useState(null); const [watermarkProgress, setWatermarkProgress] = useState(0); const [translateImage, setTranslateImage] = useState<{ src: string; name: string; format: string } | null>(null); const [translateStatus, setTranslateStatus] = useState<"idle" | "processing" | "done" | "failed">("idle"); const [isTranslateDragging, setIsTranslateDragging] = useState(false); const [translateLanguage, setTranslateLanguage] = useState("zh"); const [translateResultUrl, setTranslateResultUrl] = useState(null); 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" | "failed">("idle"); const [isImageWorkbenchDragging, setIsImageWorkbenchDragging] = useState(false); const [imageWorkbenchMaskStrokes, setImageWorkbenchMaskStrokes] = useState }>>([]); const [imageWorkbenchBrushCursor, setImageWorkbenchBrushCursor] = useState<{ x: number; y: number } | null>(null); const [imageWorkbenchResultUrl, setImageWorkbenchResultUrl] = useState(null); const [imageWorkbenchProgress, setImageWorkbenchProgress] = useState(0); const [isProductUploadDragging, setIsProductUploadDragging] = useState(false); const [activeCommerceScenario, setActiveCommerceScenario] = useState(null); const [isCommerceScenarioMoreOpen, setIsCommerceScenarioMoreOpen] = useState(false); const [remoteCommerceScenarioTemplates, setRemoteCommerceScenarioTemplates] = useState(null); const [cloneOutput, setCloneOutput] = useState(defaultCloneOutput); const [isCloneTemplateStripVisible, setIsCloneTemplateStripVisible] = useState(false); const [videoHistoryVisible, setVideoHistoryVisible] = useState(false); const [isVideoWorkspaceVisible, setIsVideoWorkspaceVisible] = useState(false); const [videoPlanTrigger, setVideoPlanTrigger] = useState(0); const [isDefaultIntentRouting, setIsDefaultIntentRouting] = useState(false); 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 [composerPopoverTop, setComposerPopoverTop] = useState(0); const [composerTooltip, setComposerTooltip] = useState<{ text: string; left: number; top: number } | null>(null); const [composerAssetTab, setComposerAssetTab] = useState("recent"); const [composerWorkMode, setComposerWorkMode] = useState("quick"); const [aiWriteDraft, setAiWriteDraft] = useState(""); const [isCommandHistoryCollapsed, setIsCommandHistoryCollapsed] = useState(true); const [inspirationPreview, setInspirationPreview] = useState<{ mediaUrl: string; mediaType: "image" | "video"; prompt: string } | null>(null); const [isQuickPanelCollapsed, setIsQuickPanelCollapsed] = useState(false); const [openCloneModelSelect, setOpenCloneModelSelect] = useState(null); const [cloneModelSelectDropUp, setCloneModelSelectDropUp] = useState(false); const [cloneReferenceMode, setCloneReferenceMode] = useState("upload"); const [cloneReferenceImages, setCloneReferenceImages] = useState([]); const [cloneReplicateLevel, setCloneReplicateLevel] = useState("high"); const [cloneSetCounts, setCloneSetCounts] = useState(defaultCloneSetCounts); const [selectedCloneDetailModules, setSelectedCloneDetailModules] = useState(defaultCloneDetailModuleIds); const [cloneModelPanelTab, setCloneModelPanelTab] = useState("scene"); const [selectedCloneModelScenes, setSelectedCloneModelScenes] = useState([]); const [cloneModelCustomScene, setCloneModelCustomScene] = useState(""); const [cloneModelGender, setCloneModelGender] = useState(tryOnModelOptions.gender[0]); const [cloneModelAge, setCloneModelAge] = useState(tryOnModelOptions.age[0]); const [cloneModelEthnicity, setCloneModelEthnicity] = useState(tryOnModelOptions.ethnicity[0]); const [cloneModelBody, setCloneModelBody] = useState(tryOnModelOptions.body[0]); const [cloneModelAppearance, setCloneModelAppearance] = useState(""); const [cloneVideoQuality, setCloneVideoQuality] = useState("high"); const [cloneVideoDuration, setCloneVideoDuration] = useState(10); const [cloneVideoSmart, setCloneVideoSmart] = useState(true); const [isCloneSettingsCollapsed, setIsCloneSettingsCollapsed] = useState(false); const [isCloneConversationCollapsed, setIsCloneConversationCollapsed] = useState(false); const [quickSetStatus, setQuickSetStatus] = useState<"idle" | "generating" | "done" | "failed">("idle"); const [quickSetResultUrls, setQuickSetResultUrls] = useState([]); const [quickSetProgress, setQuickSetProgress] = useState(0); const [quickSetRequirement, setQuickSetRequirement] = useState(""); const quickSetProgressRef = useRef(null); 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, startY: 0, offsetX: 0, offsetY: 0, }); const previewTouchGestureRef = useRef({ mode: "none", points: [], startOffset: { x: 0, y: 0 }, startZoom: 1, startDistance: 0, startCenter: { x: 0, y: 0 }, }); const nodeDragRef = useRef<{ active: boolean; nodeId: string; startX: number; startY: number; originX: number; originY: number }>({ active: false, nodeId: "", startX: 0, startY: 0, originX: 0, originY: 0, }); const [isCommandComposerCompact, setIsCommandComposerCompact] = useState(false); const typewriterText = useTypewriter("万物皆可AI,广告素材一键生成"); useEffect(() => { previewZoomRef.current = previewZoom; }, [previewZoom]); useEffect(() => { previewOffsetRef.current = previewOffset; }, [previewOffset]); useEffect(() => { if (typeof window === "undefined") return undefined; // aside 默认收起,用户手动控制展开/收起 return undefined; }, []); useEffect(() => { if (!inspirationPreview) return undefined; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") setInspirationPreview(null); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [inspirationPreview]); const previewTransformStyle = useMemo( () => ({ transform: `translate3d(${previewOffset.x}px, ${previewOffset.y}px, 0) scale(${previewZoom})`, }), [previewOffset.x, previewOffset.y, previewZoom], ); const updatePreviewTransform = (nextZoom: number, nextOffset: { x: number; y: number }) => { previewZoomRef.current = nextZoom; previewOffsetRef.current = nextOffset; setPreviewZoom(nextZoom); setPreviewOffset(nextOffset); }; const getPreviewGestureDistance = (points: PreviewTouchPoint[]) => { if (points.length < 2) return 0; return Math.hypot(points[0]!.x - points[1]!.x, points[0]!.y - points[1]!.y); }; const getPreviewGestureCenter = (points: PreviewTouchPoint[]) => { if (points.length < 2) return points[0] ? { x: points[0].x, y: points[0].y } : { x: 0, y: 0 }; return { x: (points[0]!.x + points[1]!.x) / 2, y: (points[0]!.y + points[1]!.y) / 2, }; }; const isPreviewTouchInteractiveTarget = (target: HTMLElement | null) => Boolean(target?.closest(".ecom-command-composer-wrap, .clone-ai-preview-header, .clone-ai-source-corner-action, input, textarea, select, a, button")); const startPreviewTouchGesture = (event: ReactPointerEvent) => { if (event.pointerType === "mouse" || isPreviewTouchInteractiveTarget(event.target as HTMLElement | null)) return; event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); const points = [ ...previewTouchGestureRef.current.points.filter((point) => point.id !== event.pointerId), { id: event.pointerId, x: event.clientX, y: event.clientY }, ].slice(-2); const mode = points.length >= 2 ? "pinch" : "pan"; previewTouchGestureRef.current = { mode, points, startOffset: previewOffsetRef.current, startZoom: previewZoomRef.current, startDistance: getPreviewGestureDistance(points), startCenter: getPreviewGestureCenter(points), }; event.currentTarget.classList.add("is-touch-panning"); }; const movePreviewTouchGesture = (event: ReactPointerEvent) => { const gesture = previewTouchGestureRef.current; if (gesture.mode === "none" || event.pointerType === "mouse") return; event.preventDefault(); const points = gesture.points.map((point) => point.id === event.pointerId ? { ...point, x: event.clientX, y: event.clientY } : point); if (!points.some((point) => point.id === event.pointerId)) return; if (gesture.mode === "pinch" && points.length >= 2 && gesture.startDistance > 0) { const rect = event.currentTarget.getBoundingClientRect(); const center = getPreviewGestureCenter(points); const zoomRatio = getPreviewGestureDistance(points) / gesture.startDistance; const nextZoom = Math.min(2, Math.max(0.25, gesture.startZoom * zoomRatio)); const startCenterX = gesture.startCenter.x - rect.left; const startCenterY = gesture.startCenter.y - rect.top; const currentCenterX = center.x - rect.left; const currentCenterY = center.y - rect.top; const contentX = (startCenterX - gesture.startOffset.x) / gesture.startZoom; const contentY = (startCenterY - gesture.startOffset.y) / gesture.startZoom; updatePreviewTransform(nextZoom, { x: currentCenterX - contentX * nextZoom, y: currentCenterY - contentY * nextZoom, }); } else { const point = points[0]!; const startPoint = gesture.points[0]!; updatePreviewTransform(gesture.startZoom, { x: gesture.startOffset.x + point.x - startPoint.x, y: gesture.startOffset.y + point.y - startPoint.y, }); } previewTouchGestureRef.current = { ...gesture, points }; }; const stopPreviewTouchGesture = (event: ReactPointerEvent) => { const gesture = previewTouchGestureRef.current; if (event.pointerType === "mouse" || gesture.mode === "none") return; const points = gesture.points.filter((point) => point.id !== event.pointerId); if (points.length) { previewTouchGestureRef.current = { mode: "pan", points, startOffset: previewOffsetRef.current, startZoom: previewZoomRef.current, startDistance: 0, startCenter: getPreviewGestureCenter(points), }; } else { previewTouchGestureRef.current = { mode: "none", points: [], startOffset: previewOffsetRef.current, startZoom: previewZoomRef.current, startDistance: 0, startCenter: { x: 0, y: 0 }, }; event.currentTarget.classList.remove("is-touch-panning"); } try { event.currentTarget.releasePointerCapture(event.pointerId); } catch { // Pointer capture can already be released by the browser after cancel. } }; useEffect(() => { const container = previewSurfaceRef.current; if (!container) return undefined; const handleWheel = (event: WheelEvent) => { const target = event.target as HTMLElement | null; if (target?.closest(".ecom-inspiration-preview")) { event.preventDefault(); return; } if (target?.closest(".ecom-command-composer-wrap, .clone-ai-preview-header")) return; event.preventDefault(); const currentZoom = previewZoomRef.current; const rect = container.getBoundingClientRect(); const cursorX = event.clientX - rect.left; const cursorY = event.clientY - rect.top; const zoomDelta = event.deltaY < 0 ? 1.08 : 0.92; const nextZoom = Math.min(2, Math.max(0.25, currentZoom * zoomDelta)); if (nextZoom === currentZoom) return; const currentOffset = previewOffsetRef.current; const contentX = (cursorX - currentOffset.x) / currentZoom; const contentY = (cursorY - currentOffset.y) / currentZoom; const nextOffset = { x: cursorX - contentX * nextZoom, y: cursorY - contentY * nextZoom, }; previewZoomRef.current = nextZoom; previewOffsetRef.current = nextOffset; setPreviewZoom(nextZoom); setPreviewOffset(nextOffset); }; container.addEventListener("wheel", handleWheel, { passive: false, capture: true }); return () => container.removeEventListener("wheel", handleWheel, { capture: true }); }, [activeTool, cloneOutput]); useEffect(() => { const container = previewSurfaceRef.current; if (!container) return undefined; const listenerOptions = { capture: true }; const handleMouseDown = (event: MouseEvent) => { if (event.button !== 0 && event.button !== 1) return; const target = event.target as HTMLElement | null; if (target?.closest(".ecom-command-composer-wrap, .clone-ai-preview-header, .clone-ai-preview-showcase, .clone-ai-main-result, .clone-ai-result-grid, .clone-ai-node-drag-handle, input, textarea, select, a, button, img")) return; event.preventDefault(); const currentOffset = previewOffsetRef.current; previewPanRef.current = { active: true, startX: event.clientX, startY: event.clientY, offsetX: currentOffset.x, offsetY: currentOffset.y, }; container.classList.add("is-middle-panning"); }; const handleMouseMove = (event: MouseEvent) => { const pan = previewPanRef.current; if (!pan.active) return; event.preventDefault(); const nextOffset = { x: pan.offsetX + event.clientX - pan.startX, y: pan.offsetY + event.clientY - pan.startY, }; previewOffsetRef.current = nextOffset; setPreviewOffset(nextOffset); }; const stopMousePan = () => { const pan = previewPanRef.current; if (!pan.active) return; previewPanRef.current = { ...pan, active: false }; container.classList.remove("is-middle-panning"); }; const preventAuxClick = (event: MouseEvent) => { if (event.button === 1) event.preventDefault(); }; container.addEventListener("mousedown", handleMouseDown, listenerOptions); container.addEventListener("auxclick", preventAuxClick, listenerOptions); window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", stopMousePan); window.addEventListener("blur", stopMousePan); return () => { container.removeEventListener("mousedown", handleMouseDown, listenerOptions); container.removeEventListener("auxclick", preventAuxClick, listenerOptions); window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", stopMousePan); window.removeEventListener("blur", stopMousePan); }; }, [activeTool, cloneOutput]); const bindPreviewSurface = (node: HTMLElement | null) => { previewSurfaceRef.current = node; }; const getPreviewSurfaceProps = () => ({ ref: bindPreviewSurface, onMouseDown: (event: ReactMouseEvent) => { if (event.button === 1) event.preventDefault(); }, onAuxClick: (event: ReactMouseEvent) => { if (event.button === 1) event.preventDefault(); }, onPointerDown: startPreviewTouchGesture, onPointerMove: movePreviewTouchGesture, onPointerUp: stopPreviewTouchGesture, onPointerCancel: stopPreviewTouchGesture, }); const startCanvasNodeDrag = (event: ReactPointerEvent, node: CanvasNode) => { if (event.button !== 0 || event.pointerType === "mouse") return; if ((event.target as HTMLElement | null)?.closest("button, a, input, textarea, select")) return; event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); nodeDragRef.current = { active: true, nodeId: node.id, startX: event.clientX, startY: event.clientY, originX: node.x, originY: node.y }; }; const moveCanvasNodeDrag = (event: ReactPointerEvent, nodeId: string) => { const drag = nodeDragRef.current; if (!drag.active || drag.nodeId !== nodeId || event.pointerType === "mouse") return; event.preventDefault(); event.stopPropagation(); const zoom = previewZoomRef.current; const dx = (event.clientX - drag.startX) / zoom; const dy = (event.clientY - drag.startY) / zoom; setCanvasNodes((prev) => prev.map((node) => node.id === nodeId ? { ...node, x: drag.originX + dx, y: drag.originY + dy } : node)); }; const stopCanvasNodeDrag = (event: ReactPointerEvent, nodeId: string) => { if (nodeDragRef.current.nodeId !== nodeId || event.pointerType === "mouse") return; nodeDragRef.current = { ...nodeDragRef.current, active: false }; event.stopPropagation(); try { event.currentTarget.releasePointerCapture(event.pointerId); } catch { // Pointer capture can already be released by the browser after cancel. } }; const handlePreviewWheel = (event: React.WheelEvent) => { if (!event.currentTarget) return; const container = event.currentTarget as HTMLElement; const rect = container.getBoundingClientRect(); const cursorX = event.clientX - rect.left; const cursorY = event.clientY - rect.top; const zoomDelta = event.deltaY < 0 ? 1.08 : 0.92; const nextZoom = Math.min(2, Math.max(0.25, previewZoom * zoomDelta)); if (nextZoom === previewZoom) return; const contentX = (cursorX + container.scrollLeft) / previewZoom; const contentY = (cursorY + container.scrollTop) / previewZoom; setPreviewZoom(nextZoom); requestAnimationFrame(() => { container.scrollLeft = contentX * nextZoom - cursorX; container.scrollTop = contentY * nextZoom - cursorY; }); }; 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("新建创作"); const [platform, setPlatform] = useState(defaultEcommercePlatform); const [market, setMarket] = useState(marketOptions[0]); const [language, setLanguage] = useState(getPlatformDefaultLanguage(defaultEcommercePlatform, marketOptions[0])); const [ratio, setRatio] = useState(getPlatformDefaultRatio(defaultEcommercePlatform, defaultCloneOutput)); const [status, setStatus] = useState("idle"); const [results, setResults] = useState([]); const [canvasNodes, setCanvasNodes] = useState([]); const [ecommerceHistoryRecords, setEcommerceHistoryRecords] = useState(() => readEcommerceHistoryRecords()); const [activeHistoryRecordId, setActiveHistoryRecordId] = useState(null); const [historyRefreshTick, setHistoryRefreshTick] = useState(0); const [isHistoryRefreshing, setIsHistoryRefreshing] = useState(false); const [historyRefreshMessage, setHistoryRefreshMessage] = useState(""); const [historyRefreshStamp, setHistoryRefreshStamp] = useState(0); const historyRefreshLockRef = useRef(false); const lastSavedHistorySignatureRef = useRef(""); const historyUserBucketRef = useRef(getEcommerceHistoryUserBucket()); const imageAbortRef = useRef({ current: false }); const activeHistoryTurnIdRef = useRef(null); const activeEcommerceTaskIdsRef = useRef>(new Set()); const lastFailedActionRef = useRef<(() => void) | null>(null); const [garmentImages, setGarmentImages] = useState([]); const [modelSource, setModelSource] = useState("ai"); const [modelGender, setModelGender] = useState(tryOnModelOptions.gender[0]); const [modelAge, setModelAge] = useState(tryOnModelOptions.age[0]); const [modelEthnicity, setModelEthnicity] = useState(tryOnModelOptions.ethnicity[0]); const [modelBody, setModelBody] = useState(tryOnModelOptions.body[0]); const [appearance, setAppearance] = useState(""); const [selectedScenes, setSelectedScenes] = useState([]); useEffect(() => { if (status === "done") { setIsCommandComposerCompact(true); } else if (status === "generating" || status === "idle") { setIsCommandComposerCompact(false); } }, [status]); // 用户身份变化(登入 / 登出 / 换账号)时,工作台保活不卸载,内存里的历史记录 // 不会自动失效。这里检测分桶 key 变化并从当前用户的 bucket 重新加载, // 避免未登录或换账号后仍显示上一个用户的历史。 useEffect(() => { const bucket = getEcommerceHistoryUserBucket(); if (bucket === historyUserBucketRef.current) return; historyUserBucketRef.current = bucket; setActiveHistoryRecordId(null); lastSavedHistorySignatureRef.current = ""; setEcommerceHistoryRecords(readEcommerceHistoryRecords()); }, [isAuthenticated]); useEffect(() => { writeEcommerceHistoryRecords(ecommerceHistoryRecords); }, [ecommerceHistoryRecords]); useEffect(() => { if (!isAuthenticated) return; let cancelled = false; void listEcommerceGenerationHistory(30) .then((serverRecords) => { if (cancelled) return; setEcommerceHistoryRecords((current) => { const mergedRecords = mergeEcommerceHistoryRecords(serverRecords, current, readEcommerceHistoryRecords()); writeEcommerceHistoryRecords(mergedRecords); return mergedRecords; }); }) .catch(() => { // Local history remains available when the server list endpoint is offline. }); return () => { cancelled = true; }; }, [isAuthenticated]); const [customScene, setCustomScene] = useState(""); const [smartScene, setSmartScene] = useState(false); const [tryOnRatio, setTryOnRatio] = useState(tryOnRatioOptions[0]); const [tryOnStatus, setTryOnStatus] = useState("idle"); const [tryOnResultImages, setTryOnResultImages] = useState([]); const [detailProductImages, setDetailProductImages] = useState([]); 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); 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); useEffect(() => { let cancelled = false; listEcommerceTemplates() .then((response) => { if (cancelled) return; const templates = response.templates .map(mapRemoteTemplateToScenarioTemplate) .filter((template): template is CommerceScenarioTemplate => Boolean(template)); setRemoteCommerceScenarioTemplates(templates.length ? templates : null); }) .catch(() => { if (!cancelled) setRemoteCommerceScenarioTemplates(null); }); return () => { cancelled = true; }; }, []); const productSetRatioOptions = useMemo( () => getPlatformRatioOptions(productSetPlatform, productSetOutput), [productSetOutput, productSetPlatform], ); const baseCloneRatioOptions = useMemo( () => getPlatformRatioOptions(platform, cloneOutput), [cloneOutput, platform], ); const cloneRatioOptions = baseCloneRatioOptions; const composerRatioOptions = useMemo( () => [ "1000×1000px\u00a0\u00a0\u00a01:1", "800×1200px\u00a0\u00a0\u00a02:3", "1200×800px\u00a0\u00a0\u00a03:2", "1200×900px\u00a0\u00a0\u00a04:3", "900×1200px\u00a0\u00a0\u00a03:4", "1080×1920px\u00a0\u00a0\u00a09:16", "1920×1080px\u00a0\u00a0\u00a016:9", ], [], ); const productSetLanguageOptions = useMemo( () => getPlatformLanguageOptions(productSetPlatform, productSetMarket), [productSetMarket, productSetPlatform], ); const cloneLanguageOptions = useMemo( () => getPlatformLanguageOptions(platform, market), [market, platform], ); const detailLanguageOptions = useMemo( () => 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}` })), ]; const ecommerceVideoImageDataUrls = useMemo( () => productImages.map((img) => img.src), [productImages], ); const ecommerceVideoImageFiles = useMemo( () => productImages.map((img) => img.file), [productImages], ); const selectedProductSetOutput = productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!; const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!; const visibleCommerceScenarioOptions = useMemo( () => isCommerceScenarioMoreOpen ? commerceScenarioOptions : commerceScenarioOptions.filter((option) => primaryCommerceScenarioKeys.includes(option.key)), [isCommerceScenarioMoreOpen], ); const effectiveCommerceScenarioTemplates = remoteCommerceScenarioTemplates?.length ? remoteCommerceScenarioTemplates : commerceScenarioTemplates; const popularCommerceScenarioTemplates = useMemo( () => commerceScenarioOptions .filter((option): option is { key: Exclude; label: string; desc: string; icon: ReactNode } => option.key !== "popular") .map((option) => effectiveCommerceScenarioTemplates.find((template) => template.scenario === option.key)) .filter((template): template is CommerceScenarioTemplate => Boolean(template)), [effectiveCommerceScenarioTemplates], ); const activeCommerceScenarioTemplates = activeCommerceScenario === null ? [] : activeCommerceScenario === "popular" ? popularCommerceScenarioTemplates : effectiveCommerceScenarioTemplates.filter((template) => template.scenario === activeCommerceScenario); const shouldShowScenarioSettings = activeCommerceScenario !== null && scenarioSettingsKeys.includes(activeCommerceScenario); useEffect(() => { templateStripRef.current?.scrollTo({ left: 0, behavior: "auto" }); }, [activeCommerceScenario, isCloneTemplateStripVisible]); const cloneRequirementPlaceholder = cloneOutput === "model" ? "建议包含以下信息:产品名称、核心卖点、期望场景、模特外貌描述(如小麦色皮肤、齐刘海、眼角有泪痣)、具体参数" : "建议包含以下信息:产品名称、核心卖点、期望场景、具体参数"; const productSetPreviewReady = productSetStatus === "done"; const cloneSetTotal = useMemo( () => Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0), [cloneSetCounts], ); const canGenerateSet = setImages.length > 0 && productSetStatus !== "generating"; 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 canGenerateQuickSet = productImages.length > 0 && quickSetStatus !== "generating"; const cloneVideoDurationProgress = ((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100; const cloneVideoDurationStyle: CSSProperties = useMemo( () => ({ "--clone-video-duration-progress": `${cloneVideoDurationProgress}%`, }) as CSSProperties, [cloneVideoDurationProgress], ); const trackEcommerceTask = (taskId: string) => { activeEcommerceTaskIdsRef.current.add(taskId); }; const untrackEcommerceTask = (taskId: string) => { activeEcommerceTaskIdsRef.current.delete(taskId); }; const handleCancelGenerate = () => { imageAbortRef.current.current = true; const taskIds = Array.from(activeEcommerceTaskIdsRef.current); activeEcommerceTaskIdsRef.current.clear(); taskIds.forEach((taskId) => { aiGenerationClient.cancelTask(taskId).catch(() => {}); }); lastFailedActionRef.current = null; if (productSetStatus === "generating") setProductSetStatus("idle"); if (status === "generating") { setStatus("idle"); if (activeHistoryRecordId) { const turnId = activeHistoryTurnIdRef.current; if (turnId) { updateLocalEcommerceHistoryTurn(activeHistoryRecordId, turnId, (turn) => ({ ...turn, status: "failed", errorMessage: "已取消生成", })); } else { updateLocalEcommerceHistoryRecord(activeHistoryRecordId, (record) => ({ ...record, status: "failed", errorMessage: "已取消生成", })); } } } if (detailStatus === "generating") setDetailStatus("idle"); if (tryOnStatus === "generating") setTryOnStatus("idle"); if (tryOnStatus === "modeling") setTryOnStatus("ready"); toast.info("\u5df2\u53d6\u6d88\u751f\u6210"); }; const syncRequirementMentionQuery = (value: string, selectionStart: number | null | undefined) => { setRequirementImageMentionQuery(ecommerceMentionImages.length ? getImageMentionQuery(value, selectionStart) : null); }; const insertRequirementImageMention = (image: MentionImageOption) => { const textarea = requirementTextareaRef.current; const cursor = textarea?.selectionStart ?? requirement.length; const next = insertImageMentionValue(requirement, cursor, image.name, 500); setRequirement(next.value); setRequirementImageMentionQuery(null); window.requestAnimationFrame(() => { requirementTextareaRef.current?.focus(); requirementTextareaRef.current?.setSelectionRange(next.selectionStart, next.selectionStart); }); }; const addSetImages = (files: File[]) => { if (setImages.length >= 3) return; const imageFiles = notifyRejectedImages(files); if (!imageFiles.length) return; const remainingSlots = 3 - setImages.length; if (remainingSlots <= 0) return; const localItems = createLocalImageItems(imageFiles, remainingSlots, "set"); setSetImages((current) => [...current, ...localItems].slice(0, 3)); setProductSetStatus("ready"); Promise.all(localItems.map(async (item) => { const uploaded = await uploadImageItem(item); if (uploaded.src) URL.revokeObjectURL(item.src); return { id: item.id, uploaded }; })).then((results) => { const updateMap = new Map(results.map((result) => [result.id, result.uploaded])); setSetImages((current) => current.map((item) => { const update = updateMap.get(item.id); return update ? { ...item, ...update } : item; })); }).catch(() => { toast.error("套图后台上传失败,请检查网络后重试"); }); }; const handleSetUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; void addSetImages(Array.from(files)); event.target.value = ""; }; const handleSetDrop = (event: DragEvent) => { event.preventDefault(); setIsSetUploadDragging(false); const files = Array.from(event.dataTransfer.files); if (files.length) void addSetImages(files); }; 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 = () => { if (smartCutoutTransitionTimeoutRef.current !== null) { window.clearTimeout(smartCutoutTransitionTimeoutRef.current); smartCutoutTransitionTimeoutRef.current = null; } if (smartCutoutPendingUrlsRef.current.length) { smartCutoutPendingUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); smartCutoutPendingUrlsRef.current = []; } setIsSmartCutoutTransitioning(false); }; const runSmartCutoutPageTransition = (message: { title: string; subtitle: string }, action: () => void, delay = 460) => { clearSmartCutoutTransition(); setSmartCutoutTransitionMessage(message); setIsSmartCutoutTransitioning(true); smartCutoutTransitionTimeoutRef.current = window.setTimeout(() => { smartCutoutTransitionTimeoutRef.current = null; action(); setIsSmartCutoutTransitioning(false); }, delay); }; const openSmartCutoutUpload = () => { clearSmartCutoutTransition(); setComposerMenu(null); toast.info("功能正在优化中"); }; const openWatermarkRemovalPage = () => { clearSmartCutoutTransition(); setActiveQuickTool("watermark"); setComposerMenu(null); setIsCloneSettingsCollapsed(false); }; const loadRemoteImageFromInput = async (input: HTMLInputElement | null, fallbackName: string) => { const rawValue = input?.value.trim() ?? ""; if (!rawValue) { toast.info("请先粘贴图片 URL"); return null; } let imageUrl: string; try { const parsed = new URL(rawValue, window.location.href); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new Error("仅支持 http 或 https 图片链接"); } imageUrl = parsed.toString(); } catch (error) { toast.error(error instanceof Error ? error.message : "图片 URL 不正确"); return null; } try { const response = await fetch(imageUrl); if (!response.ok) throw new Error(`图片读取失败(${response.status})`); const blob = await response.blob(); if (!blob.type.startsWith("image/")) throw new Error("链接内容不是图片"); const src = URL.createObjectURL(blob); try { await readImageDimensions(src); } catch { URL.revokeObjectURL(src); throw new Error("图片无法预览,请换一个链接"); } if (input) input.value = ""; return { src, name: getRemoteImageName(imageUrl, fallbackName), format: getRemoteImageFormat(blob.type, imageUrl), }; } catch (error) { toast.error(error instanceof Error ? error.message : "图片导入失败"); return null; } }; const closeWatermarkRemovalPage = () => { stopWatermarkProgress(); setActiveQuickTool(null); setWatermarkStatus("idle"); setWatermarkResultUrl(null); setWatermarkProgress(0); 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"); setWatermarkResultUrl(null); setWatermarkProgress(0); setActiveQuickTool("watermark"); }; const removeWatermarkImage = () => { stopWatermarkProgress(); setWatermarkStatus("idle"); setWatermarkResultUrl(null); setWatermarkProgress(0); 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 handleWatermarkUrlImport = async () => { const nextImage = await loadRemoteImageFromInput(watermarkUrlInputRef.current, "watermark-source"); if (!nextImage) return; setWatermarkImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return nextImage; }); setWatermarkStatus("idle"); setActiveQuickTool("watermark"); toast.success("图片已导入"); }; const stopWatermarkProgress = () => { if (watermarkProcessTimeoutRef.current !== null) { window.clearInterval(watermarkProcessTimeoutRef.current); watermarkProcessTimeoutRef.current = null; } }; const startWatermarkProgress = () => { stopWatermarkProgress(); setWatermarkProgress(0); watermarkProcessTimeoutRef.current = window.setInterval(() => { setWatermarkProgress((prev) => { if (prev >= 90) { stopWatermarkProgress(); return 90; } return prev + (90 - prev) * 0.06; }); }, 500); }; const handleWatermarkGenerate = async () => { if (!watermarkImage || watermarkStatus === "processing") return; setWatermarkStatus("processing"); setWatermarkResultUrl(null); startWatermarkProgress(); try { const sourceBlob = await fetch(watermarkImage.src).then((res) => res.blob()); const sourceMime = normalizeEcommerceImageMime(sourceBlob.type || "image/png"); const { url: imageUrl } = await aiGenerationClient.uploadAssetBinary(sourceBlob, { name: `watermark-source-${Date.now()}.png`, mimeType: sourceMime, scope: ecommerceOssScopes.productSource, }); const { taskId } = await aiGenerationClient.createImageEditTask({ imageUrl, function: "watermark-remove", }); const resultUrl = await waitForTask(taskId, { abortRef: { current: false }, onProgress: () => {}, }); if (resultUrl) { const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult("watermark"), "ecommerce-watermark"); setWatermarkResultUrl(persistedUrl); setWatermarkStatus("done"); stopWatermarkProgress(); setWatermarkProgress(100); toast.success("去水印处理完成"); void saveUnifiedEcommerceGenerationRecord({ clientRecordId: crypto.randomUUID(), title: `去水印 ${watermarkImage.name || ""}`.trim(), mode: "watermark", taskIds: [taskId], sourceImages: [{ url: imageUrl, label: watermarkImage.name || "watermark-source" }], results: [{ url: persistedUrl, label: "去水印结果", mediaType: "image", taskId }], createdAt: new Date().toISOString(), }); } else { setWatermarkStatus("failed"); stopWatermarkProgress(); setWatermarkProgress(0); toast.error("去水印未返回结果"); } } catch (err) { setWatermarkStatus("failed"); stopWatermarkProgress(); setWatermarkProgress(0); if (err instanceof ServerRequestError && err.status === 402) { toast.error("余额不足,请充值后继续"); } else { toast.error(err instanceof Error ? err.message : "去水印失败"); } } }; const handleWatermarkDownload = () => { if (!watermarkResultUrl || watermarkStatus !== "done") { toast.info("请先完成去水印"); return; } const link = document.createElement("a"); const safeName = (watermarkImage?.name || "watermark-result").replace(/\.[^.]+$/, "").replace(/[\\/:*?"<>|]+/g, "-"); link.href = watermarkResultUrl; link.download = `${safeName || "watermark-result"}-去水印.png`; document.body.appendChild(link); link.click(); link.remove(); }; const openImageTranslatePage = () => { clearSmartCutoutTransition(); setComposerMenu(null); toast.info("功能正在优化中"); }; const closeImageTranslatePage = () => { if (translateProcessTimeoutRef.current !== null) { window.clearTimeout(translateProcessTimeoutRef.current); translateProcessTimeoutRef.current = null; } setActiveQuickTool(null); setTranslateStatus("idle"); setTranslateResultUrl(null); setTranslateImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return null; }); }; const addTranslateImage = (file: File) => { const nextImage = { src: URL.createObjectURL(file), name: file.name, format: getImageFileFormat(file) || "PNG / JPG / WebP", }; setTranslateImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return nextImage; }); setTranslateStatus("idle"); setTranslateResultUrl(null); setActiveQuickTool("translate"); }; const removeTranslateImage = () => { if (translateProcessTimeoutRef.current !== null) { window.clearTimeout(translateProcessTimeoutRef.current); translateProcessTimeoutRef.current = null; } setTranslateStatus("idle"); setTranslateResultUrl(null); setTranslateImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return null; }); }; const handleTranslateUpload = (event: ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; addTranslateImage(file); event.target.value = ""; }; const handleTranslateDrop = (event: DragEvent) => { event.preventDefault(); setIsTranslateDragging(false); const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/")); if (file) addTranslateImage(file); }; const handleTranslateUrlImport = async () => { const nextImage = await loadRemoteImageFromInput(translateUrlInputRef.current, "translate-source"); if (!nextImage) return; setTranslateImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return nextImage; }); setTranslateStatus("idle"); setTranslateResultUrl(null); toast.success("图片已导入"); }; const handleTranslateGenerate = async () => { if (!translateImage || translateStatus === "processing") return; const targetLabel = translateLanguageOptions.find((option) => option.value === translateLanguage)?.label || "中文"; setTranslateStatus("processing"); setTranslateResultUrl(null); try { const sourceBlob = await fetch(translateImage.src).then((res) => res.blob()); const sourceMime = normalizeEcommerceImageMime(sourceBlob.type || "image/png"); const { url: imageUrl } = await aiGenerationClient.uploadAssetBinary(sourceBlob, { name: `translate-source-${Date.now()}.png`, mimeType: sourceMime, scope: ecommerceOssScopes.productSource, }); const prompt = `将图片中的所有文字翻译成${targetLabel},保持原有的排版、字体风格、位置和整体设计不变,只替换文字内容。`; const { taskId } = await aiGenerationClient.createImageEditTask({ imageUrl, function: "description_edit", prompt, }); const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: { current: false }, onProgress: () => {}, }); if (resultUrl) { const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult("translate"), "ecommerce-translate"); setTranslateResultUrl(persistedUrl); setTranslateStatus("done"); toast.success("图片翻译完成"); void saveUnifiedEcommerceGenerationRecord({ clientRecordId: crypto.randomUUID(), title: `图片翻译(${targetLabel}) ${translateImage.name || ""}`.trim(), mode: "translate", prompt, taskIds: [taskId], sourceImages: [{ url: imageUrl, label: translateImage.name || "translate-source" }], results: [{ url: persistedUrl, label: "翻译结果", mediaType: "image", taskId }], config: { targetLanguage: translateLanguage }, createdAt: new Date().toISOString(), }); } else { setTranslateStatus("failed"); toast.error("翻译未返回结果"); } } catch (err) { setTranslateStatus("failed"); if (err instanceof ServerRequestError && err.status === 402) { toast.error("余额不足,请充值后继续"); } else { toast.error(err instanceof Error ? err.message : "图片翻译失败"); } } }; const handleTranslateDownload = () => { if (!translateResultUrl || translateStatus !== "done") { toast.info("请先完成图片翻译"); return; } const link = document.createElement("a"); const safeName = (translateImage?.name || "translate-result").replace(/\.[^.]+$/, "").replace(/[\\/:*?"<>|]+/g, "-"); link.href = translateResultUrl; link.download = `${safeName || "translate-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"); setImageWorkbenchResultUrl(null); 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"); setImageWorkbenchResultUrl(null); setImageWorkbenchMaskStrokes([]); setImageWorkbenchBrushCursor(null); clearImageWorkbenchMaskCanvas(); imageWorkbenchActiveStrokeIdRef.current = null; setActiveQuickTool("image-edit"); }; const removeImageWorkbenchImage = () => { setImageWorkbenchStatus("idle"); setImageWorkbenchResultUrl(null); 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 handleImageWorkbenchUrlImport = async () => { const nextImage = await loadRemoteImageFromInput(imageWorkbenchUrlInputRef.current, "image-workbench-source"); if (!nextImage) return; setImageWorkbenchImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return nextImage; }); setImageWorkbenchStatus("idle"); setImageWorkbenchMaskStrokes([]); setImageWorkbenchBrushCursor(null); clearImageWorkbenchMaskCanvas(); imageWorkbenchActiveStrokeIdRef.current = null; setActiveQuickTool("image-edit"); toast.success("图片已导入"); }; const stopWorkbenchProgress = () => { if (imageWorkbenchProgressRef.current !== null) { window.clearInterval(imageWorkbenchProgressRef.current); imageWorkbenchProgressRef.current = null; } }; const startWorkbenchProgress = () => { stopWorkbenchProgress(); setImageWorkbenchProgress(0); imageWorkbenchProgressRef.current = window.setInterval(() => { setImageWorkbenchProgress((prev) => { if (prev >= 90) { stopWorkbenchProgress(); return 90; } return prev + (90 - prev) * 0.06; }); }, 500); }; const exportWorkbenchMask = (): string | null => { const canvas = imageWorkbenchMaskCanvasRef.current; if (!canvas) return null; const ctx = canvas.getContext("2d"); if (!ctx) return null; const w = canvas.width; const h = canvas.height; const maskCanvas = document.createElement("canvas"); maskCanvas.width = w; maskCanvas.height = h; const maskCtx = maskCanvas.getContext("2d")!; maskCtx.fillStyle = "#000000"; maskCtx.fillRect(0, 0, w, h); const imgData = ctx.getImageData(0, 0, w, h); const maskData = maskCtx.getImageData(0, 0, w, h); for (let i = 3; i < imgData.data.length; i += 4) { if (imgData.data[i] > 0) { const pi = i - 3; maskData.data[pi] = 255; maskData.data[pi + 1] = 255; maskData.data[pi + 2] = 255; maskData.data[pi + 3] = 255; } } maskCtx.putImageData(maskData, 0, 0); return maskCanvas.toDataURL("image/png"); }; const handleImageWorkbenchGenerate = async () => { if (!imageWorkbenchImage) { toast.info("请先上传图片"); return; } setImageWorkbenchStatus("processing"); setImageWorkbenchResultUrl(null); startWorkbenchProgress(); try { const sourceBlob = await fetch(imageWorkbenchImage.src).then((res) => res.blob()); const sourceMime = normalizeEcommerceImageMime(sourceBlob.type || "image/png"); const { url: imageUrl } = await aiGenerationClient.uploadAssetBinary(sourceBlob, { name: `inpaint-source-${Date.now()}.png`, mimeType: sourceMime, scope: ecommerceOssScopes.productSource, }); let maskUrl: string | undefined; if (imageWorkbenchMaskStrokes.length > 0) { const maskDataUrl = exportWorkbenchMask(); if (maskDataUrl) { const { url } = await aiGenerationClient.uploadAsset({ dataUrl: maskDataUrl, name: `inpaint-mask-${Date.now()}.png`, mimeType: "image/png", scope: ecommerceOssScopes.productSource, }); maskUrl = url; } } const { taskId } = await aiGenerationClient.createImageEditTask({ imageUrl, function: "inpaint", prompt: imageWorkbenchPrompt || undefined, maskUrl, ratio: imageWorkbenchRatio, }); const resultUrl = await waitForTask(taskId, { abortRef: { current: false }, onProgress: () => {}, }); if (resultUrl) { const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult("inpaint"), "ecommerce-inpaint"); setImageWorkbenchResultUrl(persistedUrl); setImageWorkbenchStatus("done"); stopWorkbenchProgress(); setImageWorkbenchProgress(100); toast.success("局部重绘已完成"); void saveUnifiedEcommerceGenerationRecord({ clientRecordId: crypto.randomUUID(), title: imageWorkbenchPrompt.trim() || `图片修改 ${imageWorkbenchImage.name || ""}`.trim(), mode: "inpaint", prompt: imageWorkbenchPrompt || undefined, taskIds: [taskId], sourceImages: [{ url: imageUrl, label: imageWorkbenchImage.name || "inpaint-source" }], results: [{ url: persistedUrl, label: "局部重绘结果", mediaType: "image", taskId }], config: { ratio: imageWorkbenchRatio }, createdAt: new Date().toISOString(), }); } else { setImageWorkbenchStatus("failed"); stopWorkbenchProgress(); setImageWorkbenchProgress(0); toast.error("重绘未返回结果"); } } catch (err) { setImageWorkbenchStatus("failed"); stopWorkbenchProgress(); setImageWorkbenchProgress(0); if (err instanceof ServerRequestError && err.status === 402) { toast.error("余额不足,请充值后继续"); } else { toast.error(err instanceof Error ? err.message : "重绘失败"); } } }; 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 openQuickDetailPage = () => { clearSmartCutoutTransition(); setActiveQuickTool("detail"); setComposerMenu(null); setIsCloneSettingsCollapsed(false); setIsQuickPanelCollapsed(false); setPreviewZoom(1); resetQuickSetSelectState(); if (!selectedDetailModules.length) setSelectedDetailModules(defaultCloneDetailModuleIds); }; const openHotClonePage = () => { clearSmartCutoutTransition(); setActiveQuickTool("hot"); setComposerMenu(null); setIsCloneSettingsCollapsed(false); setIsQuickPanelCollapsed(false); setPreviewZoom(1); resetQuickSetSelectState(); }; const closeSmartCutoutTool = () => { runSmartCutoutPageTransition( { title: "正在返回首页", subtitle: "回到电商智能体", }, () => { setSmartCutoutBatchImages((current) => { revokeSmartCutoutItems(current); return []; }); setSmartCutoutImage((current) => { revokeSmartCutoutItem(current); return null; }); setIsSmartCutoutComparing(false); setActiveQuickTool(null); setComposerMenu(null); }, ); }; const goSmartCutoutPrevious = () => { if (!smartCutoutImage) { closeSmartCutoutTool(); return; } runSmartCutoutPageTransition( { title: "正在返回上一页", subtitle: "回到图片上传页", }, () => { setSmartCutoutBatchImages((current) => { revokeSmartCutoutItems(current); return []; }); setSmartCutoutImage((current) => { revokeSmartCutoutItem(current); return null; }); setIsSmartCutoutComparing(false); }, ); }; const addSmartCutoutImage = (files: File[]) => { const imageFiles = files.filter((file) => file.type.startsWith("image/")); if (!imageFiles.length) { toast.error("请上传图片文件"); return; } clearSmartCutoutTransition(); setSmartCutoutBatchImages((current) => { revokeSmartCutoutItems(current); return []; }); setSmartCutoutImage((current) => { revokeSmartCutoutItem(current); return null; }); 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"); setSmartCutoutTransitionMessage({ title: imageFiles.length > 1 ? "正在批量抠图" : "正在智能抠图", subtitle: imageFiles.length > 1 ? `正在处理 ${imageFiles.length} 张图片` : "即将进入图片编辑室", }); setIsSmartCutoutTransitioning(true); smartCutoutTransitionTimeoutRef.current = window.setTimeout(() => { smartCutoutTransitionTimeoutRef.current = null; smartCutoutPendingUrlsRef.current = []; setSmartCutoutBatchImages(nextImages); setSmartCutoutImage(nextImages[0]); setIsSmartCutoutTransitioning(false); }, 620); }; const handleSmartCutoutUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; addSmartCutoutImage(Array.from(files)); event.target.value = ""; }; const handleSmartCutoutDrop = (event: DragEvent) => { event.preventDefault(); setIsSmartCutoutDragging(false); const files = Array.from(event.dataTransfer.files); if (files.length) addSmartCutoutImage(files); }; const smartCutoutBackgroundValue = useMemo(() => { const rgb = hexToRgb(smartCutoutBackgroundColor) ?? { r: 255, g: 255, b: 255 }; if (smartCutoutBackgroundAlpha >= 100) return smartCutoutBackgroundColor; return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${Math.round(smartCutoutBackgroundAlpha) / 100})`; }, [smartCutoutBackgroundAlpha, smartCutoutBackgroundColor]); const smartCutoutColorHsv = useMemo(() => hexToHsv(smartCutoutBackgroundColor), [smartCutoutBackgroundColor]); const selectedSmartCutoutSize = useMemo( () => smartCutoutSizeOptions.find((option) => option.key === smartCutoutSizeKey) ?? smartCutoutSizeOptions[0], [smartCutoutSizeKey], ); const 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": previewSmartCutoutSize.frameWidth, "--smart-cutout-frame-aspect": previewSmartCutoutSize.frameAspect, "--smart-cutout-image-max-width": previewSmartCutoutSize.imageMaxWidth, "--smart-cutout-image-max-height": previewSmartCutoutSize.imageMaxHeight, } as CSSProperties), [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)); }; const updateSmartCutoutColorFromPoint = (element: HTMLElement, clientX: number, clientY: number) => { const rect = element.getBoundingClientRect(); const saturation = clampNumber(((clientX - rect.left) / rect.width) * 100, 0, 100); const value = clampNumber(100 - ((clientY - rect.top) / rect.height) * 100, 0, 100); applySmartCutoutHsv(smartCutoutColorHsv.h, saturation, value); }; const handleSmartCutoutColorPlanePointer = (event: ReactPointerEvent) => { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); updateSmartCutoutColorFromPoint(event.currentTarget, event.clientX, event.clientY); }; const handleSmartCutoutColorPlaneMove = (event: ReactPointerEvent) => { if (event.buttons !== 1) return; updateSmartCutoutColorFromPoint(event.currentTarget, event.clientX, event.clientY); }; const handleSmartCutoutHexChange = (value: string) => { const nextValue = value.startsWith("#") ? value : `#${value}`; if (!/^#[0-9a-fA-F]{0,6}$/.test(nextValue)) return; setSmartCutoutHexDraft(nextValue); const normalized = normalizeHexColor(nextValue); if (normalized) setSmartCutoutBackgroundColor(normalized); }; const scrollSmartCutoutTools = (direction: -1 | 1) => { smartCutoutToolsRef.current?.scrollBy({ left: direction * 340, behavior: "smooth", }); }; 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]); useEffect(() => { if (!isSmartCutoutPaletteOpen) return undefined; const handlePointerDown = (event: PointerEvent) => { if (!smartCutoutPaletteRef.current?.contains(event.target as Node)) { setIsSmartCutoutPaletteOpen(false); } }; document.addEventListener("pointerdown", handlePointerDown); return () => document.removeEventListener("pointerdown", handlePointerDown); }, [isSmartCutoutPaletteOpen]); const removeSetImage = (imageId: string) => { setSetImages((current) => { const next = current.filter((item) => item.id !== imageId); if (next.length === 0) setProductSetStatus("idle"); return next; }); }; const addProductImages = (files: File[]) => { const imageFiles = notifyRejectedImages(files); if (!imageFiles.length) return; const remainingSlots = maxCloneProductImages - productImages.length; if (remainingSlots <= 0) { toast.info(`最多上传 ${maxCloneProductImages} 张素材`); return; } if (imageFiles.length > remainingSlots) { toast.info(`最多上传 ${maxCloneProductImages} 张素材,已自动保留前 ${remainingSlots} 张`); } const localItems = createLocalImageItems(imageFiles, remainingSlots, "product"); setProductImages((current) => [...current, ...localItems].slice(0, maxCloneProductImages)); setStatus("ready"); setResults([]); Promise.all(localItems.map(async (item) => { const uploaded = await uploadImageItem(item); if (uploaded.src) { URL.revokeObjectURL(item.src); } return { id: item.id, uploaded }; })).then((results) => { const updateMap = new Map(results.map((result) => [result.id, result.uploaded])); setProductImages((current) => current.map((item) => { const update = updateMap.get(item.id); if (!update) return item; return { ...item, ...update }; })); }).catch(() => { toast.error("商品图后台上传失败,请检查网络后重试"); }); }; const handleProductUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; void addProductImages(Array.from(files)); event.target.value = ""; }; const addComposerAssets = (files: File[]) => { const imageFiles = files.filter((file) => file.type.startsWith("image/")); const unsupportedCount = files.length - imageFiles.length; if (imageFiles.length) void addProductImages(imageFiles); if (unsupportedCount > 0) toast.info("仅支持上传图片文件"); }; const handleComposerAssetUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; addComposerAssets(Array.from(files)); event.target.value = ""; }; const handleProductDrop = (event: DragEvent) => { event.preventDefault(); setIsProductUploadDragging(false); 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); if (next.length === 0) { setStatus("idle"); setResults([]); } return next; }); }; const hydrateCloneReferenceImageMeta = (items: CloneImageItem[]) => { items.forEach((item) => { readImageDimensions(item.src) .then(({ width, height }) => { setCloneReferenceImages((current) => current.map((currentItem) => (currentItem.id === item.id ? { ...currentItem, width, height } : currentItem)), ); }) .catch(() => undefined); }); }; const addCloneReferenceImages = (files: File[]) => { const imageFiles = notifyRejectedImages(files); if (!imageFiles.length) return; const remainingSlots = maxCloneReferenceImages - cloneReferenceImages.length; if (remainingSlots <= 0) return; const localItems = createLocalImageItems(imageFiles, remainingSlots, "reference"); setCloneReferenceImages((current) => [...current, ...localItems].slice(0, maxCloneReferenceImages)); hydrateCloneReferenceImageMeta(localItems); Promise.all(localItems.map(async (item) => { const uploaded = await uploadImageItem(item); if (uploaded.src) URL.revokeObjectURL(item.src); return { id: item.id, uploaded }; })).then((results) => { const updateMap = new Map(results.map((result) => [result.id, result.uploaded])); setCloneReferenceImages((current) => current.map((item) => { const update = updateMap.get(item.id); return update ? { ...item, ...update } : item; })); }).catch(() => { toast.error("参考图后台上传失败,请检查网络后重试"); }); }; 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; void addCloneReferenceImages(Array.from(files)); event.target.value = ""; }; const [isCloneReferenceDragging, setIsCloneReferenceDragging] = useState(false); const handleCloneReferenceDragOver = (event: DragEvent) => { event.preventDefault(); event.stopPropagation(); if (event.dataTransfer.types.includes("Files")) { setIsCloneReferenceDragging(true); } }; const handleCloneReferenceDragLeave = (event: DragEvent) => { event.preventDefault(); event.stopPropagation(); if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) { setIsCloneReferenceDragging(false); } }; const handleCloneReferenceDrop = (event: DragEvent) => { event.preventDefault(); event.stopPropagation(); setIsCloneReferenceDragging(false); const files = Array.from(event.dataTransfer.files); if (files.length) addCloneReferenceImages(files); }; const updateCloneSetCount = (key: CloneSetCountKey, delta: -1 | 1) => { setCloneSetCounts((current) => { const total = Object.values(current).reduce((sum, value) => sum + value, 0); const nextValue = current[key] + delta; if (delta < 0 && (current[key] <= 0 || total <= minCloneSetTotal)) return current; if (delta > 0 && total >= maxCloneSetTotal) return current; return { ...current, [key]: Math.max(0, Math.min(maxCloneSetTotal, nextValue)) }; }); }; const clearCloneSetCountHold = () => { window.removeEventListener("pointerup", clearCloneSetCountHold); window.removeEventListener("pointercancel", clearCloneSetCountHold); if (countHoldTimeoutRef.current !== null) { window.clearTimeout(countHoldTimeoutRef.current); countHoldTimeoutRef.current = null; } if (countHoldIntervalRef.current !== null) { window.clearInterval(countHoldIntervalRef.current); countHoldIntervalRef.current = null; } }; const startCloneSetCountHold = (key: CloneSetCountKey, delta: -1 | 1, disabled: boolean) => { if (disabled) return; clearCloneSetCountHold(); updateCloneSetCount(key, delta); window.addEventListener("pointerup", clearCloneSetCountHold, { once: true }); window.addEventListener("pointercancel", clearCloneSetCountHold, { once: true }); countHoldTimeoutRef.current = window.setTimeout(() => { countHoldIntervalRef.current = window.setInterval(() => updateCloneSetCount(key, delta), 110); }, 320); }; const toggleCloneDetailModule = (moduleId: string) => { 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) => { setSelectedCloneModelScenes((current) => (current[0] === scene ? [] : [scene])); }; const handleProductSetPlatformChange = (nextPlatform: string) => { const normalizedPlatform = normalizePlatform(nextPlatform); setProductSetPlatform(normalizedPlatform); setProductSetRatio((current) => normalizeRatioForPlatform(normalizedPlatform, current, productSetOutput)); setProductSetLanguage(getPlatformDefaultLanguage(normalizedPlatform, productSetMarket)); }; const handleProductSetOutputChange = (nextOutput: ProductSetOutputKey) => { setProductSetOutput(nextOutput); setProductSetRatio((current) => normalizeRatioForPlatform(productSetPlatform, current, nextOutput)); }; const handleProductSetMarketChange = (nextMarket: string) => { const normalizedMarket = normalizeMarket(nextMarket); setProductSetMarket(normalizedMarket); setProductSetLanguage(getPlatformDefaultLanguage(productSetPlatform, normalizedMarket)); }; const handleClonePlatformChange = (nextPlatform: string) => { const normalizedPlatform = normalizePlatform(nextPlatform); setPlatform(normalizedPlatform); setRatio((current) => normalizeRatioForPlatform(normalizedPlatform, current, cloneOutput), ); setLanguage(getPlatformDefaultLanguage(normalizedPlatform, market)); }; const handleCloneOutputChange = (nextOutput: CloneOutputKey) => { setCloneOutput(nextOutput); setIsCloneTemplateStripVisible(true); if (nextOutput !== "video") setIsVideoWorkspaceVisible(false); setRatio((current) => normalizeRatioForPlatform(platform, current, nextOutput), ); }; const handleCommerceScenarioClick = (nextScenario: CommerceScenarioKey) => { if (nextScenario === activeCommerceScenario) { setActiveCommerceScenario(null); setIsCloneTemplateStripVisible(false); setComposerMenu(null); return; } setActiveCommerceScenario(nextScenario); setIsCloneTemplateStripVisible(true); setComposerMenu(null); if (nextScenario === "popular") return; const mappedOutput = commerceScenarioOutputMap[nextScenario]; if (mappedOutput !== cloneOutput) handleCloneOutputChange(mappedOutput); }; const handleCommerceScenarioMoreToggle = () => { setIsCommerceScenarioMoreOpen((visible) => !visible); setComposerMenu(null); }; const scrollCommerceTemplateStrip = (direction: -1 | 1) => { const strip = templateStripRef.current; if (!strip) return; const firstCard = strip.querySelector(".ecom-command-template-card"); const cardStep = firstCard ? firstCard.offsetWidth + 14 : 0; const viewportStep = Math.max(280, strip.clientWidth * 0.78); strip.scrollBy({ left: direction * Math.max(cardStep, viewportStep), behavior: "smooth", }); }; const handleCloneMarketChange = (nextMarket: string) => { const normalizedMarket = normalizeMarket(nextMarket); setMarket(normalizedMarket); setLanguage(getPlatformDefaultLanguage(platform, normalizedMarket)); }; const handleDetailPlatformChange = (nextPlatform: string) => { const normalizedPlatform = normalizePlatform(nextPlatform); setDetailPlatform(normalizedPlatform); setDetailLanguage(getPlatformDefaultLanguage(normalizedPlatform, detailMarket)); setDetailRatio((current) => getQuickSetRatioValue(current)); }; const handleDetailMarketChange = (nextMarket: string) => { const normalizedMarket = normalizeMarket(nextMarket); setDetailMarket(normalizedMarket); setDetailLanguage(getPlatformDefaultLanguage(detailPlatform, normalizedMarket)); }; const createCloneSettingSnapshot = (name: string, id = `clone-setting-${Date.now()}`): CloneSavedSetting => ({ id, name, savedAt: new Date().toISOString(), output: cloneOutput, platform, market, language, ratio, setCounts: { ...cloneSetCounts }, detailModules: [...selectedCloneDetailModules], modelPanelTab: cloneModelPanelTab, modelScenes: [...selectedCloneModelScenes], modelCustomScene: cloneModelCustomScene, modelGender: cloneModelGender, modelAge: cloneModelAge, modelEthnicity: cloneModelEthnicity, modelBody: cloneModelBody, modelAppearance: cloneModelAppearance, videoQuality: cloneVideoQuality, videoDurationSeconds: cloneVideoDuration, videoSmart: cloneVideoSmart, referenceMode: cloneReferenceMode, replicateLevel: cloneReplicateLevel, requirement, }); const latestCloneSettingSnapshot = useMemo( () => createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest"), [ cloneOutput, platform, market, language, ratio, cloneSetCounts, selectedCloneDetailModules, cloneModelPanelTab, selectedCloneModelScenes, cloneModelCustomScene, cloneModelGender, cloneModelAge, cloneModelEthnicity, cloneModelBody, cloneModelAppearance, cloneVideoQuality, cloneVideoDuration, cloneVideoSmart, cloneReferenceMode, cloneReplicateLevel, requirement, cloneSettingName, ], ); const persistLatestCloneSetting = () => { const snapshot = createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest"); latestCloneSettingRef.current = snapshot; writeCloneLatestSetting(snapshot); return snapshot; }; const applyCloneSavedSetting = (setting: CloneSavedSetting) => { const nextCounts = { selling: Number.isFinite(setting.setCounts?.selling) ? setting.setCounts.selling : defaultCloneSetCounts.selling, white: Number.isFinite(setting.setCounts?.white) ? setting.setCounts.white : defaultCloneSetCounts.white, scene: Number.isFinite(setting.setCounts?.scene) ? setting.setCounts.scene : defaultCloneSetCounts.scene, }; const nextPlatform = normalizePlatform(setting.platform); const nextMarket = normalizeMarket(setting.market); const nextOutput = cloneOutputOptions.some((option) => option.key === setting.output) ? setting.output : defaultCloneOutput; setCloneOutput(nextOutput); setPlatform(nextPlatform); setMarket(nextMarket); setLanguage(normalizeLanguageForPlatform(nextPlatform, nextMarket, setting.language)); setRatio(normalizeRatioForPlatform(nextPlatform, setting.ratio, nextOutput)); setCloneSetCounts(nextCounts); setSelectedCloneDetailModules((setting.detailModules?.length ? setting.detailModules : defaultCloneDetailModuleIds).slice(0, maxDetailModuleSelection)); setCloneModelPanelTab(setting.modelPanelTab === "model" ? "model" : "scene"); setSelectedCloneModelScenes(normalizeCloneModelSceneSelection(setting.modelScenes)); setCloneModelCustomScene(setting.modelCustomScene ?? ""); setCloneModelGender(tryOnModelOptions.gender.includes(setting.modelGender) ? setting.modelGender : tryOnModelOptions.gender[0]); setCloneModelAge(tryOnModelOptions.age.includes(setting.modelAge) ? setting.modelAge : tryOnModelOptions.age[0]); setCloneModelEthnicity( tryOnModelOptions.ethnicity.includes(setting.modelEthnicity) ? setting.modelEthnicity : tryOnModelOptions.ethnicity[0], ); setCloneModelBody(tryOnModelOptions.body.includes(setting.modelBody) ? setting.modelBody : tryOnModelOptions.body[0]); setCloneModelAppearance(setting.modelAppearance ?? ""); setCloneVideoQuality( cloneVideoQualityOptions.some((option) => option.key === setting.videoQuality) ? setting.videoQuality : "high", ); setCloneVideoDuration(clampCloneVideoDuration(setting.videoDurationSeconds)); setCloneVideoSmart(Boolean(setting.videoSmart)); setCloneReferenceMode(setting.referenceMode === "link" ? "link" : "upload"); setCloneReplicateLevel(setting.replicateLevel === "style" ? "style" : "high"); setRequirement((setting.requirement ?? "").slice(0, 500)); setCloneSettingName(setting.name); latestCloneSettingRef.current = setting; writeCloneLatestSetting(setting); }; useEffect(() => { latestCloneSettingRef.current = latestCloneSettingSnapshot; }, [latestCloneSettingSnapshot]); useEffect(() => { window.localStorage.removeItem(cloneLatestSettingStorageKey); }, []); useEffect(() => { setProductSetRatio((current) => normalizeRatioForPlatform(productSetPlatform, current, productSetOutput)); }, [productSetOutput, productSetPlatform]); useEffect(() => { setRatio((current) => { const platformRatios = getPlatformRatioOptions(platform, cloneOutput); if (platformRatios.includes(current)) return current; const normalizedRatio = normalizeRatioToken(current); const matchedRatio = platformRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio)); return matchedRatio ?? getPlatformDefaultRatio(platform, cloneOutput); }); }, [cloneOutput, platform]); useEffect(() => { if (skipInitialCloneAutoSaveRef.current) { skipInitialCloneAutoSaveRef.current = false; return undefined; } if (skipNextCloneAutoSaveRef.current) { skipNextCloneAutoSaveRef.current = false; return undefined; } const timeoutId = window.setTimeout(() => { persistLatestCloneSetting(); }, 300); return () => window.clearTimeout(timeoutId); }, [ activeTool, cloneOutput, platform, market, language, ratio, cloneSetCounts, selectedCloneDetailModules, cloneModelPanelTab, selectedCloneModelScenes, cloneModelCustomScene, cloneModelGender, cloneModelAge, cloneModelEthnicity, cloneModelBody, cloneModelAppearance, cloneVideoQuality, cloneVideoDuration, cloneVideoSmart, cloneReferenceMode, cloneReplicateLevel, requirement, cloneSettingName, ]); useEffect(() => { const persistSnapshot = () => { if (latestCloneSettingRef.current) writeCloneLatestSetting(latestCloneSettingRef.current); }; const handleVisibilityChange = () => { if (document.visibilityState === "hidden") persistSnapshot(); }; window.addEventListener("pagehide", persistSnapshot); document.addEventListener("visibilitychange", handleVisibilityChange); return () => { persistSnapshot(); window.removeEventListener("pagehide", persistSnapshot); document.removeEventListener("visibilitychange", handleVisibilityChange); }; }, []); useEffect(() => clearCloneSetCountHold, []); useEffect(() => { if (!openCloneBasicSelect) return undefined; const handlePointerDown = (event: PointerEvent) => { const target = event.target; if (!(target instanceof Element) || target.closest("[data-clone-basic-select]")) return; setOpenCloneBasicSelect(null); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setOpenCloneBasicSelect(null); }; document.addEventListener("pointerdown", handlePointerDown); document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("pointerdown", handlePointerDown); document.removeEventListener("keydown", handleKeyDown); }; }, [openCloneBasicSelect]); useEffect(() => { if (!composerMenu && !(status === "done" && !isCommandComposerCompact)) return undefined; const handlePointerDown = (event: PointerEvent) => { const target = event.target; if (!(target instanceof Node)) return; const composer = commandComposerWrapRef.current; if (composer?.contains(target)) return; if (composerMenu && composerMenu !== "settings") setComposerMenu(null); if (status === "done" && !isCommandComposerCompact) setIsCommandComposerCompact(true); }; document.addEventListener("pointerdown", handlePointerDown); return () => document.removeEventListener("pointerdown", handlePointerDown); }, [composerMenu, isCommandComposerCompact, status]); useEffect(() => { if (composerMenuCloseTimeoutRef.current !== null) { window.clearTimeout(composerMenuCloseTimeoutRef.current); composerMenuCloseTimeoutRef.current = null; } if (composerMenu) { setVisibleComposerMenu(composerMenu); setIsComposerMenuClosing(false); return; } if (!visibleComposerMenu) return; setIsComposerMenuClosing(true); composerMenuCloseTimeoutRef.current = window.setTimeout(() => { composerMenuCloseTimeoutRef.current = null; setVisibleComposerMenu(null); setIsComposerMenuClosing(false); }, 220); }, [composerMenu, visibleComposerMenu]); useEffect( () => () => { if (composerMenuCloseTimeoutRef.current !== null) { window.clearTimeout(composerMenuCloseTimeoutRef.current); } }, [], ); useEffect(() => { if (!openCloneModelSelect) return undefined; const handlePointerDown = (event: PointerEvent) => { const target = event.target; if (!(target instanceof Element) || target.closest("[data-clone-model-select]")) return; setOpenCloneModelSelect(null); setCloneModelSelectDropUp(false); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setOpenCloneModelSelect(null); setCloneModelSelectDropUp(false); } }; document.addEventListener("pointerdown", handlePointerDown); document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("pointerdown", handlePointerDown); document.removeEventListener("keydown", handleKeyDown); }; }, [openCloneModelSelect]); const addGarmentImages = (files: File[]) => { const uploadedFiles = notifyRejectedImages(files); if (!uploadedFiles.length) return; const remainingSlots = 5 - garmentImages.length; if (remainingSlots <= 0) return; const localItems = createLocalImageItems(uploadedFiles, remainingSlots, "garment"); setGarmentImages((current) => [...current, ...localItems].slice(0, 5)); setTryOnStatus("ready"); Promise.all(localItems.map(async (item) => { const uploaded = await uploadImageItem(item); if (uploaded.src) URL.revokeObjectURL(item.src); return { id: item.id, uploaded }; })).then((results) => { const updateMap = new Map(results.map((result) => [result.id, result.uploaded])); setGarmentImages((current) => current.map((item) => { const update = updateMap.get(item.id); return update ? { ...item, ...update } : item; })); }).catch(() => { toast.error("服饰图后台上传失败,请检查网络后重试"); }); }; const handleGarmentUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) { event.target.value = ""; return; } addGarmentImages(Array.from(files)); event.target.value = ""; }; const handleDetailUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; 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(); reader.onload = () => resolve(String(reader.result || "")); reader.onerror = () => reject(reader.error || new Error("文件读取失败")); reader.readAsDataURL(blob); }); const addDetailImages = (files: File[]) => { const uploadedFiles = notifyRejectedImages(files); if (!uploadedFiles.length) return; const remainingSlots = 3 - detailProductImages.length; if (remainingSlots <= 0) return; const localItems = createLocalImageItems(uploadedFiles, remainingSlots, "detail"); setDetailProductImages((current) => [...current, ...localItems].slice(0, 3)); setDetailStatus("ready"); setDetailResultUrl(null); Promise.all(localItems.map(async (item) => { const uploaded = await uploadImageItem(item); if (uploaded.src) URL.revokeObjectURL(item.src); return { id: item.id, uploaded }; })).then((results) => { const updateMap = new Map(results.map((result) => [result.id, result.uploaded])); setDetailProductImages((current) => current.map((item) => { const update = updateMap.get(item.id); return update ? { ...item, ...update } : item; })); }).catch(() => { toast.error("详情图后台上传失败,请检查网络后重试"); }); }; const uploadCloneImages = async (images: CloneImageItem[]): Promise => { const urls: string[] = []; for (const item of images) { try { if (!item.file && item.src.startsWith("blob:")) { throw new Error("本地预览图缺少原始文件,无法上传"); } const rawBlob = item.file ?? (item.src.startsWith("data:") ? null : await (await fetch(item.src)).blob()); const mimeType = normalizeEcommerceImageMime( rawBlob?.type || item.src.match(/^data:([^;,]+)/)?.[1] || "image/png", ); const blob = rawBlob ? (rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType })) : null; const dataUrl = item.src.startsWith("data:") ? item.src : await blobToDataUrl(blob!); const { url } = await aiGenerationClient.uploadAsset({ dataUrl, name: item.name, mimeType, scope: ecommerceOssScopes.productSource }); urls.push(url); } catch { // skip images that fail to upload } } return urls; }; const withStableSourceImage = (images: CloneImageItem[], sourceUrl?: string): CloneImageItem[] => { if (!sourceUrl || !images.length) return images; return images.map((image, index) => (index === 0 ? { ...image, src: sourceUrl } : image)); }; const setCountLabels: Record = { selling: { label: "卖点图", promptDesc: "selling-point infographic image highlighting core product advantages and detail close-ups" }, white: { label: "白底图", promptDesc: "clean white-background product photo showing the item from its best angle, studio lighting, no props" }, scene: { label: "场景图", promptDesc: "lifestyle scene image showing the product in a realistic usage environment with natural surroundings" }, }; const buildDetailModulePrompt = (moduleIds: string[]): string => { if (!moduleIds.length) { return "Generate a complete A+ detail layout with hero, selling points, usage scene, product detail, and specification modules."; } const selectedModules = cloneDetailModules.filter((module) => moduleIds.includes(module.id)); if (!selectedModules.length) return ""; const moduleList = selectedModules.map((module) => `${module.title}: ${module.desc}`).join("; "); return `Only generate these selected A+ detail modules, no extra modules: ${moduleList}. Keep the output focused even if only one or two modules are selected.`; }; const buildSetSubPrompt = (countKey: CloneSetCountKey, index: number, totalCount: number, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string): string => { const info = setCountLabels[countKey]; const parts: string[] = []; parts.push(`Generate an e-commerce ${info.label.toLowerCase()} for a product listing.`); parts.push(info.promptDesc); if (countKey === "white") { parts.push("The output must be a clean white-background product image. Do not use lifestyle backgrounds, props, text overlays, or people."); } if (countKey === "scene") { parts.push("The output must be a realistic usage scene image. Keep the product clearly visible and preserve its shape, color, and key details."); } if (countKey === "selling") { parts.push("The output must be a selling-point graphic with clear hierarchy, concise copy, and product detail callouts."); } if (totalCount > 1) { parts.push(`This is variant ${index + 1} of ${totalCount} —vary the angle, composition, or emphasis to make each distinct.`); } parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`); parts.push("Must comply with platform image guidelines —proper margins, no watermark, professional quality."); return parts.join(" "); }; const buildEcommerceImagePrompt = ( outputKey: CloneOutputKey, userText: string, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string, tryOnOptions?: EcommerceImagePromptOptions, ): string => { const parts: string[] = []; if (outputKey === "detail") { parts.push("Generate a professional A+ detail page hero image for an e-commerce product listing."); parts.push("Create a high-impact first-screen visual that combines the product photo with key selling points, usage scenes, and detailed specifications in a cohesive layout."); parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`); if (outputKey === "detail" && tryOnOptions?.detailModules) parts.push(buildDetailModulePrompt(tryOnOptions.detailModules)); parts.push("Follow platform A+ page best practices —clear hierarchy, professional typography, high visual impact."); } else if (outputKey === "model") { parts.push("Generate model/try-on lifestyle images for an e-commerce product listing."); parts.push("Show the product being used or worn by a model in attractive lifestyle settings."); parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`); if (tryOnOptions) { if (tryOnOptions.gender) parts.push(`Model gender: ${tryOnOptions.gender}.`); if (tryOnOptions.age) parts.push(`Model age: ${tryOnOptions.age}.`); if (tryOnOptions.ethnicity) parts.push(`Model ethnicity: ${tryOnOptions.ethnicity}.`); if (tryOnOptions.body) parts.push(`Model body type: ${tryOnOptions.body}.`); if (tryOnOptions.appearance) parts.push(`Model appearance details: ${tryOnOptions.appearance}.`); if (tryOnOptions.scenes?.length) parts.push(`Background scenes: ${tryOnOptions.scenes.join(", ")}.`); if (tryOnOptions.customScene) parts.push(`Custom background scene: ${tryOnOptions.customScene}.`); if (tryOnOptions.smartScene) parts.push("Use smart scene matching to select the best background context."); } parts.push("Model should appear natural and appealing. Background should complement the product. Image must meet platform standards."); } else if (outputKey === "hot") { parts.push("Generate a high-conversion e-commerce product image that closely replicates the style and composition of the reference image while adapting it to the target platform."); parts.push(`Replicate the visual style, color palette, and layout feel of the source product image, then adapt it for ${pPlatform} marketplace standards.`); parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`); parts.push("The result must look professional and optimized for high click-through rate and conversion on the specified platform."); } if (userText.trim()) { parts.push(`Additional user requirements: ${userText.trim()}`); } return parts.join(" "); }; const buildCommerceScenarioImagePrompt = ( scenario: CommerceDefaultImageScenarioKey, userText: string, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string, ): string => { const parts: string[] = []; const scenarioPrompts: Record = { poster: "Generate one ecommerce campaign poster image with clear product focus, promotional hierarchy, and polished marketing layout.", mainImage: "Generate one high-conversion ecommerce product main image. Keep the product accurate, clear, and platform-ready.", scene: "Generate one realistic ecommerce lifestyle scene image. Preserve the product appearance and place it in a suitable usage environment.", festival: "Generate one ecommerce product image with a tasteful holiday or seasonal marketing style.", model: "Generate one ecommerce model or try-on image that naturally presents the product on or near a suitable model.", background: "Replace or rebuild the product image background. Preserve the product exactly and use the user's prompt or extra reference image as background guidance.", retouch: "Perform a seamless ecommerce image edit. Preserve the product identity while applying the user's requested local cleanup or refinement.", }; parts.push(scenarioPrompts[scenario]); parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`); parts.push("Output a single image only."); if (userText.trim()) parts.push(`User request: ${userText.trim()}`); return parts.join(" "); }; const generateCommerceScenarioImage = async ( scenario: CommerceDefaultImageScenarioKey, images: CloneImageItem[], userText: string, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string, statusFn: (status: "generating" | "done" | "idle" | "failed") => void, resultFn: (results: CloneResult[], sourceUrl?: string) => void, ): Promise => { statusFn("generating"); try { const uploadedUrls = await uploadCloneImages(images); if (!uploadedUrls.length) { statusFn("idle"); return; } if (imageAbortRef.current.current) { statusFn("idle"); return; } const prompt = buildCommerceScenarioImagePrompt(scenario, userText, pPlatform, pRatio, pLanguage, pMarket); const stamp = Date.now(); const label = commerceScenarioOptions.find((option) => option.key === scenario)?.label || selectedCloneOutput.label; setGenerationProgress(0); const imageTask = scenario === "background" || scenario === "retouch" ? await aiGenerationClient.createImageEditTask({ imageUrl: uploadedUrls[0]!, function: scenario === "background" ? "background-replace" : "retouch", prompt, ratio: normalizeRatioForApi(pRatio), referenceUrls: uploadedUrls.slice(1), }) : await aiGenerationClient.createImageTask({ prompt, ratio: normalizeRatioForApi(pRatio), quality: pRatio.includes("720") ? "720P" : "1080P", gridMode: "single", referenceUrls: uploadedUrls, }); const { taskId } = imageTask; const storeId = imageGen.submitTask({ title: label, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId }); const immediateResultUrl = (imageTask as { resultUrl?: string | null }).resultUrl; let resultUrl: string | null = immediateResultUrl ?? null; if (!resultUrl) { trackEcommerceTask(taskId); try { resultUrl = await waitForTask(taskId, { kind: "image", abortRef: imageAbortRef.current, onProgress: (event) => { const sub = Math.max(0, Math.min(100, Number(event.progress) || 0)); setGenerationProgress(Math.round(Math.min(99, sub))); }, }); } finally { untrackEcommerceTask(taskId); } } else { setGenerationProgress(100); } if (imageAbortRef.current.current) { statusFn("idle"); return; } if (resultUrl) { const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult(scenario), `ecommerce-${scenario}`); resultFn([{ id: `scenario-${scenario}-${stamp}`, src: persistedUrl, label }], uploadedUrls[0]); statusFn("done"); imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl }); } else { statusFn("failed"); imageGen.updateTask(storeId, { status: "failed", error: "No image result returned" }); } } catch (err) { if (imageAbortRef.current.current) { statusFn("idle"); return; } if (err instanceof ServerRequestError && err.status === 402) { toast.error("余额不足,请充值后继续"); } else { toast.error(err instanceof Error ? err.message : "生成失败"); } statusFn("failed"); } }; const generateSetImages = async ( images: CloneImageItem[], counts: Record, userText: string, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string, setStatusFn: (status: "generating" | "done" | "idle" | "failed") => void, setResultFn: (urls: string[], sourceUrl?: string) => void, ): Promise => { setStatusFn("generating"); try { const referenceUrls = await uploadCloneImages(images); if (!referenceUrls.length) { setStatusFn("idle"); return; } if (imageAbortRef.current.current) { setStatusFn("idle"); return; } const generatedUrls: string[] = []; const stamp = Date.now(); const totalCount = Math.max(1, cloneSetCountKeys.reduce((sum, key) => sum + counts[key], 0)); let completedCount = 0; setGenerationProgress(0); for (const countKey of cloneSetCountKeys) { if (imageAbortRef.current.current) break; const count = counts[countKey]; for (let i = 0; i < count; i++) { if (imageAbortRef.current.current) break; const subPrompt = buildSetSubPrompt(countKey, i, count, pPlatform, pRatio, pLanguage, pMarket); const fullPrompt = userText.trim() ? `${subPrompt} Additional user requirements: ${userText.trim()}` : subPrompt; const imageTask = await aiGenerationClient.createImageTask({ prompt: fullPrompt, ratio: normalizeRatioForApi(pRatio), quality: pRatio.includes("720") ? "720P" : "1080P", gridMode: "single", referenceUrls, }); const { taskId } = imageTask; const storeId = imageGen.submitTask({ title: `${setCountLabels[countKey].label} ${i + 1}`, type: "image", status: "running", progress: 5, prompt: fullPrompt, sourceView: "ecommerce", taskId }); let resultUrl: string | null = imageTask.resultUrl ?? null; if (!resultUrl) { trackEcommerceTask(taskId); try { resultUrl = await waitForTask(taskId, { kind: "image", abortRef: imageAbortRef.current, onProgress: (event) => { const sub = Math.max(0, Math.min(100, Number(event.progress) || 0)); const overall = ((completedCount + sub / 100) / totalCount) * 100; setGenerationProgress(Math.round(Math.min(99, overall))); }, }); } finally { untrackEcommerceTask(taskId); } } else { setGenerationProgress(Math.round(Math.min(99, ((completedCount + 1) / totalCount) * 100))); } if (imageAbortRef.current.current) break; if (resultUrl) { const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult("set"), `ecommerce-${countKey}-${i + 1}`); generatedUrls.push(persistedUrl); imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl }); } else { generatedUrls.push(""); imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" }); } completedCount += 1; setGenerationProgress(Math.round(Math.min(99, (completedCount / totalCount) * 100))); } } if (imageAbortRef.current.current) { setStatusFn("idle"); return; } setResultFn(generatedUrls, referenceUrls[0]); setStatusFn(generatedUrls.some(Boolean) ? "done" : "failed"); } catch (err) { if (imageAbortRef.current.current) { setStatusFn("idle"); return; } if (err instanceof ServerRequestError && err.status === 402) { setResultFn([]); toast.error("余额不足,请充值后继续"); } else { const msg = err instanceof Error ? err.message : "生成失败"; toast.error(msg); } setStatusFn("failed"); } }; const generateEcommerceImage = async ( outputKey: CloneOutputKey, images: CloneImageItem[], userText: string, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string, tryOnOptions?: EcommerceImagePromptOptions, statusFn?: (status: "generating" | "done" | "idle" | "failed") => void, resultFn?: (results: CloneResult[], sourceUrl?: string) => void, ): Promise => { statusFn?.("generating"); try { const referenceUrls = await uploadCloneImages(images); if (!referenceUrls.length) { statusFn?.("idle"); return; } if (imageAbortRef.current.current) { statusFn?.("idle"); return; } const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket, tryOnOptions); const stamp = Date.now(); setGenerationProgress(0); const imageTask = await aiGenerationClient.createImageTask({ prompt, ratio: normalizeRatioForApi(pRatio), quality: pRatio.includes("720") ? "720P" : "1080P", gridMode: "single", referenceUrls, }); const { taskId } = imageTask; const outputLabel = cloneOutputOptions.find((option) => option.key === outputKey)?.label || selectedCloneOutput.label; const storeId = imageGen.submitTask({ title: outputLabel, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId }); let resultUrl: string | null = imageTask.resultUrl ?? null; if (!resultUrl) { trackEcommerceTask(taskId); try { resultUrl = await waitForTask(taskId, { kind: "image", abortRef: imageAbortRef.current, onProgress: (event) => { const sub = Math.max(0, Math.min(100, Number(event.progress) || 0)); setGenerationProgress(Math.round(Math.min(99, sub))); }, }); } finally { untrackEcommerceTask(taskId); } } else { setGenerationProgress(100); } if (imageAbortRef.current.current) { statusFn?.("idle"); return; } if (resultUrl) { const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult(outputKey), `ecommerce-${outputKey}`); resultFn?.([{ id: `ecommerce-${stamp}`, src: persistedUrl, label: outputLabel }], referenceUrls[0]); statusFn?.("done"); imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl }); } else { statusFn?.("idle"); imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" }); } } catch (err) { if (imageAbortRef.current.current) { statusFn?.("idle"); return; } if (err instanceof ServerRequestError && err.status === 402) { resultFn?.([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]); toast.error("余额不足,请充值后继续"); } else { const msg = err instanceof Error ? err.message : "生成失败"; toast.error(msg); } statusFn?.("failed"); } }; const handleGenerate = (defaultIntent?: CommerceDefaultIntent) => { if (!canGenerate) return; if ((appUsage?.balanceCents ?? 0) <= 0) { toast.error("积分不足,请充值后继续"); return; } const explicitImageScenario = activeCommerceScenario && activeCommerceScenario !== "popular" && activeCommerceScenario !== "salesVideo" ? activeCommerceScenario : null; const routedScenario = defaultIntent?.kind === "image" ? defaultIntent.scenario : explicitImageScenario; const effectiveOutput = routedScenario ? commerceScenarioOutputMap[routedScenario] : cloneOutput; const shouldConfirmSetCount = !defaultIntent && !routedScenario && cloneOutput === "set" && cloneSetTotal > 5; if (shouldConfirmSetCount) { if (!window.confirm("将生成 " + String(cloneSetTotal) + " 张图片,可能消耗较多积分,是否继续?")) return; } setComposerMenu(null); setIsCommandComposerCompact(true); imageAbortRef.current = { current: false }; lastFailedActionRef.current = null; setGenerationProgress(0); setResults([]); setProductSetResultImages([]); const pendingGeneration = beginEcommerceHistoryTurn(); const pendingRecordId = pendingGeneration.record.id; const pendingTurnId = pendingGeneration.turn.id; setPreviewZoom(1); setPreviewOffset({ x: 0, y: 0 }); previewOffsetRef.current = { x: 0, y: 0 }; if (defaultIntent?.kind === "video") { handleStartVideoPlan(); return; } if (routedScenario) { const routedModeLabel = commerceScenarioOptions.find((option) => option.key === routedScenario)?.label || selectedCloneOutput.label; const routedSettingLabel = commerceScenarioGenerationKind(routedScenario) === "imageEdit" ? "图片编辑 1张" : "单图 1张"; const routedGenerationKind = commerceScenarioGenerationKind(routedScenario); void generateCommerceScenarioImage( routedScenario, productImages, requirement, platform, ratio, language, market, (s) => { setStatus(s as ProductCloneStatus); if (s === "generating") { updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, output: effectiveOutput, modeLabel: routedModeLabel, settingLabel: routedSettingLabel, generationKind: routedGenerationKind, status: "generating", errorMessage: undefined, })); } else if (s === "failed") { updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, output: effectiveOutput, modeLabel: routedModeLabel, settingLabel: routedSettingLabel, generationKind: routedGenerationKind, status: "failed", errorMessage: "生成失败,请检查网络或参数后重试。", })); } }, (newResults, sourceUrl) => { const validResults = newResults.filter((item) => item.src); const turnProductImages = withStableSourceImage(productImages, sourceUrl); setResults(validResults); updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, output: effectiveOutput, modeLabel: routedModeLabel, settingLabel: routedSettingLabel, generationKind: routedGenerationKind, status: validResults.length ? "done" : "failed", errorMessage: validResults.length ? undefined : newResults[0]?.label || "生成未返回结果", productImages: turnProductImages, results: validResults, setResultImages: [], })); if (validResults.length && validResults[0].src) { upsertCanvasNode({ id: pendingTurnId, mode: routedScenario, sourceImage: sourceUrl || productImages[0]?.src, results: validResults, createdAt: Date.now(), }); } }, ); lastFailedActionRef.current = () => handleGenerate(defaultIntent); } else if (cloneOutput === "set") { void generateSetImages( productImages, cloneSetCounts, requirement, platform, ratio, language, market, (s) => { setStatus(s as ProductCloneStatus); if (s === "generating") { updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "generating", errorMessage: undefined })); } else if (s === "failed") { updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "failed", errorMessage: "生成失败,请检查网络或参数后重试。" })); } }, (urls, sourceUrl) => { setProductSetResultImages(urls); const validUrls = urls.filter(Boolean); const stableSourceUrl = sourceUrl || (productImages[0]?.src?.startsWith("blob:") ? undefined : productImages[0]?.src); const turnProductImages = withStableSourceImage(productImages, stableSourceUrl); const resultCards = validUrls.map((src, i) => ({ id: `set-${Date.now()}-${i}`, src, label: `套图 ${i + 1}` })); updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: validUrls.length ? "done" : "failed", errorMessage: validUrls.length ? undefined : "生成未返回结果", productImages: turnProductImages, setResultImages: validUrls, results: resultCards, })); if (validUrls.length) { upsertCanvasNode({ id: pendingTurnId, mode: "set", sourceImage: stableSourceUrl || productImages[0]?.src, results: resultCards, createdAt: Date.now(), }); } }, ); lastFailedActionRef.current = () => handleGenerate(); } else { const clonePromptOptions: EcommerceImagePromptOptions | undefined = cloneOutput === "model" ? { gender: cloneModelGender, age: cloneModelAge, ethnicity: cloneModelEthnicity, body: cloneModelBody, scenes: selectedCloneModelScenes, customScene: cloneModelCustomScene, } : cloneOutput === "detail" ? { detailModules: selectedCloneDetailModules } : undefined; void generateEcommerceImage( cloneOutput, productImages, requirement, platform, ratio, language, market, clonePromptOptions, (s: string) => { setStatus(s as ProductCloneStatus); if (s === "generating") { updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "generating", errorMessage: undefined })); } else if (s === "failed") { updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "failed", errorMessage: "生成失败,请检查网络或参数后重试。" })); } }, (newResults: CloneResult[], sourceUrl?: string) => { const validResults = newResults.filter((item) => item.src); const turnProductImages = withStableSourceImage(productImages, sourceUrl); setResults(validResults); updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: validResults.length ? "done" : "failed", errorMessage: validResults.length ? undefined : newResults[0]?.label || "生成未返回结果", productImages: turnProductImages, results: validResults, setResultImages: [], })); if (validResults.length && validResults[0].src) { upsertCanvasNode({ id: pendingTurnId, mode: cloneOutput, sourceImage: sourceUrl || productImages[0]?.src, results: validResults, createdAt: Date.now(), }); } }, ); lastFailedActionRef.current = () => handleGenerate(); } }; const handleGenerateModel = () => { imageAbortRef.current = { current: false }; lastFailedActionRef.current = null; setTryOnStatus("modeling"); void generateEcommerceImage( "model", garmentImages, requirement, platform, ratio, language, market, { gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene }, (s) => { if (s === "done") setTryOnStatus("ready"); else setTryOnStatus(s as TryOnStatus); }, () => { setTryOnStatus("ready"); }, ); lastFailedActionRef.current = () => handleGenerateModel(); }; const handleTryOnGenerate = () => { if (!canGenerateTryOn) return; imageAbortRef.current = { current: false }; lastFailedActionRef.current = null; void generateEcommerceImage( "model", garmentImages, requirement, platform, ratio, language, market, { gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene }, (s) => setTryOnStatus(s as TryOnStatus), (res) => { const urls: string[] = []; for (const item of res) { if (item.src) urls.push(item.src); } setTryOnResultImages(urls); }, ); lastFailedActionRef.current = () => handleTryOnGenerate(); }; const toggleScene = (scene: string) => { setSelectedScenes((current) => current.includes(scene) ? current.filter((item) => item !== scene) : [...current, scene], ); }; const toggleDetailModule = (moduleId: string) => { 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 = () => { if (!canGenerateSet) return; imageAbortRef.current = { current: false }; lastFailedActionRef.current = null; void generateSetImages( setImages, cloneSetCounts, productSetRequirement, productSetPlatform, productSetRatio, productSetLanguage, productSetMarket, (s) => setProductSetStatus(s as ProductSetStatus), (urls) => setProductSetResultImages(urls), ); lastFailedActionRef.current = () => handleSetGenerate(); }; const openProductSetPreview = (card: { id?: string; src: string; label: string }, options?: { nodeId?: string; removable?: boolean }) => { setSelectedProductSetPreview({ src: card.src, label: card.label, cardId: card.id, nodeId: options?.nodeId, removable: Boolean(options?.removable && options.nodeId && card.id), }); }; const handleDownloadCanvasResult = async (card: { src: string; label: string }) => { try { await downloadResultAsset(card.src, card.label || "generated-image", false); toast.success("已开始下载图片"); } catch (error) { toast.error(error instanceof Error ? error.message : "下载图片失败"); } }; const removeCanvasResult = (nodeId: string, cardId: string) => { setCanvasNodes((current) => current .map((node) => (node.id === nodeId ? { ...node, results: node.results.filter((card) => card.id !== cardId) } : node)) .filter((node) => node.sourceImage || node.results.length > 0), ); setResults((current) => current.filter((card) => card.id !== cardId)); toast.success("已从当前视图移除"); }; const upsertCanvasNode = (node: Omit) => { setCanvasNodes((current) => { const existingIndex = current.findIndex((item) => item.id === node.id); if (existingIndex >= 0) { return current.map((item) => (item.id === node.id ? { ...item, ...node } : item)); } return [ ...current, { ...node, x: current.length * 420, y: current.length % 2 === 0 ? 0 : 160, }, ]; }); }; const removeSelectedProductSetPreview = (preview: ProductSetPreviewSelection) => { if (!preview.nodeId || !preview.cardId) return; removeCanvasResult(preview.nodeId, preview.cardId); setSelectedProductSetPreview(null); }; const handleDetailAiWrite = () => { setDetailRequirement( "1.产品名称:无线降噪蓝牙耳机\n2.核心卖点:主动降噪、24H续航、低延迟连接、舒适佩戴\n3.适用人群:通勤、办公、运动和旅行用户\n4.期望场景:地铁通勤、居家办公、户外运动\n5.具体参数:蓝牙5.3、IPX4防水、快充10分钟使用2小时", ); }; const stopDetailProgress = () => { if (detailProgressRef.current !== null) { window.clearInterval(detailProgressRef.current); detailProgressRef.current = null; } }; const startDetailProgress = () => { stopDetailProgress(); setDetailProgress(0); detailProgressRef.current = window.setInterval(() => { setDetailProgress((prev) => { if (prev >= 90) { stopDetailProgress(); return 90; } return prev + (90 - prev) * 0.06; }); }, 500); }; const handleDetailGenerate = () => { if (!canGenerateDetail) return; imageAbortRef.current = { current: false }; lastFailedActionRef.current = null; startDetailProgress(); void generateEcommerceImage( "detail", detailProductImages, detailRequirement, detailPlatform, detailRatio, detailLanguage, detailMarket, { detailModules: selectedDetailModules }, (s: string) => { setDetailStatus(s as DetailStatus); if (s === "done") { stopDetailProgress(); setDetailProgress(100); } else if (s === "failed" || s === "idle") { stopDetailProgress(); setDetailProgress(0); } }, (res) => setDetailResultUrl(res[0]?.src ?? null), ); }; 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 handleQuickSetAiWrite = () => { setQuickSetRequirement( "1.产品名称:无线降噪蓝牙耳机\n2.核心卖点:主动降噪、24H续航、低延迟连接、舒适佩戴\n3.适用人群:通勤、办公、运动和旅行用户\n4.期望场景:地铁通勤、居家办公、户外运动\n5.具体参数:蓝牙5.3、IPX4防水、快充10分钟使用2小时", ); }; const stopHotProgress = () => { if (hotProgressRef.current !== null) { window.clearInterval(hotProgressRef.current); hotProgressRef.current = null; } }; const stopQuickSetProgress = () => { if (quickSetProgressRef.current !== null) { window.clearInterval(quickSetProgressRef.current); quickSetProgressRef.current = null; } }; const startQuickSetProgress = () => { stopQuickSetProgress(); setQuickSetProgress(0); quickSetProgressRef.current = window.setInterval(() => { setQuickSetProgress((prev) => { if (prev >= 90) { stopQuickSetProgress(); return 90; } return prev + (90 - prev) * 0.06; }); }, 500); }; 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 handleQuickSetGenerate = () => { if (!canGenerateQuickSet) return; imageAbortRef.current = { current: false }; lastFailedActionRef.current = null; startQuickSetProgress(); setQuickSetStatus("generating"); void generateSetImages( productImages, cloneSetCounts, quickSetRequirement, platform, ratio, language, market, (s) => { setQuickSetStatus(s as "idle" | "generating" | "done" | "failed"); if (s === "done") { stopQuickSetProgress(); setQuickSetProgress(100); } else if (s === "failed") { stopQuickSetProgress(); setQuickSetProgress(0); } }, (urls) => { setQuickSetResultUrls(urls); const validUrls = urls.filter(Boolean); if (validUrls.length) { setQuickSetStatus("done"); stopQuickSetProgress(); setQuickSetProgress(100); } else { setQuickSetStatus("failed"); stopQuickSetProgress(); setQuickSetProgress(0); } }, ); lastFailedActionRef.current = () => handleQuickSetGenerate(); }; 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 openQuickSetPage = () => { clearSmartCutoutTransition(); setActiveQuickTool("quick-set"); setComposerMenu(null); setIsCloneSettingsCollapsed(false); setIsQuickPanelCollapsed(false); }; const closeQuickSetPage = () => { stopQuickSetProgress(); setActiveQuickTool(null); setQuickSetStatus("idle"); setQuickSetResultUrls([]); setQuickSetProgress(0); setQuickSetRequirement(""); setComposerMenu(null); }; const openCopywritingPage = () => { clearSmartCutoutTransition(); setActiveQuickTool("copywriting"); setComposerMenu(null); setIsCloneSettingsCollapsed(false); setIsQuickPanelCollapsed(false); }; const closeCopywritingPage = () => { setActiveQuickTool(null); setComposerMenu(null); }; const openOneClickVideoPage = () => { clearSmartCutoutTransition(); setActiveQuickTool("oneClickVideo"); setComposerMenu(null); setIsCloneSettingsCollapsed(false); setIsQuickPanelCollapsed(false); }; const closeOneClickVideoPage = () => { setActiveQuickTool(null); setComposerMenu(null); }; const handleOneClickVideoPlatformChange = (nextPlatform: string) => { const normalizedPlatform = normalizePlatform(nextPlatform); setPlatform(normalizedPlatform); setRatio((current) => normalizeRatioForPlatform(normalizedPlatform, current, "video")); setLanguage(getPlatformDefaultLanguage(normalizedPlatform, market)); }; const resetTask = () => { setSetImages([]); setProductSetRequirement(""); setProductSetPlatform(defaultEcommercePlatform); setProductSetLanguage(getPlatformDefaultLanguage(defaultEcommercePlatform, productSetMarket)); setProductSetOutput(defaultProductSetOutput); setProductSetRatio(getPlatformDefaultRatio(defaultEcommercePlatform, defaultProductSetOutput)); setProductSetStatus("idle"); setProductSetResultImages([]); setIsSetUploadDragging(false); setSelectedProductSetPreview(null); setShowHostingModal(false); setProductImages([]); setIsProductUploadDragging(false); setCloneOutput(defaultCloneOutput); setPlatform(defaultEcommercePlatform); setLanguage(getPlatformDefaultLanguage(defaultEcommercePlatform, market)); setRatio(getPlatformDefaultRatio(defaultEcommercePlatform, defaultCloneOutput)); setCloneSetCounts(defaultCloneSetCounts); setSelectedCloneDetailModules(defaultCloneDetailModuleIds); setCloneModelPanelTab("scene"); setSelectedCloneModelScenes([]); setCloneModelCustomScene(""); setCloneModelGender(tryOnModelOptions.gender[0]); setCloneModelAge(tryOnModelOptions.age[0]); setCloneModelEthnicity(tryOnModelOptions.ethnicity[0]); setCloneModelBody(tryOnModelOptions.body[0]); setCloneModelAppearance(""); setCloneVideoQuality("high"); setCloneVideoDuration(10); setCloneVideoSmart(true); setCloneReferenceMode("upload"); setCloneReferenceImages([]); setCloneReplicateLevel("high"); setRequirement(""); setCloneSettingName("新建创作"); setResults([]); setStatus("idle"); setGarmentImages([]); setAppearance(""); setSelectedScenes([]); setCustomScene(""); setSmartScene(false); setTryOnRatio(tryOnRatioOptions[0]); setTryOnStatus("idle"); setTryOnResultImages([]); setDetailProductImages([]); setDetailRequirement(""); setSelectedDetailModules(defaultDetailModuleIds); setDetailStatus("idle"); }; const activeToolMeta = sideTools.find((tool) => tool.key === activeTool); const isSetTool = activeTool === "set"; const isDetail = activeTool === "detail"; const isTryOn = activeTool === "wear"; const isCloneTool = activeTool === "clone"; const isSmartCutoutTool = isCloneTool && activeQuickTool === "cutout"; const isQuickDetailTool = isCloneTool && activeQuickTool === "detail"; const isWatermarkTool = isCloneTool && activeQuickTool === "watermark"; const isImageEditTool = isCloneTool && activeQuickTool === "image-edit"; const isTranslateTool = isCloneTool && activeQuickTool === "translate"; const isHotCloneTool = isCloneTool && activeQuickTool === "hot"; const isQuickSetTool = isCloneTool && activeQuickTool === "quick-set"; const isCopywritingTool = isCloneTool && activeQuickTool === "copywriting"; const isOneClickVideoTool = isCloneTool && activeQuickTool === "oneClickVideo"; const isWorkspaceToolPage = !isCloneTool || isSmartCutoutTool || isQuickDetailTool || isWatermarkTool || isTranslateTool || isImageEditTool || isHotCloneTool || isQuickSetTool || isCopywritingTool || isOneClickVideoTool || isVideoWorkspaceVisible || Boolean(activeHistoryRecordId); useEffect(() => { onWorkspaceChromeChange?.({ isToolPage: isWorkspaceToolPage }); }, [isWorkspaceToolPage, onWorkspaceChromeChange]); useEffect(() => { return () => { onWorkspaceChromeChange?.({ isToolPage: false }); }; }, [onWorkspaceChromeChange]); const pageLabel = isSetTool ? "商品套图" : isDetail ? "A+/详情页" : isTryOn ? "AI服饰穿戴" : activeToolMeta?.label || "商品工具"; const setPrimaryLabel = setImages.length === 0 ? "请先上传商品原图" : productSetStatus === "generating" ? "生成中..." : "生成" + selectedProductSetOutput.label; const tryOnPrimaryLabel = garmentImages.length === 0 ? "请先上传服装图片" : tryOnStatus === "generating" ? "生成中..." : "生成服饰穿戴图"; const detailPrimaryLabel = detailProductImages.length === 0 ? "请上传产品图" : detailStatus === "generating" ? "生成中..." : "生成A+详情页"; const clonePrimaryLabel = cloneOutput === "video" ? !productImages.length && !requirement.trim() ? "填写需求或上传商品图" : !isAuthenticated ? "登录后生成短视频" : "生成短视频" : productImages.length === 0 ? "请先上传商品原图" : status === "generating" ? "生成中..." : "生成" + selectedCloneOutput.label; const setPreviewCards: CloneResult[] = []; let setIndex = 0; for (const countKey of cloneSetCountKeys) { const count = cloneSetCounts[countKey]; const info = setCountLabels[countKey]; for (let i = 0; i < count; i++) { setPreviewCards.push({ id: String(countKey) + "-" + String(i), src: productSetResultImages[setIndex] || productSetPreviewCards[setIndex % productSetPreviewCards.length]?.src || "", label: info.label + (count > 1 ? " " + String(i + 1) : ""), }); setIndex++; } } const clonePreviewCards: CloneResult[] = []; let cloneIndex = 0; for (const countKey of cloneSetCountKeys) { const count = cloneSetCounts[countKey]; const info = setCountLabels[countKey]; for (let i = 0; i < count; i++) { clonePreviewCards.push({ id: String(countKey) + "-" + String(i), src: productSetResultImages[cloneIndex] || productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src || "", label: info.label + (count > 1 ? " " + String(i + 1) : ""), }); cloneIndex++; } } const getCurrentHistoryResults = () => cloneOutput === "set" ? productSetResultImages .filter(Boolean) .map((src, index) => ({ id: "history-set-" + String(index), src, label: clonePreviewCards[index]?.label || "套图 " + String(index + 1) })) : results.filter((item) => item.src); const buildHistorySignature = (output: CloneOutputKey, prompt: string, historyResults: CloneResult[], sourceImages: CloneImageItem[]) => [ output, prompt.trim(), historyResults.map((item) => item.src).join("|"), sourceImages.map((item) => item.src).join("|"), ].join("::"); const getHistoryRecordResults = (record: EcommerceHistoryRecord) => { const turns = record.turns?.length ? record.turns : [buildHistoryTurnFromRecord(record)]; return turns.flatMap(getTurnResults); }; const persistEcommerceHistoryRecord = (record: EcommerceHistoryRecord, historyResults: CloneResult[]) => { void saveUnifiedEcommerceGenerationRecord({ clientRecordId: record.id, title: record.title, mode: record.output, prompt: record.requirement, sourceImages: record.productImages.map((image, index) => ({ url: image.src, ossKey: image.ossKey, label: image.name || `source-${index + 1}`, })), results: historyResults.map((item) => ({ url: item.src, label: item.label, mediaType: "image", })), config: { platform: record.platform, market: record.market, language: record.language, ratio: record.ratio, setCounts: record.setCounts, detailModules: record.detailModules, modelScenes: record.modelScenes, replicateLevel: record.replicateLevel, }, metadata: { localHistoryStorageKey: ecommerceHistoryStorageKey, referenceImageCount: record.referenceImages.length, turnCount: record.turns?.length ?? 1, latestTurnId: record.turns?.[record.turns.length - 1]?.id, modeLabel: record.modeLabel, settingLabel: record.settingLabel, generationKind: record.generationKind, referenceImages: record.referenceImages.map(({ id, src, name, width, height, format, mimeType, ossKey }) => ({ id, src, name, width, height, format, mimeType, ossKey, })), turns: (record.turns?.length ? record.turns : [buildHistoryTurnFromRecord(record)]).map((turn) => ({ ...turn, productImages: turn.productImages.map(({ id, src, name, width, height, format, mimeType, ossKey }) => ({ id, src, name, width, height, format, mimeType, ossKey, })), referenceImages: turn.referenceImages.map(({ id, src, name, width, height, format, mimeType, ossKey }) => ({ id, src, name, width, height, format, mimeType, ossKey, })), })), }, createdAt: new Date(record.createdAt).toISOString(), }); }; const formatHistoryTime = (timestamp: number) => { const diff = Math.max(0, Date.now() - timestamp); const minute = 60 * 1000; const hour = 60 * minute; const day = 24 * hour; if (diff < minute) return "刚刚"; if (diff < hour) return String(Math.floor(diff / minute)) + " 分钟前"; if (diff < day) return String(Math.floor(diff / hour)) + " 小时前"; return String(Math.floor(diff / day)) + " 天前"; }; const buildEcommerceHistoryTitle = (output: CloneOutputKey, prompt: string, createdAt: number) => { const outputLabel = cloneOutputOptions.find((option) => option.key === output)?.label || "生成记录"; return prompt.trim() || outputLabel + " " + new Date(createdAt).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }); }; const updateLocalEcommerceHistoryRecord = (recordId: string, updater: (record: EcommerceHistoryRecord) => EcommerceHistoryRecord) => { setEcommerceHistoryRecords((current) => { const nextRecords = current.map((record) => (record.id === recordId ? normalizeEcommerceHistoryRecord(updater(record)) : record)); writeEcommerceHistoryRecords(nextRecords); return nextRecords; }); }; const buildCurrentEcommerceHistoryTurn = (turnId: string, createdAt: number, turnStatus: EcommerceHistoryStatus = "generating"): EcommerceHistoryTurn => ({ id: turnId, createdAt, status: turnStatus, output: cloneOutput, modeLabel: undefined, settingLabel: undefined, generationKind: cloneOutput === "video" ? "video" : cloneOutput === "set" ? "imageSet" : "singleImage", platform, market, language, ratio, requirement, productImages, results: [], setResultImages: [], setCounts: cloneSetCounts, detailModules: selectedCloneDetailModules, modelScenes: selectedCloneModelScenes, referenceImages: cloneReferenceImages, replicateLevel: cloneReplicateLevel, }); const syncRecordSummaryWithTurn = (record: EcommerceHistoryRecord, turn: EcommerceHistoryTurn): EcommerceHistoryRecord => ({ ...record, status: turn.status, errorMessage: turn.status === "failed" ? turn.errorMessage : undefined, output: turn.output, modeLabel: turn.modeLabel, settingLabel: turn.settingLabel, generationKind: turn.generationKind, platform: turn.platform, market: turn.market, language: turn.language, ratio: turn.ratio, requirement: turn.requirement, productImages: turn.productImages, results: turn.results, setResultImages: turn.setResultImages, setCounts: turn.setCounts, detailModules: turn.detailModules, modelScenes: turn.modelScenes, referenceImages: turn.referenceImages, replicateLevel: turn.replicateLevel, }); const updateLocalEcommerceHistoryTurn = ( recordId: string, turnId: string, updater: (turn: EcommerceHistoryTurn) => EcommerceHistoryTurn, ) => { updateLocalEcommerceHistoryRecord(recordId, (record) => { const turns = record.turns?.length ? record.turns : [buildHistoryTurnFromRecord(record)]; let updatedTurn: EcommerceHistoryTurn | null = null; const nextTurns = turns.map((turn) => { if (turn.id !== turnId) return turn; updatedTurn = normalizeEcommerceHistoryTurn(updater(turn), record, turns.indexOf(turn)); return updatedTurn; }); return updatedTurn ? syncRecordSummaryWithTurn({ ...record, turns: nextTurns }, updatedTurn) : record; }); }; const beginEcommerceHistoryTurn = () => { const createdAt = Date.now(); const turn = buildCurrentEcommerceHistoryTurn(crypto.randomUUID(), createdAt); const existingRecord = activeHistoryRecordId ? ecommerceHistoryRecords.find((record) => record.id === activeHistoryRecordId) : null; const recordId = existingRecord?.id ?? crypto.randomUUID(); const baseRecord: EcommerceHistoryRecord = existingRecord ?? { id: recordId, title: buildEcommerceHistoryTitle(cloneOutput, requirement, createdAt), createdAt, status: turn.status, output: turn.output, modeLabel: turn.modeLabel, settingLabel: turn.settingLabel, generationKind: turn.generationKind, platform: turn.platform, market: turn.market, language: turn.language, ratio: turn.ratio, requirement: turn.requirement, productImages: turn.productImages, results: turn.results, setResultImages: turn.setResultImages, setCounts: turn.setCounts, detailModules: turn.detailModules, modelScenes: turn.modelScenes, referenceImages: turn.referenceImages, replicateLevel: turn.replicateLevel, turns: [], }; const previousTurns = baseRecord.turns?.length ? baseRecord.turns : existingRecord ? [buildHistoryTurnFromRecord(baseRecord)] : []; const record = normalizeEcommerceHistoryRecord(syncRecordSummaryWithTurn({ ...baseRecord, turns: [...previousTurns, turn], }, turn)); setEcommerceHistoryRecords((current) => { const nextRecords = [record, ...current.filter((item) => item.id !== record.id)].slice(0, 30); writeEcommerceHistoryRecords(nextRecords); return nextRecords; }); setActiveHistoryRecordId(record.id); activeHistoryTurnIdRef.current = turn.id; return { record, turn }; }; const saveCurrentEcommerceHistory = () => { const activeRecord = activeHistoryRecordId ? ecommerceHistoryRecords.find((record) => record.id === activeHistoryRecordId) : null; const historyResults = activeRecord?.turns?.length ? getHistoryRecordResults(activeRecord) : getCurrentHistoryResults(); if (!historyResults.length) return null; const signature = activeRecord?.turns?.length ? buildHistorySignature(activeRecord.output, activeRecord.requirement, historyResults, activeRecord.productImages) : buildHistorySignature(cloneOutput, requirement, historyResults, productImages); if (lastSavedHistorySignatureRef.current === signature && activeHistoryRecordId) return activeHistoryRecordId; const createdAt = Date.now(); const record: EcommerceHistoryRecord = activeRecord?.turns?.length ? normalizeEcommerceHistoryRecord(activeRecord) : normalizeEcommerceHistoryRecord({ id: activeRecord?.id ?? crypto.randomUUID(), title: activeRecord?.title ?? buildEcommerceHistoryTitle(cloneOutput, requirement, createdAt), createdAt: activeRecord?.createdAt ?? createdAt, status: "done", errorMessage: undefined, output: cloneOutput, platform, market, language, ratio, requirement, productImages, results: historyResults, setResultImages: cloneOutput === "set" ? historyResults.map((item) => item.src) : [], setCounts: cloneSetCounts, detailModules: selectedCloneDetailModules, modelScenes: selectedCloneModelScenes, referenceImages: cloneReferenceImages, replicateLevel: cloneReplicateLevel, }); lastSavedHistorySignatureRef.current = signature; persistEcommerceHistoryRecord(record, historyResults); setEcommerceHistoryRecords((current) => { const nextRecords = [record, ...current.filter((item) => item.id !== record.id)].slice(0, 30); writeEcommerceHistoryRecords(nextRecords); return nextRecords; }); setActiveHistoryRecordId(record.id); return record.id; }; const openEcommerceHistoryRecord = (record: EcommerceHistoryRecord) => { setActiveTool("clone"); setCloneOutput(record.output); setPlatform(record.platform); setMarket(record.market); setLanguage(record.language); setRatio(record.ratio); setRequirement(record.requirement); setProductImages(record.productImages); setCloneSetCounts(record.setCounts); setSelectedCloneDetailModules(record.detailModules.slice(0, maxDetailModuleSelection)); setSelectedCloneModelScenes(record.modelScenes); setCloneReferenceImages(record.referenceImages); setCloneReplicateLevel(record.replicateLevel); setProductSetResultImages(record.setResultImages); setResults(record.output === "set" ? [] : record.results); setStatus((record.status ?? "done") as ProductCloneStatus); setPreviewZoom(1); setComposerMenu(null); setActiveHistoryRecordId(record.id); activeHistoryTurnIdRef.current = record.status === "generating" ? record.turns?.find((turn) => turn.status === "generating")?.id ?? null : null; const recordResults = getHistoryRecordResults(record); lastSavedHistorySignatureRef.current = buildHistorySignature(record.output, record.requirement, recordResults, record.productImages); setIsCommandComposerCompact(true); const turns = record.turns?.length ? record.turns : [buildHistoryTurnFromRecord(record)]; const nodes = turns.reduce((items, turn) => { const turnResults = getTurnResults(turn); if (!turnResults.length) return items; const index = items.length; items.push({ id: turn.id, mode: turn.output, sourceImage: turn.productImages[0]?.src?.startsWith("blob:") ? undefined : turn.productImages[0]?.src, results: turnResults, createdAt: turn.createdAt, x: index * 420, y: index % 2 === 0 ? 0 : 160, }); return items; }, []); setCanvasNodes(nodes); setPreviewOffset({ x: 0, y: 0 }); previewOffsetRef.current = { x: 0, y: 0 }; }; const handleNewEcommerceConversation = () => { saveCurrentEcommerceHistory(); resetTask(); setCanvasNodes([]); setPreviewZoom(1); setPreviewOffset({ x: 0, y: 0 }); setComposerMenu(null); setIsCommandComposerCompact(false); setActiveHistoryRecordId(null); activeHistoryTurnIdRef.current = null; lastSavedHistorySignatureRef.current = ""; }; const refreshEcommerceHistory = () => { if (historyRefreshLockRef.current) return; historyRefreshLockRef.current = true; setIsHistoryRefreshing(true); setHistoryRefreshMessage("刷新中..."); setHistoryRefreshStamp(Date.now()); window.setTimeout(() => { const storedRecords = readEcommerceHistoryRecords(); const mergedRecords = [...ecommerceHistoryRecords, ...storedRecords] .reduce((records, record) => { if (!records.some((item) => item.id === record.id)) records.push(record); return records; }, []) .sort((a, b) => b.createdAt - a.createdAt) .slice(0, 30); writeEcommerceHistoryRecords(mergedRecords); setHistoryRefreshTick((tick) => tick + 1); setEcommerceHistoryRecords(mergedRecords); setHistoryRefreshMessage(mergedRecords.length ? "已刷新 " + String(mergedRecords.length) + " 条记录" : "暂无可刷新记录"); setHistoryRefreshStamp(Date.now()); setIsHistoryRefreshing(false); historyRefreshLockRef.current = false; }, 180); window.setTimeout(() => setHistoryRefreshMessage(""), 3000); }; const refreshEcommerceHistoryFromServer = async () => { if (historyRefreshLockRef.current) return; historyRefreshLockRef.current = true; setIsHistoryRefreshing(true); setHistoryRefreshMessage("Refreshing..."); setHistoryRefreshStamp(Date.now()); try { const serverRecords = isAuthenticated ? await listEcommerceGenerationHistory(30) : []; const mergedRecords = mergeEcommerceHistoryRecords(serverRecords, ecommerceHistoryRecords, readEcommerceHistoryRecords()); writeEcommerceHistoryRecords(mergedRecords); setHistoryRefreshTick((tick) => tick + 1); setEcommerceHistoryRecords(mergedRecords); setHistoryRefreshMessage(mergedRecords.length ? "Synced " + String(mergedRecords.length) + " records" : "No history records"); setHistoryRefreshStamp(Date.now()); } catch { const mergedRecords = mergeEcommerceHistoryRecords(ecommerceHistoryRecords, readEcommerceHistoryRecords()); writeEcommerceHistoryRecords(mergedRecords); setHistoryRefreshTick((tick) => tick + 1); setEcommerceHistoryRecords(mergedRecords); setHistoryRefreshMessage(mergedRecords.length ? "Loaded " + String(mergedRecords.length) + " local records" : "Server history unavailable"); setHistoryRefreshStamp(Date.now()); } finally { setIsHistoryRefreshing(false); historyRefreshLockRef.current = false; } window.setTimeout(() => setHistoryRefreshMessage(""), 3000); }; const deleteHistoryRecord = (recordId: string, event: ReactMouseEvent) => { event.stopPropagation(); const record = ecommerceHistoryRecords.find((r) => r.id === recordId); if (!record) return; const next = ecommerceHistoryRecords.filter((r) => r.id !== recordId); setEcommerceHistoryRecords(next); writeEcommerceHistoryRecords(next); if (activeHistoryRecordId === recordId) { // 删除的是当前正在查看的记录:回到首页(空闲态),不要停留在已删除任务的预览上。 resetTask(); setCanvasNodes([]); setPreviewZoom(1); setPreviewOffset({ x: 0, y: 0 }); setComposerMenu(null); setIsCommandComposerCompact(false); setActiveHistoryRecordId(null); activeHistoryTurnIdRef.current = null; lastSavedHistorySignatureRef.current = ""; } deleteEcommerceGenerationRecord(recordId).catch(() => {}); }; useEffect(() => { if (status === "done") saveCurrentEcommerceHistory(); }, [status, results, productSetResultImages]); const detailSourcePreviewImages = detailProductImages.length ? detailProductImages.reduce((urls, item) => { urls.push(item.src); return urls; }, []) : detailProductSamples; const cloneBasicSelects: Array<{ key: CloneBasicSelectKey; label: string; value: string; options: string[]; onChange: (value: string) => void; }> = [ { key: "platform", label: "平台", value: platform, options: platformOptions, onChange: handleClonePlatformChange }, { key: "market", label: "国家", value: market, options: marketOptions, onChange: handleCloneMarketChange }, { key: "language", label: "语种", value: language, options: cloneLanguageOptions, onChange: setLanguage }, { key: "ratio", label: "尺寸/比例", value: ratio, options: cloneRatioOptions, 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 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 quickSetBasicSelects: Array<{ key: CloneBasicSelectKey; label: string; value: string; options: string[]; onChange: (value: string) => void; }> = [ { key: "platform", label: "平台", value: platform, options: platformOptions, onChange: setPlatform }, { key: "market", label: "国家", value: market, options: marketOptions, onChange: setMarket }, { key: "language", label: "语种", value: language, options: cloneLanguageOptions, onChange: setLanguage }, { key: "ratio", label: "尺寸/比例", value: getQuickSetRatioValue(ratio), options: quickSetRatioOptions, onChange: setRatio }, ]; const cloneModelSelects: Array<{ key: CloneModelSelectKey; label: string; value: string; options: string[]; onChange: (value: string) => void; }> = [ { key: "gender", label: "性别", value: cloneModelGender, options: tryOnModelOptions.gender, onChange: setCloneModelGender }, { key: "age", label: "年龄", value: cloneModelAge, options: tryOnModelOptions.age, onChange: setCloneModelAge }, { key: "ethnicity", label: "人种", value: cloneModelEthnicity, options: tryOnModelOptions.ethnicity, onChange: setCloneModelEthnicity, }, { key: "body", label: "体型", value: cloneModelBody, options: tryOnModelOptions.body, onChange: setCloneModelBody }, ]; const setPanel = ( ); const clonePanel = ( ); const detailPanel = ( ); const tryOnPanel = ( ); const placeholderPanel = ( <>
{activeToolMeta?.icon}

{activeToolMeta?.label}

该工具页面正在接入,当前可使用电商 AI 作图、商品套图、A+ 详情与服饰穿搭。

); const setPreview = (

预览

上传商品图,AI 即刻生成 符合多电商平台规范 的高转化率商品套图。

{productSetPreviewReady ? (
) : (
{productSetStatus === "generating" ? : } {productSetStatus === "generating" ? "正在生成" : "等待生成"} {productSetStatus === "generating" ? : null} {productSetStatus === "generating" ? "AI 正在整理主图、场景、细节与卖点图。" : "上传商品原图并填写信息后,AI 将为您生成专业的电商商品图。"}
)} {productSetStatus === "done" ?

已生成{selectedProductSetOutput.label}预览

: null}
信息详情 {productSetRequirement.length}/500