@@ -4,7 +4,6 @@ import {
CloudUploadOutlined ,
CloseOutlined ,
DeleteOutlined ,
DownloadOutlined ,
EditOutlined ,
FireOutlined ,
FileImageOutlined ,
@@ -47,6 +46,9 @@ import { EcommerceProgressBar } from "./EcommerceProgressBar";
import ImageMentionMenu , { getImageMentionQuery , insertImageMentionValue , type MentionImageOption } from "./ImageMentionMenu" ;
import EcommerceVideoWorkspace from "./EcommerceVideoWorkspace" ;
import EcommerceVideoHistoryPanel from "./panels/EcommerceVideoHistoryPanel" ;
import ProductSetHostingModal from "./panels/ProductSetHostingModal" ;
import ProductSetPreviewModal , { type ProductSetPreviewSelection } from "./panels/ProductSetPreviewModal" ;
import CommandHistorySidebar from "./panels/CommandHistorySidebar" ;
import EcommerceDetailPanel from "./panels/EcommerceDetailPanel" ;
import EcommerceSetPanel from "./panels/EcommerceSetPanel" ;
import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel" ;
@@ -55,6 +57,28 @@ import EcommerceCopywritingPanel from "./panels/EcommerceCopywritingPanel";
import { ecommerceOssScopes , saveUnifiedEcommerceGenerationRecord , deleteEcommerceGenerationRecord } from "./ecommerceGenerationPersistence" ;
import { downloadResultAsset } from "../workbench/workbenchDownload" ;
import type { CloneOutputKey , ProductSetOutputKey } from "./utils/platformRules" ;
import {
cloneLatestSettingStorageKey ,
defaultCloneDetailModuleIds ,
defaultCloneSetCounts ,
ecommerceHistoryStorageKey ,
isCloneImageItem ,
isCloneResult ,
isCloneSavedSetting ,
readCloneLatestSetting ,
removeFilePayloadFromImages ,
writeCloneLatestSetting ,
} from "./utils/clonePersistence" ;
import type {
CloneImageItem ,
CloneModelPanelTab ,
CloneReferenceMode ,
CloneReplicateLevelKey ,
CloneResult ,
CloneSavedSetting ,
CloneSetCountKey ,
CloneVideoQualityKey ,
} from "./utils/clonePersistence" ;
const smartCutoutColorPresets = [
"#ffffff" ,
@@ -181,91 +205,6 @@ const buildInspirationPrompt = (title: string, meta: string): string => {
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" ;
@@ -277,6 +216,30 @@ import {
summarizeRejectedImages ,
validateEcommerceImageFiles ,
} from "./ecommerceImageValidation" ;
import {
clampNumber ,
hexToHsv ,
hexToRgb ,
hsvToRgb ,
normalizeHexColor ,
parseSmartCutoutAspect ,
parseSmartCutoutPercent ,
rgbToHex ,
} from "./utils/colorUtils" ;
import {
formatAspectRatio ,
formatRatioDisplayValue ,
getQuickSetRatioValue ,
getRatioDisplayParts ,
greatestCommonDivisor ,
normalizeRatioForApi ,
normalizeRatioToken ,
parseRatioToAspectCss ,
quickSetRatioOptions ,
supportedImageApiRatios ,
toSupportedImageApiRatio ,
type SupportedImageApiRatio ,
} from "./utils/ratioUtils" ;
interface ProductClonePageProps {
@@ -285,9 +248,6 @@ interface ProductClonePageProps {
type ProductCloneStatus = "idle" | "ready" | "generating" | "done" | "failed" ;
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" | "assetLibrary" | "workMode" | "aiWrite" ;
@@ -295,8 +255,6 @@ type ComposerAssetTabKey = "recent" | "recipe" | "model";
type ComposerWorkModeKey = "quick" | "think" ;
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 ;
@@ -313,25 +271,6 @@ 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 ;
@@ -357,33 +296,6 @@ interface PreviewTouchGesture {
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 {
@@ -430,14 +342,6 @@ interface EcommerceHistoryRecord {
turns? : EcommerceHistoryTurn [ ] ;
}
interface ProductSetPreviewSelection {
src : string ;
label : string ;
nodeId? : string ;
cardId? : string ;
removable? : boolean ;
}
interface EcommerceImagePromptOptions {
gender? : string ;
age? : string ;
@@ -907,15 +811,6 @@ 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 ( "\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 ;
@@ -923,105 +818,6 @@ 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" , "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 * [ × x X ] \ 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 * [ × x X ] \ s * ( \ d + ) \ s * p x ? / 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 * [ × x X * ] \ 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 } ` : "" ;
@@ -1418,11 +1214,6 @@ const cloneSetCountOptions: Array<{
{ 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 ;
@@ -1432,8 +1223,6 @@ 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 : "推荐" } ,
@@ -1512,7 +1301,6 @@ const detailModules = [
{ 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 ;
@@ -1648,50 +1436,6 @@ function notifyRejectedImages(files: File[]): File[] {
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 (
@@ -1711,19 +1455,6 @@ function isEcommerceHistoryRecord(item: unknown): item is EcommerceHistoryRecord
) ;
}
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 [ ] ;
@@ -4921,7 +4652,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
productImages , cloneSetCounts , quickSetRequirement ,
platform , ratio , language , market ,
( s ) = > {
setQuickSetStatus ( s as ProductCloneStatus ) ;
setQuickSetStatus ( s as "idle" | "generating" | "done" | "failed" ) ;
if ( s === "done" ) {
stopQuickSetProgress ( ) ;
setQuickSetProgress ( 100 ) ;
@@ -8839,160 +8570,35 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
< / div >
{ isCloneTool && ! isCommandHistoryCollapsed ? (
< div
className = "ecom-command-history__backdrop"
role = "presentation"
onClick = { ( ) = > setIsCommandHistoryCollapsed ( true ) }
/ >
) : null }
< CommandHistorySidebar
collapsed = { isCommandHistoryCollapsed }
showBackdrop = { isCloneTool && ! isCommandHistoryCollapsed }
records = { ecommerceHistoryRecords }
activeRecordId = { activeHistoryRecordId }
isRefreshing = { isHistoryRefreshing }
refreshMessage = { historyRefreshMessage }
refreshStamp = { historyRefreshStamp }
refreshTick = { historyRefreshTick }
outputLabels = { cloneOutputOptions }
formatHistoryTime = { formatHistoryTime }
onToggleCollapsed = { ( ) = > setIsCommandHistoryCollapsed ( ( current ) = > ! current ) }
onCollapse = { ( ) = > setIsCommandHistoryCollapsed ( true ) }
onNewConversation = { handleNewEcommerceConversation }
onRefresh = { refreshEcommerceHistory }
onOpenRecord = { openEcommerceHistoryRecord }
onDeleteRecord = { deleteHistoryRecord }
/ >
< 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 >
< ProductSetPreviewModal
preview = { selectedProductSetPreview }
onClose = { ( ) = > setSelectedProductSetPreview ( null ) }
onDownload = { ( preview ) = > {
void handleDownloadCanvasResult ( preview ) ;
} }
onRemove = { removeSelectedProductSetPreview }
/ >
{ 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 }
< ProductSetHostingModal visible = { showHostingModal } onClose = { ( ) = > setShowHostingModal ( false ) } / >
< EcommerceVideoHistoryPanel
visible = { videoHistoryVisible }