Files
omniai-ds-code-package/src/features/ecommerce/EcommercePage.tsx
T
stringadmin 2c02735037 fix(ecommerce): 修复模板生成误用套图链路/退出登录失效/删除历史不回首页,并完成 EcommercePage 拆分
三个 bug 均为旧代码链路污染:

1. 点击热门/海报等模板后生成,误弹"将生成 N 张图片"套图确认框
   - 根因:shouldConfirmSetCount 只判 effectiveOutput==="set",未排除场景路由的单图链路
   - 改为仅在真正套图路径(!routedScenario && cloneOutput==="set")时确认

2. 头像弹窗内"退出"按钮点击无反应,无法退出登录
   - 根因:Topbar header 内联 pointerEvents:"none",弹窗 section 及 backdrop
     未像其它可点元素那样内联 pointerEvents:"auto",整棵弹窗子树继承 none
   - 给 popover section 与 backdrop 补上内联 pointerEvents:"auto"

3. 删除当前查看的历史记录后停留在原任务页,未回到首页
   - 删除 active 记录时改为镜像"新建对话"的复位(resetTask + 清画布/预览/指令栏)

附带完成 EcommercePage.tsx 拆分重构(8615→约7700行):模块级类型/常量/资源/
工具函数拆到 ecommerceTypes/Constants/JsxConstants/Assets/ImagePipeline/IntentClassifier
六个文件并改为 import;修正拆分文件两处 stale 分歧(maxCloneProductImages=10、
ProductClonePageProps.onWorkspaceChromeChange);并入历史记录按用户分桶修复。

验证:type-check 0 错 / 159 测试通过 / build 通过

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:36:35 +08:00

7707 lines
332 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<HTMLInputElement>(null);
const productInputRef = useRef<HTMLInputElement>(null);
const cloneReferenceInputRef = useRef<HTMLInputElement>(null);
const smartCutoutInputRef = useRef<HTMLInputElement>(null);
const imageWorkbenchInputRef = useRef<HTMLInputElement>(null);
const imageWorkbenchUrlInputRef = useRef<HTMLInputElement>(null);
const imageWorkbenchProgressRef = useRef<number | null>(null);
const watermarkInputRef = useRef<HTMLInputElement>(null);
const watermarkUrlInputRef = useRef<HTMLInputElement>(null);
const watermarkProcessTimeoutRef = useRef<number | null>(null);
const translateInputRef = useRef<HTMLInputElement>(null);
const translateUrlInputRef = useRef<HTMLInputElement>(null);
const translateProcessTimeoutRef = useRef<number | null>(null);
const smartCutoutTransitionTimeoutRef = useRef<number | null>(null);
const smartCutoutPendingUrlsRef = useRef<string[]>([]);
const smartCutoutPaletteRef = useRef<HTMLDivElement>(null);
const smartCutoutToolsRef = useRef<HTMLDivElement>(null);
const composerMenuCloseTimeoutRef = useRef<number | null>(null);
const requirementTextareaRef = useRef<HTMLTextAreaElement>(null);
const commandComposerWrapRef = useRef<HTMLElement | null>(null);
const templateStripRef = useRef<HTMLElement | null>(null);
const garmentInputRef = useRef<HTMLInputElement>(null);
const detailInputRef = useRef<HTMLInputElement>(null);
const detailProgressRef = useRef<number | null>(null);
const hotProgressRef = useRef<number | null>(null);
const hotMaterialInputRef = useRef<HTMLInputElement>(null);
const countHoldTimeoutRef = useRef<number | null>(null);
const countHoldIntervalRef = useRef<number | null>(null);
const isAuthenticated = Boolean((_props as Record<string, unknown>).isAuthenticated);
const requestLogin = () => {
const handler = (_props as Record<string, unknown>).onRequireLogin;
if (typeof handler === "function") handler();
};
const imageGen = useGenerationTasks({ sourceView: "ecommerce" });
const appUsage = useAppStore((s) => s.usage);
const latestCloneSettingRef = useRef<CloneSavedSetting | null>(null);
const skipInitialCloneAutoSaveRef = useRef(true);
const skipNextCloneAutoSaveRef = useRef(false);
const [activeTool, setActiveTool] = useState<ProductKitToolKey>("clone");
useEffect(() => {
setPreviewZoom(1);
setIsCommandComposerCompact(false);
}, [activeTool]);
const [setImages, setSetImages] = useState<CloneImageItem[]>([]);
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<ProductSetOutputKey>(defaultProductSetOutput);
const [productSetStatus, setProductSetStatus] = useState<ProductSetStatus>("idle");
// 套图/图像生成的真实进度(0-100):多张串行生成时按"已完成张数 + 当前张子进度"推进,
// 替代进度条原先写死 50 导致卡在 75% 的假进度。
const [generationProgress, setGenerationProgress] = useState(0);
const [productSetResultImages, setProductSetResultImages] = useState<string[]>([]);
const [isSetUploadDragging, setIsSetUploadDragging] = useState(false);
const [selectedProductSetPreview, setSelectedProductSetPreview] = useState<ProductSetPreviewSelection | null>(null);
const [showHostingModal, setShowHostingModal] = useState(false);
const [productImages, setProductImages] = useState<CloneImageItem[]>([]);
const [activeQuickTool, setActiveQuickTool] = useState<"cutout" | "detail" | "watermark" | "image-edit" | "translate" | "hot" | "quick-set" | "copywriting" | "oneClickVideo" | null>(null);
const [smartCutoutImage, setSmartCutoutImage] = useState<SmartCutoutImageItem | null>(null);
const [smartCutoutBatchImages, setSmartCutoutBatchImages] = useState<SmartCutoutImageItem[]>([]);
const [smartCutoutBackgroundColor, setSmartCutoutBackgroundColor] = useState("#ffffff");
const [smartCutoutBackgroundAlpha, setSmartCutoutBackgroundAlpha] = useState(100);
const [smartCutoutHexDraft, setSmartCutoutHexDraft] = useState("#ffffff");
const [isSmartCutoutPaletteOpen, setIsSmartCutoutPaletteOpen] = useState(false);
const [smartCutoutSizeKey, setSmartCutoutSizeKey] = useState<SmartCutoutSizeKey>("original");
const [isSmartCutoutDragging, setIsSmartCutoutDragging] = useState(false);
const [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<string | null>(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<string | null>(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<Array<{ id: string; size: number; points: Array<{ x: number; y: number }> }>>([]);
const [imageWorkbenchBrushCursor, setImageWorkbenchBrushCursor] = useState<{ x: number; y: number } | null>(null);
const [imageWorkbenchResultUrl, setImageWorkbenchResultUrl] = useState<string | null>(null);
const [imageWorkbenchProgress, setImageWorkbenchProgress] = useState(0);
const [isProductUploadDragging, setIsProductUploadDragging] = useState(false);
const [activeCommerceScenario, setActiveCommerceScenario] = useState<CommerceScenarioKey | null>(null);
const [isCommerceScenarioMoreOpen, setIsCommerceScenarioMoreOpen] = useState(false);
const [remoteCommerceScenarioTemplates, setRemoteCommerceScenarioTemplates] = useState<CommerceScenarioTemplate[] | null>(null);
const [cloneOutput, setCloneOutput] = useState<CloneOutputKey>(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<CloneBasicSelectKey | null>(null);
const [openQuickSetSelect, setOpenQuickSetSelect] = useState<CloneBasicSelectKey | null>(null);
const [visibleQuickSetSelect, setVisibleQuickSetSelect] = useState<CloneBasicSelectKey | null>(null);
const [isQuickSetSelectClosing, setIsQuickSetSelectClosing] = useState(false);
const [composerMenu, setComposerMenu] = useState<ComposerMenuKey | null>(null);
const [visibleComposerMenu, setVisibleComposerMenu] = useState<ComposerMenuKey | null>(null);
const [isComposerMenuClosing, setIsComposerMenuClosing] = useState(false);
const [composerPopoverLeft, setComposerPopoverLeft] = useState(0);
const [composerPopoverTop, setComposerPopoverTop] = useState(0);
const [composerTooltip, setComposerTooltip] = useState<{ text: string; left: number; top: number } | null>(null);
const [composerAssetTab, setComposerAssetTab] = useState<ComposerAssetTabKey>("recent");
const [composerWorkMode, setComposerWorkMode] = useState<ComposerWorkModeKey>("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<CloneModelSelectKey | null>(null);
const [cloneModelSelectDropUp, setCloneModelSelectDropUp] = useState(false);
const [cloneReferenceMode, setCloneReferenceMode] = useState<CloneReferenceMode>("upload");
const [cloneReferenceImages, setCloneReferenceImages] = useState<CloneImageItem[]>([]);
const [cloneReplicateLevel, setCloneReplicateLevel] = useState<CloneReplicateLevelKey>("high");
const [cloneSetCounts, setCloneSetCounts] = useState(defaultCloneSetCounts);
const [selectedCloneDetailModules, setSelectedCloneDetailModules] = useState<string[]>(defaultCloneDetailModuleIds);
const [cloneModelPanelTab, setCloneModelPanelTab] = useState<CloneModelPanelTab>("scene");
const [selectedCloneModelScenes, setSelectedCloneModelScenes] = useState<string[]>([]);
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<CloneVideoQualityKey>("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<string[]>([]);
const [quickSetProgress, setQuickSetProgress] = useState(0);
const [quickSetRequirement, setQuickSetRequirement] = useState("");
const quickSetProgressRef = useRef<number | null>(null);
const [previewZoom, setPreviewZoom] = useState(1);
const quickSetSelectTimerRef = useRef<number | null>(null);
const openQuickSetSelectRef = useRef<CloneBasicSelectKey | null>(null);
const visibleQuickSetSelectRef = useRef<CloneBasicSelectKey | null>(null);
const [previewOffset, setPreviewOffset] = useState({ x: 0, y: 0 });
const previewSurfaceRef = useRef<HTMLElement | null>(null);
const previewZoomRef = useRef(previewZoom);
const previewOffsetRef = useRef(previewOffset);
const imageWorkbenchMaskPaintingRef = useRef(false);
const imageWorkbenchActiveStrokeIdRef = useRef<string | null>(null);
const imageWorkbenchMaskCanvasRef = useRef<HTMLCanvasElement>(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<PreviewTouchGesture>({
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<CSSProperties>(
() => ({
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<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLElement>) => {
if (event.button === 1) event.preventDefault();
},
onAuxClick: (event: ReactMouseEvent<HTMLElement>) => {
if (event.button === 1) event.preventDefault();
},
onPointerDown: startPreviewTouchGesture,
onPointerMove: movePreviewTouchGesture,
onPointerUp: stopPreviewTouchGesture,
onPointerCancel: stopPreviewTouchGesture,
});
const startCanvasNodeDrag = (event: ReactPointerEvent<HTMLElement>, 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<HTMLElement>, 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<HTMLElement>, 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<HTMLElement>) => {
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<HTMLDivElement>) => {
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<HTMLElement>) => {
const panel = event.currentTarget;
if (panel.scrollHeight <= panel.clientHeight) return;
event.stopPropagation();
panel.scrollTop += event.deltaY;
};
const [requirement, setRequirement] = useState("");
const [requirementImageMentionQuery, setRequirementImageMentionQuery] = useState<string | null>(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<ProductCloneStatus>("idle");
const [results, setResults] = useState<CloneResult[]>([]);
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
const [ecommerceHistoryRecords, setEcommerceHistoryRecords] = useState<EcommerceHistoryRecord[]>(() => readEcommerceHistoryRecords());
const [activeHistoryRecordId, setActiveHistoryRecordId] = useState<string | null>(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<string>(getEcommerceHistoryUserBucket());
const imageAbortRef = useRef({ current: false });
const activeHistoryTurnIdRef = useRef<string | null>(null);
const activeEcommerceTaskIdsRef = useRef<Set<string>>(new Set());
const lastFailedActionRef = useRef<(() => void) | null>(null);
const [garmentImages, setGarmentImages] = useState<CloneImageItem[]>([]);
const [modelSource, setModelSource] = useState<TryOnModelSource>("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<string[]>([]);
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<TryOnStatus>("idle");
const [tryOnResultImages, setTryOnResultImages] = useState<string[]>([]);
const [detailProductImages, setDetailProductImages] = useState<CloneImageItem[]>([]);
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<string[]>(defaultDetailModuleIds);
const [detailStatus, setDetailStatus] = useState<DetailStatus>("idle");
const [detailResultUrl, setDetailResultUrl] = useState<string | null>(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<DetailStatus>("idle");
const [hotResultUrl, setHotResultUrl] = useState<string | null>(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<CommerceScenarioKey, "popular">; 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<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
void addSetImages(Array.from(files));
event.target.value = "";
};
const handleSetDrop = (event: DragEvent<HTMLButtonElement>) => {
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<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
addWatermarkImage(file);
event.target.value = "";
};
const handleWatermarkDrop = (event: DragEvent<HTMLDivElement>) => {
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<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
addTranslateImage(file);
event.target.value = "";
};
const handleTranslateDrop = (event: DragEvent<HTMLDivElement>) => {
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<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
addImageWorkbenchImage(file);
event.target.value = "";
};
const handleImageWorkbenchDrop = (event: DragEvent<HTMLDivElement>) => {
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<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
addSmartCutoutImage(Array.from(files));
event.target.value = "";
};
const handleSmartCutoutDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsSmartCutoutDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) addSmartCutoutImage(files);
};
const smartCutoutBackgroundValue = useMemo(() => {
const rgb = hexToRgb(smartCutoutBackgroundColor) ?? { r: 255, g: 255, b: 255 };
if (smartCutoutBackgroundAlpha >= 100) return smartCutoutBackgroundColor;
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${Math.round(smartCutoutBackgroundAlpha) / 100})`;
}, [smartCutoutBackgroundAlpha, smartCutoutBackgroundColor]);
const smartCutoutColorHsv = useMemo(() => hexToHsv(smartCutoutBackgroundColor), [smartCutoutBackgroundColor]);
const selectedSmartCutoutSize = useMemo(
() => smartCutoutSizeOptions.find((option) => option.key === smartCutoutSizeKey) ?? smartCutoutSizeOptions[0],
[smartCutoutSizeKey],
);
const previewSmartCutoutSize = isSmartCutoutComparing ? smartCutoutSizeOptions[0] : selectedSmartCutoutSize;
const previewSmartCutoutSizeKey = isSmartCutoutComparing ? "original" : smartCutoutSizeKey;
const previewSmartCutoutImageSrc = isSmartCutoutComparing ? smartCutoutImage?.originalSrc ?? smartCutoutImage?.src : smartCutoutImage?.src;
const smartCutoutFrameStyle = useMemo<CSSProperties>(
() => ({
"--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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
updateSmartCutoutColorFromPoint(event.currentTarget, event.clientX, event.clientY);
};
const handleSmartCutoutColorPlaneMove = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (event.buttons !== 1) return;
updateSmartCutoutColorFromPoint(event.currentTarget, event.clientX, event.clientY);
};
const handleSmartCutoutHexChange = (value: string) => {
const nextValue = value.startsWith("#") ? value : `#${value}`;
if (!/^#[0-9a-fA-F]{0,6}$/.test(nextValue)) return;
setSmartCutoutHexDraft(nextValue);
const normalized = normalizeHexColor(nextValue);
if (normalized) setSmartCutoutBackgroundColor(normalized);
};
const scrollSmartCutoutTools = (direction: -1 | 1) => {
smartCutoutToolsRef.current?.scrollBy({
left: direction * 340,
behavior: "smooth",
});
};
const handleSmartCutoutDownload = async () => {
if (!smartCutoutImage) {
toast.error("请先上传图片");
return;
}
try {
const image = new Image();
image.decoding = "async";
const imageLoaded = new Promise<void>((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<Blob | null>((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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
addComposerAssets(Array.from(files));
event.target.value = "";
};
const handleProductDrop = (event: DragEvent<HTMLDivElement>) => {
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<HTMLInputElement>) => {
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<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer.types.includes("Files")) {
setIsCloneReferenceDragging(true);
}
};
const handleCloneReferenceDragLeave = (event: DragEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) {
setIsCloneReferenceDragging(false);
}
};
const handleCloneReferenceDrop = (event: DragEvent<HTMLButtonElement>) => {
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<HTMLElement>(".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<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) {
event.target.value = "";
return;
}
addGarmentImages(Array.from(files));
event.target.value = "";
};
const handleDetailUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
void addDetailImages(Array.from(files));
event.target.value = "";
};
const handleDetailDrop = (event: DragEvent<HTMLDivElement>) => {
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<string> =>
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<string[]> => {
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<CloneSetCountKey, { label: string; promptDesc: string }> = {
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<CommerceDefaultImageScenarioKey, string> = {
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<void> => {
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<CloneSetCountKey, number>,
userText: string,
pPlatform: string,
pRatio: string,
pLanguage: string,
pMarket: string,
setStatusFn: (status: "generating" | "done" | "idle" | "failed") => void,
setResultFn: (urls: string[], sourceUrl?: string) => void,
): Promise<void> => {
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<void> => {
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<CanvasNode, "x" | "y">) => {
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<HTMLElement>) => {
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) => (
<div className="ecom-quick-upload-thumbs" aria-label="已上传商品素材">
{items.map((item) => (
<figure
key={item.id}
className="ecom-command-asset-thumb ecom-quick-upload-thumb"
onMouseEnter={(e) => handleHotMaterialMouseEnter(item.src, e)}
onMouseLeave={handleHotMaterialMouseLeave}
>
<img src={item.src} alt={item.name} />
<button
type="button"
className="ecom-hot-material-delete"
aria-label={`删除${item.name || "图片"}`}
onClick={(event) => {
event.stopPropagation();
setHotMaterialHoverZoom(null);
onRemove(item.id);
}}
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M9 6V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v1" />
<path d="M5 6h14" />
<path d="M8 6l1 14h6l1-14" />
<path d="M10.5 10v6" />
<path d="M13.5 10v6" />
</svg>
</button>
</figure>
))}
</div>
);
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<CanvasNode[]>((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<EcommerceHistoryRecord[]>((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<string[]>((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 = (
<EcommerceSetPanel
setInputRef={setInputRef}
setImages={setImages}
isSetUploadDragging={isSetUploadDragging}
productSetOutputOptions={productSetOutputOptions}
productSetOutput={productSetOutput}
platformOptions={platformOptions}
marketOptions={marketOptions}
productSetLanguageOptions={productSetLanguageOptions}
productSetRatioOptions={productSetRatioOptions}
productSetPlatform={productSetPlatform}
productSetMarket={productSetMarket}
productSetLanguage={productSetLanguage}
productSetRatio={productSetRatio}
setIsSetUploadDragging={setIsSetUploadDragging}
handleSetDrop={handleSetDrop}
handleSetUpload={handleSetUpload}
removeSetImage={removeSetImage}
handleProductSetOutputChange={handleProductSetOutputChange}
handleProductSetPlatformChange={handleProductSetPlatformChange}
handleProductSetMarketChange={handleProductSetMarketChange}
setProductSetLanguage={setProductSetLanguage}
setProductSetRatio={setProductSetRatio}
formatRatioDisplayValue={formatRatioDisplayValue}
/>
);
const clonePanel = (
<EcommerceClonePanel
productInputRef={productInputRef}
cloneReferenceInputRef={cloneReferenceInputRef}
productImages={productImages}
isProductUploadDragging={isProductUploadDragging}
cloneOutput={cloneOutput}
cloneOutputOptions={cloneOutputOptions}
cloneBasicSelects={cloneBasicSelects}
openCloneBasicSelect={openCloneBasicSelect}
cloneReferenceMode={cloneReferenceMode}
cloneReferenceImages={cloneReferenceImages}
maxCloneReferenceImages={maxCloneReferenceImages}
cloneReplicateLevel={cloneReplicateLevel}
cloneReplicateLevelOptions={cloneReplicateLevelOptions}
cloneSetCounts={cloneSetCounts}
cloneSetCountOptions={cloneSetCountOptions}
cloneSetTotal={cloneSetTotal}
minCloneSetTotal={minCloneSetTotal}
maxCloneSetTotal={maxCloneSetTotal}
selectedCloneDetailModules={selectedCloneDetailModules}
cloneDetailModules={cloneDetailModules}
cloneModelPanelTab={cloneModelPanelTab}
tryOnScenes={tryOnScenes}
selectedCloneModelScenes={selectedCloneModelScenes}
cloneModelCustomScene={cloneModelCustomScene}
cloneModelSelects={cloneModelSelects}
openCloneModelSelect={openCloneModelSelect}
cloneModelSelectDropUp={cloneModelSelectDropUp}
cloneVideoQuality={cloneVideoQuality}
cloneVideoQualityOptions={cloneVideoQualityOptions}
cloneVideoDuration={cloneVideoDuration}
cloneVideoDurationMin={cloneVideoDurationMin}
cloneVideoDurationMax={cloneVideoDurationMax}
cloneVideoDurationStyle={cloneVideoDurationStyle}
cloneVideoSmart={cloneVideoSmart}
canGenerate={canGenerate}
status={status}
lastFailedActionRef={lastFailedActionRef}
setIsProductUploadDragging={setIsProductUploadDragging}
handleProductDrop={handleProductDrop}
removeProductImage={removeProductImage}
handleProductUpload={handleProductUpload}
handleCloneOutputChange={handleCloneOutputChange}
setOpenCloneBasicSelect={setOpenCloneBasicSelect}
setCloneReferenceMode={setCloneReferenceMode}
handleCloneReferenceUpload={handleCloneReferenceUpload}
isCloneReferenceDragging={isCloneReferenceDragging}
handleCloneReferenceDragOver={handleCloneReferenceDragOver}
handleCloneReferenceDragLeave={handleCloneReferenceDragLeave}
handleCloneReferenceDrop={handleCloneReferenceDrop}
setCloneReplicateLevel={setCloneReplicateLevel}
startCloneSetCountHold={startCloneSetCountHold}
clearCloneSetCountHold={clearCloneSetCountHold}
toggleCloneDetailModule={toggleCloneDetailModule}
setCloneModelPanelTab={setCloneModelPanelTab}
toggleCloneModelScene={toggleCloneModelScene}
setCloneModelCustomScene={setCloneModelCustomScene}
setOpenCloneModelSelect={setOpenCloneModelSelect}
setCloneModelSelectDropUp={setCloneModelSelectDropUp}
setCloneVideoQuality={setCloneVideoQuality}
setCloneVideoDuration={setCloneVideoDuration}
clampCloneVideoDuration={clampCloneVideoDuration}
setCloneVideoSmart={setCloneVideoSmart}
handleGenerate={handleGenerate}
onCancelGenerate={handleCancelGenerate}
formatRatioDisplayValue={formatRatioDisplayValue}
onStartVideoPlan={handleStartVideoPlan}
/>
);
const detailPanel = (
<EcommerceDetailPanel
detailInputRef={detailInputRef}
detailProductImages={detailProductImages}
detailPlatform={detailPlatform}
detailMarket={detailMarket}
detailLanguage={detailLanguage}
detailType={detailType}
detailRequirement={detailRequirement}
selectedDetailModules={selectedDetailModules}
detailStatus={detailStatus}
canGenerateDetail={canGenerateDetail}
detailPrimaryLabel={detailPrimaryLabel}
platformOptions={platformOptions}
marketOptions={marketOptions}
detailLanguageOptions={detailLanguageOptions}
detailTypeOptions={detailTypeOptions}
detailModules={detailModules}
handleDetailUpload={handleDetailUpload}
handleDetailPlatformChange={handleDetailPlatformChange}
handleDetailMarketChange={handleDetailMarketChange}
setDetailLanguage={setDetailLanguage}
setDetailType={setDetailType}
setDetailRequirement={setDetailRequirement}
handleDetailAiWrite={handleDetailAiWrite}
toggleDetailModule={toggleDetailModule}
handleDetailGenerate={handleDetailGenerate}
onCancelGenerate={handleCancelGenerate}
/>
);
const tryOnPanel = (
<EcommerceTryOnPanel
garmentInputRef={garmentInputRef}
garmentImages={garmentImages}
modelSource={modelSource}
modelGender={modelGender}
modelAge={modelAge}
modelEthnicity={modelEthnicity}
modelBody={modelBody}
appearance={appearance}
selectedScenes={selectedScenes}
customScene={customScene}
smartScene={smartScene}
tryOnRatio={tryOnRatio}
tryOnStatus={tryOnStatus}
canGenerateTryOn={canGenerateTryOn}
tryOnPrimaryLabel={tryOnPrimaryLabel}
tryOnModelOptions={tryOnModelOptions}
tryOnAssets={tryOnAssets}
tryOnScenes={tryOnScenes}
tryOnRatioOptions={tryOnRatioOptions}
handleGarmentUpload={handleGarmentUpload}
setModelSource={setModelSource}
setModelGender={setModelGender}
setModelAge={setModelAge}
setModelEthnicity={setModelEthnicity}
setModelBody={setModelBody}
setAppearance={setAppearance}
handleGenerateModel={handleGenerateModel}
toggleScene={toggleScene}
setCustomScene={setCustomScene}
setSmartScene={setSmartScene}
setTryOnRatio={setTryOnRatio}
handleTryOnGenerate={handleTryOnGenerate}
onCancelGenerate={handleCancelGenerate}
/>
);
const placeholderPanel = (
<>
<div className="product-clone-panel__scroll">
<section className="product-clone-empty-panel">
<span>{activeToolMeta?.icon}</span>
<h2>{activeToolMeta?.label}</h2>
<p>使 AI A+ 穿</p>
</section>
</div>
<footer className="product-clone-panel__footer">
<button type="button" className="product-clone-primary" disabled>
</button>
</footer>
</>
);
const setPreview = (
<main className="product-clone-preview product-clone-preview--set" aria-label="AI商品套图预览" onWheel={handlePreviewWheel}>
<div className="product-clone-preview__headline">
<h1></h1>
<p>
AI <span></span>
</p>
</div>
{productSetPreviewReady ? (
<section className="product-set-demo-board">
<button
type="button"
className="product-set-main-card"
onClick={() => openProductSetPreview(setPreviewCards[0] ?? productSetPreviewCards[0])}
>
<img src={setImages[0]?.src ?? (setPreviewCards[0]?.src ?? productSetPreviewCards[0].src)} alt="商品原图" />
</button>
<div className="product-set-flow-arrow" aria-hidden="true" />
<div className="product-set-card-grid result-reveal">
{setPreviewCards.map((card) => (
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
<img src={card.src} alt={card.label} />
</button>
))}
</div>
</section>
) : (
<section className="product-set-empty-preview" aria-live="polite">
{productSetStatus === "generating" ? <LoadingOutlined /> : <FileImageOutlined />}
<strong>{productSetStatus === "generating" ? "正在生成" : "等待生成"}</strong>
{productSetStatus === "generating" ? <EcommerceProgressBar status="generating" progress={generationProgress} onCancel={handleCancelGenerate} label="商品套图" /> : null}
<span>{productSetStatus === "generating" ? "AI 正在整理主图、场景、细节与卖点图。" : "上传商品原图并填写信息后,AI 将为您生成专业的电商商品图。"}</span>
</section>
)}
{productSetStatus === "done" ? <p className="product-set-generated-note">{selectedProductSetOutput.label}</p> : null}
<section className="product-set-floating-detail" aria-label="信息详情">
<div className="product-set-floating-detail__head">
<strong></strong>
<span>{productSetRequirement.length}/500</span>
</div>
<textarea
value={productSetRequirement}
onChange={(event) => setProductSetRequirement(event.target.value)}
maxLength={500}
placeholder="建议包含以下信息:产品名称、核心卖点、期望场景、具体参数"
/>
<button type="button" className="product-set-floating-submit" disabled={!canGenerateSet} onClick={handleSetGenerate}>
{productSetStatus === "generating" ? <LoadingOutlined /> : null}
{setPrimaryLabel}
</button>
{productSetStatus === "generating" ? (
<button type="button" className="product-set-floating-submit product-set-floating-submit--cancel" onClick={handleCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</section>
<button type="button" className="product-clone-help" aria-label="帮助">
<QuestionCircleOutlined />
</button>
</main>
);
const composerSettingLabel =
cloneOutput === "set"
? "套图 " + String(cloneSetTotal) + "张"
: cloneOutput === "detail"
? "详情 " + String(selectedCloneDetailModules.length) + "项"
: cloneOutput === "model"
? cloneModelPanelTab === "scene" ? "场景设置" : "模特设置"
: cloneOutput === "video"
? String(cloneVideoDuration) + "秒 " + (cloneVideoQuality === "standard" ? "720P" : "1080P")
: "换装素材";
const composerAssetTabs: Array<{ key: ComposerAssetTabKey; label: string }> = [
{ key: "recent", label: "最近保存" },
{ key: "recipe", label: "套图配方" },
{ key: "model", label: "模特库" },
];
const composerWorkModeOptions: Array<{ key: ComposerWorkModeKey; label: string; desc: string }> = [
{ key: "quick", label: "快捷", desc: "快速整理提示词,适合常规商品图生成。" },
{ key: "think", label: "思考", desc: "更强调卖点拆解、场景规划和图文一致性。" },
];
const applyAiWriteSuggestion = () => {
const keyword = aiWriteDraft.trim();
if (!keyword) {
toast.info("请输入产品关键词或卖点");
return;
}
const modeHint = composerWorkMode === "think" ? "先拆解目标人群、核心卖点和使用场景," : "";
const nextValue = `${keyword}${modeHint}请生成适合${platform}的高转化电商素材,画面干净高级,突出产品主体、核心卖点、使用场景和购买理由。`.slice(0, 500);
setRequirement(nextValue);
setComposerMenu(null);
};
const renderComposerAssetPanel = () => {
const renderEmpty = (label: string) => (
<div className="ecom-command-library-empty">
<FolderOpenOutlined />
<strong></strong>
<span>{label}</span>
</div>
);
let content: ReactNode;
if (composerAssetTab === "recent") {
content = ecommerceHistoryRecords.length ? (
<div className="ecom-command-library-list">
{ecommerceHistoryRecords.slice(0, 4).map((record) => {
const outputLabel = cloneOutputOptions.find((option) => option.key === record.output)?.label || "生成记录";
return (
<button key={record.id} type="button" onClick={() => { openEcommerceHistoryRecord(record); setComposerMenu(null); }}>
<strong>{record.title}</strong>
<span>{outputLabel} · {formatHistoryTime(record.createdAt)}</span>
</button>
);
})}
</div>
) : renderEmpty("生成后保存的素材会沉淀在这里");
} else if (composerAssetTab === "recipe") {
content = (
<div className="ecom-command-library-list">
{effectiveCommerceScenarioTemplates.slice(0, 4).map((template) => (
<button key={template.id} type="button" onClick={() => { handleCloneTemplateCardClick(template); setComposerMenu(null); }}>
<strong>{template.title}</strong>
<span>{template.badge} · {template.desc}</span>
</button>
))}
</div>
);
} else {
content = (
<div className="ecom-command-library-list ecom-command-library-list--model">
{tryOnScenes.slice(0, 4).map((scene) => (
<button key={scene} type="button" className={selectedCloneModelScenes.includes(scene) ? "is-active" : ""} onClick={() => toggleCloneModelScene(scene)}>
<strong>{scene}</strong>
<span></span>
</button>
))}
</div>
);
}
return (
<>
<header className="ecom-command-library-head">
<strong></strong>
<button type="button" className="ecom-command-library-help" onClick={(event) => event.preventDefault()}>
<QuestionCircleOutlined /> 使
</button>
</header>
<div className="ecom-command-library-tabs" role="tablist" aria-label="资产库分类">
{composerAssetTabs.map((tab) => (
<button key={tab.key} type="button" className={composerAssetTab === tab.key ? "is-active" : ""} onClick={() => setComposerAssetTab(tab.key)}>
{tab.label}
</button>
))}
</div>
{content}
</>
);
};
const renderComposerMenu = () => {
const composerLanguageOptions = Array.from(new Set(marketLanguageOptions.flatMap((option) => option.languages))).map((item) => ({
language: item,
countries: marketLanguageOptions.filter((option) => option.languages.includes(item)).map((option) => option.country),
}));
const composerPopoverStyle = {
"--composer-popover-left": `${composerPopoverLeft}px`,
"--composer-popover-top": `${composerPopoverTop}px`,
} as CSSProperties;
const menuToRender = composerMenu ?? visibleComposerMenu;
if (!menuToRender) return null;
const popoverClosingClass = !composerMenu && isComposerMenuClosing ? " is-closing" : "";
const composerPopoverKey = `${menuToRender}-${cloneOutput}-${popoverClosingClass ? "closing" : "open"}`;
if (menuToRender === "assetLibrary") {
return (
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--library${popoverClosingClass}`} style={composerPopoverStyle}>
{renderComposerAssetPanel()}
</div>
);
}
if (menuToRender === "workMode") {
return (
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--work-mode${popoverClosingClass}`} style={composerPopoverStyle}>
<header><strong></strong><span></span></header>
{composerWorkModeOptions.map((option) => (
<button key={option.key} type="button" className={composerWorkMode === option.key ? "is-active" : ""} onClick={() => { setComposerWorkMode(option.key); setComposerMenu(null); }}>
<strong>{option.label}</strong>
<span>{option.desc}</span>
</button>
))}
</div>
);
}
if (menuToRender === "aiWrite") {
return (
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--ai-write${popoverClosingClass}`} style={composerPopoverStyle}>
<header><strong>AI </strong><span></span></header>
<textarea value={aiWriteDraft} onChange={(event) => setAiWriteDraft(event.target.value.slice(0, 120))} placeholder="输入产品名称、卖点或期望风格" />
<button type="button" className="ecom-command-ai-submit" onClick={applyAiWriteSuggestion}>
</button>
</div>
);
}
if (menuToRender === "mode") {
return (
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--grid ecom-command-popover--mode${popoverClosingClass}`} style={composerPopoverStyle}>
{cloneOutputOptions.map((option) => (
<button key={option.key} type="button" className={cloneOutput === option.key ? "is-active" : ""} onClick={() => { handleCloneOutputChange(option.key); setComposerMenu(null); }}>
<span className={`ecom-command-mode-icon ecom-command-mode-icon--${option.key}`} aria-hidden="true">{option.icon}</span>
<strong>{option.label}</strong>
<em>{option.desc}</em>
</button>
))}
</div>
);
}
if (menuToRender === "platform") {
return (
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--list ecom-command-popover--platforms${popoverClosingClass}`} style={composerPopoverStyle}>
{platformOptions.map((option) => (
<button key={option} type="button" className={platform === option ? "is-active" : ""} onClick={() => { handleClonePlatformChange(option); setComposerMenu(null); }}>
{option}
</button>
))}
</div>
);
}
if (menuToRender === "language") {
return (
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--languages${popoverClosingClass}`} style={composerPopoverStyle}>
{composerLanguageOptions.map((option) => (
<button key={option.language} type="button" className={language === option.language ? "is-active" : ""} onClick={() => { setLanguage(option.language); setComposerMenu(null); }}>
{option.language}
</button>
))}
</div>
);
}
if (menuToRender === "ratio") {
return (
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--list ecom-command-popover--ratio-picker${popoverClosingClass}`} style={composerPopoverStyle}>
{composerRatioOptions.map((option) => {
const ratioParts = getRatioDisplayParts(option);
return (
<button key={option} type="button" className={ratio === option ? "is-active" : ""} onClick={() => { setRatio(option); setComposerMenu(null); }}>
{ratioParts.aspect}
</button>
);
})}
</div>
);
}
if (menuToRender === "settings" && activeCommerceScenario === "salesVideo") {
return (
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--list ecom-command-popover--duration${popoverClosingClass}`} style={composerPopoverStyle}>
{composerDurationOptions.map((option) => (
<button key={option} type="button" className={cloneVideoDuration === option ? "is-active" : ""} onClick={() => { setCloneVideoDuration(option); setComposerMenu(null); }}>
{option}
</button>
))}
</div>
);
}
return (
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--settings ecom-command-popover--settings-${cloneOutput}${popoverClosingClass}`} style={composerPopoverStyle}>
{cloneOutput === "set" ? (
<>
<header><strong></strong><span> 1-16 </span></header>
{cloneSetCountOptions.map((item) => (
<div key={item.key} className="ecom-command-count-row">
<span><strong>{item.title}</strong><em>{item.desc}</em></span>
<div>
<button type="button" onClick={() => updateCloneSetCount(item.key, -1)} disabled={cloneSetCounts[item.key] <= 0 || cloneSetTotal <= minCloneSetTotal}>-</button>
<b>{cloneSetCounts[item.key]}</b>
<button type="button" onClick={() => updateCloneSetCount(item.key, 1)} disabled={cloneSetTotal >= maxCloneSetTotal}>+</button>
</div>
</div>
))}
</>
) : cloneOutput === "detail" ? (
<>
<header><strong></strong><span></span></header>
<div className="ecom-command-module-grid ecom-command-module-grid--detail">
{cloneDetailModules.map((module) => (
<button key={module.id} type="button" className={selectedCloneDetailModules.includes(module.id) ? "is-active" : ""} onClick={() => toggleCloneDetailModule(module.id)}>
<strong>{module.title}</strong><span>{module.desc}</span>
</button>
))}
</div>
</>
) : cloneOutput === "model" ? (
<>
<header><strong></strong><span> / </span></header>
<div className="ecom-command-module-grid ecom-command-module-grid--model">
{tryOnScenes.map((scene) => (
<button key={scene} type="button" className={selectedCloneModelScenes.includes(scene) ? "is-active" : ""} onClick={() => toggleCloneModelScene(scene)}>{scene}</button>
))}
</div>
<div className="ecom-command-model-profile">
<strong className="ecom-command-model-profile__title"></strong>
{cloneModelSelects.map((item) => (
<section key={item.key}>
<strong>{item.label}</strong>
<div>
{item.options.map((option) => (
<button key={option} type="button" className={item.value === option ? "is-active" : ""} onClick={() => item.onChange(option)}>{option}</button>
))}
</div>
</section>
))}
</div>
</>
) : cloneOutput === "video" ? (
<>
<header><strong></strong><span>{cloneVideoDuration}</span></header>
<div className="ecom-command-module-grid ecom-command-module-grid--video">
{cloneVideoQualityOptions.map((option) => (
<button key={option.key} type="button" className={cloneVideoQuality === option.key ? "is-active" : ""} onClick={() => setCloneVideoQuality(option.key)}>
<strong>{option.label}</strong><span>{option.desc}</span>
</button>
))}
</div>
<label className="ecom-command-range">
<span> {cloneVideoDuration}</span>
<input type="range" min={cloneVideoDurationMin} max={cloneVideoDurationMax} step={5} value={cloneVideoDuration} onChange={(event) => setCloneVideoDuration(clampCloneVideoDuration(Number(event.target.value)))} />
</label>
</>
) : (
<>
<header><strong></strong><span></span></header>
<p className="ecom-command-popover-note"></p>
</>
)}
</div>
);
};
const toggleComposerMenu = (menuKey: ComposerMenuKey, event: ReactMouseEvent<HTMLButtonElement>) => {
const composerRect = event.currentTarget.closest(".clone-ai-input-wrapper.ecom-command-composer")?.getBoundingClientRect();
const buttonRect = event.currentTarget.getBoundingClientRect();
const composerLeft = composerRect?.left ?? buttonRect.left;
const composerTop = composerRect?.top ?? buttonRect.top;
setComposerPopoverLeft(Math.max(0, buttonRect.left - composerLeft));
setComposerPopoverTop(Math.max(0, buttonRect.bottom - composerTop + 8));
setComposerMenu((menu) => (menu === menuKey ? null : menuKey));
};
const canPlanVideo = productImages.length > 0 || requirement.trim().length > 0;
const isDefaultCommandRouting = activeCommerceScenario === null || activeCommerceScenario === "popular";
const commandGenerateDisabled = isDefaultIntentRouting || (isDefaultCommandRouting ? !canPlanVideo : cloneOutput === "video" ? false : !canGenerate);
function handleStartVideoPlan() {
if (!canPlanVideo) {
toast.info("请先上传商品图或填写短视频需求");
return;
}
if (!isAuthenticated) {
requestLogin();
return;
}
setIsVideoWorkspaceVisible(true);
setVideoPlanTrigger((value) => value + 1);
}
const showDefaultRoutingGeneratingState = () => {
setComposerMenu(null);
setIsCommandComposerCompact(true);
imageAbortRef.current = { current: false };
lastFailedActionRef.current = null;
setGenerationProgress(2);
setResults([]);
setProductSetResultImages([]);
setPreviewZoom(1);
setPreviewOffset({ x: 0, y: 0 });
previewOffsetRef.current = { x: 0, y: 0 };
setStatus("generating");
};
const resetDefaultRoutingGeneratingState = () => {
setStatus("idle");
setGenerationProgress(0);
setIsCommandComposerCompact(false);
};
const handleCommandGenerate = async () => {
if (cloneOutput === "video") {
handleStartVideoPlan();
return;
}
if (isDefaultCommandRouting) {
if (!canPlanVideo) return;
if ((appUsage?.balanceCents ?? 0) <= 0) {
toast.error("积分不足,请充值后继续");
return;
}
setIsDefaultIntentRouting(true);
showDefaultRoutingGeneratingState();
try {
const intent = await classifyDefaultCommerceIntent({
prompt: requirement,
referenceCount: productImages.length,
ratio,
language,
platform,
});
if (intent.kind === "video") {
resetDefaultRoutingGeneratingState();
handleCloneOutputChange("video");
handleStartVideoPlan();
return;
}
if (!canGenerate) {
resetDefaultRoutingGeneratingState();
toast.info("请先上传商品图");
return;
}
handleGenerate(intent);
} catch (error) {
resetDefaultRoutingGeneratingState();
toast.error(error instanceof Error ? error.message : "智能识别失败,请重试");
} finally {
setIsDefaultIntentRouting(false);
}
return;
}
handleGenerate();
};
const showMainVideoWorkspace = cloneOutput === "video" && isVideoWorkspaceVisible;
const scrollInspirationRow = (event: ReactMouseEvent<HTMLButtonElement>, direction: -1 | 1) => {
const row = event.currentTarget.closest(".ecom-inspiration-row");
const strip = row?.querySelector<HTMLElement>(".ecom-inspiration-strip");
if (!strip) return;
strip.scrollBy({ left: direction * Math.max(280, strip.clientWidth * 0.78), behavior: "smooth" });
};
const applyInspirationPrompt = (prompt: string) => {
const nextValue = prompt.slice(0, 500);
// 回到主指令栏(关闭可能打开的快捷工具页),把提示词填入并聚焦。
setActiveQuickTool(null);
setRequirement(nextValue);
syncRequirementMentionQuery(nextValue, nextValue.length);
setInspirationPreview(null);
requestAnimationFrame(() => {
const textarea = requirementTextareaRef.current;
if (textarea) {
textarea.focus();
textarea.setSelectionRange(nextValue.length, nextValue.length);
textarea.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
toast.success("提示词已填入指令栏");
};
const applyComposerPrompt = (prompt: string) => {
const nextValue = prompt.slice(0, 500);
setActiveQuickTool(null);
setComposerMenu(null);
setRequirement(nextValue);
syncRequirementMentionQuery(nextValue, nextValue.length);
setInspirationPreview(null);
requestAnimationFrame(() => {
const textarea = requirementTextareaRef.current;
if (textarea) {
textarea.focus();
textarea.setSelectionRange(nextValue.length, nextValue.length);
textarea.scrollIntoView({ behavior: "smooth", block: "center" });
}
window.setTimeout(() => {
const latestTextarea = requirementTextareaRef.current;
if (!latestTextarea) return;
latestTextarea.focus();
latestTextarea.setSelectionRange(nextValue.length, nextValue.length);
}, 80);
});
};
const addTemplateAssetsToComposer = (card: CloneTemplateAsset) => {
const sourceAssets = card.sourceAssets?.filter((asset) => asset.url.trim()) || [];
if (!sourceAssets.length) return;
const stamp = Date.now();
const nextImages: CloneImageItem[] = sourceAssets.map((asset, index) => ({
id: `template-${card.id}-${stamp}-${index}`,
src: asset.url,
name: asset.name || `${card.title}-素材${index + 1}`,
ossKey: asset.ossKey,
mimeType: asset.mimeType,
format: getRemoteImageFormat(asset.mimeType || "", asset.url),
}));
let insertedImages: CloneImageItem[] = [];
setProductImages((current) => {
const userImages = current.filter((image) => !image.id.startsWith("template-"));
const remainingSlots = maxCloneProductImages - userImages.length;
if (remainingSlots <= 0) {
toast.info("模板素材已达上限");
return userImages;
}
insertedImages = nextImages.slice(0, remainingSlots);
return [...userImages, ...insertedImages];
});
insertedImages.forEach((image) => {
void readImageDimensions(image.src)
.then(({ width, height }) => {
setProductImages((current) =>
current.map((item) => (item.id === image.id ? { ...item, width, height } : item)),
);
})
.catch(() => undefined);
});
};
const handleCloneTemplateCardClick = (card: CommerceScenarioTemplate) => {
setActiveCommerceScenario(card.scenario);
if (card.output !== cloneOutput) handleCloneOutputChange(card.output);
setIsCloneTemplateStripVisible(true);
setComposerMenu(null);
addTemplateAssetsToComposer(card);
applyComposerPrompt(card.prompt);
};
const inspirationPreviewOverlay =
inspirationPreview && typeof document !== "undefined"
? createPortal(
<div
className="ecom-inspiration-preview"
onClick={() => setInspirationPreview(null)}
onWheel={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<div className="ecom-inspiration-preview__backdrop" />
<div className="ecom-inspiration-preview__content" onClick={(event) => event.stopPropagation()}>
{inspirationPreview.mediaType === "video" ? (
<video src={inspirationPreview.mediaUrl} controls autoPlay loop playsInline className="ecom-inspiration-preview__media" />
) : (
<img src={inspirationPreview.mediaUrl} alt="" className="ecom-inspiration-preview__media" />
)}
{inspirationPreview.prompt ? (
<div className="ecom-inspiration-preview__actions">
<button
type="button"
className="ecom-inspiration-preview__use-prompt"
onClick={() => applyInspirationPrompt(inspirationPreview.prompt)}
>
<EditOutlined />
<span>使</span>
</button>
</div>
) : null}
</div>
</div>,
document.body,
)
: null;
const clonePreview = (
<main
className={`product-clone-preview clone-ai-preview${showMainVideoWorkspace ? " has-main-video-workspace" : ""}`}
data-status={status}
aria-label="电商 AI 作图预览"
{...getPreviewSurfaceProps()}
>
<header className="clone-ai-preview-header">
<strong></strong>
<span>
AI <b></b>
</span>
<div className="clone-ai-preview-zoom">
<button type="button" onClick={() => setPreviewZoom((z) => Math.max(0.25, z - 0.1))} disabled={previewZoom <= 0.25} aria-label="缩小">-</button>
<span>{Math.round(previewZoom * 100)}%</span>
<button type="button" onClick={() => setPreviewZoom((z) => Math.min(2, z + 0.1))} disabled={previewZoom >= 2} aria-label="放大">+</button>
{activeHistoryRecordId ? (
<button type="button" onClick={() => { setPreviewZoom(1); setPreviewOffset({ x: 0, y: 0 }); }} aria-label="重置画布"></button>
) : null}
</div>
</header>
{cloneOutput === "video" ? (
showMainVideoWorkspace ? (
<section className="clone-ai-main-video-workspace" style={previewTransformStyle} aria-label="短视频生成画布">
<EcommerceVideoWorkspace
isAuthenticated={isAuthenticated}
productImageDataUrls={ecommerceVideoImageDataUrls}
productImageFiles={ecommerceVideoImageFiles}
requirement={requirement}
platform={platform}
aspectRatio={ratio.includes("9:16") ? "9:16" : ratio.includes("16:9") ? "16:9" : ratio.includes("3:4") ? "3:4" : "9:16"}
durationSeconds={cloneVideoDuration}
resolution={cloneVideoQuality === "standard" ? "720P" : "1080P"}
onRequestLogin={() => (isAuthenticated ? undefined : requestLogin())}
onOpenHistory={() => setVideoHistoryVisible(true)}
triggerPlan={videoPlanTrigger}
/>
</section>
) : status === "idle" || status === "ready" ? null : (
<>
<section className="clone-ai-flow-pipeline" aria-label="生成流程">
{/* Source Node —原图素材 */}
<div className="clone-ai-flow-source">
<div className="clone-ai-flow-node clone-ai-flow-node--source">
{productImages[0]?.src ? (
<img src={productImages[0].src} alt="商品原图" />
) : (
<div className="clone-ai-flow-node__placeholder">
<FileImageOutlined />
</div>
)}
</div>
<span className="clone-ai-flow-node__label"></span>
</div>
{/* Connector - branch lines */}
<div className="clone-ai-flow-connector" aria-hidden="true">
<div className="clone-ai-flow-connector__trunk" />
<div className="clone-ai-flow-connector__branches">
<div className="clone-ai-flow-connector__branch" />
<div className="clone-ai-flow-connector__branch" />
<div className="clone-ai-flow-connector__branch" />
</div>
</div>
{/* Branches —生成路径分支 */}
{status === "done" ? (
<div className="clone-ai-flow-branches">
{results[0]?.src ? (
<div className="clone-ai-flow-branch">
<div className="clone-ai-flow-node clone-ai-flow-node--text">
<div className="clone-ai-flow-node__text-content">
<span className="clone-ai-flow-node__text-title">{selectedCloneOutput.label}</span>
<span className="clone-ai-flow-node__text-desc">{requirement || "AI 智能生成"}</span>
</div>
</div>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<button
type="button"
className="clone-ai-flow-node clone-ai-flow-node--result"
onClick={() => openProductSetPreview(results[0])}
>
<img src={results[0].src} alt={selectedCloneOutput.label} />
<span className="clone-ai-flow-node__tag">{selectedCloneOutput.label}</span>
</button>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<div className="clone-ai-flow-node clone-ai-flow-node--video">
<img src={results[0].src} alt="分镜视频" />
<span className="clone-ai-flow-node__tag clone-ai-flow-node__tag--accent"></span>
</div>
</div>
) : null}
</div>
) : (
<div className="clone-ai-flow-branches clone-ai-flow-branches--empty">
{[1, 2, 3].map((branchIndex) => (
<div
key={branchIndex}
className={`clone-ai-flow-branch${status === "generating" ? " is-generating" : ""}${status === "failed" ? " is-failed" : ""}`}
>
<div className="clone-ai-flow-node clone-ai-flow-node--text">
<div className="clone-ai-flow-node__text-content">
<span className="clone-ai-flow-node__text-title">{branchIndex}</span>
<span className="clone-ai-flow-node__text-desc">
{status === "generating" ? "AI 解析中..." : "等待生成"}
</span>
</div>
</div>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<div className="clone-ai-flow-node clone-ai-flow-node--result">
<div className="clone-ai-flow-node__placeholder">
{status === "generating" ? <LoadingOutlined /> : <FileImageOutlined />}
</div>
<span className="clone-ai-flow-node__tag">{branchIndex}</span>
</div>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<div className="clone-ai-flow-node clone-ai-flow-node--video">
<div className="clone-ai-flow-node__placeholder">
{status === "generating" ? <LoadingOutlined /> : <FileImageOutlined />}
</div>
<span className="clone-ai-flow-node__tag">{branchIndex}</span>
</div>
</div>
))}
</div>
)}
</section>
{/* Status Overlay —生成状态覆盖层 */}
{status === "generating" || status === "failed" ? (
<section className="clone-ai-flow-status" aria-live="polite">
{status === "generating" ? (
<>
<LoadingOutlined style={{ fontSize: 28 }} />
<strong></strong>
<EcommerceProgressBar status="generating" progress={generationProgress} onCancel={handleCancelGenerate} label={`${selectedCloneOutput.label}生成`} />
<span>AI {platform} / {market} {selectedCloneOutput.label}</span>
</>
) : status === "failed" ? (
<>
<FrownOutlined style={{ fontSize: 28 }} />
<strong></strong>
<span></span>
{lastFailedActionRef.current ? (
<button type="button" className="clone-ai-retry-btn" onClick={lastFailedActionRef.current}>
<ReloadOutlined />
</button>
) : null}
</>
) : null}
</section>
) : null}
</>
)
) : (
<>
{status === "done" || canvasNodes.length > 0 ? (
<div className="clone-ai-preview-zoom-wrap" style={previewTransformStyle}>
<section className="clone-ai-canvas-nodes" aria-label="生成结果">
{canvasNodes.map((node) => (
<article
key={node.id}
className="clone-ai-canvas-node"
data-mode={node.mode}
data-node-id={node.id}
style={{ transform: `translate(${node.x}px, ${node.y}px)` }}
onPointerDown={(event) => startCanvasNodeDrag(event, node)}
onPointerMove={(event) => moveCanvasNodeDrag(event, node.id)}
onPointerUp={(event) => stopCanvasNodeDrag(event, node.id)}
onPointerCancel={(event) => stopCanvasNodeDrag(event, node.id)}
>
<div
className="clone-ai-node-drag-handle"
onPointerDown={(e) => {
if (e.button !== 0) return;
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
nodeDragRef.current = { active: true, nodeId: node.id, startX: e.clientX, startY: e.clientY, originX: node.x, originY: node.y };
}}
onPointerMove={(e) => {
const drag = nodeDragRef.current;
if (!drag.active || drag.nodeId !== node.id) return;
const zoom = previewZoomRef.current;
const dx = (e.clientX - drag.startX) / zoom;
const dy = (e.clientY - drag.startY) / zoom;
setCanvasNodes((prev) => prev.map((n) => n.id === node.id ? { ...n, x: drag.originX + dx, y: drag.originY + dy } : n));
}}
onPointerUp={(e) => {
if (nodeDragRef.current.nodeId === node.id) {
nodeDragRef.current = { ...nodeDragRef.current, active: false };
e.currentTarget.releasePointerCapture(e.pointerId);
}
}}
/>
<div className="clone-ai-source-stack">
<button
type="button"
className="clone-ai-source-corner-action"
onClick={node.sourceImage ? () => openProductSetPreview({ src: node.sourceImage!, label: "原图素材" }) : undefined}
disabled={!node.sourceImage}
>
</button>
<button
type="button"
className="clone-ai-main-result"
aria-label="预览原图素材"
onClick={node.sourceImage ? () => openProductSetPreview({ src: node.sourceImage!, label: "原图素材" }) : undefined}
disabled={!node.sourceImage}
>
{node.sourceImage ? (
<img
src={node.sourceImage}
alt="原图素材"
onError={(event) => {
event.currentTarget.style.display = "none";
event.currentTarget.parentElement?.classList.add("is-missing-source");
}}
/>
) : null}
<span className="clone-ai-source-missing"></span>
</button>
</div>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<div className="clone-ai-result-stack">
<span className="clone-ai-node-label">{node.mode === "set" ? "套图" : node.mode === "detail" ? "详情图" : node.mode === "model" ? "模特图" : node.mode === "hot" ? "爆款图" : node.mode}</span>
<div className="clone-ai-result-grid result-reveal">
{node.results.map((card) => (
<button key={card.id} type="button" style={{ aspectRatio: parseRatioToAspectCss(ratio) }} onClick={() => openProductSetPreview(card, { nodeId: node.id, removable: true })}>
<img src={card.src} alt={card.label} />
</button>
))}
</div>
</div>
</article>
))}
{status === "generating" ? (
<article className="clone-ai-canvas-node is-generating" style={{ transform: `translate(${canvasNodes.length * 420}px, 0px)` }}>
<LoadingOutlined style={{ fontSize: 24 }} />
<span>{selectedCloneOutput.label}</span>
</article>
) : null}
</section>
</div>
) : status === "idle" || status === "ready" ? null : (
<section className="clone-ai-empty-state" aria-live="polite">
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <FrownOutlined /> : <FileImageOutlined />}
<strong>{status === "generating" ? "正在生成" : status === "failed" ? "生成失败" : "等待生成"}</strong>
{status === "generating" ? <EcommerceProgressBar status="generating" progress={generationProgress} onCancel={handleCancelGenerate} label={`${selectedCloneOutput.label}生成`} /> : null}
<span>
{status === "generating"
? "AI 正在为 " + platform + " / " + market + " 整理" + selectedCloneOutput.label + "。"
: status === "failed"
? "请检查网络后点击下方重试"
: "上传商品原图并填写信息后,AI 将在这里展示生成结果。"}
</span>
{status === "failed" && lastFailedActionRef.current ? (
<button type="button" className="clone-ai-retry-btn" onClick={lastFailedActionRef.current}>
<ReloadOutlined />
</button>
) : null}
</section>
)}
</>
)}
<section
ref={commandComposerWrapRef}
className={`clone-ai-bottom-input ecom-command-composer-wrap${status === "done" || canvasNodes.length > 0 ? " has-generated" : " is-before-generate"}${isCommandComposerCompact && (status === "done" || canvasNodes.length > 0) ? " is-compact" : ""}`}
aria-label="生成指令"
onClick={() => {
if (isCommandComposerCompact) setIsCommandComposerCompact(false);
}}
>
<h1 className={`ecom-command-title${status === "done" || canvasNodes.length > 0 ? " is-after-generate" : ""}`}>
{typewriterText}
<span className="typewriter-cursor" aria-hidden="true">|</span>
</h1>
<input
ref={cloneReferenceInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleCloneReferenceUpload}
aria-label="上传参考图片"
/>
<input
ref={productInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleComposerAssetUpload}
aria-label="上传商品素材"
/>
<input
ref={smartCutoutInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleSmartCutoutUpload}
aria-label="上传智能抠图素材"
/>
<div className="ecom-command-scenario-shell" data-expanded={isCommerceScenarioMoreOpen ? "true" : "false"}>
<div className="ecom-command-mode-tabs ecom-command-scenario-tabs" aria-label="电商场景">
{visibleCommerceScenarioOptions.map((option) => (
<button
key={option.key}
type="button"
className={`${activeCommerceScenario === option.key ? "is-active" : ""}${activeCommerceScenario === option.key && isCloneTemplateStripVisible ? " is-open" : ""}`}
onClick={() => handleCommerceScenarioClick(option.key)}
>
<span className={`ecom-command-mode-icon ecom-command-mode-icon--${option.key}`} aria-hidden="true">{option.icon}</span>
<strong>{option.label}</strong>
{activeCommerceScenario === option.key && isCloneTemplateStripVisible ? (
<span className="ecom-command-scenario-close" aria-hidden="true"><CloseOutlined /></span>
) : null}
</button>
))}
<button
type="button"
className={`ecom-command-scenario-more${isCommerceScenarioMoreOpen ? " is-open" : ""}`}
onClick={handleCommerceScenarioMoreToggle}
aria-expanded={isCommerceScenarioMoreOpen}
>
<span className="ecom-command-mode-icon ecom-command-mode-icon--more" aria-hidden="true">
{isCommerceScenarioMoreOpen ? <CloseOutlined /> : "···"}
</span>
<strong>{isCommerceScenarioMoreOpen ? "收起" : "更多"}</strong>
</button>
</div>
</div>
<div className="clone-ai-input-wrapper ecom-command-composer">
{productImages.length ? (
<div className="ecom-command-asset-popover" aria-label={`已上传素材,${productImages.length}/${maxCloneProductImages}`}>
<button
type="button"
className="ecom-command-asset-add"
onClick={() => productImages.length < maxCloneProductImages && productInputRef.current?.click()}
disabled={productImages.length >= maxCloneProductImages}
aria-label={productImages.length >= maxCloneProductImages ? `最多上传${maxCloneProductImages}张素材` : "继续上传素材"}
title={productImages.length >= maxCloneProductImages ? `最多上传 ${maxCloneProductImages} 张素材` : `继续上传素材 ${productImages.length}/${maxCloneProductImages}`}
>
<span aria-hidden="true">+</span>
<small>{productImages.length >= maxCloneProductImages ? "已满" : "上传"}</small>
</button>
{productImages.map((image) => (
<figure key={image.id} className="ecom-command-asset-thumb">
<img src={image.src} alt={image.name || "上传图片"} />
<button type="button" onClick={() => removeProductImage(image.id)} aria-label="删除图片">
<DeleteOutlined />
</button>
</figure>
))}
</div>
) : null}
{shouldShowScenarioSettings ? (
<div className="ecom-command-option-row ecom-command-option-row--settings">
{activeCommerceScenario !== "salesVideo" && activeCommerceScenario !== "poster" ? (
<button type="button" className={composerMenu === "platform" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("platform", event)}>
<span className="ecom-command-option-icon" aria-hidden="true"><GlobalOutlined /></span>
<span></span>{platform}
</button>
) : null}
{activeCommerceScenario !== "salesVideo" ? (
<button type="button" className={composerMenu === "language" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("language", event)}>
<span className="ecom-command-option-icon" aria-hidden="true"><FileImageOutlined /></span>
<span></span>{language}
</button>
) : null}
<button type="button" className={composerMenu === "ratio" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("ratio", event)}>
<span className="ecom-command-option-icon" aria-hidden="true"><TableOutlined /></span>
<span></span>{getRatioDisplayParts(ratio).aspect}
</button>
{scenarioAdvancedSettingsKeys.includes(activeCommerceScenario) ? (
<button type="button" className={composerMenu === "settings" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("settings", event)}>
<span className="ecom-command-option-icon" aria-hidden="true"><SettingOutlined /></span>
<span>{activeCommerceScenario === "salesVideo" ? "时长" : "设置"}</span>
{activeCommerceScenario === "salesVideo" ? `${cloneVideoDuration}` : composerSettingLabel}
</button>
) : null}
</div>
) : null}
<textarea
ref={requirementTextareaRef}
value={requirement}
onChange={(event) => {
const nextValue = event.target.value.slice(0, 500);
setRequirement(nextValue);
syncRequirementMentionQuery(nextValue, event.target.selectionStart);
}}
onClick={(event) => syncRequirementMentionQuery(requirement, event.currentTarget.selectionStart)}
onKeyUp={(event) => syncRequirementMentionQuery(requirement, event.currentTarget.selectionStart)}
onKeyDown={(event) => {
if (event.key === "Escape") setRequirementImageMentionQuery(null);
}}
maxLength={500}
placeholder={cloneRequirementPlaceholder}
/>
{requirementImageMentionQuery !== null && ecommerceMentionImages.length ? (
<ImageMentionMenu images={ecommerceMentionImages} query={requirementImageMentionQuery} onSelect={insertRequirementImageMention} />
) : null}
<div className="ecom-command-toolbar" aria-label="生成设置">
<div className="ecom-command-composer-actions">
<button
type="button"
className={`ecom-command-reference ecom-command-reference--bottom ecom-command-tool ecom-command-tool--upload${productImages.length ? " has-images" : ""}${isProductUploadDragging ? " is-dragging" : ""}`}
onClick={() => {
if (productImages.length >= maxCloneProductImages) {
toast.info(`最多上传 ${maxCloneProductImages} 张素材`);
return;
}
productInputRef.current?.click();
}}
onDragEnter={(event) => {
event.preventDefault();
setIsProductUploadDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsProductUploadDragging(false)}
onDrop={(event) => {
event.preventDefault();
setIsProductUploadDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) addComposerAssets(files);
}}
aria-disabled={productImages.length >= maxCloneProductImages}
aria-label={productImages.length >= maxCloneProductImages ? `最多上传 ${maxCloneProductImages} 张素材` : "上传素材"}
title={productImages.length >= maxCloneProductImages ? `最多上传 ${maxCloneProductImages} 张素材` : "上传素材"}
>
<span aria-hidden="true"><PaperClipOutlined /></span>
<strong></strong>
</button>
<button
type="button"
className={`ecom-command-tool ecom-command-tool--icon${composerMenu === "assetLibrary" ? " is-active" : ""}`}
onClick={(event) => toggleComposerMenu("assetLibrary", event)}
onMouseEnter={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
setComposerTooltip({ text: "资产库", left: rect.left + rect.width / 2, top: rect.top - 8 });
}}
onMouseLeave={() => setComposerTooltip(null)}
aria-label="资产库"
title="资产库"
data-tooltip="资产库"
>
<FolderOpenOutlined />
</button>
<button
type="button"
className={`ecom-command-tool ecom-command-tool--icon${composerMenu === "workMode" ? " is-active" : ""}`}
onClick={(event) => toggleComposerMenu("workMode", event)}
onMouseEnter={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
setComposerTooltip({ text: `模式:${composerWorkMode === "quick" ? "快捷" : "思考"}`, left: rect.left + rect.width / 2, top: rect.top - 8 });
}}
onMouseLeave={() => setComposerTooltip(null)}
aria-label={`模式:${composerWorkMode === "quick" ? "快捷" : "思考"}`}
title={`模式:${composerWorkMode === "quick" ? "快捷" : "思考"}`}
data-tooltip={`模式:${composerWorkMode === "quick" ? "快捷" : "思考"}`}
>
<ArrowsCounterClockwise size={17} weight="bold" />
</button>
<button
type="button"
className={`ecom-command-tool ecom-command-tool--icon${composerMenu === "aiWrite" ? " is-active" : ""}`}
onClick={(event) => toggleComposerMenu("aiWrite", event)}
onMouseEnter={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
setComposerTooltip({ text: "AI帮写", left: rect.left + rect.width / 2, top: rect.top - 8 });
}}
onMouseLeave={() => setComposerTooltip(null)}
aria-label="AI帮写"
title="AI帮写"
data-tooltip="AI帮写"
>
<MagicWand size={17} weight="bold" />
</button>
</div>
<div className="ecom-command-submit-row">
<button type="button" className="clone-ai-send-button ecom-command-send" disabled={commandGenerateDisabled} onClick={handleCommandGenerate} aria-label={clonePrimaryLabel}>
{status === "generating" || isDefaultIntentRouting ? <LoadingOutlined /> : <PaperPlaneRight size={18} weight="fill" />}
</button>
</div>
</div>
{renderComposerMenu()}
{composerTooltip ? createPortal(
<span className="ecom-command-tooltip-floating" style={{ left: composerTooltip.left, top: composerTooltip.top }}>
{composerTooltip.text}
</span>,
document.body
) : null}
</div>
{(status === "idle" || status === "ready") && !showMainVideoWorkspace && activeCommerceScenario !== null && isCloneTemplateStripVisible ? (
<div className={`ecom-command-template-carousel ecom-command-template-carousel--${activeCommerceScenario}`}>
<button
type="button"
className="ecom-command-template-nav ecom-command-template-nav--prev"
onClick={() => scrollCommerceTemplateStrip(-1)}
aria-label="查看上一组模板"
>
</button>
<section
ref={templateStripRef}
className={`ecom-command-template-strip ecom-command-template-strip--${activeCommerceScenario}`}
aria-label="模板卡片"
>
{activeCommerceScenarioTemplates.map((card) => (
<button
key={card.id}
type="button"
className="ecom-command-template-card"
aria-label={card.title}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleCloneTemplateCardClick(card);
}}
>
<span className="ecom-command-template-card__media" aria-hidden="true">
{card.mediaType === "video" ? (
<video src={card.mediaUrl} muted playsInline loop preload="metadata" />
) : (
<img src={card.mediaUrl} alt="" loading="lazy" />
)}
</span>
<span className="ecom-command-template-card__body">
<span className="ecom-command-template-card__badge">{card.badge}</span>
<strong>{card.title}</strong>
<em>{card.desc}</em>
</span>
<span className="ecom-command-template-card__prompt" aria-hidden="true">
{card.prompt}
</span>
</button>
))}
</section>
<button
type="button"
className="ecom-command-template-nav ecom-command-template-nav--next"
onClick={() => scrollCommerceTemplateStrip(1)}
aria-label="查看下一组模板"
>
</button>
</div>
) : null}
{(status === "idle" || status === "ready") && !showMainVideoWorkspace ? (
<section className="ecom-command-quick-board" aria-label="快捷功能">
{[
{ label: "A+/详情页", tone: "detail", icon: <BorderOuterOutlined />, onClick: openQuickDetailPage },
{ label: "爆款复刻", tone: "hot", icon: <FireOutlined />, onClick: openHotClonePage },
{ label: "图片修改", tone: "edit", icon: <HighlightOutlined />, onClick: openImageWorkbenchPage },
{ label: "智能抠图", tone: "cutout", icon: <ScissorOutlined />, onClick: openSmartCutoutUpload },
{ label: "去除水印", tone: "watermark", icon: <ClearOutlined />, onClick: openWatermarkRemovalPage },
{ label: "图片翻译", tone: "translate", icon: <TranslationOutlined />, onClick: openImageTranslatePage },
{ label: "商品套图", tone: "product", icon: <AppstoreAddOutlined />, onClick: openQuickSetPage },
{ label: "一键文案", tone: "copywriting", icon: <FileTextOutlined />, onClick: openCopywritingPage },
{ label: "一键视频", tone: "video", icon: <PlayCircleOutlined />, onClick: openOneClickVideoPage },
{ label: "更多功能", tone: "more", icon: <SettingOutlined />, disabled: true },
].map((item) => (
<button
key={item.label}
type="button"
className={`ecom-command-quick-card ecom-command-quick-card--${item.tone}`}
disabled={item.disabled}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (item.disabled) {
toast.info("更多功能即将上线,敬请期待!");
} else {
item.onClick?.();
}
}}
>
<span aria-hidden="true">{item.icon}</span>
<strong>{item.label}</strong>
</button>
))}
</section>
) : null}
{(status === "idle" || status === "ready") && !showMainVideoWorkspace ? (
<section className="ecom-inspiration-lab" aria-label="电商灵感案例">
<div className="ecom-inspiration-rows">
{ecommerceInspirationRows.map((row) => (
<section key={row.title} className={`ecom-inspiration-row ecom-inspiration-row--${row.variant}`} aria-label={row.title}>
<div className="ecom-inspiration-row__meta">
<strong>{row.title}</strong>
<span>{row.desc}</span>
<div className="ecom-inspiration-row__controls">
<button type="button" aria-label={`向左浏览${row.title}`} onClick={(event) => scrollInspirationRow(event, -1)}></button>
<button type="button" aria-label={`向右浏览${row.title}`} onClick={(event) => scrollInspirationRow(event, 1)}></button>
</div>
</div>
<div className="ecom-inspiration-strip" tabIndex={0}>
{row.cards.map((card, index) => (
<article key={card.title} className="ecom-inspiration-card" onClick={() => setInspirationPreview({ mediaUrl: card.mediaUrl, mediaType: card.mediaType, prompt: buildInspirationPrompt(card.title, card.meta) })}>
<div className="ecom-inspiration-card__visual" aria-hidden="true">
{card.mediaType === "video" ? (
<video src={card.mediaUrl} muted playsInline loop autoPlay preload="metadata" />
) : (
<img src={card.mediaUrl} alt="" loading="lazy" />
)}
</div>
<strong>{card.title}</strong>
<p>{card.meta}</p>
<em>{String(index + 1).padStart(2, "0")}</em>
</article>
))}
</div>
</section>
))}
</div>
</section>
) : null}
</section>
{inspirationPreviewOverlay}
</main>
);
const smartCutoutPreview = (
<main key={`smart-cutout-${smartCutoutImage ? "editor" : "upload"}`} className={`ecom-smart-cutout-page ecom-tool-page-enter${smartCutoutImage ? " is-editor" : " is-upload"}${isSmartCutoutTransitioning ? " is-transitioning" : ""}`} aria-label="智能抠图">
<input
ref={smartCutoutInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleSmartCutoutUpload}
aria-label="上传智能抠图素材"
/>
<nav className="ecom-smart-cutout-nav" aria-label="智能抠图导航">
<button type="button" onClick={closeSmartCutoutTool}>
</button>
<button type="button" onClick={goSmartCutoutPrevious}>
</button>
</nav>
{isSmartCutoutTransitioning ? (
<div className="ecom-smart-cutout-transition" role="status" aria-live="polite">
<span aria-hidden="true" />
<strong>{smartCutoutTransitionMessage.title}</strong>
<em>{smartCutoutTransitionMessage.subtitle}</em>
</div>
) : null}
{!smartCutoutImage ? (
<section className="ecom-smart-cutout-upload">
<div className="ecom-smart-cutout-head">
<strong></strong>
<span>3s </span>
</div>
<div className="ecom-smart-cutout-upload__body">
<div className="ecom-smart-cutout-demo" aria-hidden="true">
<div className="ecom-smart-cutout-demo__tile ecom-smart-cutout-demo__tile--flower" />
<div className="ecom-smart-cutout-demo__tile ecom-smart-cutout-demo__tile--product" />
<div className="ecom-smart-cutout-demo__tile ecom-smart-cutout-demo__tile--poster" />
<div className="ecom-smart-cutout-demo__tile ecom-smart-cutout-demo__tile--object" />
</div>
<div
className={`ecom-smart-cutout-upload-box${isSmartCutoutDragging ? " is-dragging" : ""}`}
role="button"
tabIndex={0}
onClick={() => smartCutoutInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
smartCutoutInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsSmartCutoutDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsSmartCutoutDragging(false)}
onDrop={handleSmartCutoutDrop}
>
<button type="button" className="ecom-smart-cutout-upload__primary">
<CloudUploadOutlined />
</button>
<button type="button" className="ecom-smart-cutout-upload__secondary">
<FolderOpenOutlined />
</button>
<span> &gt;</span>
</div>
</div>
</section>
) : (
<section className="ecom-smart-editor">
<div className="ecom-smart-editor__workspace">
<div className="ecom-smart-editor__canvas">
<div className={`ecom-smart-editor__checker is-size-${previewSmartCutoutSizeKey}`} style={smartCutoutFrameStyle}>
<div className="ecom-smart-editor__background-layer" aria-hidden="true" />
<img src={previewSmartCutoutImageSrc ?? smartCutoutImage.src} alt={smartCutoutImage.name} />
</div>
<div className="ecom-smart-editor__canvas-actions">
<button
type="button"
onPointerDown={showSmartCutoutOriginalCompare}
onPointerUp={hideSmartCutoutOriginalCompare}
onPointerCancel={hideSmartCutoutOriginalCompare}
onBlur={hideSmartCutoutOriginalCompare}
onKeyDown={(event) => {
if (event.key === " " || event.key === "Enter") setIsSmartCutoutComparing(true);
}}
onKeyUp={hideSmartCutoutOriginalCompare}
>
</button>
</div>
</div>
<div className="ecom-smart-editor__tools-shell">
<strong className="ecom-smart-editor__tools-title"></strong>
<button type="button" className="ecom-smart-editor__tools-nav" onClick={() => scrollSmartCutoutTools(-1)} aria-label="查看上一组尺寸">
</button>
<div className="ecom-smart-editor__tools" ref={smartCutoutToolsRef}>
{smartCutoutSizeOptions.map((item) => (
<div className="ecom-smart-editor__tool-item" key={item.key}>
<button
type="button"
className={smartCutoutSizeKey === item.key ? "is-active" : ""}
onClick={() => setSmartCutoutSizeKey(item.key)}
aria-label={`${item.label}${"sizeLabel" in item ? ` ${item.sizeLabel}` : ""}`}
aria-pressed={smartCutoutSizeKey === item.key}
>
<span className={`ecom-smart-editor__tool-icon ecom-smart-editor__tool-icon--${item.icon}`} aria-hidden="true" />
</button>
<span className="ecom-smart-editor__tool-text">
<span>{item.label}</span>
{"sizeLabel" in item ? <span>{item.sizeLabel}</span> : null}
</span>
</div>
))}
</div>
<button type="button" className="ecom-smart-editor__tools-nav" onClick={() => scrollSmartCutoutTools(1)} aria-label="查看更多尺寸">
</button>
</div>
{smartCutoutBatchImages.length > 1 ? (
<section className="ecom-smart-editor__batch" aria-label="批量图片">
<header>
<strong></strong>
<span>{smartCutoutBatchImages.length} </span>
</header>
<div>
{smartCutoutBatchImages.map((image, index) => (
<button
key={`${image.src}-${index}`}
type="button"
className={smartCutoutImage.src === image.src ? "is-active" : ""}
onClick={() => setSmartCutoutImage(image)}
>
<img src={image.src} alt={image.name || `上传图片 ${index + 1}`} />
<span>{index + 1}</span>
</button>
))}
</div>
</section>
) : null}
<section className="ecom-smart-editor__gallery">
<header><strong></strong><button type="button"> &gt;</button></header>
<div className="ecom-smart-editor__swatches">
{["#ffffff", "#eeeae3", "#f2e3cf", "#000000", "#a89682", "#c9c9c9"].map((color) => (
<button
key={color}
type="button"
className={smartCutoutBackgroundColor.toLowerCase() === color ? "is-active" : ""}
style={{ "--smart-cutout-swatch-bg": color } as CSSProperties}
onClick={() => {
setSmartCutoutBackgroundColor(color);
setSmartCutoutBackgroundAlpha(100);
}}
>
<span className="ecom-smart-editor__swatch-bg" aria-hidden="true" />
<img src={smartCutoutImage.src} alt="" />
</button>
))}
</div>
<header><strong>AI换背景</strong><button type="button"> &gt;</button></header>
<div className="ecom-smart-editor__scenes">
<button type="button" className="ecom-smart-editor__generate"><SettingOutlined /></button>
{["客厅陈列", "桌面日光", "香氛产品", "绿植窗边", "居家空间"].map((item) => (
<button key={item} type="button"><span>{item}</span></button>
))}
</div>
</section>
</div>
<aside className="ecom-smart-editor__side">
<strong></strong>
<div className="ecom-smart-editor__color-row">
<div className="ecom-smart-editor__color-wrap" ref={smartCutoutPaletteRef} style={{ "--smart-cutout-bg": smartCutoutBackgroundValue } as CSSProperties}>
<button
type="button"
className={`ecom-smart-editor__custom-color${isSmartCutoutPaletteOpen ? " is-active" : ""}`}
style={{ background: smartCutoutBackgroundColor }}
onClick={() => setIsSmartCutoutPaletteOpen((open) => !open)}
aria-label="打开背景调色盘"
>
<span></span>
</button>
{isSmartCutoutPaletteOpen ? (
<div className="ecom-smart-color-picker" role="dialog" aria-label="背景调色盘">
<button
type="button"
className="ecom-smart-color-picker__plane"
style={{ background: `linear-gradient(to top, #000000, transparent), linear-gradient(to right, #ffffff, hsl(${smartCutoutColorHsv.h} 100% 50%))` }}
onPointerDown={handleSmartCutoutColorPlanePointer}
onPointerMove={handleSmartCutoutColorPlaneMove}
aria-label="选择颜色明度和饱和度"
>
<span style={{ left: `${smartCutoutColorHsv.s}%`, top: `${100 - smartCutoutColorHsv.v}%` }} />
</button>
<div className="ecom-smart-color-picker__slider ecom-smart-color-picker__slider--hue">
<span style={{ background: `hsl(${smartCutoutColorHsv.h} 100% 50%)` }} />
<input
type="range"
min={0}
max={360}
value={smartCutoutColorHsv.h}
onChange={(event) => applySmartCutoutHsv(Number(event.target.value), smartCutoutColorHsv.s, smartCutoutColorHsv.v)}
aria-label="色相"
/>
</div>
<div className="ecom-smart-color-picker__slider ecom-smart-color-picker__slider--alpha">
<span style={{ background: smartCutoutBackgroundValue }} />
<input
type="range"
min={0}
max={100}
value={smartCutoutBackgroundAlpha}
onChange={(event) => setSmartCutoutBackgroundAlpha(Number(event.target.value))}
aria-label="透明度"
/>
</div>
<div className="ecom-smart-color-picker__fields">
<span aria-hidden="true"></span>
<input
value={smartCutoutHexDraft}
onChange={(event) => handleSmartCutoutHexChange(event.target.value)}
onBlur={() => setSmartCutoutHexDraft(smartCutoutBackgroundColor)}
aria-label="HEX 色值"
/>
<input
value={Math.round(smartCutoutBackgroundAlpha)}
onChange={(event) => setSmartCutoutBackgroundAlpha(clampNumber(Number(event.target.value) || 0, 0, 100))}
aria-label="透明度百分比"
/>
<strong>%</strong>
</div>
<p></p>
<div className="ecom-smart-color-picker__presets">
{smartCutoutColorPresets.map((color) => (
<button
key={color}
type="button"
className={smartCutoutBackgroundColor.toLowerCase() === color ? "is-active" : ""}
style={{ background: color }}
onClick={() => {
setSmartCutoutBackgroundColor(color);
setSmartCutoutBackgroundAlpha(100);
}}
aria-label={`选择颜色 ${color}`}
/>
))}
</div>
</div>
) : null}
</div>
{["#ffffff", "#f8f9fa", "#000000", "#bdbdbd"].map((color) => (
<button
key={color}
type="button"
className={smartCutoutBackgroundColor.toLowerCase() === color ? "is-active" : ""}
style={{ background: color }}
onClick={() => {
setSmartCutoutBackgroundColor(color);
setSmartCutoutBackgroundAlpha(100);
}}
aria-label={`背景颜色 ${color}`}
/>
))}
</div>
<div className="ecom-smart-editor__side-actions">
<button type="button" className="ecom-smart-editor__download" onClick={handleSmartCutoutDownload}></button>
<button type="button" onClick={() => smartCutoutInputRef.current?.click()}></button>
</div>
</aside>
</section>
)}
</main>
);
const imageWorkbenchPreview = (
<main key="image-workbench" className="ecom-image-workbench-page ecom-tool-page-enter" aria-label="图片修改局部重绘">
<input
ref={imageWorkbenchInputRef}
type="file"
accept="image/*"
className="ecom-command-hidden-file"
onChange={handleImageWorkbenchUpload}
aria-label="上传图片"
/>
<aside className="ecom-image-workbench-side">
<header className="ecom-quick-set-panel-head ecom-image-workbench-panel-head">
<strong className="ecom-quick-set-page-title"></strong>
<button type="button" className="ecom-quick-set-back" onClick={closeImageWorkbenchPage}></button>
<button type="button" className="ecom-quick-set-back" onClick={closeImageWorkbenchPage}></button>
</header>
<p className="ecom-image-workbench-intro"> AI </p>
<section className="ecom-image-workbench-panel">
<header>
<strong></strong>
<span>{imageWorkbenchImage ? "已上传" : "待上传"}</span>
</header>
<div
className={`ecom-image-workbench-upload${isImageWorkbenchDragging ? " is-dragging" : ""}${imageWorkbenchImage ? " has-image" : ""}`}
role="button"
tabIndex={0}
onClick={() => imageWorkbenchInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
imageWorkbenchInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsImageWorkbenchDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsImageWorkbenchDragging(false)}
onDrop={handleImageWorkbenchDrop}
>
{imageWorkbenchImage ? (
<>
<button
type="button"
className="ecom-image-workbench-remove"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
removeImageWorkbenchImage();
}}
aria-label="删除素材"
>
×
</button>
<figure>
<img src={imageWorkbenchImage.src} alt={imageWorkbenchImage.name} />
</figure>
<div>
<strong>{imageWorkbenchImage.name}</strong>
<span>{imageWorkbenchImage.format || "PNG / JPG / WebP"}</span>
</div>
</>
) : (
<>
<FileImageOutlined />
<strong></strong>
<span>PNG / JPG / WebP</span>
</>
)}
</div>
<div className="ecom-image-workbench-url-row">
<input
ref={imageWorkbenchUrlInputRef}
placeholder="粘贴图片 URL"
aria-label="粘贴图片 URL"
onKeyDown={(event) => {
if (event.key === "Enter") void handleImageWorkbenchUrlImport();
}}
/>
<button type="button" onClick={() => void handleImageWorkbenchUrlImport()}></button>
</div>
</section>
<section className="ecom-image-workbench-panel">
<header>
<strong></strong>
<span>{imageWorkbenchMaskStrokes.length ? `已遮罩 ${imageWorkbenchMaskStrokes.length}` : "未遮罩"}</span>
</header>
<label className="ecom-image-workbench-slider">
<span></span>
<input
type="range"
min={10}
max={120}
value={imageWorkbenchBrushSize}
onChange={(event) => setImageWorkbenchBrushSize(Number(event.target.value))}
/>
<em>{imageWorkbenchBrushSize}px</em>
</label>
<button
type="button"
className="ecom-image-workbench-clear"
onClick={() => {
setImageWorkbenchMaskStrokes([]);
setImageWorkbenchBrushCursor(null);
clearImageWorkbenchMaskCanvas();
setImageWorkbenchStatus("idle");
setImageWorkbenchResultUrl(null);
}}
disabled={!imageWorkbenchMaskStrokes.length}
>
</button>
</section>
<section className="ecom-image-workbench-panel">
<strong></strong>
<div className="ecom-image-workbench-ratios" aria-label="输出分辨率">
{["9:16", "16:9", "4:3", "3:4", "1:1"].map((item) => (
<button
key={item}
type="button"
className={imageWorkbenchRatio === item ? "is-active" : ""}
onClick={() => setImageWorkbenchRatio(item)}
>
{item}
</button>
))}
</div>
</section>
<section className="ecom-image-workbench-panel">
<strong></strong>
<textarea
value={imageWorkbenchPrompt}
onChange={(event) => setImageWorkbenchPrompt(event.target.value.slice(0, 300))}
placeholder="描述需要重绘的内容,例如:将背景替换为森林"
/>
</section>
<button
type="button"
className="ecom-image-workbench-primary"
onClick={handleImageWorkbenchGenerate}
disabled={!imageWorkbenchImage || imageWorkbenchStatus === "processing"}
>
{imageWorkbenchStatus === "processing" ? <LoadingOutlined /> : <SettingOutlined />}
{imageWorkbenchStatus === "processing" ? "重绘中" : "开始重绘"}
</button>
</aside>
<section className="ecom-image-workbench-stage">
{!imageWorkbenchImage ? (
<div
className={`ecom-watermark-dropzone${isImageWorkbenchDragging ? " is-dragging" : ""}`}
role="button"
tabIndex={0}
onClick={() => imageWorkbenchInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
imageWorkbenchInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsImageWorkbenchDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsImageWorkbenchDragging(false)}
onDrop={handleImageWorkbenchDrop}
>
<CloudUploadOutlined />
<strong></strong>
<span> PNG / JPG / WebP使</span>
</div>
) : (
<div className="ecom-watermark-grid">
<article className="ecom-watermark-preview-card">
<span> / </span>
<div
className="ecom-image-workbench-image-frame"
onPointerDown={handleImageWorkbenchMaskPointerDown}
onPointerMove={handleImageWorkbenchMaskPointerMove}
onPointerUp={stopImageWorkbenchMaskPainting}
onPointerCancel={stopImageWorkbenchMaskPainting}
onPointerLeave={() => {
stopImageWorkbenchMaskPainting();
setImageWorkbenchBrushCursor(null);
}}
>
<img src={imageWorkbenchImage.src} alt="局部重绘素材" draggable={false} />
<canvas ref={imageWorkbenchMaskCanvasRef} className="ecom-image-workbench-mask-layer" aria-label="遮罩区域" />
<span
className="ecom-image-workbench-brush"
style={{
left: `${imageWorkbenchBrushCursor?.x ?? 50}%`,
top: `${imageWorkbenchBrushCursor?.y ?? 50}%`,
width: imageWorkbenchBrushSize,
height: imageWorkbenchBrushSize,
}}
/>
</div>
</article>
<article className="ecom-watermark-preview-card">
<span></span>
{imageWorkbenchStatus === "processing" ? (
<div className="ecom-watermark-processing" role="status" aria-live="polite">
<LoadingOutlined />
<strong></strong>
<em>AI </em>
<div className="ecom-quick-set-progress">
<div className="ecom-quick-set-progress-bar" style={{ width: `${Math.round(imageWorkbenchProgress)}%` }} />
</div>
<em className="ecom-quick-set-progress-text">{Math.round(imageWorkbenchProgress)}%</em>
</div>
) : imageWorkbenchStatus === "done" && imageWorkbenchResultUrl ? (
<>
<img src={imageWorkbenchResultUrl} alt="重绘结果" />
</>
) : imageWorkbenchStatus === "failed" ? (
<div className="ecom-watermark-empty">
<FrownOutlined />
<strong></strong>
<em></em>
</div>
) : (
<div className="ecom-watermark-empty">
<FileImageOutlined />
<strong></strong>
<em></em>
</div>
)}
<div className="ecom-watermark-actions">
<button type="button" onClick={() => toast.success("已加入资产库")} disabled={imageWorkbenchStatus !== "done"}>
<FolderOpenOutlined />
</button>
<button type="button" onClick={() => {
if (!imageWorkbenchResultUrl) return;
const link = document.createElement("a");
link.href = imageWorkbenchResultUrl;
link.download = `inpaint-result-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
link.remove();
}} disabled={imageWorkbenchStatus !== "done"}>
<CloudUploadOutlined />
</button>
</div>
</article>
</div>
)}
</section>
</main>
);
const watermarkPreview = (
<WatermarkToolPage
inputRef={watermarkInputRef}
urlInputRef={watermarkUrlInputRef}
image={watermarkImage}
isDragging={isWatermarkDragging}
status={watermarkStatus}
progress={watermarkProgress}
resultUrl={watermarkResultUrl}
onUpload={handleWatermarkUpload}
onDrop={handleWatermarkDrop}
onDraggingChange={setIsWatermarkDragging}
onRemoveImage={removeWatermarkImage}
onUrlImport={handleWatermarkUrlImport}
onGenerate={handleWatermarkGenerate}
onDownload={handleWatermarkDownload}
onClose={closeWatermarkRemovalPage}
/>
);
const translateLanguageOptions = [
{ value: "zh", label: "中文" },
{ value: "en", label: "English" },
{ value: "ja", label: "日本語" },
{ value: "ko", label: "한국어" },
{ value: "fr", label: "Français" },
{ value: "de", label: "Deutsch" },
{ value: "es", label: "Español" },
{ value: "pt", label: "Português" },
{ value: "ru", label: "Русский" },
{ value: "ar", label: "العربية" },
];
const translatePreview = (
<main key="translate" className="ecom-watermark-page ecom-translate-page ecom-tool-page-enter" aria-label="图片翻译">
<input
ref={translateInputRef}
type="file"
accept="image/*"
className="ecom-command-hidden-file"
onChange={handleTranslateUpload}
aria-label="上传翻译图片"
/>
<aside className="ecom-watermark-side">
<header className="ecom-quick-set-panel-head ecom-watermark-panel-head">
<strong className="ecom-quick-set-page-title"></strong>
<button type="button" className="ecom-quick-set-back" onClick={closeImageTranslatePage}></button>
<button type="button" className="ecom-quick-set-back" onClick={closeImageTranslatePage}></button>
</header>
<p className="ecom-watermark-intro">AI </p>
<section className="ecom-watermark-panel ecom-translate-lang-panel">
<header>
<strong></strong>
</header>
<select
className="ecom-translate-lang-select"
value={translateLanguage}
onChange={(event) => setTranslateLanguage(event.target.value)}
aria-label="选择目标语言"
>
{translateLanguageOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</section>
<section className="ecom-watermark-panel">
<header>
<strong></strong>
<span>{translateImage ? "已上传" : "待上传"}</span>
</header>
<div
className={`ecom-watermark-upload-card${isTranslateDragging ? " is-dragging" : ""}${translateImage ? " has-image" : ""}`}
role="button"
tabIndex={0}
onClick={() => translateInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
translateInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsTranslateDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsTranslateDragging(false)}
onDrop={handleTranslateDrop}
>
{translateImage ? (
<>
<button
type="button"
className="ecom-watermark-remove"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
removeTranslateImage();
}}
aria-label="删除素材"
>
×
</button>
<figure>
<img src={translateImage.src} alt={translateImage.name} />
</figure>
<div>
<strong>{translateImage.name}</strong>
<span>{translateImage.format || "PNG / JPG / WebP"}</span>
</div>
</>
) : (
<>
<CloudUploadOutlined />
<strong></strong>
<span> PNG / JPG / WebP</span>
</>
)}
</div>
<div className="ecom-watermark-url-row">
<input
ref={translateUrlInputRef}
placeholder="粘贴图片 URL"
aria-label="粘贴图片 URL"
onKeyDown={(event) => {
if (event.key === "Enter") void handleTranslateUrlImport();
}}
/>
<button type="button" onClick={() => void handleTranslateUrlImport()}></button>
</div>
</section>
<section className="ecom-watermark-panel">
<strong></strong>
<p></p>
</section>
<button
type="button"
className="ecom-watermark-primary"
onClick={handleTranslateGenerate}
disabled={!translateImage || translateStatus === "processing"}
>
{translateStatus === "processing" ? <LoadingOutlined /> : <GlobalOutlined />}
{translateStatus === "processing" ? "翻译中" : "开始翻译"}
</button>
</aside>
<section className="ecom-watermark-workspace">
{!translateImage ? (
<div
className={`ecom-watermark-dropzone${isTranslateDragging ? " is-dragging" : ""}`}
role="button"
tabIndex={0}
onClick={() => translateInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
translateInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsTranslateDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsTranslateDragging(false)}
onDrop={handleTranslateDrop}
>
<CloudUploadOutlined />
<strong></strong>
<span> PNG / JPG / WebP</span>
</div>
) : (
<div className="ecom-watermark-grid">
<article className="ecom-watermark-preview-card">
<span></span>
<img src={translateImage.src} alt="原图" />
</article>
<article className="ecom-watermark-preview-card">
<span></span>
{translateStatus === "processing" ? (
<div className="ecom-watermark-processing" role="status" aria-live="polite">
<LoadingOutlined />
<strong></strong>
<em>AI </em>
</div>
) : translateStatus === "done" && translateResultUrl ? (
<>
<img src={translateResultUrl} alt="翻译结果" />
<button type="button" className="ecom-watermark-zoom" aria-label="查看大图">
<QuestionCircleOutlined />
</button>
</>
) : translateStatus === "failed" ? (
<div className="ecom-watermark-empty">
<GlobalOutlined />
<strong></strong>
<em></em>
</div>
) : (
<div className="ecom-watermark-empty">
<GlobalOutlined />
<strong></strong>
<em></em>
</div>
)}
<div className="ecom-watermark-actions">
<button type="button" onClick={() => toast.success("已加入资产库")} disabled={translateStatus !== "done"}>
<FolderOpenOutlined />
</button>
<button type="button" onClick={handleTranslateDownload} disabled={translateStatus !== "done"}>
<CloudUploadOutlined />
</button>
</div>
</article>
</div>
)}
</section>
</main>
);
const openQuickUploadWithKeyboard = (
event: ReactKeyboardEvent<HTMLDivElement>,
inputRef: { current: HTMLInputElement | null },
) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
inputRef.current?.click();
};
const renderQuickUploadThumbs = (items: CloneImageItem[], onRemove: (imageId: string) => void) => (
<div className="ecom-quick-upload-thumbs" aria-label="已上传商品原图">
{items.map((item) => (
<figure key={item.id} className="ecom-command-asset-thumb ecom-quick-upload-thumb">
<img src={item.src} alt={item.name} />
<span className="ecom-command-asset-zoom" aria-hidden="true">
<img src={item.src} alt="" />
</span>
<button
type="button"
aria-label="删除图片"
onClick={(event) => {
event.stopPropagation();
onRemove(item.id);
}}
>
×
</button>
</figure>
))}
</div>
);
const quickDetailVisibleSelect = quickDetailBasicSelects.find((item) => item.key === visibleQuickSetSelect) ?? null;
const quickHotVisibleSelect = quickHotBasicSelects.find((item) => item.key === visibleQuickSetSelect) ?? null;
const quickSetVisibleSelect = quickSetBasicSelects.find((item) => item.key === visibleQuickSetSelect) ?? null;
const quickDetailPreview = (
<main key="quick-detail" className={`ecom-quick-set-page ecom-quick-detail-page ecom-tool-page-enter${isQuickPanelCollapsed ? " is-panel-collapsed" : ""}`} aria-label="A+详情页生成">
<div className="ecom-quick-set-body">
<aside className="ecom-quick-set-panel" aria-label="A+详情页设置" onWheel={handleQuickPanelWheel}>
<header className="ecom-quick-set-panel-head">
<strong className="ecom-quick-set-page-title">A+/</strong>
<button type="button" className="ecom-quick-set-back" onClick={() => setActiveQuickTool(null)}>
</button>
<button type="button" className="ecom-quick-set-back" onClick={() => setActiveQuickTool(null)}>
</button>
</header>
<section>
<strong><CloudUploadOutlined /> </strong>
<div
role="button"
tabIndex={0}
className={`ecom-quick-set-upload${detailProductImages.length ? " has-images" : ""}`}
onClick={() => detailInputRef.current?.click()}
onKeyDown={(event) => openQuickUploadWithKeyboard(event, detailInputRef)}
onDragOver={(event) => event.preventDefault()}
onDrop={handleDetailDrop}
>
<FileImageOutlined />
<span></span>
<em> 3 </em>
<b>+ </b>
{detailProductImages.length ? renderQuickUploadThumbs(detailProductImages, removeDetailImage) : null}
</div>
<input
ref={detailInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleDetailUpload}
aria-label="上传详情页图片"
/>
</section>
<section className="ecom-quick-set-basic-section">
<span className="ecom-quick-set-label"></span>
<div className="ecom-quick-detail-types">
{detailTypeOptions.map((item) => (
<button
key={item}
type="button"
className={detailType === item ? "is-active" : ""}
onClick={() => setDetailType(item)}
>
{item}
</button>
))}
</div>
<span className="ecom-quick-set-label"></span>
<div className="ecom-quick-set-select-anchor">
<div className="ecom-quick-set-selects">
{quickDetailBasicSelects.map((item) => (
<button
key={item.key}
type="button"
className={openQuickSetSelect === item.key ? "is-active" : ""}
onClick={() => toggleQuickSetSelect(item.key)}
>
<span>{item.label}</span><strong>{formatRatioDisplayValue(item.value)}</strong><em></em>
</button>
))}
</div>
{quickDetailVisibleSelect ? (
<div
className={`ecom-quick-set-dropdown ecom-quick-set-dropdown--${quickDetailVisibleSelect.key}${isQuickSetSelectClosing ? " is-closing" : ""}`}
role="listbox"
aria-label={quickDetailVisibleSelect.label}
>
{quickDetailVisibleSelect.options.map((option) => (
<button
key={option}
type="button"
className={quickDetailVisibleSelect.value === option ? "is-active" : ""}
onClick={() => {
quickDetailVisibleSelect.onChange(option);
closeQuickSetSelect();
}}
>
{formatRatioDisplayValue(option)}
</button>
))}
</div>
) : null}
</div>
</section>
<section>
<strong></strong>
<div className="ecom-quick-detail-modules">
{detailModules.map((module) => (
<button
key={module.id}
type="button"
className={selectedDetailModules.includes(module.id) ? "is-active" : ""}
onClick={() => toggleDetailModule(module.id)}
>
<strong>{module.title}</strong>
<span>{module.desc}</span>
</button>
))}
</div>
</section>
<button type="button" className="ecom-quick-set-primary" onClick={handleDetailGenerate} disabled={!canGenerateDetail}>
{detailStatus === "generating" ? <LoadingOutlined /> : "✦"}
</button>
{detailStatus === "generating" ? (
<button type="button" className="ecom-quick-set-primary ecom-quick-set-primary--cancel" onClick={handleCancelGenerate}></button>
) : null}
</aside>
<section className="ecom-quick-set-stage">
<header className="ecom-quick-set-preview-head">
<h1></h1>
<p>AI <span></span> </p>
<div>
<button type="button" onClick={() => setPreviewZoom((value) => Math.max(0.25, value - 0.1))}>-</button>
<strong>{Math.round(previewZoom * 100)}%</strong>
<button type="button" onClick={() => setPreviewZoom((value) => Math.min(2, value + 0.1))}>+</button>
</div>
</header>
<div className="ecom-quick-set-canvas" onWheel={handleQuickPreviewWheel}>
{detailStatus === "done" && detailResultUrl ? (
<section className="ecom-quick-detail-result" style={{ transform: `scale(${previewZoom})` }}>
<img src={detailResultUrl} alt="A+详情页生成结果" />
<button
type="button"
className="ecom-quick-detail-download"
onClick={() => {
const link = document.createElement("a");
link.href = detailResultUrl;
link.download = `A+详情页-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
link.remove();
}}
>
<CloudUploadOutlined />
</button>
</section>
) : detailStatus === "generating" ? (
<section className="ecom-quick-set-generating">
<LoadingOutlined />
<strong> A+ </strong>
<span>AI ...</span>
<div className="ecom-quick-set-progress">
<div className="ecom-quick-set-progress-bar" style={{ width: `${Math.round(detailProgress)}%` }} />
</div>
<em className="ecom-quick-set-progress-text">{Math.round(detailProgress)}%</em>
</section>
) : detailStatus === "failed" ? (
<section className="ecom-quick-set-failed">
<FrownOutlined />
<strong></strong>
<span></span>
<button type="button" onClick={handleDetailGenerate} disabled={!canGenerateDetail}></button>
</section>
) : detailProductImages.length ? (
<section className="ecom-quick-detail-preview-card" style={{ transform: `scale(${previewZoom})` }}>
{detailGridSamples.slice(0, 6).map((src, index) => (
<figure key={src}>
<img src={src} alt={`详情页模块预览 ${index + 1}`} />
<span>{detailModules[index]?.title ?? "详情模块"}</span>
</figure>
))}
</section>
) : (
<section className="ecom-quick-set-empty">
<FileImageOutlined />
<strong></strong>
<span>AI </span>
</section>
)}
</div>
<section className="ecom-quick-set-prompt">
<textarea
value={detailRequirement}
onChange={(event) => setDetailRequirement(event.target.value.slice(0, 500))}
placeholder="建议包含以下信息,产品名称,核心卖点,期望场景,具体参数"
/>
<div className="ecom-quick-detail-prompt-actions">
<button type="button" onClick={handleDetailAiWrite} aria-label="AI帮写">AI</button>
<button type="button" onClick={handleDetailGenerate} disabled={!canGenerateDetail}></button>
</div>
<span>{detailRequirement.length}/500</span>
</section>
</section>
</div>
<button type="button" className="ecom-quick-set-help" aria-label="帮助" onClick={() => toast.info("上传商品图后,选择平台和详情模块即可生成 A+ 详情页。")}>?</button>
</main>
);
const hotClonePreview = (
<main key="quick-hot" className={`ecom-quick-set-page ecom-quick-hot-page ecom-tool-page-enter${isQuickPanelCollapsed ? " is-panel-collapsed" : ""}`} aria-label="爆款复刻生成">
<div className="ecom-quick-set-body">
<aside className="ecom-quick-set-panel" aria-label="爆款复刻设置" onWheel={handleQuickPanelWheel}>
<header className="ecom-quick-set-panel-head">
<strong className="ecom-quick-set-page-title"></strong>
<button type="button" className="ecom-quick-set-back" onClick={closeHotClonePage}>
</button>
<button type="button" className="ecom-quick-set-back" onClick={closeHotClonePage}>
</button>
</header>
<section>
<strong><FileImageOutlined /> </strong>
{productImages.length ? (
<div
role="button"
tabIndex={0}
className={`ecom-quick-set-upload ecom-quick-hot-material has-images${isHotMaterialDragging ? " is-dragging" : ""}`}
onClick={() => hotMaterialInputRef.current?.click()}
onKeyDown={(event) => openQuickUploadWithKeyboard(event, hotMaterialInputRef)}
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); if (event.dataTransfer.types.includes("Files")) setIsHotMaterialDragging(true); }}
onDragLeave={(event) => { event.preventDefault(); event.stopPropagation(); if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) setIsHotMaterialDragging(false); }}
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); setIsHotMaterialDragging(false); const files = Array.from(event.dataTransfer.files); if (files.length) addProductImages(files); }}
>
{renderHotMaterialThumbs(productImages, removeProductImage)}
<button
type="button"
className="ecom-quick-hot-add-btn"
aria-label="添加更多素材"
onClick={(event) => {
event.stopPropagation();
hotMaterialInputRef.current?.click();
}}
>
<PlusOutlined />
</button>
</div>
) : (
<div
role="button"
tabIndex={0}
className={`ecom-quick-set-upload ecom-quick-hot-material${isHotMaterialDragging ? " is-dragging" : ""}`}
onClick={() => hotMaterialInputRef.current?.click()}
onKeyDown={(event) => openQuickUploadWithKeyboard(event, hotMaterialInputRef)}
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); if (event.dataTransfer.types.includes("Files")) setIsHotMaterialDragging(true); }}
onDragLeave={(event) => { event.preventDefault(); event.stopPropagation(); if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) setIsHotMaterialDragging(false); }}
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); setIsHotMaterialDragging(false); const files = Array.from(event.dataTransfer.files); if (files.length) addProductImages(files); }}
>
<FileImageOutlined />
<span></span>
<em> {maxCloneProductImages} </em>
<b>+ </b>
</div>
)}
<input
ref={hotMaterialInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleProductUpload}
aria-label="上传爆款复刻素材"
/>
</section>
<section>
<strong><FireOutlined /> </strong>
<div
role="button"
tabIndex={0}
className={`ecom-quick-set-upload ecom-quick-hot-material ecom-quick-hot-reference${cloneReferenceImages.length ? " has-images" : ""}${isCloneReferenceDragging ? " is-dragging" : ""}`}
onClick={() => cloneReferenceInputRef.current?.click()}
onKeyDown={(event) => openQuickUploadWithKeyboard(event, cloneReferenceInputRef)}
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); if (event.dataTransfer.types.includes("Files")) setIsCloneReferenceDragging(true); }}
onDragLeave={(event) => { event.preventDefault(); event.stopPropagation(); if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) setIsCloneReferenceDragging(false); }}
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); setIsCloneReferenceDragging(false); const files = Array.from(event.dataTransfer.files); if (files.length) addCloneReferenceImages(files); }}
>
<FileImageOutlined />
<span></span>
<em> {maxCloneReferenceImages} </em>
<b>+ </b>
{cloneReferenceImages.length ? (
<>
{renderHotMaterialThumbs(cloneReferenceImages, removeCloneReferenceImage)}
<button
type="button"
className="ecom-quick-hot-add-btn"
aria-label="添加更多参考图"
onClick={(event) => {
event.stopPropagation();
cloneReferenceInputRef.current?.click();
}}
>
<PlusOutlined />
</button>
</>
) : null}
</div>
<input
ref={cloneReferenceInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleCloneReferenceUpload}
aria-label="上传爆款复刻参考图"
/>
</section>
<section>
<strong></strong>
<div className="ecom-quick-detail-modules">
{cloneReplicateLevelOptions.map((option) => (
<button
key={option.key}
type="button"
className={cloneReplicateLevel === option.key ? "is-active" : ""}
onClick={() => setCloneReplicateLevel(option.key)}
>
<strong>{option.title}</strong>
<span>{option.desc}</span>
</button>
))}
</div>
</section>
<section className="ecom-quick-set-basic-section">
<span className="ecom-quick-set-label"></span>
<div className="ecom-quick-set-select-anchor">
<div className="ecom-quick-set-selects">
{quickHotBasicSelects.map((item) => (
<button
key={item.key}
type="button"
className={openQuickSetSelect === item.key ? "is-active" : ""}
onClick={() => toggleQuickSetSelect(item.key)}
>
<span>{item.label}</span><strong>{formatRatioDisplayValue(item.value)}</strong><em></em>
</button>
))}
</div>
{quickHotVisibleSelect ? (
<div
className={`ecom-quick-set-dropdown ecom-quick-set-dropdown--${quickHotVisibleSelect.key}${isQuickSetSelectClosing ? " is-closing" : ""}`}
role="listbox"
aria-label={quickHotVisibleSelect.label}
>
{quickHotVisibleSelect.options.map((option) => (
<button
key={option}
type="button"
className={quickHotVisibleSelect.value === option ? "is-active" : ""}
onClick={() => {
quickHotVisibleSelect.onChange(option);
closeQuickSetSelect();
}}
>
{formatRatioDisplayValue(option)}
</button>
))}
</div>
) : null}
</div>
</section>
<section className="ecom-quick-hot-requirement">
<div className="ecom-quick-hot-requirement__head">
<strong> &amp; </strong>
<button type="button" className="ecom-quick-hot-requirement__ai" onClick={handleHotAiWrite}>AI </button>
</div>
<div className="ecom-quick-hot-requirement__input">
<textarea
value={hotRequirement}
onChange={(event) => setHotRequirement(event.target.value.slice(0, 500))}
placeholder="建议包含以下信息:产品名称、核心卖点、参考风格、期望场景、具体参数"
maxLength={500}
/>
<span>{hotRequirement.length}/500</span>
</div>
</section>
<div className="ecom-quick-hot-actions">
<button type="button" className="ecom-quick-set-primary ecom-quick-hot-generate" onClick={handleHotGenerate} disabled={!canGenerateHot}>
{hotStatus === "generating" ? <LoadingOutlined /> : "✦"}
</button>
<button
type="button"
className={`ecom-quick-set-primary ecom-quick-set-primary--cancel${hotStatus !== "generating" ? " is-disabled" : ""}`}
onClick={hotStatus === "generating" ? handleCancelGenerate : undefined}
disabled={hotStatus !== "generating"}
>
</button>
</div>
</aside>
{hotMaterialHoverZoom && typeof document !== "undefined"
? createPortal(
<div
className={`ecom-hot-material-zoom-portal is-${hotMaterialHoverZoom.placement}`}
style={{ left: hotMaterialHoverZoom.x, top: hotMaterialHoverZoom.y }}
>
<img src={hotMaterialHoverZoom.src} alt="" />
</div>,
document.body,
)
: null}
<section className="ecom-quick-set-stage">
<header className="ecom-quick-set-preview-head">
<h1></h1>
<p>AI <span></span></p>
<div>
<button type="button" onClick={() => setPreviewZoom((value) => Math.max(0.25, value - 0.1))}>-</button>
<strong>{Math.round(previewZoom * 100)}%</strong>
<button type="button" onClick={() => setPreviewZoom((value) => Math.min(2, value + 0.1))}>+</button>
</div>
</header>
<div className="ecom-quick-set-canvas" onWheel={handleQuickPreviewWheel}>
{hotStatus === "done" && hotResultUrl ? (
<section className="ecom-quick-detail-result" style={{ transform: `scale(${previewZoom})` }}>
<img src={hotResultUrl} alt="爆款复刻结果" />
<button
type="button"
className="ecom-quick-detail-download"
onClick={() => {
const link = document.createElement("a");
link.href = hotResultUrl;
link.download = `爆款复刻-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
link.remove();
}}
>
<CloudUploadOutlined />
</button>
</section>
) : hotStatus === "generating" ? (
<section className="ecom-quick-set-generating">
<LoadingOutlined />
<strong></strong>
<span>AI ...</span>
<div className="ecom-quick-set-progress">
<div className="ecom-quick-set-progress-bar" style={{ width: `${Math.round(hotProgress)}%` }} />
</div>
<em className="ecom-quick-set-progress-text">{Math.round(hotProgress)}%</em>
</section>
) : hotStatus === "failed" ? (
<section className="ecom-quick-set-failed">
<FrownOutlined />
<strong></strong>
<span></span>
<button type="button" onClick={handleHotGenerate} disabled={!canGenerateHot}></button>
</section>
) : (
<section className="ecom-quick-set-empty">
<FileImageOutlined />
<strong></strong>
<span>AI </span>
</section>
)}
</div>
</section>
</div>
<button type="button" className="ecom-quick-set-help" aria-label="帮助" onClick={() => toast.info("上传参考图后,选择复刻强度和平台即可生成爆款同款。")}>?</button>
</main>
);
const quickSetGenPreview = (
<main key="quick-set" className={`ecom-quick-set-page ecom-quick-hot-page ecom-tool-page-enter${isQuickPanelCollapsed ? " is-panel-collapsed" : ""}`} aria-label="电商套图生成">
<div className="ecom-quick-set-body">
<aside className="ecom-quick-set-panel" aria-label="电商套图设置" onWheel={handleQuickPanelWheel}>
<header className="ecom-quick-set-panel-head">
<strong className="ecom-quick-set-page-title"></strong>
<button type="button" className="ecom-quick-set-back" onClick={closeQuickSetPage}></button>
<button type="button" className="ecom-quick-set-back" onClick={closeQuickSetPage}></button>
</header>
<section>
<strong><FileImageOutlined /> </strong>
{productImages.length ? (
<div
role="button"
tabIndex={0}
className={`ecom-quick-set-upload ecom-quick-hot-material has-images${isProductUploadDragging ? " is-dragging" : ""}`}
onClick={() => productInputRef.current?.click()}
onKeyDown={(event) => openQuickUploadWithKeyboard(event, productInputRef)}
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); if (event.dataTransfer.types.includes("Files")) setIsProductUploadDragging(true); }}
onDragLeave={(event) => { event.preventDefault(); event.stopPropagation(); if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) setIsProductUploadDragging(false); }}
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); setIsProductUploadDragging(false); const files = Array.from(event.dataTransfer.files); if (files.length) addProductImages(files); }}
>
{renderQuickUploadThumbs(productImages, removeProductImage)}
<button
type="button"
className="ecom-quick-hot-add-btn"
aria-label="添加更多素材"
onClick={(event) => {
event.stopPropagation();
productInputRef.current?.click();
}}
>
<PlusOutlined />
</button>
</div>
) : (
<div
role="button"
tabIndex={0}
className={`ecom-quick-set-upload ecom-quick-hot-material${isProductUploadDragging ? " is-dragging" : ""}`}
onClick={() => productInputRef.current?.click()}
onKeyDown={(event) => openQuickUploadWithKeyboard(event, productInputRef)}
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); if (event.dataTransfer.types.includes("Files")) setIsProductUploadDragging(true); }}
onDragLeave={(event) => { event.preventDefault(); event.stopPropagation(); if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) setIsProductUploadDragging(false); }}
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); setIsProductUploadDragging(false); const files = Array.from(event.dataTransfer.files); if (files.length) addProductImages(files); }}
>
<FileImageOutlined />
<span></span>
<em> 7 </em>
<b>+ </b>
</div>
)}
<input
ref={productInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleProductUpload}
aria-label="上传商品图片"
/>
</section>
<section className="ecom-quick-set-basic-section">
<span className="ecom-quick-set-label"></span>
<div className="ecom-quick-set-select-anchor">
<div className="ecom-quick-set-selects">
{quickSetBasicSelects.map((item) => (
<button
key={item.key}
type="button"
className={openQuickSetSelect === item.key ? "is-active" : ""}
onClick={() => toggleQuickSetSelect(item.key)}
>
<span>{item.label}</span><strong>{formatRatioDisplayValue(item.value)}</strong><em></em>
</button>
))}
</div>
{quickSetVisibleSelect ? (
<div
className={`ecom-quick-set-dropdown ecom-quick-set-dropdown--${quickSetVisibleSelect.key}${isQuickSetSelectClosing ? " is-closing" : ""}`}
role="listbox"
aria-label={quickSetVisibleSelect.label}
>
{quickSetVisibleSelect.options.map((option) => (
<button
key={option}
type="button"
className={quickSetVisibleSelect.value === option ? "is-active" : ""}
onClick={() => {
quickSetVisibleSelect.onChange(option);
closeQuickSetSelect();
}}
>
{formatRatioDisplayValue(option)}
</button>
))}
</div>
) : null}
</div>
</section>
<section className="ecom-quick-set-count-section">
<span className="ecom-quick-set-label"></span>
<p className="ecom-quick-set-hint"> 1-16 </p>
<div className="ecom-quick-set-counts">
{cloneSetCountOptions.map((item) => {
const count = cloneSetCounts[item.key];
const decrementDisabled = count <= 0 || cloneSetTotal <= minCloneSetTotal;
const incrementDisabled = cloneSetTotal >= maxCloneSetTotal;
return (
<div key={item.key} className="ecom-quick-set-count-row">
<div className="ecom-quick-set-count-info">
<strong>{item.title}</strong>
<span>{item.desc}</span>
</div>
<div className="clone-ai-count-stepper" aria-label={`${item.title}数量`}>
<button
type="button"
disabled={decrementDisabled}
onPointerDown={(event) => {
event.preventDefault();
startCloneSetCountHold(item.key, -1, decrementDisabled);
}}
onPointerUp={clearCloneSetCountHold}
onPointerLeave={clearCloneSetCountHold}
onPointerCancel={clearCloneSetCountHold}
onBlur={clearCloneSetCountHold}
aria-label={`减少${item.title}`}
>
-
</button>
<b>{count}</b>
<button
type="button"
disabled={incrementDisabled}
onPointerDown={(event) => {
event.preventDefault();
startCloneSetCountHold(item.key, 1, incrementDisabled);
}}
onPointerUp={clearCloneSetCountHold}
onPointerLeave={clearCloneSetCountHold}
onPointerCancel={clearCloneSetCountHold}
onBlur={clearCloneSetCountHold}
aria-label={`增加${item.title}`}
>
+
</button>
</div>
</div>
);
})}
</div>
</section>
<section className="ecom-quick-hot-requirement">
<div className="ecom-quick-hot-requirement__head">
<strong> &amp; </strong>
<button type="button" className="ecom-quick-hot-requirement__ai" onClick={handleQuickSetAiWrite}>AI </button>
</div>
<div className="ecom-quick-hot-requirement__input">
<textarea
value={quickSetRequirement}
onChange={(event) => setQuickSetRequirement(event.target.value.slice(0, 500))}
placeholder="建议包含以下信息:产品名称、核心卖点、期望场景、具体参数"
maxLength={500}
/>
<span>{quickSetRequirement.length}/500</span>
</div>
</section>
<div className="ecom-quick-hot-actions">
<button type="button" className="ecom-quick-set-primary ecom-quick-hot-generate" onClick={handleQuickSetGenerate} disabled={!canGenerateQuickSet}>
{quickSetStatus === "generating" ? <LoadingOutlined /> : "✦"}
</button>
<button
type="button"
className={`ecom-quick-set-primary ecom-quick-set-primary--cancel${quickSetStatus !== "generating" ? " is-disabled" : ""}`}
onClick={quickSetStatus === "generating" ? handleCancelGenerate : undefined}
disabled={quickSetStatus !== "generating"}
>
</button>
</div>
</aside>
<section className="ecom-quick-set-stage">
<header className="ecom-quick-set-preview-head">
<h1></h1>
<p>AI <span></span> </p>
<div>
<button type="button" onClick={() => setPreviewZoom((value) => Math.max(0.25, value - 0.1))}>-</button>
<strong>{Math.round(previewZoom * 100)}%</strong>
<button type="button" onClick={() => setPreviewZoom((value) => Math.min(2, value + 0.1))}>+</button>
</div>
</header>
<div className="ecom-quick-set-canvas" onWheel={handleQuickPreviewWheel}>
{quickSetStatus === "done" && quickSetResultUrls.length > 0 ? (
<section className="ecom-quick-detail-result" style={{ transform: `scale(${previewZoom})` }}>
<div className="ecom-quick-set-result-grid">
{quickSetResultUrls.map((url, index) => (
<figure key={`quick-set-${index}`}>
<img src={url} alt={`套图 ${index + 1}`} />
<span> {index + 1}</span>
</figure>
))}
</div>
<button
type="button"
className="ecom-quick-detail-download"
onClick={() => {
quickSetResultUrls.forEach((url, index) => {
const link = document.createElement("a");
link.href = url;
link.download = `电商套图-${index + 1}-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
link.remove();
});
}}
>
<CloudUploadOutlined />
</button>
</section>
) : quickSetStatus === "generating" ? (
<section className="ecom-quick-set-generating">
<LoadingOutlined />
<strong></strong>
<span>AI ...</span>
<div className="ecom-quick-set-progress">
<div className="ecom-quick-set-progress-bar" style={{ width: `${Math.round(quickSetProgress)}%` }} />
</div>
<em className="ecom-quick-set-progress-text">{Math.round(quickSetProgress)}%</em>
</section>
) : quickSetStatus === "failed" ? (
<section className="ecom-quick-set-failed">
<FrownOutlined />
<strong></strong>
<span></span>
<button type="button" onClick={handleQuickSetGenerate} disabled={!canGenerateQuickSet}></button>
</section>
) : productImages.length ? (
<section className="ecom-quick-detail-preview-card" style={{ transform: `scale(${previewZoom})` }}>
{detailGridSamples.slice(0, 6).map((src, index) => (
<figure key={src}>
<img src={src} alt={`套图预览 ${index + 1}`} />
<span>{detailModules[index]?.title ?? "套图模块"}</span>
</figure>
))}
</section>
) : (
<section className="ecom-quick-set-empty">
<FileImageOutlined />
<strong></strong>
<span>AI </span>
</section>
)}
</div>
</section>
</div>
<button type="button" className="ecom-quick-set-help" aria-label="帮助" onClick={() => toast.info("上传商品图后,选择平台和套图数量即可生成电商套图。")}>?</button>
</main>
);
const detailPreview = (
<main className="product-clone-preview product-clone-preview--detail" aria-label="A+详情预览" onWheel={handlePreviewWheel}>
<div className="product-clone-preview__headline">
<h1>A+/</h1>
<p>
AI <span></span>
</p>
</div>
<section className="product-detail-demo-board">
<div className="product-detail-source-stack">
{detailSourcePreviewImages.map((src, index) => (
<figure key={`${src}-${index}`}>
<img src={src} alt={`商品原图 ${index + 1}`} />
</figure>
))}
<span></span>
</div>
<div className="product-detail-flow-arrow" aria-hidden="true" />
<div className="product-detail-long-result">
<img src={detailResultUrl ?? detailAssets.longPage} alt="生成电商长图" />
<span>{detailStatus === "done" ? "已生成电商长图" : "生成电商长图"}</span>
</div>
<div className="product-detail-grid-result">
{detailGridSamples.map((src, index) => (
<img key={src} src={src} alt={`详情页模块 ${index + 1}`} />
))}
<span></span>
</div>
</section>
<button type="button" className="product-clone-help" aria-label="帮助">
<QuestionCircleOutlined />
</button>
</main>
);
const tryOnPreview = (
<main className="product-clone-preview product-clone-preview--try-on" aria-label="服饰穿搭预览" onWheel={handlePreviewWheel}>
<div className="product-clone-preview__headline">
<h1>AI服饰穿搭</h1>
<p>姿</p>
</div>
{tryOnResultImages.length ? (
<section className="product-try-on-generated" aria-label="生成结果">
{tryOnResultImages.map((src, index) => (
<figure key={src}>
<img src={src} alt={`生成结果 ${index + 1}`} />
<figcaption>{selectedScenes[index % Math.max(selectedScenes.length, 1)] || "智能场景"}</figcaption>
</figure>
))}
</section>
) : null}
<section className="product-try-on-demo-board">
{tryOnCards.map((card) => (
<article key={card.title} className={`product-try-on-card product-try-on-card--${card.tone}`}>
<h2>{card.title}</h2>
<div className="product-try-on-inputs">
{card.inputs.map((src, index) => (
<div className="product-try-on-input-group" key={`${card.title}-${src}`}>
<img src={src} alt={`${card.title} 输入 ${index + 1}`} />
{index < card.inputs.length - 1 ? <span className="product-try-on-plus">+</span> : null}
</div>
))}
</div>
<div className="product-try-on-arrow" aria-hidden="true" />
<div className="product-try-on-results">
{card.results.map((src, index) => (
<img key={src} src={src} alt={`${card.title} 示例 ${index + 1}`} />
))}
</div>
</article>
))}
</section>
<button type="button" className="product-clone-help" aria-label="帮助">
<QuestionCircleOutlined />
</button>
</main>
);
const placeholderPreview = (
<main className="product-clone-preview product-clone-preview--placeholder" aria-label={`${pageLabel}预览`}>
<div className="product-clone-preview__headline">
<h1>{pageLabel}</h1>
<p></p>
</div>
<button type="button" className="product-clone-help" aria-label="帮助">
<QuestionCircleOutlined />
</button>
</main>
);
const copywritingPreview = (
<div key="copywriting" className="ecom-quick-page-wrap ecom-tool-page-enter">
<EcommerceCopywritingPanel onClose={closeCopywritingPage} />
</div>
);
const oneClickVideoPreview = (
<div key="oneClickVideo" className="ecom-quick-page-wrap ecom-tool-page-enter">
<EcommerceOneClickVideoPanel
onClose={closeOneClickVideoPage}
isAuthenticated={isAuthenticated}
onRequestLogin={requestLogin}
productImages={productImages}
productInputRef={productInputRef}
isProductUploadDragging={isProductUploadDragging}
setIsProductUploadDragging={setIsProductUploadDragging}
handleProductDrop={handleProductDrop}
handleProductUpload={handleProductUpload}
removeProductImage={removeProductImage}
maxProductImages={maxCloneProductImages}
requirement={requirement}
onRequirementChange={setRequirement}
platform={platform}
platformOptions={platformOptions}
onPlatformChange={handleOneClickVideoPlatformChange}
ratio={ratio}
ratioOptions={getPlatformRatioOptions(platform, "video")}
onRatioChange={setRatio}
videoQuality={cloneVideoQuality}
videoQualityOptions={cloneVideoQualityOptions}
onVideoQualityChange={setCloneVideoQuality}
videoDuration={cloneVideoDuration}
videoDurationMin={cloneVideoDurationMin}
videoDurationMax={cloneVideoDurationMax}
onVideoDurationChange={setCloneVideoDuration}
videoSmart={cloneVideoSmart}
onVideoSmartChange={setCloneVideoSmart}
onOpenHistory={() => setVideoHistoryVisible(true)}
/>
</div>
);
const activePreview = isSetTool
? setPreview
: isDetail
? detailPreview
: isTryOn
? tryOnPreview
: isCloneTool
? isWatermarkTool
? watermarkPreview
: isTranslateTool
? translatePreview
: isImageEditTool
? imageWorkbenchPreview
: isSmartCutoutTool
? smartCutoutPreview
: isQuickDetailTool
? (
<div key={`quick-${activeQuickTool}`} className="ecom-quick-page-wrap ecom-tool-page-enter">
{quickDetailPreview}
</div>
)
: isHotCloneTool
? (
<div key={`quick-${activeQuickTool}`} className="ecom-quick-page-wrap ecom-tool-page-enter">
{hotClonePreview}
</div>
)
: isQuickSetTool
? (
<div key={`quick-${activeQuickTool}`} className="ecom-quick-page-wrap ecom-tool-page-enter">
{quickSetGenPreview}
</div>
)
: isCopywritingTool
? copywritingPreview
: isOneClickVideoTool
? oneClickVideoPreview
: clonePreview
: placeholderPreview;
const isMainCloneWorkspace = isCloneTool && !isSmartCutoutTool && !isQuickDetailTool && !isWatermarkTool && !isTranslateTool && !isImageEditTool && !isQuickSetTool && !isCopywritingTool && !isOneClickVideoTool;
const isRecordDetailWorkspace = isMainCloneWorkspace && Boolean(activeHistoryRecordId);
const currentResultCount = canvasNodes.reduce((count, node) => count + node.results.length, 0);
const activeHistoryRecord = activeHistoryRecordId ? ecommerceHistoryRecords.find((record) => record.id === activeHistoryRecordId) : null;
const activeConversationTurns = activeHistoryRecord
? activeHistoryRecord.turns?.length
? activeHistoryRecord.turns
: [buildHistoryTurnFromRecord(activeHistoryRecord)]
: [];
const getHistoryTurnSettingLabel = (turn: EcommerceHistoryTurn) => {
if (turn.settingLabel) return turn.settingLabel;
if (turn.output === "set" && turn.results?.length && !turn.setResultImages?.length) {
return `单图 ${turn.results.length}`;
}
if (turn.output === "set") {
const total = cloneSetCountKeys.reduce((sum, key) => sum + (turn.setCounts?.[key] ?? 0), 0);
return `套图 ${total || 1}`;
}
if (turn.output === "detail") return `详情 ${turn.detailModules?.length || 1}`;
if (turn.output === "model") return `模特 ${turn.modelScenes?.length || 1}`;
return cloneOutputOptions.find((option) => option.key === turn.output)?.label || selectedCloneOutput.label;
};
const restoreHistoryTurnInputs = (turn: EcommerceHistoryTurn) => {
setCloneOutput(turn.output);
setPlatform(turn.platform);
setMarket(turn.market);
setLanguage(turn.language);
setRatio(turn.ratio);
setRequirement(turn.requirement);
setProductImages(turn.productImages);
setCloneSetCounts(turn.setCounts);
setSelectedCloneDetailModules(turn.detailModules.slice(0, maxDetailModuleSelection));
setSelectedCloneModelScenes(turn.modelScenes);
setCloneReferenceImages(turn.referenceImages);
setCloneReplicateLevel(turn.replicateLevel);
toast.info("已恢复该轮参数,可继续发送");
};
return (
<section
className={`product-clone-page page-motion${isCloneTool && isCloneSettingsCollapsed ? " is-settings-collapsed" : ""}${isCloneTool && isCommandHistoryCollapsed ? " is-history-collapsed" : ""}${isRecordDetailWorkspace && isCloneConversationCollapsed ? " is-conversation-collapsed" : ""}${isRecordDetailWorkspace ? " is-history-detail" : ""}${isSmartCutoutTool ? " is-smart-cutout-page" : ""}${isQuickDetailTool ? " is-quick-set-page" : ""}${isWatermarkTool ? " is-watermark-page" : ""}${isTranslateTool ? " is-translate-page" : ""}${isImageEditTool ? " is-image-workbench-page" : ""}${isHotCloneTool ? " is-hot-clone-page" : ""}${isQuickSetTool ? " is-quick-set-page" : ""}${isCopywritingTool ? " is-copywriting-page" : ""}${isOneClickVideoTool ? " is-one-click-video-page" : ""}`}
data-tool={activeTool}
aria-label={pageLabel}
>
<div className="product-clone-shell">
<aside className="product-clone-rail" aria-label="商品工具">
{sideTools.map((tool) => (
<button key={tool.key} type="button" className={activeTool === tool.key ? "is-active" : ""} onClick={() => setActiveTool(tool.key)}>
{tool.icon}
<span>{tool.label}</span>
</button>
))}
</aside>
<aside
id={isCloneTool ? "ecommerce-clone-settings-panel" : undefined}
className={`product-clone-panel tool-panel-enter`}
key={activeTool}
aria-label={`${pageLabel}参数`}
aria-hidden={isCloneTool && isCloneSettingsCollapsed ? true : undefined}
>
{isSetTool ? setPanel : isDetail ? detailPanel : isTryOn ? tryOnPanel : isCopywritingTool ? placeholderPanel : isCloneTool ? clonePanel : placeholderPanel}
</aside>
{isCloneTool && !isSmartCutoutTool && !isQuickDetailTool && !isWatermarkTool && !isTranslateTool && !isImageEditTool && !isHotCloneTool && !isQuickSetTool && !isCopywritingTool ? (
<button
type="button"
className="clone-ai-settings-toggle"
onClick={() => setIsCloneSettingsCollapsed((current) => !current)}
aria-label={isCloneSettingsCollapsed ? "展开设置面板" : "收起设置面板"}
aria-controls="ecommerce-clone-settings-panel"
aria-expanded={!isCloneSettingsCollapsed}
title={isCloneSettingsCollapsed ? "展开设置面板" : "收起设置面板"}
>
{isCloneSettingsCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</button>
) : null}
{isRecordDetailWorkspace ? (
<>
<aside className="clone-ai-conversation-panel" aria-label="AI 对话">
<header className="clone-ai-conversation-head">
<div>
<strong>{activeHistoryRecord?.title || "生成详情"}</strong>
<span>{selectedCloneOutput.label} · {platform} · {language}</span>
</div>
<button
type="button"
onClick={() => setIsCloneConversationCollapsed(true)}
aria-label="收起对话"
title="收起对话"
>
<MenuFoldOutlined />
</button>
</header>
<div className="clone-ai-conversation-body">
{activeConversationTurns.map((turn, index) => {
const turnResults = getTurnResults(turn);
const outputLabel = turn.modeLabel || cloneOutputOptions.find((option) => option.key === turn.output)?.label || selectedCloneOutput.label;
const turnMeta = [
{ label: "平台", value: turn.platform },
{ label: "语种", value: turn.language },
{ label: "比例", value: formatRatioDisplayValue(turn.ratio) },
{ label: "设置", value: getHistoryTurnSettingLabel(turn) },
];
const isCurrentGeneratingTurn = turn.status === "generating" && turn.id === activeHistoryTurnIdRef.current;
return (
<Fragment key={turn.id}>
<section className={`clone-ai-chat-message clone-ai-chat-message--user${index > 0 ? " clone-ai-chat-message--followup" : ""}`}>
<span>{index === 0 ? "需求" : `继续生成 ${index + 1} · ${outputLabel}`}</span>
<p>{turn.requirement?.trim() || "上传商品素材,描述你想生成的商品图、详情图、模特图或短视频。"}</p>
<div className="clone-ai-chat-meta" aria-label="需求参数">
{turnMeta.map((item) => (
<em key={item.label}>
<span>{item.label}</span>
<strong>{item.value}</strong>
</em>
))}
</div>
{turn.productImages.length ? (
<div className="clone-ai-chat-assets" aria-label="已上传素材">
{turn.productImages.slice(0, 4).map((image) => (
<img key={image.id} src={image.src} alt={image.name || "商品素材"} />
))}
{turn.productImages.length > 4 ? <em>+{turn.productImages.length - 4}</em> : null}
</div>
) : null}
</section>
<section className={`clone-ai-chat-message clone-ai-chat-message--assistant is-${turn.status}`}>
<span></span>
<p>
{turn.status === "done" || turnResults.length
? `已生成 ${turnResults.length}${outputLabel},已同步到中间画布,可拖拽、缩放和预览。`
: turn.status === "generating"
? `正在为 ${turn.platform} / ${turn.market} 生成${outputLabel},完成后会自动追加到画布。`
: turn.errorMessage || "生成失败,请检查网络或参数后重试。"}
</p>
{isCurrentGeneratingTurn ? (
<EcommerceProgressBar status="generating" progress={generationProgress} onCancel={handleCancelGenerate} label={`${outputLabel}生成`} />
) : null}
{turn.status === "failed" ? (
<button type="button" className="clone-ai-retry-btn" onClick={() => restoreHistoryTurnInputs(turn)}>
<ReloadOutlined />
</button>
) : null}
{turnResults.length ? (
<div className="clone-ai-chat-results" aria-label="生成结果缩略图">
{turnResults.slice(0, 6).map((item) => (
<button
key={item.id}
type="button"
onClick={() => openProductSetPreview(item)}
aria-label={`预览${item.label}`}
>
<img src={item.src} alt={item.label} />
</button>
))}
</div>
) : null}
</section>
</Fragment>
);
})}
</div>
</aside>
<button
type="button"
className="clone-ai-conversation-toggle"
onClick={() => setIsCloneConversationCollapsed((current) => !current)}
aria-label={isCloneConversationCollapsed ? "展开对话" : "收起对话"}
title={isCloneConversationCollapsed ? "展开对话" : "收起对话"}
aria-expanded={!isCloneConversationCollapsed}
>
{isCloneConversationCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</button>
</>
) : null}
{activePreview}
</div>
<CommandHistorySidebar
collapsed={isCommandHistoryCollapsed}
showBackdrop={isCloneTool && !isCommandHistoryCollapsed}
records={ecommerceHistoryRecords}
activeRecordId={activeHistoryRecordId}
isRefreshing={isHistoryRefreshing}
refreshMessage={historyRefreshMessage}
refreshStamp={historyRefreshStamp}
refreshTick={historyRefreshTick}
outputLabels={cloneOutputOptions}
formatHistoryTime={formatHistoryTime}
onToggleCollapsed={() => setIsCommandHistoryCollapsed((current) => !current)}
onCollapse={() => setIsCommandHistoryCollapsed(true)}
onNewConversation={handleNewEcommerceConversation}
onRefresh={refreshEcommerceHistoryFromServer}
onOpenRecord={openEcommerceHistoryRecord}
onDeleteRecord={deleteHistoryRecord}
/>
<ProductSetPreviewModal
preview={selectedProductSetPreview}
onClose={() => setSelectedProductSetPreview(null)}
onDownload={(preview) => {
void handleDownloadCanvasResult(preview);
}}
onRemove={removeSelectedProductSetPreview}
/>
<ProductSetHostingModal visible={showHostingModal} onClose={() => setShowHostingModal(false)} />
<EcommerceVideoHistoryPanel
visible={videoHistoryVisible}
onClose={() => setVideoHistoryVisible(false)}
/>
</section>
);
}
export default ProductClonePage;