Files
omniai-ds-code-package/src/features/ecommerce/EcommercePage.tsx
T
ludan 5811cbac16 feat: refactor ecommerce toolbar from mode tabs to scenario-based tabs with rich template cards
- EcommercePage.tsx:
  - Introduce CommerceScenarioKey type (popular/poster/mainImage/scene/festival/model/background/retouch/salesVideo) and CommerceScenarioTemplate interface with scenario/output/desc/badge fields
  - Add commerceScenarioOptions (9 scenario tabs with icons) replacing cloneOutputOptions as toolbar data source; each scenario maps to an output mode via commerceScenarioOutputMap
  - Add commerceScenarioTemplates (16 templates across 8 scenarios) with thumbnail, badge, title, and description; popularCommerceScenarioTemplates aggregates cross-scenario highlights for the "热门" default tab
  - Replace activeCloneTemplateCards with activeCommerceScenarioTemplates filtered by scenario; popular tab shows highlights, others show scenario-specific templates
  - handleCommerceScenarioClick: switch scenario, auto-toggle output mode, toggle template strip visibility; clicking active scenario toggles strip; popular tab preserves current output
  - handleCloneTemplateCardClick: auto-switch output mode to match template, fill prompt, focus textarea with 80ms delayed re-focus for reliability
  - Template card markup upgraded: media thumbnail (94px cover image with hover scale), body with badge pill, title, and 2-line description
  - Active scenario button shows close indicator (CloseOutlined) when strip is open
  - Template strip defaults to visible (isCloneTemplateStripVisible initial true)
  - Add "左右滑动查看更多" scroll hint for narrow viewports
- ecommerce-standalone.css (+355 lines):
  - Scenario tabs: horizontal flex scroll with hidden scrollbar, pill-shaped buttons (grid: 24px icon + fluid label), per-scenario color accent via --mode-accent custom property (pink for popular, orange for poster/festival, green for mainImage/scene/background, blue for model/retouch/salesVideo)
  - Active/open states: radial gradient glow, lifted shadow, intensified border color
  - Icon slot: 24px rounded square with tinted background and inset highlight
  - Close indicator: absolute top-right circle with hover scale
  - Template cards: 2-column grid (94px media + fluid body), badge capsule, thumbnail with hover scale(1.035), title 820 weight, 2-line description clamp
  - Responsive: ≤1024px 2-col card grid, ≤900px scroll-hint visible + left-aligned tabs, ≤640px horizontal scroll cards with snap, compact card sizing
2026-06-16 19:11:50 +08:00

8173 lines
350 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AppstoreOutlined,
ClearOutlined,
CloudUploadOutlined,
CloseOutlined,
DeleteOutlined,
DownloadOutlined,
EditOutlined,
FireOutlined,
FileImageOutlined,
FolderOpenOutlined,
FrownOutlined,
GlobalOutlined,
LayoutOutlined,
LoadingOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
PaperClipOutlined,
PlusOutlined,
QuestionCircleOutlined,
ReloadOutlined,
ScissorOutlined,
SettingOutlined,
SkinOutlined,
TableOutlined,
VideoCameraOutlined,
} from "@ant-design/icons";
import { Fragment, useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTypewriter } from "../../hooks/useTypewriter";
import "../../styles/pages/ecommerce.css";
import "../../styles/pages/local-theme-parity.css";
import { ossAssets } from "../../data/ossAssets";
import { EcommerceProgressBar } from "./EcommerceProgressBar";
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu";
import EcommerceVideoWorkspace from "./EcommerceVideoWorkspace";
import EcommerceVideoHistoryPanel from "./panels/EcommerceVideoHistoryPanel";
import EcommerceDetailPanel from "./panels/EcommerceDetailPanel";
import EcommerceSetPanel from "./panels/EcommerceSetPanel";
import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel";
import EcommerceClonePanel from "./panels/EcommerceClonePanel";
import { ecommerceOssScopes, saveUnifiedEcommerceGenerationRecord, deleteEcommerceGenerationRecord } from "./ecommerceGenerationPersistence";
import { downloadResultAsset } from "../workbench/workbenchDownload";
const smartCutoutColorPresets = [
"#ffffff",
"#111111",
"#ff3131",
"#ff7a1a",
"#f7c600",
"#29b34a",
"#25a9e0",
"#438df5",
"#9029d9",
"#8aa3ad",
"#6b7b86",
"#f46f7b",
"#ff9451",
"#f7d34f",
"#55c66f",
"#73c7f3",
"#6dabf5",
"#b45adb",
"#bcc8ce",
"#aeb7bd",
"#ffbec4",
"#ffd1ac",
"#f8e69d",
"#91de9e",
"#b7e5fb",
"#b9d9fb",
"#d7abe8",
"#dfe5e8",
"#d7dde0",
"#ffe2e4",
"#ffe5d1",
"#f8efcf",
"#c9efcf",
"#d8f0fb",
"#d8eafa",
"#ead2f1",
];
const smartCutoutSizeOptions = [
{ key: "original", label: "原尺寸", icon: "image", frameWidth: "min(520px, 78%)", frameAspect: "auto", imageMaxWidth: "78%", imageMaxHeight: "310px" },
{ key: "trim", label: "裁剪到边缘", icon: "crop", frameWidth: "min(420px, 70%)", frameAspect: "auto", imageMaxWidth: "92%", imageMaxHeight: "360px" },
{ key: "one-inch", label: "一寸头像", sizeLabel: "295*413", icon: "portrait", frameWidth: "min(290px, 50%)", frameAspect: "295 / 413", imageMaxWidth: "86%", imageMaxHeight: "86%", outputWidth: 295, outputHeight: 413 },
{ key: "two-inch", label: "二寸头像", sizeLabel: "413*579", icon: "portrait", frameWidth: "min(320px, 54%)", frameAspect: "413 / 579", imageMaxWidth: "86%", imageMaxHeight: "86%", outputWidth: 413, outputHeight: 579 },
{ key: "taobao-1-1", label: "淘宝1:1主图", sizeLabel: "800*800", icon: "shop", frameWidth: "min(430px, 72%)", frameAspect: "800 / 800", imageMaxWidth: "82%", imageMaxHeight: "82%", outputWidth: 800, outputHeight: 800 },
{ key: "taobao-3-4", label: "淘宝3:4主图", sizeLabel: "750*1000", icon: "shop", frameWidth: "min(330px, 56%)", frameAspect: "750 / 1000", imageMaxWidth: "82%", imageMaxHeight: "82%", outputWidth: 750, outputHeight: 1000 },
{ key: "pdd-main", label: "拼多多主图", sizeLabel: "800*800", icon: "pdd", frameWidth: "min(430px, 72%)", frameAspect: "800 / 800", imageMaxWidth: "82%", imageMaxHeight: "82%", outputWidth: 800, outputHeight: 800 },
{ key: "xiaohongshu-cover", label: "小红书封面", sizeLabel: "1242*1660", icon: "text", frameWidth: "min(330px, 56%)", frameAspect: "1242 / 1660", imageMaxWidth: "82%", imageMaxHeight: "82%", outputWidth: 1242, outputHeight: 1660 },
{ key: "ratio-1-1", label: "1:1", icon: "square", frameWidth: "min(430px, 72%)", frameAspect: "1 / 1", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "ratio-3-2", label: "3:2", icon: "landscape", frameWidth: "min(520px, 78%)", frameAspect: "3 / 2", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "ratio-2-3", label: "2:3", icon: "portrait-ratio", frameWidth: "min(330px, 56%)", frameAspect: "2 / 3", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "ratio-4-3", label: "4:3", icon: "landscape", frameWidth: "min(520px, 78%)", frameAspect: "4 / 3", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "ratio-3-4", label: "3:4", icon: "portrait-ratio", frameWidth: "min(330px, 56%)", frameAspect: "3 / 4", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "ratio-16-9", label: "16:9", icon: "wide", frameWidth: "min(560px, 82%)", frameAspect: "16 / 9", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "ratio-9-16", label: "9:16", icon: "phone", frameWidth: "min(260px, 46%)", frameAspect: "9 / 16", imageMaxWidth: "82%", imageMaxHeight: "82%" },
] as const;
type SmartCutoutSizeKey = (typeof smartCutoutSizeOptions)[number]["key"];
type SmartCutoutImageItem = { src: string; name: string; originalSrc?: string };
const ecommerceInspirationTabs = ["最近打开", "一键同款", "海报模板", "热门", "商品图", "模特穿戴"];
const ecommerceInspirationAssets = ossAssets.ecommerce.inspiration;
const ecommerceInspirationRows = [
{
title: "作品记录",
desc: "沉淀最近生成的高转化素材,随时回看与复用。",
variant: "team",
cards: [
{ title: "指定ASIN,优化listing", meta: "竞品拆解 · 卖点重排 · 图文建议", mediaUrl: ecommerceInspirationAssets.asinListing, mediaType: "image" },
{ title: "TikTok美区爆品分析", meta: "脚本方向 · 人群洞察 · 素材策略", mediaUrl: ecommerceInspirationAssets.tiktokPreference, mediaType: "image" },
{ title: "竞品分析 + 全套listing", meta: "关键词 · 主图结构 · 转化建议", mediaUrl: ecommerceInspirationAssets.competitorListing, mediaType: "image" },
{ title: "世界杯属性快闪视频", meta: "热点追踪 · 模板复用 · 15秒短片", mediaUrl: ecommerceInspirationAssets.worldCupFlashVideo, mediaType: "video" },
],
},
{
title: "电商套图",
desc: "主图 / 详情图全套一次性生成。",
variant: "listing",
cards: [
{ title: "科技礼盒主图", meta: "高反差质感 · 参数卖点", mediaUrl: ecommerceInspirationAssets.officeStyleSet, mediaType: "image" },
{ title: "美妆节日套图", meta: "促销氛围 · 多规格展示", mediaUrl: ecommerceInspirationAssets.fathersDaySet, mediaType: "image" },
{ title: "防晒产品场景", meta: "户外光感 · 功效表达", mediaUrl: ecommerceInspirationAssets.sprayScene, mediaType: "image" },
{ title: "露营家具详情", meta: "场景组合 · 尺寸说明", mediaUrl: ecommerceInspirationAssets.campingCart, mediaType: "image" },
{ title: "香氛A+页面", meta: "材质细节 · 品牌氛围", mediaUrl: ecommerceInspirationAssets.perfumeSet, mediaType: "image" },
{ title: "童装listing组合", meta: "多角度 · 人群展示", mediaUrl: ecommerceInspirationAssets.cosmeticApplication, mediaType: "image" },
{ title: "高考文具淘宝套图", meta: "文具套装 · 淘宝主图 · 卖点陈列", mediaUrl: ecommerceInspirationAssets.stationeryTaobaoSet, mediaType: "image" },
{ title: "条纹单人沙发套图", meta: "家居场景 · 多角度展示 · 软装质感", mediaUrl: ecommerceInspirationAssets.stripedSingleSofaSet, mediaType: "image" },
{ title: "棕色皮夹克照片集", meta: "服饰套图 · 质感细节 · 穿搭展示", mediaUrl: ecommerceInspirationAssets.brownLeatherJacketPhotoSet, mediaType: "image" },
{ title: "防晒帽模特佩戴", meta: "真人试戴 · 户外防晒 · 穿戴效果", mediaUrl: ecommerceInspirationAssets.modelSunHatTryon, mediaType: "image" },
{ title: "淘宝耳机商品图", meta: "数码主图 · 参数卖点 · 平台套图", mediaUrl: ecommerceInspirationAssets.taobaoEarphoneProduct, mediaType: "image" },
{ title: "Etsy香薰蜡烛套图", meta: "香氛氛围 · 手作质感 · 跨境陈列", mediaUrl: ecommerceInspirationAssets.etsyScentedCandleSet, mediaType: "image" },
],
},
{
title: "商品视频",
desc: "口播模拟 / 商品展示视频 / 社媒短片。",
variant: "video",
cards: [
{ title: "口播种草短片", meta: "手持展示 · 真实推荐", mediaUrl: ecommerceInspirationAssets.spokenReview, mediaType: "video" },
{ title: "香水质感视频", meta: "光影旋转 · 高级静物", mediaUrl: ecommerceInspirationAssets.perfumeTexture, mediaType: "video" },
{ title: "玩具互动短视频", meta: "生活场景 · 情绪表达", mediaUrl: ecommerceInspirationAssets.toyInteraction, mediaType: "video" },
{ title: "器皿产品展示", meta: "极简背景 · 材质突出", mediaUrl: ecommerceInspirationAssets.vesselDisplay, mediaType: "video" },
{ title: "饰品模特试戴", meta: "近景特写 · 搭配建议", mediaUrl: ecommerceInspirationAssets.jewelryModel, mediaType: "video" },
{ title: "包袋生活方式", meta: "室内场景 · 组合展示", mediaUrl: ecommerceInspirationAssets.sofaLifestyle, mediaType: "video" },
{ title: "口红TikTok带货", meta: "UGC口播 · 真实推荐 · 社媒转化", mediaUrl: ecommerceInspirationAssets.lipstickUgcTiktokVideo, mediaType: "video" },
{ title: "小夜灯抖音开箱", meta: "开箱种草 · 暖光氛围 · 竖版短片", mediaUrl: ecommerceInspirationAssets.nightLightUnboxingDouyin, mediaType: "video" },
{ title: "清洁剂痛点解决", meta: "问题演示 · 功效对比 · 抖音素材", mediaUrl: ecommerceInspirationAssets.cleanerPainpointDouyin, mediaType: "video" },
{ title: "连衣裙穿搭视频", meta: "服饰上身 · 场景走动 · 穿搭展示", mediaUrl: ecommerceInspirationAssets.dressOutfitVideo, mediaType: "video" },
{ title: "防晒霜TikTok种草", meta: "UGC测评 · 户外防晒 · 平台短片", mediaUrl: ecommerceInspirationAssets.sunscreenUgcTiktokVideo, mediaType: "video" },
{ title: "世界杯属性快闪", meta: "热点短片 · 节奏快闪 · 活动素材", mediaUrl: ecommerceInspirationAssets.worldCupFlashVideo, mediaType: "video" },
],
},
] as const;
// 把灵感卡片的标题 + 卖点要点合成一段可直接填入指令栏的提示词。
const buildInspirationPrompt = (title: string, meta: string): string => {
const points = meta
.split(/[·、,]/)
.map((part) => part.trim())
.filter(Boolean);
const base = title.trim();
return points.length ? `${base}。风格要点:${points.join("、")}` : `${base}`;
};
const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const normalizeHexColor = (value: string) => {
const clean = value.trim().replace(/^#/, "");
if (!/^[0-9a-fA-F]{6}$/.test(clean)) return null;
return `#${clean.toLowerCase()}`;
};
const hexToRgb = (value: string) => {
const normalized = normalizeHexColor(value);
if (!normalized) return null;
const numeric = Number.parseInt(normalized.slice(1), 16);
return {
r: (numeric >> 16) & 255,
g: (numeric >> 8) & 255,
b: numeric & 255,
};
};
const rgbToHex = (r: number, g: number, b: number) =>
`#${[r, g, b].map((item) => clampNumber(Math.round(item), 0, 255).toString(16).padStart(2, "0")).join("")}`;
const parseSmartCutoutAspect = (aspect: string) => {
const match = aspect.match(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/);
if (!match) return null;
const width = Number(match[1]);
const height = Number(match[2]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null;
return width / height;
};
const parseSmartCutoutPercent = (value: string, fallback: number) => {
const numeric = Number(value.replace("%", ""));
if (!Number.isFinite(numeric)) return fallback;
return clampNumber(numeric / 100, 0.05, 1);
};
const hsvToRgb = (h: number, s: number, v: number) => {
const hue = ((h % 360) + 360) % 360;
const saturation = clampNumber(s, 0, 100) / 100;
const value = clampNumber(v, 0, 100) / 100;
const chroma = value * saturation;
const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
const match = value - chroma;
const [red, green, blue] =
hue < 60
? [chroma, x, 0]
: hue < 120
? [x, chroma, 0]
: hue < 180
? [0, chroma, x]
: hue < 240
? [0, x, chroma]
: hue < 300
? [x, 0, chroma]
: [chroma, 0, x];
return {
r: (red + match) * 255,
g: (green + match) * 255,
b: (blue + match) * 255,
};
};
const hexToHsv = (value: string) => {
const rgb = hexToRgb(value) ?? { r: 255, g: 255, b: 255 };
const red = rgb.r / 255;
const green = rgb.g / 255;
const blue = rgb.b / 255;
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
const delta = max - min;
const hue =
delta === 0
? 0
: max === red
? 60 * (((green - blue) / delta) % 6)
: max === green
? 60 * ((blue - red) / delta + 2)
: 60 * ((red - green) / delta + 4);
return {
h: Math.round((hue + 360) % 360),
s: max === 0 ? 0 : Math.round((delta / max) * 100),
v: Math.round(max * 100),
};
};
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { ServerRequestError } from "../../api/serverConnection";
import { waitForTask } from "../../api/taskSubscription";
import { toast } from "../../components/toast/toastStore";
import { useGenerationTasks } from "../../hooks/useGenerationTasks";
import { useAppStore } from "../../stores";
import {
normalizeEcommerceImageMime,
summarizeRejectedImages,
validateEcommerceImageFiles,
} from "./ecommerceImageValidation";
interface ProductClonePageProps {
[key: string]: unknown;
}
type ProductCloneStatus = "idle" | "ready" | "generating" | "done" | "failed";
type ProductSetOutputKey = "set" | "detail" | "model" | "video";
type CloneOutputKey = ProductSetOutputKey | "hot";
type CommerceScenarioKey = "popular" | "poster" | "mainImage" | "scene" | "festival" | "model" | "background" | "retouch" | "salesVideo";
type CloneSetCountKey = "selling" | "white" | "scene";
type CloneModelPanelTab = "scene" | "model";
type CloneVideoQualityKey = "standard" | "high" | "ultra";
type ProductSetStatus = "idle" | "ready" | "generating" | "done" | "failed";
type ProductKitToolKey = "set" | "detail" | "wear" | "clone";
type ComposerMenuKey = "mode" | "platform" | "language" | "ratio" | "settings";
type CloneBasicSelectKey = "platform" | "market" | "language" | "ratio";
type CloneModelSelectKey = "gender" | "age" | "ethnicity" | "body";
type CloneReferenceMode = "upload" | "link";
type CloneReplicateLevelKey = "style" | "high";
type CloneTemplateAsset = {
id: string;
title: string;
prompt: string;
mediaUrl: string;
};
interface CommerceScenarioTemplate extends CloneTemplateAsset {
scenario: Exclude<CommerceScenarioKey, "popular">;
output: ProductSetOutputKey;
desc: string;
badge: string;
}
type TryOnModelSource = "ai" | "library";
type TryOnStatus = "idle" | "modeling" | "ready" | "generating" | "done" | "failed";
type DetailStatus = "idle" | "ready" | "generating" | "done" | "failed";
interface CloneImageItem {
id: string;
src: string;
name: string;
file?: File;
width?: number;
height?: number;
format?: string;
mimeType?: string;
ossKey?: string;
}
interface CloneResult {
id: string;
src: string;
label: string;
type?: "image" | "video";
}
interface CanvasNode {
id: string;
mode: string;
sourceImage?: string;
results: CloneResult[];
createdAt: number;
x: number;
y: number;
}
interface PreviewTouchPoint {
id: number;
x: number;
y: number;
}
interface PreviewTouchGesture {
mode: "none" | "pan" | "pinch";
points: PreviewTouchPoint[];
startOffset: { x: number; y: number };
startZoom: number;
startDistance: number;
startCenter: { x: number; y: number };
}
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 EcommerceHistoryStatus = "generating" | "done" | "failed";
interface EcommerceHistoryTurn {
id: string;
createdAt: number;
status: EcommerceHistoryStatus;
errorMessage?: string;
output: CloneOutputKey;
platform: string;
market: string;
language: string;
ratio: string;
requirement: string;
productImages: CloneImageItem[];
results: CloneResult[];
setResultImages: string[];
setCounts: Record<CloneSetCountKey, number>;
detailModules: string[];
modelScenes: string[];
referenceImages: CloneImageItem[];
replicateLevel: CloneReplicateLevelKey;
}
interface EcommerceHistoryRecord {
id: string;
title: string;
createdAt: number;
status?: EcommerceHistoryStatus;
errorMessage?: string;
output: CloneOutputKey;
platform: string;
market: string;
language: string;
ratio: string;
requirement: string;
productImages: CloneImageItem[];
results: CloneResult[];
setResultImages: string[];
setCounts: Record<CloneSetCountKey, number>;
detailModules: string[];
modelScenes: string[];
referenceImages: CloneImageItem[];
replicateLevel: CloneReplicateLevelKey;
turns?: EcommerceHistoryTurn[];
}
interface ProductSetPreviewSelection {
src: string;
label: string;
nodeId?: string;
cardId?: string;
removable?: boolean;
}
interface EcommerceImagePromptOptions {
gender?: string;
age?: string;
ethnicity?: string;
body?: string;
appearance?: string;
scenes?: string[];
customScene?: string;
smartScene?: boolean;
detailModules?: string[];
}
type PlatformRatioModeKey = ProductSetOutputKey | "hot";
interface PlatformRatioGroup {
ratios: string[];
defaultRatio: string;
}
const sideTools: Array<{ key: ProductKitToolKey; label: string; icon: ReactNode }> = [
{ key: "set", label: "商品套图", icon: <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", "首图主体占比 ≥80%"],
defaultRatio: "京东主图 / SKU 图 800×800px",
ratioGroups: {
set: {
ratios: ["1000×1000px\u00a0\u00a0\u00a01:1"],
defaultRatio: "1000×1000px\u00a0\u00a0\u00a01:1",
},
detail: {
ratios: [
"750×1000px\u00a0\u00a0\u00a03:4",
"990×1320px\u00a0\u00a0\u00a03:4",
"750×1125px\u00a0\u00a0\u00a02:3",
"990×1485px\u00a0\u00a0\u00a02:3",
],
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
},
model: {
ratios: ["750×1125px\u00a0\u00a0\u00a02:3", "990×1485px\u00a0\u00a0\u00a02:3"],
defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3",
},
video: {
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1920×1080px\u00a0\u00a0\u00a016:9"],
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
},
hot: {
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
},
},
specs: ["主图 / SKU 图 800×800px,白底,≤3MB", "详情页宽 750px,首图主体占比 ≥80%"],
},
{
label: "拼多多",
ratios: ["主图 750×352px", "主图 800×800px", "详情页宽 750px"],
defaultRatio: "主图 750×352px",
ratioGroups: {
set: {
ratios: ["800×800px\u00a0\u00a0\u00a01:1", "750×1000px\u00a0\u00a0\u00a03:4"],
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
},
detail: {
ratios: ["750×1000px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3"],
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
},
model: {
ratios: ["750×1000px\u00a0\u00a0\u00a03:4"],
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
},
video: {
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"],
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
},
hot: {
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
},
},
specs: ["主图 750×352px 或 800×800px,≤1MB", "详情页宽 750px,要求纯白底、无水印、无拼接"],
},
{
label: "抖音电商",
ratios: ["短视频1080×1920px"],
defaultRatio: "短视频1080×1920px",
ratioGroups: {
video: {
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"],
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
},
hot: {
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
},
},
specs: ["短视频 1080×1920px9: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×800px1:1"],
},
{
label: "Instagram",
ratios: ["帖子 1080×1350px", "帖子 1080×1080px", "Stories / Reels 1080×1920px", "头像 320×320px"],
defaultRatio: "帖子 1080×1350px",
ratioGroups: {
set: {
ratios: ["1080×1080px\u00a0\u00a0\u00a01:1", "1080×1350px\u00a0\u00a0\u00a04:5"],
defaultRatio: "1080×1080px\u00a0\u00a0\u00a01:1",
},
detail: {
ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"],
defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5",
},
model: {
ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"],
defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5",
},
video: {
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1080×1350px\u00a0\u00a0\u00a04:5"],
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
},
},
specs: ["帖子 1080×1350px 或 1080×1080px", "Stories / Reels 封面 1080×1920px,头像 320×320px"],
tip: "建议 ≤8MB JPG。",
aliases: ["Instagram Reels"],
},
{
label: "速卖通",
ratios: ["主图 800×800px", "主图 1000×1000px+"],
defaultRatio: "主图 800×800px",
ratioGroups: {
set: {
ratios: ["1000×1000px\u00a0\u00a0\u00a01:1"],
defaultRatio: "1000×1000px\u00a0\u00a0\u00a01:1",
},
detail: {
ratios: ["750×1125px\u00a0\u00a0\u00a02:3", "750×1000px\u00a0\u00a0\u00a03:4"],
defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3",
},
model: {
ratios: ["750×1125px\u00a0\u00a0\u00a02:3"],
defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3",
},
video: {
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1920×1080px\u00a0\u00a0\u00a016:9"],
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
},
hot: {
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
},
},
specs: ["主图建议 800×800px 或更高,1:1", "适合跨境电商主图、SKU 图和场景图"],
},
{
label: "eBay",
ratios: ["商品图1:1", "白底多角度展示图 1:1"],
defaultRatio: "商品图1:1",
ratioGroups: {
set: {
ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"],
defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1",
},
detail: {
ratios: ["1000×1500px\u00a0\u00a0\u00a02:3", "1200×1600px\u00a0\u00a0\u00a03:4"],
defaultRatio: "1000×1500px\u00a0\u00a0\u00a02:3",
},
model: {
ratios: ["1000×1500px\u00a0\u00a0\u00a02:3"],
defaultRatio: "1000×1500px\u00a0\u00a0\u00a02:3",
},
video: {
ratios: ["1920×1080px\u00a0\u00a0\u00a016:9", "1080×1920px\u00a0\u00a0\u00a09:16"],
defaultRatio: "1920×1080px\u00a0\u00a0\u00a016:9",
},
hot: {
ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"],
defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1",
},
},
specs: ["商品图建议 1:1,主体清晰居中", "适合白底主图和多角度展示图"],
},
{
label: "TikTok Shop",
ratios: ["商品主图 1:1", "短视频/ 竖版封面 9:16"],
defaultRatio: "商品主图 1:1",
ratioGroups: {
set: {
ratios: ["1280×1280px\u00a0\u00a0\u00a01:1"],
defaultRatio: "1280×1280px\u00a0\u00a0\u00a01:1",
},
detail: {
ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"],
defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5",
},
model: {
ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"],
defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5",
},
video: {
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"],
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
},
hot: {
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
},
},
specs: ["商品主图建议 1:1", "短视频竖版封面建议 9:16"],
},
];
const platformOptions = platformSpecOptions.map((option) => option.label);
const getPlatformLogoText = (value: string) => {
const normalized = value.toLowerCase();
if (value.includes("淘宝") || value.includes("天猫")) return "淘";
if (value.includes("京东")) return "京";
if (value.includes("拼多多") || value.includes("鎷煎澶")) return "拼";
if (value.includes("抖音")) return "抖";
if (normalized.includes("amazon")) return "a";
if (normalized.includes("shopee")) return "S";
if (normalized.includes("lazada")) return "L";
if (normalized.includes("instagram")) return "IG";
if (value.includes("速卖通") || value.includes("閫熷崠閫")) return "AE";
if (normalized.includes("ebay")) return "eB";
if (normalized.includes("tiktok")) return "♪";
return value.trim().slice(0, 1).toUpperCase() || "商";
};
const getPlatformLogoVariant = (value: string) => {
const normalized = value.toLowerCase();
if (value.includes("淘宝") || value.includes("天猫")) return "taobao";
if (value.includes("京东")) return "jd";
if (value.includes("拼多多") || value.includes("鎷煎澶")) return "pdd";
if (value.includes("抖音")) return "douyin";
if (normalized.includes("amazon")) return "amazon";
if (normalized.includes("shopee")) return "shopee";
if (normalized.includes("lazada")) return "lazada";
if (normalized.includes("instagram")) return "instagram";
if (value.includes("速卖通") || value.includes("閫熷崠閫")) return "aliexpress";
if (normalized.includes("ebay")) return "ebay";
if (normalized.includes("tiktok")) return "tiktok";
return "default";
};
const getPlatformLogoMarks = (value: string) => {
if (value.includes("淘宝") || value.includes("天猫")) return ["淘", "猫"];
return [getPlatformLogoText(value)];
};
const renderPlatformLogo = (value: string) => {
const marks = getPlatformLogoMarks(value);
const variant = getPlatformLogoVariant(value);
return (
<span
className={`ecom-platform-logo-mark ecom-platform-logo-mark--${variant}${marks.length > 1 ? " ecom-platform-logo-mark--duo" : ""}`}
aria-hidden="true"
>
{marks.map((text) => (
<span key={text} className={`ecom-platform-logo-mark__tile${text.length > 1 ? " ecom-platform-logo-mark__tile--wide" : ""}`}>
{text}
</span>
))}
</span>
);
};
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 legacyPlatformAliases: Record<string, string> = {
"淘宝/天猫": "淘宝/天猫",
"京东": "京东",
"拼多多": "拼多多",
"抖音电商": "抖音电商",
"亚马逊Amazon": "亚马逊 Amazon",
"速卖通": "速卖通",
};
const normalizePlatform = (value: string) => getPlatformSpec(legacyPlatformAliases[value] ?? value).label;
const domesticPlatformLabels = new Set(["淘宝/天猫", "京东", "拼多多", "抖音电商"]);
const domesticPlatformLanguages = ["中文"];
const isDomesticPlatform = (platformValue: string) => domesticPlatformLabels.has(normalizePlatform(platformValue));
const getPlatformRatioGroup = (value: string, mode?: PlatformRatioModeKey): PlatformRatioGroup => {
const platformSpec = getPlatformSpec(value);
return (mode ? platformSpec.ratioGroups?.[mode] : null) ?? {
ratios: platformSpec.ratios,
defaultRatio: platformSpec.defaultRatio,
};
};
const getPlatformRatioOptions = (value: string, mode?: PlatformRatioModeKey) => getPlatformRatioGroup(value, mode).ratios;
const getPlatformDefaultRatio = (value: string, mode?: PlatformRatioModeKey) => getPlatformRatioGroup(value, mode).defaultRatio;
const getUniqueRatioOptions = (ratios: string[]) => Array.from(new Set(ratios));
const normalizeRatioToken = (value: string) =>
value
.replaceAll("\u00a0", " ")
.replaceAll("脳", "×")
.replaceAll("*", "×")
.replaceAll("", ":")
.replace(/锛\?/g, ":")
.replace(/\s+/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 quickSetRatioOptions = ["1:1", "3:4", "4:3", "9:16", "16:9"];
const getQuickSetRatioValue = (value: string) => {
const normalizedValue = normalizeRatioToken(value);
if (quickSetRatioOptions.includes(normalizedValue)) return normalizedValue;
const sizeMatch = normalizedValue.match(/(\d+)\s*[×xX]\s*(\d+)/u);
if (sizeMatch) {
const width = Number(sizeMatch[1]);
const height = Number(sizeMatch[2]);
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
const aspect = formatAspectRatio(width, height);
if (quickSetRatioOptions.includes(aspect)) return aspect;
}
}
const ratioMatch = normalizedValue.match(/(\d+)\s*[:]\s*(\d+)/u);
if (ratioMatch) {
const aspect = `${Number(ratioMatch[1])}:${Number(ratioMatch[2])}`;
if (quickSetRatioOptions.includes(aspect)) return aspect;
}
return quickSetRatioOptions[0]!;
};
const formatRatioDisplayValue = (value: string) => {
const normalizedValue = normalizeRatioToken(value);
const sizeMatch = normalizedValue.match(/(\d+)\s*[×xX]\s*(\d+)\s*px?/u);
if (sizeMatch) {
const width = Number(sizeMatch[1]);
const height = Number(sizeMatch[2]);
return `${width}×${height}px\u00a0\u00a0\u00a0${formatAspectRatio(width, height)}`;
}
return normalizedValue
.replace("淘宝主图 / SKU 图 ", "淘宝主图 / SKU 图 ")
.replace("京东主图 / SKU 图 ", "京东主图 / SKU 图 ")
.replace("详情页宽", "详情页宽")
.replace("短视频", "短视频")
.replace("主图", "主图")
.replace("商品主图", "商品主图")
.replace("鍟嗗搧鍥?", "商品图")
.replace(/\s+:/g, ":")
.replace(/:\s+/g, ":");
};
const getRatioDisplayParts = (value: string) => {
const display = formatRatioDisplayValue(value).replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim();
const aspectMatch = display.match(/(\d+\s*[:]\s*\d+)(?!.*\d+\s*[:]\s*\d+)/u);
const aspect = aspectMatch?.[1]?.replace(/\s+/g, "") ?? "自适应";
const size = aspectMatch ? display.replace(aspectMatch[0], "").trim() : display;
return {
size: size || "原图比例",
aspect,
};
};
/** Extract CSS aspect-ratio from a ratio string like "1000x1000px 1:1" -> "1 / 1" */
const parseRatioToAspectCss = (ratioStr: string): string => {
const match = ratioStr.match(/(\d+)\D+(\d+)/u);
if (!match) return "1 / 1";
return `${match[1]} / ${match[2]}`;
};
const supportedImageApiRatios = ["1:1", "3:4", "4:3", "9:16", "16:9"] as const;
type SupportedImageApiRatio = typeof supportedImageApiRatios[number];
const toSupportedImageApiRatio = (width: number, height: number): SupportedImageApiRatio => {
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return "1:1";
let bestRatio: SupportedImageApiRatio = "1:1";
let bestScore = Number.POSITIVE_INFINITY;
const target = Math.log(width / height);
for (const ratio of supportedImageApiRatios) {
const [left, right] = ratio.split(":").map(Number);
const score = Math.abs(target - Math.log(left / right));
if (score < bestScore) {
bestRatio = ratio;
bestScore = score;
}
}
return bestRatio;
};
/** Normalize ratio display string ("1000×1000px 1:1") to an image API aspect ratio ("1:1"). */
const normalizeRatioForApi = (ratioStr: string): string => {
const normalizedValue = normalizeRatioToken(ratioStr);
const explicitRatios = Array.from(normalizedValue.matchAll(/(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)/g));
const explicitRatio = explicitRatios.at(-1);
if (explicitRatio) {
return toSupportedImageApiRatio(Number(explicitRatio[1]), Number(explicitRatio[2]));
}
const sizeMatch = normalizedValue.match(/(\d+(?:\.\d+)?)\s*[×xX*]\s*(\d+(?:\.\d+)?)/u);
if (!sizeMatch) return "1:1";
return toSupportedImageApiRatio(Number(sizeMatch[1]), Number(sizeMatch[2]));
};
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; desc: string; icon: ReactNode }> = [
{ key: "set", label: "套图", desc: "主图/卖点/场景", icon: <AppstoreOutlined /> },
{ key: "detail", label: "详情图", desc: "长图模块化生成", icon: <LayoutOutlined /> },
{ key: "model", label: "模特图", desc: "真人穿搭展示", icon: <SkinOutlined /> },
{ key: "video", label: "短视频", desc: "分镜视频链路", icon: <VideoCameraOutlined /> },
];
const cloneOutputOptions: Array<{ key: ProductSetOutputKey; label: string; desc: string; icon: ReactNode }> = [
...productSetOutputOptions,
];
const commerceScenarioOptions: Array<{ key: CommerceScenarioKey; label: string; desc: string; icon: ReactNode }> = [
{ key: "popular", label: "热门", desc: "高频模板", icon: <FireOutlined /> },
{ key: "poster", label: "海报生成", desc: "活动视觉", icon: <LayoutOutlined /> },
{ key: "mainImage", label: "商品主图", desc: "主图转化", icon: <FileImageOutlined /> },
{ key: "scene", label: "场景图", desc: "生活氛围", icon: <AppstoreOutlined /> },
{ key: "festival", label: "节日风格图", desc: "节点营销", icon: <GlobalOutlined /> },
{ key: "model", label: "模特图", desc: "真人展示", icon: <SkinOutlined /> },
{ key: "background", label: "更换背景", desc: "背景重构", icon: <ClearOutlined /> },
{ key: "retouch", label: "无痕改图", desc: "精修优化", icon: <EditOutlined /> },
{ key: "salesVideo", label: "带货视频", desc: "短视频脚本", icon: <VideoCameraOutlined /> },
];
const commerceScenarioOutputMap: Record<Exclude<CommerceScenarioKey, "popular">, ProductSetOutputKey> = {
poster: "set",
mainImage: "set",
scene: "set",
festival: "set",
model: "model",
background: "set",
retouch: "set",
salesVideo: "video",
};
const commerceScenarioTemplates: CommerceScenarioTemplate[] = [
{
id: "poster-campaign-clean",
scenario: "poster",
output: "set",
title: "新品活动海报",
desc: "适合首发、上新、促销专题的主视觉",
badge: "高频推荐",
prompt: "帮我生成一张电商新品活动海报,突出产品主体、核心卖点和促销氛围,画面干净高级,适合店铺首页和广告投放。",
mediaUrl: ossAssets.ecommerce.detail.longPage,
},
{
id: "poster-social-drop",
scenario: "poster",
output: "set",
title: "社媒种草海报",
desc: "更适合小红书、朋友圈、站外广告",
badge: "热门模板",
prompt: "生成一张社媒种草风格商品海报,突出产品质感、生活方式和一句清晰卖点,画面轻盈、有品牌感。",
mediaUrl: ossAssets.ecommerce.inspiration.officeStyleSet,
},
{
id: "main-clean-product",
scenario: "mainImage",
output: "set",
title: "高转化商品主图",
desc: "白底/浅场景,主体清楚,卖点明确",
badge: "高频推荐",
prompt: "生成一张高转化商品主图,产品主体居中清晰,背景简洁,突出核心卖点和材质细节,适合电商搜索列表展示。",
mediaUrl: ossAssets.ecommerce.productSet.main,
},
{
id: "main-selling-point",
scenario: "mainImage",
output: "set",
title: "卖点强化主图",
desc: "适合列表点击率优化",
badge: "点击率优先",
prompt: "生成一张卖点强化商品主图,保留产品真实质感,加入清晰卖点表达和轻量信息层级,适合提升列表点击率。",
mediaUrl: ossAssets.ecommerce.productSet.selling,
},
{
id: "scene-lifestyle",
scenario: "scene",
output: "set",
title: "生活方式场景图",
desc: "把商品放进真实使用环境",
badge: "高频推荐",
prompt: "生成生活方式商品场景图,把产品自然放入真实使用环境,突出使用感、氛围和购买理由,画面真实且商业化。",
mediaUrl: ossAssets.ecommerce.productSet.scene,
},
{
id: "scene-premium",
scenario: "scene",
output: "set",
title: "高级质感场景",
desc: "适合品牌调性和详情页氛围图",
badge: "品牌感",
prompt: "生成高级质感商品场景图,背景克制、光影柔和,突出产品材质、轮廓和品牌调性,适合详情页和广告素材。",
mediaUrl: ossAssets.ecommerce.detail.gridA,
},
{
id: "festival-seasonal",
scenario: "festival",
output: "set",
title: "节日营销图",
desc: "适合大促、节庆、节点活动",
badge: "节点营销",
prompt: "生成节日营销风格商品图,结合节日氛围和促销视觉,但保持产品主体清晰、信息不过载,适合电商活动投放。",
mediaUrl: ossAssets.ecommerce.detail.gridB,
},
{
id: "festival-gift",
scenario: "festival",
output: "set",
title: "礼赠氛围图",
desc: "适合礼盒、礼品、节日送礼场景",
badge: "热门模板",
prompt: "生成礼赠氛围商品图,突出节日送礼感、包装质感和温暖情绪,画面高级克制,适合活动页与社媒投放。",
mediaUrl: ossAssets.ecommerce.detail.gridC,
},
{
id: "model-natural-fit",
scenario: "model",
output: "model",
title: "自然穿搭模特图",
desc: "突出上身效果、版型和真实穿着",
badge: "高频推荐",
prompt: "生成自然穿搭模特图,突出服饰上身效果、版型和整体气质,模特姿态自然,适合服饰电商详情与主图展示。",
mediaUrl: ossAssets.ecommerce.tryOn.dressA,
},
{
id: "model-street",
scenario: "model",
output: "model",
title: "街拍模特场景",
desc: "更适合年轻化、生活方式品牌",
badge: "风格推荐",
prompt: "生成街拍风格模特图,模特自然展示商品,背景有生活气息,突出穿搭氛围、比例和品牌调性。",
mediaUrl: ossAssets.ecommerce.tryOn.modelWoman,
},
{
id: "background-clean",
scenario: "background",
output: "set",
title: "商品换浅色背景",
desc: "保留主体,重构干净商业背景",
badge: "高频推荐",
prompt: "为商品更换干净浅色商业背景,保留产品主体、边缘和材质细节,整体画面适合电商主图和广告素材。",
mediaUrl: ossAssets.ecommerce.productSet.detail,
},
{
id: "background-scene",
scenario: "background",
output: "set",
title: "商品换场景背景",
desc: "从普通拍摄变成真实使用场景",
badge: "场景增强",
prompt: "为商品更换真实使用场景背景,保持主体比例和边缘自然,增强生活化氛围和商业转化感。",
mediaUrl: ossAssets.ecommerce.productSet.scene,
},
{
id: "retouch-clean",
scenario: "retouch",
output: "set",
title: "白底精修图",
desc: "修正瑕疵、增强质感和边缘细节",
badge: "高频推荐",
prompt: "对商品图进行无痕精修,清理瑕疵、优化光影和边缘细节,保持商品真实结构,输出干净高级的电商图。",
mediaUrl: ossAssets.ecommerce.productSet.main,
},
{
id: "retouch-premium",
scenario: "retouch",
output: "set",
title: "质感增强图",
desc: "强化材质、反光和商品高级感",
badge: "精修模板",
prompt: "对商品图进行质感增强,强化材质、光泽、纹理和立体感,画面自然不过度修饰,适合商业广告素材。",
mediaUrl: ossAssets.ecommerce.productSet.selling,
},
{
id: "sales-video-hook",
scenario: "salesVideo",
output: "video",
title: "带货视频开场",
desc: "第一秒抓住注意力,快速进入卖点",
badge: "高频推荐",
prompt: "生成电商带货短视频脚本和分镜,第一秒突出产品和痛点,随后展示核心卖点、使用场景和行动引导。",
mediaUrl: ossAssets.ecommerce.inspiration.tiktokPreference,
},
{
id: "sales-video-demo",
scenario: "salesVideo",
output: "video",
title: "使用演示视频",
desc: "适合讲解型、种草型短视频",
badge: "转化优先",
prompt: "生成商品使用演示短视频分镜,围绕使用过程、关键卖点和效果对比展开,节奏清晰,适合带货转化。",
mediaUrl: ossAssets.ecommerce.inspiration.asinListing,
},
];
const popularCommerceScenarioTemplates = commerceScenarioOptions
.filter((option): option is { key: Exclude<CommerceScenarioKey, "popular">; label: string; desc: string; icon: ReactNode } => option.key !== "popular")
.map((option) => commerceScenarioTemplates.find((template) => template.scenario === option.key))
.filter((template): template is CommerceScenarioTemplate => Boolean(template));
const cloneSetCountOptions: Array<{
key: CloneSetCountKey;
title: string;
desc: string;
}> = [
{ key: "selling", title: "卖点图", desc: "展示商品核心卖点和细节特写" },
{ key: "white", title: "白底图", desc: "白底主图,多角度呈现商品细节" },
{ key: "scene", title: "场景图", desc: "展示商品生活使用场景和人物搭配" },
];
const cloneSetCountKeys = cloneSetCountOptions.map((option) => option.key);
const defaultCloneSetCounts: Record<CloneSetCountKey, number> = {
selling: 3,
white: 1,
scene: 3,
};
const minCloneSetTotal = 1;
const maxCloneSetTotal = 16;
const maxCloneProductImages = 20;
const maxCloneReferenceImages = 20;
const cloneVideoDurationMin = 5;
const cloneVideoDurationMax = 45;
const defaultEcommercePlatform = "淘宝/天猫";
const defaultProductSetOutput: ProductSetOutputKey = "set";
const defaultCloneOutput: CloneOutputKey = "set";
const cloneLatestSettingStorageKey = "omniai.clone-ai.latest-setting";
const ecommerceHistoryStorageKey = "omniai.ecommerce.history.records";
const cloneVideoQualityOptions: Array<{ key: CloneVideoQualityKey; label: string; desc: string }> = [
{ 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 = [
ossAssets.ecommerce.slides.slide4,
ossAssets.ecommerce.generated,
ossAssets.ecommerce.slides.slide5,
];
const productSetAssets = ossAssets.ecommerce.productSet;
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 = ossAssets.ecommerce.tryOn;
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: "SKU组合图", desc: "呈现颜色款式组合" },
{ id: "ingredient", title: "成分材质图", desc: "说明配方或材料构成" },
{ id: "service", title: "保障说明图", desc: "传达质保退换承诺" },
{ id: "tips", title: "使用提示图", desc: "提醒操作与保养要点" },
];
const defaultDetailModuleIds: string[] = [];
const defaultCloneDetailModuleIds = ["hero", "selling", "usage", "angle", "scene", "detail"];
const maxDetailModuleSelection = 6;
const cloneDetailModules = detailModules;
const detailAssets = ossAssets.ecommerce.detail;
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 getRemoteImageFormat(mimeType: string, imageUrl: string) {
const mimeFormat = mimeType.split("/")[1]?.replace("jpeg", "jpg").toUpperCase();
if (mimeFormat) return mimeFormat;
return imageUrl.split("?")[0].split(".").pop()?.toUpperCase() ?? "IMAGE";
}
function getRemoteImageName(imageUrl: string, fallback: string) {
try {
const parsed = new URL(imageUrl);
const filename = decodeURIComponent(parsed.pathname.split("/").filter(Boolean).pop() || "");
return filename || fallback;
} catch {
return fallback;
}
}
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;
});
}
const blobToDataUrl = (blob: Blob): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
reader.readAsDataURL(blob);
});
async function createUploadedImageItems(files: File[], limit: number, prefix: string): Promise<CloneImageItem[]> {
const selectedFiles = Array.from(files).slice(0, limit);
const stamp = Date.now();
const items = await Promise.all(selectedFiles.map(async (file, index) => {
const localPreviewUrl = URL.createObjectURL(file);
let src = localPreviewUrl;
let ossKey: string | undefined;
let shouldRevokeLocalPreview = false;
let dimensions: { width?: number; height?: number } = {};
try {
dimensions = await readImageDimensions(localPreviewUrl);
} catch {
dimensions = {};
}
const mimeType = normalizeEcommerceImageMime(file.type);
try {
const uploadBlob = file.type === mimeType ? file : new Blob([file], { type: mimeType });
const uploaded = await aiGenerationClient.uploadAssetBinary(uploadBlob, {
name: file.name,
mimeType,
scope: ecommerceOssScopes.productSource,
});
src = uploaded.url;
ossKey = uploaded.ossKey;
shouldRevokeLocalPreview = true;
} catch {
src = localPreviewUrl;
} finally {
if (shouldRevokeLocalPreview) URL.revokeObjectURL(localPreviewUrl);
}
return {
id: `${prefix}-${stamp}-${index}`,
src,
name: file.name,
file,
format: getImageFileFormat(file),
mimeType,
ossKey,
...dimensions,
};
}));
return items;
}
async function persistGeneratedImageUrl(sourceUrl: string, scope: string, namePrefix: string): Promise<string> {
if (!sourceUrl) return sourceUrl;
try {
if (sourceUrl.startsWith("data:")) {
const { url } = await aiGenerationClient.uploadAsset({
dataUrl: sourceUrl,
name: `${namePrefix}-${Date.now()}.png`,
scope,
});
return url || sourceUrl;
}
if (sourceUrl.startsWith("blob:")) {
const rawBlob = await fetch(sourceUrl).then((res) => res.blob());
const mimeType = normalizeEcommerceImageMime(rawBlob.type);
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
const { url } = await aiGenerationClient.uploadAssetBinary(blob, {
name: `${namePrefix}-${Date.now()}.png`,
mimeType,
scope,
});
return url;
}
const { url } = await aiGenerationClient.uploadAssetByUrl({
sourceUrl,
name: `${namePrefix}-${Date.now()}`,
scope,
});
return url || sourceUrl;
} catch {
return sourceUrl;
}
}
function notifyRejectedImages(files: File[]): File[] {
const { accepted, rejected } = validateEcommerceImageFiles(files);
const message = summarizeRejectedImages(rejected);
if (message) toast.error(message);
return accepted;
}
function isCloneSavedSetting(item: unknown): item is CloneSavedSetting {
const candidate = item as Partial<CloneSavedSetting>;
return (
typeof candidate.id === "string" &&
typeof candidate.name === "string" &&
typeof candidate.savedAt === "string" &&
typeof candidate.output === "string" &&
typeof candidate.platform === "string" &&
typeof candidate.market === "string" &&
typeof candidate.language === "string" &&
typeof candidate.ratio === "string" &&
typeof candidate.videoDurationSeconds === "number"
);
}
function readCloneLatestSetting() {
if (typeof window === "undefined") return null;
try {
const rawValue = window.localStorage.getItem(cloneLatestSettingStorageKey);
if (rawValue) {
const parsedValue: unknown = JSON.parse(rawValue);
if (isCloneSavedSetting(parsedValue)) return parsedValue;
}
} catch {
return null;
}
return null;
}
function writeCloneLatestSetting(setting: CloneSavedSetting) {
if (typeof window === "undefined") return;
window.localStorage.setItem(cloneLatestSettingStorageKey, JSON.stringify(setting));
}
function isCloneImageItem(item: unknown): item is CloneImageItem {
const candidate = item as Partial<CloneImageItem>;
return typeof candidate.id === "string" && typeof candidate.src === "string" && typeof candidate.name === "string";
}
function isCloneResult(item: unknown): item is CloneResult {
const candidate = item as Partial<CloneResult>;
return typeof candidate.id === "string" && typeof candidate.src === "string" && typeof candidate.label === "string";
}
function isEcommerceHistoryRecord(item: unknown): item is EcommerceHistoryRecord {
const candidate = item as Partial<EcommerceHistoryRecord>;
return (
typeof candidate.id === "string" &&
typeof candidate.title === "string" &&
typeof candidate.createdAt === "number" &&
typeof candidate.output === "string" &&
typeof candidate.platform === "string" &&
typeof candidate.market === "string" &&
typeof candidate.language === "string" &&
typeof candidate.ratio === "string" &&
typeof candidate.requirement === "string" &&
Array.isArray(candidate.productImages) &&
candidate.productImages.every(isCloneImageItem) &&
Array.isArray(candidate.results) &&
candidate.results.every(isCloneResult)
);
}
function removeFilePayloadFromImages(images: CloneImageItem[]): CloneImageItem[] {
return images.map(({ id, src, name, width, height, format, mimeType, ossKey }) => ({
id,
src,
name,
width,
height,
format,
mimeType,
ossKey,
}));
}
function getTurnResults(turn: EcommerceHistoryTurn): CloneResult[] {
if (turn.results?.length) return turn.results.filter((item) => item.src);
if (turn.output !== "set") return [];
return (turn.setResultImages ?? [])
.filter(Boolean)
.map((src, index) => ({ id: `${turn.id}-set-${index}`, src, label: `套图 ${index + 1}` }));
}
function buildHistoryTurnFromRecord(record: EcommerceHistoryRecord): EcommerceHistoryTurn {
return {
id: `${record.id}-turn-initial`,
createdAt: record.createdAt,
status: record.status ?? "done",
errorMessage: record.status === "failed" ? record.errorMessage : undefined,
output: record.output,
platform: record.platform,
market: record.market,
language: record.language,
ratio: record.ratio,
requirement: record.requirement,
productImages: record.productImages ?? [],
results: record.results ?? [],
setResultImages: record.setResultImages ?? [],
setCounts: record.setCounts ?? defaultCloneSetCounts,
detailModules: record.detailModules ?? defaultCloneDetailModuleIds,
modelScenes: record.modelScenes ?? [],
referenceImages: record.referenceImages ?? [],
replicateLevel: record.replicateLevel ?? "high",
};
}
function normalizeEcommerceHistoryTurn(turn: EcommerceHistoryTurn, fallback: EcommerceHistoryRecord, index: number): EcommerceHistoryTurn {
const status = turn.status ?? fallback.status ?? "done";
return {
id: typeof turn.id === "string" && turn.id ? turn.id : `${fallback.id}-turn-${index + 1}`,
createdAt: typeof turn.createdAt === "number" ? turn.createdAt : fallback.createdAt,
status,
errorMessage: status === "failed" ? turn.errorMessage ?? fallback.errorMessage : undefined,
output: turn.output ?? fallback.output,
platform: turn.platform ?? fallback.platform,
market: turn.market ?? fallback.market,
language: turn.language ?? fallback.language,
ratio: turn.ratio ?? fallback.ratio,
requirement: turn.requirement ?? fallback.requirement,
productImages: removeFilePayloadFromImages(Array.isArray(turn.productImages) ? turn.productImages : fallback.productImages),
results: Array.isArray(turn.results) ? turn.results.filter(isCloneResult) : [],
setResultImages: Array.isArray(turn.setResultImages) ? turn.setResultImages.filter(Boolean) : [],
setCounts: turn.setCounts ?? fallback.setCounts ?? defaultCloneSetCounts,
detailModules: turn.detailModules ?? fallback.detailModules ?? defaultCloneDetailModuleIds,
modelScenes: turn.modelScenes ?? fallback.modelScenes ?? [],
referenceImages: removeFilePayloadFromImages(Array.isArray(turn.referenceImages) ? turn.referenceImages : fallback.referenceImages ?? []),
replicateLevel: turn.replicateLevel ?? fallback.replicateLevel ?? "high",
};
}
function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord): EcommerceHistoryRecord {
const status = record.status ?? "done";
const baseRecord = {
...record,
status,
errorMessage: status === "failed" ? record.errorMessage : undefined,
productImages: removeFilePayloadFromImages(record.productImages),
referenceImages: removeFilePayloadFromImages(record.referenceImages ?? []),
results: record.results ?? [],
setResultImages: record.setResultImages ?? [],
setCounts: record.setCounts ?? defaultCloneSetCounts,
detailModules: record.detailModules ?? defaultCloneDetailModuleIds,
modelScenes: record.modelScenes ?? [],
replicateLevel: record.replicateLevel ?? "high",
};
const rawTurns = Array.isArray(record.turns) && record.turns.length
? record.turns
: [buildHistoryTurnFromRecord(baseRecord)];
const turns = rawTurns.map((turn, index) => normalizeEcommerceHistoryTurn(turn, baseRecord, index));
return {
...baseRecord,
turns,
};
}
function readEcommerceHistoryRecords() {
if (typeof window === "undefined") return [];
try {
const rawValue = window.localStorage.getItem(ecommerceHistoryStorageKey);
if (!rawValue) return [];
const parsedValue: unknown = JSON.parse(rawValue);
if (!Array.isArray(parsedValue)) return [];
return parsedValue
.filter(isEcommerceHistoryRecord)
.map(normalizeEcommerceHistoryRecord)
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, 30);
} catch {
return [];
}
}
function writeEcommerceHistoryRecords(records: EcommerceHistoryRecord[]) {
if (typeof window === "undefined") return;
window.localStorage.setItem(ecommerceHistoryStorageKey, JSON.stringify(records.map(normalizeEcommerceHistoryRecord).slice(0, 30)));
}
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 smartCutoutInputRef = useRef<HTMLInputElement>(null);
const imageWorkbenchInputRef = useRef<HTMLInputElement>(null);
const imageWorkbenchUrlInputRef = useRef<HTMLInputElement>(null);
const imageWorkbenchProgressRef = useRef<number | null>(null);
const watermarkInputRef = useRef<HTMLInputElement>(null);
const watermarkUrlInputRef = useRef<HTMLInputElement>(null);
const watermarkProcessTimeoutRef = useRef<number | null>(null);
const translateInputRef = useRef<HTMLInputElement>(null);
const translateUrlInputRef = useRef<HTMLInputElement>(null);
const translateProcessTimeoutRef = useRef<number | null>(null);
const smartCutoutTransitionTimeoutRef = useRef<number | null>(null);
const smartCutoutPendingUrlsRef = useRef<string[]>([]);
const smartCutoutPaletteRef = useRef<HTMLDivElement>(null);
const smartCutoutToolsRef = useRef<HTMLDivElement>(null);
const composerMenuCloseTimeoutRef = useRef<number | null>(null);
const requirementTextareaRef = useRef<HTMLTextAreaElement>(null);
const commandComposerWrapRef = useRef<HTMLElement | null>(null);
const garmentInputRef = useRef<HTMLInputElement>(null);
const detailInputRef = useRef<HTMLInputElement>(null);
const detailProgressRef = useRef<number | null>(null);
const hotProgressRef = useRef<number | null>(null);
const hotMaterialInputRef = useRef<HTMLInputElement>(null);
const countHoldTimeoutRef = useRef<number | null>(null);
const countHoldIntervalRef = useRef<number | null>(null);
const isAuthenticated = Boolean((_props as Record<string, unknown>).isAuthenticated);
const requestLogin = () => {
const handler = (_props as Record<string, unknown>).onRequireLogin;
if (typeof handler === "function") handler();
};
const imageGen = useGenerationTasks({ sourceView: "ecommerce" });
const appUsage = useAppStore((s) => s.usage);
const latestCloneSettingRef = useRef<CloneSavedSetting | null>(null);
const skipInitialCloneAutoSaveRef = useRef(true);
const skipNextCloneAutoSaveRef = useRef(false);
const [activeTool, setActiveTool] = useState<ProductKitToolKey>("clone");
useEffect(() => {
setPreviewZoom(1);
setIsCommandComposerCompact(false);
}, [activeTool]);
const [setImages, setSetImages] = useState<CloneImageItem[]>([]);
const [productSetPlatform, setProductSetPlatform] = useState(defaultEcommercePlatform);
const [productSetMarket, setProductSetMarket] = useState(marketOptions[0]);
const [productSetLanguage, setProductSetLanguage] = useState(getPlatformDefaultLanguage(defaultEcommercePlatform, marketOptions[0]));
const [productSetRatio, setProductSetRatio] = useState(getPlatformDefaultRatio(defaultEcommercePlatform, defaultProductSetOutput));
const [productSetRequirement, setProductSetRequirement] = useState("");
const [productSetOutput, setProductSetOutput] = useState<ProductSetOutputKey>(defaultProductSetOutput);
const [productSetStatus, setProductSetStatus] = useState<ProductSetStatus>("idle");
// 套图/图像生成的真实进度(0-100):多张串行生成时按"已完成张数 + 当前张子进度"推进,
// 替代进度条原先写死 50 导致卡在 75% 的假进度。
const [generationProgress, setGenerationProgress] = useState(0);
const [productSetResultImages, setProductSetResultImages] = useState<string[]>([]);
const [isSetUploadDragging, setIsSetUploadDragging] = useState(false);
const [selectedProductSetPreview, setSelectedProductSetPreview] = useState<ProductSetPreviewSelection | null>(null);
const [showHostingModal, setShowHostingModal] = useState(false);
const [productImages, setProductImages] = useState<CloneImageItem[]>([]);
const [activeQuickTool, setActiveQuickTool] = useState<"cutout" | "detail" | "watermark" | "image-edit" | "translate" | "hot" | null>(null);
const [smartCutoutImage, setSmartCutoutImage] = useState<SmartCutoutImageItem | null>(null);
const [smartCutoutBatchImages, setSmartCutoutBatchImages] = useState<SmartCutoutImageItem[]>([]);
const [smartCutoutBackgroundColor, setSmartCutoutBackgroundColor] = useState("#ffffff");
const [smartCutoutBackgroundAlpha, setSmartCutoutBackgroundAlpha] = useState(100);
const [smartCutoutHexDraft, setSmartCutoutHexDraft] = useState("#ffffff");
const [isSmartCutoutPaletteOpen, setIsSmartCutoutPaletteOpen] = useState(false);
const [smartCutoutSizeKey, setSmartCutoutSizeKey] = useState<SmartCutoutSizeKey>("original");
const [isSmartCutoutDragging, setIsSmartCutoutDragging] = useState(false);
const [isSmartCutoutComparing, setIsSmartCutoutComparing] = useState(false);
const [isSmartCutoutTransitioning, setIsSmartCutoutTransitioning] = useState(false);
const [smartCutoutTransitionMessage, setSmartCutoutTransitionMessage] = useState({
title: "正在切换页面",
subtitle: "请稍候",
});
const [watermarkImage, setWatermarkImage] = useState<{ src: string; name: string; format: string } | null>(null);
const [watermarkStatus, setWatermarkStatus] = useState<"idle" | "processing" | "done" | "failed">("idle");
const [isWatermarkDragging, setIsWatermarkDragging] = useState(false);
const [watermarkResultUrl, setWatermarkResultUrl] = useState<string | null>(null);
const [watermarkProgress, setWatermarkProgress] = useState(0);
const [translateImage, setTranslateImage] = useState<{ src: string; name: string; format: string } | null>(null);
const [translateStatus, setTranslateStatus] = useState<"idle" | "processing" | "done" | "failed">("idle");
const [isTranslateDragging, setIsTranslateDragging] = useState(false);
const [translateLanguage, setTranslateLanguage] = useState("zh");
const [translateResultUrl, setTranslateResultUrl] = useState<string | null>(null);
const [imageWorkbenchImage, setImageWorkbenchImage] = useState<{ src: string; name: string; format: string } | null>(null);
const [imageWorkbenchPrompt, setImageWorkbenchPrompt] = useState("");
const [imageWorkbenchBrushSize, setImageWorkbenchBrushSize] = useState(50);
const [imageWorkbenchRatio, setImageWorkbenchRatio] = useState("1:1");
const [imageWorkbenchStatus, setImageWorkbenchStatus] = useState<"idle" | "processing" | "done" | "failed">("idle");
const [isImageWorkbenchDragging, setIsImageWorkbenchDragging] = useState(false);
const [imageWorkbenchMaskStrokes, setImageWorkbenchMaskStrokes] = useState<Array<{ id: string; size: number; points: Array<{ x: number; y: number }> }>>([]);
const [imageWorkbenchBrushCursor, setImageWorkbenchBrushCursor] = useState<{ x: number; y: number } | null>(null);
const [imageWorkbenchResultUrl, setImageWorkbenchResultUrl] = useState<string | null>(null);
const [imageWorkbenchProgress, setImageWorkbenchProgress] = useState(0);
const [isProductUploadDragging, setIsProductUploadDragging] = useState(false);
const [activeCommerceScenario, setActiveCommerceScenario] = useState<CommerceScenarioKey>("popular");
const [cloneOutput, setCloneOutput] = useState<CloneOutputKey>(defaultCloneOutput);
const [isCloneTemplateStripVisible, setIsCloneTemplateStripVisible] = useState(true);
const [videoHistoryVisible, setVideoHistoryVisible] = useState(false);
const [isVideoWorkspaceVisible, setIsVideoWorkspaceVisible] = useState(false);
const [videoPlanTrigger, setVideoPlanTrigger] = useState(0);
const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState<CloneBasicSelectKey | null>(null);
const [openQuickSetSelect, setOpenQuickSetSelect] = useState<CloneBasicSelectKey | null>(null);
const [visibleQuickSetSelect, setVisibleQuickSetSelect] = useState<CloneBasicSelectKey | null>(null);
const [isQuickSetSelectClosing, setIsQuickSetSelectClosing] = useState(false);
const [composerMenu, setComposerMenu] = useState<ComposerMenuKey | null>(null);
const [visibleComposerMenu, setVisibleComposerMenu] = useState<ComposerMenuKey | null>(null);
const [isComposerMenuClosing, setIsComposerMenuClosing] = useState(false);
const [composerPopoverLeft, setComposerPopoverLeft] = useState(0);
const [composerPopoverTop, setComposerPopoverTop] = useState(0);
const [isCommandHistoryCollapsed, setIsCommandHistoryCollapsed] = useState(true);
const [inspirationPreview, setInspirationPreview] = useState<{ mediaUrl: string; mediaType: "image" | "video"; prompt: string } | null>(null);
const [isQuickPanelCollapsed, setIsQuickPanelCollapsed] = useState(false);
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 [isCloneSettingsCollapsed, setIsCloneSettingsCollapsed] = useState(false);
const [isCloneConversationCollapsed, setIsCloneConversationCollapsed] = useState(false);
const [previewZoom, setPreviewZoom] = useState(1);
const quickSetSelectTimerRef = useRef<number | null>(null);
const openQuickSetSelectRef = useRef<CloneBasicSelectKey | null>(null);
const visibleQuickSetSelectRef = useRef<CloneBasicSelectKey | null>(null);
const [previewOffset, setPreviewOffset] = useState({ x: 0, y: 0 });
const previewSurfaceRef = useRef<HTMLElement | null>(null);
const previewZoomRef = useRef(previewZoom);
const previewOffsetRef = useRef(previewOffset);
const imageWorkbenchMaskPaintingRef = useRef(false);
const imageWorkbenchActiveStrokeIdRef = useRef<string | null>(null);
const imageWorkbenchMaskCanvasRef = useRef<HTMLCanvasElement>(null);
const imageWorkbenchLastMaskPointRef = useRef<{ x: number; y: number } | null>(null);
const previewPanRef = useRef<{ active: boolean; startX: number; startY: number; offsetX: number; offsetY: number }>({
active: false,
startX: 0,
startY: 0,
offsetX: 0,
offsetY: 0,
});
const previewTouchGestureRef = useRef<PreviewTouchGesture>({
mode: "none",
points: [],
startOffset: { x: 0, y: 0 },
startZoom: 1,
startDistance: 0,
startCenter: { x: 0, y: 0 },
});
const nodeDragRef = useRef<{ active: boolean; nodeId: string; startX: number; startY: number; originX: number; originY: number }>({
active: false,
nodeId: "",
startX: 0,
startY: 0,
originX: 0,
originY: 0,
});
const [isCommandComposerCompact, setIsCommandComposerCompact] = useState(false);
const typewriterText = useTypewriter("万物皆可AI,广告素材一键生成");
useEffect(() => {
previewZoomRef.current = previewZoom;
}, [previewZoom]);
useEffect(() => {
previewOffsetRef.current = previewOffset;
}, [previewOffset]);
useEffect(() => {
if (typeof window === "undefined") return undefined;
// aside 默认收起,用户手动控制展开/收起
return undefined;
}, []);
useEffect(() => {
if (!inspirationPreview) return undefined;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setInspirationPreview(null);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [inspirationPreview]);
const previewTransformStyle = useMemo<CSSProperties>(
() => ({
transform: `translate3d(${previewOffset.x}px, ${previewOffset.y}px, 0) scale(${previewZoom})`,
}),
[previewOffset.x, previewOffset.y, previewZoom],
);
const updatePreviewTransform = (nextZoom: number, nextOffset: { x: number; y: number }) => {
previewZoomRef.current = nextZoom;
previewOffsetRef.current = nextOffset;
setPreviewZoom(nextZoom);
setPreviewOffset(nextOffset);
};
const getPreviewGestureDistance = (points: PreviewTouchPoint[]) => {
if (points.length < 2) return 0;
return Math.hypot(points[0]!.x - points[1]!.x, points[0]!.y - points[1]!.y);
};
const getPreviewGestureCenter = (points: PreviewTouchPoint[]) => {
if (points.length < 2) return points[0] ? { x: points[0].x, y: points[0].y } : { x: 0, y: 0 };
return {
x: (points[0]!.x + points[1]!.x) / 2,
y: (points[0]!.y + points[1]!.y) / 2,
};
};
const isPreviewTouchInteractiveTarget = (target: HTMLElement | null) =>
Boolean(target?.closest(".ecom-command-composer-wrap, .clone-ai-preview-header, .clone-ai-source-corner-action, input, textarea, select, a, button"));
const startPreviewTouchGesture = (event: ReactPointerEvent<HTMLElement>) => {
if (event.pointerType === "mouse" || isPreviewTouchInteractiveTarget(event.target as HTMLElement | null)) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
const points = [
...previewTouchGestureRef.current.points.filter((point) => point.id !== event.pointerId),
{ id: event.pointerId, x: event.clientX, y: event.clientY },
].slice(-2);
const mode = points.length >= 2 ? "pinch" : "pan";
previewTouchGestureRef.current = {
mode,
points,
startOffset: previewOffsetRef.current,
startZoom: previewZoomRef.current,
startDistance: getPreviewGestureDistance(points),
startCenter: getPreviewGestureCenter(points),
};
event.currentTarget.classList.add("is-touch-panning");
};
const movePreviewTouchGesture = (event: ReactPointerEvent<HTMLElement>) => {
const gesture = previewTouchGestureRef.current;
if (gesture.mode === "none" || event.pointerType === "mouse") return;
event.preventDefault();
const points = gesture.points.map((point) => point.id === event.pointerId ? { ...point, x: event.clientX, y: event.clientY } : point);
if (!points.some((point) => point.id === event.pointerId)) return;
if (gesture.mode === "pinch" && points.length >= 2 && gesture.startDistance > 0) {
const rect = event.currentTarget.getBoundingClientRect();
const center = getPreviewGestureCenter(points);
const zoomRatio = getPreviewGestureDistance(points) / gesture.startDistance;
const nextZoom = Math.min(2, Math.max(0.25, gesture.startZoom * zoomRatio));
const startCenterX = gesture.startCenter.x - rect.left;
const startCenterY = gesture.startCenter.y - rect.top;
const currentCenterX = center.x - rect.left;
const currentCenterY = center.y - rect.top;
const contentX = (startCenterX - gesture.startOffset.x) / gesture.startZoom;
const contentY = (startCenterY - gesture.startOffset.y) / gesture.startZoom;
updatePreviewTransform(nextZoom, {
x: currentCenterX - contentX * nextZoom,
y: currentCenterY - contentY * nextZoom,
});
} else {
const point = points[0]!;
const startPoint = gesture.points[0]!;
updatePreviewTransform(gesture.startZoom, {
x: gesture.startOffset.x + point.x - startPoint.x,
y: gesture.startOffset.y + point.y - startPoint.y,
});
}
previewTouchGestureRef.current = { ...gesture, points };
};
const stopPreviewTouchGesture = (event: ReactPointerEvent<HTMLElement>) => {
const gesture = previewTouchGestureRef.current;
if (event.pointerType === "mouse" || gesture.mode === "none") return;
const points = gesture.points.filter((point) => point.id !== event.pointerId);
if (points.length) {
previewTouchGestureRef.current = {
mode: "pan",
points,
startOffset: previewOffsetRef.current,
startZoom: previewZoomRef.current,
startDistance: 0,
startCenter: getPreviewGestureCenter(points),
};
} else {
previewTouchGestureRef.current = {
mode: "none",
points: [],
startOffset: previewOffsetRef.current,
startZoom: previewZoomRef.current,
startDistance: 0,
startCenter: { x: 0, y: 0 },
};
event.currentTarget.classList.remove("is-touch-panning");
}
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// Pointer capture can already be released by the browser after cancel.
}
};
useEffect(() => {
const container = previewSurfaceRef.current;
if (!container) return undefined;
const handleWheel = (event: WheelEvent) => {
const target = event.target as HTMLElement | null;
if (target?.closest(".ecom-inspiration-preview")) {
event.preventDefault();
return;
}
if (target?.closest(".ecom-command-composer-wrap, .clone-ai-preview-header")) return;
event.preventDefault();
const currentZoom = previewZoomRef.current;
const rect = container.getBoundingClientRect();
const cursorX = event.clientX - rect.left;
const cursorY = event.clientY - rect.top;
const zoomDelta = event.deltaY < 0 ? 1.08 : 0.92;
const nextZoom = Math.min(2, Math.max(0.25, currentZoom * zoomDelta));
if (nextZoom === currentZoom) return;
const currentOffset = previewOffsetRef.current;
const contentX = (cursorX - currentOffset.x) / currentZoom;
const contentY = (cursorY - currentOffset.y) / currentZoom;
const nextOffset = {
x: cursorX - contentX * nextZoom,
y: cursorY - contentY * nextZoom,
};
previewZoomRef.current = nextZoom;
previewOffsetRef.current = nextOffset;
setPreviewZoom(nextZoom);
setPreviewOffset(nextOffset);
};
container.addEventListener("wheel", handleWheel, { passive: false, capture: true });
return () => container.removeEventListener("wheel", handleWheel, { capture: true });
}, [activeTool, cloneOutput]);
useEffect(() => {
const container = previewSurfaceRef.current;
if (!container) return undefined;
const listenerOptions = { capture: true };
const handleMouseDown = (event: MouseEvent) => {
if (event.button !== 0 && event.button !== 1) return;
const target = event.target as HTMLElement | null;
if (target?.closest(".ecom-command-composer-wrap, .clone-ai-preview-header, .clone-ai-preview-showcase, .clone-ai-main-result, .clone-ai-result-grid, .clone-ai-node-drag-handle, input, textarea, select, a, button, img")) return;
event.preventDefault();
const currentOffset = previewOffsetRef.current;
previewPanRef.current = {
active: true,
startX: event.clientX,
startY: event.clientY,
offsetX: currentOffset.x,
offsetY: currentOffset.y,
};
container.classList.add("is-middle-panning");
};
const handleMouseMove = (event: MouseEvent) => {
const pan = previewPanRef.current;
if (!pan.active) return;
event.preventDefault();
const nextOffset = {
x: pan.offsetX + event.clientX - pan.startX,
y: pan.offsetY + event.clientY - pan.startY,
};
previewOffsetRef.current = nextOffset;
setPreviewOffset(nextOffset);
};
const stopMousePan = () => {
const pan = previewPanRef.current;
if (!pan.active) return;
previewPanRef.current = { ...pan, active: false };
container.classList.remove("is-middle-panning");
};
const preventAuxClick = (event: MouseEvent) => {
if (event.button === 1) event.preventDefault();
};
container.addEventListener("mousedown", handleMouseDown, listenerOptions);
container.addEventListener("auxclick", preventAuxClick, listenerOptions);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", stopMousePan);
window.addEventListener("blur", stopMousePan);
return () => {
container.removeEventListener("mousedown", handleMouseDown, listenerOptions);
container.removeEventListener("auxclick", preventAuxClick, listenerOptions);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", stopMousePan);
window.removeEventListener("blur", stopMousePan);
};
}, [activeTool, cloneOutput]);
const bindPreviewSurface = (node: HTMLElement | null) => {
previewSurfaceRef.current = node;
};
const getPreviewSurfaceProps = () => ({
ref: bindPreviewSurface,
onMouseDown: (event: ReactMouseEvent<HTMLElement>) => {
if (event.button === 1) event.preventDefault();
},
onAuxClick: (event: ReactMouseEvent<HTMLElement>) => {
if (event.button === 1) event.preventDefault();
},
onPointerDown: startPreviewTouchGesture,
onPointerMove: movePreviewTouchGesture,
onPointerUp: stopPreviewTouchGesture,
onPointerCancel: stopPreviewTouchGesture,
});
const startCanvasNodeDrag = (event: ReactPointerEvent<HTMLElement>, node: CanvasNode) => {
if (event.button !== 0 || event.pointerType === "mouse") return;
if ((event.target as HTMLElement | null)?.closest("button, a, input, textarea, select")) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
nodeDragRef.current = { active: true, nodeId: node.id, startX: event.clientX, startY: event.clientY, originX: node.x, originY: node.y };
};
const moveCanvasNodeDrag = (event: ReactPointerEvent<HTMLElement>, nodeId: string) => {
const drag = nodeDragRef.current;
if (!drag.active || drag.nodeId !== nodeId || event.pointerType === "mouse") return;
event.preventDefault();
event.stopPropagation();
const zoom = previewZoomRef.current;
const dx = (event.clientX - drag.startX) / zoom;
const dy = (event.clientY - drag.startY) / zoom;
setCanvasNodes((prev) => prev.map((node) => node.id === nodeId ? { ...node, x: drag.originX + dx, y: drag.originY + dy } : node));
};
const stopCanvasNodeDrag = (event: ReactPointerEvent<HTMLElement>, nodeId: string) => {
if (nodeDragRef.current.nodeId !== nodeId || event.pointerType === "mouse") return;
nodeDragRef.current = { ...nodeDragRef.current, active: false };
event.stopPropagation();
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// Pointer capture can already be released by the browser after cancel.
}
};
const handlePreviewWheel = (event: React.WheelEvent<HTMLElement>) => {
if (!event.currentTarget) return;
const container = event.currentTarget as HTMLElement;
const rect = container.getBoundingClientRect();
const cursorX = event.clientX - rect.left;
const cursorY = event.clientY - rect.top;
const zoomDelta = event.deltaY < 0 ? 1.08 : 0.92;
const nextZoom = Math.min(2, Math.max(0.25, previewZoom * zoomDelta));
if (nextZoom === previewZoom) return;
const contentX = (cursorX + container.scrollLeft) / previewZoom;
const contentY = (cursorY + container.scrollTop) / previewZoom;
setPreviewZoom(nextZoom);
requestAnimationFrame(() => {
container.scrollLeft = contentX * nextZoom - cursorX;
container.scrollTop = contentY * nextZoom - cursorY;
});
};
const handleQuickPreviewWheel = (event: React.WheelEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const zoomDelta = event.deltaY < 0 ? 1.08 : 0.92;
setPreviewZoom((value) => Math.min(2, Math.max(0.25, value * zoomDelta)));
};
const handleQuickPanelWheel = (event: React.WheelEvent<HTMLElement>) => {
const panel = event.currentTarget;
if (panel.scrollHeight <= panel.clientHeight) return;
event.stopPropagation();
panel.scrollTop += event.deltaY;
};
const [requirement, setRequirement] = useState("");
const [requirementImageMentionQuery, setRequirementImageMentionQuery] = useState<string | null>(null);
const [cloneSettingName, setCloneSettingName] = useState("新建创作");
const [platform, setPlatform] = useState(defaultEcommercePlatform);
const [market, setMarket] = useState(marketOptions[0]);
const [language, setLanguage] = useState(getPlatformDefaultLanguage(defaultEcommercePlatform, marketOptions[0]));
const [ratio, setRatio] = useState(getPlatformDefaultRatio(defaultEcommercePlatform, defaultCloneOutput));
const [status, setStatus] = useState<ProductCloneStatus>("idle");
const [results, setResults] = useState<CloneResult[]>([]);
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
const [ecommerceHistoryRecords, setEcommerceHistoryRecords] = useState<EcommerceHistoryRecord[]>(() => readEcommerceHistoryRecords());
const [activeHistoryRecordId, setActiveHistoryRecordId] = useState<string | null>(null);
const [historyRefreshTick, setHistoryRefreshTick] = useState(0);
const [isHistoryRefreshing, setIsHistoryRefreshing] = useState(false);
const [historyRefreshMessage, setHistoryRefreshMessage] = useState("");
const [historyRefreshStamp, setHistoryRefreshStamp] = useState(0);
const historyRefreshLockRef = useRef(false);
const lastSavedHistorySignatureRef = useRef("");
const imageAbortRef = useRef({ current: false });
const activeHistoryTurnIdRef = useRef<string | null>(null);
const activeEcommerceTaskIdsRef = useRef<Set<string>>(new Set());
const lastFailedActionRef = useRef<(() => void) | null>(null);
const [garmentImages, setGarmentImages] = useState<CloneImageItem[]>([]);
const [modelSource, setModelSource] = useState<TryOnModelSource>("ai");
const [modelGender, setModelGender] = useState(tryOnModelOptions.gender[0]);
const [modelAge, setModelAge] = useState(tryOnModelOptions.age[0]);
const [modelEthnicity, setModelEthnicity] = useState(tryOnModelOptions.ethnicity[0]);
const [modelBody, setModelBody] = useState(tryOnModelOptions.body[0]);
const [appearance, setAppearance] = useState("");
const [selectedScenes, setSelectedScenes] = useState<string[]>([]);
useEffect(() => {
if (status === "done") {
setIsCommandComposerCompact(true);
} else if (status === "generating" || status === "idle") {
setIsCommandComposerCompact(false);
}
}, [status]);
useEffect(() => {
writeEcommerceHistoryRecords(ecommerceHistoryRecords);
}, [ecommerceHistoryRecords]);
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 [detailRatio, setDetailRatio] = useState(getQuickSetRatioValue(getPlatformDefaultRatio(platformOptions[0], "detail")));
const [detailType, setDetailType] = useState(detailTypeOptions[0]);
const [detailRequirement, setDetailRequirement] = useState("");
const [selectedDetailModules, setSelectedDetailModules] = useState<string[]>(defaultDetailModuleIds);
const [detailStatus, setDetailStatus] = useState<DetailStatus>("idle");
const [detailResultUrl, setDetailResultUrl] = useState<string | null>(null);
const [detailProgress, setDetailProgress] = useState(0);
const [hotRequirement, setHotRequirement] = useState("");
const [isHotMaterialDragging, setIsHotMaterialDragging] = useState(false);
const [hotMaterialHoverZoom, setHotMaterialHoverZoom] = useState<{ src: string; x: number; y: number; placement: "above" | "below" } | null>(null);
const [hotPlatform, setHotPlatform] = useState(platformOptions[0]);
const [hotMarket, setHotMarket] = useState(marketOptions[0]);
const [hotLanguage, setHotLanguage] = useState(getPlatformDefaultLanguage(platformOptions[0], marketOptions[0]));
const [hotRatio, setHotRatio] = useState(getQuickSetRatioValue(getPlatformDefaultRatio(platformOptions[0], "detail")));
const [hotStatus, setHotStatus] = useState<DetailStatus>("idle");
const [hotResultUrl, setHotResultUrl] = useState<string | null>(null);
const [hotProgress, setHotProgress] = useState(0);
const productSetRatioOptions = useMemo(
() => getPlatformRatioOptions(productSetPlatform, productSetOutput),
[productSetOutput, productSetPlatform],
);
const baseCloneRatioOptions = useMemo(
() => getPlatformRatioOptions(platform, cloneOutput),
[cloneOutput, platform],
);
const cloneRatioOptions = baseCloneRatioOptions;
const productSetLanguageOptions = useMemo(
() => getPlatformLanguageOptions(productSetPlatform, productSetMarket),
[productSetMarket, productSetPlatform],
);
const cloneLanguageOptions = useMemo(
() => getPlatformLanguageOptions(platform, market),
[market, platform],
);
const detailLanguageOptions = useMemo(
() => getPlatformLanguageOptions(detailPlatform, detailMarket),
[detailMarket, detailPlatform],
);
const hotLanguageOptions = useMemo(
() => getPlatformLanguageOptions(hotPlatform, hotMarket),
[hotMarket, hotPlatform],
);
const ecommerceMentionImages: MentionImageOption[] = [
...productImages.map((image, index) => ({ ...image, label: `商品图 ${index + 1}` })),
...cloneReferenceImages.map((image, index) => ({ ...image, label: `参考图 ${index + 1}` })),
];
const ecommerceVideoImageDataUrls = useMemo(
() => productImages.map((img) => img.src),
[productImages],
);
const ecommerceVideoImageFiles = useMemo(
() => productImages.map((img) => img.file),
[productImages],
);
const selectedProductSetOutput =
productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!;
const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!;
const activeCommerceScenarioTemplates = activeCommerceScenario === "popular"
? popularCommerceScenarioTemplates
: commerceScenarioTemplates.filter((template) => template.scenario === activeCommerceScenario);
const cloneRequirementPlaceholder =
cloneOutput === "model"
? "建议包含以下信息:产品名称、核心卖点、期望场景、模特外貌描述(如小麦色皮肤、齐刘海、眼角有泪痣)、具体参数"
: "建议包含以下信息:产品名称、核心卖点、期望场景、具体参数";
const productSetPreviewReady = productSetStatus === "done";
const cloneSetTotal = useMemo(
() => Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0),
[cloneSetCounts],
);
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 canGenerateHot = cloneReferenceImages.length > 0 && hotStatus !== "generating";
const cloneVideoDurationProgress =
((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100;
const cloneVideoDurationStyle: CSSProperties = useMemo(
() => ({
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
}) as CSSProperties,
[cloneVideoDurationProgress],
);
const trackEcommerceTask = (taskId: string) => {
activeEcommerceTaskIdsRef.current.add(taskId);
};
const untrackEcommerceTask = (taskId: string) => {
activeEcommerceTaskIdsRef.current.delete(taskId);
};
const handleCancelGenerate = () => {
imageAbortRef.current.current = true;
const taskIds = Array.from(activeEcommerceTaskIdsRef.current);
activeEcommerceTaskIdsRef.current.clear();
taskIds.forEach((taskId) => {
aiGenerationClient.cancelTask(taskId).catch(() => {});
});
lastFailedActionRef.current = null;
if (productSetStatus === "generating") setProductSetStatus("idle");
if (status === "generating") {
setStatus("idle");
if (activeHistoryRecordId) {
const turnId = activeHistoryTurnIdRef.current;
if (turnId) {
updateLocalEcommerceHistoryTurn(activeHistoryRecordId, turnId, (turn) => ({
...turn,
status: "failed",
errorMessage: "已取消生成",
}));
} else {
updateLocalEcommerceHistoryRecord(activeHistoryRecordId, (record) => ({
...record,
status: "failed",
errorMessage: "已取消生成",
}));
}
}
}
if (detailStatus === "generating") setDetailStatus("idle");
if (tryOnStatus === "generating") setTryOnStatus("idle");
if (tryOnStatus === "modeling") setTryOnStatus("ready");
toast.info("\u5df2\u53d6\u6d88\u751f\u6210");
};
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 = async (files: File[]) => {
if (setImages.length >= 3) return;
const imageFiles = notifyRejectedImages(files);
if (!imageFiles.length) return;
try {
const nextImages = await createUploadedImageItems(imageFiles, 3 - setImages.length, "set");
setSetImages((current) => {
if (current.length >= 3) return current;
return nextImages.length ? [...current, ...nextImages].slice(0, 3) : current;
});
setProductSetStatus("ready");
} catch (err) {
toast.error(err instanceof Error ? err.message : "商品图上传失败");
}
};
const handleSetUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
void 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) void addSetImages(files);
};
const revokeSmartCutoutItem = (item: SmartCutoutImageItem | null) => {
if (!item) return;
URL.revokeObjectURL(item.src);
if (item.originalSrc && item.originalSrc !== item.src) URL.revokeObjectURL(item.originalSrc);
};
const revokeSmartCutoutItems = (items: SmartCutoutImageItem[]) => {
items.forEach(revokeSmartCutoutItem);
};
const clearSmartCutoutTransition = () => {
if (smartCutoutTransitionTimeoutRef.current !== null) {
window.clearTimeout(smartCutoutTransitionTimeoutRef.current);
smartCutoutTransitionTimeoutRef.current = null;
}
if (smartCutoutPendingUrlsRef.current.length) {
smartCutoutPendingUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
smartCutoutPendingUrlsRef.current = [];
}
setIsSmartCutoutTransitioning(false);
};
const runSmartCutoutPageTransition = (message: { title: string; subtitle: string }, action: () => void, delay = 460) => {
clearSmartCutoutTransition();
setSmartCutoutTransitionMessage(message);
setIsSmartCutoutTransitioning(true);
smartCutoutTransitionTimeoutRef.current = window.setTimeout(() => {
smartCutoutTransitionTimeoutRef.current = null;
action();
setIsSmartCutoutTransitioning(false);
}, delay);
};
const openSmartCutoutUpload = () => {
clearSmartCutoutTransition();
setComposerMenu(null);
toast.info("功能正在优化中");
};
const openWatermarkRemovalPage = () => {
clearSmartCutoutTransition();
setActiveQuickTool("watermark");
setComposerMenu(null);
setIsCloneSettingsCollapsed(false);
};
const loadRemoteImageFromInput = async (input: HTMLInputElement | null, fallbackName: string) => {
const rawValue = input?.value.trim() ?? "";
if (!rawValue) {
toast.info("请先粘贴图片 URL");
return null;
}
let imageUrl: string;
try {
const parsed = new URL(rawValue, window.location.href);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("仅支持 http 或 https 图片链接");
}
imageUrl = parsed.toString();
} catch (error) {
toast.error(error instanceof Error ? error.message : "图片 URL 不正确");
return null;
}
try {
const response = await fetch(imageUrl);
if (!response.ok) throw new Error(`图片读取失败(${response.status}`);
const blob = await response.blob();
if (!blob.type.startsWith("image/")) throw new Error("链接内容不是图片");
const src = URL.createObjectURL(blob);
try {
await readImageDimensions(src);
} catch {
URL.revokeObjectURL(src);
throw new Error("图片无法预览,请换一个链接");
}
if (input) input.value = "";
return {
src,
name: getRemoteImageName(imageUrl, fallbackName),
format: getRemoteImageFormat(blob.type, imageUrl),
};
} catch (error) {
toast.error(error instanceof Error ? error.message : "图片导入失败");
return null;
}
};
const closeWatermarkRemovalPage = () => {
stopWatermarkProgress();
setActiveQuickTool(null);
setWatermarkStatus("idle");
setWatermarkResultUrl(null);
setWatermarkProgress(0);
setWatermarkImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return null;
});
};
const addWatermarkImage = (file: File) => {
const nextImage = {
src: URL.createObjectURL(file),
name: file.name,
format: getImageFileFormat(file) || "PNG / JPG / WebP",
};
setWatermarkImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return nextImage;
});
setWatermarkStatus("idle");
setWatermarkResultUrl(null);
setWatermarkProgress(0);
setActiveQuickTool("watermark");
};
const removeWatermarkImage = () => {
stopWatermarkProgress();
setWatermarkStatus("idle");
setWatermarkResultUrl(null);
setWatermarkProgress(0);
setWatermarkImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return null;
});
};
const handleWatermarkUpload = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
addWatermarkImage(file);
event.target.value = "";
};
const handleWatermarkDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsWatermarkDragging(false);
const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/"));
if (file) addWatermarkImage(file);
};
const handleWatermarkUrlImport = async () => {
const nextImage = await loadRemoteImageFromInput(watermarkUrlInputRef.current, "watermark-source");
if (!nextImage) return;
setWatermarkImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return nextImage;
});
setWatermarkStatus("idle");
setActiveQuickTool("watermark");
toast.success("图片已导入");
};
const stopWatermarkProgress = () => {
if (watermarkProcessTimeoutRef.current !== null) {
window.clearInterval(watermarkProcessTimeoutRef.current);
watermarkProcessTimeoutRef.current = null;
}
};
const startWatermarkProgress = () => {
stopWatermarkProgress();
setWatermarkProgress(0);
watermarkProcessTimeoutRef.current = window.setInterval(() => {
setWatermarkProgress((prev) => {
if (prev >= 90) {
stopWatermarkProgress();
return 90;
}
return prev + (90 - prev) * 0.06;
});
}, 500);
};
const handleWatermarkGenerate = async () => {
if (!watermarkImage || watermarkStatus === "processing") return;
setWatermarkStatus("processing");
setWatermarkResultUrl(null);
startWatermarkProgress();
try {
const sourceBlob = await fetch(watermarkImage.src).then((res) => res.blob());
const sourceMime = normalizeEcommerceImageMime(sourceBlob.type || "image/png");
const { url: imageUrl } = await aiGenerationClient.uploadAssetBinary(sourceBlob, {
name: `watermark-source-${Date.now()}.png`,
mimeType: sourceMime,
scope: ecommerceOssScopes.productSource,
});
const { taskId } = await aiGenerationClient.createImageEditTask({
imageUrl,
function: "watermark-remove",
});
const resultUrl = await waitForTask(taskId, {
abortRef: { current: false },
onProgress: () => {},
});
if (resultUrl) {
const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult("watermark"), "ecommerce-watermark");
setWatermarkResultUrl(persistedUrl);
setWatermarkStatus("done");
stopWatermarkProgress();
setWatermarkProgress(100);
toast.success("去水印处理完成");
void saveUnifiedEcommerceGenerationRecord({
clientRecordId: crypto.randomUUID(),
title: `去水印 ${watermarkImage.name || ""}`.trim(),
mode: "watermark",
taskIds: [taskId],
sourceImages: [{ url: imageUrl, label: watermarkImage.name || "watermark-source" }],
results: [{ url: persistedUrl, label: "去水印结果", mediaType: "image", taskId }],
createdAt: new Date().toISOString(),
});
} else {
setWatermarkStatus("failed");
stopWatermarkProgress();
setWatermarkProgress(0);
toast.error("去水印未返回结果");
}
} catch (err) {
setWatermarkStatus("failed");
stopWatermarkProgress();
setWatermarkProgress(0);
if (err instanceof ServerRequestError && err.status === 402) {
toast.error("余额不足,请充值后继续");
} else {
toast.error(err instanceof Error ? err.message : "去水印失败");
}
}
};
const handleWatermarkDownload = () => {
if (!watermarkResultUrl || watermarkStatus !== "done") {
toast.info("请先完成去水印");
return;
}
const link = document.createElement("a");
const safeName = (watermarkImage?.name || "watermark-result").replace(/\.[^.]+$/, "").replace(/[\\/:*?"<>|]+/g, "-");
link.href = watermarkResultUrl;
link.download = `${safeName || "watermark-result"}-去水印.png`;
document.body.appendChild(link);
link.click();
link.remove();
};
const openImageTranslatePage = () => {
clearSmartCutoutTransition();
setComposerMenu(null);
toast.info("功能正在优化中");
};
const closeImageTranslatePage = () => {
if (translateProcessTimeoutRef.current !== null) {
window.clearTimeout(translateProcessTimeoutRef.current);
translateProcessTimeoutRef.current = null;
}
setActiveQuickTool(null);
setTranslateStatus("idle");
setTranslateResultUrl(null);
setTranslateImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return null;
});
};
const addTranslateImage = (file: File) => {
const nextImage = {
src: URL.createObjectURL(file),
name: file.name,
format: getImageFileFormat(file) || "PNG / JPG / WebP",
};
setTranslateImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return nextImage;
});
setTranslateStatus("idle");
setTranslateResultUrl(null);
setActiveQuickTool("translate");
};
const removeTranslateImage = () => {
if (translateProcessTimeoutRef.current !== null) {
window.clearTimeout(translateProcessTimeoutRef.current);
translateProcessTimeoutRef.current = null;
}
setTranslateStatus("idle");
setTranslateResultUrl(null);
setTranslateImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return null;
});
};
const handleTranslateUpload = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
addTranslateImage(file);
event.target.value = "";
};
const handleTranslateDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsTranslateDragging(false);
const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/"));
if (file) addTranslateImage(file);
};
const handleTranslateUrlImport = async () => {
const nextImage = await loadRemoteImageFromInput(translateUrlInputRef.current, "translate-source");
if (!nextImage) return;
setTranslateImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return nextImage;
});
setTranslateStatus("idle");
setTranslateResultUrl(null);
toast.success("图片已导入");
};
const handleTranslateGenerate = async () => {
if (!translateImage || translateStatus === "processing") return;
const targetLabel = translateLanguageOptions.find((option) => option.value === translateLanguage)?.label || "中文";
setTranslateStatus("processing");
setTranslateResultUrl(null);
try {
const sourceBlob = await fetch(translateImage.src).then((res) => res.blob());
const sourceMime = normalizeEcommerceImageMime(sourceBlob.type || "image/png");
const { url: imageUrl } = await aiGenerationClient.uploadAssetBinary(sourceBlob, {
name: `translate-source-${Date.now()}.png`,
mimeType: sourceMime,
scope: ecommerceOssScopes.productSource,
});
const prompt = `将图片中的所有文字翻译成${targetLabel},保持原有的排版、字体风格、位置和整体设计不变,只替换文字内容。`;
const { taskId } = await aiGenerationClient.createImageEditTask({
imageUrl,
function: "description_edit",
prompt,
});
const resultUrl = await waitForTask(taskId, {
kind: "image",
abortRef: { current: false },
onProgress: () => {},
});
if (resultUrl) {
const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult("translate"), "ecommerce-translate");
setTranslateResultUrl(persistedUrl);
setTranslateStatus("done");
toast.success("图片翻译完成");
void saveUnifiedEcommerceGenerationRecord({
clientRecordId: crypto.randomUUID(),
title: `图片翻译(${targetLabel} ${translateImage.name || ""}`.trim(),
mode: "translate",
prompt,
taskIds: [taskId],
sourceImages: [{ url: imageUrl, label: translateImage.name || "translate-source" }],
results: [{ url: persistedUrl, label: "翻译结果", mediaType: "image", taskId }],
config: { targetLanguage: translateLanguage },
createdAt: new Date().toISOString(),
});
} else {
setTranslateStatus("failed");
toast.error("翻译未返回结果");
}
} catch (err) {
setTranslateStatus("failed");
if (err instanceof ServerRequestError && err.status === 402) {
toast.error("余额不足,请充值后继续");
} else {
toast.error(err instanceof Error ? err.message : "图片翻译失败");
}
}
};
const handleTranslateDownload = () => {
if (!translateResultUrl || translateStatus !== "done") {
toast.info("请先完成图片翻译");
return;
}
const link = document.createElement("a");
const safeName = (translateImage?.name || "translate-result").replace(/\.[^.]+$/, "").replace(/[\\/:*?"<>|]+/g, "-");
link.href = translateResultUrl;
link.download = `${safeName || "translate-result"}-翻译.png`;
document.body.appendChild(link);
link.click();
link.remove();
};
const openImageWorkbenchPage = () => {
clearSmartCutoutTransition();
setActiveQuickTool("image-edit");
setComposerMenu(null);
setIsCloneSettingsCollapsed(false);
setImageWorkbenchStatus("idle");
};
const closeImageWorkbenchPage = () => {
setActiveQuickTool(null);
setImageWorkbenchStatus("idle");
setImageWorkbenchResultUrl(null);
setImageWorkbenchPrompt("");
setImageWorkbenchMaskStrokes([]);
setImageWorkbenchBrushCursor(null);
clearImageWorkbenchMaskCanvas();
imageWorkbenchMaskPaintingRef.current = false;
imageWorkbenchActiveStrokeIdRef.current = null;
setImageWorkbenchImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return null;
});
};
const addImageWorkbenchImage = (file: File) => {
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
return;
}
const nextImage = {
src: URL.createObjectURL(file),
name: file.name,
format: getImageFileFormat(file) || "PNG / JPG / WebP",
};
setImageWorkbenchImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return nextImage;
});
setImageWorkbenchStatus("idle");
setImageWorkbenchResultUrl(null);
setImageWorkbenchMaskStrokes([]);
setImageWorkbenchBrushCursor(null);
clearImageWorkbenchMaskCanvas();
imageWorkbenchActiveStrokeIdRef.current = null;
setActiveQuickTool("image-edit");
};
const removeImageWorkbenchImage = () => {
setImageWorkbenchStatus("idle");
setImageWorkbenchResultUrl(null);
setImageWorkbenchMaskStrokes([]);
setImageWorkbenchBrushCursor(null);
clearImageWorkbenchMaskCanvas();
imageWorkbenchMaskPaintingRef.current = false;
imageWorkbenchActiveStrokeIdRef.current = null;
setImageWorkbenchImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return null;
});
};
const handleImageWorkbenchUpload = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
addImageWorkbenchImage(file);
event.target.value = "";
};
const handleImageWorkbenchDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsImageWorkbenchDragging(false);
const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/"));
if (file) addImageWorkbenchImage(file);
};
const handleImageWorkbenchUrlImport = async () => {
const nextImage = await loadRemoteImageFromInput(imageWorkbenchUrlInputRef.current, "image-workbench-source");
if (!nextImage) return;
setImageWorkbenchImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return nextImage;
});
setImageWorkbenchStatus("idle");
setImageWorkbenchMaskStrokes([]);
setImageWorkbenchBrushCursor(null);
clearImageWorkbenchMaskCanvas();
imageWorkbenchActiveStrokeIdRef.current = null;
setActiveQuickTool("image-edit");
toast.success("图片已导入");
};
const stopWorkbenchProgress = () => {
if (imageWorkbenchProgressRef.current !== null) {
window.clearInterval(imageWorkbenchProgressRef.current);
imageWorkbenchProgressRef.current = null;
}
};
const startWorkbenchProgress = () => {
stopWorkbenchProgress();
setImageWorkbenchProgress(0);
imageWorkbenchProgressRef.current = window.setInterval(() => {
setImageWorkbenchProgress((prev) => {
if (prev >= 90) {
stopWorkbenchProgress();
return 90;
}
return prev + (90 - prev) * 0.06;
});
}, 500);
};
const exportWorkbenchMask = (): string | null => {
const canvas = imageWorkbenchMaskCanvasRef.current;
if (!canvas) return null;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
const w = canvas.width;
const h = canvas.height;
const maskCanvas = document.createElement("canvas");
maskCanvas.width = w;
maskCanvas.height = h;
const maskCtx = maskCanvas.getContext("2d")!;
maskCtx.fillStyle = "#000000";
maskCtx.fillRect(0, 0, w, h);
const imgData = ctx.getImageData(0, 0, w, h);
const maskData = maskCtx.getImageData(0, 0, w, h);
for (let i = 3; i < imgData.data.length; i += 4) {
if (imgData.data[i] > 0) {
const pi = i - 3;
maskData.data[pi] = 255;
maskData.data[pi + 1] = 255;
maskData.data[pi + 2] = 255;
maskData.data[pi + 3] = 255;
}
}
maskCtx.putImageData(maskData, 0, 0);
return maskCanvas.toDataURL("image/png");
};
const handleImageWorkbenchGenerate = async () => {
if (!imageWorkbenchImage) {
toast.info("请先上传图片");
return;
}
setImageWorkbenchStatus("processing");
setImageWorkbenchResultUrl(null);
startWorkbenchProgress();
try {
const sourceBlob = await fetch(imageWorkbenchImage.src).then((res) => res.blob());
const sourceMime = normalizeEcommerceImageMime(sourceBlob.type || "image/png");
const { url: imageUrl } = await aiGenerationClient.uploadAssetBinary(sourceBlob, {
name: `inpaint-source-${Date.now()}.png`,
mimeType: sourceMime,
scope: ecommerceOssScopes.productSource,
});
let maskUrl: string | undefined;
if (imageWorkbenchMaskStrokes.length > 0) {
const maskDataUrl = exportWorkbenchMask();
if (maskDataUrl) {
const { url } = await aiGenerationClient.uploadAsset({
dataUrl: maskDataUrl,
name: `inpaint-mask-${Date.now()}.png`,
mimeType: "image/png",
scope: ecommerceOssScopes.productSource,
});
maskUrl = url;
}
}
const { taskId } = await aiGenerationClient.createImageEditTask({
imageUrl,
function: "inpaint",
prompt: imageWorkbenchPrompt || undefined,
maskUrl,
ratio: imageWorkbenchRatio,
});
const resultUrl = await waitForTask(taskId, {
abortRef: { current: false },
onProgress: () => {},
});
if (resultUrl) {
const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult("inpaint"), "ecommerce-inpaint");
setImageWorkbenchResultUrl(persistedUrl);
setImageWorkbenchStatus("done");
stopWorkbenchProgress();
setImageWorkbenchProgress(100);
toast.success("局部重绘已完成");
void saveUnifiedEcommerceGenerationRecord({
clientRecordId: crypto.randomUUID(),
title: imageWorkbenchPrompt.trim() || `图片修改 ${imageWorkbenchImage.name || ""}`.trim(),
mode: "inpaint",
prompt: imageWorkbenchPrompt || undefined,
taskIds: [taskId],
sourceImages: [{ url: imageUrl, label: imageWorkbenchImage.name || "inpaint-source" }],
results: [{ url: persistedUrl, label: "局部重绘结果", mediaType: "image", taskId }],
config: { ratio: imageWorkbenchRatio },
createdAt: new Date().toISOString(),
});
} else {
setImageWorkbenchStatus("failed");
stopWorkbenchProgress();
setImageWorkbenchProgress(0);
toast.error("重绘未返回结果");
}
} catch (err) {
setImageWorkbenchStatus("failed");
stopWorkbenchProgress();
setImageWorkbenchProgress(0);
if (err instanceof ServerRequestError && err.status === 402) {
toast.error("余额不足,请充值后继续");
} else {
toast.error(err instanceof Error ? err.message : "重绘失败");
}
}
};
const syncImageWorkbenchMaskCanvas = () => {
const canvas = imageWorkbenchMaskCanvasRef.current;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const width = Math.max(1, Math.round(rect.width * dpr));
const height = Math.max(1, Math.round(rect.height * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
const context = canvas.getContext("2d");
if (!context) return null;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.lineCap = "round";
context.lineJoin = "round";
context.strokeStyle = "rgba(30, 189, 219, 0.52)";
context.fillStyle = "rgba(30, 189, 219, 0.52)";
context.lineWidth = imageWorkbenchBrushSize;
return { canvas, context, rect };
};
const clearImageWorkbenchMaskCanvas = () => {
const canvas = imageWorkbenchMaskCanvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, canvas.width, canvas.height);
};
const getImageWorkbenchMaskPoint = (event: ReactPointerEvent<HTMLElement>) => {
const canvas = imageWorkbenchMaskCanvasRef.current;
const rect = (canvas ?? event.currentTarget).getBoundingClientRect();
const x = clampNumber(event.clientX - rect.left, 0, rect.width);
const y = clampNumber(event.clientY - rect.top, 0, rect.height);
return { x, y };
};
const appendImageWorkbenchMaskPoint = (event: ReactPointerEvent<HTMLElement>) => {
const point = getImageWorkbenchMaskPoint(event);
const rect = imageWorkbenchMaskCanvasRef.current?.getBoundingClientRect();
setImageWorkbenchBrushCursor(rect ? { x: (point.x / Math.max(rect.width, 1)) * 100, y: (point.y / Math.max(rect.height, 1)) * 100 } : null);
setImageWorkbenchStatus((current) => (current === "done" ? "idle" : current));
const activeStrokeId = imageWorkbenchActiveStrokeIdRef.current;
if (!activeStrokeId) return;
const lastPoint = imageWorkbenchLastMaskPointRef.current;
if (lastPoint && Math.hypot(point.x - lastPoint.x, point.y - lastPoint.y) < 1.5) return;
const synced = syncImageWorkbenchMaskCanvas();
if (!synced) return;
synced.context.beginPath();
synced.context.moveTo(lastPoint?.x ?? point.x, lastPoint?.y ?? point.y);
synced.context.lineTo(point.x, point.y);
synced.context.stroke();
imageWorkbenchLastMaskPointRef.current = point;
};
const handleImageWorkbenchMaskPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!imageWorkbenchImage || event.button !== 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
syncImageWorkbenchMaskCanvas();
const point = getImageWorkbenchMaskPoint(event);
const rect = imageWorkbenchMaskCanvasRef.current?.getBoundingClientRect();
const strokeId = `mask-${Date.now()}`;
imageWorkbenchMaskPaintingRef.current = true;
imageWorkbenchActiveStrokeIdRef.current = strokeId;
imageWorkbenchLastMaskPointRef.current = point;
setImageWorkbenchBrushCursor(rect ? { x: (point.x / Math.max(rect.width, 1)) * 100, y: (point.y / Math.max(rect.height, 1)) * 100 } : null);
setImageWorkbenchStatus((current) => (current === "done" ? "idle" : current));
setImageWorkbenchMaskStrokes((current) => [...current, { id: strokeId, size: imageWorkbenchBrushSize, points: [] }]);
const synced = syncImageWorkbenchMaskCanvas();
if (synced) {
synced.context.beginPath();
synced.context.arc(point.x, point.y, imageWorkbenchBrushSize / 2, 0, Math.PI * 2);
synced.context.fill();
}
};
const handleImageWorkbenchMaskPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!imageWorkbenchImage) return;
const point = getImageWorkbenchMaskPoint(event);
const rect = imageWorkbenchMaskCanvasRef.current?.getBoundingClientRect();
setImageWorkbenchBrushCursor(rect ? { x: (point.x / Math.max(rect.width, 1)) * 100, y: (point.y / Math.max(rect.height, 1)) * 100 } : null);
if (!imageWorkbenchMaskPaintingRef.current) return;
appendImageWorkbenchMaskPoint(event);
};
const stopImageWorkbenchMaskPainting = () => {
imageWorkbenchMaskPaintingRef.current = false;
imageWorkbenchActiveStrokeIdRef.current = null;
imageWorkbenchLastMaskPointRef.current = null;
};
const clearQuickSetSelectTimer = () => {
if (quickSetSelectTimerRef.current) {
window.clearTimeout(quickSetSelectTimerRef.current);
quickSetSelectTimerRef.current = null;
}
};
const resetQuickSetSelectState = () => {
clearQuickSetSelectTimer();
openQuickSetSelectRef.current = null;
visibleQuickSetSelectRef.current = null;
setOpenQuickSetSelect(null);
setVisibleQuickSetSelect(null);
setIsQuickSetSelectClosing(false);
};
const closeQuickSetSelect = () => {
if (!visibleQuickSetSelectRef.current) {
openQuickSetSelectRef.current = null;
setOpenQuickSetSelect(null);
return;
}
clearQuickSetSelectTimer();
openQuickSetSelectRef.current = null;
setOpenQuickSetSelect(null);
setIsQuickSetSelectClosing(true);
quickSetSelectTimerRef.current = window.setTimeout(() => {
visibleQuickSetSelectRef.current = null;
setVisibleQuickSetSelect(null);
setIsQuickSetSelectClosing(false);
quickSetSelectTimerRef.current = null;
}, 420);
};
const showQuickSetSelect = (key: CloneBasicSelectKey) => {
clearQuickSetSelectTimer();
if (visibleQuickSetSelectRef.current && visibleQuickSetSelectRef.current !== key && openQuickSetSelectRef.current) {
openQuickSetSelectRef.current = null;
setOpenQuickSetSelect(null);
setIsQuickSetSelectClosing(true);
quickSetSelectTimerRef.current = window.setTimeout(() => {
visibleQuickSetSelectRef.current = key;
openQuickSetSelectRef.current = key;
setVisibleQuickSetSelect(key);
setOpenQuickSetSelect(key);
setIsQuickSetSelectClosing(false);
quickSetSelectTimerRef.current = null;
}, 180);
return;
}
visibleQuickSetSelectRef.current = key;
openQuickSetSelectRef.current = key;
setVisibleQuickSetSelect(key);
setOpenQuickSetSelect(key);
setIsQuickSetSelectClosing(false);
};
const toggleQuickSetSelect = (key: CloneBasicSelectKey) => {
if (openQuickSetSelectRef.current === key) {
closeQuickSetSelect();
return;
}
showQuickSetSelect(key);
};
const openQuickDetailPage = () => {
clearSmartCutoutTransition();
setActiveQuickTool("detail");
setComposerMenu(null);
setIsCloneSettingsCollapsed(false);
setIsQuickPanelCollapsed(false);
setPreviewZoom(1);
resetQuickSetSelectState();
if (!selectedDetailModules.length) setSelectedDetailModules(defaultCloneDetailModuleIds);
};
const openHotClonePage = () => {
clearSmartCutoutTransition();
setActiveQuickTool("hot");
setComposerMenu(null);
setIsCloneSettingsCollapsed(false);
setIsQuickPanelCollapsed(false);
setPreviewZoom(1);
resetQuickSetSelectState();
};
const closeSmartCutoutTool = () => {
runSmartCutoutPageTransition(
{
title: "正在返回首页",
subtitle: "回到电商智能体",
},
() => {
setSmartCutoutBatchImages((current) => {
revokeSmartCutoutItems(current);
return [];
});
setSmartCutoutImage((current) => {
revokeSmartCutoutItem(current);
return null;
});
setIsSmartCutoutComparing(false);
setActiveQuickTool(null);
setComposerMenu(null);
},
);
};
const goSmartCutoutPrevious = () => {
if (!smartCutoutImage) {
closeSmartCutoutTool();
return;
}
runSmartCutoutPageTransition(
{
title: "正在返回上一页",
subtitle: "回到图片上传页",
},
() => {
setSmartCutoutBatchImages((current) => {
revokeSmartCutoutItems(current);
return [];
});
setSmartCutoutImage((current) => {
revokeSmartCutoutItem(current);
return null;
});
setIsSmartCutoutComparing(false);
},
);
};
const addSmartCutoutImage = (files: File[]) => {
const imageFiles = files.filter((file) => file.type.startsWith("image/"));
if (!imageFiles.length) {
toast.error("请上传图片文件");
return;
}
clearSmartCutoutTransition();
setSmartCutoutBatchImages((current) => {
revokeSmartCutoutItems(current);
return [];
});
setSmartCutoutImage((current) => {
revokeSmartCutoutItem(current);
return null;
});
setIsSmartCutoutComparing(false);
const nextImages = imageFiles.map((file) => {
const originalSrc = URL.createObjectURL(file);
return { src: originalSrc, originalSrc, name: file.name };
});
smartCutoutPendingUrlsRef.current = nextImages.map((item) => item.src);
setActiveQuickTool("cutout");
setSmartCutoutSizeKey("original");
setSmartCutoutTransitionMessage({
title: imageFiles.length > 1 ? "正在批量抠图" : "正在智能抠图",
subtitle: imageFiles.length > 1 ? `正在处理 ${imageFiles.length} 张图片` : "即将进入图片编辑室",
});
setIsSmartCutoutTransitioning(true);
smartCutoutTransitionTimeoutRef.current = window.setTimeout(() => {
smartCutoutTransitionTimeoutRef.current = null;
smartCutoutPendingUrlsRef.current = [];
setSmartCutoutBatchImages(nextImages);
setSmartCutoutImage(nextImages[0]);
setIsSmartCutoutTransitioning(false);
}, 620);
};
const handleSmartCutoutUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
addSmartCutoutImage(Array.from(files));
event.target.value = "";
};
const handleSmartCutoutDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsSmartCutoutDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) addSmartCutoutImage(files);
};
const smartCutoutBackgroundValue = useMemo(() => {
const rgb = hexToRgb(smartCutoutBackgroundColor) ?? { r: 255, g: 255, b: 255 };
if (smartCutoutBackgroundAlpha >= 100) return smartCutoutBackgroundColor;
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${Math.round(smartCutoutBackgroundAlpha) / 100})`;
}, [smartCutoutBackgroundAlpha, smartCutoutBackgroundColor]);
const smartCutoutColorHsv = useMemo(() => hexToHsv(smartCutoutBackgroundColor), [smartCutoutBackgroundColor]);
const selectedSmartCutoutSize = useMemo(
() => smartCutoutSizeOptions.find((option) => option.key === smartCutoutSizeKey) ?? smartCutoutSizeOptions[0],
[smartCutoutSizeKey],
);
const previewSmartCutoutSize = isSmartCutoutComparing ? smartCutoutSizeOptions[0] : selectedSmartCutoutSize;
const previewSmartCutoutSizeKey = isSmartCutoutComparing ? "original" : smartCutoutSizeKey;
const previewSmartCutoutImageSrc = isSmartCutoutComparing ? smartCutoutImage?.originalSrc ?? smartCutoutImage?.src : smartCutoutImage?.src;
const smartCutoutFrameStyle = useMemo<CSSProperties>(
() => ({
"--smart-cutout-bg": smartCutoutBackgroundValue,
"--smart-cutout-frame-width": previewSmartCutoutSize.frameWidth,
"--smart-cutout-frame-aspect": previewSmartCutoutSize.frameAspect,
"--smart-cutout-image-max-width": previewSmartCutoutSize.imageMaxWidth,
"--smart-cutout-image-max-height": previewSmartCutoutSize.imageMaxHeight,
} as CSSProperties),
[previewSmartCutoutSize, smartCutoutBackgroundValue],
);
const showSmartCutoutOriginalCompare = (event: ReactPointerEvent<HTMLButtonElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
setIsSmartCutoutComparing(true);
};
const hideSmartCutoutOriginalCompare = () => {
setIsSmartCutoutComparing(false);
};
const applySmartCutoutHsv = (h: number, s: number, v: number) => {
const rgb = hsvToRgb(h, s, v);
setSmartCutoutBackgroundColor(rgbToHex(rgb.r, rgb.g, rgb.b));
};
const updateSmartCutoutColorFromPoint = (element: HTMLElement, clientX: number, clientY: number) => {
const rect = element.getBoundingClientRect();
const saturation = clampNumber(((clientX - rect.left) / rect.width) * 100, 0, 100);
const value = clampNumber(100 - ((clientY - rect.top) / rect.height) * 100, 0, 100);
applySmartCutoutHsv(smartCutoutColorHsv.h, saturation, value);
};
const handleSmartCutoutColorPlanePointer = (event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
updateSmartCutoutColorFromPoint(event.currentTarget, event.clientX, event.clientY);
};
const handleSmartCutoutColorPlaneMove = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (event.buttons !== 1) return;
updateSmartCutoutColorFromPoint(event.currentTarget, event.clientX, event.clientY);
};
const handleSmartCutoutHexChange = (value: string) => {
const nextValue = value.startsWith("#") ? value : `#${value}`;
if (!/^#[0-9a-fA-F]{0,6}$/.test(nextValue)) return;
setSmartCutoutHexDraft(nextValue);
const normalized = normalizeHexColor(nextValue);
if (normalized) setSmartCutoutBackgroundColor(normalized);
};
const scrollSmartCutoutTools = (direction: -1 | 1) => {
smartCutoutToolsRef.current?.scrollBy({
left: direction * 340,
behavior: "smooth",
});
};
const handleSmartCutoutDownload = async () => {
if (!smartCutoutImage) {
toast.error("请先上传图片");
return;
}
try {
const image = new Image();
image.decoding = "async";
const imageLoaded = new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error("图片加载失败"));
});
image.src = smartCutoutImage.src;
await imageLoaded;
const aspect = parseSmartCutoutAspect(selectedSmartCutoutSize.frameAspect);
const naturalWidth = Math.max(1, image.naturalWidth || image.width || 1200);
const naturalHeight = Math.max(1, image.naturalHeight || image.height || 900);
const outputWidth = "outputWidth" in selectedSmartCutoutSize ? selectedSmartCutoutSize.outputWidth : undefined;
const outputHeight = "outputHeight" in selectedSmartCutoutSize ? selectedSmartCutoutSize.outputHeight : undefined;
let canvasWidth = naturalWidth;
let canvasHeight = naturalHeight;
if (outputWidth && outputHeight) {
canvasWidth = outputWidth;
canvasHeight = outputHeight;
} else if (aspect) {
const longSide = 1600;
if (aspect >= 1) {
canvasWidth = longSide;
canvasHeight = Math.round(longSide / aspect);
} else {
canvasHeight = longSide;
canvasWidth = Math.round(longSide * aspect);
}
} else {
const maxSide = 1600;
const scale = Math.min(1, maxSide / Math.max(naturalWidth, naturalHeight));
canvasWidth = Math.max(1, Math.round(naturalWidth * scale));
canvasHeight = Math.max(1, Math.round(naturalHeight * scale));
}
const canvas = document.createElement("canvas");
canvas.width = canvasWidth;
canvas.height = canvasHeight;
const context = canvas.getContext("2d");
if (!context) throw new Error("无法生成图片");
context.clearRect(0, 0, canvasWidth, canvasHeight);
context.fillStyle = smartCutoutBackgroundValue;
context.fillRect(0, 0, canvasWidth, canvasHeight);
const maxWidthRatio = parseSmartCutoutPercent(selectedSmartCutoutSize.imageMaxWidth, 0.82);
const maxHeightRatio = parseSmartCutoutPercent(selectedSmartCutoutSize.imageMaxHeight, 0.82);
const fitScale = Math.min((canvasWidth * maxWidthRatio) / naturalWidth, (canvasHeight * maxHeightRatio) / naturalHeight, 1);
const drawWidth = Math.round(naturalWidth * fitScale);
const drawHeight = Math.round(naturalHeight * fitScale);
const drawX = Math.round((canvasWidth - drawWidth) / 2);
const drawY = Math.round((canvasHeight - drawHeight) / 2);
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
if (!blob) throw new Error("图片导出失败");
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
const safeName = (smartCutoutImage.name || "smart-cutout").replace(/\.[^.]+$/, "").replace(/[\\/:*?"<>|]+/g, "-");
link.href = objectUrl;
link.download = `${safeName || "smart-cutout"}-${selectedSmartCutoutSize.label}.png`;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
toast.success("已下载图片");
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载图片失败");
}
};
useEffect(() => {
setSmartCutoutHexDraft(smartCutoutBackgroundColor);
}, [smartCutoutBackgroundColor]);
useEffect(() => {
if (!isSmartCutoutPaletteOpen) return undefined;
const handlePointerDown = (event: PointerEvent) => {
if (!smartCutoutPaletteRef.current?.contains(event.target as Node)) {
setIsSmartCutoutPaletteOpen(false);
}
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [isSmartCutoutPaletteOpen]);
const removeSetImage = (imageId: string) => {
setSetImages((current) => {
const next = current.filter((item) => item.id !== imageId);
if (next.length === 0) setProductSetStatus("idle");
return next;
});
};
const addProductImages = async (files: File[]) => {
const imageFiles = notifyRejectedImages(files);
if (!imageFiles.length) return;
try {
const nextImages = await createUploadedImageItems(imageFiles, maxCloneProductImages - productImages.length, "product");
setProductImages((current) => {
if (current.length >= maxCloneProductImages) return current;
return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneProductImages) : current;
});
setStatus("ready");
setResults([]);
} catch (err) {
toast.error(err instanceof Error ? err.message : "商品图上传失败");
}
};
const handleProductUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
void addProductImages(Array.from(files));
event.target.value = "";
};
const addComposerAssets = (files: File[]) => {
const imageFiles = files.filter((file) => file.type.startsWith("image/"));
const unsupportedCount = files.length - imageFiles.length;
if (imageFiles.length) void addProductImages(imageFiles);
if (unsupportedCount > 0) toast.info("仅支持上传图片文件");
};
const handleComposerAssetUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
addComposerAssets(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) void 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 = async (files: File[]) => {
const imageFiles = notifyRejectedImages(files);
if (!imageFiles.length) return;
const remainingSlots = maxCloneReferenceImages - cloneReferenceImages.length;
if (remainingSlots <= 0) return;
try {
const nextImages = await createUploadedImageItems(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);
} catch (err) {
toast.error(err instanceof Error ? err.message : "参考图上传失败");
}
};
const removeCloneReferenceImage = (imageId: string) => {
setCloneReferenceImages((current) => {
const next = current.filter((item) => item.id !== imageId);
if (next.length === 0) {
setHotStatus("idle");
setHotResultUrl(null);
}
return next;
});
};
const handleCloneReferenceUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
void addCloneReferenceImages(Array.from(files));
event.target.value = "";
};
const [isCloneReferenceDragging, setIsCloneReferenceDragging] = useState(false);
const handleCloneReferenceDragOver = (event: DragEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer.types.includes("Files")) {
setIsCloneReferenceDragging(true);
}
};
const handleCloneReferenceDragLeave = (event: DragEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) {
setIsCloneReferenceDragging(false);
}
};
const handleCloneReferenceDrop = (event: DragEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setIsCloneReferenceDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) addCloneReferenceImages(files);
};
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 = () => {
window.removeEventListener("pointerup", clearCloneSetCountHold);
window.removeEventListener("pointercancel", 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) => {
if (current.includes(moduleId)) return current.filter((item) => item !== moduleId);
if (current.length >= maxDetailModuleSelection) {
toast.info(`最多选择 ${maxDetailModuleSelection} 个模块`);
return current;
}
return [...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) =>
normalizeRatioForPlatform(normalizedPlatform, current, cloneOutput),
);
setLanguage(getPlatformDefaultLanguage(normalizedPlatform, market));
};
const handleCloneOutputChange = (nextOutput: CloneOutputKey) => {
setCloneOutput(nextOutput);
setIsCloneTemplateStripVisible(true);
if (nextOutput !== "video") setIsVideoWorkspaceVisible(false);
setRatio((current) =>
normalizeRatioForPlatform(platform, current, nextOutput),
);
};
const handleCommerceScenarioClick = (nextScenario: CommerceScenarioKey) => {
if (nextScenario === activeCommerceScenario) {
setIsCloneTemplateStripVisible((visible) => !visible);
setComposerMenu(null);
return;
}
setActiveCommerceScenario(nextScenario);
setIsCloneTemplateStripVisible(true);
setComposerMenu(null);
if (nextScenario === "popular") return;
const mappedOutput = commerceScenarioOutputMap[nextScenario];
if (mappedOutput !== cloneOutput) handleCloneOutputChange(mappedOutput);
};
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));
setDetailRatio((current) => getQuickSetRatioValue(current));
};
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 latestCloneSettingSnapshot = useMemo(
() => createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest"),
[
cloneOutput,
platform,
market,
language,
ratio,
cloneSetCounts,
selectedCloneDetailModules,
cloneModelPanelTab,
selectedCloneModelScenes,
cloneModelCustomScene,
cloneModelGender,
cloneModelAge,
cloneModelEthnicity,
cloneModelBody,
cloneModelAppearance,
cloneVideoQuality,
cloneVideoDuration,
cloneVideoSmart,
cloneReferenceMode,
cloneReplicateLevel,
requirement,
cloneSettingName,
],
);
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 : defaultCloneOutput;
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).slice(0, maxDetailModuleSelection));
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 = latestCloneSettingSnapshot;
}, [latestCloneSettingSnapshot]);
useEffect(() => {
window.localStorage.removeItem(cloneLatestSettingStorageKey);
}, []);
useEffect(() => {
setProductSetRatio((current) => normalizeRatioForPlatform(productSetPlatform, current, productSetOutput));
}, [productSetOutput, productSetPlatform]);
useEffect(() => {
setRatio((current) => {
const platformRatios = getPlatformRatioOptions(platform, cloneOutput);
if (platformRatios.includes(current)) return current;
const normalizedRatio = normalizeRatioToken(current);
const matchedRatio = platformRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio));
return matchedRatio ?? getPlatformDefaultRatio(platform, cloneOutput);
});
}, [cloneOutput, 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 (!composerMenu && !(status === "done" && !isCommandComposerCompact)) return undefined;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
const composer = commandComposerWrapRef.current;
if (composer?.contains(target)) return;
if (composerMenu && composerMenu !== "settings") setComposerMenu(null);
if (status === "done" && !isCommandComposerCompact) setIsCommandComposerCompact(true);
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [composerMenu, isCommandComposerCompact, status]);
useEffect(() => {
if (composerMenuCloseTimeoutRef.current !== null) {
window.clearTimeout(composerMenuCloseTimeoutRef.current);
composerMenuCloseTimeoutRef.current = null;
}
if (composerMenu) {
setVisibleComposerMenu(composerMenu);
setIsComposerMenuClosing(false);
return;
}
if (!visibleComposerMenu) return;
setIsComposerMenuClosing(true);
composerMenuCloseTimeoutRef.current = window.setTimeout(() => {
composerMenuCloseTimeoutRef.current = null;
setVisibleComposerMenu(null);
setIsComposerMenuClosing(false);
}, 220);
}, [composerMenu, visibleComposerMenu]);
useEffect(
() => () => {
if (composerMenuCloseTimeoutRef.current !== null) {
window.clearTimeout(composerMenuCloseTimeoutRef.current);
}
},
[],
);
useEffect(() => {
if (!openCloneModelSelect) return undefined;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Element) || target.closest("[data-clone-model-select]")) return;
setOpenCloneModelSelect(null);
setCloneModelSelectDropUp(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpenCloneModelSelect(null);
setCloneModelSelectDropUp(false);
}
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [openCloneModelSelect]);
const handleGarmentUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
const uploadedFiles = notifyRejectedImages(Array.from(files));
if (!uploadedFiles.length) {
event.target.value = "";
return;
}
void (async () => {
try {
const nextImages = await createUploadedImageItems(uploadedFiles, 5 - garmentImages.length, "garment");
setGarmentImages((current) => [...current, ...nextImages].slice(0, 5));
setTryOnStatus("ready");
} catch (err) {
toast.error(err instanceof Error ? err.message : "服饰图上传失败");
}
})();
event.target.value = "";
};
const handleDetailUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
void addDetailImages(Array.from(files));
event.target.value = "";
};
const handleDetailDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
if (files.length) void addDetailImages(files);
};
const removeDetailImage = (imageId: string) => {
setDetailProductImages((current) => {
const next = current.filter((item) => item.id !== imageId);
if (next.length === 0) {
setDetailStatus("idle");
setDetailResultUrl(null);
}
return next;
});
};
const blobToDataUrl = (blob: Blob): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
reader.readAsDataURL(blob);
});
const addDetailImages = async (files: File[]) => {
const uploadedFiles = notifyRejectedImages(files);
if (!uploadedFiles.length) return;
try {
const nextImages = await createUploadedImageItems(uploadedFiles, 3 - detailProductImages.length, "detail");
setDetailProductImages((current) => {
if (current.length >= 3) return current;
return nextImages.length ? [...current, ...nextImages].slice(0, 3) : current;
});
setDetailStatus("ready");
setDetailResultUrl(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : "详情图上传失败");
}
};
const uploadCloneImages = async (images: CloneImageItem[]): Promise<string[]> => {
const urls: string[] = [];
for (const item of images) {
try {
if (!item.file && item.src.startsWith("blob:")) {
throw new Error("本地预览图缺少原始文件,无法上传");
}
const rawBlob = item.file ?? (item.src.startsWith("data:") ? null : await (await fetch(item.src)).blob());
const mimeType = normalizeEcommerceImageMime(
rawBlob?.type || item.src.match(/^data:([^;,]+)/)?.[1] || "image/png",
);
const blob = rawBlob ? (rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType })) : null;
const dataUrl = item.src.startsWith("data:") ? item.src : await blobToDataUrl(blob!);
const { url } = await aiGenerationClient.uploadAsset({ dataUrl, name: item.name, mimeType, scope: ecommerceOssScopes.productSource });
urls.push(url);
} catch {
// skip images that fail to upload
}
}
return urls;
};
const setCountLabels: Record<CloneSetCountKey, { label: string; promptDesc: string }> = {
selling: { label: "卖点图", promptDesc: "selling-point infographic image highlighting core product advantages and detail close-ups" },
white: { label: "白底图", promptDesc: "clean white-background product photo showing the item from its best angle, studio lighting, no props" },
scene: { label: "场景图", promptDesc: "lifestyle scene image showing the product in a realistic usage environment with natural surroundings" },
};
const buildDetailModulePrompt = (moduleIds: string[]): string => {
if (!moduleIds.length) {
return "Generate a complete A+ detail layout with hero, selling points, usage scene, product detail, and specification modules.";
}
const selectedModules = cloneDetailModules.filter((module) => moduleIds.includes(module.id));
if (!selectedModules.length) return "";
const moduleList = selectedModules.map((module) => `${module.title}: ${module.desc}`).join("; ");
return `Only generate these selected A+ detail modules, no extra modules: ${moduleList}. Keep the output focused even if only one or two modules are selected.`;
};
const buildSetSubPrompt = (countKey: CloneSetCountKey, index: number, totalCount: number, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string): string => {
const info = setCountLabels[countKey];
const parts: string[] = [];
parts.push(`Generate an e-commerce ${info.label.toLowerCase()} for a product listing.`);
parts.push(info.promptDesc);
if (countKey === "white") {
parts.push("The output must be a clean white-background product image. Do not use lifestyle backgrounds, props, text overlays, or people.");
}
if (countKey === "scene") {
parts.push("The output must be a realistic usage scene image. Keep the product clearly visible and preserve its shape, color, and key details.");
}
if (countKey === "selling") {
parts.push("The output must be a selling-point graphic with clear hierarchy, concise copy, and product detail callouts.");
}
if (totalCount > 1) {
parts.push(`This is variant ${index + 1} of ${totalCount} —vary the angle, composition, or emphasis to make each distinct.`);
}
parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`);
parts.push("Must comply with platform image guidelines —proper margins, no watermark, professional quality.");
return parts.join(" ");
};
const buildEcommerceImagePrompt = (
outputKey: CloneOutputKey, userText: string,
pPlatform: string, pRatio: string, pLanguage: string, pMarket: string,
tryOnOptions?: EcommerceImagePromptOptions,
): string => {
const parts: string[] = [];
if (outputKey === "detail") {
parts.push("Generate a professional A+ detail page hero image for an e-commerce product listing.");
parts.push("Create a high-impact first-screen visual that combines the product photo with key selling points, usage scenes, and detailed specifications in a cohesive layout.");
parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`);
if (outputKey === "detail" && tryOnOptions?.detailModules) parts.push(buildDetailModulePrompt(tryOnOptions.detailModules));
parts.push("Follow platform A+ page best practices —clear hierarchy, professional typography, high visual impact.");
} else if (outputKey === "model") {
parts.push("Generate model/try-on lifestyle images for an e-commerce product listing.");
parts.push("Show the product being used or worn by a model in attractive lifestyle settings.");
parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`);
if (tryOnOptions) {
if (tryOnOptions.gender) parts.push(`Model gender: ${tryOnOptions.gender}.`);
if (tryOnOptions.age) parts.push(`Model age: ${tryOnOptions.age}.`);
if (tryOnOptions.ethnicity) parts.push(`Model ethnicity: ${tryOnOptions.ethnicity}.`);
if (tryOnOptions.body) parts.push(`Model body type: ${tryOnOptions.body}.`);
if (tryOnOptions.appearance) parts.push(`Model appearance details: ${tryOnOptions.appearance}.`);
if (tryOnOptions.scenes?.length) parts.push(`Background scenes: ${tryOnOptions.scenes.join(", ")}.`);
if (tryOnOptions.customScene) parts.push(`Custom background scene: ${tryOnOptions.customScene}.`);
if (tryOnOptions.smartScene) parts.push("Use smart scene matching to select the best background context.");
}
parts.push("Model should appear natural and appealing. Background should complement the product. Image must meet platform standards.");
} else if (outputKey === "hot") {
parts.push("Generate a high-conversion e-commerce product image that closely replicates the style and composition of the reference image while adapting it to the target platform.");
parts.push(`Replicate the visual style, color palette, and layout feel of the source product image, then adapt it for ${pPlatform} marketplace standards.`);
parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`);
parts.push("The result must look professional and optimized for high click-through rate and conversion on the specified platform.");
}
if (userText.trim()) {
parts.push(`Additional user requirements: ${userText.trim()}`);
}
return parts.join(" ");
};
const generateSetImages = async (
images: CloneImageItem[],
counts: Record<CloneSetCountKey, number>,
userText: string,
pPlatform: string,
pRatio: string,
pLanguage: string,
pMarket: string,
setStatusFn: (status: "generating" | "done" | "idle" | "failed") => void,
setResultFn: (urls: string[]) => void,
): Promise<void> => {
setStatusFn("generating");
try {
const referenceUrls = await uploadCloneImages(images);
if (!referenceUrls.length) {
setStatusFn("idle");
return;
}
if (imageAbortRef.current.current) {
setStatusFn("idle");
return;
}
const generatedUrls: string[] = [];
const stamp = Date.now();
const totalCount = Math.max(1, cloneSetCountKeys.reduce((sum, key) => sum + counts[key], 0));
let completedCount = 0;
setGenerationProgress(0);
for (const countKey of cloneSetCountKeys) {
if (imageAbortRef.current.current) break;
const count = counts[countKey];
for (let i = 0; i < count; i++) {
if (imageAbortRef.current.current) break;
const subPrompt = buildSetSubPrompt(countKey, i, count, pPlatform, pRatio, pLanguage, pMarket);
const fullPrompt = userText.trim() ? `${subPrompt} Additional user requirements: ${userText.trim()}` : subPrompt;
const { taskId } = await aiGenerationClient.createImageTask({
prompt: fullPrompt,
ratio: normalizeRatioForApi(pRatio),
quality: pRatio.includes("720") ? "720P" : "1080P",
gridMode: "single",
referenceUrls,
});
trackEcommerceTask(taskId);
const storeId = imageGen.submitTask({ title: `${setCountLabels[countKey].label} ${i + 1}`, type: "image", status: "running", progress: 5, prompt: fullPrompt, sourceView: "ecommerce", taskId });
let resultUrl: string | null = null;
try {
resultUrl = await waitForTask(taskId, {
kind: "image",
abortRef: imageAbortRef.current,
onProgress: (event) => {
// 整体进度 = (已完成张数 + 当前张子进度) / 总张数。
const sub = Math.max(0, Math.min(100, Number(event.progress) || 0));
const overall = ((completedCount + sub / 100) / totalCount) * 100;
setGenerationProgress(Math.round(Math.min(99, overall)));
},
});
} finally {
untrackEcommerceTask(taskId);
}
if (imageAbortRef.current.current) break;
if (resultUrl) {
const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult("set"), `ecommerce-${countKey}-${i + 1}`);
generatedUrls.push(persistedUrl);
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl });
} else {
generatedUrls.push("");
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
}
completedCount += 1;
setGenerationProgress(Math.round(Math.min(99, (completedCount / totalCount) * 100)));
}
}
if (imageAbortRef.current.current) {
setStatusFn("idle");
return;
}
setResultFn(generatedUrls);
setStatusFn(generatedUrls.some(Boolean) ? "done" : "failed");
} catch (err) {
if (imageAbortRef.current.current) {
setStatusFn("idle");
return;
}
if (err instanceof ServerRequestError && err.status === 402) {
setResultFn([]);
toast.error("余额不足,请充值后继续");
} else {
const msg = err instanceof Error ? err.message : "生成失败";
toast.error(msg);
}
setStatusFn("failed");
}
};
const generateEcommerceImage = async (
outputKey: CloneOutputKey,
images: CloneImageItem[],
userText: string,
pPlatform: string,
pRatio: string,
pLanguage: string,
pMarket: string,
tryOnOptions?: EcommerceImagePromptOptions,
statusFn?: (status: "generating" | "done" | "idle" | "failed") => void,
resultFn?: (results: CloneResult[]) => void,
): Promise<void> => {
statusFn?.("generating");
try {
const referenceUrls = await uploadCloneImages(images);
if (!referenceUrls.length) {
statusFn?.("idle");
return;
}
if (imageAbortRef.current.current) {
statusFn?.("idle");
return;
}
const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket, tryOnOptions);
const stamp = Date.now();
setGenerationProgress(0);
const { taskId } = await aiGenerationClient.createImageTask({
prompt,
ratio: normalizeRatioForApi(pRatio),
quality: pRatio.includes("720") ? "720P" : "1080P",
gridMode: "single",
referenceUrls,
});
trackEcommerceTask(taskId);
const storeId = imageGen.submitTask({ title: `电商${outputKey}`, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId });
let resultUrl: string | null = null;
try {
resultUrl = await waitForTask(taskId, {
kind: "image",
abortRef: imageAbortRef.current,
onProgress: (event) => {
const sub = Math.max(0, Math.min(100, Number(event.progress) || 0));
setGenerationProgress(Math.round(Math.min(99, sub)));
},
});
} finally {
untrackEcommerceTask(taskId);
}
if (imageAbortRef.current.current) {
statusFn?.("idle");
return;
}
if (resultUrl) {
const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult(outputKey), `ecommerce-${outputKey}`);
resultFn?.([{ id: `ecommerce-${stamp}`, src: persistedUrl, label: selectedCloneOutput.label }]);
statusFn?.("done");
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl });
} else {
statusFn?.("idle");
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
}
} catch (err) {
if (imageAbortRef.current.current) {
statusFn?.("idle");
return;
}
if (err instanceof ServerRequestError && err.status === 402) {
resultFn?.([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]);
toast.error("余额不足,请充值后继续");
} else {
const msg = err instanceof Error ? err.message : "生成失败";
toast.error(msg);
}
statusFn?.("failed");
}
};
const handleGenerate = () => {
if (!canGenerate) return;
if ((appUsage?.balanceCents ?? 0) <= 0) {
toast.error("积分不足,请充值后继续");
return;
}
if (cloneOutput === "set" && cloneSetTotal > 5) {
if (!window.confirm("将生成 " + String(cloneSetTotal) + " 张图片,可能消耗较多积分,是否继续?")) return;
}
setComposerMenu(null);
setIsCommandComposerCompact(true);
imageAbortRef.current = { current: false };
lastFailedActionRef.current = null;
setGenerationProgress(0);
setResults([]);
setProductSetResultImages([]);
const pendingGeneration = beginEcommerceHistoryTurn();
const pendingRecordId = pendingGeneration.record.id;
const pendingTurnId = pendingGeneration.turn.id;
setPreviewZoom(1);
setPreviewOffset({ x: 0, y: 0 });
previewOffsetRef.current = { x: 0, y: 0 };
if (cloneOutput === "set") {
void generateSetImages(
productImages, cloneSetCounts, requirement,
platform, ratio, language, market,
(s) => {
setStatus(s as ProductCloneStatus);
if (s === "generating") {
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "generating", errorMessage: undefined }));
} else if (s === "failed") {
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "failed", errorMessage: "生成失败,请检查网络或参数后重试。" }));
}
},
(urls) => {
setProductSetResultImages(urls);
const validUrls = urls.filter(Boolean);
const resultCards = validUrls.map((src, i) => ({ id: `set-${Date.now()}-${i}`, src, label: `套图 ${i + 1}` }));
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({
...turn,
status: validUrls.length ? "done" : "failed",
errorMessage: validUrls.length ? undefined : "生成未返回结果",
setResultImages: validUrls,
results: resultCards,
}));
if (validUrls.length) {
upsertCanvasNode({
id: pendingTurnId,
mode: "set",
sourceImage: productImages[0]?.src,
results: resultCards,
createdAt: Date.now(),
});
}
},
);
lastFailedActionRef.current = () => handleGenerate();
} else {
const clonePromptOptions: EcommerceImagePromptOptions | undefined =
cloneOutput === "model"
? {
gender: cloneModelGender,
age: cloneModelAge,
ethnicity: cloneModelEthnicity,
body: cloneModelBody,
scenes: selectedCloneModelScenes,
customScene: cloneModelCustomScene,
}
: cloneOutput === "detail"
? { detailModules: selectedCloneDetailModules }
: undefined;
void generateEcommerceImage(
cloneOutput, productImages, requirement,
platform, ratio, language, market,
clonePromptOptions,
(s: string) => {
setStatus(s as ProductCloneStatus);
if (s === "generating") {
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "generating", errorMessage: undefined }));
} else if (s === "failed") {
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "failed", errorMessage: "生成失败,请检查网络或参数后重试。" }));
}
},
(newResults: CloneResult[]) => {
const validResults = newResults.filter((item) => item.src);
setResults(validResults);
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({
...turn,
status: validResults.length ? "done" : "failed",
errorMessage: validResults.length ? undefined : newResults[0]?.label || "生成未返回结果",
results: validResults,
setResultImages: [],
}));
if (validResults.length && validResults[0].src) {
upsertCanvasNode({
id: pendingTurnId,
mode: cloneOutput,
sourceImage: productImages[0]?.src,
results: validResults,
createdAt: Date.now(),
});
}
},
);
lastFailedActionRef.current = () => handleGenerate();
}
};
const handleGenerateModel = () => {
imageAbortRef.current = { current: false };
lastFailedActionRef.current = null;
setTryOnStatus("modeling");
void generateEcommerceImage(
"model", garmentImages, requirement,
platform, ratio, language, market,
{ gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene },
(s) => {
if (s === "done") setTryOnStatus("ready");
else setTryOnStatus(s as TryOnStatus);
},
() => { setTryOnStatus("ready"); },
);
lastFailedActionRef.current = () => handleGenerateModel();
};
const handleTryOnGenerate = () => {
if (!canGenerateTryOn) return;
imageAbortRef.current = { current: false };
lastFailedActionRef.current = null;
void generateEcommerceImage(
"model", garmentImages, requirement,
platform, ratio, language, market,
{ gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene },
(s) => setTryOnStatus(s as TryOnStatus),
(res) => {
const urls: string[] = [];
for (const item of res) {
if (item.src) urls.push(item.src);
}
setTryOnResultImages(urls);
},
);
lastFailedActionRef.current = () => handleTryOnGenerate();
};
const toggleScene = (scene: string) => {
setSelectedScenes((current) =>
current.includes(scene) ? current.filter((item) => item !== scene) : [...current, scene],
);
};
const toggleDetailModule = (moduleId: string) => {
setSelectedDetailModules((current) => {
if (current.includes(moduleId)) return current.filter((item) => item !== moduleId);
if (current.length >= maxDetailModuleSelection) {
toast.info(`最多选择 ${maxDetailModuleSelection} 个模块`);
return current;
}
return [...current, moduleId];
});
};
const handleSetGenerate = () => {
if (!canGenerateSet) return;
imageAbortRef.current = { current: false };
lastFailedActionRef.current = null;
void generateSetImages(
setImages, cloneSetCounts, productSetRequirement,
productSetPlatform, productSetRatio, productSetLanguage, productSetMarket,
(s) => setProductSetStatus(s as ProductSetStatus),
(urls) => setProductSetResultImages(urls),
);
lastFailedActionRef.current = () => handleSetGenerate();
};
const openProductSetPreview = (card: { id?: string; src: string; label: string }, options?: { nodeId?: string; removable?: boolean }) => {
setSelectedProductSetPreview({
src: card.src,
label: card.label,
cardId: card.id,
nodeId: options?.nodeId,
removable: Boolean(options?.removable && options.nodeId && card.id),
});
};
const handleDownloadCanvasResult = async (card: { src: string; label: string }) => {
try {
await downloadResultAsset(card.src, card.label || "generated-image", false);
toast.success("已开始下载图片");
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载图片失败");
}
};
const removeCanvasResult = (nodeId: string, cardId: string) => {
setCanvasNodes((current) =>
current
.map((node) => (node.id === nodeId ? { ...node, results: node.results.filter((card) => card.id !== cardId) } : node))
.filter((node) => node.sourceImage || node.results.length > 0),
);
setResults((current) => current.filter((card) => card.id !== cardId));
toast.success("已从当前视图移除");
};
const upsertCanvasNode = (node: Omit<CanvasNode, "x" | "y">) => {
setCanvasNodes((current) => {
const existingIndex = current.findIndex((item) => item.id === node.id);
if (existingIndex >= 0) {
return current.map((item) => (item.id === node.id ? { ...item, ...node } : item));
}
return [
...current,
{
...node,
x: current.length * 420,
y: current.length % 2 === 0 ? 0 : 160,
},
];
});
};
const removeSelectedProductSetPreview = (preview: ProductSetPreviewSelection) => {
if (!preview.nodeId || !preview.cardId) return;
removeCanvasResult(preview.nodeId, preview.cardId);
setSelectedProductSetPreview(null);
};
const handleDetailAiWrite = () => {
setDetailRequirement(
"1.产品名称:无线降噪蓝牙耳机\n2.核心卖点:主动降噪、24H续航、低延迟连接、舒适佩戴\n3.适用人群:通勤、办公、运动和旅行用户\n4.期望场景:地铁通勤、居家办公、户外运动\n5.具体参数:蓝牙5.3、IPX4防水、快充10分钟使用2小时",
);
};
const stopDetailProgress = () => {
if (detailProgressRef.current !== null) {
window.clearInterval(detailProgressRef.current);
detailProgressRef.current = null;
}
};
const startDetailProgress = () => {
stopDetailProgress();
setDetailProgress(0);
detailProgressRef.current = window.setInterval(() => {
setDetailProgress((prev) => {
if (prev >= 90) {
stopDetailProgress();
return 90;
}
return prev + (90 - prev) * 0.06;
});
}, 500);
};
const handleDetailGenerate = () => {
if (!canGenerateDetail) return;
imageAbortRef.current = { current: false };
lastFailedActionRef.current = null;
startDetailProgress();
void generateEcommerceImage(
"detail", detailProductImages, detailRequirement,
detailPlatform, detailRatio, detailLanguage, detailMarket,
{ detailModules: selectedDetailModules },
(s: string) => {
setDetailStatus(s as DetailStatus);
if (s === "done") {
stopDetailProgress();
setDetailProgress(100);
} else if (s === "failed" || s === "idle") {
stopDetailProgress();
setDetailProgress(0);
}
},
(res) => setDetailResultUrl(res[0]?.src ?? null),
);
};
const handleHotPlatformChange = (nextPlatform: string) => {
const normalizedPlatform = normalizePlatform(nextPlatform);
setHotPlatform(normalizedPlatform);
setHotLanguage(getPlatformDefaultLanguage(normalizedPlatform, hotMarket));
setHotRatio((current) => getQuickSetRatioValue(current));
};
const handleHotMarketChange = (nextMarket: string) => {
const normalizedMarket = normalizeMarket(nextMarket);
setHotMarket(normalizedMarket);
setHotLanguage(getPlatformDefaultLanguage(hotPlatform, normalizedMarket));
};
const handleHotAiWrite = () => {
setHotRequirement(
"1.产品名称:便携式咖啡保温杯\n2.核心卖点:316不锈钢内胆、12小时长效保温、防漏便携、大容量\n3.参考风格:极简日系、暖光氛围、生活场景\n4.期望场景:办公桌面、户外通勤、运动健身\n5.具体参数:容量500ml、口径4.5cm、高度22cm",
);
};
const stopHotProgress = () => {
if (hotProgressRef.current !== null) {
window.clearInterval(hotProgressRef.current);
hotProgressRef.current = null;
}
};
const startHotProgress = () => {
stopHotProgress();
setHotProgress(0);
hotProgressRef.current = window.setInterval(() => {
setHotProgress((prev) => {
if (prev >= 90) {
stopHotProgress();
return 90;
}
return prev + (90 - prev) * 0.06;
});
}, 500);
};
const handleHotGenerate = () => {
if (!canGenerateHot) return;
imageAbortRef.current = { current: false };
lastFailedActionRef.current = null;
startHotProgress();
void generateEcommerceImage(
"hot", cloneReferenceImages, hotRequirement,
hotPlatform, hotRatio, hotLanguage, hotMarket,
undefined,
(s: string) => {
setHotStatus(s as DetailStatus);
if (s === "done") {
stopHotProgress();
setHotProgress(100);
} else if (s === "failed" || s === "idle") {
stopHotProgress();
setHotProgress(0);
}
},
(res) => setHotResultUrl(res[0]?.src ?? null),
);
};
const handleHotMaterialMouseEnter = (src: string, event: ReactMouseEvent<HTMLElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const previewHalfWidth = 150;
const previewHeight = 360;
const gap = 12;
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const x = Math.min(
Math.max(rect.left + rect.width / 2, previewHalfWidth + gap),
Math.max(previewHalfWidth + gap, viewportWidth - previewHalfWidth - gap),
);
const showAbove = rect.top > previewHeight + gap;
const y = showAbove
? rect.top - gap
: Math.min(rect.bottom + gap, viewportHeight - gap);
setHotMaterialHoverZoom({ src, x, y, placement: showAbove ? "above" : "below" });
};
const handleHotMaterialMouseLeave = () => setHotMaterialHoverZoom(null);
const renderHotMaterialThumbs = (items: CloneImageItem[], onRemove: (imageId: string) => void) => (
<div className="ecom-quick-upload-thumbs" aria-label="已上传商品素材">
{items.map((item) => (
<figure
key={item.id}
className="ecom-command-asset-thumb ecom-quick-upload-thumb"
onMouseEnter={(e) => handleHotMaterialMouseEnter(item.src, e)}
onMouseLeave={handleHotMaterialMouseLeave}
>
<img src={item.src} alt={item.name} />
<button
type="button"
className="ecom-hot-material-delete"
aria-label={`删除${item.name || "图片"}`}
onClick={(event) => {
event.stopPropagation();
setHotMaterialHoverZoom(null);
onRemove(item.id);
}}
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M9 6V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v1" />
<path d="M5 6h14" />
<path d="M8 6l1 14h6l1-14" />
<path d="M10.5 10v6" />
<path d="M13.5 10v6" />
</svg>
</button>
</figure>
))}
</div>
);
const closeHotClonePage = () => {
stopHotProgress();
setActiveQuickTool(null);
setHotStatus("idle");
setHotResultUrl(null);
setHotProgress(0);
setHotRequirement("");
setIsHotMaterialDragging(false);
setHotMaterialHoverZoom(null);
setComposerMenu(null);
};
const resetTask = () => {
setSetImages([]);
setProductSetRequirement("");
setProductSetPlatform(defaultEcommercePlatform);
setProductSetLanguage(getPlatformDefaultLanguage(defaultEcommercePlatform, productSetMarket));
setProductSetOutput(defaultProductSetOutput);
setProductSetRatio(getPlatformDefaultRatio(defaultEcommercePlatform, defaultProductSetOutput));
setProductSetStatus("idle");
setProductSetResultImages([]);
setIsSetUploadDragging(false);
setSelectedProductSetPreview(null);
setShowHostingModal(false);
setProductImages([]);
setIsProductUploadDragging(false);
setCloneOutput(defaultCloneOutput);
setPlatform(defaultEcommercePlatform);
setLanguage(getPlatformDefaultLanguage(defaultEcommercePlatform, market));
setRatio(getPlatformDefaultRatio(defaultEcommercePlatform, defaultCloneOutput));
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 isSmartCutoutTool = isCloneTool && activeQuickTool === "cutout";
const isQuickDetailTool = isCloneTool && activeQuickTool === "detail";
const isWatermarkTool = isCloneTool && activeQuickTool === "watermark";
const isImageEditTool = isCloneTool && activeQuickTool === "image-edit";
const isTranslateTool = isCloneTool && activeQuickTool === "translate";
const isHotCloneTool = isCloneTool && activeQuickTool === "hot";
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 =
cloneOutput === "video"
? !productImages.length && !requirement.trim()
? "填写需求或上传商品图"
: !isAuthenticated
? "登录后生成短视频"
: "生成短视频"
: productImages.length === 0 ? "请先上传商品原图" : status === "generating" ? "生成中..." : "生成" + selectedCloneOutput.label;
const setPreviewCards: CloneResult[] = [];
let setIndex = 0;
for (const countKey of cloneSetCountKeys) {
const count = cloneSetCounts[countKey];
const info = setCountLabels[countKey];
for (let i = 0; i < count; i++) {
setPreviewCards.push({
id: String(countKey) + "-" + String(i),
src: productSetResultImages[setIndex] || productSetPreviewCards[setIndex % productSetPreviewCards.length]?.src || "",
label: info.label + (count > 1 ? " " + String(i + 1) : ""),
});
setIndex++;
}
}
const clonePreviewCards: CloneResult[] = [];
let cloneIndex = 0;
for (const countKey of cloneSetCountKeys) {
const count = cloneSetCounts[countKey];
const info = setCountLabels[countKey];
for (let i = 0; i < count; i++) {
clonePreviewCards.push({
id: String(countKey) + "-" + String(i),
src: productSetResultImages[cloneIndex] || productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src || "",
label: info.label + (count > 1 ? " " + String(i + 1) : ""),
});
cloneIndex++;
}
}
const getCurrentHistoryResults = () =>
cloneOutput === "set"
? productSetResultImages
.filter(Boolean)
.map((src, index) => ({ id: "history-set-" + String(index), src, label: clonePreviewCards[index]?.label || "套图 " + String(index + 1) }))
: results.filter((item) => item.src);
const buildHistorySignature = (output: CloneOutputKey, prompt: string, historyResults: CloneResult[], sourceImages: CloneImageItem[]) =>
[
output,
prompt.trim(),
historyResults.map((item) => item.src).join("|"),
sourceImages.map((item) => item.src).join("|"),
].join("::");
const getHistoryRecordResults = (record: EcommerceHistoryRecord) => {
const turns = record.turns?.length ? record.turns : [buildHistoryTurnFromRecord(record)];
return turns.flatMap(getTurnResults);
};
const persistEcommerceHistoryRecord = (record: EcommerceHistoryRecord, historyResults: CloneResult[]) => {
void saveUnifiedEcommerceGenerationRecord({
clientRecordId: record.id,
title: record.title,
mode: record.output,
prompt: record.requirement,
sourceImages: record.productImages.map((image, index) => ({
url: image.src,
ossKey: image.ossKey,
label: image.name || `source-${index + 1}`,
})),
results: historyResults.map((item) => ({
url: item.src,
label: item.label,
mediaType: "image",
})),
config: {
platform: record.platform,
market: record.market,
language: record.language,
ratio: record.ratio,
setCounts: record.setCounts,
detailModules: record.detailModules,
modelScenes: record.modelScenes,
replicateLevel: record.replicateLevel,
},
metadata: {
localHistoryStorageKey: ecommerceHistoryStorageKey,
referenceImageCount: record.referenceImages.length,
turnCount: record.turns?.length ?? 1,
latestTurnId: record.turns?.[record.turns.length - 1]?.id,
},
createdAt: new Date(record.createdAt).toISOString(),
});
};
const formatHistoryTime = (timestamp: number) => {
const diff = Math.max(0, Date.now() - timestamp);
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (diff < minute) return "刚刚";
if (diff < hour) return String(Math.floor(diff / minute)) + " 分钟前";
if (diff < day) return String(Math.floor(diff / hour)) + " 小时前";
return String(Math.floor(diff / day)) + " 天前";
};
const buildEcommerceHistoryTitle = (output: CloneOutputKey, prompt: string, createdAt: number) => {
const outputLabel = cloneOutputOptions.find((option) => option.key === output)?.label || "生成记录";
return prompt.trim() || outputLabel + " " + new Date(createdAt).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
};
const updateLocalEcommerceHistoryRecord = (recordId: string, updater: (record: EcommerceHistoryRecord) => EcommerceHistoryRecord) => {
setEcommerceHistoryRecords((current) => {
const nextRecords = current.map((record) => (record.id === recordId ? normalizeEcommerceHistoryRecord(updater(record)) : record));
writeEcommerceHistoryRecords(nextRecords);
return nextRecords;
});
};
const buildCurrentEcommerceHistoryTurn = (turnId: string, createdAt: number, turnStatus: EcommerceHistoryStatus = "generating"): EcommerceHistoryTurn => ({
id: turnId,
createdAt,
status: turnStatus,
output: cloneOutput,
platform,
market,
language,
ratio,
requirement,
productImages,
results: [],
setResultImages: [],
setCounts: cloneSetCounts,
detailModules: selectedCloneDetailModules,
modelScenes: selectedCloneModelScenes,
referenceImages: cloneReferenceImages,
replicateLevel: cloneReplicateLevel,
});
const syncRecordSummaryWithTurn = (record: EcommerceHistoryRecord, turn: EcommerceHistoryTurn): EcommerceHistoryRecord => ({
...record,
status: turn.status,
errorMessage: turn.status === "failed" ? turn.errorMessage : undefined,
output: turn.output,
platform: turn.platform,
market: turn.market,
language: turn.language,
ratio: turn.ratio,
requirement: turn.requirement,
productImages: turn.productImages,
results: turn.results,
setResultImages: turn.setResultImages,
setCounts: turn.setCounts,
detailModules: turn.detailModules,
modelScenes: turn.modelScenes,
referenceImages: turn.referenceImages,
replicateLevel: turn.replicateLevel,
});
const updateLocalEcommerceHistoryTurn = (
recordId: string,
turnId: string,
updater: (turn: EcommerceHistoryTurn) => EcommerceHistoryTurn,
) => {
updateLocalEcommerceHistoryRecord(recordId, (record) => {
const turns = record.turns?.length ? record.turns : [buildHistoryTurnFromRecord(record)];
let updatedTurn: EcommerceHistoryTurn | null = null;
const nextTurns = turns.map((turn) => {
if (turn.id !== turnId) return turn;
updatedTurn = normalizeEcommerceHistoryTurn(updater(turn), record, turns.indexOf(turn));
return updatedTurn;
});
return updatedTurn ? syncRecordSummaryWithTurn({ ...record, turns: nextTurns }, updatedTurn) : record;
});
};
const beginEcommerceHistoryTurn = () => {
const createdAt = Date.now();
const turn = buildCurrentEcommerceHistoryTurn(crypto.randomUUID(), createdAt);
const existingRecord = activeHistoryRecordId
? ecommerceHistoryRecords.find((record) => record.id === activeHistoryRecordId)
: null;
const recordId = existingRecord?.id ?? crypto.randomUUID();
const baseRecord: EcommerceHistoryRecord = existingRecord ?? {
id: recordId,
title: buildEcommerceHistoryTitle(cloneOutput, requirement, createdAt),
createdAt,
status: turn.status,
output: turn.output,
platform: turn.platform,
market: turn.market,
language: turn.language,
ratio: turn.ratio,
requirement: turn.requirement,
productImages: turn.productImages,
results: turn.results,
setResultImages: turn.setResultImages,
setCounts: turn.setCounts,
detailModules: turn.detailModules,
modelScenes: turn.modelScenes,
referenceImages: turn.referenceImages,
replicateLevel: turn.replicateLevel,
turns: [],
};
const previousTurns = baseRecord.turns?.length ? baseRecord.turns : existingRecord ? [buildHistoryTurnFromRecord(baseRecord)] : [];
const record = normalizeEcommerceHistoryRecord(syncRecordSummaryWithTurn({
...baseRecord,
turns: [...previousTurns, turn],
}, turn));
setEcommerceHistoryRecords((current) => {
const nextRecords = [record, ...current.filter((item) => item.id !== record.id)].slice(0, 30);
writeEcommerceHistoryRecords(nextRecords);
return nextRecords;
});
setActiveHistoryRecordId(record.id);
activeHistoryTurnIdRef.current = turn.id;
return { record, turn };
};
const saveCurrentEcommerceHistory = () => {
const activeRecord = activeHistoryRecordId ? ecommerceHistoryRecords.find((record) => record.id === activeHistoryRecordId) : null;
const historyResults = activeRecord?.turns?.length ? getHistoryRecordResults(activeRecord) : getCurrentHistoryResults();
if (!historyResults.length) return null;
const signature = activeRecord?.turns?.length
? buildHistorySignature(activeRecord.output, activeRecord.requirement, historyResults, activeRecord.productImages)
: buildHistorySignature(cloneOutput, requirement, historyResults, productImages);
if (lastSavedHistorySignatureRef.current === signature && activeHistoryRecordId) return activeHistoryRecordId;
const createdAt = Date.now();
const record: EcommerceHistoryRecord = activeRecord?.turns?.length
? normalizeEcommerceHistoryRecord(activeRecord)
: normalizeEcommerceHistoryRecord({
id: activeRecord?.id ?? crypto.randomUUID(),
title: activeRecord?.title ?? buildEcommerceHistoryTitle(cloneOutput, requirement, createdAt),
createdAt: activeRecord?.createdAt ?? createdAt,
status: "done",
errorMessage: undefined,
output: cloneOutput,
platform,
market,
language,
ratio,
requirement,
productImages,
results: historyResults,
setResultImages: cloneOutput === "set" ? historyResults.map((item) => item.src) : [],
setCounts: cloneSetCounts,
detailModules: selectedCloneDetailModules,
modelScenes: selectedCloneModelScenes,
referenceImages: cloneReferenceImages,
replicateLevel: cloneReplicateLevel,
});
lastSavedHistorySignatureRef.current = signature;
persistEcommerceHistoryRecord(record, historyResults);
setEcommerceHistoryRecords((current) => {
const nextRecords = [record, ...current.filter((item) => item.id !== record.id)].slice(0, 30);
writeEcommerceHistoryRecords(nextRecords);
return nextRecords;
});
setActiveHistoryRecordId(record.id);
return record.id;
};
const openEcommerceHistoryRecord = (record: EcommerceHistoryRecord) => {
setActiveTool("clone");
setCloneOutput(record.output);
setPlatform(record.platform);
setMarket(record.market);
setLanguage(record.language);
setRatio(record.ratio);
setRequirement(record.requirement);
setProductImages(record.productImages);
setCloneSetCounts(record.setCounts);
setSelectedCloneDetailModules(record.detailModules.slice(0, maxDetailModuleSelection));
setSelectedCloneModelScenes(record.modelScenes);
setCloneReferenceImages(record.referenceImages);
setCloneReplicateLevel(record.replicateLevel);
setProductSetResultImages(record.setResultImages);
setResults(record.output === "set" ? [] : record.results);
setStatus((record.status ?? "done") as ProductCloneStatus);
setPreviewZoom(1);
setComposerMenu(null);
setActiveHistoryRecordId(record.id);
activeHistoryTurnIdRef.current = record.status === "generating"
? record.turns?.find((turn) => turn.status === "generating")?.id ?? null
: null;
const recordResults = getHistoryRecordResults(record);
lastSavedHistorySignatureRef.current = buildHistorySignature(record.output, record.requirement, recordResults, record.productImages);
setIsCommandComposerCompact(true);
const turns = record.turns?.length ? record.turns : [buildHistoryTurnFromRecord(record)];
const nodes = turns.reduce<CanvasNode[]>((items, turn) => {
const turnResults = getTurnResults(turn);
if (!turnResults.length) return items;
const index = items.length;
items.push({
id: turn.id,
mode: turn.output,
sourceImage: turn.productImages[0]?.src,
results: turnResults,
createdAt: turn.createdAt,
x: index * 420,
y: index % 2 === 0 ? 0 : 160,
});
return items;
}, []);
setCanvasNodes(nodes);
setPreviewOffset({ x: 0, y: 0 });
previewOffsetRef.current = { x: 0, y: 0 };
};
const handleNewEcommerceConversation = () => {
saveCurrentEcommerceHistory();
resetTask();
setCanvasNodes([]);
setPreviewZoom(1);
setPreviewOffset({ x: 0, y: 0 });
setComposerMenu(null);
setIsCommandComposerCompact(false);
setActiveHistoryRecordId(null);
activeHistoryTurnIdRef.current = null;
lastSavedHistorySignatureRef.current = "";
};
const refreshEcommerceHistory = () => {
if (historyRefreshLockRef.current) return;
historyRefreshLockRef.current = true;
setIsHistoryRefreshing(true);
setHistoryRefreshMessage("刷新中...");
setHistoryRefreshStamp(Date.now());
window.setTimeout(() => {
const storedRecords = readEcommerceHistoryRecords();
const mergedRecords = [...ecommerceHistoryRecords, ...storedRecords]
.reduce<EcommerceHistoryRecord[]>((records, record) => {
if (!records.some((item) => item.id === record.id)) records.push(record);
return records;
}, [])
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, 30);
writeEcommerceHistoryRecords(mergedRecords);
setHistoryRefreshTick((tick) => tick + 1);
setEcommerceHistoryRecords(mergedRecords);
setHistoryRefreshMessage(mergedRecords.length ? "已刷新 " + String(mergedRecords.length) + " 条记录" : "暂无可刷新记录");
setHistoryRefreshStamp(Date.now());
setIsHistoryRefreshing(false);
historyRefreshLockRef.current = false;
}, 180);
window.setTimeout(() => setHistoryRefreshMessage(""), 3000);
};
const deleteHistoryRecord = (recordId: string, event: ReactMouseEvent) => {
event.stopPropagation();
const record = ecommerceHistoryRecords.find((r) => r.id === recordId);
if (!record) return;
const next = ecommerceHistoryRecords.filter((r) => r.id !== recordId);
setEcommerceHistoryRecords(next);
writeEcommerceHistoryRecords(next);
if (activeHistoryRecordId === recordId) {
setActiveHistoryRecordId(null);
activeHistoryTurnIdRef.current = null;
}
deleteEcommerceGenerationRecord(recordId).catch(() => {});
};
useEffect(() => {
if (status === "done") saveCurrentEcommerceHistory();
}, [status, results, productSetResultImages]);
const detailSourcePreviewImages = detailProductImages.length
? detailProductImages.reduce<string[]>((urls, item) => {
urls.push(item.src);
return urls;
}, [])
: detailProductSamples;
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 quickDetailBasicSelects: Array<{
key: CloneBasicSelectKey;
label: string;
value: string;
options: string[];
onChange: (value: string) => void;
}> = [
{ key: "platform", label: "平台", value: detailPlatform, options: platformOptions, onChange: handleDetailPlatformChange },
{ key: "market", label: "国家", value: detailMarket, options: marketOptions, onChange: handleDetailMarketChange },
{ key: "language", label: "语种", value: detailLanguage, options: detailLanguageOptions, onChange: setDetailLanguage },
{ key: "ratio", label: "尺寸/比例", value: getQuickSetRatioValue(detailRatio), options: quickSetRatioOptions, onChange: setDetailRatio },
];
const quickHotBasicSelects: Array<{
key: CloneBasicSelectKey;
label: string;
value: string;
options: string[];
onChange: (value: string) => void;
}> = [
{ key: "platform", label: "平台", value: hotPlatform, options: platformOptions, onChange: handleHotPlatformChange },
{ key: "market", label: "国家", value: hotMarket, options: marketOptions, onChange: handleHotMarketChange },
{ key: "language", label: "语种", value: hotLanguage, options: hotLanguageOptions, onChange: setHotLanguage },
{ key: "ratio", label: "尺寸/比例", value: getQuickSetRatioValue(hotRatio), options: quickSetRatioOptions, onChange: setHotRatio },
];
const cloneModelSelects: Array<{
key: CloneModelSelectKey;
label: string;
value: string;
options: string[];
onChange: (value: string) => void;
}> = [
{ key: "gender", label: "性别", value: cloneModelGender, options: tryOnModelOptions.gender, onChange: setCloneModelGender },
{ key: "age", label: "年龄", value: cloneModelAge, options: tryOnModelOptions.age, onChange: setCloneModelAge },
{
key: "ethnicity",
label: "人种",
value: cloneModelEthnicity,
options: tryOnModelOptions.ethnicity,
onChange: setCloneModelEthnicity,
},
{ key: "body", label: "体型", value: cloneModelBody, options: tryOnModelOptions.body, onChange: setCloneModelBody },
];
const setPanel = (
<EcommerceSetPanel
setInputRef={setInputRef}
setImages={setImages}
isSetUploadDragging={isSetUploadDragging}
productSetOutputOptions={productSetOutputOptions}
productSetOutput={productSetOutput}
platformOptions={platformOptions}
marketOptions={marketOptions}
productSetLanguageOptions={productSetLanguageOptions}
productSetRatioOptions={productSetRatioOptions}
productSetPlatform={productSetPlatform}
productSetMarket={productSetMarket}
productSetLanguage={productSetLanguage}
productSetRatio={productSetRatio}
setIsSetUploadDragging={setIsSetUploadDragging}
handleSetDrop={handleSetDrop}
handleSetUpload={handleSetUpload}
removeSetImage={removeSetImage}
handleProductSetOutputChange={handleProductSetOutputChange}
handleProductSetPlatformChange={handleProductSetPlatformChange}
handleProductSetMarketChange={handleProductSetMarketChange}
setProductSetLanguage={setProductSetLanguage}
setProductSetRatio={setProductSetRatio}
formatRatioDisplayValue={formatRatioDisplayValue}
/>
);
const clonePanel = (
<EcommerceClonePanel
productInputRef={productInputRef}
cloneReferenceInputRef={cloneReferenceInputRef}
productImages={productImages}
isProductUploadDragging={isProductUploadDragging}
cloneOutput={cloneOutput}
cloneOutputOptions={cloneOutputOptions}
cloneBasicSelects={cloneBasicSelects}
openCloneBasicSelect={openCloneBasicSelect}
cloneReferenceMode={cloneReferenceMode}
cloneReferenceImages={cloneReferenceImages}
maxCloneReferenceImages={maxCloneReferenceImages}
cloneReplicateLevel={cloneReplicateLevel}
cloneReplicateLevelOptions={cloneReplicateLevelOptions}
cloneSetCounts={cloneSetCounts}
cloneSetCountOptions={cloneSetCountOptions}
cloneSetTotal={cloneSetTotal}
minCloneSetTotal={minCloneSetTotal}
maxCloneSetTotal={maxCloneSetTotal}
selectedCloneDetailModules={selectedCloneDetailModules}
cloneDetailModules={cloneDetailModules}
cloneModelPanelTab={cloneModelPanelTab}
tryOnScenes={tryOnScenes}
selectedCloneModelScenes={selectedCloneModelScenes}
cloneModelCustomScene={cloneModelCustomScene}
cloneModelSelects={cloneModelSelects}
openCloneModelSelect={openCloneModelSelect}
cloneModelSelectDropUp={cloneModelSelectDropUp}
cloneVideoQuality={cloneVideoQuality}
cloneVideoQualityOptions={cloneVideoQualityOptions}
cloneVideoDuration={cloneVideoDuration}
cloneVideoDurationMin={cloneVideoDurationMin}
cloneVideoDurationMax={cloneVideoDurationMax}
cloneVideoDurationStyle={cloneVideoDurationStyle}
cloneVideoSmart={cloneVideoSmart}
canGenerate={canGenerate}
status={status}
lastFailedActionRef={lastFailedActionRef}
setIsProductUploadDragging={setIsProductUploadDragging}
handleProductDrop={handleProductDrop}
removeProductImage={removeProductImage}
handleProductUpload={handleProductUpload}
handleCloneOutputChange={handleCloneOutputChange}
setOpenCloneBasicSelect={setOpenCloneBasicSelect}
setCloneReferenceMode={setCloneReferenceMode}
handleCloneReferenceUpload={handleCloneReferenceUpload}
isCloneReferenceDragging={isCloneReferenceDragging}
handleCloneReferenceDragOver={handleCloneReferenceDragOver}
handleCloneReferenceDragLeave={handleCloneReferenceDragLeave}
handleCloneReferenceDrop={handleCloneReferenceDrop}
setCloneReplicateLevel={setCloneReplicateLevel}
startCloneSetCountHold={startCloneSetCountHold}
clearCloneSetCountHold={clearCloneSetCountHold}
toggleCloneDetailModule={toggleCloneDetailModule}
setCloneModelPanelTab={setCloneModelPanelTab}
toggleCloneModelScene={toggleCloneModelScene}
setCloneModelCustomScene={setCloneModelCustomScene}
setOpenCloneModelSelect={setOpenCloneModelSelect}
setCloneModelSelectDropUp={setCloneModelSelectDropUp}
setCloneVideoQuality={setCloneVideoQuality}
setCloneVideoDuration={setCloneVideoDuration}
clampCloneVideoDuration={clampCloneVideoDuration}
setCloneVideoSmart={setCloneVideoSmart}
handleGenerate={handleGenerate}
onCancelGenerate={handleCancelGenerate}
formatRatioDisplayValue={formatRatioDisplayValue}
onStartVideoPlan={handleStartVideoPlan}
/>
);
const detailPanel = (
<EcommerceDetailPanel
detailInputRef={detailInputRef}
detailProductImages={detailProductImages}
detailPlatform={detailPlatform}
detailMarket={detailMarket}
detailLanguage={detailLanguage}
detailType={detailType}
detailRequirement={detailRequirement}
selectedDetailModules={selectedDetailModules}
detailStatus={detailStatus}
canGenerateDetail={canGenerateDetail}
detailPrimaryLabel={detailPrimaryLabel}
platformOptions={platformOptions}
marketOptions={marketOptions}
detailLanguageOptions={detailLanguageOptions}
detailTypeOptions={detailTypeOptions}
detailModules={detailModules}
handleDetailUpload={handleDetailUpload}
handleDetailPlatformChange={handleDetailPlatformChange}
handleDetailMarketChange={handleDetailMarketChange}
setDetailLanguage={setDetailLanguage}
setDetailType={setDetailType}
setDetailRequirement={setDetailRequirement}
handleDetailAiWrite={handleDetailAiWrite}
toggleDetailModule={toggleDetailModule}
handleDetailGenerate={handleDetailGenerate}
onCancelGenerate={handleCancelGenerate}
/>
);
const tryOnPanel = (
<EcommerceTryOnPanel
garmentInputRef={garmentInputRef}
garmentImages={garmentImages}
modelSource={modelSource}
modelGender={modelGender}
modelAge={modelAge}
modelEthnicity={modelEthnicity}
modelBody={modelBody}
appearance={appearance}
selectedScenes={selectedScenes}
customScene={customScene}
smartScene={smartScene}
tryOnRatio={tryOnRatio}
tryOnStatus={tryOnStatus}
canGenerateTryOn={canGenerateTryOn}
tryOnPrimaryLabel={tryOnPrimaryLabel}
tryOnModelOptions={tryOnModelOptions}
tryOnAssets={tryOnAssets}
tryOnScenes={tryOnScenes}
tryOnRatioOptions={tryOnRatioOptions}
handleGarmentUpload={handleGarmentUpload}
setModelSource={setModelSource}
setModelGender={setModelGender}
setModelAge={setModelAge}
setModelEthnicity={setModelEthnicity}
setModelBody={setModelBody}
setAppearance={setAppearance}
handleGenerateModel={handleGenerateModel}
toggleScene={toggleScene}
setCustomScene={setCustomScene}
setSmartScene={setSmartScene}
setTryOnRatio={setTryOnRatio}
handleTryOnGenerate={handleTryOnGenerate}
onCancelGenerate={handleCancelGenerate}
/>
);
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商品套图预览" onWheel={handlePreviewWheel}>
<div className="product-clone-preview__headline">
<h1></h1>
<p>
AI <span></span>
</p>
</div>
{productSetPreviewReady ? (
<section className="product-set-demo-board">
<button
type="button"
className="product-set-main-card"
onClick={() => openProductSetPreview(setPreviewCards[0] ?? productSetPreviewCards[0])}
>
<img src={setImages[0]?.src ?? (setPreviewCards[0]?.src ?? productSetPreviewCards[0].src)} alt="商品原图" />
</button>
<div className="product-set-flow-arrow" aria-hidden="true" />
<div className="product-set-card-grid result-reveal">
{setPreviewCards.map((card) => (
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
<img src={card.src} alt={card.label} />
</button>
))}
</div>
</section>
) : (
<section className="product-set-empty-preview" aria-live="polite">
{productSetStatus === "generating" ? <LoadingOutlined /> : <FileImageOutlined />}
<strong>{productSetStatus === "generating" ? "正在生成" : "等待生成"}</strong>
{productSetStatus === "generating" ? <EcommerceProgressBar status="generating" progress={generationProgress} onCancel={handleCancelGenerate} label="商品套图" /> : null}
<span>{productSetStatus === "generating" ? "AI 正在整理主图、场景、细节与卖点图。" : "上传商品原图并填写信息后,AI 将为您生成专业的电商商品图。"}</span>
</section>
)}
{productSetStatus === "done" ? <p className="product-set-generated-note">{selectedProductSetOutput.label}</p> : null}
<section className="product-set-floating-detail" aria-label="信息详情">
<div className="product-set-floating-detail__head">
<strong></strong>
<span>{productSetRequirement.length}/500</span>
</div>
<textarea
value={productSetRequirement}
onChange={(event) => setProductSetRequirement(event.target.value)}
maxLength={500}
placeholder="建议包含以下信息:产品名称、核心卖点、期望场景、具体参数"
/>
<button type="button" className="product-set-floating-submit" disabled={!canGenerateSet} onClick={handleSetGenerate}>
{productSetStatus === "generating" ? <LoadingOutlined /> : null}
{setPrimaryLabel}
</button>
{productSetStatus === "generating" ? (
<button type="button" className="product-set-floating-submit product-set-floating-submit--cancel" onClick={handleCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</section>
<button type="button" className="product-clone-help" aria-label="帮助">
<QuestionCircleOutlined />
</button>
</main>
);
const composerSettingLabel =
cloneOutput === "set"
? "套图 " + String(cloneSetTotal) + "张"
: cloneOutput === "detail"
? "详情 " + String(selectedCloneDetailModules.length) + "项"
: cloneOutput === "model"
? cloneModelPanelTab === "scene" ? "场景设置" : "模特设置"
: cloneOutput === "video"
? String(cloneVideoDuration) + "秒 " + (cloneVideoQuality === "standard" ? "720P" : "1080P")
: "换装素材";
const renderComposerMenu = () => {
const composerLanguageOptions = Array.from(new Set(marketLanguageOptions.flatMap((option) => option.languages))).map((item) => ({
language: item,
countries: marketLanguageOptions.filter((option) => option.languages.includes(item)).map((option) => option.country),
}));
const composerPopoverStyle = {
"--composer-popover-left": `${composerPopoverLeft}px`,
"--composer-popover-top": `${composerPopoverTop}px`,
} as CSSProperties;
const menuToRender = composerMenu ?? visibleComposerMenu;
if (!menuToRender) return null;
const popoverClosingClass = !composerMenu && isComposerMenuClosing ? " is-closing" : "";
const composerPopoverKey = `${menuToRender}-${cloneOutput}-${popoverClosingClass ? "closing" : "open"}`;
if (menuToRender === "mode") {
return (
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--grid ecom-command-popover--mode${popoverClosingClass}`} style={composerPopoverStyle}>
{cloneOutputOptions.map((option) => (
<button key={option.key} type="button" className={cloneOutput === option.key ? "is-active" : ""} onClick={() => { handleCloneOutputChange(option.key); setComposerMenu(null); }}>
<span className={`ecom-command-mode-icon ecom-command-mode-icon--${option.key}`} aria-hidden="true">{option.icon}</span>
<strong>{option.label}</strong>
<em>{option.desc}</em>
</button>
))}
</div>
);
}
if (menuToRender === "platform") {
return (
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--list ecom-command-popover--ratio ecom-command-popover--platform${popoverClosingClass}`} style={composerPopoverStyle}>
{platformOptions.map((option) => (
<button key={option} type="button" className={platform === option ? "is-active" : ""} onClick={() => { handleClonePlatformChange(option); setComposerMenu(null); }}>
{renderPlatformLogo(option)}
<span className="ecom-platform-name">{option}</span>
</button>
))}
</div>
);
}
if (menuToRender === "language") {
return (
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--languages${popoverClosingClass}`} style={composerPopoverStyle}>
{composerLanguageOptions.map((option) => (
<button key={option.language} type="button" className={language === option.language ? "is-active" : ""} onClick={() => { setLanguage(option.language); setComposerMenu(null); }}>
<strong>{option.language}</strong>
<span>({option.countries.slice(0, 3).join(" / ")}{option.countries.length > 3 ? " +" + String(option.countries.length - 3) : ""})</span>
</button>
))}
</div>
);
}
if (menuToRender === "ratio") {
return (
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--list ecom-command-popover--ratio-picker${popoverClosingClass}`} style={composerPopoverStyle}>
{cloneRatioOptions.map((option) => {
const ratioParts = getRatioDisplayParts(option);
return (
<button key={option} type="button" className={ratio === option ? "is-active" : ""} onClick={() => { setRatio(option); setComposerMenu(null); }}>
<span className="ecom-command-ratio-icon" aria-hidden="true">
<TableOutlined />
</span>
<span className="ecom-command-ratio-copy">
<strong>{ratioParts.size}</strong>
<em>{ratioParts.aspect}</em>
</span>
</button>
);
})}
</div>
);
}
return (
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--settings ecom-command-popover--settings-${cloneOutput}${popoverClosingClass}`} style={composerPopoverStyle}>
{cloneOutput === "set" ? (
<>
<header><strong></strong><span> 1-16 </span></header>
{cloneSetCountOptions.map((item) => (
<div key={item.key} className="ecom-command-count-row">
<span><strong>{item.title}</strong><em>{item.desc}</em></span>
<div>
<button type="button" onClick={() => updateCloneSetCount(item.key, -1)} disabled={cloneSetCounts[item.key] <= 0 || cloneSetTotal <= minCloneSetTotal}>-</button>
<b>{cloneSetCounts[item.key]}</b>
<button type="button" onClick={() => updateCloneSetCount(item.key, 1)} disabled={cloneSetTotal >= maxCloneSetTotal}>+</button>
</div>
</div>
))}
</>
) : cloneOutput === "detail" ? (
<>
<header><strong></strong><span></span></header>
<div className="ecom-command-module-grid ecom-command-module-grid--detail">
{cloneDetailModules.map((module) => (
<button key={module.id} type="button" className={selectedCloneDetailModules.includes(module.id) ? "is-active" : ""} onClick={() => toggleCloneDetailModule(module.id)}>
<strong>{module.title}</strong><span>{module.desc}</span>
</button>
))}
</div>
</>
) : cloneOutput === "model" ? (
<>
<header><strong></strong><span> / </span></header>
<div className="ecom-command-module-grid ecom-command-module-grid--model">
{tryOnScenes.map((scene) => (
<button key={scene} type="button" className={selectedCloneModelScenes.includes(scene) ? "is-active" : ""} onClick={() => toggleCloneModelScene(scene)}>{scene}</button>
))}
</div>
<div className="ecom-command-model-profile">
<strong className="ecom-command-model-profile__title"></strong>
{cloneModelSelects.map((item) => (
<section key={item.key}>
<strong>{item.label}</strong>
<div>
{item.options.map((option) => (
<button key={option} type="button" className={item.value === option ? "is-active" : ""} onClick={() => item.onChange(option)}>{option}</button>
))}
</div>
</section>
))}
</div>
</>
) : cloneOutput === "video" ? (
<>
<header><strong></strong><span>{cloneVideoDuration}</span></header>
<div className="ecom-command-module-grid ecom-command-module-grid--video">
{cloneVideoQualityOptions.map((option) => (
<button key={option.key} type="button" className={cloneVideoQuality === option.key ? "is-active" : ""} onClick={() => setCloneVideoQuality(option.key)}>
<strong>{option.label}</strong><span>{option.desc}</span>
</button>
))}
</div>
<label className="ecom-command-range">
<span> {cloneVideoDuration}</span>
<input type="range" min={cloneVideoDurationMin} max={cloneVideoDurationMax} step={5} value={cloneVideoDuration} onChange={(event) => setCloneVideoDuration(clampCloneVideoDuration(Number(event.target.value)))} />
</label>
</>
) : (
<>
<header><strong></strong><span></span></header>
<p className="ecom-command-popover-note"></p>
</>
)}
</div>
);
};
const toggleComposerMenu = (menuKey: ComposerMenuKey, event: ReactMouseEvent<HTMLButtonElement>) => {
const composerRect = event.currentTarget.closest(".clone-ai-input-wrapper.ecom-command-composer")?.getBoundingClientRect();
const buttonRect = event.currentTarget.getBoundingClientRect();
const composerLeft = composerRect?.left ?? buttonRect.left;
const composerTop = composerRect?.top ?? buttonRect.top;
setComposerPopoverLeft(Math.max(0, buttonRect.left - composerLeft));
setComposerPopoverTop(Math.max(0, buttonRect.bottom - composerTop + 8));
setComposerMenu((menu) => (menu === menuKey ? null : menuKey));
};
const canPlanVideo = productImages.length > 0 || requirement.trim().length > 0;
const commandGenerateDisabled = cloneOutput === "video" ? false : !canGenerate;
function handleStartVideoPlan() {
if (!canPlanVideo) {
toast.info("请先上传商品图或填写短视频需求");
return;
}
if (!isAuthenticated) {
requestLogin();
return;
}
setIsVideoWorkspaceVisible(true);
setVideoPlanTrigger((value) => value + 1);
}
const handleCommandGenerate = () => {
if (cloneOutput === "video") {
handleStartVideoPlan();
return;
}
handleGenerate();
};
const showMainVideoWorkspace = cloneOutput === "video" && isVideoWorkspaceVisible;
const scrollInspirationRow = (event: ReactMouseEvent<HTMLButtonElement>, direction: -1 | 1) => {
const row = event.currentTarget.closest(".ecom-inspiration-row");
const strip = row?.querySelector<HTMLElement>(".ecom-inspiration-strip");
if (!strip) return;
strip.scrollBy({ left: direction * Math.max(280, strip.clientWidth * 0.78), behavior: "smooth" });
};
const applyInspirationPrompt = (prompt: string) => {
const nextValue = prompt.slice(0, 500);
// 回到主指令栏(关闭可能打开的快捷工具页),把提示词填入并聚焦。
setActiveQuickTool(null);
setRequirement(nextValue);
syncRequirementMentionQuery(nextValue, nextValue.length);
setInspirationPreview(null);
requestAnimationFrame(() => {
const textarea = requirementTextareaRef.current;
if (textarea) {
textarea.focus();
textarea.setSelectionRange(nextValue.length, nextValue.length);
textarea.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
toast.success("提示词已填入指令栏");
};
const applyComposerPrompt = (prompt: string) => {
const nextValue = prompt.slice(0, 500);
setActiveQuickTool(null);
setComposerMenu(null);
setRequirement(nextValue);
syncRequirementMentionQuery(nextValue, nextValue.length);
setInspirationPreview(null);
requestAnimationFrame(() => {
const textarea = requirementTextareaRef.current;
if (textarea) {
textarea.focus();
textarea.setSelectionRange(nextValue.length, nextValue.length);
textarea.scrollIntoView({ behavior: "smooth", block: "center" });
}
window.setTimeout(() => {
const latestTextarea = requirementTextareaRef.current;
if (!latestTextarea) return;
latestTextarea.focus();
latestTextarea.setSelectionRange(nextValue.length, nextValue.length);
}, 80);
});
};
const addTemplateImageToComposer = async (card: CloneTemplateAsset) => {
if (productImages.length >= maxCloneProductImages) {
toast.info("模板图片已达上限");
return;
}
try {
const stamp = Date.now();
const uploaded = await aiGenerationClient.uploadAssetByUrl({
sourceUrl: card.mediaUrl,
name: `${card.id}-${stamp}`,
scope: ecommerceOssScopes.productSource,
});
const nextImage: CloneImageItem = {
id: `template-${card.id}-${stamp}`,
src: uploaded.url || card.mediaUrl,
name: card.title,
ossKey: uploaded.ossKey,
};
setProductImages((current) => [...current, nextImage].slice(0, maxCloneProductImages));
void readImageDimensions(nextImage.src)
.then(({ width, height }) => {
setProductImages((current) =>
current.map((item) => (item.id === nextImage.id ? { ...item, width, height } : item)),
);
})
.catch(() => undefined);
} catch {
toast.error("模板图片导入失败");
}
};
const handleCloneTemplateCardClick = (card: CommerceScenarioTemplate) => {
if (card.output !== cloneOutput) handleCloneOutputChange(card.output);
setIsCloneTemplateStripVisible(true);
setComposerMenu(null);
void addTemplateImageToComposer(card);
applyComposerPrompt(card.prompt);
};
const inspirationPreviewOverlay =
inspirationPreview && typeof document !== "undefined"
? createPortal(
<div
className="ecom-inspiration-preview"
onClick={() => setInspirationPreview(null)}
onWheel={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<div className="ecom-inspiration-preview__backdrop" />
<div className="ecom-inspiration-preview__content" onClick={(event) => event.stopPropagation()}>
{inspirationPreview.mediaType === "video" ? (
<video src={inspirationPreview.mediaUrl} controls autoPlay loop playsInline className="ecom-inspiration-preview__media" />
) : (
<img src={inspirationPreview.mediaUrl} alt="" className="ecom-inspiration-preview__media" />
)}
{inspirationPreview.prompt ? (
<div className="ecom-inspiration-preview__actions">
<button
type="button"
className="ecom-inspiration-preview__use-prompt"
onClick={() => applyInspirationPrompt(inspirationPreview.prompt)}
>
<EditOutlined />
<span>使</span>
</button>
</div>
) : null}
</div>
</div>,
document.body,
)
: null;
const clonePreview = (
<main
className={`product-clone-preview clone-ai-preview${showMainVideoWorkspace ? " has-main-video-workspace" : ""}`}
data-status={status}
aria-label="电商 AI 作图预览"
{...getPreviewSurfaceProps()}
>
<header className="clone-ai-preview-header">
<strong></strong>
<span>
AI <b></b>
</span>
<div className="clone-ai-preview-zoom">
<button type="button" onClick={() => setPreviewZoom((z) => Math.max(0.25, z - 0.1))} disabled={previewZoom <= 0.25} aria-label="缩小">-</button>
<span>{Math.round(previewZoom * 100)}%</span>
<button type="button" onClick={() => setPreviewZoom((z) => Math.min(2, z + 0.1))} disabled={previewZoom >= 2} aria-label="放大">+</button>
{activeHistoryRecordId ? (
<button type="button" onClick={() => { setPreviewZoom(1); setPreviewOffset({ x: 0, y: 0 }); }} aria-label="重置画布"></button>
) : null}
</div>
</header>
{cloneOutput === "video" ? (
showMainVideoWorkspace ? (
<section className="clone-ai-main-video-workspace" style={previewTransformStyle} aria-label="短视频生成画布">
<EcommerceVideoWorkspace
isAuthenticated={isAuthenticated}
productImageDataUrls={ecommerceVideoImageDataUrls}
productImageFiles={ecommerceVideoImageFiles}
requirement={requirement}
platform={platform}
aspectRatio={ratio.includes("9:16") ? "9:16" : ratio.includes("16:9") ? "16:9" : ratio.includes("3:4") ? "3:4" : "9:16"}
durationSeconds={cloneVideoDuration}
resolution={cloneVideoQuality === "standard" ? "720P" : "1080P"}
onRequestLogin={() => (isAuthenticated ? undefined : requestLogin())}
onOpenHistory={() => setVideoHistoryVisible(true)}
triggerPlan={videoPlanTrigger}
/>
</section>
) : status === "idle" || status === "ready" ? null : (
<>
<section className="clone-ai-flow-pipeline" aria-label="生成流程">
{/* Source Node —原图素材 */}
<div className="clone-ai-flow-source">
<div className="clone-ai-flow-node clone-ai-flow-node--source">
{productImages[0]?.src ? (
<img src={productImages[0].src} alt="商品原图" />
) : (
<div className="clone-ai-flow-node__placeholder">
<FileImageOutlined />
</div>
)}
</div>
<span className="clone-ai-flow-node__label"></span>
</div>
{/* Connector - branch lines */}
<div className="clone-ai-flow-connector" aria-hidden="true">
<div className="clone-ai-flow-connector__trunk" />
<div className="clone-ai-flow-connector__branches">
<div className="clone-ai-flow-connector__branch" />
<div className="clone-ai-flow-connector__branch" />
<div className="clone-ai-flow-connector__branch" />
</div>
</div>
{/* Branches —生成路径分支 */}
{status === "done" ? (
<div className="clone-ai-flow-branches">
{results[0]?.src ? (
<div className="clone-ai-flow-branch">
<div className="clone-ai-flow-node clone-ai-flow-node--text">
<div className="clone-ai-flow-node__text-content">
<span className="clone-ai-flow-node__text-title">{selectedCloneOutput.label}</span>
<span className="clone-ai-flow-node__text-desc">{requirement || "AI 智能生成"}</span>
</div>
</div>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<button
type="button"
className="clone-ai-flow-node clone-ai-flow-node--result"
onClick={() => openProductSetPreview(results[0])}
>
<img src={results[0].src} alt={selectedCloneOutput.label} />
<span className="clone-ai-flow-node__tag">{selectedCloneOutput.label}</span>
</button>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<div className="clone-ai-flow-node clone-ai-flow-node--video">
<img src={results[0].src} alt="分镜视频" />
<span className="clone-ai-flow-node__tag clone-ai-flow-node__tag--accent"></span>
</div>
</div>
) : null}
</div>
) : (
<div className="clone-ai-flow-branches clone-ai-flow-branches--empty">
{[1, 2, 3].map((branchIndex) => (
<div
key={branchIndex}
className={`clone-ai-flow-branch${status === "generating" ? " is-generating" : ""}${status === "failed" ? " is-failed" : ""}`}
>
<div className="clone-ai-flow-node clone-ai-flow-node--text">
<div className="clone-ai-flow-node__text-content">
<span className="clone-ai-flow-node__text-title">{branchIndex}</span>
<span className="clone-ai-flow-node__text-desc">
{status === "generating" ? "AI 解析中..." : "等待生成"}
</span>
</div>
</div>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<div className="clone-ai-flow-node clone-ai-flow-node--result">
<div className="clone-ai-flow-node__placeholder">
{status === "generating" ? <LoadingOutlined /> : <FileImageOutlined />}
</div>
<span className="clone-ai-flow-node__tag">{branchIndex}</span>
</div>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<div className="clone-ai-flow-node clone-ai-flow-node--video">
<div className="clone-ai-flow-node__placeholder">
{status === "generating" ? <LoadingOutlined /> : <FileImageOutlined />}
</div>
<span className="clone-ai-flow-node__tag">{branchIndex}</span>
</div>
</div>
))}
</div>
)}
</section>
{/* Status Overlay —生成状态覆盖层 */}
{status === "generating" || status === "failed" ? (
<section className="clone-ai-flow-status" aria-live="polite">
{status === "generating" ? (
<>
<LoadingOutlined style={{ fontSize: 28 }} />
<strong></strong>
<EcommerceProgressBar status="generating" progress={generationProgress} onCancel={handleCancelGenerate} label={`${selectedCloneOutput.label}生成`} />
<span>AI {platform} / {market} {selectedCloneOutput.label}</span>
</>
) : status === "failed" ? (
<>
<FrownOutlined style={{ fontSize: 28 }} />
<strong></strong>
<span></span>
{lastFailedActionRef.current ? (
<button type="button" className="clone-ai-retry-btn" onClick={lastFailedActionRef.current}>
<ReloadOutlined />
</button>
) : null}
</>
) : null}
</section>
) : null}
</>
)
) : (
<>
{status === "done" || canvasNodes.length > 0 ? (
<div className="clone-ai-preview-zoom-wrap" style={previewTransformStyle}>
<section className="clone-ai-canvas-nodes" aria-label="生成结果">
{canvasNodes.map((node) => (
<article
key={node.id}
className="clone-ai-canvas-node"
data-mode={node.mode}
data-node-id={node.id}
style={{ transform: `translate(${node.x}px, ${node.y}px)` }}
onPointerDown={(event) => startCanvasNodeDrag(event, node)}
onPointerMove={(event) => moveCanvasNodeDrag(event, node.id)}
onPointerUp={(event) => stopCanvasNodeDrag(event, node.id)}
onPointerCancel={(event) => stopCanvasNodeDrag(event, node.id)}
>
<div
className="clone-ai-node-drag-handle"
onPointerDown={(e) => {
if (e.button !== 0) return;
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
nodeDragRef.current = { active: true, nodeId: node.id, startX: e.clientX, startY: e.clientY, originX: node.x, originY: node.y };
}}
onPointerMove={(e) => {
const drag = nodeDragRef.current;
if (!drag.active || drag.nodeId !== node.id) return;
const zoom = previewZoomRef.current;
const dx = (e.clientX - drag.startX) / zoom;
const dy = (e.clientY - drag.startY) / zoom;
setCanvasNodes((prev) => prev.map((n) => n.id === node.id ? { ...n, x: drag.originX + dx, y: drag.originY + dy } : n));
}}
onPointerUp={(e) => {
if (nodeDragRef.current.nodeId === node.id) {
nodeDragRef.current = { ...nodeDragRef.current, active: false };
e.currentTarget.releasePointerCapture(e.pointerId);
}
}}
/>
{node.sourceImage ? (
<div className="clone-ai-source-stack">
<button
type="button"
className="clone-ai-source-corner-action"
onClick={() => openProductSetPreview({ src: node.sourceImage!, label: "原图素材" })}
>
</button>
<button
type="button"
className="clone-ai-main-result"
aria-label="预览原图素材"
onClick={() => openProductSetPreview({ src: node.sourceImage!, label: "原图素材" })}
>
<img src={node.sourceImage} alt="原图素材" />
</button>
</div>
) : null}
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<div className="clone-ai-result-stack">
<span className="clone-ai-node-label">{node.mode === "set" ? "套图" : node.mode === "detail" ? "详情图" : node.mode === "model" ? "模特图" : node.mode === "hot" ? "爆款图" : node.mode}</span>
<div className="clone-ai-result-grid result-reveal">
{node.results.map((card) => (
<button key={card.id} type="button" style={{ aspectRatio: parseRatioToAspectCss(ratio) }} onClick={() => openProductSetPreview(card, { nodeId: node.id, removable: true })}>
<img src={card.src} alt={card.label} />
</button>
))}
</div>
</div>
</article>
))}
{status === "generating" ? (
<article className="clone-ai-canvas-node is-generating" style={{ transform: `translate(${canvasNodes.length * 420}px, 0px)` }}>
<LoadingOutlined style={{ fontSize: 24 }} />
<span>{selectedCloneOutput.label}</span>
</article>
) : null}
</section>
</div>
) : status === "idle" || status === "ready" ? null : (
<section className="clone-ai-empty-state" aria-live="polite">
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <FrownOutlined /> : <FileImageOutlined />}
<strong>{status === "generating" ? "正在生成" : status === "failed" ? "生成失败" : "等待生成"}</strong>
{status === "generating" ? <EcommerceProgressBar status="generating" progress={generationProgress} onCancel={handleCancelGenerate} label={`${selectedCloneOutput.label}生成`} /> : null}
<span>
{status === "generating"
? "AI 正在为 " + platform + " / " + market + " 整理" + selectedCloneOutput.label + "。"
: status === "failed"
? "请检查网络后点击下方重试"
: "上传商品原图并填写信息后,AI 将在这里展示生成结果。"}
</span>
{status === "failed" && lastFailedActionRef.current ? (
<button type="button" className="clone-ai-retry-btn" onClick={lastFailedActionRef.current}>
<ReloadOutlined />
</button>
) : null}
</section>
)}
</>
)}
<section
ref={commandComposerWrapRef}
className={`clone-ai-bottom-input ecom-command-composer-wrap${status === "done" || canvasNodes.length > 0 ? " has-generated" : " is-before-generate"}${isCommandComposerCompact && (status === "done" || canvasNodes.length > 0) ? " is-compact" : ""}`}
aria-label="生成指令"
onClick={() => {
if (isCommandComposerCompact) setIsCommandComposerCompact(false);
}}
>
<h1 className={`ecom-command-title${status === "done" || canvasNodes.length > 0 ? " is-after-generate" : ""}`}>
{typewriterText}
<span className="typewriter-cursor" aria-hidden="true">|</span>
</h1>
<input
ref={cloneReferenceInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleCloneReferenceUpload}
aria-label="上传参考图片"
/>
<input
ref={productInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleComposerAssetUpload}
aria-label="上传商品素材"
/>
<input
ref={smartCutoutInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleSmartCutoutUpload}
aria-label="上传智能抠图素材"
/>
<div className="ecom-command-mode-tabs ecom-command-scenario-tabs" aria-label="电商场景">
{commerceScenarioOptions.map((option) => (
<button
key={option.key}
type="button"
className={`${activeCommerceScenario === option.key ? "is-active" : ""}${activeCommerceScenario === option.key && isCloneTemplateStripVisible ? " is-open" : ""}`}
onClick={() => handleCommerceScenarioClick(option.key)}
>
<span className={`ecom-command-mode-icon ecom-command-mode-icon--${option.key}`} aria-hidden="true">{option.icon}</span>
<strong>{option.label}</strong>
{activeCommerceScenario === option.key && isCloneTemplateStripVisible ? (
<span className="ecom-command-scenario-close" aria-hidden="true"><CloseOutlined /></span>
) : null}
</button>
))}
</div>
<span className="ecom-command-scenario-scroll-hint" aria-hidden="true"></span>
<div className="clone-ai-input-wrapper ecom-command-composer">
{productImages.length ? (
<div className="ecom-command-asset-popover" aria-label={`已上传素材,${productImages.length}/${maxCloneProductImages}`}>
<button
type="button"
className="ecom-command-asset-add"
onClick={() => productImages.length < maxCloneProductImages && productInputRef.current?.click()}
disabled={productImages.length >= maxCloneProductImages}
aria-label={productImages.length >= maxCloneProductImages ? `最多上传${maxCloneProductImages}张素材` : "继续上传素材"}
title={productImages.length >= maxCloneProductImages ? `最多上传 ${maxCloneProductImages} 张素材` : `继续上传素材 ${productImages.length}/${maxCloneProductImages}`}
>
<span aria-hidden="true">+</span>
<small>{productImages.length >= maxCloneProductImages ? "已满" : "上传"}</small>
</button>
{productImages.map((image) => (
<figure key={image.id} className="ecom-command-asset-thumb">
<img src={image.src} alt={image.name || "上传图片"} />
<button type="button" onClick={() => removeProductImage(image.id)} aria-label="删除图片">
<DeleteOutlined />
</button>
</figure>
))}
</div>
) : null}
<div className="ecom-command-option-row ecom-command-option-row--settings">
<button type="button" className={composerMenu === "platform" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("platform", event)}>
<span className="ecom-command-option-icon" aria-hidden="true"><GlobalOutlined /></span>
<span></span>{platform}
</button>
<button type="button" className={composerMenu === "language" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("language", event)}>
<span className="ecom-command-option-icon" aria-hidden="true"><FileImageOutlined /></span>
<span></span>{language}
</button>
<button type="button" className={composerMenu === "ratio" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("ratio", event)}>
<span className="ecom-command-option-icon" aria-hidden="true"><TableOutlined /></span>
<span></span>{formatRatioDisplayValue(ratio)}
</button>
<button type="button" className={composerMenu === "settings" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("settings", event)}>
<span className="ecom-command-option-icon" aria-hidden="true"><SettingOutlined /></span>
<span></span>{composerSettingLabel}
</button>
</div>
<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={cloneRequirementPlaceholder}
/>
{requirementImageMentionQuery !== null && ecommerceMentionImages.length ? (
<ImageMentionMenu images={ecommerceMentionImages} query={requirementImageMentionQuery} onSelect={insertRequirementImageMention} />
) : null}
<div className="ecom-command-toolbar" aria-label="生成设置">
<div className="ecom-command-composer-actions">
<button
type="button"
className={`ecom-command-reference ecom-command-reference--bottom${productImages.length ? " has-images" : ""}${isProductUploadDragging ? " is-dragging" : ""}`}
onClick={() => productInputRef.current?.click()}
onDragEnter={(event) => {
event.preventDefault();
setIsProductUploadDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsProductUploadDragging(false)}
onDrop={(event) => {
event.preventDefault();
setIsProductUploadDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) addComposerAssets(files);
}}
>
<span aria-hidden="true"><PaperClipOutlined /></span>
<strong></strong>
</button>
</div>
<div className="ecom-command-submit-row">
<button type="button" className="clone-ai-send-button ecom-command-send" disabled={commandGenerateDisabled} onClick={handleCommandGenerate} aria-label={clonePrimaryLabel}>
{status === "generating" ? <LoadingOutlined /> : "➤"}
</button>
</div>
</div>
{renderComposerMenu()}
</div>
{(status === "idle" || status === "ready") && !showMainVideoWorkspace && isCloneTemplateStripVisible ? (
<section className={`ecom-command-template-strip ecom-command-template-strip--${activeCommerceScenario}`} aria-label="模板卡片">
{activeCommerceScenarioTemplates.map((card) => (
<button
key={card.id}
type="button"
className="ecom-command-template-card"
aria-label={card.title}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleCloneTemplateCardClick(card);
}}
>
<span className="ecom-command-template-card__media" aria-hidden="true">
<img src={card.mediaUrl} alt="" loading="lazy" />
</span>
<span className="ecom-command-template-card__body">
<span className="ecom-command-template-card__badge">{card.badge}</span>
<strong>{card.title}</strong>
<em>{card.desc}</em>
</span>
</button>
))}
</section>
) : null}
{(status === "idle" || status === "ready") && !showMainVideoWorkspace ? (
<section className="ecom-command-quick-board" aria-label="快捷功能">
{[
{ label: "A+/详情页", tone: "detail", icon: <LayoutOutlined />, onClick: openQuickDetailPage },
{ label: "爆款复刻", tone: "hot", icon: <FireOutlined />, onClick: openHotClonePage },
{ label: "图片修改", tone: "edit", icon: <EditOutlined />, onClick: openImageWorkbenchPage },
{ label: "智能抠图", tone: "cutout", icon: <ScissorOutlined />, onClick: openSmartCutoutUpload },
{ label: "去除水印", tone: "watermark", icon: <ClearOutlined />, onClick: openWatermarkRemovalPage },
{ label: "图片翻译", tone: "translate", icon: <GlobalOutlined />, onClick: openImageTranslatePage },
].map((item) => (
<button
key={item.label}
type="button"
className={`ecom-command-quick-card ecom-command-quick-card--${item.tone}`}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
item.onClick?.();
}}
>
<span aria-hidden="true">{item.icon}</span>
<strong>{item.label}</strong>
</button>
))}
</section>
) : null}
{(status === "idle" || status === "ready") && !showMainVideoWorkspace ? (
<section className="ecom-inspiration-lab" aria-label="电商灵感案例">
<div className="ecom-inspiration-rows">
{ecommerceInspirationRows.map((row) => (
<section key={row.title} className={`ecom-inspiration-row ecom-inspiration-row--${row.variant}`} aria-label={row.title}>
<div className="ecom-inspiration-row__meta">
<strong>{row.title}</strong>
<span>{row.desc}</span>
<div className="ecom-inspiration-row__controls">
<button type="button" aria-label={`向左浏览${row.title}`} onClick={(event) => scrollInspirationRow(event, -1)}></button>
<button type="button" aria-label={`向右浏览${row.title}`} onClick={(event) => scrollInspirationRow(event, 1)}></button>
</div>
</div>
<div className="ecom-inspiration-strip" tabIndex={0}>
{row.cards.map((card, index) => (
<article key={card.title} className="ecom-inspiration-card" onClick={() => setInspirationPreview({ mediaUrl: card.mediaUrl, mediaType: card.mediaType, prompt: buildInspirationPrompt(card.title, card.meta) })}>
<div className="ecom-inspiration-card__visual" aria-hidden="true">
{card.mediaType === "video" ? (
<video src={card.mediaUrl} muted playsInline loop autoPlay preload="metadata" />
) : (
<img src={card.mediaUrl} alt="" loading="lazy" />
)}
</div>
<strong>{card.title}</strong>
<p>{card.meta}</p>
<em>{String(index + 1).padStart(2, "0")}</em>
</article>
))}
</div>
</section>
))}
</div>
</section>
) : null}
</section>
{inspirationPreviewOverlay}
</main>
);
const smartCutoutPreview = (
<main key={`smart-cutout-${smartCutoutImage ? "editor" : "upload"}`} className={`ecom-smart-cutout-page ecom-tool-page-enter${smartCutoutImage ? " is-editor" : " is-upload"}${isSmartCutoutTransitioning ? " is-transitioning" : ""}`} aria-label="智能抠图">
<input
ref={smartCutoutInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleSmartCutoutUpload}
aria-label="上传智能抠图素材"
/>
<nav className="ecom-smart-cutout-nav" aria-label="智能抠图导航">
<button type="button" onClick={closeSmartCutoutTool}>
</button>
<button type="button" onClick={goSmartCutoutPrevious}>
</button>
</nav>
{isSmartCutoutTransitioning ? (
<div className="ecom-smart-cutout-transition" role="status" aria-live="polite">
<span aria-hidden="true" />
<strong>{smartCutoutTransitionMessage.title}</strong>
<em>{smartCutoutTransitionMessage.subtitle}</em>
</div>
) : null}
{!smartCutoutImage ? (
<section className="ecom-smart-cutout-upload">
<div className="ecom-smart-cutout-head">
<strong></strong>
<span>3s </span>
</div>
<div className="ecom-smart-cutout-upload__body">
<div className="ecom-smart-cutout-demo" aria-hidden="true">
<div className="ecom-smart-cutout-demo__tile ecom-smart-cutout-demo__tile--flower" />
<div className="ecom-smart-cutout-demo__tile ecom-smart-cutout-demo__tile--product" />
<div className="ecom-smart-cutout-demo__tile ecom-smart-cutout-demo__tile--poster" />
<div className="ecom-smart-cutout-demo__tile ecom-smart-cutout-demo__tile--object" />
</div>
<div
className={`ecom-smart-cutout-upload-box${isSmartCutoutDragging ? " is-dragging" : ""}`}
role="button"
tabIndex={0}
onClick={() => smartCutoutInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
smartCutoutInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsSmartCutoutDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsSmartCutoutDragging(false)}
onDrop={handleSmartCutoutDrop}
>
<button type="button" className="ecom-smart-cutout-upload__primary">
<CloudUploadOutlined />
</button>
<button type="button" className="ecom-smart-cutout-upload__secondary">
<FolderOpenOutlined />
</button>
<span> &gt;</span>
</div>
</div>
</section>
) : (
<section className="ecom-smart-editor">
<div className="ecom-smart-editor__workspace">
<div className="ecom-smart-editor__canvas">
<div className={`ecom-smart-editor__checker is-size-${previewSmartCutoutSizeKey}`} style={smartCutoutFrameStyle}>
<div className="ecom-smart-editor__background-layer" aria-hidden="true" />
<img src={previewSmartCutoutImageSrc ?? smartCutoutImage.src} alt={smartCutoutImage.name} />
</div>
<div className="ecom-smart-editor__canvas-actions">
<button
type="button"
onPointerDown={showSmartCutoutOriginalCompare}
onPointerUp={hideSmartCutoutOriginalCompare}
onPointerCancel={hideSmartCutoutOriginalCompare}
onBlur={hideSmartCutoutOriginalCompare}
onKeyDown={(event) => {
if (event.key === " " || event.key === "Enter") setIsSmartCutoutComparing(true);
}}
onKeyUp={hideSmartCutoutOriginalCompare}
>
</button>
</div>
</div>
<div className="ecom-smart-editor__tools-shell">
<strong className="ecom-smart-editor__tools-title"></strong>
<button type="button" className="ecom-smart-editor__tools-nav" onClick={() => scrollSmartCutoutTools(-1)} aria-label="查看上一组尺寸">
</button>
<div className="ecom-smart-editor__tools" ref={smartCutoutToolsRef}>
{smartCutoutSizeOptions.map((item) => (
<div className="ecom-smart-editor__tool-item" key={item.key}>
<button
type="button"
className={smartCutoutSizeKey === item.key ? "is-active" : ""}
onClick={() => setSmartCutoutSizeKey(item.key)}
aria-label={`${item.label}${"sizeLabel" in item ? ` ${item.sizeLabel}` : ""}`}
aria-pressed={smartCutoutSizeKey === item.key}
>
<span className={`ecom-smart-editor__tool-icon ecom-smart-editor__tool-icon--${item.icon}`} aria-hidden="true" />
</button>
<span className="ecom-smart-editor__tool-text">
<span>{item.label}</span>
{"sizeLabel" in item ? <span>{item.sizeLabel}</span> : null}
</span>
</div>
))}
</div>
<button type="button" className="ecom-smart-editor__tools-nav" onClick={() => scrollSmartCutoutTools(1)} aria-label="查看更多尺寸">
</button>
</div>
{smartCutoutBatchImages.length > 1 ? (
<section className="ecom-smart-editor__batch" aria-label="批量图片">
<header>
<strong></strong>
<span>{smartCutoutBatchImages.length} </span>
</header>
<div>
{smartCutoutBatchImages.map((image, index) => (
<button
key={`${image.src}-${index}`}
type="button"
className={smartCutoutImage.src === image.src ? "is-active" : ""}
onClick={() => setSmartCutoutImage(image)}
>
<img src={image.src} alt={image.name || `上传图片 ${index + 1}`} />
<span>{index + 1}</span>
</button>
))}
</div>
</section>
) : null}
<section className="ecom-smart-editor__gallery">
<header><strong></strong><button type="button"> &gt;</button></header>
<div className="ecom-smart-editor__swatches">
{["#ffffff", "#eeeae3", "#f2e3cf", "#000000", "#a89682", "#c9c9c9"].map((color) => (
<button
key={color}
type="button"
className={smartCutoutBackgroundColor.toLowerCase() === color ? "is-active" : ""}
style={{ "--smart-cutout-swatch-bg": color } as CSSProperties}
onClick={() => {
setSmartCutoutBackgroundColor(color);
setSmartCutoutBackgroundAlpha(100);
}}
>
<span className="ecom-smart-editor__swatch-bg" aria-hidden="true" />
<img src={smartCutoutImage.src} alt="" />
</button>
))}
</div>
<header><strong>AI换背景</strong><button type="button"> &gt;</button></header>
<div className="ecom-smart-editor__scenes">
<button type="button" className="ecom-smart-editor__generate"><SettingOutlined /></button>
{["客厅陈列", "桌面日光", "香氛产品", "绿植窗边", "居家空间"].map((item) => (
<button key={item} type="button"><span>{item}</span></button>
))}
</div>
</section>
</div>
<aside className="ecom-smart-editor__side">
<strong></strong>
<div className="ecom-smart-editor__color-row">
<div className="ecom-smart-editor__color-wrap" ref={smartCutoutPaletteRef} style={{ "--smart-cutout-bg": smartCutoutBackgroundValue } as CSSProperties}>
<button
type="button"
className={`ecom-smart-editor__custom-color${isSmartCutoutPaletteOpen ? " is-active" : ""}`}
style={{ background: smartCutoutBackgroundColor }}
onClick={() => setIsSmartCutoutPaletteOpen((open) => !open)}
aria-label="打开背景调色盘"
>
<span></span>
</button>
{isSmartCutoutPaletteOpen ? (
<div className="ecom-smart-color-picker" role="dialog" aria-label="背景调色盘">
<button
type="button"
className="ecom-smart-color-picker__plane"
style={{ background: `linear-gradient(to top, #000000, transparent), linear-gradient(to right, #ffffff, hsl(${smartCutoutColorHsv.h} 100% 50%))` }}
onPointerDown={handleSmartCutoutColorPlanePointer}
onPointerMove={handleSmartCutoutColorPlaneMove}
aria-label="选择颜色明度和饱和度"
>
<span style={{ left: `${smartCutoutColorHsv.s}%`, top: `${100 - smartCutoutColorHsv.v}%` }} />
</button>
<div className="ecom-smart-color-picker__slider ecom-smart-color-picker__slider--hue">
<span style={{ background: `hsl(${smartCutoutColorHsv.h} 100% 50%)` }} />
<input
type="range"
min={0}
max={360}
value={smartCutoutColorHsv.h}
onChange={(event) => applySmartCutoutHsv(Number(event.target.value), smartCutoutColorHsv.s, smartCutoutColorHsv.v)}
aria-label="色相"
/>
</div>
<div className="ecom-smart-color-picker__slider ecom-smart-color-picker__slider--alpha">
<span style={{ background: smartCutoutBackgroundValue }} />
<input
type="range"
min={0}
max={100}
value={smartCutoutBackgroundAlpha}
onChange={(event) => setSmartCutoutBackgroundAlpha(Number(event.target.value))}
aria-label="透明度"
/>
</div>
<div className="ecom-smart-color-picker__fields">
<span aria-hidden="true"></span>
<input
value={smartCutoutHexDraft}
onChange={(event) => handleSmartCutoutHexChange(event.target.value)}
onBlur={() => setSmartCutoutHexDraft(smartCutoutBackgroundColor)}
aria-label="HEX 色值"
/>
<input
value={Math.round(smartCutoutBackgroundAlpha)}
onChange={(event) => setSmartCutoutBackgroundAlpha(clampNumber(Number(event.target.value) || 0, 0, 100))}
aria-label="透明度百分比"
/>
<strong>%</strong>
</div>
<p></p>
<div className="ecom-smart-color-picker__presets">
{smartCutoutColorPresets.map((color) => (
<button
key={color}
type="button"
className={smartCutoutBackgroundColor.toLowerCase() === color ? "is-active" : ""}
style={{ background: color }}
onClick={() => {
setSmartCutoutBackgroundColor(color);
setSmartCutoutBackgroundAlpha(100);
}}
aria-label={`选择颜色 ${color}`}
/>
))}
</div>
</div>
) : null}
</div>
{["#ffffff", "#f8f9fa", "#000000", "#bdbdbd"].map((color) => (
<button
key={color}
type="button"
className={smartCutoutBackgroundColor.toLowerCase() === color ? "is-active" : ""}
style={{ background: color }}
onClick={() => {
setSmartCutoutBackgroundColor(color);
setSmartCutoutBackgroundAlpha(100);
}}
aria-label={`背景颜色 ${color}`}
/>
))}
</div>
<div className="ecom-smart-editor__side-actions">
<button type="button" className="ecom-smart-editor__download" onClick={handleSmartCutoutDownload}></button>
<button type="button" onClick={() => smartCutoutInputRef.current?.click()}></button>
</div>
</aside>
</section>
)}
</main>
);
const imageWorkbenchPreview = (
<main key="image-workbench" className="ecom-image-workbench-page ecom-tool-page-enter" aria-label="图片修改局部重绘">
<input
ref={imageWorkbenchInputRef}
type="file"
accept="image/*"
className="ecom-command-hidden-file"
onChange={handleImageWorkbenchUpload}
aria-label="上传图片"
/>
<aside className="ecom-image-workbench-side">
<header className="ecom-quick-set-panel-head ecom-image-workbench-panel-head">
<strong className="ecom-quick-set-page-title"></strong>
<button type="button" className="ecom-quick-set-back" onClick={closeImageWorkbenchPage}></button>
<button type="button" className="ecom-quick-set-back" onClick={closeImageWorkbenchPage}></button>
</header>
<p className="ecom-image-workbench-intro"> AI </p>
<section className="ecom-image-workbench-panel">
<header>
<strong></strong>
<span>{imageWorkbenchImage ? "已上传" : "待上传"}</span>
</header>
<div
className={`ecom-image-workbench-upload${isImageWorkbenchDragging ? " is-dragging" : ""}${imageWorkbenchImage ? " has-image" : ""}`}
role="button"
tabIndex={0}
onClick={() => imageWorkbenchInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
imageWorkbenchInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsImageWorkbenchDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsImageWorkbenchDragging(false)}
onDrop={handleImageWorkbenchDrop}
>
{imageWorkbenchImage ? (
<>
<button
type="button"
className="ecom-image-workbench-remove"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
removeImageWorkbenchImage();
}}
aria-label="删除素材"
>
×
</button>
<figure>
<img src={imageWorkbenchImage.src} alt={imageWorkbenchImage.name} />
</figure>
<div>
<strong>{imageWorkbenchImage.name}</strong>
<span>{imageWorkbenchImage.format || "PNG / JPG / WebP"}</span>
</div>
</>
) : (
<>
<FileImageOutlined />
<strong></strong>
<span>PNG / JPG / WebP</span>
</>
)}
</div>
<div className="ecom-image-workbench-url-row">
<input
ref={imageWorkbenchUrlInputRef}
placeholder="粘贴图片 URL"
aria-label="粘贴图片 URL"
onKeyDown={(event) => {
if (event.key === "Enter") void handleImageWorkbenchUrlImport();
}}
/>
<button type="button" onClick={() => void handleImageWorkbenchUrlImport()}></button>
</div>
</section>
<section className="ecom-image-workbench-panel">
<header>
<strong></strong>
<span>{imageWorkbenchMaskStrokes.length ? `已遮罩 ${imageWorkbenchMaskStrokes.length}` : "未遮罩"}</span>
</header>
<label className="ecom-image-workbench-slider">
<span></span>
<input
type="range"
min={10}
max={120}
value={imageWorkbenchBrushSize}
onChange={(event) => setImageWorkbenchBrushSize(Number(event.target.value))}
/>
<em>{imageWorkbenchBrushSize}px</em>
</label>
<button
type="button"
className="ecom-image-workbench-clear"
onClick={() => {
setImageWorkbenchMaskStrokes([]);
setImageWorkbenchBrushCursor(null);
clearImageWorkbenchMaskCanvas();
setImageWorkbenchStatus("idle");
setImageWorkbenchResultUrl(null);
}}
disabled={!imageWorkbenchMaskStrokes.length}
>
</button>
</section>
<section className="ecom-image-workbench-panel">
<strong></strong>
<div className="ecom-image-workbench-ratios" aria-label="输出分辨率">
{["9:16", "16:9", "4:3", "3:4", "1:1"].map((item) => (
<button
key={item}
type="button"
className={imageWorkbenchRatio === item ? "is-active" : ""}
onClick={() => setImageWorkbenchRatio(item)}
>
{item}
</button>
))}
</div>
</section>
<section className="ecom-image-workbench-panel">
<strong></strong>
<textarea
value={imageWorkbenchPrompt}
onChange={(event) => setImageWorkbenchPrompt(event.target.value.slice(0, 300))}
placeholder="描述需要重绘的内容,例如:将背景替换为森林"
/>
</section>
<button
type="button"
className="ecom-image-workbench-primary"
onClick={handleImageWorkbenchGenerate}
disabled={!imageWorkbenchImage || imageWorkbenchStatus === "processing"}
>
{imageWorkbenchStatus === "processing" ? <LoadingOutlined /> : <SettingOutlined />}
{imageWorkbenchStatus === "processing" ? "重绘中" : "开始重绘"}
</button>
</aside>
<section className="ecom-image-workbench-stage">
{!imageWorkbenchImage ? (
<div
className={`ecom-watermark-dropzone${isImageWorkbenchDragging ? " is-dragging" : ""}`}
role="button"
tabIndex={0}
onClick={() => imageWorkbenchInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
imageWorkbenchInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsImageWorkbenchDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsImageWorkbenchDragging(false)}
onDrop={handleImageWorkbenchDrop}
>
<CloudUploadOutlined />
<strong></strong>
<span> PNG / JPG / WebP使</span>
</div>
) : (
<div className="ecom-watermark-grid">
<article className="ecom-watermark-preview-card">
<span> / </span>
<div
className="ecom-image-workbench-image-frame"
onPointerDown={handleImageWorkbenchMaskPointerDown}
onPointerMove={handleImageWorkbenchMaskPointerMove}
onPointerUp={stopImageWorkbenchMaskPainting}
onPointerCancel={stopImageWorkbenchMaskPainting}
onPointerLeave={() => {
stopImageWorkbenchMaskPainting();
setImageWorkbenchBrushCursor(null);
}}
>
<img src={imageWorkbenchImage.src} alt="局部重绘素材" draggable={false} />
<canvas ref={imageWorkbenchMaskCanvasRef} className="ecom-image-workbench-mask-layer" aria-label="遮罩区域" />
<span
className="ecom-image-workbench-brush"
style={{
left: `${imageWorkbenchBrushCursor?.x ?? 50}%`,
top: `${imageWorkbenchBrushCursor?.y ?? 50}%`,
width: imageWorkbenchBrushSize,
height: imageWorkbenchBrushSize,
}}
/>
</div>
</article>
<article className="ecom-watermark-preview-card">
<span></span>
{imageWorkbenchStatus === "processing" ? (
<div className="ecom-watermark-processing" role="status" aria-live="polite">
<LoadingOutlined />
<strong></strong>
<em>AI </em>
<div className="ecom-quick-set-progress">
<div className="ecom-quick-set-progress-bar" style={{ width: `${Math.round(imageWorkbenchProgress)}%` }} />
</div>
<em className="ecom-quick-set-progress-text">{Math.round(imageWorkbenchProgress)}%</em>
</div>
) : imageWorkbenchStatus === "done" && imageWorkbenchResultUrl ? (
<>
<img src={imageWorkbenchResultUrl} alt="重绘结果" />
</>
) : imageWorkbenchStatus === "failed" ? (
<div className="ecom-watermark-empty">
<FrownOutlined />
<strong></strong>
<em></em>
</div>
) : (
<div className="ecom-watermark-empty">
<FileImageOutlined />
<strong></strong>
<em></em>
</div>
)}
<div className="ecom-watermark-actions">
<button type="button" onClick={() => toast.success("已加入资产库")} disabled={imageWorkbenchStatus !== "done"}>
<FolderOpenOutlined />
</button>
<button type="button" onClick={() => {
if (!imageWorkbenchResultUrl) return;
const link = document.createElement("a");
link.href = imageWorkbenchResultUrl;
link.download = `inpaint-result-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
link.remove();
}} disabled={imageWorkbenchStatus !== "done"}>
<CloudUploadOutlined />
</button>
</div>
</article>
</div>
)}
</section>
</main>
);
const watermarkPreview = (
<main key="watermark" className="ecom-watermark-page ecom-tool-page-enter" aria-label="去水印">
<input
ref={watermarkInputRef}
type="file"
accept="image/*"
className="ecom-command-hidden-file"
onChange={handleWatermarkUpload}
aria-label="上传去水印图片"
/>
<aside className="ecom-watermark-side">
<header className="ecom-quick-set-panel-head ecom-watermark-panel-head">
<strong className="ecom-quick-set-page-title"></strong>
<button type="button" className="ecom-quick-set-back" onClick={closeWatermarkRemovalPage}></button>
<button type="button" className="ecom-quick-set-back" onClick={closeWatermarkRemovalPage}></button>
</header>
<p className="ecom-watermark-intro"></p>
<section className="ecom-watermark-panel">
<header>
<strong></strong>
<span>{watermarkImage ? "已上传" : "待上传"}</span>
</header>
<div
className={`ecom-watermark-upload-card${isWatermarkDragging ? " is-dragging" : ""}${watermarkImage ? " has-image" : ""}`}
role="button"
tabIndex={0}
onClick={() => watermarkInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
watermarkInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsWatermarkDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsWatermarkDragging(false)}
onDrop={handleWatermarkDrop}
>
{watermarkImage ? (
<>
<button
type="button"
className="ecom-watermark-remove"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
removeWatermarkImage();
}}
aria-label="删除素材"
>
×
</button>
<figure>
<img src={watermarkImage.src} alt={watermarkImage.name} />
</figure>
<div>
<strong>{watermarkImage.name}</strong>
<span>{watermarkImage.format || "PNG / JPG / WebP"}</span>
</div>
</>
) : (
<>
<CloudUploadOutlined />
<strong></strong>
<span> PNG / JPG / WebP</span>
</>
)}
</div>
<div className="ecom-watermark-url-row">
<input
ref={watermarkUrlInputRef}
placeholder="粘贴图片 URL"
aria-label="粘贴图片 URL"
onKeyDown={(event) => {
if (event.key === "Enter") void handleWatermarkUrlImport();
}}
/>
<button type="button" onClick={() => void handleWatermarkUrlImport()}></button>
</div>
</section>
<section className="ecom-watermark-panel">
<strong></strong>
<p></p>
</section>
<button
type="button"
className="ecom-watermark-primary"
onClick={handleWatermarkGenerate}
disabled={!watermarkImage || watermarkStatus === "processing"}
>
{watermarkStatus === "processing" ? <LoadingOutlined /> : <FileImageOutlined />}
{watermarkStatus === "processing" ? "处理中" : "开始去水印"}
</button>
</aside>
<section className="ecom-watermark-workspace">
{!watermarkImage ? (
<div
className={`ecom-watermark-dropzone${isWatermarkDragging ? " is-dragging" : ""}`}
role="button"
tabIndex={0}
onClick={() => watermarkInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
watermarkInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsWatermarkDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsWatermarkDragging(false)}
onDrop={handleWatermarkDrop}
>
<CloudUploadOutlined />
<strong></strong>
<span> PNG / JPG / WebP</span>
</div>
) : (
<div className="ecom-watermark-grid">
<article className="ecom-watermark-preview-card">
<span></span>
<img src={watermarkImage.src} alt="原图" />
</article>
<article className="ecom-watermark-preview-card">
<span></span>
{watermarkStatus === "processing" ? (
<div className="ecom-watermark-processing" role="status" aria-live="polite">
<LoadingOutlined />
<strong></strong>
<em>AI </em>
<div className="ecom-quick-set-progress">
<div className="ecom-quick-set-progress-bar" style={{ width: `${Math.round(watermarkProgress)}%` }} />
</div>
<em className="ecom-quick-set-progress-text">{Math.round(watermarkProgress)}%</em>
</div>
) : watermarkStatus === "done" && watermarkResultUrl ? (
<>
<img src={watermarkResultUrl} alt="去水印结果" />
<button type="button" className="ecom-watermark-zoom" aria-label="查看大图">
<QuestionCircleOutlined />
</button>
</>
) : watermarkStatus === "failed" ? (
<div className="ecom-watermark-empty">
<FrownOutlined />
<strong></strong>
<em></em>
</div>
) : (
<div className="ecom-watermark-empty">
<FileImageOutlined />
<strong></strong>
<em></em>
</div>
)}
<div className="ecom-watermark-actions">
<button type="button" onClick={() => toast.success("已加入资产库")} disabled={watermarkStatus !== "done"}>
<FolderOpenOutlined />
</button>
<button type="button" onClick={handleWatermarkDownload} disabled={watermarkStatus !== "done"}>
<CloudUploadOutlined />
</button>
</div>
</article>
</div>
)}
</section>
</main>
);
const translateLanguageOptions = [
{ value: "zh", label: "中文" },
{ value: "en", label: "English" },
{ value: "ja", label: "日本語" },
{ value: "ko", label: "한국어" },
{ value: "fr", label: "Français" },
{ value: "de", label: "Deutsch" },
{ value: "es", label: "Español" },
{ value: "pt", label: "Português" },
{ value: "ru", label: "Русский" },
{ value: "ar", label: "العربية" },
];
const translatePreview = (
<main key="translate" className="ecom-watermark-page ecom-translate-page ecom-tool-page-enter" aria-label="图片翻译">
<input
ref={translateInputRef}
type="file"
accept="image/*"
className="ecom-command-hidden-file"
onChange={handleTranslateUpload}
aria-label="上传翻译图片"
/>
<aside className="ecom-watermark-side">
<header className="ecom-quick-set-panel-head ecom-watermark-panel-head">
<strong className="ecom-quick-set-page-title"></strong>
<button type="button" className="ecom-quick-set-back" onClick={closeImageTranslatePage}></button>
<button type="button" className="ecom-quick-set-back" onClick={closeImageTranslatePage}></button>
</header>
<p className="ecom-watermark-intro">AI </p>
<section className="ecom-watermark-panel ecom-translate-lang-panel">
<header>
<strong></strong>
</header>
<select
className="ecom-translate-lang-select"
value={translateLanguage}
onChange={(event) => setTranslateLanguage(event.target.value)}
aria-label="选择目标语言"
>
{translateLanguageOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</section>
<section className="ecom-watermark-panel">
<header>
<strong></strong>
<span>{translateImage ? "已上传" : "待上传"}</span>
</header>
<div
className={`ecom-watermark-upload-card${isTranslateDragging ? " is-dragging" : ""}${translateImage ? " has-image" : ""}`}
role="button"
tabIndex={0}
onClick={() => translateInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
translateInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsTranslateDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsTranslateDragging(false)}
onDrop={handleTranslateDrop}
>
{translateImage ? (
<>
<button
type="button"
className="ecom-watermark-remove"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
removeTranslateImage();
}}
aria-label="删除素材"
>
×
</button>
<figure>
<img src={translateImage.src} alt={translateImage.name} />
</figure>
<div>
<strong>{translateImage.name}</strong>
<span>{translateImage.format || "PNG / JPG / WebP"}</span>
</div>
</>
) : (
<>
<CloudUploadOutlined />
<strong></strong>
<span> PNG / JPG / WebP</span>
</>
)}
</div>
<div className="ecom-watermark-url-row">
<input
ref={translateUrlInputRef}
placeholder="粘贴图片 URL"
aria-label="粘贴图片 URL"
onKeyDown={(event) => {
if (event.key === "Enter") void handleTranslateUrlImport();
}}
/>
<button type="button" onClick={() => void handleTranslateUrlImport()}></button>
</div>
</section>
<section className="ecom-watermark-panel">
<strong></strong>
<p></p>
</section>
<button
type="button"
className="ecom-watermark-primary"
onClick={handleTranslateGenerate}
disabled={!translateImage || translateStatus === "processing"}
>
{translateStatus === "processing" ? <LoadingOutlined /> : <GlobalOutlined />}
{translateStatus === "processing" ? "翻译中" : "开始翻译"}
</button>
</aside>
<section className="ecom-watermark-workspace">
{!translateImage ? (
<div
className={`ecom-watermark-dropzone${isTranslateDragging ? " is-dragging" : ""}`}
role="button"
tabIndex={0}
onClick={() => translateInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
translateInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsTranslateDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsTranslateDragging(false)}
onDrop={handleTranslateDrop}
>
<CloudUploadOutlined />
<strong></strong>
<span> PNG / JPG / WebP</span>
</div>
) : (
<div className="ecom-watermark-grid">
<article className="ecom-watermark-preview-card">
<span></span>
<img src={translateImage.src} alt="原图" />
</article>
<article className="ecom-watermark-preview-card">
<span></span>
{translateStatus === "processing" ? (
<div className="ecom-watermark-processing" role="status" aria-live="polite">
<LoadingOutlined />
<strong></strong>
<em>AI </em>
</div>
) : translateStatus === "done" && translateResultUrl ? (
<>
<img src={translateResultUrl} alt="翻译结果" />
<button type="button" className="ecom-watermark-zoom" aria-label="查看大图">
<QuestionCircleOutlined />
</button>
</>
) : translateStatus === "failed" ? (
<div className="ecom-watermark-empty">
<GlobalOutlined />
<strong></strong>
<em></em>
</div>
) : (
<div className="ecom-watermark-empty">
<GlobalOutlined />
<strong></strong>
<em></em>
</div>
)}
<div className="ecom-watermark-actions">
<button type="button" onClick={() => toast.success("已加入资产库")} disabled={translateStatus !== "done"}>
<FolderOpenOutlined />
</button>
<button type="button" onClick={handleTranslateDownload} disabled={translateStatus !== "done"}>
<CloudUploadOutlined />
</button>
</div>
</article>
</div>
)}
</section>
</main>
);
const openQuickUploadWithKeyboard = (
event: ReactKeyboardEvent<HTMLDivElement>,
inputRef: { current: HTMLInputElement | null },
) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
inputRef.current?.click();
};
const renderQuickUploadThumbs = (items: CloneImageItem[], onRemove: (imageId: string) => void) => (
<div className="ecom-quick-upload-thumbs" aria-label="已上传商品原图">
{items.map((item) => (
<figure key={item.id} className="ecom-command-asset-thumb ecom-quick-upload-thumb">
<img src={item.src} alt={item.name} />
<span className="ecom-command-asset-zoom" aria-hidden="true">
<img src={item.src} alt="" />
</span>
<button
type="button"
aria-label="删除图片"
onClick={(event) => {
event.stopPropagation();
onRemove(item.id);
}}
>
×
</button>
</figure>
))}
</div>
);
const quickDetailVisibleSelect = quickDetailBasicSelects.find((item) => item.key === visibleQuickSetSelect) ?? null;
const quickHotVisibleSelect = quickHotBasicSelects.find((item) => item.key === visibleQuickSetSelect) ?? null;
const quickDetailPreview = (
<main key="quick-detail" className={`ecom-quick-set-page ecom-quick-detail-page ecom-tool-page-enter${isQuickPanelCollapsed ? " is-panel-collapsed" : ""}`} aria-label="A+详情页生成">
<div className="ecom-quick-set-body">
<aside className="ecom-quick-set-panel" aria-label="A+详情页设置" onWheel={handleQuickPanelWheel}>
<header className="ecom-quick-set-panel-head">
<strong className="ecom-quick-set-page-title">A+/</strong>
<button type="button" className="ecom-quick-set-back" onClick={() => setActiveQuickTool(null)}>
</button>
<button type="button" className="ecom-quick-set-back" onClick={() => setActiveQuickTool(null)}>
</button>
</header>
<section>
<strong><CloudUploadOutlined /> </strong>
<div
role="button"
tabIndex={0}
className={`ecom-quick-set-upload${detailProductImages.length ? " has-images" : ""}`}
onClick={() => detailInputRef.current?.click()}
onKeyDown={(event) => openQuickUploadWithKeyboard(event, detailInputRef)}
onDragOver={(event) => event.preventDefault()}
onDrop={handleDetailDrop}
>
<FileImageOutlined />
<span></span>
<em> 3 </em>
<b>+ </b>
{detailProductImages.length ? renderQuickUploadThumbs(detailProductImages, removeDetailImage) : null}
</div>
<input
ref={detailInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleDetailUpload}
aria-label="上传详情页图片"
/>
</section>
<section className="ecom-quick-set-basic-section">
<span className="ecom-quick-set-label"></span>
<div className="ecom-quick-detail-types">
{detailTypeOptions.map((item) => (
<button
key={item}
type="button"
className={detailType === item ? "is-active" : ""}
onClick={() => setDetailType(item)}
>
{item}
</button>
))}
</div>
<span className="ecom-quick-set-label"></span>
<div className="ecom-quick-set-select-anchor">
<div className="ecom-quick-set-selects">
{quickDetailBasicSelects.map((item) => (
<button
key={item.key}
type="button"
className={openQuickSetSelect === item.key ? "is-active" : ""}
onClick={() => toggleQuickSetSelect(item.key)}
>
<span>{item.label}</span><strong>{formatRatioDisplayValue(item.value)}</strong><em></em>
</button>
))}
</div>
{quickDetailVisibleSelect ? (
<div
className={`ecom-quick-set-dropdown ecom-quick-set-dropdown--${quickDetailVisibleSelect.key}${isQuickSetSelectClosing ? " is-closing" : ""}`}
role="listbox"
aria-label={quickDetailVisibleSelect.label}
>
{quickDetailVisibleSelect.options.map((option) => (
<button
key={option}
type="button"
className={quickDetailVisibleSelect.value === option ? "is-active" : ""}
onClick={() => {
quickDetailVisibleSelect.onChange(option);
closeQuickSetSelect();
}}
>
{formatRatioDisplayValue(option)}
</button>
))}
</div>
) : null}
</div>
</section>
<section>
<strong></strong>
<div className="ecom-quick-detail-modules">
{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>
<button type="button" className="ecom-quick-set-primary" onClick={handleDetailGenerate} disabled={!canGenerateDetail}>
{detailStatus === "generating" ? <LoadingOutlined /> : "✦"}
</button>
{detailStatus === "generating" ? (
<button type="button" className="ecom-quick-set-primary ecom-quick-set-primary--cancel" onClick={handleCancelGenerate}></button>
) : null}
</aside>
<section className="ecom-quick-set-stage">
<header className="ecom-quick-set-preview-head">
<h1></h1>
<p>AI <span></span> </p>
<div>
<button type="button" onClick={() => setPreviewZoom((value) => Math.max(0.25, value - 0.1))}>-</button>
<strong>{Math.round(previewZoom * 100)}%</strong>
<button type="button" onClick={() => setPreviewZoom((value) => Math.min(2, value + 0.1))}>+</button>
</div>
</header>
<div className="ecom-quick-set-canvas" onWheel={handleQuickPreviewWheel}>
{detailStatus === "done" && detailResultUrl ? (
<section className="ecom-quick-detail-result" style={{ transform: `scale(${previewZoom})` }}>
<img src={detailResultUrl} alt="A+详情页生成结果" />
<button
type="button"
className="ecom-quick-detail-download"
onClick={() => {
const link = document.createElement("a");
link.href = detailResultUrl;
link.download = `A+详情页-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
link.remove();
}}
>
<CloudUploadOutlined />
</button>
</section>
) : detailStatus === "generating" ? (
<section className="ecom-quick-set-generating">
<LoadingOutlined />
<strong> A+ </strong>
<span>AI ...</span>
<div className="ecom-quick-set-progress">
<div className="ecom-quick-set-progress-bar" style={{ width: `${Math.round(detailProgress)}%` }} />
</div>
<em className="ecom-quick-set-progress-text">{Math.round(detailProgress)}%</em>
</section>
) : detailStatus === "failed" ? (
<section className="ecom-quick-set-failed">
<FrownOutlined />
<strong></strong>
<span></span>
<button type="button" onClick={handleDetailGenerate} disabled={!canGenerateDetail}></button>
</section>
) : detailProductImages.length ? (
<section className="ecom-quick-detail-preview-card" style={{ transform: `scale(${previewZoom})` }}>
{detailGridSamples.slice(0, 6).map((src, index) => (
<figure key={src}>
<img src={src} alt={`详情页模块预览 ${index + 1}`} />
<span>{detailModules[index]?.title ?? "详情模块"}</span>
</figure>
))}
</section>
) : (
<section className="ecom-quick-set-empty">
<FileImageOutlined />
<strong></strong>
<span>AI </span>
</section>
)}
</div>
<section className="ecom-quick-set-prompt">
<textarea
value={detailRequirement}
onChange={(event) => setDetailRequirement(event.target.value.slice(0, 500))}
placeholder="建议包含以下信息,产品名称,核心卖点,期望场景,具体参数"
/>
<div className="ecom-quick-detail-prompt-actions">
<button type="button" onClick={handleDetailAiWrite} aria-label="AI帮写">AI</button>
<button type="button" onClick={handleDetailGenerate} disabled={!canGenerateDetail}></button>
</div>
<span>{detailRequirement.length}/500</span>
</section>
</section>
</div>
<button type="button" className="ecom-quick-set-help" aria-label="帮助" onClick={() => toast.info("上传商品图后,选择平台和详情模块即可生成 A+ 详情页。")}>?</button>
</main>
);
const hotClonePreview = (
<main key="quick-hot" className={`ecom-quick-set-page ecom-quick-hot-page ecom-tool-page-enter${isQuickPanelCollapsed ? " is-panel-collapsed" : ""}`} aria-label="爆款复刻生成">
<div className="ecom-quick-set-body">
<aside className="ecom-quick-set-panel" aria-label="爆款复刻设置" onWheel={handleQuickPanelWheel}>
<header className="ecom-quick-set-panel-head">
<strong className="ecom-quick-set-page-title"></strong>
<button type="button" className="ecom-quick-set-back" onClick={closeHotClonePage}>
</button>
<button type="button" className="ecom-quick-set-back" onClick={closeHotClonePage}>
</button>
</header>
<section>
<strong><FileImageOutlined /> </strong>
{productImages.length ? (
<div
role="button"
tabIndex={0}
className={`ecom-quick-set-upload ecom-quick-hot-material has-images${isHotMaterialDragging ? " is-dragging" : ""}`}
onClick={() => hotMaterialInputRef.current?.click()}
onKeyDown={(event) => openQuickUploadWithKeyboard(event, hotMaterialInputRef)}
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); if (event.dataTransfer.types.includes("Files")) setIsHotMaterialDragging(true); }}
onDragLeave={(event) => { event.preventDefault(); event.stopPropagation(); if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) setIsHotMaterialDragging(false); }}
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); setIsHotMaterialDragging(false); const files = Array.from(event.dataTransfer.files); if (files.length) addProductImages(files); }}
>
{renderHotMaterialThumbs(productImages, removeProductImage)}
<button
type="button"
className="ecom-quick-hot-add-btn"
aria-label="添加更多素材"
onClick={(event) => {
event.stopPropagation();
hotMaterialInputRef.current?.click();
}}
>
<PlusOutlined />
</button>
</div>
) : (
<div
role="button"
tabIndex={0}
className={`ecom-quick-set-upload ecom-quick-hot-material${isHotMaterialDragging ? " is-dragging" : ""}`}
onClick={() => hotMaterialInputRef.current?.click()}
onKeyDown={(event) => openQuickUploadWithKeyboard(event, hotMaterialInputRef)}
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); if (event.dataTransfer.types.includes("Files")) setIsHotMaterialDragging(true); }}
onDragLeave={(event) => { event.preventDefault(); event.stopPropagation(); if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) setIsHotMaterialDragging(false); }}
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); setIsHotMaterialDragging(false); const files = Array.from(event.dataTransfer.files); if (files.length) addProductImages(files); }}
>
<FileImageOutlined />
<span></span>
<em> {maxCloneProductImages} </em>
<b>+ </b>
</div>
)}
<input
ref={hotMaterialInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleProductUpload}
aria-label="上传爆款复刻素材"
/>
</section>
<section>
<strong><FireOutlined /> </strong>
<div
role="button"
tabIndex={0}
className={`ecom-quick-set-upload ecom-quick-hot-material ecom-quick-hot-reference${cloneReferenceImages.length ? " has-images" : ""}${isCloneReferenceDragging ? " is-dragging" : ""}`}
onClick={() => cloneReferenceInputRef.current?.click()}
onKeyDown={(event) => openQuickUploadWithKeyboard(event, cloneReferenceInputRef)}
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); if (event.dataTransfer.types.includes("Files")) setIsCloneReferenceDragging(true); }}
onDragLeave={(event) => { event.preventDefault(); event.stopPropagation(); if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) setIsCloneReferenceDragging(false); }}
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); setIsCloneReferenceDragging(false); const files = Array.from(event.dataTransfer.files); if (files.length) addCloneReferenceImages(files); }}
>
<FileImageOutlined />
<span></span>
<em> {maxCloneReferenceImages} </em>
<b>+ </b>
{cloneReferenceImages.length ? (
<>
{renderHotMaterialThumbs(cloneReferenceImages, removeCloneReferenceImage)}
<button
type="button"
className="ecom-quick-hot-add-btn"
aria-label="添加更多参考图"
onClick={(event) => {
event.stopPropagation();
cloneReferenceInputRef.current?.click();
}}
>
<PlusOutlined />
</button>
</>
) : null}
</div>
<input
ref={cloneReferenceInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleCloneReferenceUpload}
aria-label="上传爆款复刻参考图"
/>
</section>
<section>
<strong></strong>
<div className="ecom-quick-detail-modules">
{cloneReplicateLevelOptions.map((option) => (
<button
key={option.key}
type="button"
className={cloneReplicateLevel === option.key ? "is-active" : ""}
onClick={() => setCloneReplicateLevel(option.key)}
>
<strong>{option.title}</strong>
<span>{option.desc}</span>
</button>
))}
</div>
</section>
<section className="ecom-quick-set-basic-section">
<span className="ecom-quick-set-label"></span>
<div className="ecom-quick-set-select-anchor">
<div className="ecom-quick-set-selects">
{quickHotBasicSelects.map((item) => (
<button
key={item.key}
type="button"
className={openQuickSetSelect === item.key ? "is-active" : ""}
onClick={() => toggleQuickSetSelect(item.key)}
>
<span>{item.label}</span><strong>{formatRatioDisplayValue(item.value)}</strong><em></em>
</button>
))}
</div>
{quickHotVisibleSelect ? (
<div
className={`ecom-quick-set-dropdown ecom-quick-set-dropdown--${quickHotVisibleSelect.key}${isQuickSetSelectClosing ? " is-closing" : ""}`}
role="listbox"
aria-label={quickHotVisibleSelect.label}
>
{quickHotVisibleSelect.options.map((option) => (
<button
key={option}
type="button"
className={quickHotVisibleSelect.value === option ? "is-active" : ""}
onClick={() => {
quickHotVisibleSelect.onChange(option);
closeQuickSetSelect();
}}
>
{formatRatioDisplayValue(option)}
</button>
))}
</div>
) : null}
</div>
</section>
<section className="ecom-quick-hot-requirement">
<div className="ecom-quick-hot-requirement__head">
<strong> &amp; </strong>
<button type="button" className="ecom-quick-hot-requirement__ai" onClick={handleHotAiWrite}>AI </button>
</div>
<div className="ecom-quick-hot-requirement__input">
<textarea
value={hotRequirement}
onChange={(event) => setHotRequirement(event.target.value.slice(0, 500))}
placeholder="建议包含以下信息:产品名称、核心卖点、参考风格、期望场景、具体参数"
maxLength={500}
/>
<span>{hotRequirement.length}/500</span>
</div>
</section>
<div className="ecom-quick-hot-actions">
<button type="button" className="ecom-quick-set-primary ecom-quick-hot-generate" onClick={handleHotGenerate} disabled={!canGenerateHot}>
{hotStatus === "generating" ? <LoadingOutlined /> : "✦"}
</button>
<button
type="button"
className={`ecom-quick-set-primary ecom-quick-set-primary--cancel${hotStatus !== "generating" ? " is-disabled" : ""}`}
onClick={hotStatus === "generating" ? handleCancelGenerate : undefined}
disabled={hotStatus !== "generating"}
>
</button>
</div>
</aside>
{hotMaterialHoverZoom && typeof document !== "undefined"
? createPortal(
<div
className={`ecom-hot-material-zoom-portal is-${hotMaterialHoverZoom.placement}`}
style={{ left: hotMaterialHoverZoom.x, top: hotMaterialHoverZoom.y }}
>
<img src={hotMaterialHoverZoom.src} alt="" />
</div>,
document.body,
)
: null}
<section className="ecom-quick-set-stage">
<header className="ecom-quick-set-preview-head">
<h1></h1>
<p>AI <span></span></p>
<div>
<button type="button" onClick={() => setPreviewZoom((value) => Math.max(0.25, value - 0.1))}>-</button>
<strong>{Math.round(previewZoom * 100)}%</strong>
<button type="button" onClick={() => setPreviewZoom((value) => Math.min(2, value + 0.1))}>+</button>
</div>
</header>
<div className="ecom-quick-set-canvas" onWheel={handleQuickPreviewWheel}>
{hotStatus === "done" && hotResultUrl ? (
<section className="ecom-quick-detail-result" style={{ transform: `scale(${previewZoom})` }}>
<img src={hotResultUrl} alt="爆款复刻结果" />
<button
type="button"
className="ecom-quick-detail-download"
onClick={() => {
const link = document.createElement("a");
link.href = hotResultUrl;
link.download = `爆款复刻-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
link.remove();
}}
>
<CloudUploadOutlined />
</button>
</section>
) : hotStatus === "generating" ? (
<section className="ecom-quick-set-generating">
<LoadingOutlined />
<strong></strong>
<span>AI ...</span>
<div className="ecom-quick-set-progress">
<div className="ecom-quick-set-progress-bar" style={{ width: `${Math.round(hotProgress)}%` }} />
</div>
<em className="ecom-quick-set-progress-text">{Math.round(hotProgress)}%</em>
</section>
) : hotStatus === "failed" ? (
<section className="ecom-quick-set-failed">
<FrownOutlined />
<strong></strong>
<span></span>
<button type="button" onClick={handleHotGenerate} disabled={!canGenerateHot}></button>
</section>
) : (
<section className="ecom-quick-set-empty">
<FileImageOutlined />
<strong></strong>
<span>AI </span>
</section>
)}
</div>
</section>
</div>
<button type="button" className="ecom-quick-set-help" aria-label="帮助" onClick={() => toast.info("上传参考图后,选择复刻强度和平台即可生成爆款同款。")}>?</button>
</main>
);
const detailPreview = (
<main className="product-clone-preview product-clone-preview--detail" aria-label="A+详情预览" onWheel={handlePreviewWheel}>
<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">
{detailSourcePreviewImages.map((src, index) => (
<figure key={`${src}-${index}`}>
<img src={src} alt={`商品原图 ${index + 1}`} />
</figure>
))}
<span></span>
</div>
<div className="product-detail-flow-arrow" aria-hidden="true" />
<div className="product-detail-long-result">
<img src={detailResultUrl ?? detailAssets.longPage} alt="生成电商长图" />
<span>{detailStatus === "done" ? "已生成电商长图" : "生成电商长图"}</span>
</div>
<div className="product-detail-grid-result">
{detailGridSamples.map((src, index) => (
<img key={src} src={src} alt={`详情页模块 ${index + 1}`} />
))}
<span></span>
</div>
</section>
<button type="button" className="product-clone-help" aria-label="帮助">
<QuestionCircleOutlined />
</button>
</main>
);
const tryOnPreview = (
<main className="product-clone-preview product-clone-preview--try-on" aria-label="服饰穿搭预览" onWheel={handlePreviewWheel}>
<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>
);
const activePreview = isSetTool
? setPreview
: isDetail
? detailPreview
: isTryOn
? tryOnPreview
: isCloneTool
? isWatermarkTool
? watermarkPreview
: isTranslateTool
? translatePreview
: isImageEditTool
? imageWorkbenchPreview
: isSmartCutoutTool
? smartCutoutPreview
: isQuickDetailTool
? (
<div key={`quick-${activeQuickTool}`} className="ecom-quick-page-wrap ecom-tool-page-enter">
{quickDetailPreview}
</div>
)
: isHotCloneTool
? (
<div key={`quick-${activeQuickTool}`} className="ecom-quick-page-wrap ecom-tool-page-enter">
{hotClonePreview}
</div>
)
: clonePreview
: placeholderPreview;
const isMainCloneWorkspace = isCloneTool && !isSmartCutoutTool && !isQuickDetailTool && !isWatermarkTool && !isTranslateTool && !isImageEditTool;
const isRecordDetailWorkspace = isMainCloneWorkspace && Boolean(activeHistoryRecordId);
const currentResultCount = canvasNodes.reduce((count, node) => count + node.results.length, 0);
const activeHistoryRecord = activeHistoryRecordId ? ecommerceHistoryRecords.find((record) => record.id === activeHistoryRecordId) : null;
const activeConversationTurns = activeHistoryRecord
? activeHistoryRecord.turns?.length
? activeHistoryRecord.turns
: [buildHistoryTurnFromRecord(activeHistoryRecord)]
: [];
const getHistoryTurnSettingLabel = (turn: EcommerceHistoryTurn) => {
if (turn.output === "set") {
const total = cloneSetCountKeys.reduce((sum, key) => sum + (turn.setCounts?.[key] ?? 0), 0);
return `套图 ${total || 1}`;
}
if (turn.output === "detail") return `详情 ${turn.detailModules?.length || 1}`;
if (turn.output === "model") return `模特 ${turn.modelScenes?.length || 1}`;
return cloneOutputOptions.find((option) => option.key === turn.output)?.label || selectedCloneOutput.label;
};
const restoreHistoryTurnInputs = (turn: EcommerceHistoryTurn) => {
setCloneOutput(turn.output);
setPlatform(turn.platform);
setMarket(turn.market);
setLanguage(turn.language);
setRatio(turn.ratio);
setRequirement(turn.requirement);
setProductImages(turn.productImages);
setCloneSetCounts(turn.setCounts);
setSelectedCloneDetailModules(turn.detailModules.slice(0, maxDetailModuleSelection));
setSelectedCloneModelScenes(turn.modelScenes);
setCloneReferenceImages(turn.referenceImages);
setCloneReplicateLevel(turn.replicateLevel);
toast.info("已恢复该轮参数,可继续发送");
};
return (
<section
className={`product-clone-page page-motion${isCloneTool && isCloneSettingsCollapsed ? " is-settings-collapsed" : ""}${isCloneTool && isCommandHistoryCollapsed ? " is-history-collapsed" : ""}${isRecordDetailWorkspace && isCloneConversationCollapsed ? " is-conversation-collapsed" : ""}${isRecordDetailWorkspace ? " is-history-detail" : ""}${isSmartCutoutTool ? " is-smart-cutout-page" : ""}${isQuickDetailTool ? " is-quick-set-page" : ""}${isWatermarkTool ? " is-watermark-page" : ""}${isTranslateTool ? " is-translate-page" : ""}${isImageEditTool ? " is-image-workbench-page" : ""}${isHotCloneTool ? " is-hot-clone-page" : ""}`}
data-tool={activeTool}
aria-label={pageLabel}
>
<div className="product-clone-shell">
<aside className="product-clone-rail" aria-label="商品工具">
{sideTools.map((tool) => (
<button key={tool.key} type="button" className={activeTool === tool.key ? "is-active" : ""} onClick={() => setActiveTool(tool.key)}>
{tool.icon}
<span>{tool.label}</span>
</button>
))}
</aside>
<aside
id={isCloneTool ? "ecommerce-clone-settings-panel" : undefined}
className={`product-clone-panel tool-panel-enter`}
key={activeTool}
aria-label={`${pageLabel}参数`}
aria-hidden={isCloneTool && isCloneSettingsCollapsed ? true : undefined}
>
{isSetTool ? setPanel : isDetail ? detailPanel : isTryOn ? tryOnPanel : isCloneTool ? clonePanel : placeholderPanel}
</aside>
{isCloneTool && !isSmartCutoutTool && !isQuickDetailTool && !isWatermarkTool && !isTranslateTool && !isImageEditTool && !isHotCloneTool ? (
<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}
{isRecordDetailWorkspace ? (
<>
<aside className="clone-ai-conversation-panel" aria-label="AI 对话">
<header className="clone-ai-conversation-head">
<div>
<strong>{activeHistoryRecord?.title || "生成详情"}</strong>
<span>{selectedCloneOutput.label} · {platform} · {language}</span>
</div>
<button
type="button"
onClick={() => setIsCloneConversationCollapsed(true)}
aria-label="收起对话"
title="收起对话"
>
<MenuFoldOutlined />
</button>
</header>
<div className="clone-ai-conversation-body">
{activeConversationTurns.map((turn, index) => {
const turnResults = getTurnResults(turn);
const outputLabel = cloneOutputOptions.find((option) => option.key === turn.output)?.label || selectedCloneOutput.label;
const turnMeta = [
{ label: "平台", value: turn.platform },
{ label: "语种", value: turn.language },
{ label: "比例", value: formatRatioDisplayValue(turn.ratio) },
{ label: "设置", value: getHistoryTurnSettingLabel(turn) },
];
const isCurrentGeneratingTurn = turn.status === "generating" && turn.id === activeHistoryTurnIdRef.current;
return (
<Fragment key={turn.id}>
<section className={`clone-ai-chat-message clone-ai-chat-message--user${index > 0 ? " clone-ai-chat-message--followup" : ""}`}>
<span>{index === 0 ? "需求" : `继续生成 ${index + 1}`}</span>
<p>{turn.requirement?.trim() || "上传商品素材,描述你想生成的商品图、详情图、模特图或短视频。"}</p>
<div className="clone-ai-chat-meta" aria-label="需求参数">
{turnMeta.map((item) => (
<em key={item.label}>
<span>{item.label}</span>
<strong>{item.value}</strong>
</em>
))}
</div>
{turn.productImages.length ? (
<div className="clone-ai-chat-assets" aria-label="已上传素材">
{turn.productImages.slice(0, 4).map((image) => (
<img key={image.id} src={image.src} alt={image.name || "商品素材"} />
))}
{turn.productImages.length > 4 ? <em>+{turn.productImages.length - 4}</em> : null}
</div>
) : null}
</section>
<section className={`clone-ai-chat-message clone-ai-chat-message--assistant is-${turn.status}`}>
<span></span>
<p>
{turn.status === "done" || turnResults.length
? `已生成 ${turnResults.length}${outputLabel},已同步到中间画布,可拖拽、缩放和预览。`
: turn.status === "generating"
? `正在为 ${turn.platform} / ${turn.market} 生成${outputLabel},完成后会自动追加到画布。`
: turn.errorMessage || "生成失败,请检查网络或参数后重试。"}
</p>
{isCurrentGeneratingTurn ? (
<EcommerceProgressBar status="generating" progress={generationProgress} onCancel={handleCancelGenerate} label={`${outputLabel}生成`} />
) : null}
{turn.status === "failed" ? (
<button type="button" className="clone-ai-retry-btn" onClick={() => restoreHistoryTurnInputs(turn)}>
<ReloadOutlined />
</button>
) : null}
{turnResults.length ? (
<div className="clone-ai-chat-results" aria-label="生成结果缩略图">
{turnResults.slice(0, 6).map((item) => (
<button
key={item.id}
type="button"
onClick={() => openProductSetPreview(item)}
aria-label={`预览${item.label}`}
>
<img src={item.src} alt={item.label} />
</button>
))}
</div>
) : null}
</section>
</Fragment>
);
})}
</div>
</aside>
<button
type="button"
className="clone-ai-conversation-toggle"
onClick={() => setIsCloneConversationCollapsed((current) => !current)}
aria-label={isCloneConversationCollapsed ? "展开对话" : "收起对话"}
title={isCloneConversationCollapsed ? "展开对话" : "收起对话"}
aria-expanded={!isCloneConversationCollapsed}
>
{isCloneConversationCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</button>
</>
) : null}
{activePreview}
</div>
{isCloneTool && !isCommandHistoryCollapsed ? (
<div
className="ecom-command-history__backdrop"
role="presentation"
onClick={() => setIsCommandHistoryCollapsed(true)}
/>
) : null}
<aside className="ecom-command-history" aria-label="生成历史">
<div className="ecom-command-history__tools">
<button
type="button"
className="ecom-command-history__toggle"
onClick={() => setIsCommandHistoryCollapsed((current) => !current)}
title={isCommandHistoryCollapsed ? "展开记录" : "收起记录"}
aria-label={isCommandHistoryCollapsed ? "展开记录" : "收起记录"}
aria-expanded={!isCommandHistoryCollapsed}
>
{isCommandHistoryCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</button>
<button type="button" className="ecom-command-history__new" onClick={handleNewEcommerceConversation}></button>
<button
type="button"
className={`ecom-command-history__refresh${isHistoryRefreshing ? " is-refreshing" : ""}`}
aria-label={isHistoryRefreshing ? "刷新中" : "刷新历史"}
title={isHistoryRefreshing ? "刷新中" : "刷新历史"}
onPointerDown={refreshEcommerceHistory}
onClick={refreshEcommerceHistory}
disabled={isHistoryRefreshing}
>
<ReloadOutlined />
</button>
</div>
<div className="ecom-command-history__heading">
<strong></strong>
<span>{ecommerceHistoryRecords.length} </span>
</div>
{historyRefreshMessage ? (
<p key={historyRefreshStamp} className="ecom-command-history__refresh-note" role="status">{historyRefreshMessage}</p>
) : null}
<nav className="ecom-command-history__list" aria-label="历史对话">
{ecommerceHistoryRecords.length ? (
ecommerceHistoryRecords.map((record) => {
const outputLabel = cloneOutputOptions.find((option) => option.key === record.output)?.label || "生成记录";
const statusLabel = record.status === "generating" ? "生成中" : record.status === "failed" ? "失败" : formatHistoryTime(record.createdAt);
return (
<div key={`${record.id}-${historyRefreshTick}`} className={`ecom-command-history__item${activeHistoryRecordId === record.id ? " is-active" : ""}`}>
<button
type="button"
className="ecom-command-history__item-main"
onClick={() => openEcommerceHistoryRecord(record)}
>
<strong>{record.title}</strong>
<span>{outputLabel} · {statusLabel}</span>
</button>
<button
type="button"
className="ecom-command-history__item-delete"
aria-label="删除此记录"
title="删除"
onClick={(e) => deleteHistoryRecord(record.id, e)}
>
<DeleteOutlined />
</button>
</div>
);
})
) : (
<p className="ecom-command-history__empty"></p>
)}
</nav>
</aside>
{selectedProductSetPreview && typeof document !== "undefined" ? createPortal((
<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} />
<div className="product-set-preview-footer">
<strong>{selectedProductSetPreview.label}</strong>
<div className="product-set-preview-actions" aria-label="图片操作">
<button
type="button"
className="product-set-preview-action"
onClick={() => {
void handleDownloadCanvasResult(selectedProductSetPreview);
}}
>
<DownloadOutlined />
<span></span>
</button>
{selectedProductSetPreview.removable ? (
<button
type="button"
className="product-set-preview-action product-set-preview-action--danger"
onClick={() => removeSelectedProductSetPreview(selectedProductSetPreview)}
>
<DeleteOutlined />
<span></span>
</button>
) : null}
</div>
</div>
</section>
</div>
), document.body) : 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}
<EcommerceVideoHistoryPanel
visible={videoHistoryVisible}
onClose={() => setVideoHistoryVisible(false)}
/>
</section>
);
}
export default ProductClonePage;