diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index c8f1260..e2dffe5 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -1,4 +1,4 @@ -import { +import { AppstoreOutlined, ClearOutlined, CloudUploadOutlined, @@ -16,7 +16,6 @@ import { MenuFoldOutlined, MenuUnfoldOutlined, PaperClipOutlined, - PlusOutlined, QuestionCircleOutlined, ReloadOutlined, ScissorOutlined, @@ -41,36 +40,6 @@ import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel"; import EcommerceClonePanel from "./panels/EcommerceClonePanel"; import { ecommerceOssScopes, saveUnifiedEcommerceGenerationRecord, deleteEcommerceGenerationRecord } from "./ecommerceGenerationPersistence"; import { downloadResultAsset } from "../workbench/workbenchDownload"; -import { clampNumber, normalizeHexColor, hexToRgb, rgbToHex, parseSmartCutoutAspect, parseSmartCutoutPercent, hsvToRgb, hexToHsv } from "./utils/colorUtils"; -import { normalizeRatioToken, quickSetRatioOptions, getQuickSetRatioValue, formatRatioDisplayValue, getRatioDisplayParts, parseRatioToAspectCss, supportedImageApiRatios, toSupportedImageApiRatio, normalizeRatioForApi, greatestCommonDivisor, formatAspectRatio } from "./utils/ratioUtils"; -import { - defaultCloneOutput, - defaultEcommercePlatform, - defaultProductSetOutput, - formatUploadedImageRatio, - getPlatformDefaultLanguage, - getPlatformDefaultRatio, - getPlatformLanguageOptions, - getPlatformRatioOptions, - getUniqueRatioOptions, - marketLanguageOptions, - marketOptions, - normalizeLanguageForPlatform, - normalizeMarket, - normalizePlatform, - normalizeRatioForPlatform, - platformOptions, - type CloneOutputKey, - type ProductSetOutputKey, -} from "./utils/platformRules"; -import { type CloneSetCountKey, type CloneModelPanelTab, type CloneVideoQualityKey, type CloneReferenceMode, type CloneReplicateLevelKey, type CloneImageItem, type CloneResult, type CloneSavedSetting, type EcommerceHistoryRecord, defaultCloneSetCounts, defaultCloneDetailModuleIds, cloneLatestSettingStorageKey, ecommerceHistoryStorageKey, isCloneSavedSetting, isCloneImageItem, isCloneResult, isEcommerceHistoryRecord, readCloneLatestSetting, writeCloneLatestSetting, clearCloneLatestSetting, readEcommerceHistoryRecords, writeEcommerceHistoryRecords, removeFilePayloadFromImages, normalizeEcommerceHistoryRecord } from "./utils/clonePersistence"; -import { - buildEcommerceImagePrompt, - buildSetSubPrompt, - setCountLabels, - type EcommerceImagePromptOptions, -} from "./utils/promptBuilder"; -import { aiGenerationClient } from "../../api/aiGenerationClient"; const smartCutoutColorPresets = [ "#ffffff", @@ -197,6 +166,92 @@ const buildInspirationPrompt = (title: string, meta: string): string => { return points.length ? `${base}。风格要点:${points.join("、")}。` : `${base}。`; }; +const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + +const normalizeHexColor = (value: string) => { + const clean = value.trim().replace(/^#/, ""); + if (!/^[0-9a-fA-F]{6}$/.test(clean)) return null; + return `#${clean.toLowerCase()}`; +}; + +const hexToRgb = (value: string) => { + const normalized = normalizeHexColor(value); + if (!normalized) return null; + const numeric = Number.parseInt(normalized.slice(1), 16); + return { + r: (numeric >> 16) & 255, + g: (numeric >> 8) & 255, + b: numeric & 255, + }; +}; + +const rgbToHex = (r: number, g: number, b: number) => + `#${[r, g, b].map((item) => clampNumber(Math.round(item), 0, 255).toString(16).padStart(2, "0")).join("")}`; + +const parseSmartCutoutAspect = (aspect: string) => { + const match = aspect.match(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/); + if (!match) return null; + const width = Number(match[1]); + const height = Number(match[2]); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null; + return width / height; +}; + +const parseSmartCutoutPercent = (value: string, fallback: number) => { + const numeric = Number(value.replace("%", "")); + if (!Number.isFinite(numeric)) return fallback; + return clampNumber(numeric / 100, 0.05, 1); +}; + +const hsvToRgb = (h: number, s: number, v: number) => { + const hue = ((h % 360) + 360) % 360; + const saturation = clampNumber(s, 0, 100) / 100; + const value = clampNumber(v, 0, 100) / 100; + const chroma = value * saturation; + const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1)); + const match = value - chroma; + const [red, green, blue] = + hue < 60 + ? [chroma, x, 0] + : hue < 120 + ? [x, chroma, 0] + : hue < 180 + ? [0, chroma, x] + : hue < 240 + ? [0, x, chroma] + : hue < 300 + ? [x, 0, chroma] + : [chroma, 0, x]; + return { + r: (red + match) * 255, + g: (green + match) * 255, + b: (blue + match) * 255, + }; +}; + +const hexToHsv = (value: string) => { + const rgb = hexToRgb(value) ?? { r: 255, g: 255, b: 255 }; + const red = rgb.r / 255; + const green = rgb.g / 255; + const blue = rgb.b / 255; + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + const delta = max - min; + const hue = + delta === 0 + ? 0 + : max === red + ? 60 * (((green - blue) / delta) % 6) + : max === green + ? 60 * ((blue - red) / delta + 2) + : 60 * ((red - green) / delta + 4); + return { + h: Math.round((hue + 360) % 360), + s: max === 0 ? 0 : Math.round((delta / max) * 100), + v: Math.round(max * 100), + }; +}; +import { aiGenerationClient } from "../../api/aiGenerationClient"; import { ServerRequestError } from "../../api/serverConnection"; import { waitForTask } from "../../api/taskSubscription"; import { toast } from "../../components/toast/toastStore"; @@ -213,16 +268,42 @@ interface ProductClonePageProps { [key: string]: unknown; } +type ProductCloneStatus = "idle" | "ready" | "generating" | "done" | "failed"; +type ProductSetOutputKey = "set" | "detail" | "model" | "video"; +type CloneOutputKey = ProductSetOutputKey | "hot"; +type CloneSetCountKey = "selling" | "white" | "scene"; +type CloneModelPanelTab = "scene" | "model"; +type CloneVideoQualityKey = "standard" | "high" | "ultra"; type ProductSetStatus = "idle" | "ready" | "generating" | "done" | "failed"; type ProductKitToolKey = "set" | "detail" | "wear" | "clone"; type ComposerMenuKey = "mode" | "platform" | "language" | "ratio" | "settings"; type CloneBasicSelectKey = "platform" | "market" | "language" | "ratio"; type CloneModelSelectKey = "gender" | "age" | "ethnicity" | "body"; -type ProductCloneStatus = "idle" | "ready" | "generating" | "done" | "failed"; +type CloneReferenceMode = "upload" | "link"; +type CloneReplicateLevelKey = "style" | "high"; type TryOnModelSource = "ai" | "library"; type TryOnStatus = "idle" | "modeling" | "ready" | "generating" | "done" | "failed"; type DetailStatus = "idle" | "ready" | "generating" | "done" | "failed"; +interface CloneImageItem { + id: string; + src: string; + name: string; + file?: File; + width?: number; + height?: number; + format?: string; + mimeType?: string; + ossKey?: string; +} + +interface CloneResult { + id: string; + src: string; + label: string; + type?: "image" | "video"; +} + interface CanvasNode { id: string; mode: string; @@ -233,6 +314,67 @@ interface CanvasNode { y: number; } +interface PreviewTouchPoint { + id: number; + x: number; + y: number; +} + +interface PreviewTouchGesture { + mode: "none" | "pan" | "pinch"; + points: PreviewTouchPoint[]; + startOffset: { x: number; y: number }; + startZoom: number; + startDistance: number; + startCenter: { x: number; y: number }; +} + +interface CloneSavedSetting { + id: string; + name: string; + savedAt: string; + output: CloneOutputKey; + platform: string; + market: string; + language: string; + ratio: string; + setCounts: Record; + detailModules: string[]; + modelPanelTab: CloneModelPanelTab; + modelScenes: string[]; + modelCustomScene: string; + modelGender: string; + modelAge: string; + modelEthnicity: string; + modelBody: string; + modelAppearance: string; + videoQuality: CloneVideoQualityKey; + videoDurationSeconds: number; + videoSmart: boolean; + referenceMode?: CloneReferenceMode; + replicateLevel?: CloneReplicateLevelKey; + requirement: string; +} + +interface EcommerceHistoryRecord { + id: string; + title: string; + createdAt: number; + output: CloneOutputKey; + platform: string; + market: string; + language: string; + ratio: string; + requirement: string; + productImages: CloneImageItem[]; + results: CloneResult[]; + setResultImages: string[]; + setCounts: Record; + detailModules: string[]; + modelScenes: string[]; + referenceImages: CloneImageItem[]; + replicateLevel: CloneReplicateLevelKey; +} interface ProductSetPreviewSelection { src: string; @@ -242,6 +384,25 @@ interface ProductSetPreviewSelection { removable?: boolean; } +interface EcommerceImagePromptOptions { + gender?: string; + age?: string; + ethnicity?: string; + body?: string; + appearance?: string; + scenes?: string[]; + customScene?: string; + smartScene?: boolean; + detailModules?: string[]; +} + +type PlatformRatioModeKey = ProductSetOutputKey | "hot"; + +interface PlatformRatioGroup { + ratios: string[]; + defaultRatio: string; +} + const sideTools: Array<{ key: ProductKitToolKey; label: string; icon: ReactNode }> = [ { key: "set", label: "商品套图", icon: }, { key: "detail", label: "A+详情", icon: }, @@ -249,43 +410,358 @@ const sideTools: Array<{ key: ProductKitToolKey; label: string; icon: ReactNode { key: "clone", label: "电商AI作图", icon: }, ]; +const platformSpecOptions: Array<{ + label: string; + ratios: string[]; + defaultRatio: string; + ratioGroups?: Partial>; + specs: string[]; + tip?: string; + aliases?: string[]; +}> = [ + { + label: "淘宝/天猫", + ratios: ["淘宝主图 / SKU 图 800×800px", "详情页宽 750px", "详情页宽 790px"], + defaultRatio: "淘宝主图 / SKU 图 800×800px", + ratioGroups: { + set: { + ratios: ["1000×1000px\u00a0\u00a0\u00a01:1", "800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "1000×1000px\u00a0\u00a0\u00a01:1", + }, + detail: { + ratios: [ + "750×1000px\u00a0\u00a0\u00a03:4", + "790×1053px\u00a0\u00a0\u00a03:4", + "750×1125px\u00a0\u00a0\u00a02:3", + "790×1185px\u00a0\u00a0\u00a02:3", + ], + defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", + }, + model: { + ratios: ["750×1000px\u00a0\u00a0\u00a03:4"], + defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", + }, + video: { + ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1080×1440px\u00a0\u00a0\u00a03:4", "1080×1080px\u00a0\u00a0\u00a01:1"], + defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", + }, + hot: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + }, + specs: ["主图 / SKU 图 800×800px,≤3MB", "详情页宽 750px 或 790px,单张高度≤1546px"], + tip: "建议主图 200-400KB JPG,超过 500KB 会影响加载速度。", + }, + { + label: "京东", + ratios: ["京东主图 / SKU 图 800×800px", "详情页宽 750px", "首图主体占比 ≥80%"], + defaultRatio: "京东主图 / SKU 图 800×800px", + ratioGroups: { + set: { + ratios: ["1000×1000px\u00a0\u00a0\u00a01:1"], + defaultRatio: "1000×1000px\u00a0\u00a0\u00a01:1", + }, + detail: { + ratios: [ + "750×1000px\u00a0\u00a0\u00a03:4", + "990×1320px\u00a0\u00a0\u00a03:4", + "750×1125px\u00a0\u00a0\u00a02:3", + "990×1485px\u00a0\u00a0\u00a02:3", + ], + defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", + }, + model: { + ratios: ["750×1125px\u00a0\u00a0\u00a02:3", "990×1485px\u00a0\u00a0\u00a02:3"], + defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3", + }, + video: { + ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1920×1080px\u00a0\u00a0\u00a016:9"], + defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", + }, + hot: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + }, + specs: ["主图 / SKU 图 800×800px,白底,≤3MB", "详情页宽 750px,首图主体占比 ≥80%"], + }, + { + label: "拼多多", + ratios: ["主图 750×352px", "主图 800×800px", "详情页宽 750px"], + defaultRatio: "主图 750×352px", + ratioGroups: { + set: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1", "750×1000px\u00a0\u00a0\u00a03:4"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + detail: { + ratios: ["750×1000px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3"], + defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", + }, + model: { + ratios: ["750×1000px\u00a0\u00a0\u00a03:4"], + defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", + }, + video: { + ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"], + defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", + }, + hot: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + }, + specs: ["主图 750×352px 或 800×800px,≤1MB", "详情页宽 750px,要求纯白底、无水印、无拼接"], + }, + { + label: "抖音电商", + ratios: ["短视频1080×1920px"], + defaultRatio: "短视频1080×1920px", + ratioGroups: { + video: { + ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"], + defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", + }, + hot: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + }, + specs: ["短视频 1080×1920px,9:16", "30s 内最佳"], + }, + { + label: "亚马逊 Amazon", + ratios: ["主图 ≥1600×1600px", "建议 2000×2000px+", "最小 500×500px"], + defaultRatio: "主图 ≥1600×1600px", + ratioGroups: { + set: { + ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"], + defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1", + }, + detail: { + ratios: ["1600×1600px\u00a0\u00a0\u00a01:1", "1200×1800px\u00a0\u00a0\u00a02:3", "1200×1600px\u00a0\u00a0\u00a03:4"], + defaultRatio: "1200×1800px\u00a0\u00a0\u00a02:3", + }, + model: { + ratios: ["1200×1800px\u00a0\u00a0\u00a02:3"], + defaultRatio: "1200×1800px\u00a0\u00a0\u00a02:3", + }, + video: { + ratios: ["1920×1080px\u00a0\u00a0\u00a016:9"], + defaultRatio: "1920×1080px\u00a0\u00a0\u00a016:9", + }, + hot: { + ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"], + defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1", + }, + }, + specs: ["主图 1600×1600px+,纯白底,≤10MB", "最小 500×500px,建议 2000px+ 以支持缩放"], + aliases: ["亚马逊"], + }, + { + label: "Shopee", + ratios: ["商品主图 1024×1024px", "基础主图 800×800px"], + defaultRatio: "商品主图 1024×1024px", + ratioGroups: { + set: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + detail: { + ratios: ["750×1000px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3"], + defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", + }, + model: { + ratios: ["750×1000px\u00a0\u00a0\u00a03:4"], + defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", + }, + video: { + ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"], + defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", + }, + hot: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + }, + specs: ["商品主图推荐 1024×1024px,基础 800×800px", "≤2MB,白底或浅色底"], + aliases: ["虾皮 Shopee/Lazada", "虾皮"], + }, + { + label: "Lazada", + ratios: ["商品主图 800×800px"], + defaultRatio: "商品主图 800×800px", + ratioGroups: { + set: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + detail: { + ratios: ["750×1000px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3"], + defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", + }, + model: { + ratios: ["750×1000px\u00a0\u00a0\u00a03:4"], + defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", + }, + video: { + ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"], + defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", + }, + hot: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + }, + specs: ["商品主图 800×800px,1:1"], + }, + { + label: "Instagram", + ratios: ["帖子 1080×1350px", "帖子 1080×1080px", "Stories / Reels 1080×1920px", "头像 320×320px"], + defaultRatio: "帖子 1080×1350px", + ratioGroups: { + set: { + ratios: ["1080×1080px\u00a0\u00a0\u00a01:1", "1080×1350px\u00a0\u00a0\u00a04:5"], + defaultRatio: "1080×1080px\u00a0\u00a0\u00a01:1", + }, + detail: { + ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"], + defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5", + }, + model: { + ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"], + defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5", + }, + video: { + ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1080×1350px\u00a0\u00a0\u00a04:5"], + defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", + }, + }, + specs: ["帖子 1080×1350px 或 1080×1080px", "Stories / Reels 封面 1080×1920px,头像 320×320px"], + tip: "建议 ≤8MB JPG。", + aliases: ["Instagram Reels"], + }, + { + label: "速卖通", + ratios: ["主图 800×800px", "主图 1000×1000px+"], + defaultRatio: "主图 800×800px", + ratioGroups: { + set: { + ratios: ["1000×1000px\u00a0\u00a0\u00a01:1"], + defaultRatio: "1000×1000px\u00a0\u00a0\u00a01:1", + }, + detail: { + ratios: ["750×1125px\u00a0\u00a0\u00a02:3", "750×1000px\u00a0\u00a0\u00a03:4"], + defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3", + }, + model: { + ratios: ["750×1125px\u00a0\u00a0\u00a02:3"], + defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3", + }, + video: { + ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1920×1080px\u00a0\u00a0\u00a016:9"], + defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", + }, + hot: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + }, + specs: ["主图建议 800×800px 或更高,1:1", "适合跨境电商主图、SKU 图和场景图"], + }, + { + label: "eBay", + ratios: ["商品图1:1", "白底多角度展示图 1:1"], + defaultRatio: "商品图1:1", + ratioGroups: { + set: { + ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"], + defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1", + }, + detail: { + ratios: ["1000×1500px\u00a0\u00a0\u00a02:3", "1200×1600px\u00a0\u00a0\u00a03:4"], + defaultRatio: "1000×1500px\u00a0\u00a0\u00a02:3", + }, + model: { + ratios: ["1000×1500px\u00a0\u00a0\u00a02:3"], + defaultRatio: "1000×1500px\u00a0\u00a0\u00a02:3", + }, + video: { + ratios: ["1920×1080px\u00a0\u00a0\u00a016:9", "1080×1920px\u00a0\u00a0\u00a09:16"], + defaultRatio: "1920×1080px\u00a0\u00a0\u00a016:9", + }, + hot: { + ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"], + defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1", + }, + }, + specs: ["商品图建议 1:1,主体清晰居中", "适合白底主图和多角度展示图"], + }, + { + label: "TikTok Shop", + ratios: ["商品主图 1:1", "短视频/ 竖版封面 9:16"], + defaultRatio: "商品主图 1:1", + ratioGroups: { + set: { + ratios: ["1280×1280px\u00a0\u00a0\u00a01:1"], + defaultRatio: "1280×1280px\u00a0\u00a0\u00a01:1", + }, + detail: { + ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"], + defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5", + }, + model: { + ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"], + defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5", + }, + video: { + ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"], + defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", + }, + hot: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + }, + specs: ["商品主图建议 1:1", "短视频竖版封面建议 9:16"], + }, +]; +const platformOptions = platformSpecOptions.map((option) => option.label); const getPlatformLogoText = (value: string) => { const normalized = value.toLowerCase(); if (value.includes("淘宝") || value.includes("天猫")) return "淘"; if (value.includes("京东")) return "京"; - if (value.includes("拼多多")) return "拼"; + if (value.includes("拼多多") || value.includes("鎷煎澶")) return "拼"; if (value.includes("抖音")) return "抖"; if (normalized.includes("amazon")) return "a"; if (normalized.includes("shopee")) return "S"; if (normalized.includes("lazada")) return "L"; if (normalized.includes("instagram")) return "IG"; - if (value.includes("速卖通")) return "AE"; + if (value.includes("速卖通") || value.includes("閫熷崠閫")) return "AE"; if (normalized.includes("ebay")) return "eB"; if (normalized.includes("tiktok")) return "♪"; return value.trim().slice(0, 1).toUpperCase() || "商"; }; - const getPlatformLogoVariant = (value: string) => { const normalized = value.toLowerCase(); if (value.includes("淘宝") || value.includes("天猫")) return "taobao"; if (value.includes("京东")) return "jd"; - if (value.includes("拼多多")) return "pdd"; + if (value.includes("拼多多") || value.includes("鎷煎澶")) return "pdd"; if (value.includes("抖音")) return "douyin"; if (normalized.includes("amazon")) return "amazon"; if (normalized.includes("shopee")) return "shopee"; if (normalized.includes("lazada")) return "lazada"; if (normalized.includes("instagram")) return "instagram"; - if (value.includes("速卖通")) return "aliexpress"; + if (value.includes("速卖通") || value.includes("閫熷崠閫")) return "aliexpress"; if (normalized.includes("ebay")) return "ebay"; if (normalized.includes("tiktok")) return "tiktok"; return "default"; }; - const getPlatformLogoMarks = (value: string) => { if (value.includes("淘宝") || value.includes("天猫")) return ["淘", "猫"]; return [getPlatformLogoText(value)]; }; - const renderPlatformLogo = (value: string) => { const marks = getPlatformLogoMarks(value); const variant = getPlatformLogoVariant(value); @@ -302,15 +778,232 @@ const renderPlatformLogo = (value: string) => { ); }; +const marketLanguageOptions: Array<{ country: string; languages: string[] }> = [ + { country: "中国", languages: ["中文"] }, + { country: "美国", languages: ["英文"] }, + { country: "加拿大", languages: ["英文", "法文"] }, + { country: "英国", languages: ["英文"] }, + { country: "德国", languages: ["德文"] }, + { country: "法国", languages: ["法文"] }, + { country: "意大利", languages: ["意大利语"] }, + { country: "西班牙", languages: ["西班牙语"] }, + { country: "日本", languages: ["日文"] }, + { country: "韩国", languages: ["韩文"] }, + { country: "澳大利亚", languages: ["英文"] }, + { country: "新加坡", languages: ["英文", "中文"] }, + { country: "马来西亚", languages: ["马来语", "英文", "中文"] }, + { country: "印尼", languages: ["印度尼西亚语", "英文"] }, + { country: "越南", languages: ["越南语", "英文"] }, + { country: "泰国", languages: ["泰语", "英文"] }, + { country: "菲律宾", languages: ["菲律宾语(他加禄语)", "英文"] }, + { country: "巴西", languages: ["葡萄牙语"] }, + { country: "墨西哥", languages: ["西班牙语"] }, + { country: "智利", languages: ["西班牙语"] }, + { country: "哥伦比亚", languages: ["西班牙语"] }, + { country: "阿联酋", languages: ["阿拉伯语", "英文"] }, + { country: "沙特阿拉伯", languages: ["阿拉伯语", "英文"] }, + { country: "俄罗斯", languages: ["俄语"] }, + { country: "波兰", languages: ["波兰语"] }, +]; +const marketOptions = marketLanguageOptions.map((option) => option.country); +const languageOptions = Array.from(new Set(marketLanguageOptions.flatMap((option) => option.languages))); +const languageAliases: Record = { + "英文": "英文", + "中文": "中文", + "英语": "英文", + "日语": "日文", + "日文": "日文", + "德语": "德文", + "德文": "德文", + "法语": "法文", + "法文": "法文", + "韩语": "韩文", + "韩文": "韩文", + "西文": "西班牙语", + "西班牙语": "西班牙语", + "葡文": "葡萄牙语", + "葡萄牙语": "葡萄牙语", + "印尼语": "印度尼西亚语", + "印度尼西亚语": "印度尼西亚语", + "菲律宾语": "菲律宾语(他加禄语)", + "菲律宾语(他加禄语)": "菲律宾语(他加禄语)", +}; +const defaultPlatformSpec = platformSpecOptions[0]!; +const getPlatformSpec = (value: string) => + platformSpecOptions.find((option) => option.label === value || option.aliases?.includes(value)) ?? defaultPlatformSpec; +const legacyPlatformAliases: Record = { + "淘宝/天猫": "淘宝/天猫", + "京东": "京东", + "拼多多": "拼多多", + "抖音电商": "抖音电商", + "亚马逊Amazon": "亚马逊 Amazon", + "速卖通": "速卖通", +}; +const normalizePlatform = (value: string) => getPlatformSpec(legacyPlatformAliases[value] ?? value).label; +const domesticPlatformLabels = new Set(["淘宝/天猫", "京东", "拼多多", "抖音电商"]); +const domesticPlatformLanguages = ["中文"]; +const isDomesticPlatform = (platformValue: string) => domesticPlatformLabels.has(normalizePlatform(platformValue)); +const getPlatformRatioGroup = (value: string, mode?: PlatformRatioModeKey): PlatformRatioGroup => { + const platformSpec = getPlatformSpec(value); + return (mode ? platformSpec.ratioGroups?.[mode] : null) ?? { + ratios: platformSpec.ratios, + defaultRatio: platformSpec.defaultRatio, + }; +}; +const getPlatformRatioOptions = (value: string, mode?: PlatformRatioModeKey) => getPlatformRatioGroup(value, mode).ratios; +const getPlatformDefaultRatio = (value: string, mode?: PlatformRatioModeKey) => getPlatformRatioGroup(value, mode).defaultRatio; +const getUniqueRatioOptions = (ratios: string[]) => Array.from(new Set(ratios)); +const normalizeRatioToken = (value: string) => + value + .replaceAll("\u00a0", " ") + .replaceAll("脳", "×") + .replaceAll("*", "×") + .replaceAll(":", ":") + .replace(/锛\?/g, ":") + .replace(/\s+/g, " ") + .trim(); +const normalizeRatioForPlatform = (platformValue: string, ratioValue: string, mode?: PlatformRatioModeKey) => { + const platformRatios = getPlatformRatioOptions(platformValue, mode); + if (platformRatios.includes(ratioValue)) return ratioValue; + const normalizedRatio = normalizeRatioToken(ratioValue); + const matchedRatio = platformRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio)); + return matchedRatio ?? getPlatformDefaultRatio(platformValue, mode); +}; +const quickSetRatioOptions = ["1:1", "3:4", "4:3", "9:16", "16:9"]; +const getQuickSetRatioValue = (value: string) => { + const normalizedValue = normalizeRatioToken(value); + if (quickSetRatioOptions.includes(normalizedValue)) return normalizedValue; + const sizeMatch = normalizedValue.match(/(\d+)\s*[×xX]\s*(\d+)/u); + if (sizeMatch) { + const width = Number(sizeMatch[1]); + const height = Number(sizeMatch[2]); + if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { + const aspect = formatAspectRatio(width, height); + if (quickSetRatioOptions.includes(aspect)) return aspect; + } + } + const ratioMatch = normalizedValue.match(/(\d+)\s*[::]\s*(\d+)/u); + if (ratioMatch) { + const aspect = `${Number(ratioMatch[1])}:${Number(ratioMatch[2])}`; + if (quickSetRatioOptions.includes(aspect)) return aspect; + } + return quickSetRatioOptions[0]!; +}; +const formatRatioDisplayValue = (value: string) => { + const normalizedValue = normalizeRatioToken(value); + const sizeMatch = normalizedValue.match(/(\d+)\s*[×xX]\s*(\d+)\s*px?/u); + if (sizeMatch) { + const width = Number(sizeMatch[1]); + const height = Number(sizeMatch[2]); + return `${width}×${height}px\u00a0\u00a0\u00a0${formatAspectRatio(width, height)}`; + } + return normalizedValue + .replace("淘宝主图 / SKU 图 ", "淘宝主图 / SKU 图 ") + .replace("京东主图 / SKU 图 ", "京东主图 / SKU 图 ") + .replace("详情页宽", "详情页宽") + .replace("短视频", "短视频") + .replace("主图", "主图") + .replace("商品主图", "商品主图") + .replace("鍟嗗搧鍥?", "商品图") + .replace(/\s+:/g, ":") + .replace(/:\s+/g, ":"); +}; +const getRatioDisplayParts = (value: string) => { + const display = formatRatioDisplayValue(value).replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim(); + const aspectMatch = display.match(/(\d+\s*[::]\s*\d+)(?!.*\d+\s*[::]\s*\d+)/u); + const aspect = aspectMatch?.[1]?.replace(/\s+/g, "") ?? "自适应"; + const size = aspectMatch ? display.replace(aspectMatch[0], "").trim() : display; + return { + size: size || "原图比例", + aspect, + }; +}; +/** Extract CSS aspect-ratio from a ratio string like "1000x1000px 1:1" -> "1 / 1" */ +const parseRatioToAspectCss = (ratioStr: string): string => { + const match = ratioStr.match(/(\d+)\D+(\d+)/u); + if (!match) return "1 / 1"; + return `${match[1]} / ${match[2]}`; +}; +const supportedImageApiRatios = ["1:1", "3:4", "4:3", "9:16", "16:9"] as const; +type SupportedImageApiRatio = typeof supportedImageApiRatios[number]; +const toSupportedImageApiRatio = (width: number, height: number): SupportedImageApiRatio => { + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return "1:1"; + let bestRatio: SupportedImageApiRatio = "1:1"; + let bestScore = Number.POSITIVE_INFINITY; + const target = Math.log(width / height); + for (const ratio of supportedImageApiRatios) { + const [left, right] = ratio.split(":").map(Number); + const score = Math.abs(target - Math.log(left / right)); + if (score < bestScore) { + bestRatio = ratio; + bestScore = score; + } + } + return bestRatio; +}; + +/** Normalize ratio display string ("1000×1000px 1:1") to an image API aspect ratio ("1:1"). */ +const normalizeRatioForApi = (ratioStr: string): string => { + const normalizedValue = normalizeRatioToken(ratioStr); + const explicitRatios = Array.from(normalizedValue.matchAll(/(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)/g)); + const explicitRatio = explicitRatios.at(-1); + if (explicitRatio) { + return toSupportedImageApiRatio(Number(explicitRatio[1]), Number(explicitRatio[2])); + } + + const sizeMatch = normalizedValue.match(/(\d+(?:\.\d+)?)\s*[×xX*]\s*(\d+(?:\.\d+)?)/u); + if (!sizeMatch) return "1:1"; + return toSupportedImageApiRatio(Number(sizeMatch[1]), Number(sizeMatch[2])); +}; +const greatestCommonDivisor = (left: number, right: number): number => { + let a = Math.abs(left); + let b = Math.abs(right); + while (b) { + [a, b] = [b, a % b]; + } + return a || 1; +}; +const formatAspectRatio = (width: number, height: number) => { + const divisor = greatestCommonDivisor(width, height); + return `${Math.round(width / divisor)}:${Math.round(height / divisor)}`; +}; +const formatUploadedImageRatio = (image?: CloneImageItem) => { + if (!image) return null; + const format = image.format ? `\u00a0\u00a0\u00a0${image.format}` : ""; + if (!image.width || !image.height) return `上传图片\u00a0\u00a0\u00a0原图比例${format}`; + return `上传图片 ${image.width}×${image.height}px\u00a0\u00a0\u00a0${formatAspectRatio(image.width, image.height)}${format}`; +}; +const defaultMarketLanguageOption = marketLanguageOptions[0]!; +const normalizeMarket = (value: string) => + marketLanguageOptions.some((option) => option.country === value) ? value : defaultMarketLanguageOption.country; +const normalizeLanguage = (value: string) => languageAliases[value] ?? value; +const uniqueLanguages = (languages: string[]) => Array.from(new Set(languages)); +const appendEnglish = (languages: string[]) => Array.from(new Set([...languages, "英文"])); +const getMarketLanguageOptions = (marketValue: string) => + appendEnglish((marketLanguageOptions.find((option) => option.country === marketValue) ?? defaultMarketLanguageOption).languages); +const getPlatformLanguageOptions = (platformValue: string, marketValue: string) => { + const marketLanguages = getMarketLanguageOptions(marketValue); + if (!isDomesticPlatform(platformValue)) return marketLanguages; + const localLanguages = marketLanguages.filter((item) => item !== "英文"); + return uniqueLanguages([...localLanguages, ...domesticPlatformLanguages, "英文"]); +}; +const getPlatformDefaultLanguage = (platformValue: string, marketValue: string) => + isDomesticPlatform(platformValue) ? "中文" : (getPlatformLanguageOptions(platformValue, marketValue)[0] ?? languageOptions[0] ?? "英文"); +const normalizeLanguageForPlatform = (platformValue: string, marketValue: string, languageValue: string) => { + const normalizedLanguage = normalizeLanguage(languageValue); + const platformLanguages = getPlatformLanguageOptions(platformValue, marketValue); + return platformLanguages.includes(normalizedLanguage) ? normalizedLanguage : getPlatformDefaultLanguage(platformValue, marketValue); +}; const productSetOutputOptions: Array<{ key: ProductSetOutputKey; label: string; desc: string; icon: ReactNode }> = [ { key: "set", label: "套图", desc: "主图/卖点/场景", icon: }, { key: "detail", label: "详情图", desc: "长图模块化生成", icon: }, { key: "model", label: "模特图", desc: "真人穿搭展示", icon: }, { key: "video", label: "短视频", desc: "分镜视频链路", icon: }, ]; -const cloneOutputOptions: Array<{ key: ProductSetOutputKey; label: string; desc: string; icon: ReactNode }> = [ +const cloneOutputOptions: Array<{ key: CloneOutputKey; label: string; desc: string; icon: ReactNode }> = [ ...productSetOutputOptions, + { key: "hot", label: "爆款复刻", desc: "参考图风格迁移", icon: }, ]; const cloneSetCountOptions: Array<{ key: CloneSetCountKey; @@ -322,12 +1015,22 @@ const cloneSetCountOptions: Array<{ { key: "scene", title: "场景图", desc: "展示商品生活使用场景和人物搭配" }, ]; const cloneSetCountKeys = cloneSetCountOptions.map((option) => option.key); +const defaultCloneSetCounts: Record = { + selling: 3, + white: 1, + scene: 3, +}; const minCloneSetTotal = 1; const maxCloneSetTotal = 16; -const maxCloneProductImages = 7; +const maxCloneProductImages = 20; const maxCloneReferenceImages = 20; const cloneVideoDurationMin = 5; const cloneVideoDurationMax = 45; +const defaultEcommercePlatform = "淘宝/天猫"; +const defaultProductSetOutput: ProductSetOutputKey = "set"; +const defaultCloneOutput: CloneOutputKey = "set"; +const cloneLatestSettingStorageKey = "omniai.clone-ai.latest-setting"; +const ecommerceHistoryStorageKey = "omniai.ecommerce.history.records"; const cloneVideoQualityOptions: Array<{ key: CloneVideoQualityKey; label: string; desc: string }> = [ { key: "standard", label: "标准", desc: "快速出片" }, { key: "high", label: "高清", desc: "推荐" }, @@ -406,6 +1109,7 @@ const detailModules = [ { id: "tips", title: "使用提示图", desc: "提醒操作与保养要点" }, ]; const defaultDetailModuleIds: string[] = []; +const defaultCloneDetailModuleIds = ["hero", "selling", "usage", "angle", "scene", "detail"]; const maxDetailModuleSelection = 6; const cloneDetailModules = detailModules; const detailAssets = ossAssets.ecommerce.detail; @@ -541,6 +1245,117 @@ function notifyRejectedImages(files: File[]): File[] { return accepted; } +function isCloneSavedSetting(item: unknown): item is CloneSavedSetting { + const candidate = item as Partial; + return ( + typeof candidate.id === "string" && + typeof candidate.name === "string" && + typeof candidate.savedAt === "string" && + typeof candidate.output === "string" && + typeof candidate.platform === "string" && + typeof candidate.market === "string" && + typeof candidate.language === "string" && + typeof candidate.ratio === "string" && + typeof candidate.videoDurationSeconds === "number" + ); +} + +function readCloneLatestSetting() { + if (typeof window === "undefined") return null; + try { + const rawValue = window.localStorage.getItem(cloneLatestSettingStorageKey); + if (rawValue) { + const parsedValue: unknown = JSON.parse(rawValue); + if (isCloneSavedSetting(parsedValue)) return parsedValue; + } + } catch { + return null; + } + return null; +} + +function writeCloneLatestSetting(setting: CloneSavedSetting) { + if (typeof window === "undefined") return; + window.localStorage.setItem(cloneLatestSettingStorageKey, JSON.stringify(setting)); +} + +function isCloneImageItem(item: unknown): item is CloneImageItem { + const candidate = item as Partial; + return typeof candidate.id === "string" && typeof candidate.src === "string" && typeof candidate.name === "string"; +} + +function isCloneResult(item: unknown): item is CloneResult { + const candidate = item as Partial; + return typeof candidate.id === "string" && typeof candidate.src === "string" && typeof candidate.label === "string"; +} + +function isEcommerceHistoryRecord(item: unknown): item is EcommerceHistoryRecord { + const candidate = item as Partial; + return ( + typeof candidate.id === "string" && + typeof candidate.title === "string" && + typeof candidate.createdAt === "number" && + typeof candidate.output === "string" && + typeof candidate.platform === "string" && + typeof candidate.market === "string" && + typeof candidate.language === "string" && + typeof candidate.ratio === "string" && + typeof candidate.requirement === "string" && + Array.isArray(candidate.productImages) && + candidate.productImages.every(isCloneImageItem) && + Array.isArray(candidate.results) && + candidate.results.every(isCloneResult) + ); +} + +function removeFilePayloadFromImages(images: CloneImageItem[]): CloneImageItem[] { + return images.map(({ id, src, name, width, height, format, mimeType, ossKey }) => ({ + id, + src, + name, + width, + height, + format, + mimeType, + ossKey, + })); +} + +function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord): EcommerceHistoryRecord { + return { + ...record, + productImages: removeFilePayloadFromImages(record.productImages), + referenceImages: removeFilePayloadFromImages(record.referenceImages ?? []), + results: record.results ?? [], + setResultImages: record.setResultImages ?? [], + setCounts: record.setCounts ?? defaultCloneSetCounts, + detailModules: record.detailModules ?? defaultCloneDetailModuleIds, + modelScenes: record.modelScenes ?? [], + replicateLevel: record.replicateLevel ?? "high", + }; +} + +function readEcommerceHistoryRecords() { + if (typeof window === "undefined") return []; + try { + const rawValue = window.localStorage.getItem(ecommerceHistoryStorageKey); + if (!rawValue) return []; + const parsedValue: unknown = JSON.parse(rawValue); + if (!Array.isArray(parsedValue)) return []; + return parsedValue + .filter(isEcommerceHistoryRecord) + .map(normalizeEcommerceHistoryRecord) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, 30); + } catch { + return []; + } +} + +function writeEcommerceHistoryRecords(records: EcommerceHistoryRecord[]) { + if (typeof window === "undefined") return; + window.localStorage.setItem(ecommerceHistoryStorageKey, JSON.stringify(records.map(normalizeEcommerceHistoryRecord).slice(0, 30))); +} function clampCloneVideoDuration(value: number) { return Math.min(cloneVideoDurationMax, Math.max(cloneVideoDurationMin, Math.round(value))); @@ -570,8 +1385,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const garmentInputRef = useRef(null); const detailInputRef = useRef(null); const detailProgressRef = useRef(null); - const hotProgressRef = useRef(null); - const hotMaterialInputRef = useRef(null); const countHoldTimeoutRef = useRef(null); const countHoldIntervalRef = useRef(null); const isAuthenticated = Boolean((_props as Record).isAuthenticated); @@ -605,7 +1418,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [selectedProductSetPreview, setSelectedProductSetPreview] = useState(null); const [showHostingModal, setShowHostingModal] = useState(false); const [productImages, setProductImages] = useState([]); - const [activeQuickTool, setActiveQuickTool] = useState<"cutout" | "detail" | "watermark" | "image-edit" | "translate" | "hot" | null>(null); + const [activeQuickTool, setActiveQuickTool] = useState<"cutout" | "detail" | "watermark" | "image-edit" | "translate" | null>(null); const [smartCutoutImage, setSmartCutoutImage] = useState(null); const [smartCutoutBatchImages, setSmartCutoutBatchImages] = useState([]); const [smartCutoutBackgroundColor, setSmartCutoutBackgroundColor] = useState("#ffffff"); @@ -696,6 +1509,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { offsetX: 0, offsetY: 0, }); + const previewTouchGestureRef = useRef({ + mode: "none", + points: [], + startOffset: { x: 0, y: 0 }, + startZoom: 1, + startDistance: 0, + startCenter: { x: 0, y: 0 }, + }); const nodeDragRef = useRef<{ active: boolean; nodeId: string; startX: number; startY: number; originX: number; originY: number }>({ active: false, nodeId: "", @@ -737,6 +1558,114 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { [previewOffset.x, previewOffset.y, previewZoom], ); + const updatePreviewTransform = (nextZoom: number, nextOffset: { x: number; y: number }) => { + previewZoomRef.current = nextZoom; + previewOffsetRef.current = nextOffset; + setPreviewZoom(nextZoom); + setPreviewOffset(nextOffset); + }; + + const getPreviewGestureDistance = (points: PreviewTouchPoint[]) => { + if (points.length < 2) return 0; + return Math.hypot(points[0]!.x - points[1]!.x, points[0]!.y - points[1]!.y); + }; + + const getPreviewGestureCenter = (points: PreviewTouchPoint[]) => { + if (points.length < 2) return points[0] ? { x: points[0].x, y: points[0].y } : { x: 0, y: 0 }; + return { + x: (points[0]!.x + points[1]!.x) / 2, + y: (points[0]!.y + points[1]!.y) / 2, + }; + }; + + const isPreviewTouchInteractiveTarget = (target: HTMLElement | null) => + Boolean(target?.closest(".ecom-command-composer-wrap, .clone-ai-preview-header, .clone-ai-source-corner-action, input, textarea, select, a, button")); + + const startPreviewTouchGesture = (event: ReactPointerEvent) => { + if (event.pointerType === "mouse" || isPreviewTouchInteractiveTarget(event.target as HTMLElement | null)) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + const points = [ + ...previewTouchGestureRef.current.points.filter((point) => point.id !== event.pointerId), + { id: event.pointerId, x: event.clientX, y: event.clientY }, + ].slice(-2); + const mode = points.length >= 2 ? "pinch" : "pan"; + previewTouchGestureRef.current = { + mode, + points, + startOffset: previewOffsetRef.current, + startZoom: previewZoomRef.current, + startDistance: getPreviewGestureDistance(points), + startCenter: getPreviewGestureCenter(points), + }; + event.currentTarget.classList.add("is-touch-panning"); + }; + + const movePreviewTouchGesture = (event: ReactPointerEvent) => { + const gesture = previewTouchGestureRef.current; + if (gesture.mode === "none" || event.pointerType === "mouse") return; + event.preventDefault(); + const points = gesture.points.map((point) => point.id === event.pointerId ? { ...point, x: event.clientX, y: event.clientY } : point); + if (!points.some((point) => point.id === event.pointerId)) return; + + if (gesture.mode === "pinch" && points.length >= 2 && gesture.startDistance > 0) { + const rect = event.currentTarget.getBoundingClientRect(); + const center = getPreviewGestureCenter(points); + const zoomRatio = getPreviewGestureDistance(points) / gesture.startDistance; + const nextZoom = Math.min(2, Math.max(0.25, gesture.startZoom * zoomRatio)); + const startCenterX = gesture.startCenter.x - rect.left; + const startCenterY = gesture.startCenter.y - rect.top; + const currentCenterX = center.x - rect.left; + const currentCenterY = center.y - rect.top; + const contentX = (startCenterX - gesture.startOffset.x) / gesture.startZoom; + const contentY = (startCenterY - gesture.startOffset.y) / gesture.startZoom; + updatePreviewTransform(nextZoom, { + x: currentCenterX - contentX * nextZoom, + y: currentCenterY - contentY * nextZoom, + }); + } else { + const point = points[0]!; + const startPoint = gesture.points[0]!; + updatePreviewTransform(gesture.startZoom, { + x: gesture.startOffset.x + point.x - startPoint.x, + y: gesture.startOffset.y + point.y - startPoint.y, + }); + } + + previewTouchGestureRef.current = { ...gesture, points }; + }; + + const stopPreviewTouchGesture = (event: ReactPointerEvent) => { + const gesture = previewTouchGestureRef.current; + if (event.pointerType === "mouse" || gesture.mode === "none") return; + const points = gesture.points.filter((point) => point.id !== event.pointerId); + if (points.length) { + previewTouchGestureRef.current = { + mode: "pan", + points, + startOffset: previewOffsetRef.current, + startZoom: previewZoomRef.current, + startDistance: 0, + startCenter: getPreviewGestureCenter(points), + }; + } else { + previewTouchGestureRef.current = { + mode: "none", + points: [], + startOffset: previewOffsetRef.current, + startZoom: previewZoomRef.current, + startDistance: 0, + startCenter: { x: 0, y: 0 }, + }; + event.currentTarget.classList.remove("is-touch-panning"); + } + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + // Pointer capture can already be released by the browser after cancel. + } + }; + useEffect(() => { const container = previewSurfaceRef.current; if (!container) return undefined; @@ -846,8 +1775,43 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { onAuxClick: (event: ReactMouseEvent) => { if (event.button === 1) event.preventDefault(); }, + onPointerDown: startPreviewTouchGesture, + onPointerMove: movePreviewTouchGesture, + onPointerUp: stopPreviewTouchGesture, + onPointerCancel: stopPreviewTouchGesture, }); + const startCanvasNodeDrag = (event: ReactPointerEvent, node: CanvasNode) => { + if (event.button !== 0 || event.pointerType === "mouse") return; + if ((event.target as HTMLElement | null)?.closest("button, a, input, textarea, select")) return; + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + nodeDragRef.current = { active: true, nodeId: node.id, startX: event.clientX, startY: event.clientY, originX: node.x, originY: node.y }; + }; + + const moveCanvasNodeDrag = (event: ReactPointerEvent, nodeId: string) => { + const drag = nodeDragRef.current; + if (!drag.active || drag.nodeId !== nodeId || event.pointerType === "mouse") return; + event.preventDefault(); + event.stopPropagation(); + const zoom = previewZoomRef.current; + const dx = (event.clientX - drag.startX) / zoom; + const dy = (event.clientY - drag.startY) / zoom; + setCanvasNodes((prev) => prev.map((node) => node.id === nodeId ? { ...node, x: drag.originX + dx, y: drag.originY + dy } : node)); + }; + + const stopCanvasNodeDrag = (event: ReactPointerEvent, nodeId: string) => { + if (nodeDragRef.current.nodeId !== nodeId || event.pointerType === "mouse") return; + nodeDragRef.current = { ...nodeDragRef.current, active: false }; + event.stopPropagation(); + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + // Pointer capture can already be released by the browser after cancel. + } + }; + const handlePreviewWheel = (event: React.WheelEvent) => { if (!event.currentTarget) return; const container = event.currentTarget as HTMLElement; @@ -941,25 +1905,24 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [detailStatus, setDetailStatus] = useState("idle"); const [detailResultUrl, setDetailResultUrl] = useState(null); const [detailProgress, setDetailProgress] = useState(0); - const [hotRequirement, setHotRequirement] = useState(""); - const [isHotMaterialDragging, setIsHotMaterialDragging] = useState(false); - const [hotMaterialHoverZoom, setHotMaterialHoverZoom] = useState<{ src: string; x: number; y: number; placement: "above" | "below" } | null>(null); - const [hotPlatform, setHotPlatform] = useState(platformOptions[0]); - const [hotMarket, setHotMarket] = useState(marketOptions[0]); - const [hotLanguage, setHotLanguage] = useState(getPlatformDefaultLanguage(platformOptions[0], marketOptions[0])); - const [hotRatio, setHotRatio] = useState(getQuickSetRatioValue(getPlatformDefaultRatio(platformOptions[0], "detail"))); - const [hotStatus, setHotStatus] = useState("idle"); - const [hotResultUrl, setHotResultUrl] = useState(null); - const [hotProgress, setHotProgress] = useState(0); const productSetRatioOptions = useMemo( () => getPlatformRatioOptions(productSetPlatform, productSetOutput), [productSetOutput, productSetPlatform], ); + const hotUploadedRatioOption = useMemo( + () => cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null, + [cloneOutput, cloneReferenceImages], + ); const baseCloneRatioOptions = useMemo( () => getPlatformRatioOptions(platform, cloneOutput), [cloneOutput, platform], ); - const cloneRatioOptions = baseCloneRatioOptions; + const cloneRatioOptions = useMemo( + () => hotUploadedRatioOption + ? getUniqueRatioOptions([...baseCloneRatioOptions, hotUploadedRatioOption]) + : baseCloneRatioOptions, + [baseCloneRatioOptions, hotUploadedRatioOption], + ); const productSetLanguageOptions = useMemo( () => getPlatformLanguageOptions(productSetPlatform, productSetMarket), [productSetMarket, productSetPlatform], @@ -972,10 +1935,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { () => getPlatformLanguageOptions(detailPlatform, detailMarket), [detailMarket, detailPlatform], ); - const hotLanguageOptions = useMemo( - () => getPlatformLanguageOptions(hotPlatform, hotMarket), - [hotMarket, hotPlatform], - ); const ecommerceMentionImages: MentionImageOption[] = [ ...productImages.map((image, index) => ({ ...image, label: `商品图 ${index + 1}` })), ...cloneReferenceImages.map((image, index) => ({ ...image, label: `参考图 ${index + 1}` })), @@ -1006,7 +1965,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const canGenerate = productImages.length > 0 && status !== "generating"; const canGenerateTryOn = garmentImages.length > 0 && tryOnStatus !== "generating" && tryOnStatus !== "modeling"; const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating"; - const canGenerateHot = cloneReferenceImages.length > 0 && hotStatus !== "generating"; const cloneVideoDurationProgress = ((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100; const cloneVideoDurationStyle: CSSProperties = useMemo( @@ -1121,8 +2079,21 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const openSmartCutoutUpload = () => { clearSmartCutoutTransition(); + setSmartCutoutTransitionMessage({ + title: "正在进入智能抠图", + subtitle: "为你打开图片处理工具", + }); + setActiveQuickTool("cutout"); + setSmartCutoutBatchImages((current) => { + revokeSmartCutoutItems(current); + return []; + }); + setSmartCutoutImage((current) => { + revokeSmartCutoutItem(current); + return null; + }); + setIsSmartCutoutComparing(false); setComposerMenu(null); - toast.info("功能正在优化中"); }; const openWatermarkRemovalPage = () => { @@ -1336,8 +2307,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const openImageTranslatePage = () => { clearSmartCutoutTransition(); + setActiveQuickTool("translate"); setComposerMenu(null); - toast.info("功能正在优化中"); + setIsCloneSettingsCollapsed(false); }; const closeImageTranslatePage = () => { @@ -1871,16 +2843,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { if (!selectedDetailModules.length) setSelectedDetailModules(defaultCloneDetailModuleIds); }; - const openHotClonePage = () => { - clearSmartCutoutTransition(); - setActiveQuickTool("hot"); - setComposerMenu(null); - setIsCloneSettingsCollapsed(false); - setIsQuickPanelCollapsed(false); - setPreviewZoom(1); - resetQuickSetSelectState(); - }; - const closeSmartCutoutTool = () => { runSmartCutoutPageTransition( { @@ -2239,17 +3201,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { } }; - const removeCloneReferenceImage = (imageId: string) => { - setCloneReferenceImages((current) => { - const next = current.filter((item) => item.id !== imageId); - if (next.length === 0) { - setHotStatus("idle"); - setHotResultUrl(null); - } - return next; - }); - }; - const handleCloneReferenceUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; @@ -2354,7 +3305,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const normalizedPlatform = normalizePlatform(nextPlatform); setPlatform(normalizedPlatform); setRatio((current) => - normalizeRatioForPlatform(normalizedPlatform, current, cloneOutput), + cloneOutput === "hot" && current.startsWith("上传图片") && hotUploadedRatioOption + ? hotUploadedRatioOption + : normalizeRatioForPlatform(normalizedPlatform, current, cloneOutput), ); setLanguage(getPlatformDefaultLanguage(normalizedPlatform, market)); }; @@ -2363,7 +3316,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { setCloneOutput(nextOutput); if (nextOutput !== "video") setIsVideoWorkspaceVisible(false); setRatio((current) => - normalizeRatioForPlatform(platform, current, nextOutput), + nextOutput === "hot" && current.startsWith("上传图片") && hotUploadedRatioOption + ? hotUploadedRatioOption + : normalizeRatioForPlatform(platform, current, nextOutput), ); }; @@ -2492,7 +3447,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }, [latestCloneSettingSnapshot]); useEffect(() => { - clearCloneLatestSetting(); + window.localStorage.removeItem(cloneLatestSettingStorageKey); }, []); useEffect(() => { @@ -2502,12 +3457,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { useEffect(() => { setRatio((current) => { const platformRatios = getPlatformRatioOptions(platform, cloneOutput); - if (platformRatios.includes(current)) return current; + const availableRatios = hotUploadedRatioOption ? getUniqueRatioOptions([...platformRatios, hotUploadedRatioOption]) : platformRatios; + if (current.startsWith("上传图片") && hotUploadedRatioOption) return hotUploadedRatioOption; + if (availableRatios.includes(current)) return current; const normalizedRatio = normalizeRatioToken(current); - const matchedRatio = platformRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio)); + const matchedRatio = availableRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio)); return matchedRatio ?? getPlatformDefaultRatio(platform, cloneOutput); }); - }, [cloneOutput, platform]); + }, [cloneOutput, hotUploadedRatioOption, platform]); useEffect(() => { if (skipInitialCloneAutoSaveRef.current) { @@ -2749,6 +3706,85 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return urls; }; + const setCountLabels: Record = { + selling: { label: "卖点图", promptDesc: "selling-point infographic image highlighting core product advantages and detail close-ups" }, + white: { label: "白底图", promptDesc: "clean white-background product photo showing the item from its best angle, studio lighting, no props" }, + scene: { label: "场景图", promptDesc: "lifestyle scene image showing the product in a realistic usage environment with natural surroundings" }, + }; + + const buildDetailModulePrompt = (moduleIds: string[]): string => { + if (!moduleIds.length) { + return "Generate a complete A+ detail layout with hero, selling points, usage scene, product detail, and specification modules."; + } + + const selectedModules = cloneDetailModules.filter((module) => moduleIds.includes(module.id)); + if (!selectedModules.length) return ""; + + const moduleList = selectedModules.map((module) => `${module.title}: ${module.desc}`).join("; "); + return `Only generate these selected A+ detail modules, no extra modules: ${moduleList}. Keep the output focused even if only one or two modules are selected.`; + }; + + const buildSetSubPrompt = (countKey: CloneSetCountKey, index: number, totalCount: number, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string): string => { + const info = setCountLabels[countKey]; + const parts: string[] = []; + parts.push(`Generate an e-commerce ${info.label.toLowerCase()} for a product listing.`); + parts.push(info.promptDesc); + if (countKey === "white") { + parts.push("The output must be a clean white-background product image. Do not use lifestyle backgrounds, props, text overlays, or people."); + } + if (countKey === "scene") { + parts.push("The output must be a realistic usage scene image. Keep the product clearly visible and preserve its shape, color, and key details."); + } + if (countKey === "selling") { + parts.push("The output must be a selling-point graphic with clear hierarchy, concise copy, and product detail callouts."); + } + if (totalCount > 1) { + parts.push(`This is variant ${index + 1} of ${totalCount} —vary the angle, composition, or emphasis to make each distinct.`); + } + parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`); + parts.push("Must comply with platform image guidelines —proper margins, no watermark, professional quality."); + return parts.join(" "); + }; + + const buildEcommerceImagePrompt = ( + outputKey: CloneOutputKey, userText: string, + pPlatform: string, pRatio: string, pLanguage: string, pMarket: string, + tryOnOptions?: EcommerceImagePromptOptions, + ): string => { + const parts: string[] = []; + if (outputKey === "detail") { + parts.push("Generate a professional A+ detail page hero image for an e-commerce product listing."); + parts.push("Create a high-impact first-screen visual that combines the product photo with key selling points, usage scenes, and detailed specifications in a cohesive layout."); + parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`); + if (outputKey === "detail" && tryOnOptions?.detailModules) parts.push(buildDetailModulePrompt(tryOnOptions.detailModules)); + parts.push("Follow platform A+ page best practices —clear hierarchy, professional typography, high visual impact."); + } else if (outputKey === "model") { + parts.push("Generate model/try-on lifestyle images for an e-commerce product listing."); + parts.push("Show the product being used or worn by a model in attractive lifestyle settings."); + parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`); + if (tryOnOptions) { + if (tryOnOptions.gender) parts.push(`Model gender: ${tryOnOptions.gender}.`); + if (tryOnOptions.age) parts.push(`Model age: ${tryOnOptions.age}.`); + if (tryOnOptions.ethnicity) parts.push(`Model ethnicity: ${tryOnOptions.ethnicity}.`); + if (tryOnOptions.body) parts.push(`Model body type: ${tryOnOptions.body}.`); + if (tryOnOptions.appearance) parts.push(`Model appearance details: ${tryOnOptions.appearance}.`); + if (tryOnOptions.scenes?.length) parts.push(`Background scenes: ${tryOnOptions.scenes.join(", ")}.`); + if (tryOnOptions.customScene) parts.push(`Custom background scene: ${tryOnOptions.customScene}.`); + if (tryOnOptions.smartScene) parts.push("Use smart scene matching to select the best background context."); + } + parts.push("Model should appear natural and appealing. Background should complement the product. Image must meet platform standards."); + } else if (outputKey === "hot") { + parts.push("Generate a high-conversion e-commerce product image that closely replicates the style and composition of the reference image while adapting it to the target platform."); + parts.push(`Replicate the visual style, color palette, and layout feel of the source product image, then adapt it for ${pPlatform} marketplace standards.`); + parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`); + parts.push("The result must look professional and optimized for high click-through rate and conversion on the specified platform."); + } + if (userText.trim()) { + parts.push(`Additional user requirements: ${userText.trim()}`); + } + return parts.join(" "); + }; + const generateSetImages = async ( images: CloneImageItem[], counts: Record, @@ -2874,7 +3910,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return; } - const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket, tryOnOptions, cloneDetailModules); + const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket, tryOnOptions); const stamp = Date.now(); setGenerationProgress(0); @@ -3160,133 +4196,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { ); }; - const handleHotPlatformChange = (nextPlatform: string) => { - const normalizedPlatform = normalizePlatform(nextPlatform); - setHotPlatform(normalizedPlatform); - setHotLanguage(getPlatformDefaultLanguage(normalizedPlatform, hotMarket)); - setHotRatio((current) => getQuickSetRatioValue(current)); - }; - - const handleHotMarketChange = (nextMarket: string) => { - const normalizedMarket = normalizeMarket(nextMarket); - setHotMarket(normalizedMarket); - setHotLanguage(getPlatformDefaultLanguage(hotPlatform, normalizedMarket)); - }; - - const handleHotAiWrite = () => { - setHotRequirement( - "1.产品名称:便携式咖啡保温杯\n2.核心卖点:316不锈钢内胆、12小时长效保温、防漏便携、大容量\n3.参考风格:极简日系、暖光氛围、生活场景\n4.期望场景:办公桌面、户外通勤、运动健身\n5.具体参数:容量500ml、口径4.5cm、高度22cm", - ); - }; - - const stopHotProgress = () => { - if (hotProgressRef.current !== null) { - window.clearInterval(hotProgressRef.current); - hotProgressRef.current = null; - } - }; - - const startHotProgress = () => { - stopHotProgress(); - setHotProgress(0); - hotProgressRef.current = window.setInterval(() => { - setHotProgress((prev) => { - if (prev >= 90) { - stopHotProgress(); - return 90; - } - return prev + (90 - prev) * 0.06; - }); - }, 500); - }; - - const handleHotGenerate = () => { - if (!canGenerateHot) return; - imageAbortRef.current = { current: false }; - lastFailedActionRef.current = null; - startHotProgress(); - void generateEcommerceImage( - "hot", cloneReferenceImages, hotRequirement, - hotPlatform, hotRatio, hotLanguage, hotMarket, - undefined, - (s: string) => { - setHotStatus(s as DetailStatus); - if (s === "done") { - stopHotProgress(); - setHotProgress(100); - } else if (s === "failed" || s === "idle") { - stopHotProgress(); - setHotProgress(0); - } - }, - (res) => setHotResultUrl(res[0]?.src ?? null), - ); - }; - - const handleHotMaterialMouseEnter = (src: string, event: ReactMouseEvent) => { - const rect = event.currentTarget.getBoundingClientRect(); - const previewHalfWidth = 150; - const previewHeight = 360; - const gap = 12; - const viewportWidth = window.innerWidth || document.documentElement.clientWidth; - const viewportHeight = window.innerHeight || document.documentElement.clientHeight; - const x = Math.min( - Math.max(rect.left + rect.width / 2, previewHalfWidth + gap), - Math.max(previewHalfWidth + gap, viewportWidth - previewHalfWidth - gap), - ); - const showAbove = rect.top > previewHeight + gap; - const y = showAbove - ? rect.top - gap - : Math.min(rect.bottom + gap, viewportHeight - gap); - setHotMaterialHoverZoom({ src, x, y, placement: showAbove ? "above" : "below" }); - }; - const handleHotMaterialMouseLeave = () => setHotMaterialHoverZoom(null); - - const renderHotMaterialThumbs = (items: CloneImageItem[], onRemove: (imageId: string) => void) => ( -
- {items.map((item) => ( -
handleHotMaterialMouseEnter(item.src, e)} - onMouseLeave={handleHotMaterialMouseLeave} - > - {item.name} - -
- ))} -
- ); - - const closeHotClonePage = () => { - stopHotProgress(); - setActiveQuickTool(null); - setHotStatus("idle"); - setHotResultUrl(null); - setHotProgress(0); - setHotRequirement(""); - setIsHotMaterialDragging(false); - setHotMaterialHoverZoom(null); - setComposerMenu(null); - }; - const resetTask = () => { setSetImages([]); setProductSetRequirement(""); @@ -3349,7 +4258,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const isWatermarkTool = isCloneTool && activeQuickTool === "watermark"; const isImageEditTool = isCloneTool && activeQuickTool === "image-edit"; const isTranslateTool = isCloneTool && activeQuickTool === "translate"; - const isHotCloneTool = isCloneTool && activeQuickTool === "hot"; const pageLabel = isSetTool ? "商品套图" : isDetail ? "A+/详情页" : isTryOn ? "AI服饰穿戴" : activeToolMeta?.label || "商品工具"; const setPrimaryLabel = setImages.length === 0 @@ -3627,19 +4535,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { { key: "ratio", label: "尺寸/比例", value: getQuickSetRatioValue(detailRatio), options: quickSetRatioOptions, onChange: setDetailRatio }, ]; - const quickHotBasicSelects: Array<{ - key: CloneBasicSelectKey; - label: string; - value: string; - options: string[]; - onChange: (value: string) => void; - }> = [ - { key: "platform", label: "平台", value: hotPlatform, options: platformOptions, onChange: handleHotPlatformChange }, - { key: "market", label: "国家", value: hotMarket, options: marketOptions, onChange: handleHotMarketChange }, - { key: "language", label: "语种", value: hotLanguage, options: hotLanguageOptions, onChange: setHotLanguage }, - { key: "ratio", label: "尺寸/比例", value: getQuickSetRatioValue(hotRatio), options: quickSetRatioOptions, onChange: setHotRatio }, - ]; - const cloneModelSelects: Array<{ key: CloneModelSelectKey; label: string; @@ -3919,6 +4814,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { ? cloneModelPanelTab === "scene" ? "场景设置" : "模特设置" : cloneOutput === "video" ? String(cloneVideoDuration) + "秒 " + (cloneVideoQuality === "standard" ? "720P" : "1080P") + : cloneOutput === "hot" + ? cloneReplicateLevel === "style" ? "风格复刻" : "高度复刻" : "换装素材"; const renderComposerMenu = () => { @@ -4055,6 +4952,48 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { setCloneVideoDuration(clampCloneVideoDuration(Number(event.target.value)))} /> + ) : cloneOutput === "hot" ? ( + <> +
爆款复刻设置{cloneReferenceImages.length}/{maxCloneReferenceImages}
+
+ +
+ {cloneReplicateLevelOptions.map((option) => ( + + ))} +
+
+ ) : ( <>
视频换装设置上传视频和服装参考
@@ -4329,6 +5268,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { 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)} >
{productImages.length ? ( -
+
+ {productImages.map((image) => (
{image.name -
))} -
) : null}
@@ -4556,7 +5506,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
{[ { label: "A+/详情页", tone: "detail", icon: , onClick: openQuickDetailPage }, - { label: "爆款复刻", tone: "hot", icon: , onClick: openHotClonePage }, { label: "图片修改", tone: "edit", icon: , onClick: openImageWorkbenchPage }, { label: "智能抠图", tone: "cutout", icon: , onClick: openSmartCutoutUpload }, { label: "去除水印", tone: "watermark", icon: , onClick: openWatermarkRemovalPage }, @@ -5574,7 +6523,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { ); const quickDetailVisibleSelect = quickDetailBasicSelects.find((item) => item.key === visibleQuickSetSelect) ?? null; - const quickHotVisibleSelect = quickHotBasicSelects.find((item) => item.key === visibleQuickSetSelect) ?? null; const quickDetailPreview = (
@@ -5772,255 +6720,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
); - const hotClonePreview = ( -
-
-