Files
omniai-web/src/features/ecommerce/EcommercePage.tsx
T

2366 lines
104 KiB
TypeScript
Raw Normal View History

2026-06-02 12:38:01 +08:00
import {
AppstoreOutlined,
CloudUploadOutlined,
CloseOutlined,
FileImageOutlined,
FrownOutlined,
2026-06-02 12:38:01 +08:00
LoadingOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
QuestionCircleOutlined,
ReloadOutlined,
2026-06-02 12:38:01 +08:00
SettingOutlined,
SkinOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react";
import { EcommerceProgressBar } from "./EcommerceProgressBar";
const OSS_MUBAN = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban";
const ecommerceGenerated = `${OSS_MUBAN}/ecommerce-carousel-generated.png`;
const ecommerceSlide4 = `${OSS_MUBAN}/slide-4.png`;
const ecommerceSlide5 = `${OSS_MUBAN}/slide-5.png`;
2026-06-02 12:38:01 +08:00
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu";
import EcommerceVideoWorkspace from "./EcommerceVideoWorkspace";
import EcommerceDetailPanel from "./panels/EcommerceDetailPanel";
import EcommerceSetPanel from "./panels/EcommerceSetPanel";
import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel";
import EcommerceClonePanel from "./panels/EcommerceClonePanel";
2026-06-02 12:38:01 +08:00
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { ServerRequestError } from "../../api/serverConnection";
import { waitForTask } from "../../api/taskSubscription";
import { toast } from "../../components/toast/toastStore";
import { useGenerationTasks } from "../../hooks/useGenerationTasks";
import { useAppStore } from "../../stores";
2026-06-02 12:38:01 +08:00
import {
normalizeEcommerceImageMime,
summarizeRejectedImages,
validateEcommerceImageFiles,
} from "./ecommerceImageValidation";
2026-06-02 12:38:01 +08:00
interface ProductClonePageProps {
[key: string]: unknown;
}
type ProductCloneStatus = "idle" | "ready" | "generating" | "done" | "failed";
2026-06-02 12:38:01 +08:00
type ProductSetOutputKey = "set" | "detail" | "model" | "video";
type CloneOutputKey = ProductSetOutputKey | "hot" | "video-outfit";
2026-06-02 12:38:01 +08:00
type CloneSetCountKey = "selling" | "white" | "scene";
type CloneModelPanelTab = "scene" | "model";
type CloneVideoQualityKey = "standard" | "high" | "ultra";
type ProductSetStatus = "idle" | "ready" | "generating" | "done" | "failed";
2026-06-02 12:38:01 +08:00
type ProductKitToolKey = "set" | "detail" | "wear" | "clone";
type CloneBasicSelectKey = "platform" | "market" | "language" | "ratio";
type CloneModelSelectKey = "gender" | "age" | "ethnicity" | "body";
type CloneReferenceMode = "upload" | "link";
type CloneReplicateLevelKey = "style" | "high";
type TryOnModelSource = "ai" | "library";
type TryOnStatus = "idle" | "modeling" | "ready" | "generating" | "done" | "failed";
type DetailStatus = "idle" | "ready" | "generating" | "done" | "failed";
2026-06-02 12:38:01 +08:00
interface CloneImageItem {
id: string;
src: string;
name: string;
width?: number;
height?: number;
format?: string;
}
interface CloneResult {
id: string;
src: string;
label: string;
}
interface CloneSavedSetting {
id: string;
name: string;
savedAt: string;
output: CloneOutputKey;
platform: string;
market: string;
language: string;
ratio: string;
setCounts: Record<CloneSetCountKey, number>;
detailModules: string[];
modelPanelTab: CloneModelPanelTab;
modelScenes: string[];
modelCustomScene: string;
modelGender: string;
modelAge: string;
modelEthnicity: string;
modelBody: string;
modelAppearance: string;
videoQuality: CloneVideoQualityKey;
videoDurationSeconds: number;
videoSmart: boolean;
referenceMode?: CloneReferenceMode;
replicateLevel?: CloneReplicateLevelKey;
requirement: string;
}
type PlatformRatioModeKey = ProductSetOutputKey | "hot" | "video-outfit";
2026-06-02 12:38:01 +08:00
interface PlatformRatioGroup {
ratios: string[];
defaultRatio: string;
}
const sideTools: Array<{ key: ProductKitToolKey; label: string; icon: ReactNode }> = [
{ key: "set", label: "商品套图", icon: <AppstoreOutlined /> },
{ key: "detail", label: "A+详情", icon: <FileImageOutlined /> },
{ key: "wear", label: "服饰穿戴", icon: <SkinOutlined /> },
{ key: "clone", label: "电商AI作图", icon: <AppstoreOutlined /> },
];
const platformSpecOptions: Array<{
label: string;
ratios: string[];
defaultRatio: string;
ratioGroups?: Partial<Record<PlatformRatioModeKey, PlatformRatioGroup>>;
specs: string[];
tip?: string;
aliases?: string[];
}> = [
{
label: "淘宝/天猫",
ratios: ["淘宝主图 / SKU 图 800×800px", "详情页宽 750px", "详情页宽 790px"],
defaultRatio: "淘宝主图 / SKU 图 800×800px",
ratioGroups: {
set: {
ratios: ["1000×1000px\u00a0\u00a0\u00a011", "800×800px\u00a0\u00a0\u00a011"],
defaultRatio: "1000×1000px\u00a0\u00a0\u00a011",
},
detail: {
ratios: [
"750×1000px\u00a0\u00a0\u00a034",
"790×1053px\u00a0\u00a0\u00a034",
"750×1125px\u00a0\u00a0\u00a023",
"790×1185px\u00a0\u00a0\u00a023",
],
defaultRatio: "750×1000px\u00a0\u00a0\u00a034",
},
model: {
ratios: ["750×1000px\u00a0\u00a0\u00a034"],
defaultRatio: "750×1000px\u00a0\u00a0\u00a034",
},
video: {
ratios: ["1080×1920px\u00a0\u00a0\u00a0916", "1080×1440px\u00a0\u00a0\u00a034", "1080×1080px\u00a0\u00a0\u00a011"],
defaultRatio: "1080×1920px\u00a0\u00a0\u00a0916",
},
hot: {
ratios: ["800×800px\u00a0\u00a0\u00a011"],
defaultRatio: "800×800px\u00a0\u00a0\u00a011",
},
},
specs: ["主图 / SKU 图 800×800px,≤3MB", "详情页宽 750px 或 790px,单张高≤1546px"],
tip: "建议主图 200-400KB JPG,超过 500KB 会影响加载速度。",
},
{
label: "京东",
ratios: ["京东主图 / SKU 图 800×800px", "详情页宽 750px", "首图主体占比 ≥70%"],
defaultRatio: "京东主图 / SKU 图 800×800px",
ratioGroups: {
set: {
ratios: ["1000×1000px\u00a0\u00a0\u00a011"],
defaultRatio: "1000×1000px\u00a0\u00a0\u00a011",
},
detail: {
ratios: [
"750×1000px\u00a0\u00a0\u00a034",
"990×1320px\u00a0\u00a0\u00a034",
"750×1125px\u00a0\u00a0\u00a023",
"990×1485px\u00a0\u00a0\u00a023",
],
defaultRatio: "750×1000px\u00a0\u00a0\u00a034",
},
model: {
ratios: ["750×1125px\u00a0\u00a0\u00a023", "990×1485px\u00a0\u00a0\u00a023"],
defaultRatio: "750×1125px\u00a0\u00a0\u00a023",
},
video: {
ratios: ["1080×1920px\u00a0\u00a0\u00a0916", "1920×1080px\u00a0\u00a0\u00a0169"],
defaultRatio: "1080×1920px\u00a0\u00a0\u00a0916",
},
hot: {
ratios: ["800×800px\u00a0\u00a0\u00a011"],
defaultRatio: "800×800px\u00a0\u00a0\u00a011",
},
},
specs: ["主图 / SKU 图 800×800px,白底,≤1MB", "详情页宽 750px,首图主体占比 ≥70%"],
},
{
label: "拼多多",
ratios: ["主图 750×352px", "主图 800×800px", "详情页宽 750px"],
defaultRatio: "主图 750×352px",
ratioGroups: {
set: {
ratios: ["800×800px\u00a0\u00a0\u00a011", "750×1000px\u00a0\u00a0\u00a034"],
defaultRatio: "800×800px\u00a0\u00a0\u00a011",
},
detail: {
ratios: ["750×1000px\u00a0\u00a0\u00a034", "750×1125px\u00a0\u00a0\u00a023"],
defaultRatio: "750×1000px\u00a0\u00a0\u00a034",
},
model: {
ratios: ["750×1000px\u00a0\u00a0\u00a034"],
defaultRatio: "750×1000px\u00a0\u00a0\u00a034",
},
video: {
ratios: ["1080×1920px\u00a0\u00a0\u00a0916"],
defaultRatio: "1080×1920px\u00a0\u00a0\u00a0916",
},
hot: {
ratios: ["800×800px\u00a0\u00a0\u00a011"],
defaultRatio: "800×800px\u00a0\u00a0\u00a011",
},
},
specs: ["主图 750×352px 或 800×800px,≤1MB", "详情页宽 750px,要求纯白底、无水印、无拼接"],
},
{
label: "抖音电商",
ratios: ["短视频 1080×1920px"],
defaultRatio: "短视频 1080×1920px",
ratioGroups: {
video: {
ratios: ["1080×1920px\u00a0\u00a0\u00a0916"],
defaultRatio: "1080×1920px\u00a0\u00a0\u00a0916",
},
hot: {
ratios: ["800×800px\u00a0\u00a0\u00a011"],
defaultRatio: "800×800px\u00a0\u00a0\u00a011",
},
},
specs: ["短视频 1080×1920px9:16", "30s 内最佳"],
},
{
label: "亚马逊 Amazon",
ratios: ["主图 ≥1600×1600px", "建议 2000×2000px+", "最小 500×500px"],
defaultRatio: "主图 ≥1600×1600px",
ratioGroups: {
set: {
ratios: ["1600×1600px\u00a0\u00a0\u00a011"],
defaultRatio: "1600×1600px\u00a0\u00a0\u00a011",
},
detail: {
ratios: ["1600×1600px\u00a0\u00a0\u00a011", "1200×1800px\u00a0\u00a0\u00a023", "1200×1600px\u00a0\u00a0\u00a034"],
defaultRatio: "1200×1800px\u00a0\u00a0\u00a023",
},
model: {
ratios: ["1200×1800px\u00a0\u00a0\u00a023"],
defaultRatio: "1200×1800px\u00a0\u00a0\u00a023",
},
video: {
ratios: ["1920×1080px\u00a0\u00a0\u00a0169"],
defaultRatio: "1920×1080px\u00a0\u00a0\u00a0169",
},
hot: {
ratios: ["1600×1600px\u00a0\u00a0\u00a011"],
defaultRatio: "1600×1600px\u00a0\u00a0\u00a011",
},
},
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\u00a011"],
defaultRatio: "800×800px\u00a0\u00a0\u00a011",
},
detail: {
ratios: ["750×1000px\u00a0\u00a0\u00a034", "750×1125px\u00a0\u00a0\u00a023"],
defaultRatio: "750×1000px\u00a0\u00a0\u00a034",
},
model: {
ratios: ["750×1000px\u00a0\u00a0\u00a034"],
defaultRatio: "750×1000px\u00a0\u00a0\u00a034",
},
video: {
ratios: ["1080×1920px\u00a0\u00a0\u00a0916"],
defaultRatio: "1080×1920px\u00a0\u00a0\u00a0916",
},
hot: {
ratios: ["800×800px\u00a0\u00a0\u00a011"],
defaultRatio: "800×800px\u00a0\u00a0\u00a011",
},
},
specs: ["商品主图推荐 1024×1024px,基础 800×800px", "≤2MB,白底或浅色底"],
aliases: ["虾皮 Shopee/Lazada", "虾皮"],
},
{
label: "Lazada",
ratios: ["商品主图 800×800px"],
defaultRatio: "商品主图 800×800px",
ratioGroups: {
set: {
ratios: ["800×800px\u00a0\u00a0\u00a011"],
defaultRatio: "800×800px\u00a0\u00a0\u00a011",
},
detail: {
ratios: ["750×1000px\u00a0\u00a0\u00a034", "750×1125px\u00a0\u00a0\u00a023"],
defaultRatio: "750×1000px\u00a0\u00a0\u00a034",
},
model: {
ratios: ["750×1000px\u00a0\u00a0\u00a034"],
defaultRatio: "750×1000px\u00a0\u00a0\u00a034",
},
video: {
ratios: ["1080×1920px\u00a0\u00a0\u00a0916"],
defaultRatio: "1080×1920px\u00a0\u00a0\u00a0916",
},
hot: {
ratios: ["800×800px\u00a0\u00a0\u00a011"],
defaultRatio: "800×800px\u00a0\u00a0\u00a011",
},
},
specs: ["商品主图 800×800px1:1"],
},
{
label: "Instagram",
ratios: ["帖子 1080×1350px", "帖子 1080×1080px", "Stories / Reels 1080×1920px", "头像 320×320px"],
defaultRatio: "帖子 1080×1350px",
ratioGroups: {
set: {
ratios: ["1080×1080px\u00a0\u00a0\u00a011", "1080×1350px\u00a0\u00a0\u00a045"],
defaultRatio: "1080×1080px\u00a0\u00a0\u00a011",
},
detail: {
ratios: ["1080×1350px\u00a0\u00a0\u00a045"],
defaultRatio: "1080×1350px\u00a0\u00a0\u00a045",
},
model: {
ratios: ["1080×1350px\u00a0\u00a0\u00a045"],
defaultRatio: "1080×1350px\u00a0\u00a0\u00a045",
},
video: {
ratios: ["1080×1920px\u00a0\u00a0\u00a0916", "1080×1350px\u00a0\u00a0\u00a045"],
defaultRatio: "1080×1920px\u00a0\u00a0\u00a0916",
},
},
specs: ["帖子 1080×1350px 或 1080×1080px", "Stories / Reels 封面 1080×1920px,头像 320×320px"],
tip: "建议 ≤1MB JPG。",
aliases: ["Instagram Reels"],
},
{
label: "速卖通",
ratios: ["主图 800×800px", "主图 1000×1000px+"],
defaultRatio: "主图 800×800px",
ratioGroups: {
set: {
ratios: ["1000×1000px\u00a0\u00a0\u00a011"],
defaultRatio: "1000×1000px\u00a0\u00a0\u00a011",
},
detail: {
ratios: ["750×1125px\u00a0\u00a0\u00a023", "750×1000px\u00a0\u00a0\u00a034"],
defaultRatio: "750×1125px\u00a0\u00a0\u00a023",
},
model: {
ratios: ["750×1125px\u00a0\u00a0\u00a023"],
defaultRatio: "750×1125px\u00a0\u00a0\u00a023",
},
video: {
ratios: ["1080×1920px\u00a0\u00a0\u00a0916", "1920×1080px\u00a0\u00a0\u00a0169"],
defaultRatio: "1080×1920px\u00a0\u00a0\u00a0916",
},
hot: {
ratios: ["800×800px\u00a0\u00a0\u00a011"],
defaultRatio: "800×800px\u00a0\u00a0\u00a011",
},
},
specs: ["主图建议 800×800px 或更高,1:1", "适合跨境电商主图、SKU 图和场景图"],
},
{
label: "eBay",
ratios: ["商品图 1:1", "白底多角度展示图 1:1"],
defaultRatio: "商品图 1:1",
ratioGroups: {
set: {
ratios: ["1600×1600px\u00a0\u00a0\u00a011"],
defaultRatio: "1600×1600px\u00a0\u00a0\u00a011",
},
detail: {
ratios: ["1000×1500px\u00a0\u00a0\u00a023", "1200×1600px\u00a0\u00a0\u00a034"],
defaultRatio: "1000×1500px\u00a0\u00a0\u00a023",
},
model: {
ratios: ["1000×1500px\u00a0\u00a0\u00a023"],
defaultRatio: "1000×1500px\u00a0\u00a0\u00a023",
},
video: {
ratios: ["1920×1080px\u00a0\u00a0\u00a0169", "1080×1920px\u00a0\u00a0\u00a0916"],
defaultRatio: "1920×1080px\u00a0\u00a0\u00a0169",
},
hot: {
ratios: ["1600×1600px\u00a0\u00a0\u00a011"],
defaultRatio: "1600×1600px\u00a0\u00a0\u00a011",
},
},
specs: ["商品图建议 1:1,主体清晰居中", "适合白底主图和多角度展示图"],
},
{
label: "TikTok Shop",
ratios: ["商品主图 1:1", "短视频 / 竖版封面 9:16"],
defaultRatio: "商品主图 1:1",
ratioGroups: {
set: {
ratios: ["1280×1280px\u00a0\u00a0\u00a011"],
defaultRatio: "1280×1280px\u00a0\u00a0\u00a011",
},
detail: {
ratios: ["1080×1350px\u00a0\u00a0\u00a045"],
defaultRatio: "1080×1350px\u00a0\u00a0\u00a045",
},
model: {
ratios: ["1080×1350px\u00a0\u00a0\u00a045"],
defaultRatio: "1080×1350px\u00a0\u00a0\u00a045",
},
video: {
ratios: ["1080×1920px\u00a0\u00a0\u00a0916"],
defaultRatio: "1080×1920px\u00a0\u00a0\u00a0916",
},
hot: {
ratios: ["800×800px\u00a0\u00a0\u00a011"],
defaultRatio: "800×800px\u00a0\u00a0\u00a011",
},
},
specs: ["商品主图建议 1:1", "短视频/竖版封面建议 9:16"],
},
];
const platformOptions = platformSpecOptions.map((option) => option.label);
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<string, string> = {
: "英文",
: "日文",
: "德文",
: "法文",
: "韩文",
西: "西班牙语",
: "葡萄牙语",
: "印度尼西亚语",
: "菲律宾语(他加禄语)",
};
const defaultPlatformSpec = platformSpecOptions[0]!;
const getPlatformSpec = (value: string) =>
platformSpecOptions.find((option) => option.label === value || option.aliases?.includes(value)) ?? defaultPlatformSpec;
const normalizePlatform = (value: string) => getPlatformSpec(value).label;
const domesticPlatformLabels = new Set(["淘宝/天猫", "京东", "拼多多", "抖音电商"]);
const domesticPlatformLanguages = ["中文"];
const isDomesticPlatform = (platformValue: string) => domesticPlatformLabels.has(normalizePlatform(platformValue));
const getPlatformRatioGroup = (value: string, mode?: PlatformRatioModeKey): PlatformRatioGroup => {
const platformSpec = getPlatformSpec(value);
return (mode ? platformSpec.ratioGroups?.[mode] : null) ?? {
ratios: platformSpec.ratios,
defaultRatio: platformSpec.defaultRatio,
};
};
const getPlatformRatioOptions = (value: string, mode?: PlatformRatioModeKey) => getPlatformRatioGroup(value, mode).ratios;
const getPlatformDefaultRatio = (value: string, mode?: PlatformRatioModeKey) => getPlatformRatioGroup(value, mode).defaultRatio;
const getUniqueRatioOptions = (ratios: string[]) => Array.from(new Set(ratios));
const normalizeRatioToken = (value: string) => value.replace(//g, ":").trim();
const normalizeRatioForPlatform = (platformValue: string, ratioValue: string, mode?: PlatformRatioModeKey) => {
const platformRatios = getPlatformRatioOptions(platformValue, mode);
if (platformRatios.includes(ratioValue)) return ratioValue;
const normalizedRatio = normalizeRatioToken(ratioValue);
const matchedRatio = platformRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio));
return matchedRatio ?? getPlatformDefaultRatio(platformValue, mode);
};
const formatRatioDisplayValue = (value: string) => {
if (!value.includes("套图")) return value;
const size = value.match(/\d+\s*×\s*\d+\s*px?/u)?.[0]?.replace(/\s+/g, "") ?? "";
const ratio = value.match(/\d+(?:\.\d+)?\s*[:]\s*\d+(?:\.\d+)?/u)?.[0]?.replace(/\s+/g, "").replace(/:/g, "") ?? "";
return size && ratio ? `${size}\u00a0\u00a0\u00a0${ratio}` : value.replace(/^套图[:]\s*/, "");
};
const greatestCommonDivisor = (left: number, right: number): number => {
let a = Math.abs(left);
let b = Math.abs(right);
while (b) {
[a, b] = [b, a % b];
}
return a || 1;
};
const formatAspectRatio = (width: number, height: number) => {
const divisor = greatestCommonDivisor(width, height);
return `${Math.round(width / divisor)}${Math.round(height / divisor)}`;
};
const formatUploadedImageRatio = (image?: CloneImageItem) => {
if (!image) return null;
const format = image.format ? `\u00a0\u00a0\u00a0${image.format}` : "";
if (!image.width || !image.height) return `上传图片\u00a0\u00a0\u00a0原图比例${format}`;
return `上传图片 ${image.width}×${image.height}px\u00a0\u00a0\u00a0${formatAspectRatio(image.width, image.height)}${format}`;
};
const defaultMarketLanguageOption = marketLanguageOptions[0]!;
const normalizeMarket = (value: string) =>
marketLanguageOptions.some((option) => option.country === value) ? value : defaultMarketLanguageOption.country;
const normalizeLanguage = (value: string) => languageAliases[value] ?? value;
const uniqueLanguages = (languages: string[]) => Array.from(new Set(languages));
const appendEnglish = (languages: string[]) => Array.from(new Set([...languages, "英文"]));
const getMarketLanguageOptions = (marketValue: string) =>
appendEnglish((marketLanguageOptions.find((option) => option.country === marketValue) ?? defaultMarketLanguageOption).languages);
const getPlatformLanguageOptions = (platformValue: string, marketValue: string) => {
const marketLanguages = getMarketLanguageOptions(marketValue);
if (!isDomesticPlatform(platformValue)) return marketLanguages;
const localLanguages = marketLanguages.filter((item) => item !== "英文");
return uniqueLanguages([...localLanguages, ...domesticPlatformLanguages, "英文"]);
};
const getPlatformDefaultLanguage = (platformValue: string, marketValue: string) =>
isDomesticPlatform(platformValue) ? "中文" : (getPlatformLanguageOptions(platformValue, marketValue)[0] ?? languageOptions[0] ?? "英文");
const normalizeLanguageForPlatform = (platformValue: string, marketValue: string, languageValue: string) => {
const normalizedLanguage = normalizeLanguage(languageValue);
const platformLanguages = getPlatformLanguageOptions(platformValue, marketValue);
return platformLanguages.includes(normalizedLanguage) ? normalizedLanguage : getPlatformDefaultLanguage(platformValue, marketValue);
};
const productSetOutputOptions: Array<{ key: ProductSetOutputKey; label: string }> = [
{ key: "set", label: "套图" },
{ key: "detail", label: "详情图" },
{ key: "model", label: "模特图" },
{ key: "video", label: "短视频" },
];
const cloneOutputOptions: Array<{ key: CloneOutputKey; label: string }> = [
...productSetOutputOptions,
{ key: "hot", label: "爆款图复刻" },
{ key: "video-outfit", label: "视频换装" },
2026-06-02 12:38:01 +08:00
];
const cloneSetCountOptions: Array<{
key: CloneSetCountKey;
title: string;
desc: string;
}> = [
{ key: "selling", title: "卖点图", desc: "展示商品的核心卖点及细节特写" },
{ key: "white", title: "白底图", desc: "白底主图,多角度呈现商品细节" },
{ key: "scene", title: "场景图", desc: "展示商品的生活使用场景和人物搭配" },
];
const defaultCloneSetCounts: Record<CloneSetCountKey, number> = {
selling: 3,
white: 1,
scene: 3,
};
const minCloneSetTotal = 1;
const maxCloneSetTotal = 16;
const maxCloneProductImages = 7;
const maxCloneReferenceImages = 20;
const cloneVideoDurationMin = 5;
const cloneVideoDurationMax = 15;
const cloneLatestSettingStorageKey = "omniai.clone-ai.latest-setting";
const cloneVideoQualityOptions: Array<{ key: CloneVideoQualityKey; label: string; desc: string }> = [
{ key: "standard", label: "标准", desc: "快速出片" },
{ key: "high", label: "高清", desc: "推荐" },
{ key: "ultra", label: "超清", desc: "细节增强" },
];
const cloneReplicateLevelOptions: Array<{ key: CloneReplicateLevelKey; title: string; desc: string }> = [
{ key: "style", title: "参考风格", desc: "参考整体风格和结构,自动调整色彩和重构场景。" },
{ key: "high", title: "高度复刻", desc: "参照参考图视觉结构替换产品和文案,场景细节略有差异。" },
];
const tryOnRatioOptions = ["3:4", "1:1", "9:16"];
const tryOnScenes = ["纯色棚拍", "都市街头", "街角咖啡", "自然草坪", "度假海滩", "温馨居家", "艺术展馆"];
const normalizeCloneModelSceneSelection = (scenes: string[] | null | undefined) => {
const validScenes = (scenes ?? []).filter((scene) => typeof scene === "string" && scene.trim());
const latestScene = validScenes[validScenes.length - 1];
return latestScene ? [latestScene] : [];
};
const tryOnModelOptions = {
gender: ["女", "男"],
age: ["青年", "少年", "中年"],
ethnicity: ["欧美白人", "亚洲人", "拉美裔", "非洲裔"],
body: ["标准", "高挑", "微胖", "运动"],
};
const sampleResults = [ecommerceSlide4, ecommerceGenerated, ecommerceSlide5];
const productSetAssets = {
main: "https://xiuxiu-pro.meitudata.com/poster/6e3eebacad8d5e47e1896ee7d54827bc.png?imageView2/2/w/800/format/webp/q/80/ignore-error/1",
scene: "https://xiuxiu-pro.meitudata.com/poster/21225fc86b28d9e4d85636483c67408e.png?imageView2/2/w/400/format/webp/q/80/ignore-error/1",
model: "https://xiuxiu-pro.meitudata.com/poster/4b8e6d1bd0996be52822dd1fac73cffd.png?imageView2/2/w/400/format/webp/q/80/ignore-error/1",
detail: "https://xiuxiu-pro.meitudata.com/poster/29dd195a450ee5a7f7451ded6680e969.png?imageView2/2/w/400/format/webp/q/80/ignore-error/1",
selling: "https://xiuxiu-pro.meitudata.com/poster/66bdef541b67588e8db2a03b39dc815b.jpg?imageView2/2/w/400/format/webp/q/80/ignore-error/1",
hosting: "https://xiuxiu-pro-new.meitudata.com/poster/50c17a98c77fac4d0523c8cbdf0d33ca.jpg?imageView2/2/format/webp/q/80/ignore-error/1",
};
const productSetPreviewCards = [
{ id: "main", label: "01 主图 (白底/合规)", src: productSetAssets.main },
{ id: "scene", label: "02 场景展示", src: productSetAssets.scene },
{ id: "model", label: "03 模特场景图", src: productSetAssets.model },
{ id: "detail", label: "04 细节说明", src: productSetAssets.detail },
{ id: "selling", label: "05 卖点详解", src: productSetAssets.selling },
];
const tryOnAssets = {
dressA: "https://xiuxiu-pro-new.meitudata.com/poster/133ca2d6c13bac6cfaa11fa29a155551.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
dressB: "https://xiuxiu-pro-new.meitudata.com/poster/a661006820e888d9df13023075096e94.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
modelWoman: "https://xiuxiu-pro-new.meitudata.com/poster/f806c6afaf6f38f634c156c5b6058201.png?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
modelMan: "https://xiuxiu-pro-new.meitudata.com/poster/8c26503c67dc695e25e420e48caf4cde.png?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
modelAsian: "https://xiuxiu-pro-new.meitudata.com/poster/0f2a7c92707312ec74647d66f15a6ef9.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
tryA: "https://xiuxiu-pro-new.meitudata.com/poster/7f77e0866f05ff723959e1f48830713c.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
tryB: "https://xiuxiu-pro-new.meitudata.com/poster/0b951004eabcdd7cae595dfdb4c7f8c3.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
jacket: "https://xiuxiu-pro-new.meitudata.com/poster/fdbf10b4c92af5b1986444cdd9affaa5.png?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
jacketResultA: "https://xiuxiu-pro-new.meitudata.com/poster/b1152bb292323b87696dd2f6e518e818.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
jacketResultB: "https://xiuxiu-pro-new.meitudata.com/poster/1c1e757702108fef92d85be0c2802c01.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
hat: "https://xiuxiu-pro-new.meitudata.com/poster/278af735b076ab812888802d3e3db0b8.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
hatResultA: "https://xiuxiu-pro-new.meitudata.com/poster/a3ba241b7aa6060869b096d3f10e5db4.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
hatResultB: "https://xiuxiu-pro-new.meitudata.com/poster/01ed1ae80a187c70c682bb6d0ec6fa68.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
};
const tryOnCards = [
{
title: "多件混搭自动融合",
tone: "red",
inputs: [tryOnAssets.dressA, tryOnAssets.dressB, tryOnAssets.modelWoman],
results: [tryOnAssets.tryA, tryOnAssets.tryB],
},
{
title: "一件也能出大片",
tone: "brown",
inputs: [tryOnAssets.jacket, tryOnAssets.modelMan],
results: [tryOnAssets.jacketResultA, tryOnAssets.jacketResultB],
},
{
title: "鞋帽饰品完美适配",
tone: "gold",
inputs: [tryOnAssets.hat, tryOnAssets.modelAsian],
results: [tryOnAssets.hatResultA, tryOnAssets.hatResultB],
},
];
const detailTypeOptions = ["普通A+", "品牌A+", "标准详情页", "移动端长图"];
const detailModules = [
{ id: "hero", title: "首屏主视觉", desc: "传递核心价值" },
{ id: "selling", title: "核心卖点图", desc: "突出卖点优势" },
{ id: "usage", title: "使用场景图", desc: "呈现真实使用场景" },
{ id: "angle", title: "多角度图", desc: "多角度呈现外观" },
{ id: "scene", title: "场景氛围图", desc: "展示使用场景" },
{ id: "detail", title: "商品细节图", desc: "放大材质与工艺" },
{ id: "story", title: "品牌故事图", desc: "传达品牌理念" },
{ id: "size", title: "尺寸/容量/尺码图", desc: "展示规格信息" },
{ id: "compare", title: "效果对比图", desc: "使用前后效果对比" },
{ id: "spec", title: "详细规格/参数表", desc: "展示详细商品数据" },
{ id: "craft", title: "工艺制作图", desc: "展示工艺制作过程" },
{ id: "gift", title: "配件/赠品图", desc: "明确收货的所有物品" },
{ id: "series", title: "系列展示图", desc: "多色或多SKU展示" },
{ id: "ingredient", title: "商品成分图", desc: "展示配方/材质/成分" },
{ id: "service", title: "售后保障图", desc: "说明质保退换政策" },
{ id: "tips", title: "使用建议图", desc: "商品使用的注意事项" },
];
const defaultDetailModuleIds: string[] = [];
const defaultCloneDetailModuleIds = ["hero", "selling", "usage", "angle", "scene", "detail"];
const cloneDetailModules = detailModules;
const detailAssets = {
productA: "https://xiuxiu-pro.meitudata.com/poster/182676711565ee98e20cf92d766d1643.png?imageView2/2/format/webp/q/80/ignore-error/1",
productB: "https://xiuxiu-pro.meitudata.com/poster/ba6312cbc3a32ceb8966f9ea20b9ee9c.png?imageView2/2/format/webp/q/80/ignore-error/1",
productC: "https://xiuxiu-pro.meitudata.com/poster/7ee5753a3141fa12cda155126c8225d3.png?imageView2/2/format/webp/q/80/ignore-error/1",
longPage: "https://xiuxiu-pro.meitudata.com/poster/19ef313484fc87c9bdd3cd52ce2a5947.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridA: "https://xiuxiu-pro.meitudata.com/poster/e74e8d920ac0f87020f90457d42a7153.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridB: "https://xiuxiu-pro.meitudata.com/poster/1652064f17c5c2b32ce287244b505c15.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridC: "https://xiuxiu-pro.meitudata.com/poster/dd8abace327edf61d8a8e2d7db42cfbe.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridD: "https://xiuxiu-pro.meitudata.com/poster/7dc397f1cb76a35f7f0ed3c3ce78ba81.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridE: "https://xiuxiu-pro.meitudata.com/poster/1199bd8b968a5162752e1ee2b093d315.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridF: "https://xiuxiu-pro.meitudata.com/poster/7a8cdb3693418df9915741960f8f5aa8.png?imageView2/2/format/webp/q/80/ignore-error/1",
};
const detailProductSamples = [detailAssets.productA, detailAssets.productB, detailAssets.productC];
const detailGridSamples = [detailAssets.gridA, detailAssets.gridB, detailAssets.gridC, detailAssets.gridD, detailAssets.gridE, detailAssets.gridF];
function getImageFileFormat(file: File) {
const mimeFormat = file.type.split("/")[1]?.replace("jpeg", "jpg").toUpperCase();
if (mimeFormat) return mimeFormat;
return file.name.split(".").pop()?.toUpperCase() ?? "";
}
function readImageDimensions(src: string): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
image.onerror = reject;
image.src = src;
});
}
function createObjectImageItems(files: File[], limit: number, prefix: string) {
return Array.from(files)
.slice(0, limit)
.map<CloneImageItem>((file, index) => ({
id: `${prefix}-${Date.now()}-${index}`,
src: URL.createObjectURL(file),
name: file.name,
format: getImageFileFormat(file),
}));
}
function notifyRejectedImages(files: File[]): File[] {
const { accepted, rejected } = validateEcommerceImageFiles(files);
const message = summarizeRejectedImages(rejected);
if (message) toast.error(message);
return accepted;
}
2026-06-02 12:38:01 +08:00
function isCloneSavedSetting(item: unknown): item is CloneSavedSetting {
const candidate = item as Partial<CloneSavedSetting>;
return (
typeof candidate.id === "string" &&
typeof candidate.name === "string" &&
typeof candidate.savedAt === "string" &&
typeof candidate.output === "string" &&
typeof candidate.platform === "string" &&
typeof candidate.market === "string" &&
typeof candidate.language === "string" &&
typeof candidate.ratio === "string" &&
typeof candidate.videoDurationSeconds === "number"
);
}
function readCloneLatestSetting() {
if (typeof window === "undefined") return null;
try {
const rawValue = window.localStorage.getItem(cloneLatestSettingStorageKey);
if (rawValue) {
const parsedValue: unknown = JSON.parse(rawValue);
if (isCloneSavedSetting(parsedValue)) return parsedValue;
}
} catch {
return null;
}
return null;
}
function writeCloneLatestSetting(setting: CloneSavedSetting) {
if (typeof window === "undefined") return;
window.localStorage.setItem(cloneLatestSettingStorageKey, JSON.stringify(setting));
}
function clampCloneVideoDuration(value: number) {
return Math.min(cloneVideoDurationMax, Math.max(cloneVideoDurationMin, Math.round(value)));
}
function ProductClonePage(_props: ProductClonePageProps = {}) {
const setInputRef = useRef<HTMLInputElement>(null);
const productInputRef = useRef<HTMLInputElement>(null);
const cloneReferenceInputRef = useRef<HTMLInputElement>(null);
const requirementTextareaRef = useRef<HTMLTextAreaElement>(null);
const garmentInputRef = useRef<HTMLInputElement>(null);
const detailInputRef = useRef<HTMLInputElement>(null);
const countHoldTimeoutRef = useRef<number | null>(null);
const countHoldIntervalRef = useRef<number | null>(null);
const imageGen = useGenerationTasks({ sourceView: "ecommerce" });
const appUsage = useAppStore((s) => s.usage);
2026-06-02 12:38:01 +08:00
const latestCloneSettingRef = useRef<CloneSavedSetting | null>(null);
const skipInitialCloneAutoSaveRef = useRef(true);
const skipNextCloneAutoSaveRef = useRef(false);
const [activeTool, setActiveTool] = useState<ProductKitToolKey>("clone");
const [setImages, setSetImages] = useState<CloneImageItem[]>([]);
const [productSetPlatform, setProductSetPlatform] = useState(platformOptions[0]);
const [productSetMarket, setProductSetMarket] = useState(marketOptions[0]);
const [productSetLanguage, setProductSetLanguage] = useState(getPlatformDefaultLanguage(platformOptions[0], marketOptions[0]));
const [productSetRatio, setProductSetRatio] = useState(getPlatformDefaultRatio(platformOptions[0]));
const [productSetRequirement, setProductSetRequirement] = useState("");
const [productSetOutput, setProductSetOutput] = useState<ProductSetOutputKey>("video");
const [productSetStatus, setProductSetStatus] = useState<ProductSetStatus>("idle");
const [productSetResultImages, setProductSetResultImages] = useState<string[]>([]);
2026-06-02 12:38:01 +08:00
const [isSetUploadDragging, setIsSetUploadDragging] = useState(false);
const [selectedProductSetPreview, setSelectedProductSetPreview] = useState<{ src: string; label: string } | null>(null);
const [showHostingModal, setShowHostingModal] = useState(false);
const [productImages, setProductImages] = useState<CloneImageItem[]>([]);
const [isProductUploadDragging, setIsProductUploadDragging] = useState(false);
const [cloneOutput, setCloneOutput] = useState<CloneOutputKey>("detail");
const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState<CloneBasicSelectKey | null>(null);
const [openCloneModelSelect, setOpenCloneModelSelect] = useState<CloneModelSelectKey | null>(null);
const [cloneModelSelectDropUp, setCloneModelSelectDropUp] = useState(false);
const [cloneReferenceMode, setCloneReferenceMode] = useState<CloneReferenceMode>("upload");
const [cloneReferenceImages, setCloneReferenceImages] = useState<CloneImageItem[]>([]);
const [cloneReplicateLevel, setCloneReplicateLevel] = useState<CloneReplicateLevelKey>("high");
const [cloneSetCounts, setCloneSetCounts] = useState(defaultCloneSetCounts);
const [selectedCloneDetailModules, setSelectedCloneDetailModules] = useState<string[]>(defaultCloneDetailModuleIds);
const [cloneModelPanelTab, setCloneModelPanelTab] = useState<CloneModelPanelTab>("scene");
const [selectedCloneModelScenes, setSelectedCloneModelScenes] = useState<string[]>([]);
const [cloneModelCustomScene, setCloneModelCustomScene] = useState("");
const [cloneModelGender, setCloneModelGender] = useState(tryOnModelOptions.gender[0]);
const [cloneModelAge, setCloneModelAge] = useState(tryOnModelOptions.age[0]);
const [cloneModelEthnicity, setCloneModelEthnicity] = useState(tryOnModelOptions.ethnicity[0]);
const [cloneModelBody, setCloneModelBody] = useState(tryOnModelOptions.body[0]);
const [cloneModelAppearance, setCloneModelAppearance] = useState("");
const [cloneVideoQuality, setCloneVideoQuality] = useState<CloneVideoQualityKey>("high");
const [cloneVideoDuration, setCloneVideoDuration] = useState(10);
const [cloneVideoSmart, setCloneVideoSmart] = useState(true);
const [videoOutfitVideoFile, setVideoOutfitVideoFile] = useState<File | null>(null);
const [videoOutfitRefFile, setVideoOutfitRefFile] = useState<File | null>(null);
2026-06-02 12:38:01 +08:00
const [isCloneSettingsCollapsed, setIsCloneSettingsCollapsed] = useState(false);
const [requirement, setRequirement] = useState("");
const [requirementImageMentionQuery, setRequirementImageMentionQuery] = useState<string | null>(null);
const [cloneSettingName, setCloneSettingName] = useState("新建创作");
const [platform, setPlatform] = useState(platformOptions[0]);
const [market, setMarket] = useState(marketOptions[0]);
const [language, setLanguage] = useState(getPlatformDefaultLanguage(platformOptions[0], marketOptions[0]));
const [ratio, setRatio] = useState(getPlatformDefaultRatio(platformOptions[0]));
const [status, setStatus] = useState<ProductCloneStatus>("idle");
const [results, setResults] = useState<CloneResult[]>([]);
const imageAbortRef = useRef({ current: false });
const lastFailedActionRef = useRef<(() => void) | null>(null);
2026-06-02 12:38:01 +08:00
const [garmentImages, setGarmentImages] = useState<CloneImageItem[]>([]);
const [modelSource, setModelSource] = useState<TryOnModelSource>("ai");
const [modelGender, setModelGender] = useState(tryOnModelOptions.gender[0]);
const [modelAge, setModelAge] = useState(tryOnModelOptions.age[0]);
const [modelEthnicity, setModelEthnicity] = useState(tryOnModelOptions.ethnicity[0]);
const [modelBody, setModelBody] = useState(tryOnModelOptions.body[0]);
const [appearance, setAppearance] = useState("");
const [selectedScenes, setSelectedScenes] = useState<string[]>([]);
const [customScene, setCustomScene] = useState("");
const [smartScene, setSmartScene] = useState(false);
const [tryOnRatio, setTryOnRatio] = useState(tryOnRatioOptions[0]);
const [tryOnStatus, setTryOnStatus] = useState<TryOnStatus>("idle");
const [tryOnResultImages, setTryOnResultImages] = useState<string[]>([]);
const [detailProductImages, setDetailProductImages] = useState<CloneImageItem[]>([]);
const [detailPlatform, setDetailPlatform] = useState(platformOptions[0]);
const [detailMarket, setDetailMarket] = useState(marketOptions[0]);
const [detailLanguage, setDetailLanguage] = useState(getPlatformDefaultLanguage(platformOptions[0], marketOptions[0]));
const [detailType, setDetailType] = useState(detailTypeOptions[0]);
const [detailRequirement, setDetailRequirement] = useState("");
const [selectedDetailModules, setSelectedDetailModules] = useState<string[]>(defaultDetailModuleIds);
const [detailStatus, setDetailStatus] = useState<DetailStatus>("idle");
const [detailResultUrl, setDetailResultUrl] = useState<string | null>(null);
2026-06-02 12:38:01 +08:00
const productSetRatioOptions = getPlatformRatioOptions(productSetPlatform, productSetOutput);
const hotUploadedRatioOption = cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null;
const baseCloneRatioOptions = getPlatformRatioOptions(platform, cloneOutput);
const cloneRatioOptions = hotUploadedRatioOption
? getUniqueRatioOptions([...baseCloneRatioOptions, hotUploadedRatioOption])
: baseCloneRatioOptions;
const productSetLanguageOptions = getPlatformLanguageOptions(productSetPlatform, productSetMarket);
const cloneLanguageOptions = getPlatformLanguageOptions(platform, market);
const detailLanguageOptions = getPlatformLanguageOptions(detailPlatform, detailMarket);
const ecommerceMentionImages: MentionImageOption[] = [
...productImages.map((image, index) => ({ ...image, label: `商品图 ${index + 1}` })),
...cloneReferenceImages.map((image, index) => ({ ...image, label: `参考图 ${index + 1}` })),
];
const selectedProductSetOutput =
productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!;
const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!;
const productSetPreviewReady = productSetStatus === "done";
const cloneSetTotal = Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0);
const canGenerateSet = setImages.length > 0 && productSetStatus !== "generating";
const canGenerate = (cloneOutput === "video-outfit"
? videoOutfitVideoFile && videoOutfitRefFile
: productImages.length > 0) && status !== "generating";
2026-06-02 12:38:01 +08:00
const canGenerateTryOn = garmentImages.length > 0 && tryOnStatus !== "generating" && tryOnStatus !== "modeling";
const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating";
const cloneVideoDurationProgress =
((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100;
const cloneVideoDurationStyle = {
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
} as CSSProperties;
const syncRequirementMentionQuery = (value: string, selectionStart: number | null | undefined) => {
setRequirementImageMentionQuery(ecommerceMentionImages.length ? getImageMentionQuery(value, selectionStart) : null);
};
const insertRequirementImageMention = (image: MentionImageOption) => {
const textarea = requirementTextareaRef.current;
const cursor = textarea?.selectionStart ?? requirement.length;
const next = insertImageMentionValue(requirement, cursor, image.name, 500);
setRequirement(next.value);
setRequirementImageMentionQuery(null);
window.requestAnimationFrame(() => {
requirementTextareaRef.current?.focus();
requirementTextareaRef.current?.setSelectionRange(next.selectionStart, next.selectionStart);
});
};
const addSetImages = (files: File[]) => {
if (setImages.length >= 3) return;
const imageFiles = notifyRejectedImages(files);
2026-06-02 12:38:01 +08:00
if (!imageFiles.length) return;
setSetImages((current) => {
const nextImages = createObjectImageItems(imageFiles, 3 - current.length, "set");
return nextImages.length ? [...current, ...nextImages].slice(0, 3) : current;
});
setProductSetStatus("ready");
};
const handleSetUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
addSetImages(Array.from(files));
event.target.value = "";
};
const handleSetDrop = (event: DragEvent<HTMLButtonElement>) => {
event.preventDefault();
setIsSetUploadDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) addSetImages(files);
};
const removeSetImage = (imageId: string) => {
setSetImages((current) => {
const next = current.filter((item) => item.id !== imageId);
if (next.length === 0) setProductSetStatus("idle");
return next;
});
};
const addProductImages = (files: File[]) => {
const imageFiles = notifyRejectedImages(files);
2026-06-02 12:38:01 +08:00
if (!imageFiles.length) return;
setProductImages((current) => {
if (current.length >= maxCloneProductImages) return current;
const nextImages = createObjectImageItems(imageFiles, maxCloneProductImages - current.length, "product");
return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneProductImages) : current;
});
setStatus("ready");
setResults([]);
};
const handleProductUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
addProductImages(Array.from(files));
event.target.value = "";
};
const handleProductDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsProductUploadDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) addProductImages(files);
};
const removeProductImage = (imageId: string) => {
setProductImages((current) => {
const next = current.filter((item) => item.id !== imageId);
if (next.length === 0) {
setStatus("idle");
setResults([]);
}
return next;
});
};
const hydrateCloneReferenceImageMeta = (items: CloneImageItem[]) => {
items.forEach((item) => {
readImageDimensions(item.src)
.then(({ width, height }) => {
setCloneReferenceImages((current) =>
current.map((currentItem) => (currentItem.id === item.id ? { ...currentItem, width, height } : currentItem)),
);
})
.catch(() => undefined);
});
};
const addCloneReferenceImages = (files: File[]) => {
const imageFiles = notifyRejectedImages(files);
2026-06-02 12:38:01 +08:00
if (!imageFiles.length) return;
const remainingSlots = maxCloneReferenceImages - cloneReferenceImages.length;
if (remainingSlots <= 0) return;
const nextImages = createObjectImageItems(imageFiles, remainingSlots, "reference");
if (!nextImages.length) return;
setCloneReferenceImages((current) => {
if (current.length >= maxCloneReferenceImages) return current;
return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneReferenceImages) : current;
});
hydrateCloneReferenceImageMeta(nextImages);
};
const handleCloneReferenceUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
addCloneReferenceImages(Array.from(files));
event.target.value = "";
};
const updateCloneSetCount = (key: CloneSetCountKey, delta: -1 | 1) => {
setCloneSetCounts((current) => {
const total = Object.values(current).reduce((sum, value) => sum + value, 0);
const nextValue = current[key] + delta;
if (delta < 0 && (current[key] <= 0 || total <= minCloneSetTotal)) return current;
if (delta > 0 && total >= maxCloneSetTotal) return current;
return { ...current, [key]: Math.max(0, Math.min(maxCloneSetTotal, nextValue)) };
});
};
const clearCloneSetCountHold = () => {
if (countHoldTimeoutRef.current !== null) {
window.clearTimeout(countHoldTimeoutRef.current);
countHoldTimeoutRef.current = null;
}
if (countHoldIntervalRef.current !== null) {
window.clearInterval(countHoldIntervalRef.current);
countHoldIntervalRef.current = null;
}
};
const startCloneSetCountHold = (key: CloneSetCountKey, delta: -1 | 1, disabled: boolean) => {
if (disabled) return;
clearCloneSetCountHold();
updateCloneSetCount(key, delta);
window.addEventListener("pointerup", clearCloneSetCountHold, { once: true });
window.addEventListener("pointercancel", clearCloneSetCountHold, { once: true });
countHoldTimeoutRef.current = window.setTimeout(() => {
countHoldIntervalRef.current = window.setInterval(() => updateCloneSetCount(key, delta), 110);
}, 320);
};
const toggleCloneDetailModule = (moduleId: string) => {
setSelectedCloneDetailModules((current) =>
current.includes(moduleId) ? current.filter((item) => item !== moduleId) : [...current, moduleId],
);
};
const toggleCloneModelScene = (scene: string) => {
setSelectedCloneModelScenes((current) => (current[0] === scene ? [] : [scene]));
};
const handleProductSetPlatformChange = (nextPlatform: string) => {
const normalizedPlatform = normalizePlatform(nextPlatform);
setProductSetPlatform(normalizedPlatform);
setProductSetRatio((current) => normalizeRatioForPlatform(normalizedPlatform, current, productSetOutput));
setProductSetLanguage(getPlatformDefaultLanguage(normalizedPlatform, productSetMarket));
};
const handleProductSetOutputChange = (nextOutput: ProductSetOutputKey) => {
setProductSetOutput(nextOutput);
setProductSetRatio((current) => normalizeRatioForPlatform(productSetPlatform, current, nextOutput));
};
const handleProductSetMarketChange = (nextMarket: string) => {
const normalizedMarket = normalizeMarket(nextMarket);
setProductSetMarket(normalizedMarket);
setProductSetLanguage(getPlatformDefaultLanguage(productSetPlatform, normalizedMarket));
};
const handleClonePlatformChange = (nextPlatform: string) => {
const normalizedPlatform = normalizePlatform(nextPlatform);
setPlatform(normalizedPlatform);
setRatio((current) =>
cloneOutput === "hot" && current.startsWith("上传图片") && hotUploadedRatioOption
? hotUploadedRatioOption
: normalizeRatioForPlatform(normalizedPlatform, current, cloneOutput),
);
setLanguage(getPlatformDefaultLanguage(normalizedPlatform, market));
};
const handleCloneOutputChange = (nextOutput: CloneOutputKey) => {
setCloneOutput(nextOutput);
setRatio((current) =>
nextOutput === "hot" && current.startsWith("上传图片") && hotUploadedRatioOption
? hotUploadedRatioOption
: normalizeRatioForPlatform(platform, current, nextOutput),
);
};
const handleCloneMarketChange = (nextMarket: string) => {
const normalizedMarket = normalizeMarket(nextMarket);
setMarket(normalizedMarket);
setLanguage(getPlatformDefaultLanguage(platform, normalizedMarket));
};
const handleDetailPlatformChange = (nextPlatform: string) => {
const normalizedPlatform = normalizePlatform(nextPlatform);
setDetailPlatform(normalizedPlatform);
setDetailLanguage(getPlatformDefaultLanguage(normalizedPlatform, detailMarket));
};
const handleDetailMarketChange = (nextMarket: string) => {
const normalizedMarket = normalizeMarket(nextMarket);
setDetailMarket(normalizedMarket);
setDetailLanguage(getPlatformDefaultLanguage(detailPlatform, normalizedMarket));
};
const createCloneSettingSnapshot = (name: string, id = `clone-setting-${Date.now()}`): CloneSavedSetting => ({
id,
name,
savedAt: new Date().toISOString(),
output: cloneOutput,
platform,
market,
language,
ratio,
setCounts: { ...cloneSetCounts },
detailModules: [...selectedCloneDetailModules],
modelPanelTab: cloneModelPanelTab,
modelScenes: [...selectedCloneModelScenes],
modelCustomScene: cloneModelCustomScene,
modelGender: cloneModelGender,
modelAge: cloneModelAge,
modelEthnicity: cloneModelEthnicity,
modelBody: cloneModelBody,
modelAppearance: cloneModelAppearance,
videoQuality: cloneVideoQuality,
videoDurationSeconds: cloneVideoDuration,
videoSmart: cloneVideoSmart,
referenceMode: cloneReferenceMode,
replicateLevel: cloneReplicateLevel,
requirement,
});
const persistLatestCloneSetting = () => {
const snapshot = createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest");
latestCloneSettingRef.current = snapshot;
writeCloneLatestSetting(snapshot);
return snapshot;
};
const applyCloneSavedSetting = (setting: CloneSavedSetting) => {
const nextCounts = {
selling: Number.isFinite(setting.setCounts?.selling) ? setting.setCounts.selling : defaultCloneSetCounts.selling,
white: Number.isFinite(setting.setCounts?.white) ? setting.setCounts.white : defaultCloneSetCounts.white,
scene: Number.isFinite(setting.setCounts?.scene) ? setting.setCounts.scene : defaultCloneSetCounts.scene,
};
const nextPlatform = normalizePlatform(setting.platform);
const nextMarket = normalizeMarket(setting.market);
const nextOutput = cloneOutputOptions.some((option) => option.key === setting.output) ? setting.output : "detail";
setCloneOutput(nextOutput);
setPlatform(nextPlatform);
setMarket(nextMarket);
setLanguage(normalizeLanguageForPlatform(nextPlatform, nextMarket, setting.language));
setRatio(normalizeRatioForPlatform(nextPlatform, setting.ratio, nextOutput));
setCloneSetCounts(nextCounts);
setSelectedCloneDetailModules(setting.detailModules?.length ? setting.detailModules : defaultCloneDetailModuleIds);
setCloneModelPanelTab(setting.modelPanelTab === "model" ? "model" : "scene");
setSelectedCloneModelScenes(normalizeCloneModelSceneSelection(setting.modelScenes));
setCloneModelCustomScene(setting.modelCustomScene ?? "");
setCloneModelGender(tryOnModelOptions.gender.includes(setting.modelGender) ? setting.modelGender : tryOnModelOptions.gender[0]);
setCloneModelAge(tryOnModelOptions.age.includes(setting.modelAge) ? setting.modelAge : tryOnModelOptions.age[0]);
setCloneModelEthnicity(
tryOnModelOptions.ethnicity.includes(setting.modelEthnicity) ? setting.modelEthnicity : tryOnModelOptions.ethnicity[0],
);
setCloneModelBody(tryOnModelOptions.body.includes(setting.modelBody) ? setting.modelBody : tryOnModelOptions.body[0]);
setCloneModelAppearance(setting.modelAppearance ?? "");
setCloneVideoQuality(
cloneVideoQualityOptions.some((option) => option.key === setting.videoQuality) ? setting.videoQuality : "high",
);
setCloneVideoDuration(clampCloneVideoDuration(setting.videoDurationSeconds));
setCloneVideoSmart(Boolean(setting.videoSmart));
setCloneReferenceMode(setting.referenceMode === "link" ? "link" : "upload");
setCloneReplicateLevel(setting.replicateLevel === "style" ? "style" : "high");
setRequirement((setting.requirement ?? "").slice(0, 500));
setCloneSettingName(setting.name);
latestCloneSettingRef.current = setting;
writeCloneLatestSetting(setting);
};
useEffect(() => {
latestCloneSettingRef.current = createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest");
});
useEffect(() => {
const latestSetting = readCloneLatestSetting();
if (!latestSetting) return;
skipNextCloneAutoSaveRef.current = true;
applyCloneSavedSetting(latestSetting);
}, []);
useEffect(() => {
setProductSetRatio((current) => normalizeRatioForPlatform(productSetPlatform, current, productSetOutput));
}, [productSetOutput, productSetPlatform]);
useEffect(() => {
setRatio((current) => {
const platformRatios = getPlatformRatioOptions(platform, cloneOutput);
const availableRatios = hotUploadedRatioOption ? getUniqueRatioOptions([...platformRatios, hotUploadedRatioOption]) : platformRatios;
if (current.startsWith("上传图片") && hotUploadedRatioOption) return hotUploadedRatioOption;
if (availableRatios.includes(current)) return current;
const normalizedRatio = normalizeRatioToken(current);
const matchedRatio = availableRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio));
return matchedRatio ?? getPlatformDefaultRatio(platform, cloneOutput);
});
}, [cloneOutput, hotUploadedRatioOption, platform]);
useEffect(() => {
if (skipInitialCloneAutoSaveRef.current) {
skipInitialCloneAutoSaveRef.current = false;
return undefined;
}
if (skipNextCloneAutoSaveRef.current) {
skipNextCloneAutoSaveRef.current = false;
return undefined;
}
const timeoutId = window.setTimeout(() => {
persistLatestCloneSetting();
}, 300);
return () => window.clearTimeout(timeoutId);
}, [
activeTool,
cloneOutput,
platform,
market,
language,
ratio,
cloneSetCounts,
selectedCloneDetailModules,
cloneModelPanelTab,
selectedCloneModelScenes,
cloneModelCustomScene,
cloneModelGender,
cloneModelAge,
cloneModelEthnicity,
cloneModelBody,
cloneModelAppearance,
cloneVideoQuality,
cloneVideoDuration,
cloneVideoSmart,
cloneReferenceMode,
cloneReplicateLevel,
requirement,
cloneSettingName,
]);
useEffect(() => {
const persistSnapshot = () => {
if (latestCloneSettingRef.current) writeCloneLatestSetting(latestCloneSettingRef.current);
};
const handleVisibilityChange = () => {
if (document.visibilityState === "hidden") persistSnapshot();
};
window.addEventListener("pagehide", persistSnapshot);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
persistSnapshot();
window.removeEventListener("pagehide", persistSnapshot);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
useEffect(() => clearCloneSetCountHold, []);
useEffect(() => {
if (!openCloneBasicSelect) return undefined;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Element) || target.closest("[data-clone-basic-select]")) return;
setOpenCloneBasicSelect(null);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpenCloneBasicSelect(null);
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [openCloneBasicSelect]);
useEffect(() => {
if (!openCloneModelSelect) return undefined;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Element) || target.closest("[data-clone-model-select]")) return;
setOpenCloneModelSelect(null);
setCloneModelSelectDropUp(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpenCloneModelSelect(null);
setCloneModelSelectDropUp(false);
}
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [openCloneModelSelect]);
const handleGarmentUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
const uploadedFiles = notifyRejectedImages(Array.from(files));
if (!uploadedFiles.length) {
event.target.value = "";
return;
}
2026-06-02 12:38:01 +08:00
setGarmentImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 5 - current.length, "garment")].slice(0, 5));
setTryOnStatus("ready");
event.target.value = "";
};
const handleDetailUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
const uploadedFiles = notifyRejectedImages(Array.from(files));
if (!uploadedFiles.length) {
event.target.value = "";
return;
}
2026-06-02 12:38:01 +08:00
setDetailProductImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 3 - current.length, "detail")].slice(0, 3));
setDetailStatus("ready");
event.target.value = "";
};
const blobToDataUrl = (blob: Blob): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
reader.readAsDataURL(blob);
});
2026-06-02 12:38:01 +08:00
const uploadCloneImages = async (images: CloneImageItem[]): Promise<string[]> => {
const urls: string[] = [];
for (const item of images) {
try {
const resp = await fetch(item.src);
const rawBlob = await resp.blob();
const mimeType = normalizeEcommerceImageMime(rawBlob.type);
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
const dataUrl = await blobToDataUrl(blob);
const { url } = await aiGenerationClient.uploadAsset({ dataUrl, name: item.name, mimeType, scope: "ecommerce-product" });
urls.push(url);
} catch {
// skip images that fail to upload
}
}
return urls;
};
const IMAGE_MODEL = "gpt-image-2";
const setCountLabels: Record<CloneSetCountKey, { label: string; promptDesc: string }> = {
selling: { label: "卖点图", promptDesc: "selling-point infographic image highlighting core product advantages and detail close-ups" },
white: { label: "白底图", promptDesc: "clean white-background product photo showing the item from its best angle, studio lighting, no props" },
scene: { label: "场景图", promptDesc: "lifestyle scene image showing the product in a realistic usage environment with natural surroundings" },
};
const 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 (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?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean },
): 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}.`);
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.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<CloneSetCountKey, number>,
userText: string,
pPlatform: string,
pRatio: string,
pLanguage: string,
pMarket: string,
setStatusFn: (status: "generating" | "done" | "idle") => void,
setResultFn: (urls: string[]) => void,
): Promise<void> => {
setStatusFn("generating");
try {
const referenceUrls = await uploadCloneImages(images);
if (!referenceUrls.length) {
setStatusFn("idle");
return;
}
const generatedUrls: string[] = [];
const stamp = Date.now();
for (const countKey of cloneSetCountOptions.map((o) => o.key)) {
const count = counts[countKey];
for (let i = 0; i < count; i++) {
if (imageAbortRef.current.current) break;
const subPrompt = buildSetSubPrompt(countKey, i, count, pPlatform, pRatio, pLanguage, pMarket);
const fullPrompt = userText.trim() ? `${subPrompt} Additional user requirements: ${userText.trim()}` : subPrompt;
const { taskId } = await aiGenerationClient.createImageTask({
model: IMAGE_MODEL,
prompt: fullPrompt,
ratio: pRatio,
quality: pRatio.includes("720") ? "720P" : "1080P",
gridMode: "single",
referenceUrls,
});
const storeId = imageGen.submitTask({ title: `${setCountLabels[countKey].label} ${i + 1}`, type: "image", status: "running", progress: 5, prompt: fullPrompt, sourceView: "ecommerce", taskId });
const resultUrl = await waitForTask(taskId, {
abortRef: imageAbortRef.current,
onProgress: () => {},
});
if (resultUrl) {
generatedUrls.push(resultUrl);
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl });
} else {
generatedUrls.push("");
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
}
}
}
setResultFn(generatedUrls);
setStatusFn(generatedUrls.some(Boolean) ? "done" : "idle");
} catch (err) {
if (err instanceof ServerRequestError && err.status === 402) {
setResultFn([]);
toast.error("余额不足,请充值后继续");
} else {
const msg = err instanceof Error ? err.message : "生成失败";
toast.error(msg);
}
setStatusFn("failed");
}
};
const generateEcommerceImage = async (
outputKey: CloneOutputKey,
images: CloneImageItem[],
userText: string,
pPlatform: string,
pRatio: string,
pLanguage: string,
pMarket: string,
tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean },
statusFn?: (status: "generating" | "done" | "idle" | "failed") => void,
resultFn?: (results: CloneImageItem[]) => void,
): Promise<void> => {
setStatusFn("generating");
try {
const referenceUrls = await uploadCloneImages(images);
if (!referenceUrls.length) {
setStatusFn("idle");
return;
}
const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket, tryOnOptions);
const stamp = Date.now();
const { taskId } = await aiGenerationClient.createImageTask({
model: IMAGE_MODEL,
prompt,
ratio: pRatio,
quality: pRatio.includes("720") ? "720P" : "1080P",
gridMode: "single",
referenceUrls,
});
const storeId = imageGen.submitTask({ title: `电商${outputKey}`, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId });
const resultUrl = await waitForTask(taskId, {
abortRef: imageAbortRef.current,
onProgress: () => {},
});
if (resultUrl) {
setResultFn([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]);
setStatusFn("done");
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl });
} else {
setStatusFn("idle");
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
}
} catch (err) {
if (err instanceof ServerRequestError && err.status === 402) {
setResultFn([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]);
toast.error("余额不足,请充值后继续");
} else {
const msg = err instanceof Error ? err.message : "生成失败";
toast.error(msg);
}
setStatusFn("failed");
}
};
const handleVideoOutfitGenerate = async () => {
if (!videoOutfitVideoFile || !videoOutfitRefFile) return;
setStatus("generating");
2026-06-02 12:38:01 +08:00
try {
const readAsDataUrl = (file: File): Promise<string> => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error("文件读取失败"));
reader.readAsDataURL(file);
});
2026-06-02 12:38:01 +08:00
const videoDataUrl = await readAsDataUrl(videoOutfitVideoFile);
const refDataUrl = await readAsDataUrl(videoOutfitRefFile);
2026-06-02 12:38:01 +08:00
const videoAsset = await aiGenerationClient.uploadAsset({
dataUrl: videoDataUrl, name: videoOutfitVideoFile.name,
mimeType: videoOutfitVideoFile.type || "video/mp4", scope: "video-outfit",
});
const refAsset = await aiGenerationClient.uploadAsset({
dataUrl: refDataUrl, name: videoOutfitRefFile.name,
mimeType: videoOutfitRefFile.type || "image/png", scope: "video-outfit",
});
2026-06-02 12:38:01 +08:00
const { taskId } = await aiGenerationClient.createVideoEditTask({
videoUrl: videoAsset.url,
referenceUrls: [refAsset.url],
prompt: requirement || undefined,
});
2026-06-02 12:38:01 +08:00
const { waitForTask } = await import("../../api/taskSubscription");
abortRef.current = { current: false };
const resultUrl = await waitForTask(taskId, { abortRef: abortRef.current });
if (resultUrl) {
setResults([{ id: crypto.randomUUID(), name: "换装视频", src: resultUrl, type: "video", size: 0 }]);
}
setStatus("done");
2026-06-02 12:38:01 +08:00
} catch (err) {
setStatus("failed");
toast.error(err instanceof Error ? err.message : "视频换装生成失败");
2026-06-02 12:38:01 +08:00
}
};
const handleGenerate = () => {
if (!canGenerate) return;
2026-06-02 12:38:01 +08:00
if ((appUsage?.balanceCents ?? 0) <= 0) {
toast.error("积分不足,请充值后继续");
return;
2026-06-02 12:38:01 +08:00
}
if (cloneOutput === "set" && cloneSetTotal > 5) {
if (!window.confirm(`将生成 ${cloneSetTotal} 张图片,可能消耗较多积分,是否继续?`)) return;
}
2026-06-02 12:38:01 +08:00
imageAbortRef.current = { current: false };
lastFailedActionRef.current = null;
if (cloneOutput === "video-outfit") {
void handleVideoOutfitGenerate();
} else if (cloneOutput === "set") {
void generateSetImages(
productImages, cloneSetCounts, requirement,
platform, ratio, language, market,
(s) => setStatus(s as ProductCloneStatus),
(urls) => setProductSetResultImages(urls),
);
} else {
void generateEcommerceImage(
cloneOutput, productImages, requirement,
platform, ratio, language, market,
(s) => setStatus(s as ProductCloneStatus), setResults,
);
lastFailedActionRef.current = () => handleGenerate();
}
2026-06-02 12:38:01 +08:00
};
const handleGenerateModel = () => {
imageAbortRef.current = { current: false };
lastFailedActionRef.current = null;
2026-06-02 12:38:01 +08:00
setTryOnStatus("modeling");
void generateEcommerceImage(
"model", garmentImages, requirement,
platform, ratio, language, market,
{ gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene },
(s) => {
if (s === "done") setTryOnStatus("ready");
else setTryOnStatus(s as TryOnStatus);
},
() => { setTryOnStatus("ready"); },
);
lastFailedActionRef.current = () => handleGenerateModel();
2026-06-02 12:38:01 +08:00
};
const handleTryOnGenerate = () => {
if (!canGenerateTryOn) return;
imageAbortRef.current = { current: false };
lastFailedActionRef.current = null;
void generateEcommerceImage(
"model", garmentImages, requirement,
platform, ratio, language, market,
{ gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene },
(s) => setTryOnStatus(s as TryOnStatus),
(res) => setTryOnResultImages(res.map((r) => r.src).filter(Boolean)),
);
lastFailedActionRef.current = () => handleTryOnGenerate();
2026-06-02 12:38:01 +08:00
};
const toggleScene = (scene: string) => {
setSelectedScenes((current) =>
current.includes(scene) ? current.filter((item) => item !== scene) : [...current, scene],
);
};
const toggleDetailModule = (moduleId: string) => {
setSelectedDetailModules((current) =>
current.includes(moduleId) ? current.filter((item) => item !== moduleId) : [...current, moduleId],
);
};
const handleSetGenerate = () => {
if (!canGenerateSet) return;
imageAbortRef.current = { current: false };
lastFailedActionRef.current = null;
void generateSetImages(
setImages, cloneSetCounts, productSetRequirement,
productSetPlatform, productSetRatio, productSetLanguage, productSetMarket,
(s) => setProductSetStatus(s as ProductSetStatus),
(urls) => setProductSetResultImages(urls),
);
lastFailedActionRef.current = () => handleSetGenerate();
2026-06-02 12:38:01 +08:00
};
const openProductSetPreview = (card: { src: string; label: string }) => {
setSelectedProductSetPreview(card);
};
const handleDetailAiWrite = () => {
setDetailRequirement(
"1.产品名称:无线降噪蓝牙耳机\n2.核心卖点:主动降噪、24H续航、低延迟连接、舒适佩戴\n3.适用人群:通勤、办公、运动和旅行用户\n4.期望场景:地铁通勤、居家办公、户外运动\n5.具体参数:蓝牙5.3、IPX4防水、快充10分钟使用2小时",
);
};
const handleDetailGenerate = () => {
if (!canGenerateDetail) return;
imageAbortRef.current = { current: false };
lastFailedActionRef.current = null;
void generateEcommerceImage(
"detail", detailProductImages, detailRequirement,
detailPlatform, getPlatformDefaultRatio(detailPlatform), detailLanguage, detailMarket,
(s) => setDetailStatus(s as DetailStatus),
(res) => setDetailResultUrl(res[0]?.src ?? null),
);
2026-06-02 12:38:01 +08:00
};
const resetTask = () => {
setSetImages([]);
setProductSetRequirement("");
setProductSetOutput("video");
setProductSetRatio((current) => normalizeRatioForPlatform(productSetPlatform, current, "video"));
setProductSetStatus("idle");
setIsSetUploadDragging(false);
setSelectedProductSetPreview(null);
setShowHostingModal(false);
setProductImages([]);
setIsProductUploadDragging(false);
setCloneOutput("detail");
setRatio((current) => normalizeRatioForPlatform(platform, current, "detail"));
setCloneSetCounts(defaultCloneSetCounts);
setSelectedCloneDetailModules(defaultCloneDetailModuleIds);
setCloneModelPanelTab("scene");
setSelectedCloneModelScenes([]);
setCloneModelCustomScene("");
setCloneModelGender(tryOnModelOptions.gender[0]);
setCloneModelAge(tryOnModelOptions.age[0]);
setCloneModelEthnicity(tryOnModelOptions.ethnicity[0]);
setCloneModelBody(tryOnModelOptions.body[0]);
setCloneModelAppearance("");
setCloneVideoQuality("high");
setCloneVideoDuration(10);
setCloneVideoSmart(true);
setCloneReferenceMode("upload");
setCloneReferenceImages([]);
setCloneReplicateLevel("high");
setRequirement("");
setCloneSettingName("新建创作");
setResults([]);
setStatus("idle");
setGarmentImages([]);
setAppearance("");
setSelectedScenes([]);
setCustomScene("");
setSmartScene(false);
setTryOnRatio(tryOnRatioOptions[0]);
setTryOnStatus("idle");
setTryOnResultImages([]);
setDetailProductImages([]);
setDetailRequirement("");
setSelectedDetailModules(defaultDetailModuleIds);
setDetailStatus("idle");
};
const activeToolMeta = sideTools.find((tool) => tool.key === activeTool);
const isSetTool = activeTool === "set";
const isDetail = activeTool === "detail";
const isTryOn = activeTool === "wear";
const isCloneTool = activeTool === "clone";
const pageLabel = isSetTool ? "商品套图" : isDetail ? "A+/详情页" : isTryOn ? "AI服饰穿戴" : activeToolMeta?.label || "商品工具";
const setPrimaryLabel =
setImages.length === 0
? `请先上传商品原图`
: productSetStatus === "generating"
? "生成中..."
: `生成${selectedProductSetOutput.label}`;
const tryOnPrimaryLabel =
garmentImages.length === 0 ? "请先上传服装图片" : tryOnStatus === "generating" ? "生成中..." : "生成服饰穿戴图";
const detailPrimaryLabel =
detailProductImages.length === 0 ? "请上传产品图" : detailStatus === "generating" ? "生成中..." : "生成A+详情页";
const clonePrimaryLabel =
productImages.length === 0 ? "请先上传商品原图" : status === "generating" ? "生成中..." : `生成${selectedCloneOutput.label}`;
const setPreviewCards: CloneResult[] = [];
let setIndex = 0;
for (const countKey of cloneSetCountOptions.map((o) => o.key)) {
const count = cloneSetCounts[countKey];
const info = setCountLabels[countKey];
for (let i = 0; i < count; i++) {
setPreviewCards.push({
id: `${countKey}-${i}`,
src: productSetResultImages[setIndex] ?? productSetPreviewCards[setIndex % productSetPreviewCards.length]?.src ?? "",
label: `${info.label}${count > 1 ? ` ${i + 1}` : ""}`,
});
setIndex++;
}
}
const clonePreviewCards: CloneResult[] = [];
let cloneIndex = 0;
for (const countKey of cloneSetCountOptions.map((o) => o.key)) {
const count = cloneSetCounts[countKey];
const info = setCountLabels[countKey];
for (let i = 0; i < count; i++) {
clonePreviewCards.push({
id: `${countKey}-${i}`,
src: results[cloneIndex]?.src ?? productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src ?? "",
label: `${info.label}${count > 1 ? ` ${i + 1}` : ""}`,
});
cloneIndex++;
}
}
2026-06-02 12:38:01 +08:00
const cloneBasicSelects: Array<{
key: CloneBasicSelectKey;
label: string;
value: string;
options: string[];
onChange: (value: string) => void;
}> = [
{ key: "platform", label: "平台", value: platform, options: platformOptions, onChange: handleClonePlatformChange },
{ key: "market", label: "国家", value: market, options: marketOptions, onChange: handleCloneMarketChange },
{ key: "language", label: "语言", value: language, options: cloneLanguageOptions, onChange: setLanguage },
{ key: "ratio", label: "尺寸/比例", value: ratio, options: cloneRatioOptions, onChange: setRatio },
];
const cloneModelSelects: Array<{
key: CloneModelSelectKey;
label: string;
value: string;
options: string[];
onChange: (value: string) => void;
}> = [
{ key: "gender", label: "性别", value: cloneModelGender, options: tryOnModelOptions.gender, onChange: setCloneModelGender },
{ key: "age", label: "年龄", value: cloneModelAge, options: tryOnModelOptions.age, onChange: setCloneModelAge },
{
key: "ethnicity",
label: "人种",
value: cloneModelEthnicity,
options: tryOnModelOptions.ethnicity,
onChange: setCloneModelEthnicity,
},
{ key: "body", label: "体型", value: cloneModelBody, options: tryOnModelOptions.body, onChange: setCloneModelBody },
];
const setPanel = (
<EcommerceSetPanel
setInputRef={setInputRef}
setImages={setImages}
isSetUploadDragging={isSetUploadDragging}
productSetOutputOptions={productSetOutputOptions}
productSetOutput={productSetOutput}
platformOptions={platformOptions}
marketOptions={marketOptions}
productSetLanguageOptions={productSetLanguageOptions}
productSetRatioOptions={productSetRatioOptions}
productSetPlatform={productSetPlatform}
productSetMarket={productSetMarket}
productSetLanguage={productSetLanguage}
productSetRatio={productSetRatio}
setIsSetUploadDragging={setIsSetUploadDragging}
handleSetDrop={handleSetDrop}
handleSetUpload={handleSetUpload}
removeSetImage={removeSetImage}
handleProductSetOutputChange={handleProductSetOutputChange}
handleProductSetPlatformChange={handleProductSetPlatformChange}
handleProductSetMarketChange={handleProductSetMarketChange}
setProductSetLanguage={setProductSetLanguage}
setProductSetRatio={setProductSetRatio}
formatRatioDisplayValue={formatRatioDisplayValue}
/>
2026-06-02 12:38:01 +08:00
);
const clonePanel = (
<EcommerceClonePanel
productInputRef={productInputRef}
cloneReferenceInputRef={cloneReferenceInputRef}
productImages={productImages}
isProductUploadDragging={isProductUploadDragging}
cloneOutput={cloneOutput}
cloneOutputOptions={cloneOutputOptions}
cloneBasicSelects={cloneBasicSelects}
openCloneBasicSelect={openCloneBasicSelect}
cloneReferenceMode={cloneReferenceMode}
cloneReferenceImages={cloneReferenceImages}
maxCloneReferenceImages={maxCloneReferenceImages}
cloneReplicateLevel={cloneReplicateLevel}
cloneReplicateLevelOptions={cloneReplicateLevelOptions}
cloneSetCounts={cloneSetCounts}
cloneSetCountOptions={cloneSetCountOptions}
cloneSetTotal={cloneSetTotal}
minCloneSetTotal={minCloneSetTotal}
maxCloneSetTotal={maxCloneSetTotal}
selectedCloneDetailModules={selectedCloneDetailModules}
cloneDetailModules={cloneDetailModules}
cloneModelPanelTab={cloneModelPanelTab}
tryOnScenes={tryOnScenes}
selectedCloneModelScenes={selectedCloneModelScenes}
cloneModelCustomScene={cloneModelCustomScene}
cloneModelSelects={cloneModelSelects}
openCloneModelSelect={openCloneModelSelect}
cloneModelSelectDropUp={cloneModelSelectDropUp}
cloneModelAppearance={cloneModelAppearance}
cloneVideoQuality={cloneVideoQuality}
cloneVideoQualityOptions={cloneVideoQualityOptions}
cloneVideoDuration={cloneVideoDuration}
cloneVideoDurationMin={cloneVideoDurationMin}
cloneVideoDurationMax={cloneVideoDurationMax}
cloneVideoDurationStyle={cloneVideoDurationStyle}
cloneVideoSmart={cloneVideoSmart}
canGenerate={canGenerate}
status={status}
lastFailedActionRef={lastFailedActionRef}
setIsProductUploadDragging={setIsProductUploadDragging}
handleProductDrop={handleProductDrop}
removeProductImage={removeProductImage}
handleProductUpload={handleProductUpload}
handleCloneOutputChange={handleCloneOutputChange}
setOpenCloneBasicSelect={setOpenCloneBasicSelect}
setCloneReferenceMode={setCloneReferenceMode}
handleCloneReferenceUpload={handleCloneReferenceUpload}
setCloneReplicateLevel={setCloneReplicateLevel}
startCloneSetCountHold={startCloneSetCountHold}
clearCloneSetCountHold={clearCloneSetCountHold}
toggleCloneDetailModule={toggleCloneDetailModule}
setCloneModelPanelTab={setCloneModelPanelTab}
toggleCloneModelScene={toggleCloneModelScene}
setCloneModelCustomScene={setCloneModelCustomScene}
setOpenCloneModelSelect={setOpenCloneModelSelect}
setCloneModelSelectDropUp={setCloneModelSelectDropUp}
setCloneModelAppearance={setCloneModelAppearance}
setCloneVideoQuality={setCloneVideoQuality}
setCloneVideoDuration={setCloneVideoDuration}
clampCloneVideoDuration={clampCloneVideoDuration}
setCloneVideoSmart={setCloneVideoSmart}
handleGenerate={handleGenerate}
formatRatioDisplayValue={formatRatioDisplayValue}
setVideoOutfitFiles={(video, ref) => { setVideoOutfitVideoFile(video); setVideoOutfitRefFile(ref); }}
/>
2026-06-02 12:38:01 +08:00
);
const detailPanel = (
<EcommerceDetailPanel
detailInputRef={detailInputRef}
detailProductImages={detailProductImages}
detailPlatform={detailPlatform}
detailMarket={detailMarket}
detailLanguage={detailLanguage}
detailType={detailType}
detailRequirement={detailRequirement}
selectedDetailModules={selectedDetailModules}
detailStatus={detailStatus}
canGenerateDetail={canGenerateDetail}
detailPrimaryLabel={detailPrimaryLabel}
platformOptions={platformOptions}
marketOptions={marketOptions}
detailLanguageOptions={detailLanguageOptions}
detailTypeOptions={detailTypeOptions}
detailModules={detailModules}
handleDetailUpload={handleDetailUpload}
handleDetailPlatformChange={handleDetailPlatformChange}
handleDetailMarketChange={handleDetailMarketChange}
setDetailLanguage={setDetailLanguage}
setDetailType={setDetailType}
setDetailRequirement={setDetailRequirement}
handleDetailAiWrite={handleDetailAiWrite}
toggleDetailModule={toggleDetailModule}
handleDetailGenerate={handleDetailGenerate}
/>
2026-06-02 12:38:01 +08:00
);
const tryOnPanel = (
<EcommerceTryOnPanel
garmentInputRef={garmentInputRef}
garmentImages={garmentImages}
modelSource={modelSource}
modelGender={modelGender}
modelAge={modelAge}
modelEthnicity={modelEthnicity}
modelBody={modelBody}
appearance={appearance}
selectedScenes={selectedScenes}
customScene={customScene}
smartScene={smartScene}
tryOnRatio={tryOnRatio}
tryOnStatus={tryOnStatus}
canGenerateTryOn={canGenerateTryOn}
tryOnPrimaryLabel={tryOnPrimaryLabel}
tryOnModelOptions={tryOnModelOptions}
tryOnAssets={tryOnAssets}
tryOnScenes={tryOnScenes}
tryOnRatioOptions={tryOnRatioOptions}
handleGarmentUpload={handleGarmentUpload}
setModelSource={setModelSource}
setModelGender={setModelGender}
setModelAge={setModelAge}
setModelEthnicity={setModelEthnicity}
setModelBody={setModelBody}
setAppearance={setAppearance}
handleGenerateModel={handleGenerateModel}
toggleScene={toggleScene}
setCustomScene={setCustomScene}
setSmartScene={setSmartScene}
setTryOnRatio={setTryOnRatio}
handleTryOnGenerate={handleTryOnGenerate}
/>
2026-06-02 12:38:01 +08:00
);
const placeholderPanel = (
<>
<div className="product-clone-panel__scroll">
<section className="product-clone-empty-panel">
<span>{activeToolMeta?.icon}</span>
<h2>{activeToolMeta?.label}</h2>
<p>使AI作图A+穿</p>
</section>
</div>
<footer className="product-clone-panel__footer">
<button type="button" className="product-clone-primary" disabled>
</button>
</footer>
</>
);
const setPreview = (
<main className="product-clone-preview product-clone-preview--set" aria-label="AI商品套图预览">
<div className="product-clone-preview__headline">
<h1></h1>
<p>
AI <span></span>
</p>
</div>
{productSetPreviewReady ? (
<section className="product-set-demo-board">
<button
type="button"
className="product-set-main-card"
onClick={() => openProductSetPreview(setPreviewCards[0] ?? productSetPreviewCards[0])}
2026-06-02 12:38:01 +08:00
>
<img src={setImages[0]?.src ?? (setPreviewCards[0]?.src ?? productSetPreviewCards[0].src)} alt="商品原图" />
<span></span>
2026-06-02 12:38:01 +08:00
</button>
<div className="product-set-flow-arrow" aria-hidden="true" />
<div className="product-set-card-grid result-reveal">
{setPreviewCards.map((card) => (
2026-06-02 12:38:01 +08:00
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
<img src={card.src} alt={card.label} />
<span>{card.label}</span>
</button>
))}
</div>
</section>
) : (
<section className="product-set-empty-preview" aria-live="polite">
{productSetStatus === "generating" ? <LoadingOutlined /> : <FileImageOutlined />}
<strong>{productSetStatus === "generating" ? "正在生成" : "等待生成"}</strong>
{productSetStatus === "generating" ? <EcommerceProgressBar status="generating" label="商品套图" /> : null}
2026-06-02 12:38:01 +08:00
<span>{productSetStatus === "generating" ? "AI 正在整理主图、场景、细节与卖点图。" : "上传商品原图并填写信息后,AI 将为您生成专业的电商商品图"}</span>
</section>
)}
{productSetStatus === "done" ? <p className="product-set-generated-note">{selectedProductSetOutput.label}</p> : null}
<section className="product-set-floating-detail" aria-label="信息详情">
<div className="product-set-floating-detail__head">
<strong></strong>
<span>{productSetRequirement.length}/500</span>
</div>
<textarea
value={productSetRequirement}
onChange={(event) => setProductSetRequirement(event.target.value)}
maxLength={500}
placeholder="建议包含以下信息:产品名称、核心卖点、期望场景、具体参数"
/>
<button type="button" className="product-set-floating-submit" disabled={!canGenerateSet} onClick={handleSetGenerate}>
{productSetStatus === "generating" ? <LoadingOutlined /> : null}
{setPrimaryLabel}
</button>
</section>
<button type="button" className="product-clone-help" aria-label="帮助">
<QuestionCircleOutlined />
</button>
</main>
);
const clonePreview = (
<main className="product-clone-preview clone-ai-preview" aria-label="电商AI作图预览">
<header className="clone-ai-preview-header">
<strong></strong>
<span>
AI <b></b>
</span>
<div className="clone-ai-preview-summary" aria-label="当前生成配置">
<span>{selectedCloneOutput.label}</span>
<span>{platform}</span>
<span>{market}</span>
<span>{language}</span>
<span>{formatRatioDisplayValue(ratio)}</span>
</div>
2026-06-02 12:38:01 +08:00
</header>
{status === "done" ? (
<section className="clone-ai-preview-showcase" aria-label="生成结果">
<button type="button" className="clone-ai-main-result" onClick={() => openProductSetPreview(cloneOutput === "set" ? clonePreviewCards[0] : results[0])}>
<img src={productImages[0]?.src ?? (cloneOutput === "set" ? clonePreviewCards[0].src : results[0]?.src ?? "")} alt="上传商品原图" />
2026-06-02 12:38:01 +08:00
<span></span>
</button>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<div className="clone-ai-result-grid result-reveal">
{cloneOutput === "set" ? (
clonePreviewCards.map((card) => (
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
<img src={card.src} alt={card.label} />
<span>{card.label}</span>
</button>
))
) : results[0]?.src ? (
<button type="button" onClick={() => openProductSetPreview(results[0])}>
<img src={results[0].src} alt={selectedCloneOutput.label} />
<span>{selectedCloneOutput.label}</span>
2026-06-02 12:38:01 +08:00
</button>
) : null}
2026-06-02 12:38:01 +08:00
</div>
</section>
) : (
<section className="clone-ai-empty-state" aria-live="polite">
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <FrownOutlined /> : <FileImageOutlined />}
<strong>{status === "generating" ? "正在生成" : status === "failed" ? "生成失败" : "等待生成"}</strong>
{status === "generating" ? <EcommerceProgressBar status="generating" label={`${selectedCloneOutput.label}生成`} /> : null}
2026-06-02 12:38:01 +08:00
<span>
{status === "generating"
? `AI 正在为 ${platform} / ${market} 整理${selectedCloneOutput.label}`
: status === "failed"
? "请检查网络后点击下方重试"
2026-06-02 12:38:01 +08:00
: "上传商品原图并填写信息后,AI 将在这里展示生成结果。"}
</span>
{status === "failed" && lastFailedActionRef.current ? (
<button type="button" className="clone-ai-retry-btn" onClick={lastFailedActionRef.current}>
<ReloadOutlined />
</button>
) : null}
2026-06-02 12:38:01 +08:00
</section>
)}
<section className="clone-ai-bottom-input" aria-label="信息详情">
<div className="clone-ai-input-wrapper">
<textarea
ref={requirementTextareaRef}
value={requirement}
onChange={(event) => {
const nextValue = event.target.value.slice(0, 500);
setRequirement(nextValue);
syncRequirementMentionQuery(nextValue, event.target.selectionStart);
}}
onClick={(event) => syncRequirementMentionQuery(requirement, event.currentTarget.selectionStart)}
onKeyUp={(event) => syncRequirementMentionQuery(requirement, event.currentTarget.selectionStart)}
onKeyDown={(event) => {
if (event.key === "Escape") setRequirementImageMentionQuery(null);
}}
maxLength={500}
placeholder="建议包含以下信息,产品名称,核心卖点,期望场景,具体参数"
/>
{requirementImageMentionQuery !== null && ecommerceMentionImages.length ? (
<ImageMentionMenu images={ecommerceMentionImages} query={requirementImageMentionQuery} onSelect={insertRequirementImageMention} />
) : null}
<button type="button" className="clone-ai-send-button" disabled={!canGenerate} onClick={handleGenerate} aria-label={clonePrimaryLabel}>
{status === "generating" ? <LoadingOutlined /> : "↑"}
</button>
</div>
<span className="clone-ai-char-count">{requirement.length}/500</span>
</section>
</main>
);
const detailPreview = (
<main className="product-clone-preview product-clone-preview--detail" aria-label="A+详情预览">
<div className="product-clone-preview__headline">
<h1>A+/</h1>
<p>
AI <span></span>
</p>
</div>
<section className="product-detail-demo-board">
<div className="product-detail-source-stack">
{(detailProductImages.length ? detailProductImages.map((item) => item.src) : detailProductSamples).map((src, index) => (
<figure key={`${src}-${index}`}>
<img src={src} alt={`商品原图 ${index + 1}`} />
</figure>
))}
<span></span>
</div>
<div className="product-detail-flow-arrow" aria-hidden="true" />
<div className="product-detail-long-result">
<img src={detailResultUrl ?? detailAssets.longPage} alt="生成电商长图" />
2026-06-02 12:38:01 +08:00
<span>{detailStatus === "done" ? "已生成电商长图" : "生成电商长图"}</span>
</div>
<div className="product-detail-grid-result">
{detailGridSamples.map((src, index) => (
<img key={src} src={src} alt={`详情页模块 ${index + 1}`} />
))}
<span></span>
</div>
</section>
<button type="button" className="product-clone-help" aria-label="帮助">
<QuestionCircleOutlined />
</button>
</main>
);
const tryOnPreview = (
<main className="product-clone-preview product-clone-preview--try-on" aria-label="服饰穿戴预览">
<div className="product-clone-preview__headline">
<h1>AI服饰穿戴</h1>
<p>姿</p>
</div>
{tryOnResultImages.length ? (
<section className="product-try-on-generated" aria-label="生成结果">
{tryOnResultImages.map((src, index) => (
<figure key={src}>
<img src={src} alt={`生成结果 ${index + 1}`} />
<figcaption>{selectedScenes[index % Math.max(selectedScenes.length, 1)] || "智能场景"}</figcaption>
</figure>
))}
</section>
) : null}
<section className="product-try-on-demo-board">
{tryOnCards.map((card) => (
<article key={card.title} className={`product-try-on-card product-try-on-card--${card.tone}`}>
<h2>{card.title}</h2>
<div className="product-try-on-inputs">
{card.inputs.map((src, index) => (
<div className="product-try-on-input-group" key={`${card.title}-${src}`}>
<img src={src} alt={`${card.title} 输入 ${index + 1}`} />
{index < card.inputs.length - 1 ? <span className="product-try-on-plus">+</span> : null}
</div>
))}
</div>
<div className="product-try-on-arrow" aria-hidden="true" />
<div className="product-try-on-results">
{card.results.map((src, index) => (
<img key={src} src={src} alt={`${card.title} 示例 ${index + 1}`} />
))}
</div>
</article>
))}
</section>
<button type="button" className="product-clone-help" aria-label="帮助">
<QuestionCircleOutlined />
</button>
</main>
);
const placeholderPreview = (
<main className="product-clone-preview product-clone-preview--placeholder" aria-label={`${pageLabel}预览`}>
<div className="product-clone-preview__headline">
<h1>{pageLabel}</h1>
<p></p>
</div>
<button type="button" className="product-clone-help" aria-label="帮助">
<QuestionCircleOutlined />
</button>
</main>
);
return (
<section
className={`product-clone-page page-motion${isCloneTool && isCloneSettingsCollapsed ? " is-settings-collapsed" : ""}`}
data-tool={activeTool}
aria-label={pageLabel}
>
<div className="product-clone-shell">
<aside className="product-clone-rail" aria-label="商品工具">
{sideTools.map((tool) => (
<button key={tool.key} type="button" className={activeTool === tool.key ? "is-active" : ""} onClick={() => setActiveTool(tool.key)}>
{tool.icon}
<span>{tool.label}</span>
</button>
))}
</aside>
<aside
id={isCloneTool ? "ecommerce-clone-settings-panel" : undefined}
className={`product-clone-panel tool-panel-enter`}
key={activeTool}
2026-06-02 12:38:01 +08:00
aria-label={`${pageLabel}参数`}
aria-hidden={isCloneTool && isCloneSettingsCollapsed ? true : undefined}
>
{isSetTool ? setPanel : isDetail ? detailPanel : isTryOn ? tryOnPanel : isCloneTool ? clonePanel : placeholderPanel}
</aside>
{isCloneTool ? (
<button
type="button"
className="clone-ai-settings-toggle"
onClick={() => setIsCloneSettingsCollapsed((current) => !current)}
aria-label={isCloneSettingsCollapsed ? "展开设置面板" : "收起设置面板"}
aria-controls="ecommerce-clone-settings-panel"
aria-expanded={!isCloneSettingsCollapsed}
title={isCloneSettingsCollapsed ? "展开设置面板" : "收起设置面板"}
>
{isCloneSettingsCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</button>
) : null}
{isSetTool ? setPreview : isDetail ? detailPreview : isTryOn ? tryOnPreview : isCloneTool ? (cloneOutput === "video" ? (
<main className="product-clone-preview product-clone-preview--video" style={{ padding: 0, overflow: "hidden" }}>
<EcommerceVideoWorkspace
isAuthenticated={Boolean((_props as Record<string, unknown>).isAuthenticated)}
productImageDataUrls={productImages.map((img) => img.src)}
requirement={requirement}
platform={platform}
aspectRatio={ratio.includes("916") || ratio.includes("9:16") ? "9:16" : ratio.includes("169") || ratio.includes("16:9") ? "16:9" : ratio.includes("34") || ratio.includes("3:4") ? "3:4" : "9:16"}
durationSeconds={cloneVideoDuration}
resolution={cloneVideoQuality === "standard" ? "720P" : "1080P"}
onRequestLogin={() => ((_props as Record<string, unknown>).isAuthenticated ? undefined : (window.location.hash = "#/login"))}
2026-06-02 12:38:01 +08:00
/>
</main>
) : cloneOutput === "video-outfit" && results.length > 0 && results[0].type === "video" ? (
<main className="product-clone-preview product-clone-preview--video-outfit" style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ maxWidth: "100%", maxHeight: "100%" }}>
<video src={results[0].src} controls style={{ maxWidth: "100%", maxHeight: "70vh", borderRadius: "12px" }} />
</div>
</main>
2026-06-02 12:38:01 +08:00
) : clonePreview) : placeholderPreview}
</div>
{selectedProductSetPreview ? (
<div className="product-set-preview-backdrop" role="presentation" onClick={() => setSelectedProductSetPreview(null)}>
<section
className="product-set-preview-modal"
role="dialog"
aria-modal="true"
aria-label={selectedProductSetPreview.label}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
className="product-set-preview-close"
onClick={() => setSelectedProductSetPreview(null)}
aria-label="关闭预览"
>
<CloseOutlined />
</button>
<img src={selectedProductSetPreview.src} alt={selectedProductSetPreview.label} />
<strong>{selectedProductSetPreview.label}</strong>
</section>
</div>
) : null}
{showHostingModal ? (
<div className="product-set-hosting-backdrop" role="presentation">
<section className="product-set-hosting-modal" role="dialog" aria-modal="true" aria-label="批量托管上线啦">
<img src={productSetAssets.hosting} alt="托管模式" />
<div className="product-set-hosting-content">
<button type="button" className="product-set-hosting-close" onClick={() => setShowHostingModal(false)} aria-label="关闭">
×
</button>
<h2>
线
<span>6</span>
</h2>
<strong></strong>
<ul>
<li>
<b></b>
<span>线</span>
</li>
<li>
<b>40%</b>
<span>线</span>
</li>
<li>
<b>AI智能提取</b>
<span></span>
</li>
</ul>
<button type="button" className="product-set-hosting-confirm" onClick={() => setShowHostingModal(false)}>
</button>
</div>
</section>
</div>
) : null}
</section>
);
}
export default ProductClonePage;