Files
omniai-web/src/features/ecommerce/EcommercePage.tsx
T
stringadmin f5a75074a4 feat: 邮箱注册验证 + 9项功能修复与优化
【认证系统】
- 新增邮箱验证码注册/登录流程 (sendEmailCode / verifyEmail / forgotPassword / resetPassword)
- register-email 现在需要验证码
- 服务端新增 email_verification_codes 表 + patch-email-verification.js
- App.tsx 登录后 emailVerified 检查提醒
- keyServerClient token 显式传递修复 401 错误

【电商模块】
- 自动推进: 策划完成后自动生成分镜图/视频
- 模特图选项 (性别/年龄/种族/体型/场景) 注入 AI 提示词
- 任务持久化指纹修复 (图片数量替代 blob URL)
- 新增「视频换装」入口 (happyhorse-1.0-video-edit)

【剧本评分】
- 新增 .docx/.doc Word 文档支持 (ZIP解压+XML提取)
- 历史记录支持点击查看/恢复评测结果

【画布】
- ReactFlow 节点禁止内置拖拽避免冲突
- 连接线拖拽弹窗优化 (预览线不消失, 弹窗跟踪鼠标)

【页面修复】
- 首页轮播图改为 aspect-ratio: 16/9 解决尺寸问题
- 资产库新增悬停删除按钮
- scriptEvalClient 改用服务端 /api/ai/chat 端点
- TokenUsagePage 未登录跳过 API 调用
2026-06-03 20:19:07 +08:00

2359 lines
104 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AppstoreOutlined,
CloudUploadOutlined,
CloseOutlined,
FileImageOutlined,
FrownOutlined,
LoadingOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
QuestionCircleOutlined,
ReloadOutlined,
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`;
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";
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";
import {
normalizeEcommerceImageMime,
summarizeRejectedImages,
validateEcommerceImageFiles,
} from "./ecommerceImageValidation";
interface ProductClonePageProps {
[key: string]: unknown;
}
type ProductCloneStatus = "idle" | "ready" | "generating" | "done" | "failed";
type ProductSetOutputKey = "set" | "detail" | "model" | "video";
type CloneOutputKey = ProductSetOutputKey | "hot" | "video-outfit";
type CloneSetCountKey = "selling" | "white" | "scene";
type CloneModelPanelTab = "scene" | "model";
type CloneVideoQualityKey = "standard" | "high" | "ultra";
type ProductSetStatus = "idle" | "ready" | "generating" | "done" | "failed";
type ProductKitToolKey = "set" | "detail" | "wear" | "clone";
type 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";
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";
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: "视频换装" },
];
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;
}
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);
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[]>([]);
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);
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);
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);
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";
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);
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);
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);
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;
}
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;
}
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);
});
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");
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);
});
const videoDataUrl = await readAsDataUrl(videoOutfitVideoFile);
const refDataUrl = await readAsDataUrl(videoOutfitRefFile);
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",
});
const { taskId } = await aiGenerationClient.createVideoEditTask({
videoUrl: videoAsset.url,
referenceUrls: [refAsset.url],
prompt: requirement || undefined,
});
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");
} catch (err) {
setStatus("failed");
toast.error(err instanceof Error ? err.message : "视频换装生成失败");
}
};
const handleGenerate = () => {
if (!canGenerate) return;
if ((appUsage?.balanceCents ?? 0) <= 0) {
toast.error("积分不足,请充值后继续");
return;
}
if (cloneOutput === "set" && cloneSetTotal > 5) {
if (!window.confirm(`将生成 ${cloneSetTotal} 张图片,可能消耗较多积分,是否继续?`)) return;
}
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();
}
};
const handleGenerateModel = () => {
imageAbortRef.current = { current: false };
lastFailedActionRef.current = null;
setTryOnStatus("modeling");
void generateEcommerceImage(
"model", garmentImages, requirement,
platform, ratio, language, market,
{ gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene },
(s) => {
if (s === "done") setTryOnStatus("ready");
else setTryOnStatus(s as TryOnStatus);
},
() => { setTryOnStatus("ready"); },
);
lastFailedActionRef.current = () => handleGenerateModel();
};
const handleTryOnGenerate = () => {
if (!canGenerateTryOn) return;
imageAbortRef.current = { current: false };
lastFailedActionRef.current = null;
void generateEcommerceImage(
"model", garmentImages, requirement,
platform, ratio, language, market,
{ gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene },
(s) => setTryOnStatus(s as TryOnStatus),
(res) => setTryOnResultImages(res.map((r) => r.src).filter(Boolean)),
);
lastFailedActionRef.current = () => handleTryOnGenerate();
};
const toggleScene = (scene: string) => {
setSelectedScenes((current) =>
current.includes(scene) ? current.filter((item) => item !== scene) : [...current, scene],
);
};
const toggleDetailModule = (moduleId: string) => {
setSelectedDetailModules((current) =>
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();
};
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),
);
};
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++;
}
}
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}
/>
);
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); }}
/>
);
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}
/>
);
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}
/>
);
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])}
>
<img src={setImages[0]?.src ?? (setPreviewCards[0]?.src ?? productSetPreviewCards[0].src)} alt="商品原图" />
<span></span>
</button>
<div className="product-set-flow-arrow" aria-hidden="true" />
<div className="product-set-card-grid result-reveal">
{setPreviewCards.map((card) => (
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
<img src={card.src} alt={card.label} />
<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}
<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>
</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="上传商品原图" />
<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>
</button>
) : null}
</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}
<span>
{status === "generating"
? `AI 正在为 ${platform} / ${market} 整理${selectedCloneOutput.label}`
: status === "failed"
? "请检查网络后点击下方重试"
: "上传商品原图并填写信息后,AI 将在这里展示生成结果。"}
</span>
{status === "failed" && lastFailedActionRef.current ? (
<button type="button" className="clone-ai-retry-btn" onClick={lastFailedActionRef.current}>
<ReloadOutlined />
</button>
) : null}
</section>
)}
<section 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="生成电商长图" />
<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}
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"))}
/>
</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>
) : 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;