44c748b0dc
- Add uploadAssetBinary method to aiGenerationClient (FormData + busboy) - Replace base64 dataUrl upload in uploadProductImages with direct blob upload via /oss/upload-binary multipart endpoint - This eliminates the DATA_URL_PATTERN regex parsing bug that produced 44-byte corrupt files on OSS, causing DashScope "image format illegal" errors Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2916 lines
127 KiB
TypeScript
2916 lines
127 KiB
TypeScript
import {
|
||
AppstoreOutlined,
|
||
CloudUploadOutlined,
|
||
CloseOutlined,
|
||
FileImageOutlined,
|
||
LoadingOutlined,
|
||
MenuFoldOutlined,
|
||
MenuUnfoldOutlined,
|
||
QuestionCircleOutlined,
|
||
SettingOutlined,
|
||
SkinOutlined,
|
||
} from "@ant-design/icons";
|
||
import { useEffect, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react";
|
||
|
||
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 { aiGenerationClient } from "../../api/aiGenerationClient";
|
||
import {
|
||
analyzeProductImages,
|
||
buildProductSummary,
|
||
extractSellingPoints,
|
||
generateCreativeOptions,
|
||
generateStoryboard,
|
||
generateVideoPrompts,
|
||
checkCompliance,
|
||
type AdVideoUserConfig,
|
||
type ProductSummary,
|
||
type SellingPointResult,
|
||
type CreativeOption,
|
||
type Storyboard,
|
||
type VideoPrompt,
|
||
type ComplianceCheck,
|
||
} from "../../api/adVideoPlanClient";
|
||
|
||
|
||
interface ProductClonePageProps {
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
type ProductCloneStatus = "idle" | "ready" | "generating" | "done";
|
||
type ProductSetOutputKey = "set" | "detail" | "model" | "video";
|
||
type CloneOutputKey = ProductSetOutputKey | "hot";
|
||
type CloneSetCountKey = "selling" | "white" | "scene";
|
||
type CloneModelPanelTab = "scene" | "model";
|
||
type CloneVideoQualityKey = "standard" | "high" | "ultra";
|
||
type ProductSetStatus = "idle" | "ready" | "generating" | "done";
|
||
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";
|
||
type DetailStatus = "idle" | "ready" | "generating" | "done";
|
||
|
||
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";
|
||
|
||
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\u00a01:1", "800×800px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "1000×1000px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
detail: {
|
||
ratios: [
|
||
"750×1000px\u00a0\u00a0\u00a03:4",
|
||
"790×1053px\u00a0\u00a0\u00a03:4",
|
||
"750×1125px\u00a0\u00a0\u00a02:3",
|
||
"790×1185px\u00a0\u00a0\u00a02:3",
|
||
],
|
||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||
},
|
||
model: {
|
||
ratios: ["750×1000px\u00a0\u00a0\u00a03:4"],
|
||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||
},
|
||
video: {
|
||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1080×1440px\u00a0\u00a0\u00a03:4", "1080×1080px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||
},
|
||
hot: {
|
||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
},
|
||
specs: ["主图 / SKU 图 800×800px,≤3MB", "详情页宽 750px 或 790px,单张高≤1546px"],
|
||
tip: "建议主图 200-400KB JPG,超过 500KB 会影响加载速度。",
|
||
},
|
||
{
|
||
label: "京东",
|
||
ratios: ["京东主图 / SKU 图 800×800px", "详情页宽 750px", "首图主体占比 ≥70%"],
|
||
defaultRatio: "京东主图 / SKU 图 800×800px",
|
||
ratioGroups: {
|
||
set: {
|
||
ratios: ["1000×1000px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "1000×1000px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
detail: {
|
||
ratios: [
|
||
"750×1000px\u00a0\u00a0\u00a03:4",
|
||
"990×1320px\u00a0\u00a0\u00a03:4",
|
||
"750×1125px\u00a0\u00a0\u00a02:3",
|
||
"990×1485px\u00a0\u00a0\u00a02:3",
|
||
],
|
||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||
},
|
||
model: {
|
||
ratios: ["750×1125px\u00a0\u00a0\u00a02:3", "990×1485px\u00a0\u00a0\u00a02:3"],
|
||
defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3",
|
||
},
|
||
video: {
|
||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1920×1080px\u00a0\u00a0\u00a016:9"],
|
||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||
},
|
||
hot: {
|
||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
},
|
||
specs: ["主图 / SKU 图 800×800px,白底,≤1MB", "详情页宽 750px,首图主体占比 ≥70%"],
|
||
},
|
||
{
|
||
label: "拼多多",
|
||
ratios: ["主图 750×352px", "主图 800×800px", "详情页宽 750px"],
|
||
defaultRatio: "主图 750×352px",
|
||
ratioGroups: {
|
||
set: {
|
||
ratios: ["800×800px\u00a0\u00a0\u00a01:1", "750×1000px\u00a0\u00a0\u00a03:4"],
|
||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
detail: {
|
||
ratios: ["750×1000px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3"],
|
||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||
},
|
||
model: {
|
||
ratios: ["750×1000px\u00a0\u00a0\u00a03:4"],
|
||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||
},
|
||
video: {
|
||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"],
|
||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||
},
|
||
hot: {
|
||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
},
|
||
specs: ["主图 750×352px 或 800×800px,≤1MB", "详情页宽 750px,要求纯白底、无水印、无拼接"],
|
||
},
|
||
{
|
||
label: "抖音电商",
|
||
ratios: ["短视频 1080×1920px"],
|
||
defaultRatio: "短视频 1080×1920px",
|
||
ratioGroups: {
|
||
video: {
|
||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"],
|
||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||
},
|
||
hot: {
|
||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
},
|
||
specs: ["短视频 1080×1920px,9:16", "30s 内最佳"],
|
||
},
|
||
{
|
||
label: "亚马逊 Amazon",
|
||
ratios: ["主图 ≥1600×1600px", "建议 2000×2000px+", "最小 500×500px"],
|
||
defaultRatio: "主图 ≥1600×1600px",
|
||
ratioGroups: {
|
||
set: {
|
||
ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
detail: {
|
||
ratios: ["1600×1600px\u00a0\u00a0\u00a01:1", "1200×1800px\u00a0\u00a0\u00a02:3", "1200×1600px\u00a0\u00a0\u00a03:4"],
|
||
defaultRatio: "1200×1800px\u00a0\u00a0\u00a02:3",
|
||
},
|
||
model: {
|
||
ratios: ["1200×1800px\u00a0\u00a0\u00a02:3"],
|
||
defaultRatio: "1200×1800px\u00a0\u00a0\u00a02:3",
|
||
},
|
||
video: {
|
||
ratios: ["1920×1080px\u00a0\u00a0\u00a016:9"],
|
||
defaultRatio: "1920×1080px\u00a0\u00a0\u00a016:9",
|
||
},
|
||
hot: {
|
||
ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
},
|
||
specs: ["主图 1600×1600px+,纯白底,≤10MB", "最小 500×500px,建议 2000px+ 以支持缩放"],
|
||
aliases: ["亚马逊"],
|
||
},
|
||
{
|
||
label: "Shopee",
|
||
ratios: ["商品主图 1024×1024px", "基础主图 800×800px"],
|
||
defaultRatio: "商品主图 1024×1024px",
|
||
ratioGroups: {
|
||
set: {
|
||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
detail: {
|
||
ratios: ["750×1000px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3"],
|
||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||
},
|
||
model: {
|
||
ratios: ["750×1000px\u00a0\u00a0\u00a03:4"],
|
||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||
},
|
||
video: {
|
||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"],
|
||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||
},
|
||
hot: {
|
||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
},
|
||
specs: ["商品主图推荐 1024×1024px,基础 800×800px", "≤2MB,白底或浅色底"],
|
||
aliases: ["虾皮 Shopee/Lazada", "虾皮"],
|
||
},
|
||
{
|
||
label: "Lazada",
|
||
ratios: ["商品主图 800×800px"],
|
||
defaultRatio: "商品主图 800×800px",
|
||
ratioGroups: {
|
||
set: {
|
||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
detail: {
|
||
ratios: ["750×1000px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3"],
|
||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||
},
|
||
model: {
|
||
ratios: ["750×1000px\u00a0\u00a0\u00a03:4"],
|
||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||
},
|
||
video: {
|
||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"],
|
||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||
},
|
||
hot: {
|
||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
},
|
||
specs: ["商品主图 800×800px,1:1"],
|
||
},
|
||
{
|
||
label: "Instagram",
|
||
ratios: ["帖子 1080×1350px", "帖子 1080×1080px", "Stories / Reels 1080×1920px", "头像 320×320px"],
|
||
defaultRatio: "帖子 1080×1350px",
|
||
ratioGroups: {
|
||
set: {
|
||
ratios: ["1080×1080px\u00a0\u00a0\u00a01:1", "1080×1350px\u00a0\u00a0\u00a04:5"],
|
||
defaultRatio: "1080×1080px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
detail: {
|
||
ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"],
|
||
defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5",
|
||
},
|
||
model: {
|
||
ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"],
|
||
defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5",
|
||
},
|
||
video: {
|
||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1080×1350px\u00a0\u00a0\u00a04:5"],
|
||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||
},
|
||
},
|
||
specs: ["帖子 1080×1350px 或 1080×1080px", "Stories / Reels 封面 1080×1920px,头像 320×320px"],
|
||
tip: "建议 ≤1MB JPG。",
|
||
aliases: ["Instagram Reels"],
|
||
},
|
||
{
|
||
label: "速卖通",
|
||
ratios: ["主图 800×800px", "主图 1000×1000px+"],
|
||
defaultRatio: "主图 800×800px",
|
||
ratioGroups: {
|
||
set: {
|
||
ratios: ["1000×1000px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "1000×1000px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
detail: {
|
||
ratios: ["750×1125px\u00a0\u00a0\u00a02:3", "750×1000px\u00a0\u00a0\u00a03:4"],
|
||
defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3",
|
||
},
|
||
model: {
|
||
ratios: ["750×1125px\u00a0\u00a0\u00a02:3"],
|
||
defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3",
|
||
},
|
||
video: {
|
||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1920×1080px\u00a0\u00a0\u00a016:9"],
|
||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||
},
|
||
hot: {
|
||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
},
|
||
specs: ["主图建议 800×800px 或更高,1:1", "适合跨境电商主图、SKU 图和场景图"],
|
||
},
|
||
{
|
||
label: "eBay",
|
||
ratios: ["商品图 1:1", "白底多角度展示图 1:1"],
|
||
defaultRatio: "商品图 1:1",
|
||
ratioGroups: {
|
||
set: {
|
||
ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
detail: {
|
||
ratios: ["1000×1500px\u00a0\u00a0\u00a02:3", "1200×1600px\u00a0\u00a0\u00a03:4"],
|
||
defaultRatio: "1000×1500px\u00a0\u00a0\u00a02:3",
|
||
},
|
||
model: {
|
||
ratios: ["1000×1500px\u00a0\u00a0\u00a02:3"],
|
||
defaultRatio: "1000×1500px\u00a0\u00a0\u00a02:3",
|
||
},
|
||
video: {
|
||
ratios: ["1920×1080px\u00a0\u00a0\u00a016:9", "1080×1920px\u00a0\u00a0\u00a09:16"],
|
||
defaultRatio: "1920×1080px\u00a0\u00a0\u00a016:9",
|
||
},
|
||
hot: {
|
||
ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
},
|
||
specs: ["商品图建议 1:1,主体清晰居中", "适合白底主图和多角度展示图"],
|
||
},
|
||
{
|
||
label: "TikTok Shop",
|
||
ratios: ["商品主图 1:1", "短视频 / 竖版封面 9:16"],
|
||
defaultRatio: "商品主图 1:1",
|
||
ratioGroups: {
|
||
set: {
|
||
ratios: ["1280×1280px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "1280×1280px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
detail: {
|
||
ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"],
|
||
defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5",
|
||
},
|
||
model: {
|
||
ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"],
|
||
defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5",
|
||
},
|
||
video: {
|
||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"],
|
||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||
},
|
||
hot: {
|
||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
},
|
||
specs: ["商品主图建议 1:1", "短视频/竖版封面建议 9:16"],
|
||
},
|
||
];
|
||
const platformOptions = platformSpecOptions.map((option) => option.label);
|
||
const 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: "爆款图复刻" },
|
||
];
|
||
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)
|
||
.filter((file) => file.type.startsWith("image/"))
|
||
.slice(0, limit)
|
||
.map<CloneImageItem>((file, index) => ({
|
||
id: `${prefix}-${Date.now()}-${index}`,
|
||
src: URL.createObjectURL(file),
|
||
name: file.name,
|
||
format: getImageFileFormat(file),
|
||
}));
|
||
}
|
||
|
||
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 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 [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 [adVideoStep, setAdVideoStep] = useState<"idle" | "planning" | "planned" | "rendering">("idle");
|
||
const [adVideoBusy, setAdVideoBusy] = useState(false);
|
||
const [adVideoError, setAdVideoError] = useState<string | null>(null);
|
||
const [adVideoProgress, setAdVideoProgress] = useState("");
|
||
const [adVideoSummary, setAdVideoSummary] = useState<ProductSummary | null>(null);
|
||
const [adVideoSelling, setAdVideoSelling] = useState<SellingPointResult | null>(null);
|
||
const [adVideoCreatives, setAdVideoCreatives] = useState<CreativeOption[]>([]);
|
||
const [adVideoStoryboard, setAdVideoStoryboard] = useState<Storyboard | null>(null);
|
||
const [adVideoPrompts, setAdVideoPrompts] = useState<VideoPrompt[]>([]);
|
||
const [adVideoCompliance, setAdVideoCompliance] = useState<ComplianceCheck | null>(null);
|
||
const [adVideoScenes, setAdVideoScenes] = useState<
|
||
Array<{ sceneId: number; taskId?: string; status: string; progress: number; resultUrl?: string | null; error?: string }>
|
||
>([]);
|
||
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 [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 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 = 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 = files.filter((file) => file.type.startsWith("image/"));
|
||
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 = files.filter((file) => file.type.startsWith("image/"));
|
||
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 = files.filter((file) => file.type.startsWith("image/"));
|
||
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 = Array.from(files);
|
||
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 = Array.from(files);
|
||
setDetailProductImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 3 - current.length, "detail")].slice(0, 3));
|
||
setDetailStatus("ready");
|
||
event.target.value = "";
|
||
};
|
||
|
||
const buildAdVideoConfig = (): AdVideoUserConfig => ({
|
||
platform,
|
||
aspectRatio: ratio.includes("9:16") || ratio.includes("9:16") ? "9:16" : ratio.includes("16:9") || ratio.includes("16:9") ? "16:9" : "1:1",
|
||
durationSeconds: cloneVideoDuration,
|
||
style: "痛点解决",
|
||
language,
|
||
market,
|
||
needVoiceover: true,
|
||
needSubtitle: true,
|
||
conversionFocus: "conversion",
|
||
});
|
||
|
||
const uploadProductImages = async (): Promise<string[]> => {
|
||
const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
||
const urls: string[] = [];
|
||
for (const item of productImages) {
|
||
try {
|
||
const resp = await fetch(item.src);
|
||
const rawBlob = await resp.blob();
|
||
const mimeType = SUPPORTED_IMAGE_TYPES.has(rawBlob.type) ? rawBlob.type : "image/png";
|
||
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
|
||
const { url } = await aiGenerationClient.uploadAssetBinary(blob, { name: item.name, mimeType, scope: "ecommerce-product" });
|
||
urls.push(url);
|
||
} catch {
|
||
// skip images that fail to upload
|
||
}
|
||
}
|
||
return urls;
|
||
};
|
||
|
||
const adVideoUploadedUrlsRef = useRef<string[]>([]);
|
||
|
||
const handleAdVideoPlan = async () => {
|
||
if (productImages.length === 0 && !requirement.trim()) {
|
||
setAdVideoError("请先上传产品图片或填写商品说明");
|
||
return;
|
||
}
|
||
setAdVideoBusy(true);
|
||
setAdVideoError(null);
|
||
setAdVideoStep("planning");
|
||
try {
|
||
setAdVideoProgress("正在上传产品图片…");
|
||
const imageUrls = await uploadProductImages();
|
||
adVideoUploadedUrlsRef.current = imageUrls;
|
||
|
||
setAdVideoProgress("正在分析产品图片…");
|
||
const imageDesc = await analyzeProductImages(imageUrls);
|
||
|
||
setAdVideoProgress("正在生成商品理解…");
|
||
const summary = await buildProductSummary(imageDesc, requirement);
|
||
setAdVideoSummary(summary);
|
||
|
||
setAdVideoProgress("正在提炼卖点…");
|
||
const selling = await extractSellingPoints(summary);
|
||
setAdVideoSelling(selling);
|
||
|
||
const config = buildAdVideoConfig();
|
||
setAdVideoProgress("正在生成广告创意…");
|
||
const creatives = await generateCreativeOptions(selling, config);
|
||
setAdVideoCreatives(creatives);
|
||
const chosen = creatives[0];
|
||
if (!chosen) throw new Error("未能生成有效的广告创意");
|
||
|
||
setAdVideoProgress("正在生成视频分镜…");
|
||
const storyboard = await generateStoryboard(chosen, summary, config);
|
||
setAdVideoStoryboard(storyboard);
|
||
|
||
setAdVideoProgress("正在生成镜头提示词…");
|
||
const prompts = await generateVideoPrompts(storyboard, summary);
|
||
setAdVideoPrompts(prompts);
|
||
|
||
setAdVideoProgress("正在进行合规检查…");
|
||
const compliance = await checkCompliance(summary, selling, storyboard);
|
||
setAdVideoCompliance(compliance);
|
||
|
||
setAdVideoStep("planned");
|
||
} catch (err) {
|
||
setAdVideoError(err instanceof Error ? err.message : "广告策划生成失败");
|
||
setAdVideoStep("idle");
|
||
} finally {
|
||
setAdVideoBusy(false);
|
||
setAdVideoProgress("");
|
||
}
|
||
};
|
||
|
||
const pollAdVideoTask = async (sceneId: number, taskId: string) => {
|
||
for (let i = 0; i < 150; i++) {
|
||
await new Promise((r) => setTimeout(r, 4000));
|
||
let st;
|
||
try {
|
||
st = await aiGenerationClient.getTaskStatus(taskId);
|
||
} catch {
|
||
continue;
|
||
}
|
||
setAdVideoScenes((prev) =>
|
||
prev.map((s) =>
|
||
s.sceneId === sceneId
|
||
? { ...s, status: st.status === "cancelled" ? "failed" : st.status, progress: st.progress, resultUrl: st.resultUrl }
|
||
: s,
|
||
),
|
||
);
|
||
if (st.status === "completed" || st.status === "failed" || st.status === "cancelled") return;
|
||
}
|
||
};
|
||
|
||
const handleAdVideoRender = async () => {
|
||
if (!adVideoStoryboard) return;
|
||
setAdVideoStep("rendering");
|
||
setAdVideoError(null);
|
||
const referenceUrl = adVideoUploadedUrlsRef.current[0];
|
||
setAdVideoScenes(
|
||
adVideoStoryboard.scenes.map((s) => ({ sceneId: s.scene_id, status: "idle", progress: 0 })),
|
||
);
|
||
for (const scene of adVideoStoryboard.scenes) {
|
||
const prompt = adVideoPrompts.find((p) => p.scene_id === scene.scene_id);
|
||
const positivePrompt = prompt?.positive_prompt || scene.visual_description;
|
||
const sceneDuration = Number.parseInt(scene.duration, 10) || 5;
|
||
try {
|
||
const { taskId } = await aiGenerationClient.createVideoTask({
|
||
model: "happyhorse-1.0-i2v",
|
||
prompt: positivePrompt,
|
||
ratio: buildAdVideoConfig().aspectRatio,
|
||
duration: sceneDuration,
|
||
imageUrl: referenceUrl,
|
||
referenceUrls: referenceUrl ? [referenceUrl] : undefined,
|
||
});
|
||
setAdVideoScenes((prev) =>
|
||
prev.map((s) => (s.sceneId === scene.scene_id ? { ...s, taskId, status: "pending" } : s)),
|
||
);
|
||
void pollAdVideoTask(scene.scene_id, taskId);
|
||
} catch (err) {
|
||
setAdVideoScenes((prev) =>
|
||
prev.map((s) =>
|
||
s.sceneId === scene.scene_id
|
||
? { ...s, status: "failed", error: err instanceof Error ? err.message : "提交失败" }
|
||
: s,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
};
|
||
|
||
const renderAdVideoCompliance = () => {
|
||
if (!adVideoCompliance) return null;
|
||
const level = adVideoCompliance.risk_level;
|
||
const canRender = adVideoCompliance.allow_video_generation;
|
||
const rendering = adVideoStep === "rendering";
|
||
return (
|
||
<div className="clone-ai-adwizard__block">
|
||
<span className={`clone-ai-adwizard__risk is-${level}`}>
|
||
合规风险:{level === "low" ? "低" : level === "medium" ? "中" : "高"}
|
||
</span>
|
||
{adVideoCompliance.issues.length > 0 ? (
|
||
<ul className="clone-ai-adwizard__issues">
|
||
{adVideoCompliance.issues.map((issue, i) => (
|
||
<li key={i}>{issue.field}:{issue.problem} → {issue.suggestion}</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
className="clone-ai-adwizard__render"
|
||
disabled={!canRender || rendering}
|
||
onClick={handleAdVideoRender}
|
||
>
|
||
{rendering ? "正在出视频…" : canRender ? "✦ 确认并逐镜头出视频" : "存在高风险,无法生成"}
|
||
</button>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const renderAdVideoPlan = () => {
|
||
if (adVideoStep !== "planned" && adVideoStep !== "rendering") return null;
|
||
return (
|
||
<div className="clone-ai-adwizard__plan-result">
|
||
{adVideoSummary ? (
|
||
<div className="clone-ai-adwizard__block">
|
||
<strong>{adVideoSummary.product_name} · {adVideoSummary.category}</strong>
|
||
<p>{adVideoSummary.appearance}</p>
|
||
<div className="clone-ai-adwizard__chips">
|
||
{adVideoSummary.selling_points.map((sp, i) => (
|
||
<span key={i}>{sp}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{adVideoCreatives[0] ? (
|
||
<div className="clone-ai-adwizard__block">
|
||
<span className="clone-ai-adwizard__label">广告创意:{adVideoCreatives[0].creative_type}</span>
|
||
<p>{adVideoCreatives[0].hook}</p>
|
||
</div>
|
||
) : null}
|
||
{adVideoStoryboard ? (
|
||
<div className="clone-ai-adwizard__block">
|
||
<span className="clone-ai-adwizard__label">分镜:{adVideoStoryboard.video_title}</span>
|
||
<div className="clone-ai-adwizard__scenes">
|
||
{adVideoStoryboard.scenes.map((scene) => {
|
||
const sceneVideo = adVideoScenes.find((s) => s.sceneId === scene.scene_id);
|
||
return (
|
||
<div key={scene.scene_id} className="clone-ai-adwizard__scene">
|
||
<div className="clone-ai-adwizard__scene-head">
|
||
<span>镜头 {scene.scene_id} · {scene.duration}</span>
|
||
{sceneVideo ? (
|
||
<span className={`clone-ai-adwizard__scene-status is-${sceneVideo.status}`}>
|
||
{sceneVideo.status === "completed"
|
||
? "完成"
|
||
: sceneVideo.status === "failed"
|
||
? "失败"
|
||
: sceneVideo.status === "idle"
|
||
? "等待"
|
||
: `${sceneVideo.progress}%`}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<p>{scene.visual_description}</p>
|
||
{sceneVideo?.resultUrl ? (
|
||
<video src={sceneVideo.resultUrl} controls className="clone-ai-adwizard__scene-video" />
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{renderAdVideoCompliance()}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const handleGenerate = () => {
|
||
if (!canGenerate) return;
|
||
setStatus("generating");
|
||
window.setTimeout(() => {
|
||
const stamp = Date.now();
|
||
setResults(
|
||
sampleResults.map((src, index) => ({
|
||
id: `clone-result-${stamp}-${index}`,
|
||
src,
|
||
label: index === 0 ? "高度复刻" : index === 1 ? "参考风格" : "平台适配",
|
||
})),
|
||
);
|
||
setStatus("done");
|
||
}, 900);
|
||
};
|
||
|
||
const handleGenerateModel = () => {
|
||
setTryOnStatus("modeling");
|
||
window.setTimeout(() => setTryOnStatus("ready"), 700);
|
||
};
|
||
|
||
const handleTryOnGenerate = () => {
|
||
if (!canGenerateTryOn) return;
|
||
setTryOnStatus("generating");
|
||
window.setTimeout(() => {
|
||
setTryOnResultImages([tryOnAssets.tryA, tryOnAssets.tryB, tryOnAssets.hatResultA]);
|
||
setTryOnStatus("done");
|
||
}, 900);
|
||
};
|
||
|
||
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;
|
||
setProductSetStatus("generating");
|
||
window.setTimeout(() => setProductSetStatus("done"), 900);
|
||
};
|
||
|
||
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;
|
||
setDetailStatus("generating");
|
||
window.setTimeout(() => setDetailStatus("done"), 900);
|
||
};
|
||
|
||
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 clonePreviewCards = productSetPreviewCards.map((card, index) => ({
|
||
...card,
|
||
src: results[index]?.src ?? card.src,
|
||
}));
|
||
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 = (
|
||
<>
|
||
<div className="product-clone-panel__scroll">
|
||
<section className="product-clone-field product-set-upload-section">
|
||
<h2>
|
||
上传商品原图
|
||
<CloudUploadOutlined />
|
||
</h2>
|
||
<button
|
||
type="button"
|
||
className={`product-clone-upload-zone product-set-upload${isSetUploadDragging ? " is-dragging" : ""}`}
|
||
onClick={() => setInputRef.current?.click()}
|
||
onDragEnter={(event) => {
|
||
event.preventDefault();
|
||
setIsSetUploadDragging(true);
|
||
}}
|
||
onDragOver={(event) => event.preventDefault()}
|
||
onDragLeave={() => setIsSetUploadDragging(false)}
|
||
onDrop={handleSetDrop}
|
||
>
|
||
<span className="product-set-upload-icon">
|
||
<FileImageOutlined />
|
||
</span>
|
||
<span className="product-set-upload-title">拖拽或点击上传</span>
|
||
<strong>
|
||
<span aria-hidden="true">+</span>
|
||
上传图片
|
||
</strong>
|
||
<span className="product-set-upload-note">同一产品,最多 3 张</span>
|
||
</button>
|
||
<input ref={setInputRef} type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={handleSetUpload} />
|
||
{setImages.length ? (
|
||
<div className="product-clone-thumb-row product-set-thumb-row" aria-label="已上传商品原图">
|
||
{setImages.map((item) => (
|
||
<figure key={item.id} className="product-set-thumb">
|
||
<img src={item.src} alt={item.name} />
|
||
<span className="uploaded-image-zoom" aria-hidden="true">
|
||
<img src={item.src} alt="" />
|
||
</span>
|
||
<button type="button" onClick={() => removeSetImage(item.id)} aria-label={`删除${item.name}`}>
|
||
<CloseOutlined />
|
||
</button>
|
||
</figure>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
|
||
<section className="product-clone-field product-set-settings-section">
|
||
<h2>
|
||
生成设置
|
||
<SettingOutlined />
|
||
</h2>
|
||
<div className="product-set-setting-block">
|
||
<span className="product-set-setting-title">生成内容</span>
|
||
<div className="product-set-output-grid" role="radiogroup" aria-label="生成内容">
|
||
{productSetOutputOptions.map((option) => (
|
||
<button
|
||
key={option.key}
|
||
type="button"
|
||
className={productSetOutput === option.key ? "is-active" : ""}
|
||
aria-pressed={productSetOutput === option.key}
|
||
onClick={() => handleProductSetOutputChange(option.key)}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="product-set-setting-block">
|
||
<span className="product-set-setting-title">基础设置</span>
|
||
<div className="product-set-field-grid">
|
||
<label>
|
||
<span>平台</span>
|
||
<select value={productSetPlatform} onChange={(event) => handleProductSetPlatformChange(event.target.value)}>
|
||
{platformOptions.map((item) => (
|
||
<option key={item}>{item}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>国家</span>
|
||
<select value={productSetMarket} onChange={(event) => handleProductSetMarketChange(event.target.value)}>
|
||
{marketOptions.map((item) => (
|
||
<option key={item}>{item}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>语言</span>
|
||
<select value={productSetLanguage} onChange={(event) => setProductSetLanguage(event.target.value)}>
|
||
{productSetLanguageOptions.map((item) => (
|
||
<option key={item}>{item}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>尺寸/比例</span>
|
||
<select
|
||
value={productSetRatio}
|
||
onChange={(event) => setProductSetRatio(event.target.value)}
|
||
disabled={productSetRatioOptions.length <= 1}
|
||
>
|
||
{productSetRatioOptions.map((item) => (
|
||
<option key={item} value={item}>{formatRatioDisplayValue(item)}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
const clonePanel = (
|
||
<>
|
||
<div className="product-clone-panel__scroll clone-ai-panel">
|
||
<header className="clone-ai-logo">
|
||
<span className="clone-ai-logo__mark">AI</span>
|
||
<strong>电商生成</strong>
|
||
</header>
|
||
|
||
<section className="clone-ai-card">
|
||
<h2>
|
||
<CloudUploadOutlined />
|
||
上传商品原图
|
||
</h2>
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
className={`clone-ai-upload-zone${isProductUploadDragging ? " is-dragging" : ""}`}
|
||
onClick={() => productInputRef.current?.click()}
|
||
onKeyDown={(event) => {
|
||
if (event.target !== event.currentTarget) return;
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
productInputRef.current?.click();
|
||
}
|
||
}}
|
||
onDragEnter={(event) => {
|
||
event.preventDefault();
|
||
setIsProductUploadDragging(true);
|
||
}}
|
||
onDragOver={(event) => event.preventDefault()}
|
||
onDragLeave={() => setIsProductUploadDragging(false)}
|
||
onDrop={handleProductDrop}
|
||
>
|
||
<div className="clone-ai-upload-main">
|
||
<span className="clone-ai-upload-icon">
|
||
<FileImageOutlined />
|
||
</span>
|
||
<span className="clone-ai-upload-title">拖拽或点击上传</span>
|
||
<strong>
|
||
<span aria-hidden="true">+</span>
|
||
上传图片
|
||
</strong>
|
||
<span className="clone-ai-upload-hint">同一产品,最多 7 张</span>
|
||
</div>
|
||
{productImages.length ? (
|
||
<div className="clone-ai-uploaded-files" aria-label="已上传商品原图">
|
||
{productImages.map((item) => (
|
||
<figure key={item.id} className="clone-ai-uploaded-file">
|
||
<img src={item.src} alt={item.name} />
|
||
<span className="uploaded-image-zoom" aria-hidden="true">
|
||
<img src={item.src} alt="" />
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
removeProductImage(item.id);
|
||
}}
|
||
aria-label={`删除${item.name}`}
|
||
>
|
||
<CloseOutlined />
|
||
</button>
|
||
</figure>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<input ref={productInputRef} type="file" accept="image/*" multiple onChange={handleProductUpload} />
|
||
</section>
|
||
|
||
<section className="clone-ai-card">
|
||
<h2>
|
||
<SettingOutlined />
|
||
生成设置
|
||
</h2>
|
||
<div className="clone-ai-settings-section">
|
||
<span className="clone-ai-settings-label">生成内容</span>
|
||
<div className="clone-ai-tag-group" role="radiogroup" aria-label="生成内容">
|
||
{cloneOutputOptions.map((option) => (
|
||
<button
|
||
key={option.key}
|
||
type="button"
|
||
className={cloneOutput === option.key ? "is-active" : ""}
|
||
aria-pressed={cloneOutput === option.key}
|
||
onClick={() => handleCloneOutputChange(option.key)}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="clone-ai-settings-section">
|
||
<span className="clone-ai-settings-label">基础设置</span>
|
||
<div className="clone-ai-select-group">
|
||
{cloneBasicSelects.map((item) => {
|
||
const hasMultipleOptions = item.options.length > 1;
|
||
const isOpen = hasMultipleOptions && openCloneBasicSelect === item.key;
|
||
return (
|
||
<div key={item.key} className="clone-ai-basic-select" data-clone-basic-select>
|
||
<button
|
||
type="button"
|
||
className={`${isOpen ? "is-open" : ""}${hasMultipleOptions ? "" : " is-static"}`}
|
||
aria-expanded={hasMultipleOptions ? isOpen : undefined}
|
||
aria-haspopup={hasMultipleOptions ? "listbox" : undefined}
|
||
aria-controls={hasMultipleOptions ? `clone-basic-select-${item.key}` : undefined}
|
||
onClick={() => setOpenCloneBasicSelect(hasMultipleOptions ? (isOpen ? null : item.key) : null)}
|
||
>
|
||
<span>{item.label}</span>
|
||
<strong>{item.key === "ratio" ? formatRatioDisplayValue(item.value) : item.value}</strong>
|
||
{hasMultipleOptions ? <i aria-hidden="true" /> : null}
|
||
</button>
|
||
{hasMultipleOptions && isOpen ? (
|
||
<div id={`clone-basic-select-${item.key}`} className="clone-ai-basic-select__menu" role="listbox">
|
||
{item.options.map((option) => (
|
||
<button
|
||
key={option}
|
||
type="button"
|
||
className={item.value === option ? "is-active" : ""}
|
||
role="option"
|
||
aria-selected={item.value === option}
|
||
onClick={() => {
|
||
item.onChange(option);
|
||
setOpenCloneBasicSelect(null);
|
||
}}
|
||
>
|
||
{item.key === "ratio" ? formatRatioDisplayValue(option) : option}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{cloneOutput === "hot" ? (
|
||
<section className="clone-ai-replicate-panel" aria-label="爆款图复刻设置">
|
||
<div className="clone-ai-replicate-section">
|
||
<span className="clone-ai-replicate-title">参考内容</span>
|
||
<div className="clone-ai-replicate-tabs" role="tablist" aria-label="参考内容来源">
|
||
<button
|
||
type="button"
|
||
className={cloneReferenceMode === "upload" ? "is-active" : ""}
|
||
aria-selected={cloneReferenceMode === "upload"}
|
||
onClick={() => setCloneReferenceMode("upload")}
|
||
>
|
||
上传参考图
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={cloneReferenceMode === "link" ? "is-active" : ""}
|
||
aria-selected={cloneReferenceMode === "link"}
|
||
onClick={() => setCloneReferenceMode("link")}
|
||
>
|
||
导入链接
|
||
</button>
|
||
</div>
|
||
{cloneReferenceMode === "upload" ? (
|
||
<button type="button" className="clone-ai-replicate-upload" onClick={() => cloneReferenceInputRef.current?.click()}>
|
||
<span>
|
||
<CloudUploadOutlined />
|
||
<span className="clone-ai-replicate-upload-text">添加图片</span>
|
||
</span>
|
||
<em>{cloneReferenceImages.length ? `已选 ${cloneReferenceImages.length}/${maxCloneReferenceImages}` : `最多 ${maxCloneReferenceImages} 张`}</em>
|
||
{cloneReferenceImages.length ? (
|
||
<div className="clone-ai-replicate-preview" aria-hidden="true">
|
||
{cloneReferenceImages.slice(0, 4).map((item) => (
|
||
<figure key={item.id}>
|
||
<img src={item.src} alt="" />
|
||
<span className="uploaded-image-zoom">
|
||
<img src={item.src} alt="" />
|
||
</span>
|
||
</figure>
|
||
))}
|
||
{cloneReferenceImages.length > 4 ? <b>+{cloneReferenceImages.length - 4}</b> : null}
|
||
</div>
|
||
) : null}
|
||
</button>
|
||
) : (
|
||
<label className="clone-ai-replicate-link">
|
||
<input placeholder="粘贴商品图或详情页链接" />
|
||
</label>
|
||
)}
|
||
<input
|
||
ref={cloneReferenceInputRef}
|
||
type="file"
|
||
accept="image/jpeg,image/png,image/webp"
|
||
multiple
|
||
onChange={handleCloneReferenceUpload}
|
||
/>
|
||
</div>
|
||
<div className="clone-ai-replicate-section">
|
||
<span className="clone-ai-replicate-title">复刻程度</span>
|
||
<div className="clone-ai-replicate-levels" role="radiogroup" aria-label="复刻程度">
|
||
{cloneReplicateLevelOptions.map((option) => (
|
||
<button
|
||
key={option.key}
|
||
type="button"
|
||
className={cloneReplicateLevel === option.key ? "is-active" : ""}
|
||
aria-pressed={cloneReplicateLevel === option.key}
|
||
onClick={() => setCloneReplicateLevel(option.key)}
|
||
>
|
||
<strong>{option.title}</strong>
|
||
<span>{option.desc}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{cloneOutput === "set" ? (
|
||
<section className="clone-ai-count-panel" aria-label="套图图片数量">
|
||
<p>可自由调整各类型图片数量,总数 1-16 张</p>
|
||
<div className="clone-ai-count-list">
|
||
{cloneSetCountOptions.map((item) => {
|
||
const count = cloneSetCounts[item.key];
|
||
const decrementDisabled = count <= 0 || cloneSetTotal <= minCloneSetTotal;
|
||
const incrementDisabled = cloneSetTotal >= maxCloneSetTotal;
|
||
return (
|
||
<div key={item.key} className="clone-ai-count-row">
|
||
<div className="clone-ai-count-copy">
|
||
<strong>{item.title}</strong>
|
||
<span>{item.desc}</span>
|
||
</div>
|
||
<div className="clone-ai-count-stepper" aria-label={`${item.title}数量`}>
|
||
<button
|
||
type="button"
|
||
disabled={decrementDisabled}
|
||
onPointerDown={(event) => {
|
||
event.preventDefault();
|
||
startCloneSetCountHold(item.key, -1, decrementDisabled);
|
||
}}
|
||
onPointerUp={clearCloneSetCountHold}
|
||
onPointerLeave={clearCloneSetCountHold}
|
||
onPointerCancel={clearCloneSetCountHold}
|
||
onBlur={clearCloneSetCountHold}
|
||
aria-label={`减少${item.title}`}
|
||
>
|
||
-
|
||
</button>
|
||
<b>{count}</b>
|
||
<button
|
||
type="button"
|
||
disabled={incrementDisabled}
|
||
onPointerDown={(event) => {
|
||
event.preventDefault();
|
||
startCloneSetCountHold(item.key, 1, incrementDisabled);
|
||
}}
|
||
onPointerUp={clearCloneSetCountHold}
|
||
onPointerLeave={clearCloneSetCountHold}
|
||
onPointerCancel={clearCloneSetCountHold}
|
||
onBlur={clearCloneSetCountHold}
|
||
aria-label={`增加${item.title}`}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{cloneOutput === "detail" ? (
|
||
<section className="clone-ai-module-panel" aria-label="详情图包含模块">
|
||
<p>
|
||
包含模块(多选)
|
||
<QuestionCircleOutlined />
|
||
</p>
|
||
<div className="clone-ai-module-list">
|
||
{cloneDetailModules.map((module) => {
|
||
const isSelected = selectedCloneDetailModules.includes(module.id);
|
||
return (
|
||
<button
|
||
key={module.id}
|
||
type="button"
|
||
className={isSelected ? "is-active" : ""}
|
||
aria-pressed={isSelected}
|
||
onClick={() => toggleCloneDetailModule(module.id)}
|
||
>
|
||
<strong>{module.title}</strong>
|
||
<span>{module.desc}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{cloneOutput === "model" ? (
|
||
<section className="clone-ai-model-panel" aria-label="模特图设置">
|
||
<div className="clone-ai-model-tabs" role="tablist" aria-label="模特图设置类型">
|
||
<button
|
||
type="button"
|
||
className={cloneModelPanelTab === "scene" ? "is-active" : ""}
|
||
aria-selected={cloneModelPanelTab === "scene"}
|
||
onClick={() => setCloneModelPanelTab("scene")}
|
||
>
|
||
拍摄场景
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={cloneModelPanelTab === "model" ? "is-active" : ""}
|
||
aria-selected={cloneModelPanelTab === "model"}
|
||
onClick={() => setCloneModelPanelTab("model")}
|
||
>
|
||
模特形象
|
||
</button>
|
||
</div>
|
||
<div className="clone-ai-model-scroll">
|
||
{cloneModelPanelTab === "scene" ? (
|
||
<div className="clone-ai-model-scenes">
|
||
<div className="clone-ai-model-scene-grid">
|
||
{tryOnScenes.map((scene) => {
|
||
const isSelected = selectedCloneModelScenes.includes(scene);
|
||
return (
|
||
<button
|
||
key={scene}
|
||
type="button"
|
||
className={isSelected ? "is-active" : ""}
|
||
aria-pressed={isSelected}
|
||
onClick={() => toggleCloneModelScene(scene)}
|
||
>
|
||
<span aria-hidden="true" />
|
||
{scene}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<label className="clone-ai-model-textarea">
|
||
<strong>或自定义描述场景(可选)</strong>
|
||
<textarea
|
||
value={cloneModelCustomScene}
|
||
onChange={(event) => setCloneModelCustomScene(event.target.value)}
|
||
placeholder="描述你想要的场景:如秋季枫叶小径、暖色调午后阳光、模特倚靠树干..."
|
||
/>
|
||
</label>
|
||
</div>
|
||
) : (
|
||
<div className="clone-ai-model-profile">
|
||
<div className="clone-ai-model-select-grid">
|
||
{cloneModelSelects.map((item) => {
|
||
const isOpen = openCloneModelSelect === item.key;
|
||
return (
|
||
<div
|
||
key={item.key}
|
||
className={`clone-ai-model-select${isOpen ? " is-open" : ""}${
|
||
isOpen && cloneModelSelectDropUp ? " is-drop-up" : ""
|
||
}`}
|
||
data-clone-model-select
|
||
>
|
||
<span>{item.label}</span>
|
||
<button
|
||
type="button"
|
||
className={isOpen ? "is-open" : ""}
|
||
aria-expanded={isOpen}
|
||
aria-haspopup="listbox"
|
||
aria-controls={`clone-model-select-${item.key}`}
|
||
onClick={(event) => {
|
||
setOpenCloneBasicSelect(null);
|
||
if (!isOpen) {
|
||
event.currentTarget.scrollIntoView({ block: "center", inline: "nearest" });
|
||
const triggerRect = event.currentTarget.getBoundingClientRect();
|
||
const scrollRect = event.currentTarget.closest(".clone-ai-model-scroll")?.getBoundingClientRect();
|
||
const lowerBoundary = Math.min(window.innerHeight, scrollRect?.bottom ?? window.innerHeight);
|
||
const upperBoundary = Math.max(0, scrollRect?.top ?? 0);
|
||
const estimatedMenuHeight = Math.min(150, item.options.length * 36 + 12);
|
||
const belowSpace = lowerBoundary - triggerRect.bottom;
|
||
const aboveSpace = triggerRect.top - upperBoundary;
|
||
setCloneModelSelectDropUp(belowSpace < estimatedMenuHeight && aboveSpace > belowSpace);
|
||
} else {
|
||
setCloneModelSelectDropUp(false);
|
||
}
|
||
setOpenCloneModelSelect(isOpen ? null : item.key);
|
||
}}
|
||
>
|
||
<strong>{item.value}</strong>
|
||
<i aria-hidden="true" />
|
||
</button>
|
||
{isOpen ? (
|
||
<div id={`clone-model-select-${item.key}`} className="clone-ai-model-select__menu" role="listbox">
|
||
{item.options.map((option) => (
|
||
<button
|
||
key={option}
|
||
type="button"
|
||
className={item.value === option ? "is-active" : ""}
|
||
role="option"
|
||
aria-selected={item.value === option}
|
||
onClick={() => {
|
||
item.onChange(option);
|
||
setOpenCloneModelSelect(null);
|
||
setCloneModelSelectDropUp(false);
|
||
}}
|
||
>
|
||
{option}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<label className="clone-ai-model-textarea">
|
||
<strong>外貌细节(可选)</strong>
|
||
<textarea
|
||
value={cloneModelAppearance}
|
||
onChange={(event) => setCloneModelAppearance(event.target.value)}
|
||
placeholder="例如:小麦色皮肤、齐刘海、眼角有泪痣..."
|
||
/>
|
||
</label>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{cloneOutput === "video" ? (
|
||
<section className="clone-ai-video-panel" aria-label="短视频设置">
|
||
<div className="clone-ai-video-section">
|
||
<span className="clone-ai-video-title">视频画质</span>
|
||
<div className="clone-ai-video-options">
|
||
{cloneVideoQualityOptions.map((option) => (
|
||
<button
|
||
key={option.key}
|
||
type="button"
|
||
className={cloneVideoQuality === option.key ? "is-active" : ""}
|
||
aria-pressed={cloneVideoQuality === option.key}
|
||
onClick={() => setCloneVideoQuality(option.key)}
|
||
>
|
||
<strong>{option.label}</strong>
|
||
<span>{option.desc}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="clone-ai-video-section">
|
||
<div className="clone-ai-video-title-row">
|
||
<span className="clone-ai-video-title">时间设置</span>
|
||
<strong>{cloneVideoDuration}秒</strong>
|
||
</div>
|
||
<div className="clone-ai-duration-control" style={cloneVideoDurationStyle}>
|
||
<input
|
||
type="range"
|
||
min={cloneVideoDurationMin}
|
||
max={cloneVideoDurationMax}
|
||
step={1}
|
||
value={cloneVideoDuration}
|
||
onChange={(event) => setCloneVideoDuration(clampCloneVideoDuration(Number(event.target.value)))}
|
||
aria-label="短视频时长"
|
||
/>
|
||
<div className="clone-ai-duration-scale" aria-hidden="true">
|
||
<span>5秒</span>
|
||
<span>10秒</span>
|
||
<span>15秒</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={`clone-ai-video-smart${cloneVideoSmart ? " is-on" : ""}`}
|
||
aria-pressed={cloneVideoSmart}
|
||
onClick={() => setCloneVideoSmart((current) => !current)}
|
||
>
|
||
<span>
|
||
<strong>智能选择</strong>
|
||
<em>根据平台、商品图和尺寸自动匹配推荐参数</em>
|
||
</span>
|
||
<i aria-hidden="true" />
|
||
</button>
|
||
</section>
|
||
) : null}
|
||
|
||
<button type="button" className="clone-ai-generate" disabled={!canGenerate || cloneOutput === "video"} onClick={handleGenerate} style={cloneOutput === "video" ? { display: "none" } : undefined}>
|
||
{status === "generating" ? <LoadingOutlined /> : null}
|
||
{status === "generating" ? "生成中..." : "✦ 开始生成"}
|
||
</button>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
const detailPanel = (
|
||
<>
|
||
<div className="product-clone-panel__scroll">
|
||
<section className="product-clone-field">
|
||
<h2>
|
||
商品原图
|
||
<QuestionCircleOutlined />
|
||
</h2>
|
||
<button type="button" className="product-clone-upload-zone product-detail-upload" onClick={() => detailInputRef.current?.click()}>
|
||
<strong>
|
||
<CloudUploadOutlined />
|
||
上传图片
|
||
</strong>
|
||
<span>同一产品,最多3张。</span>
|
||
</button>
|
||
<input ref={detailInputRef} type="file" accept="image/*" multiple onChange={handleDetailUpload} />
|
||
{detailProductImages.length ? (
|
||
<div className="product-clone-thumb-row" aria-label="已上传商品原图">
|
||
{detailProductImages.map((item) => (
|
||
<figure key={item.id} className="product-clone-uploaded-thumb">
|
||
<img src={item.src} alt={item.name} />
|
||
<span className="uploaded-image-zoom" aria-hidden="true">
|
||
<img src={item.src} alt="" />
|
||
</span>
|
||
</figure>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
|
||
<section className="product-clone-field">
|
||
<h2>生成设置</h2>
|
||
<div className="product-detail-settings-grid">
|
||
<select value={detailPlatform} onChange={(event) => handleDetailPlatformChange(event.target.value)}>
|
||
{platformOptions.map((item) => (
|
||
<option key={item}>{item}</option>
|
||
))}
|
||
</select>
|
||
<select value={detailMarket} onChange={(event) => handleDetailMarketChange(event.target.value)}>
|
||
{marketOptions.map((item) => (
|
||
<option key={item}>{item}</option>
|
||
))}
|
||
</select>
|
||
<select value={detailLanguage} onChange={(event) => setDetailLanguage(event.target.value)}>
|
||
{detailLanguageOptions.map((item) => (
|
||
<option key={item}>{item}</option>
|
||
))}
|
||
</select>
|
||
<select value={detailType} onChange={(event) => setDetailType(event.target.value)}>
|
||
{detailTypeOptions.map((item) => (
|
||
<option key={item}>{item}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="product-clone-field product-detail-requirement">
|
||
<h2>
|
||
商品卖点&要求
|
||
<QuestionCircleOutlined />
|
||
<button
|
||
type="button"
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
handleDetailAiWrite();
|
||
}}
|
||
>
|
||
AI 帮写
|
||
</button>
|
||
</h2>
|
||
<textarea
|
||
value={detailRequirement}
|
||
onChange={(event) => setDetailRequirement(event.target.value)}
|
||
placeholder={"建议包含以下信息生成更精准:\n1.产品名称\n2.核心卖点\n3.适用人群\n4.期望场景\n5.具体参数"}
|
||
/>
|
||
</section>
|
||
|
||
<section className="product-clone-field">
|
||
<h2>
|
||
包含模块(多选)
|
||
<QuestionCircleOutlined />
|
||
</h2>
|
||
<div className="product-detail-module-grid">
|
||
{detailModules.map((module) => (
|
||
<button
|
||
key={module.id}
|
||
type="button"
|
||
className={selectedDetailModules.includes(module.id) ? "is-active" : ""}
|
||
onClick={() => toggleDetailModule(module.id)}
|
||
>
|
||
<strong>{module.title}</strong>
|
||
<span>{module.desc}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<footer className="product-clone-panel__footer">
|
||
<button type="button" className="product-clone-primary" disabled={!canGenerateDetail} onClick={handleDetailGenerate}>
|
||
{detailStatus === "generating" ? <LoadingOutlined /> : null}
|
||
{detailPrimaryLabel}
|
||
</button>
|
||
</footer>
|
||
</>
|
||
);
|
||
|
||
const tryOnPanel = (
|
||
<>
|
||
<div className="product-clone-panel__scroll">
|
||
<section className="product-clone-field">
|
||
<h2>服装图片</h2>
|
||
<button type="button" className="product-clone-upload-zone product-try-on-upload" onClick={() => garmentInputRef.current?.click()}>
|
||
<strong>
|
||
<CloudUploadOutlined />
|
||
服装图片
|
||
</strong>
|
||
<span>整套搭配或同一件服装不同角度图,最多5张。</span>
|
||
</button>
|
||
<input ref={garmentInputRef} type="file" accept="image/*" multiple onChange={handleGarmentUpload} />
|
||
{garmentImages.length ? (
|
||
<div className="product-clone-thumb-row product-try-on-thumb-row" aria-label="已上传服装图片">
|
||
{garmentImages.map((item) => (
|
||
<figure key={item.id} className="product-clone-uploaded-thumb">
|
||
<img src={item.src} alt={item.name} />
|
||
<span className="uploaded-image-zoom" aria-hidden="true">
|
||
<img src={item.src} alt="" />
|
||
</span>
|
||
</figure>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
|
||
<section className="product-clone-field">
|
||
<h2>模特形象</h2>
|
||
<div className="product-clone-segment" role="tablist" aria-label="模特来源">
|
||
<button type="button" className={modelSource === "ai" ? "is-active" : ""} onClick={() => setModelSource("ai")}>
|
||
AI 生成
|
||
</button>
|
||
<button type="button" className={modelSource === "library" ? "is-active" : ""} onClick={() => setModelSource("library")}>
|
||
模特库
|
||
<QuestionCircleOutlined />
|
||
</button>
|
||
</div>
|
||
{modelSource === "ai" ? (
|
||
<>
|
||
<div className="product-clone-model-grid">
|
||
<select value={modelGender} onChange={(event) => setModelGender(event.target.value)}>
|
||
{tryOnModelOptions.gender.map((item) => (
|
||
<option key={item}>{item}</option>
|
||
))}
|
||
</select>
|
||
<select value={modelAge} onChange={(event) => setModelAge(event.target.value)}>
|
||
{tryOnModelOptions.age.map((item) => (
|
||
<option key={item}>{item}</option>
|
||
))}
|
||
</select>
|
||
<select value={modelEthnicity} onChange={(event) => setModelEthnicity(event.target.value)}>
|
||
{tryOnModelOptions.ethnicity.map((item) => (
|
||
<option key={item}>{item}</option>
|
||
))}
|
||
</select>
|
||
<select value={modelBody} onChange={(event) => setModelBody(event.target.value)}>
|
||
{tryOnModelOptions.body.map((item) => (
|
||
<option key={item}>{item}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<label className="product-try-on-textarea-label">
|
||
<span>外貌细节(可选)</span>
|
||
<textarea
|
||
value={appearance}
|
||
onChange={(event) => setAppearance(event.target.value)}
|
||
placeholder="例如:小麦色皮肤、齐刘海、眼角有泪痣..."
|
||
/>
|
||
</label>
|
||
<button type="button" className="product-clone-model-button" onClick={handleGenerateModel} disabled={tryOnStatus === "modeling"}>
|
||
{tryOnStatus === "modeling" ? <LoadingOutlined /> : null}
|
||
{tryOnStatus === "modeling" ? "生成中..." : "生成基准模特"}
|
||
</button>
|
||
</>
|
||
) : (
|
||
<div className="product-try-on-library" aria-label="模特库">
|
||
{[tryOnAssets.modelWoman, tryOnAssets.modelMan, tryOnAssets.modelAsian].map((src, index) => (
|
||
<button key={src} type="button" className={index === 0 ? "is-active" : ""}>
|
||
<img src={src} alt={`模特 ${index + 1}`} />
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className="product-clone-field">
|
||
<h2>拍摄场景</h2>
|
||
<div className="product-clone-scene-grid">
|
||
{tryOnScenes.map((scene) => (
|
||
<button
|
||
key={scene}
|
||
type="button"
|
||
className={selectedScenes.includes(scene) ? "is-active" : ""}
|
||
onClick={() => toggleScene(scene)}
|
||
>
|
||
<span aria-hidden="true" />
|
||
{scene}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<label className="product-clone-field product-try-on-scene-field">
|
||
<h2>或自定义描述场景(可选)</h2>
|
||
<textarea
|
||
value={customScene}
|
||
onChange={(event) => setCustomScene(event.target.value)}
|
||
placeholder="描述你想要的场景:如秋季枫叶小径、暖色调午后阳光、模特倚靠树干..."
|
||
/>
|
||
</label>
|
||
|
||
<section className="product-clone-field">
|
||
<button type="button" className="product-clone-switch-row" onClick={() => setSmartScene((current) => !current)}>
|
||
<span>
|
||
<strong>智能推荐场景</strong>
|
||
<em>根据服装自动匹配最佳场景</em>
|
||
</span>
|
||
<span className={`product-clone-switch${smartScene ? " is-on" : ""}`} role="switch" aria-checked={smartScene}>
|
||
<span />
|
||
</span>
|
||
</button>
|
||
</section>
|
||
|
||
<section className="product-clone-field">
|
||
<h2>图片比例</h2>
|
||
<div className="product-clone-ratio-row">
|
||
{tryOnRatioOptions.map((item) => (
|
||
<button key={item} type="button" className={tryOnRatio === item ? "is-active" : ""} onClick={() => setTryOnRatio(item)}>
|
||
{item}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<footer className="product-clone-panel__footer">
|
||
<button type="button" className="product-clone-primary" disabled={!canGenerateTryOn} onClick={handleTryOnGenerate}>
|
||
{tryOnStatus === "generating" ? <LoadingOutlined /> : null}
|
||
{tryOnPrimaryLabel}
|
||
</button>
|
||
</footer>
|
||
</>
|
||
);
|
||
|
||
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(productSetPreviewCards[0])}
|
||
>
|
||
<img src={productSetPreviewCards[0].src} alt="01 主图" />
|
||
<span>{productSetPreviewCards[0].label}</span>
|
||
</button>
|
||
<div className="product-set-flow-arrow" aria-hidden="true" />
|
||
<div className="product-set-card-grid">
|
||
{productSetPreviewCards.slice(1).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>
|
||
<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(clonePreviewCards[0])}>
|
||
<img src={productImages[0]?.src ?? clonePreviewCards[0].src} alt="上传商品原图" />
|
||
<span>原图素材</span>
|
||
</button>
|
||
<div className="clone-ai-flow-arrow" aria-hidden="true" />
|
||
<div className="clone-ai-result-grid">
|
||
{clonePreviewCards.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="clone-ai-empty-state" aria-live="polite">
|
||
{status === "generating" ? <LoadingOutlined /> : <FileImageOutlined />}
|
||
<strong>{status === "generating" ? "正在生成" : "等待生成"}</strong>
|
||
<span>
|
||
{status === "generating"
|
||
? `AI 正在为 ${platform} / ${market} 整理${selectedCloneOutput.label}。`
|
||
: "上传商品原图并填写信息后,AI 将在这里展示生成结果。"}
|
||
</span>
|
||
</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={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"
|
||
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("9:16") || ratio.includes("9:16") ? "9:16" : ratio.includes("16:9") || ratio.includes("16:9") ? "16:9" : ratio.includes("3:4") || ratio.includes("3:4") ? "3:4" : "9:16"}
|
||
durationSeconds={cloneVideoDuration}
|
||
resolution={cloneVideoQuality === "standard" ? "720P" : "1080P"}
|
||
onRequestLogin={() => ((_props as Record<string, (() => void) | undefined>).onRequireLogin?.())}
|
||
/>
|
||
</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;
|