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, ":");
};