feat: enhance ecommerce UI — sidebar, transitions, ratio options, cancel buttons, i18n fixes

This commit is contained in:
Codex
2026-06-11 20:38:35 +08:00
parent 31c0e79e2e
commit 9b9a276014
10 changed files with 555 additions and 196 deletions
+163 -126
View File
@@ -15,6 +15,7 @@
SettingOutlined,
SkinOutlined,
TableOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import { 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 { useTypewriter } from "../../hooks/useTypewriter";
@@ -324,8 +325,8 @@ const platformSpecOptions: Array<{
}> = [
{
label: "淘宝/天猫",
ratios: ["娣樺疂涓诲浘 / SKU 鍥?800脳800px", "璇︽儏椤靛 750px", "璇︽儏椤靛 790px"],
defaultRatio: "娣樺疂涓诲浘 / SKU 鍥?800脳800px",
ratios: ["淘宝主图 / SKU 鍥?800脳800px", "详情页宽 750px", "详情页宽 790px"],
defaultRatio: "淘宝主图 / SKU 鍥?800脳800px",
ratioGroups: {
set: {
ratios: ["1000脳1000px\u00a0\u00a0\u00a01锛?", "800脳800px\u00a0\u00a0\u00a01锛?"],
@@ -353,13 +354,13 @@ const platformSpecOptions: Array<{
defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?",
},
},
specs: ["涓诲浘 / SKU 鍥?800脳800px锛屸墹3MB", "璇︽儏椤靛 750px ?790px锛屽崟寮犻珮鈮?546px"],
tip: "寤鸿涓诲浘 200-400KB JPG锛岃秴杩?500KB 浼氬奖鍝嶅姞杞介€熷害銆?",
specs: ["主图 / SKU 鍥?800脳800px锛屸墹3MB", "详情页宽 750px ?790px锛屽崟寮犻珮鈮?546px"],
tip: "建议主图 200-400KB JPG锛岃秴杩?500KB 浼氬奖鍝嶅姞杞介€熷害銆?",
},
{
label: "京东",
ratios: ["浜笢涓诲浘 / SKU 鍥?800脳800px", "璇︽儏椤靛 750px", "棣栧浘涓讳綋鍗犳瘮 鈮?0%"],
defaultRatio: "浜笢涓诲浘 / SKU 鍥?800脳800px",
ratios: ["京东主图 / SKU 鍥?800脳800px", "详情页宽 750px", "首图主体占比 鈮?0%"],
defaultRatio: "京东主图 / SKU 鍥?800脳800px",
ratioGroups: {
set: {
ratios: ["1000脳1000px\u00a0\u00a0\u00a01锛?"],
@@ -387,12 +388,12 @@ const platformSpecOptions: Array<{
defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?",
},
},
specs: ["涓诲浘 / SKU 鍥?800脳800px锛岀櫧搴曪紝鈮?MB", "璇︽儏椤靛 750px锛岄鍥句富浣撳崰姣?鈮?0%"],
specs: ["主图 / SKU 鍥?800脳800px锛岀櫧搴曪紝鈮?MB", "详情页宽 750px锛岄鍥句富浣撳崰姣?鈮?0%"],
},
{
label: "拼多多",
ratios: ["涓诲浘 750脳352px", "涓诲浘 800脳800px", "璇︽儏椤靛 750px"],
defaultRatio: "涓诲浘 750脳352px",
ratios: ["主图 750脳352px", "主图 800脳800px", "详情页宽 750px"],
defaultRatio: "主图 750脳352px",
ratioGroups: {
set: {
ratios: ["800脳800px\u00a0\u00a0\u00a01锛?", "750脳1000px\u00a0\u00a0\u00a03锛?"],
@@ -415,7 +416,7 @@ const platformSpecOptions: Array<{
defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?",
},
},
specs: ["涓诲浘 750脳352px ?800脳800px锛屸墹1MB", "璇︽儏椤靛 750px锛岃姹傜函鐧藉簳銆佹棤姘村嵃銆佹棤鎷兼帴"],
specs: ["主图 750脳352px ?800脳800px锛屸墹1MB", "详情页宽 750px,要求纯白底、无水印、无拼接"],
},
{
label: "抖音电商",
@@ -431,12 +432,12 @@ const platformSpecOptions: Array<{
defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?",
},
},
specs: ["鐭棰?1080脳1920px锛?:16", "30s 呮渶浣?"],
specs: ["鐭棰?1080脳1920px锛?:16", "30s 呮渶浣?"],
},
{
label: "亚马逊 Amazon",
ratios: ["涓诲浘 鈮?600脳1600px", "寤鸿 2000脳2000px+", "鏈€灏?500脳500px"],
defaultRatio: "涓诲浘 鈮?600脳1600px",
ratios: ["主图 鈮?600脳1600px", "建议 2000脳2000px+", "鏈€灏?500脳500px"],
defaultRatio: "主图 鈮?600脳1600px",
ratioGroups: {
set: {
ratios: ["1600脳1600px\u00a0\u00a0\u00a01锛?"],
@@ -459,13 +460,13 @@ const platformSpecOptions: Array<{
defaultRatio: "1600脳1600px\u00a0\u00a0\u00a01锛?",
},
},
specs: ["涓诲浘 1600脳1600px+锛岀函鐧藉簳锛屸墹10MB", "鏈€灏?500脳500px锛屽缓璁?2000px+ 浠ユ敮鎸佺缉鏀?"],
specs: ["主图 1600脳1600px+,纯白底,≤10MB", "鏈€灏?500脳500px锛屽缓璁?2000px+ 浠ユ敮鎸佺缉鏀?"],
aliases: ["浜氶┈閫?"],
},
{
label: "Shopee",
ratios: ["鍟嗗搧涓诲浘 1024脳1024px", "鍩虹涓诲浘 800脳800px"],
defaultRatio: "鍟嗗搧涓诲浘 1024脳1024px",
ratios: ["商品主图 1024脳1024px", "基础主图 800脳800px"],
defaultRatio: "商品主图 1024脳1024px",
ratioGroups: {
set: {
ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"],
@@ -488,13 +489,13 @@ const platformSpecOptions: Array<{
defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?",
},
},
specs: ["鍟嗗搧涓诲浘鎺ㄨ崘 1024脳1024px锛屽熀纭€ 800脳800px", "鈮?MB锛岀櫧搴曟垨娴呰壊搴?"],
aliases: ["铏剧毊 Shopee/Lazada", "铏剧毊"],
specs: ["商品主图推荐 1024脳1024px,基础 800脳800px", "鈮?MB锛岀櫧搴曟垨娴呰壊搴?"],
aliases: ["虾皮 Shopee/Lazada", "虾皮"],
},
{
label: "Lazada",
ratios: ["鍟嗗搧涓诲浘 800脳800px"],
defaultRatio: "鍟嗗搧涓诲浘 800脳800px",
ratios: ["商品主图 800脳800px"],
defaultRatio: "商品主图 800脳800px",
ratioGroups: {
set: {
ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"],
@@ -517,12 +518,12 @@ const platformSpecOptions: Array<{
defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?",
},
},
specs: ["鍟嗗搧涓诲浘 800脳800px锛?:1"],
specs: ["商品主图 800脳800px锛?:1"],
},
{
label: "Instagram",
ratios: ["甯栧瓙 1080脳1350px", "甯栧瓙 1080脳1080px", "Stories / Reels 1080脳1920px", "澶村儚 320脳320px"],
defaultRatio: "甯栧瓙 1080脳1350px",
ratios: ["帖子 1080脳1350px", "帖子 1080脳1080px", "Stories / Reels 1080脳1920px", "头像 320脳320px"],
defaultRatio: "帖子 1080脳1350px",
ratioGroups: {
set: {
ratios: ["1080脳1080px\u00a0\u00a0\u00a01锛?", "1080脳1350px\u00a0\u00a0\u00a04锛?"],
@@ -541,14 +542,14 @@ const platformSpecOptions: Array<{
defaultRatio: "1080脳1920px\u00a0\u00a0\u00a09锛?6",
},
},
specs: ["甯栧瓙 1080脳1350px ?1080脳1080px", "Stories / Reels 灏侀潰 1080脳1920px锛屽ご鍍?320脳320px"],
tip: "寤鸿 鈮?MB JPG銆?",
specs: ["帖子 1080脳1350px ?1080脳1080px", "Stories / Reels 封面 1080脳1920px锛屽ご鍍?320脳320px"],
tip: "建议 鈮?MB JPG銆?",
aliases: ["Instagram Reels"],
},
{
label: "速卖通",
ratios: ["涓诲浘 800脳800px", "涓诲浘 1000脳1000px+"],
defaultRatio: "涓诲浘 800脳800px",
ratios: ["主图 800脳800px", "主图 1000脳1000px+"],
defaultRatio: "主图 800脳800px",
ratioGroups: {
set: {
ratios: ["1000脳1000px\u00a0\u00a0\u00a01锛?"],
@@ -571,11 +572,11 @@ const platformSpecOptions: Array<{
defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?",
},
},
specs: ["涓诲浘寤鸿 800脳800px 鎴栨洿楂橈紝1:1", "閫傚悎璺ㄥ鐢靛晢涓诲浘銆丼KU 鍥惧拰鍦烘櫙鍥?"],
specs: ["主图建议 800脳800px 或更高,1:1", "适合跨境电商主图、SKU 鍥惧拰鍦烘櫙鍥?"],
},
{
label: "eBay",
ratios: ["鍟嗗搧鍥?1:1", "鐧藉簳澶氳搴﹀睍绀哄浘 1:1"],
ratios: ["鍟嗗搧鍥?1:1", "白底多角度展示图 1:1"],
defaultRatio: "鍟嗗搧鍥?1:1",
ratioGroups: {
set: {
@@ -599,12 +600,12 @@ const platformSpecOptions: Array<{
defaultRatio: "1600脳1600px\u00a0\u00a0\u00a01锛?",
},
},
specs: ["鍟嗗搧鍥惧缓璁?1:1锛屼富浣撴竻鏅板眳涓?", "閫傚悎鐧藉簳涓诲浘鍜屽瑙掑害灞曠ず鍥?"],
specs: ["鍟嗗搧鍥惧缓璁?1:1锛屼富浣撴竻鏅板眳涓?", "閫傚悎白底主图鍜屽瑙掑害灞曠ず鍥?"],
},
{
label: "TikTok Shop",
ratios: ["鍟嗗搧涓诲浘 1:1", "鐭棰?/ 绔栫増灏侀潰 9:16"],
defaultRatio: "鍟嗗搧涓诲浘 1:1",
ratios: ["商品主图 1:1", "鐭棰?/ 竖版封面 9:16"],
defaultRatio: "商品主图 1:1",
ratioGroups: {
set: {
ratios: ["1280脳1280px\u00a0\u00a0\u00a01锛?"],
@@ -627,16 +628,16 @@ const platformSpecOptions: Array<{
defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?",
},
},
specs: ["鍟嗗搧涓诲浘寤鸿 1:1", "鐭棰?绔栫増灏侀潰寤鸿 9:16"],
specs: ["商品主图建议 1:1", "鐭棰?竖版封面建议 9:16"],
},
];
const platformOptions = platformSpecOptions.map((option) => option.label);
const getPlatformLogoSources = (value: string) => {
const normalized = value.toLowerCase();
if (value.includes("淘宝") || value.includes("天猫") || value.includes("娣樺疂") || value.includes("澶╃尗")) return [taobaoLogo, tmallLogo];
if (value.includes("京东") || value.includes("浜笢")) return [jdLogo];
if (value.includes("淘宝") || value.includes("天猫") || value.includes("淘宝") || value.includes("天猫")) return [taobaoLogo, tmallLogo];
if (value.includes("京东") || value.includes("京东")) return [jdLogo];
if (value.includes("拼多多") || value.includes("鎷煎澶")) return [pinduoduoLogo];
if (value.includes("抖音") || value.includes("鎶栭煶")) return [douyinLogo];
if (value.includes("抖音") || value.includes("抖音")) return [douyinLogo];
if (normalized.includes("amazon")) return [amazonLogo];
if (normalized.includes("shopee")) return [shopeeLogo];
if (normalized.includes("lazada")) return [lazadaLogo];
@@ -686,34 +687,34 @@ const marketLanguageOptions: Array<{ country: string; languages: string[] }> = [
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",
"閫熷崠閫?": "速卖通",
};
@@ -731,7 +732,7 @@ const getPlatformRatioGroup = (value: string, mode?: PlatformRatioModeKey): Plat
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("锛?", ":").replaceAll("", ":").replaceAll("", "×").trim();
const normalizeRatioToken = (value: string) => value.replaceAll("", ":").replaceAll("×", "×").trim();
const normalizeRatioForPlatform = (platformValue: string, ratioValue: string, mode?: PlatformRatioModeKey) => {
const platformRatios = getPlatformRatioOptions(platformValue, mode);
if (platformRatios.includes(ratioValue)) return ratioValue;
@@ -739,7 +740,7 @@ const normalizeRatioForPlatform = (platformValue: string, ratioValue: string, mo
const matchedRatio = platformRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio));
return matchedRatio ?? getPlatformDefaultRatio(platformValue, mode);
};
const quickSetRatioOptions = ["1:1", "3:4", "9:16", "16:9"];
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;
@@ -768,12 +769,12 @@ const formatRatioDisplayValue = (value: string) => {
return `${width}×${height}px\u00a0\u00a0\u00a0${formatAspectRatio(width, height)}`;
}
return normalizedValue
.replace("娣樺疂涓诲浘 / SKU 鍥?", "淘宝主图 / SKU 图 ")
.replace("浜笢涓诲浘 / SKU 鍥?", "京东主图 / SKU 图 ")
.replace("璇︽儏椤靛", "详情页宽")
.replace("淘宝主图 / SKU 鍥?", "淘宝主图 / SKU 图 ")
.replace("京东主图 / SKU 鍥?", "京东主图 / SKU 图 ")
.replace("详情页宽", "详情页宽")
.replace("鐭棰?", "短视频")
.replace("涓诲浘", "主图")
.replace("鍟嗗搧涓诲浘", "商品主图")
.replace("主图", "主图")
.replace("商品主图", "商品主图")
.replace("鍟嗗搧鍥?", "商品图");
};
/** Extract CSS aspect-ratio from a ratio string like "1000x1000px 1:1" -> "1 / 1" */
@@ -893,29 +894,29 @@ const sampleResults = [
];
const productSetAssets = ossAssets.ecommerce.productSet;
const productSetPreviewCards = [
{ id: "main", label: "01 涓诲浘 (鐧藉簳/鍚堣)", src: productSetAssets.main },
{ id: "scene", label: "02 鍦烘櫙灞曠ず", src: productSetAssets.scene },
{ 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 },
{ id: "detail", label: "04 细节说明", src: productSetAssets.detail },
{ id: "selling", label: "05 卖点详解", src: productSetAssets.selling },
];
const tryOnAssets = ossAssets.ecommerce.tryOn;
const tryOnCards = [
{
title: "澶氫欢娣锋惌鑷姩铻嶅悎",
title: "多件混搭自动融合",
tone: "red",
inputs: [tryOnAssets.dressA, tryOnAssets.dressB, tryOnAssets.modelWoman],
results: [tryOnAssets.tryA, tryOnAssets.tryB],
},
{
title: "涓€浠朵篃鑳藉嚭澶х墖",
title: "一件也能出大片",
tone: "brown",
inputs: [tryOnAssets.jacket, tryOnAssets.modelMan],
results: [tryOnAssets.jacketResultA, tryOnAssets.jacketResultB],
},
{
title: "闉嬪附楗板搧瀹岀編閫傞厤",
title: "鞋帽饰品完美适配",
tone: "gold",
inputs: [tryOnAssets.hat, tryOnAssets.modelAsian],
results: [tryOnAssets.hatResultA, tryOnAssets.hatResultB],
@@ -968,7 +969,7 @@ 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.onerror = () => reject(reader.error || new Error("文件读取失败"));
reader.readAsDataURL(blob);
});
@@ -1218,6 +1219,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [showHostingModal, setShowHostingModal] = useState(false);
const [productImages, setProductImages] = useState<CloneImageItem[]>([]);
const [activeQuickTool, setActiveQuickTool] = useState<"cutout" | "set" | "detail" | "watermark" | "image-edit" | "hot-video" | null>(null);
const [quickPageTransition, setQuickPageTransition] = useState<{ title: string; subtitle: string } | null>(null);
const quickPageTransitionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [smartCutoutImage, setSmartCutoutImage] = useState<SmartCutoutImageItem | null>(null);
const [smartCutoutBatchImages, setSmartCutoutBatchImages] = useState<SmartCutoutImageItem[]>([]);
const [smartCutoutBackgroundColor, setSmartCutoutBackgroundColor] = useState("#ffffff");
@@ -1477,7 +1480,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [requirement, setRequirement] = useState("");
const [requirementImageMentionQuery, setRequirementImageMentionQuery] = useState<string | null>(null);
const [cloneSettingName, setCloneSettingName] = useState("鏂板缓鍒涗綔");
const [cloneSettingName, setCloneSettingName] = useState("新建创作");
const [platform, setPlatform] = useState(defaultEcommercePlatform);
const [market, setMarket] = useState(marketOptions[0]);
const [language, setLanguage] = useState(getPlatformDefaultLanguage(defaultEcommercePlatform, marketOptions[0]));
@@ -1681,6 +1684,24 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
items.forEach(revokeSmartCutoutItem);
};
const clearQuickPageTransition = () => {
if (quickPageTransitionTimeoutRef.current !== null) {
window.clearTimeout(quickPageTransitionTimeoutRef.current);
quickPageTransitionTimeoutRef.current = null;
}
setQuickPageTransition(null);
};
const runQuickPageTransition = (message: { title: string; subtitle: string }, action: () => void, delay = 400) => {
clearQuickPageTransition();
setQuickPageTransition(message);
quickPageTransitionTimeoutRef.current = window.setTimeout(() => {
quickPageTransitionTimeoutRef.current = null;
action();
setQuickPageTransition(null);
}, delay);
};
const clearSmartCutoutTransition = () => {
if (smartCutoutTransitionTimeoutRef.current !== null) {
window.clearTimeout(smartCutoutTransitionTimeoutRef.current);
@@ -2393,7 +2414,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setStatus("ready");
setResults([]);
}
if (unsupportedCount > 0) toast.info("浠呮敮鎸佷笂浼犲浘鐗囨垨瑙嗛鏂囦欢");
if (unsupportedCount > 0) toast.info("仅支持上传图片或视频文件");
};
const handleComposerAssetUpload = (event: ChangeEvent<HTMLInputElement>) => {
@@ -2453,7 +2474,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
});
hydrateCloneReferenceImageMeta(nextImages);
} catch (err) {
toast.error(err instanceof Error ? err.message : "鍙傝€冨浘涓婁紶澶辫触");
toast.error(err instanceof Error ? err.message : "参考图上传失败");
}
};
@@ -2561,7 +2582,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const normalizedPlatform = normalizePlatform(nextPlatform);
setPlatform(normalizedPlatform);
setRatio((current) =>
cloneOutput === "hot" && current.startsWith("涓婁紶鍥剧墖") && hotUploadedRatioOption
cloneOutput === "hot" && current.startsWith("上传图片") && hotUploadedRatioOption
? hotUploadedRatioOption
: normalizeRatioForPlatform(normalizedPlatform, current, cloneOutput),
);
@@ -2571,7 +2592,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const handleCloneOutputChange = (nextOutput: CloneOutputKey) => {
setCloneOutput(nextOutput);
setRatio((current) =>
nextOutput === "hot" && current.startsWith("涓婁紶鍥剧墖") && hotUploadedRatioOption
nextOutput === "hot" && current.startsWith("上传图片") && hotUploadedRatioOption
? hotUploadedRatioOption
: normalizeRatioForPlatform(platform, current, nextOutput),
);
@@ -2717,7 +2738,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setRatio((current) => {
const platformRatios = getPlatformRatioOptions(platform, cloneOutput);
const availableRatios = hotUploadedRatioOption ? getUniqueRatioOptions([...platformRatios, hotUploadedRatioOption]) : platformRatios;
if (current.startsWith("涓婁紶鍥剧墖") && hotUploadedRatioOption) return hotUploadedRatioOption;
if (current.startsWith("上传图片") && hotUploadedRatioOption) return hotUploadedRatioOption;
if (availableRatios.includes(current)) return current;
const normalizedRatio = normalizeRatioToken(current);
const matchedRatio = availableRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio));
@@ -2923,7 +2944,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("鏂囦欢璇诲彇澶辫触"));
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
reader.readAsDataURL(blob);
});
@@ -2948,7 +2969,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
for (const item of images) {
try {
if (!item.file && item.src.startsWith("blob:")) {
throw new Error("鏈湴棰勮鍥剧己灏戝師濮嬫枃浠讹紝鏃犳硶涓婁紶");
throw new Error("本地预览图缺少原始文件,无法上传");
}
const rawBlob = item.file ?? (item.src.startsWith("data:") ? null : await (await fetch(item.src)).blob());
const mimeType = normalizeEcommerceImageMime(
@@ -2969,7 +2990,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
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" },
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" },
};
@@ -3000,10 +3021,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
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(`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.");
parts.push("Must comply with platform image guidelines proper margins, no watermark, professional quality.");
return parts.join(" ");
};
@@ -3018,7 +3039,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
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.");
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.");
@@ -3110,7 +3131,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl });
} else {
generatedUrls.push("");
imageGen.updateTask(storeId, { status: "failed", error: "鐢熸垚鏈繑鍥炵粨鏋?" });
imageGen.updateTask(storeId, { status: "failed", error: "生成鏈繑鍥炵粨鏋?" });
}
}
}
@@ -3128,9 +3149,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
}
if (err instanceof ServerRequestError && err.status === 402) {
setResultFn([]);
toast.error("浣欓涓嶈冻锛岃鍏呭€煎悗缁х画");
toast.error("余额不足,请充值后继续");
} else {
const msg = err instanceof Error ? err.message : "鐢熸垚澶辫触";
const msg = err instanceof Error ? err.message : "生成失败";
toast.error(msg);
}
setStatusFn("failed");
@@ -3198,7 +3219,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl });
} else {
statusFn?.("idle");
imageGen.updateTask(storeId, { status: "failed", error: "鐢熸垚鏈繑鍥炵粨鏋?" });
imageGen.updateTask(storeId, { status: "failed", error: "生成鏈繑鍥炵粨鏋?" });
}
} catch (err) {
if (imageAbortRef.current.current) {
@@ -3206,10 +3227,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
return;
}
if (err instanceof ServerRequestError && err.status === 402) {
resultFn?.([{ id: `ecommerce-error-402`, src: "", label: "浣欓涓嶈冻锛岃鍏呭€煎悗缁х画" }]);
toast.error("浣欓涓嶈冻锛岃鍏呭€煎悗缁х画");
resultFn?.([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]);
toast.error("余额不足,请充值后继续");
} else {
const msg = err instanceof Error ? err.message : "鐢熸垚澶辫触";
const msg = err instanceof Error ? err.message : "生成失败";
toast.error(msg);
}
statusFn?.("failed");
@@ -3223,7 +3244,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const readAsDataUrl = (file: File): Promise<string> => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error("鏂囦欢璇诲彇澶辫触"));
reader.onerror = () => reject(new Error("文件读取失败"));
reader.readAsDataURL(file);
});
@@ -3262,7 +3283,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
return;
}
if (resultUrl) {
setResults([{ id: crypto.randomUUID(), src: resultUrl, label: "鎹㈣瑙嗛" }]);
setResults([{ id: crypto.randomUUID(), src: resultUrl, label: "换装视频" }]);
}
setStatus("done");
} catch (err) {
@@ -3271,7 +3292,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
return;
}
setStatus("failed");
toast.error(err instanceof Error ? err.message : "瑙嗛鎹㈣鐢熸垚澶辫触");
toast.error(err instanceof Error ? err.message : "视频换装生成失败");
}
};
@@ -3279,7 +3300,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
if (!canGenerate) return;
if ((appUsage?.balanceCents ?? 0) <= 0) {
toast.error("绉垎涓嶈冻锛岃鍏呭€煎悗缁х画");
toast.error("积分不足,请充值后继续");
return;
}
@@ -3395,7 +3416,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const handleDetailAiWrite = () => {
setDetailRequirement(
"1.浜у搧鍚嶇О锛氭棤绾块檷鍣摑鐗欒€虫満\n2.鏍稿績鍗栫偣锛氫富鍔ㄩ檷鍣€?4H缁埅銆佷綆寤惰繜杩炴帴銆佽垝閫備僵鎴碶n3.閫傜敤浜虹兢锛氶€氬嫟銆佸姙鍏€佽繍鍔ㄥ拰鏃呰鐢ㄦ埛\n4.鏈熸湜鍦烘櫙锛氬湴閾侀€氬嫟銆佸眳瀹跺姙鍏€佹埛澶栬繍鍔╘n5.鍏蜂綋鍙傛暟锛氳摑鐗?.3銆両PX4闃叉按銆佸揩鍏?0鍒嗛挓浣跨敤2灏忔椂",
"1.产品名称:无线降噪蓝牙耳机\n2.鏍稿績鍗栫偣锛氫富鍔ㄩ檷鍣€?4H续航、低延迟连接、舒适佩戴\n3.适用人群:通勤、办公、运动和旅行用户\n4.期望场景:地铁通勤、居家办公、户外运动\n5.鍏蜂綋参数锛氳摑鐗?.3銆両PX4闃叉按銆佸揩鍏?0分钟使用2小时",
);
};
@@ -3447,7 +3468,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setCloneReferenceImages([]);
setCloneReplicateLevel("high");
setRequirement("");
setCloneSettingName("鏂板缓鍒涗綔");
setCloneSettingName("新建创作");
setResults([]);
setStatus("idle");
setGarmentImages([]);
@@ -3475,19 +3496,19 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const isWatermarkTool = isCloneTool && activeQuickTool === "watermark";
const isImageEditTool = isCloneTool && activeQuickTool === "image-edit";
const isHotVideoTool = isCloneTool && activeQuickTool === "hot-video";
const pageLabel = isSetTool ? "鍟嗗搧濂楀浘" : isDetail ? "A+/璇︽儏椤?" : isTryOn ? "AI鏈嶉グ绌挎埓" : activeToolMeta?.label || "鍟嗗搧宸ュ叿";
const pageLabel = isSetTool ? "商品套图" : isDetail ? "A+/璇︽儏椤?" : isTryOn ? "AI服饰穿戴" : activeToolMeta?.label || "商品工具";
const setPrimaryLabel =
setImages.length === 0
? "璇峰厛涓婁紶鍟嗗搧鍘熷浘"
? "请先上传商品原图"
: productSetStatus === "generating"
? "鐢熸垚涓?.."
: "鐢熸垚" + selectedProductSetOutput.label;
? "生成涓?.."
: "生成" + selectedProductSetOutput.label;
const tryOnPrimaryLabel =
garmentImages.length === 0 ? "璇峰厛涓婁紶鏈嶈鍥剧墖" : tryOnStatus === "generating" ? "鐢熸垚涓?.." : "鐢熸垚鏈嶉グ绌挎埓鍥?";
garmentImages.length === 0 ? "请先上传服装图片" : tryOnStatus === "generating" ? "生成涓?.." : "生成服饰穿戴鍥?";
const detailPrimaryLabel =
detailProductImages.length === 0 ? "璇蜂笂浼犱骇鍝佸浘" : detailStatus === "generating" ? "鐢熸垚涓?.." : "鐢熸垚A+璇︽儏椤?";
detailProductImages.length === 0 ? "请上传产品图" : detailStatus === "generating" ? "生成涓?.." : "生成A+璇︽儏椤?";
const clonePrimaryLabel =
productImages.length === 0 ? "璇峰厛涓婁紶鍟嗗搧鍘熷浘" : status === "generating" ? "鐢熸垚涓?.." : "鐢熸垚" + selectedCloneOutput.label;
productImages.length === 0 ? "请先上传商品原图" : status === "generating" ? "生成涓?.." : "生成" + selectedCloneOutput.label;
const setPreviewCards: CloneResult[] = [];
let setIndex = 0;
for (const countKey of cloneSetCountKeys) {
@@ -3522,7 +3543,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
cloneOutput === "set"
? productSetResultImages
.filter(Boolean)
.map((src, index) => ({ id: "history-set-" + String(index), src, label: clonePreviewCards[index]?.label || "濂楀浘 " + String(index + 1) }))
.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[]) =>
@@ -3538,7 +3559,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (diff < minute) return "鍒氬垰";
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)) + " 天前";
@@ -3551,7 +3572,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
if (lastSavedHistorySignatureRef.current === signature && activeHistoryRecordId) return activeHistoryRecordId;
const createdAt = Date.now();
const outputLabel = cloneOutputOptions.find((option) => option.key === cloneOutput)?.label || "鐢熸垚璁板綍";
const outputLabel = cloneOutputOptions.find((option) => option.key === cloneOutput)?.label || "生成记录";
const title = requirement.trim() || outputLabel + " " + new Date(createdAt).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
const record: EcommerceHistoryRecord = {
id: crypto.randomUUID(),
@@ -3935,23 +3956,23 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<section className="product-set-empty-preview" aria-live="polite">
{productSetStatus === "generating" ? <LoadingOutlined /> : <FileImageOutlined />}
<strong>{productSetStatus === "generating" ? "正在生成" : "等待生成"}</strong>
{productSetStatus === "generating" ? <EcommerceProgressBar status="generating" label="商品套图" /> : null}
{productSetStatus === "generating" ? <EcommerceProgressBar status="generating" 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="淇℃伅璇︽儏">
<section className="product-set-floating-detail" aria-label="信息详情">
<div className="product-set-floating-detail__head">
<strong></strong>
<strong></strong>
<span>{productSetRequirement.length}/500</span>
</div>
<textarea
value={productSetRequirement}
onChange={(event) => setProductSetRequirement(event.target.value)}
maxLength={500}
placeholder="寤鸿鍖呭惈浠ヤ笅淇℃伅锛氫骇鍝佸悕绉般€佹牳蹇冨崠鐐广€佹湡鏈涘満鏅€佸叿浣撳弬鏁?"
placeholder="建议鍖呭惈浠ヤ笅淇℃伅锛氫骇鍝佸悕绉般€佹牳蹇冨崠鐐广€佹湡鏈涘満鏅€佸叿浣撳弬鏁?"
/>
<button type="button" className="product-set-floating-submit" disabled={!canGenerateSet} onClick={handleSetGenerate}>
{productSetStatus === "generating" ? <LoadingOutlined /> : null}
@@ -3964,7 +3985,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
) : null}
</section>
<button type="button" className="product-clone-help" aria-label="甯姪">
<button type="button" className="product-clone-help" aria-label="帮助">
<QuestionCircleOutlined />
</button>
</main>
@@ -4197,7 +4218,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
{cloneOutput === "video" ? (
<>
<section className="clone-ai-flow-pipeline" aria-label="生成流程">
{/* Source Node 鈥?鍘熷浘绱犳潗 */}
{/* Source Node —原图素材 */}
<div className="clone-ai-flow-source">
<div className="clone-ai-flow-node clone-ai-flow-node--source">
{productImages[0]?.src ? (
@@ -4211,7 +4232,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<span className="clone-ai-flow-node__label"></span>
</div>
{/* Connector 鈥?鍒嗘敮杩炴帴绾?*/}
{/* Connector 鍒嗘敮杩炴帴绾?*/}
<div className="clone-ai-flow-connector" aria-hidden="true">
<div className="clone-ai-flow-connector__trunk" />
<div className="clone-ai-flow-connector__branches">
@@ -4221,7 +4242,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</div>
</div>
{/* Branches 鈥?鐢熸垚璺緞鍒嗘敮 */}
{/* Branches —生成路径分支 */}
{status === "done" ? (
<div className="clone-ai-flow-branches">
{results[0]?.src ? (
@@ -4284,14 +4305,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
)}
</section>
{/* Status Overlay 鈥?鐢熸垚鐘舵€佽鐩栧眰 */}
{/* 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" label={`${selectedCloneOutput.label}生成`} />
<EcommerceProgressBar status="generating" onCancel={handleCancelGenerate} label={`${selectedCloneOutput.label}生成`} />
<span>AI {platform} / {market} {selectedCloneOutput.label}</span>
</>
) : status === "failed" ? (
@@ -4301,7 +4322,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<span></span>
{lastFailedActionRef.current ? (
<button type="button" className="clone-ai-retry-btn" onClick={lastFailedActionRef.current}>
<ReloadOutlined />
<ReloadOutlined />
</button>
) : null}
</>
@@ -4348,7 +4369,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<section className="clone-ai-empty-state" aria-live="polite">
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <FrownOutlined /> : <FileImageOutlined />}
<strong>{status === "generating" ? "正在生成" : status === "failed" ? "生成失败" : "等待生成"}</strong>
{status === "generating" ? <EcommerceProgressBar status="generating" label={`${selectedCloneOutput.label}生成`} /> : null}
{status === "generating" ? <EcommerceProgressBar status="generating" onCancel={handleCancelGenerate} label={`${selectedCloneOutput.label}生成`} /> : null}
<span>
{status === "generating"
? "AI 正在为 " + platform + " / " + market + " 整理" + selectedCloneOutput.label + "。"
@@ -4526,14 +4547,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
{ label: "智能抠图", icon: <FileImageOutlined />, onClick: openSmartCutoutUpload },
{ label: "商品套图", icon: <AppstoreOutlined />, onClick: openQuickProductSetPage },
{ label: "图片修改", icon: <SettingOutlined />, onClick: openImageWorkbenchPage },
{ label: "增/去水印", icon: <FileImageOutlined />, onClick: openWatermarkRemovalPage },
{ label: "去水印", icon: <FileImageOutlined />, onClick: openWatermarkRemovalPage },
{ label: "图片批处理", icon: <FolderOpenOutlined /> },
{ label: "一键翻译", icon: <FileImageOutlined /> },
{ label: "A+/详情页", icon: <FileImageOutlined />, onClick: openQuickDetailPage },
{ label: "变清晰", icon: <FileImageOutlined /> },
{ label: "AI消除", icon: <SettingOutlined /> },
{ label: "证件照", icon: <SkinOutlined /> },
{ label: "爆款视频", icon: <CloudUploadOutlined />, onClick: openHotVideoPage },
{ label: "广告视频", icon: <CloudUploadOutlined />, onClick: openHotVideoPage },
{ label: "拼图", icon: <TableOutlined /> },
].map((item) => (
<button
@@ -5238,13 +5259,13 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
);
const hotVideoPreview = (
<main className="ecom-hot-video-page" aria-label="爆款视频">
<main className="ecom-hot-video-page" aria-label="广告视频">
<nav className="ecom-hot-video-nav">
<button type="button" className="ecom-hot-video-back" onClick={closeHotVideoPage}>
</button>
<div className="ecom-hot-video-nav-title">
<h1></h1>
<h1>广</h1>
<span>AI智能策划 · </span>
</div>
<div className="ecom-hot-video-nav-meta">
@@ -5406,6 +5427,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<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={() => setActiveQuickTool(null)}>
</button>
@@ -5563,6 +5585,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<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>
@@ -5820,6 +5843,20 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</main>
);
const quickPageSidebar = (isQuickSetTool || isQuickDetailTool || isHotVideoTool) ? (
<aside className="ecom-quick-page-sidebar" aria-label="快捷页面切换">
<button type="button" className={isQuickSetTool ? "is-active" : ""} onClick={openQuickProductSetPage} title="商品套图" aria-label="商品套图">
<AppstoreOutlined /><span></span>
</button>
<button type="button" className={isQuickDetailTool ? "is-active" : ""} onClick={openQuickDetailPage} title="A+/详情" aria-label="A+/详情">
<FileImageOutlined /><span>A+/</span>
</button>
<button type="button" className={isHotVideoTool ? "is-active" : ""} onClick={openHotVideoPage} title="广告视频" aria-label="广告视频">
<ThunderboltOutlined /><span>广</span>
</button>
</aside>
) : null;
const activePreview = isSetTool
? setPreview
: isDetail
@@ -5870,7 +5907,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
id={isCloneTool ? "ecommerce-clone-settings-panel" : undefined}
className={`product-clone-panel tool-panel-enter`}
key={activeTool}
aria-label={`${pageLabel}鍙傛暟`}
aria-label={`${pageLabel}参数`}
aria-hidden={isCloneTool && isCloneSettingsCollapsed ? true : undefined}
>
{isSetTool ? setPanel : isDetail ? detailPanel : isTryOn ? tryOnPanel : isCloneTool ? clonePanel : placeholderPanel}
@@ -5881,10 +5918,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
type="button"
className="clone-ai-settings-toggle"
onClick={() => setIsCloneSettingsCollapsed((current) => !current)}
aria-label={isCloneSettingsCollapsed ? "灞曞紑璁剧疆闈㈡澘" : "鏀惰捣璁剧疆闈㈡澘"}
aria-label={isCloneSettingsCollapsed ? "展开设置面板" : "收起设置面板"}
aria-controls="ecommerce-clone-settings-panel"
aria-expanded={!isCloneSettingsCollapsed}
title={isCloneSettingsCollapsed ? "灞曞紑璁剧疆闈㈡澘" : "鏀惰捣璁剧疆闈㈡澘"}
title={isCloneSettingsCollapsed ? "展开设置面板" : "收起设置面板"}
>
{isCloneSettingsCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</button>
@@ -1,8 +1,10 @@
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
import type { ReactNode } from "react";
interface EcommerceProgressBarProps {
status: "idle" | "generating" | "done" | "failed" | string;
label?: string;
onCancel?: () => void;
}
function mapStatus(status: string): "running" | "completed" | "failed" {
@@ -12,7 +14,7 @@ function mapStatus(status: string): "running" | "completed" | "failed" {
return "running";
}
export function EcommerceProgressBar({ status, label }: EcommerceProgressBarProps) {
export function EcommerceProgressBar({ status, label, onCancel }: EcommerceProgressBarProps) {
const progress = mapStatus(status) === "running" ? 50 : 100;
const smoothed = useSmoothedProgress(progress, mapStatus(status));
@@ -21,6 +23,17 @@ export function EcommerceProgressBar({ status, label }: EcommerceProgressBarProp
return (
<div className="ecommerce-progress-bar">
<span className="ecommerce-progress-bar__label">{label || "AI 正在生成"}</span>
{onCancel ? (
<button
type="button"
className="ecommerce-progress-bar__cancel"
onClick={(e) => { e.stopPropagation(); onCancel(); }}
title="取消生成"
aria-label="取消生成"
>
</button>
) : null}
<div className="ecommerce-progress-bar__track">
<div className="ecommerce-progress-bar__fill" style={{ width: `${smoothed}%` }} />
</div>
@@ -44,6 +44,7 @@ interface EcommerceVideoWorkspaceProps {
onRequestLogin?: () => void;
onOpenHistory?: () => void;
triggerPlan?: number;
saveRef?: { current: (() => void) | null };
}
const ALL_STEPS: PlanStep[] = [
@@ -108,6 +109,7 @@ export default function EcommerceVideoWorkspace({
onRequestLogin,
onOpenHistory,
triggerPlan,
saveRef,
}: EcommerceVideoWorkspaceProps) {
const [stage, setStage] = useState<EcommerceVideoStage>("idle");
const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null);
@@ -195,6 +197,32 @@ export default function EcommerceVideoWorkspace({
}).catch(() => {});
}, [stage, planResult, scenes, sourceImageUrls, platform, aspectRatio, durationSeconds, resolution]);
// ── Expose manual save via ref ──────────────────────────
const planResultRef = useRef(planResult);
planResultRef.current = planResult;
const scenesRef = useRef(scenes);
scenesRef.current = scenes;
const sourceImageUrlsRef = useRef(sourceImageUrls);
sourceImageUrlsRef.current = sourceImageUrls;
useEffect(() => {
if (!saveRef) return;
saveRef.current = () => {
const currentPlan = planResultRef.current;
const currentScenes = scenesRef.current;
const currentSources = sourceImageUrlsRef.current;
if (!currentPlan || !currentScenes.length) return;
const title = currentPlan.storyboard?.video_title || currentPlan.summary?.product_name || "电商广告视频";
saveVideoHistory({
title,
config: { platform, aspectRatio, durationSeconds, resolution },
plan: currentPlan as unknown as Record<string, unknown>,
scenes: currentScenes.map((s) => ({ sceneId: s.sceneId, prompt: s.prompt, imageUrl: s.imageUrl, videoUrl: s.resultUrl })),
sourceImageUrls: currentSources,
}).catch(() => {});
};
}, [saveRef, platform, aspectRatio, durationSeconds, resolution]);
// ── Keep-alive: resume polling for running tasks ──────────
useEffect(() => {
if (keepalivePollingStartedRef.current) return;
@@ -6,6 +6,7 @@ import {
HistoryOutlined,
LoadingOutlined,
PlayCircleOutlined,
SaveOutlined,
} from "@ant-design/icons";
import {
fetchVideoHistory,
@@ -16,11 +17,13 @@ import {
interface EcommerceVideoHistoryPanelProps {
visible: boolean;
onClose: () => void;
onSaveCurrent?: () => void;
}
export default function EcommerceVideoHistoryPanel({
visible,
onClose,
onSaveCurrent,
}: EcommerceVideoHistoryPanelProps) {
const [items, setItems] = useState<VideoHistoryItem[]>([]);
const [total, setTotal] = useState(0);
@@ -68,9 +71,16 @@ export default function EcommerceVideoHistoryPanel({
<div className="ecom-video-history-panel__header">
<HistoryOutlined />
<span></span>
<button className="ecom-video-history-panel__close" onClick={onClose}>
<CloseOutlined />
</button>
<div className="ecom-video-history-panel__actions">
{onSaveCurrent ? (
<button className="ecom-video-history-panel__save" onClick={onSaveCurrent} title="保存当前">
<SaveOutlined />
</button>
) : null}
<button className="ecom-video-history-panel__close" onClick={onClose} title="收起面板">
<HistoryOutlined />
</button>
</div>
</div>
<div className="ecom-video-history-panel__body">