diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index 27ce6a6..9994622 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -42,6 +42,32 @@ import { ecommerceOssScopes, saveUnifiedEcommerceGenerationRecord, deleteEcommer 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 { + buildEcommerceImagePrompt, + buildSetSubPrompt, + setCountLabels, + type EcommerceImagePromptOptions, +} from "./utils/promptBuilder"; import { aiGenerationClient } from "../../api/aiGenerationClient"; const smartCutoutColorPresets = [ @@ -186,8 +212,6 @@ interface ProductClonePageProps { } 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"; @@ -286,25 +310,6 @@ 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: }, @@ -312,358 +317,43 @@ 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("拼多多") || value.includes("鎷煎澶")) return "拼"; + if (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("速卖通") || value.includes("閫熷崠閫")) return "AE"; + if (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("拼多多") || value.includes("鎷煎澶")) return "pdd"; + if (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("速卖通") || value.includes("閫熷崠閫")) return "aliexpress"; + if (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); @@ -680,115 +370,7 @@ 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 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 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: }, @@ -820,9 +402,6 @@ const maxCloneProductImages = 7; 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 }> = [ @@ -3335,85 +2914,6 @@ 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, @@ -3539,7 +3039,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return; } - const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket, tryOnOptions); + const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket, tryOnOptions, cloneDetailModules); const stamp = Date.now(); setGenerationProgress(0); diff --git a/src/features/ecommerce/utils/platformRules.test.ts b/src/features/ecommerce/utils/platformRules.test.ts new file mode 100644 index 0000000..a702bce --- /dev/null +++ b/src/features/ecommerce/utils/platformRules.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { + defaultCloneOutput, + defaultEcommercePlatform, + defaultProductSetOutput, + formatUploadedImageRatio, + getPlatformDefaultLanguage, + getPlatformDefaultRatio, + getPlatformLanguageOptions, + getPlatformRatioOptions, + getUniqueRatioOptions, + normalizeLanguageForPlatform, + normalizeMarket, + normalizePlatform, + normalizeRatioForPlatform, + platformOptions, +} from "./platformRules"; + +describe("platform defaults", () => { + it("exposes the default ecommerce platform and outputs", () => { + expect(defaultEcommercePlatform).toBe("淘宝/天猫"); + expect(defaultProductSetOutput).toBe("set"); + expect(defaultCloneOutput).toBe("set"); + }); + + it("lists platform labels for UI selectors", () => { + expect(platformOptions).toContain("淘宝/天猫"); + expect(platformOptions).toContain("亚马逊 Amazon"); + expect(platformOptions).toContain("TikTok Shop"); + }); +}); + +describe("normalizePlatform", () => { + it("normalizes legacy labels", () => { + expect(normalizePlatform("亚马逊Amazon")).toBe("亚马逊 Amazon"); + expect(normalizePlatform("亚马逊")).toBe("亚马逊 Amazon"); + }); + + it("falls back to the default platform for unknown labels", () => { + expect(normalizePlatform("unknown")).toBe("淘宝/天猫"); + }); +}); + +describe("platform ratios", () => { + it("returns mode-specific ratios", () => { + expect(getPlatformRatioOptions("淘宝/天猫", "set")).toContain("1000×1000px\u00a0\u00a0\u00a01:1"); + expect(getPlatformDefaultRatio("淘宝/天猫", "video")).toBe("1080×1920px\u00a0\u00a0\u00a09:16"); + }); + + it("normalizes an existing or partially matching ratio for a platform", () => { + expect(normalizeRatioForPlatform("淘宝/天猫", "1000×1000px\u00a0\u00a0\u00a01:1", "set")).toBe("1000×1000px\u00a0\u00a0\u00a01:1"); + expect(normalizeRatioForPlatform("淘宝/天猫", "1000×1000px", "set")).toBe("1000×1000px\u00a0\u00a0\u00a01:1"); + }); + + it("falls back to the mode default when no ratio matches", () => { + expect(normalizeRatioForPlatform("淘宝/天猫", "nope", "set")).toBe("1000×1000px\u00a0\u00a0\u00a01:1"); + }); + + it("deduplicates ratio lists without changing order", () => { + expect(getUniqueRatioOptions(["1:1", "3:4", "1:1"])).toEqual(["1:1", "3:4"]); + }); +}); + +describe("market and language rules", () => { + it("normalizes unknown markets to the default country", () => { + expect(normalizeMarket("火星")).toBe("中国"); + }); + + it("uses Chinese by default for domestic platforms", () => { + expect(getPlatformDefaultLanguage("淘宝/天猫", "美国")).toBe("中文"); + }); + + it("includes English for domestic platforms while preserving local languages", () => { + expect(getPlatformLanguageOptions("淘宝/天猫", "美国")).toEqual(["中文", "英文"]); + }); + + it("uses market languages for cross-border platforms", () => { + expect(getPlatformDefaultLanguage("亚马逊 Amazon", "日本")).toBe("日文"); + }); + + it("normalizes language aliases and falls back when not available", () => { + expect(normalizeLanguageForPlatform("亚马逊 Amazon", "日本", "日语")).toBe("日文"); + expect(normalizeLanguageForPlatform("亚马逊 Amazon", "日本", "德语")).toBe("日文"); + }); +}); + +describe("formatUploadedImageRatio", () => { + it("formats dimensions and aspect ratio", () => { + expect(formatUploadedImageRatio({ width: 750, height: 1000, format: "PNG" })).toBe("上传图片 750×1000px\u00a0\u00a0\u00a03:4\u00a0\u00a0\u00a0PNG"); + }); + + it("falls back to original ratio when dimensions are missing", () => { + expect(formatUploadedImageRatio({ format: "JPG" })).toBe("上传图片\u00a0\u00a0\u00a0原图比例\u00a0\u00a0\u00a0JPG"); + }); +}); diff --git a/src/features/ecommerce/utils/platformRules.ts b/src/features/ecommerce/utils/platformRules.ts new file mode 100644 index 0000000..4e48447 --- /dev/null +++ b/src/features/ecommerce/utils/platformRules.ts @@ -0,0 +1,479 @@ +import { formatAspectRatio, normalizeRatioToken } from "./ratioUtils"; + +export type ProductSetOutputKey = "set" | "detail" | "model" | "video"; +export type CloneOutputKey = ProductSetOutputKey | "hot"; +export type PlatformRatioModeKey = ProductSetOutputKey | "hot"; + +export interface PlatformRatioGroup { + ratios: string[]; + defaultRatio: string; +} + +export interface EcommercePlatformSpec { + label: string; + ratios: string[]; + defaultRatio: string; + ratioGroups?: Partial>; + specs: string[]; + tip?: string; + aliases?: string[]; +} +export const platformSpecOptions: EcommercePlatformSpec[] = [ + { + 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"], + }, +]; +export 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("拼多多") || 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("速卖通") || 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("拼多多") || 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("速卖通") || 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)]; +}; +export 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: ["波兰语"] }, +]; +export const marketOptions = marketLanguageOptions.map((option) => option.country); +export const languageOptions = Array.from(new Set(marketLanguageOptions.flatMap((option) => option.languages))); +export const languageAliases: Record = { + "英文": "英文", + "中文": "中文", + "英语": "英文", + "日语": "日文", + "日文": "日文", + "德语": "德文", + "德文": "德文", + "法语": "法文", + "法文": "法文", + "韩语": "韩文", + "韩文": "韩文", + "西文": "西班牙语", + "西班牙语": "西班牙语", + "葡文": "葡萄牙语", + "葡萄牙语": "葡萄牙语", + "印尼语": "印度尼西亚语", + "印度尼西亚语": "印度尼西亚语", + "菲律宾语": "菲律宾语(他加禄语)", + "菲律宾语(他加禄语)": "菲律宾语(他加禄语)", +}; +export const defaultPlatformSpec = platformSpecOptions[0]!; +export const getPlatformSpec = (value: string) => + platformSpecOptions.find((option) => option.label === value || option.aliases?.includes(value)) ?? defaultPlatformSpec; +export const legacyPlatformAliases: Record = { + "淘宝/天猫": "淘宝/天猫", + "京东": "京东", + "拼多多": "拼多多", + "抖音电商": "抖音电商", + "亚马逊Amazon": "亚马逊 Amazon", + "速卖通": "速卖通", +}; +export const normalizePlatform = (value: string) => getPlatformSpec(legacyPlatformAliases[value] ?? value).label; +export const domesticPlatformLabels = new Set(["淘宝/天猫", "京东", "拼多多", "抖音电商"]); +export const domesticPlatformLanguages = ["中文"]; +export const isDomesticPlatform = (platformValue: string) => domesticPlatformLabels.has(normalizePlatform(platformValue)); +export const getPlatformRatioGroup = (value: string, mode?: PlatformRatioModeKey): PlatformRatioGroup => { + const platformSpec = getPlatformSpec(value); + return (mode ? platformSpec.ratioGroups?.[mode] : null) ?? { + ratios: platformSpec.ratios, + defaultRatio: platformSpec.defaultRatio, + }; +}; +export const getPlatformRatioOptions = (value: string, mode?: PlatformRatioModeKey) => getPlatformRatioGroup(value, mode).ratios; +export const getPlatformDefaultRatio = (value: string, mode?: PlatformRatioModeKey) => getPlatformRatioGroup(value, mode).defaultRatio; +export const getUniqueRatioOptions = (ratios: string[]) => Array.from(new Set(ratios)); +export 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); +}; + +export const defaultMarketLanguageOption = marketLanguageOptions[0]!; +export const normalizeMarket = (value: string) => + marketLanguageOptions.some((option) => option.country === value) ? value : defaultMarketLanguageOption.country; +export const normalizeLanguage = (value: string) => languageAliases[value] ?? value; +export const uniqueLanguages = (languages: string[]) => Array.from(new Set(languages)); +export const appendEnglish = (languages: string[]) => Array.from(new Set([...languages, "英文"])); +export const getMarketLanguageOptions = (marketValue: string) => + appendEnglish((marketLanguageOptions.find((option) => option.country === marketValue) ?? defaultMarketLanguageOption).languages); +export 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, "英文"]); +}; +export const getPlatformDefaultLanguage = (platformValue: string, marketValue: string) => + isDomesticPlatform(platformValue) ? "中文" : (getPlatformLanguageOptions(platformValue, marketValue)[0] ?? languageOptions[0] ?? "英文"); +export 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); +}; + +export const defaultEcommercePlatform = "淘宝/天猫"; +export const defaultProductSetOutput: ProductSetOutputKey = "set"; +export const defaultCloneOutput: CloneOutputKey = "set"; + +export const formatUploadedImageRatio = (image?: { width?: number; height?: number; format?: string }) => { + 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}`; +}; diff --git a/src/features/ecommerce/utils/promptBuilder.test.ts b/src/features/ecommerce/utils/promptBuilder.test.ts new file mode 100644 index 0000000..715afd2 --- /dev/null +++ b/src/features/ecommerce/utils/promptBuilder.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { + buildDetailModulePrompt, + buildEcommerceImagePrompt, + buildSetSubPrompt, + setCountLabels, + type EcommercePromptDetailModule, +} from "./promptBuilder"; + +const detailModules: EcommercePromptDetailModule[] = [ + { id: "hero", title: "首页焦点图", desc: "集中呈现核心利益点" }, + { id: "usage", title: "使用情境图", desc: "还原实际使用画面" }, + { id: "spec", title: "参数信息表", desc: "整理商品关键数据" }, +]; + +describe("buildDetailModulePrompt", () => { + it("uses the complete-detail prompt when no modules are selected", () => { + expect(buildDetailModulePrompt([], detailModules)).toContain("complete A+ detail layout"); + }); + + it("includes only selected modules", () => { + const prompt = buildDetailModulePrompt(["hero", "spec"], detailModules); + expect(prompt).toContain("首页焦点图: 集中呈现核心利益点"); + expect(prompt).toContain("参数信息表: 整理商品关键数据"); + expect(prompt).not.toContain("使用情境图"); + }); + + it("returns an empty prompt for unknown selected ids", () => { + expect(buildDetailModulePrompt(["missing"], detailModules)).toBe(""); + }); +}); + +describe("buildSetSubPrompt", () => { + it("builds white-background prompts with strict background guidance", () => { + const prompt = buildSetSubPrompt("white", 0, 1, "淘宝/天猫", "1:1", "中文", "中国"); + expect(prompt).toContain(setCountLabels.white.label); + expect(prompt).toContain("clean white-background product image"); + expect(prompt).toContain("Platform: 淘宝/天猫. Aspect ratio: 1:1. Language/copy: 中文. Market: 中国."); + }); + + it("adds variant guidance when generating multiple images", () => { + expect(buildSetSubPrompt("scene", 1, 3, "Amazon", "3:4", "英文", "美国")).toContain("variant 2 of 3"); + }); +}); + +describe("buildEcommerceImagePrompt", () => { + it("builds detail prompts with selected A+ modules", () => { + const prompt = buildEcommerceImagePrompt( + "detail", + "突出轻量化", + "京东", + "3:4", + "中文", + "中国", + { detailModules: ["usage"] }, + detailModules, + ); + expect(prompt).toContain("professional A+ detail page"); + expect(prompt).toContain("使用情境图: 还原实际使用画面"); + expect(prompt).toContain("Additional user requirements: 突出轻量化"); + }); + + it("builds model prompts with model attributes and scenes", () => { + const prompt = buildEcommerceImagePrompt( + "model", + "", + "Shopee", + "3:4", + "英文", + "美国", + { gender: "女", age: "青年", ethnicity: "亚洲人", body: "标准", appearance: "短发", scenes: ["都市街头"], smartScene: true }, + ); + expect(prompt).toContain("Model gender: 女."); + expect(prompt).toContain("Background scenes: 都市街头."); + expect(prompt).toContain("Use smart scene matching"); + }); + + it("builds hot-replication prompts", () => { + const prompt = buildEcommerceImagePrompt("hot", "", "TikTok Shop", "9:16", "英文", "美国"); + expect(prompt).toContain("closely replicates the style"); + expect(prompt).toContain("TikTok Shop marketplace standards"); + }); +}); diff --git a/src/features/ecommerce/utils/promptBuilder.ts b/src/features/ecommerce/utils/promptBuilder.ts new file mode 100644 index 0000000..c716420 --- /dev/null +++ b/src/features/ecommerce/utils/promptBuilder.ts @@ -0,0 +1,113 @@ +import type { CloneOutputKey } from "./platformRules"; + +export type EcommerceSetCountKey = "selling" | "white" | "scene"; + +export interface EcommercePromptDetailModule { + id: string; + title: string; + desc: string; +} + +export interface EcommerceImagePromptOptions { + gender?: string; + age?: string; + ethnicity?: string; + body?: string; + appearance?: string; + scenes?: string[]; + customScene?: string; + smartScene?: boolean; + detailModules?: string[]; +} + +export 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" }, +}; + +export const buildDetailModulePrompt = (moduleIds: string[], modules: EcommercePromptDetailModule[]): string => { + if (!moduleIds.length) { + return "Generate a complete A+ detail layout with hero, selling points, usage scene, product detail, and specification modules."; + } + + const selectedModules = modules.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.`; +}; + +export const buildSetSubPrompt = ( + countKey: EcommerceSetCountKey, + index: number, + totalCount: number, + platform: string, + ratio: string, + language: string, + market: 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: ${platform}. Aspect ratio: ${ratio}. Language/copy: ${language}. Market: ${market}.`); + parts.push("Must comply with platform image guidelines —proper margins, no watermark, professional quality."); + return parts.join(" "); +}; + +export const buildEcommerceImagePrompt = ( + outputKey: CloneOutputKey, + userText: string, + platform: string, + ratio: string, + language: string, + market: string, + options?: EcommerceImagePromptOptions, + detailModules: EcommercePromptDetailModule[] = [], +): 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: ${platform}. Aspect ratio: ${ratio}. Language/copy: ${language}. Market: ${market}.`); + if (options?.detailModules) parts.push(buildDetailModulePrompt(options.detailModules, 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: ${platform}. Aspect ratio: ${ratio}. Language/copy: ${language}. Market: ${market}.`); + if (options) { + if (options.gender) parts.push(`Model gender: ${options.gender}.`); + if (options.age) parts.push(`Model age: ${options.age}.`); + if (options.ethnicity) parts.push(`Model ethnicity: ${options.ethnicity}.`); + if (options.body) parts.push(`Model body type: ${options.body}.`); + if (options.appearance) parts.push(`Model appearance details: ${options.appearance}.`); + if (options.scenes?.length) parts.push(`Background scenes: ${options.scenes.join(", ")}.`); + if (options.customScene) parts.push(`Custom background scene: ${options.customScene}.`); + if (options.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 ${platform} marketplace standards.`); + parts.push(`Platform: ${platform}. Aspect ratio: ${ratio}. Language/copy: ${language}. Market: ${market}.`); + 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(" "); +}; diff --git a/src/features/ecommerce/utils/ratioUtils.test.ts b/src/features/ecommerce/utils/ratioUtils.test.ts index b5cc389..11b022b 100644 --- a/src/features/ecommerce/utils/ratioUtils.test.ts +++ b/src/features/ecommerce/utils/ratioUtils.test.ts @@ -12,18 +12,23 @@ import { } from "./ratioUtils"; describe("normalizeRatioToken", () => { - it("converts fullwidth and mojibake characters", () => { + it("normalizes non-breaking spaces", () => { expect(normalizeRatioToken("800\u00a0\u00a0px")).toBe("800 px"); }); - it("replaces * with ×", () => { + + it("replaces plain separators with the normal multiplication sign", () => { expect(normalizeRatioToken("800*800")).toBe("800×800"); }); - it("replaces mojibake 脳 with ×", () => { - expect(normalizeRatioToken("800脳800")).toBe("800×800"); + + it("replaces legacy mojibake multiply signs", () => { + expect(normalizeRatioToken("800\u8133800")).toBe("800×800"); }); - it("replaces fullwidth colon", () => { + + it("replaces fullwidth and legacy mojibake colons", () => { expect(normalizeRatioToken("1:1")).toBe("1:1"); + expect(normalizeRatioToken("1\u951b?1")).toBe("1:1"); }); + it("collapses whitespace and trims", () => { expect(normalizeRatioToken(" 1 : 1 ")).toBe("1 : 1"); }); @@ -34,10 +39,12 @@ describe("greatestCommonDivisor", () => { expect(greatestCommonDivisor(12, 8)).toBe(4); expect(greatestCommonDivisor(1920, 1080)).toBe(120); }); + it("handles zero with fallback to 1", () => { expect(greatestCommonDivisor(0, 5)).toBe(5); expect(greatestCommonDivisor(0, 0)).toBe(1); }); + it("handles negatives via abs", () => { expect(greatestCommonDivisor(-12, 8)).toBe(4); }); @@ -47,9 +54,11 @@ describe("formatAspectRatio", () => { it("reduces 1920x1080 to 16:9", () => { expect(formatAspectRatio(1920, 1080)).toBe("16:9"); }); + it("reduces 750x1000 to 3:4", () => { expect(formatAspectRatio(750, 1000)).toBe("3:4"); }); + it("reduces 800x800 to 1:1", () => { expect(formatAspectRatio(800, 800)).toBe("1:1"); }); @@ -59,13 +68,16 @@ describe("getQuickSetRatioValue", () => { it("passes through a canonical quick-set value", () => { expect(getQuickSetRatioValue("1:1")).toBe("1:1"); }); + it("derives from a WxH size string", () => { expect(getQuickSetRatioValue("1920×1080px")).toBe("16:9"); expect(getQuickSetRatioValue("750×1000px")).toBe("3:4"); }); + it("derives from a raw ratio string", () => { expect(getQuickSetRatioValue("9:16")).toBe("9:16"); }); + it("falls back to 1:1 for unparseable input", () => { expect(getQuickSetRatioValue("unknown")).toBe("1:1"); }); @@ -75,8 +87,13 @@ describe("formatRatioDisplayValue", () => { it("formats a WxHpx string with aspect suffix", () => { expect(formatRatioDisplayValue("1000×1000px 1:1")).toBe("1000×1000px\u00a0\u00a0\u00a01:1"); }); - it("reformats 800×800px without explicit aspect", () => { - expect(formatRatioDisplayValue("800×800px")).toBe("800×800px\u00a0\u00a0\u00a01:1"); + + it("reformats 800x800px without explicit aspect", () => { + expect(formatRatioDisplayValue("800x800px")).toBe("800×800px\u00a0\u00a0\u00a01:1"); + }); + + it("replaces legacy mojibake product image labels", () => { + expect(formatRatioDisplayValue("\u935f\u55d7\u6427\u9365?")).toBe("商品图"); }); }); @@ -87,6 +104,7 @@ describe("getRatioDisplayParts", () => { aspect: "1:1", }); }); + it("uses 自适应 when no aspect present", () => { const parts = getRatioDisplayParts("原图"); expect(parts.aspect).toBe("自适应"); @@ -97,6 +115,7 @@ describe("parseRatioToAspectCss", () => { it("extracts CSS aspect-ratio", () => { expect(parseRatioToAspectCss("1000×1000px 1:1")).toBe("1000 / 1000"); }); + it("falls back to 1 / 1", () => { expect(parseRatioToAspectCss("no numbers")).toBe("1 / 1"); }); @@ -106,18 +125,23 @@ describe("toSupportedImageApiRatio", () => { it("snaps square to 1:1", () => { expect(toSupportedImageApiRatio(800, 800)).toBe("1:1"); }); + it("snaps 750x1000 to 3:4", () => { expect(toSupportedImageApiRatio(750, 1000)).toBe("3:4"); }); + it("snaps 1920x1080 to 16:9", () => { expect(toSupportedImageApiRatio(1920, 1080)).toBe("16:9"); }); + it("snaps 1080x1920 to 9:16", () => { expect(toSupportedImageApiRatio(1080, 1920)).toBe("9:16"); }); + it("snaps 800x600 to 4:3", () => { expect(toSupportedImageApiRatio(800, 600)).toBe("4:3"); }); + it("returns 1:1 for non-finite or non-positive", () => { expect(toSupportedImageApiRatio(NaN, 100)).toBe("1:1"); expect(toSupportedImageApiRatio(0, 100)).toBe("1:1"); @@ -130,13 +154,16 @@ describe("normalizeRatioForApi", () => { expect(normalizeRatioForApi("1000×1000px 1:1")).toBe("1:1"); expect(normalizeRatioForApi("750×1000px 3:4")).toBe("3:4"); }); + it("derives ratio from a bare size string", () => { expect(normalizeRatioForApi("1920×1080px")).toBe("16:9"); }); + it("returns 1:1 for unparseable input", () => { expect(normalizeRatioForApi("")).toBe("1:1"); expect(normalizeRatioForApi("无尺寸信息")).toBe("1:1"); }); + it("uses the last explicit ratio when multiple present", () => { expect(normalizeRatioForApi("4:3 16:9")).toBe("16:9"); }); diff --git a/src/features/ecommerce/utils/ratioUtils.ts b/src/features/ecommerce/utils/ratioUtils.ts index 4d862f4..8340718 100644 --- a/src/features/ecommerce/utils/ratioUtils.ts +++ b/src/features/ecommerce/utils/ratioUtils.ts @@ -1,15 +1,19 @@ -// 比例 / 尺寸相关的纯计算工具。 -// 从 EcommercePage.tsx 抽出,逻辑零改动,仅加 export 以便单测。 +// Ratio and dimension formatting helpers. +// Keep compatibility with a few legacy mojibake tokens, but never emit them. // normalizeRatioForPlatform / formatUploadedImageRatio 因依赖平台规格表与 CloneImageItem, // 暂留在 EcommercePage.tsx,后续随 platformSpec 一起整理。 +const LEGACY_MULTIPLY_SIGN = "\u8133"; +const LEGACY_FULLWIDTH_COLON = "\u951b?"; +const LEGACY_PRODUCT_IMAGE_LABEL = "\u935f\u55d7\u6427\u9365?"; + export const normalizeRatioToken = (value: string) => value .replaceAll("\u00a0", " ") - .replaceAll("脳", "×") + .replaceAll(LEGACY_MULTIPLY_SIGN, "×") .replaceAll("*", "×") .replaceAll(":", ":") - .replace(/锛\?/g, ":") + .replaceAll(LEGACY_FULLWIDTH_COLON, ":") .replace(/\s+/g, " ") .trim(); @@ -64,7 +68,7 @@ export const formatRatioDisplayValue = (value: string) => { .replace("短视频", "短视频") .replace("主图", "主图") .replace("商品主图", "商品主图") - .replace("鍟嗗搧鍥?", "商品图") + .replace(LEGACY_PRODUCT_IMAGE_LABEL, "商品图") .replace(/\s+:/g, ":") .replace(/:\s+/g, ":"); };