5811cbac16
- 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
8173 lines
350 KiB
TypeScript
8173 lines
350 KiB
TypeScript
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×1920px,9:16", "30s 内最佳"],
|
||
},
|
||
{
|
||
label: "亚马逊 Amazon",
|
||
ratios: ["主图 ≥1600×1600px", "建议 2000×2000px+", "最小 500×500px"],
|
||
defaultRatio: "主图 ≥1600×1600px",
|
||
ratioGroups: {
|
||
set: {
|
||
ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
detail: {
|
||
ratios: ["1600×1600px\u00a0\u00a0\u00a01:1", "1200×1800px\u00a0\u00a0\u00a02:3", "1200×1600px\u00a0\u00a0\u00a03:4"],
|
||
defaultRatio: "1200×1800px\u00a0\u00a0\u00a02:3",
|
||
},
|
||
model: {
|
||
ratios: ["1200×1800px\u00a0\u00a0\u00a02:3"],
|
||
defaultRatio: "1200×1800px\u00a0\u00a0\u00a02:3",
|
||
},
|
||
video: {
|
||
ratios: ["1920×1080px\u00a0\u00a0\u00a016:9"],
|
||
defaultRatio: "1920×1080px\u00a0\u00a0\u00a016:9",
|
||
},
|
||
hot: {
|
||
ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
},
|
||
specs: ["主图 1600×1600px+,纯白底,≤10MB", "最小 500×500px,建议 2000px+ 以支持缩放"],
|
||
aliases: ["亚马逊"],
|
||
},
|
||
{
|
||
label: "Shopee",
|
||
ratios: ["商品主图 1024×1024px", "基础主图 800×800px"],
|
||
defaultRatio: "商品主图 1024×1024px",
|
||
ratioGroups: {
|
||
set: {
|
||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
detail: {
|
||
ratios: ["750×1000px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3"],
|
||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||
},
|
||
model: {
|
||
ratios: ["750×1000px\u00a0\u00a0\u00a03:4"],
|
||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||
},
|
||
video: {
|
||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"],
|
||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||
},
|
||
hot: {
|
||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
},
|
||
specs: ["商品主图推荐 1024×1024px,基础 800×800px", "≤2MB,白底或浅色底"],
|
||
aliases: ["虾皮 Shopee/Lazada", "虾皮"],
|
||
},
|
||
{
|
||
label: "Lazada",
|
||
ratios: ["商品主图 800×800px"],
|
||
defaultRatio: "商品主图 800×800px",
|
||
ratioGroups: {
|
||
set: {
|
||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
detail: {
|
||
ratios: ["750×1000px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3"],
|
||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||
},
|
||
model: {
|
||
ratios: ["750×1000px\u00a0\u00a0\u00a03:4"],
|
||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||
},
|
||
video: {
|
||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"],
|
||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||
},
|
||
hot: {
|
||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
},
|
||
specs: ["商品主图 800×800px,1:1"],
|
||
},
|
||
{
|
||
label: "Instagram",
|
||
ratios: ["帖子 1080×1350px", "帖子 1080×1080px", "Stories / Reels 1080×1920px", "头像 320×320px"],
|
||
defaultRatio: "帖子 1080×1350px",
|
||
ratioGroups: {
|
||
set: {
|
||
ratios: ["1080×1080px\u00a0\u00a0\u00a01:1", "1080×1350px\u00a0\u00a0\u00a04:5"],
|
||
defaultRatio: "1080×1080px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
detail: {
|
||
ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"],
|
||
defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5",
|
||
},
|
||
model: {
|
||
ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"],
|
||
defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5",
|
||
},
|
||
video: {
|
||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1080×1350px\u00a0\u00a0\u00a04:5"],
|
||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||
},
|
||
},
|
||
specs: ["帖子 1080×1350px 或 1080×1080px", "Stories / Reels 封面 1080×1920px,头像 320×320px"],
|
||
tip: "建议 ≤8MB JPG。",
|
||
aliases: ["Instagram Reels"],
|
||
},
|
||
{
|
||
label: "速卖通",
|
||
ratios: ["主图 800×800px", "主图 1000×1000px+"],
|
||
defaultRatio: "主图 800×800px",
|
||
ratioGroups: {
|
||
set: {
|
||
ratios: ["1000×1000px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "1000×1000px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
detail: {
|
||
ratios: ["750×1125px\u00a0\u00a0\u00a02:3", "750×1000px\u00a0\u00a0\u00a03:4"],
|
||
defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3",
|
||
},
|
||
model: {
|
||
ratios: ["750×1125px\u00a0\u00a0\u00a02:3"],
|
||
defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3",
|
||
},
|
||
video: {
|
||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1920×1080px\u00a0\u00a0\u00a016:9"],
|
||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||
},
|
||
hot: {
|
||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
},
|
||
specs: ["主图建议 800×800px 或更高,1:1", "适合跨境电商主图、SKU 图和场景图"],
|
||
},
|
||
{
|
||
label: "eBay",
|
||
ratios: ["商品图1:1", "白底多角度展示图 1:1"],
|
||
defaultRatio: "商品图1:1",
|
||
ratioGroups: {
|
||
set: {
|
||
ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
detail: {
|
||
ratios: ["1000×1500px\u00a0\u00a0\u00a02:3", "1200×1600px\u00a0\u00a0\u00a03:4"],
|
||
defaultRatio: "1000×1500px\u00a0\u00a0\u00a02:3",
|
||
},
|
||
model: {
|
||
ratios: ["1000×1500px\u00a0\u00a0\u00a02:3"],
|
||
defaultRatio: "1000×1500px\u00a0\u00a0\u00a02:3",
|
||
},
|
||
video: {
|
||
ratios: ["1920×1080px\u00a0\u00a0\u00a016:9", "1080×1920px\u00a0\u00a0\u00a09:16"],
|
||
defaultRatio: "1920×1080px\u00a0\u00a0\u00a016:9",
|
||
},
|
||
hot: {
|
||
ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
},
|
||
specs: ["商品图建议 1:1,主体清晰居中", "适合白底主图和多角度展示图"],
|
||
},
|
||
{
|
||
label: "TikTok Shop",
|
||
ratios: ["商品主图 1:1", "短视频/ 竖版封面 9:16"],
|
||
defaultRatio: "商品主图 1:1",
|
||
ratioGroups: {
|
||
set: {
|
||
ratios: ["1280×1280px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "1280×1280px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
detail: {
|
||
ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"],
|
||
defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5",
|
||
},
|
||
model: {
|
||
ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"],
|
||
defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5",
|
||
},
|
||
video: {
|
||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"],
|
||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||
},
|
||
hot: {
|
||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||
},
|
||
},
|
||
specs: ["商品主图建议 1:1", "短视频竖版封面建议 9:16"],
|
||
},
|
||
];
|
||
const platformOptions = platformSpecOptions.map((option) => option.label);
|
||
const getPlatformLogoText = (value: string) => {
|
||
const normalized = value.toLowerCase();
|
||
if (value.includes("淘宝") || value.includes("天猫")) return "淘";
|
||
if (value.includes("京东")) return "京";
|
||
if (value.includes("拼多多") || value.includes("鎷煎澶")) return "拼";
|
||
if (value.includes("抖音")) return "抖";
|
||
if (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>上传文件夹 ></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">查看全部 ></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">查看全部 ></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>商品卖点 & 需求</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;
|