Initial ecommerce standalone package

This commit is contained in:
2026-06-10 14:06:16 +08:00
commit 3d98933e24
241 changed files with 135283 additions and 0 deletions
@@ -0,0 +1,847 @@
import {
CloudUploadOutlined,
CloseOutlined,
FileImageOutlined,
LoadingOutlined,
QuestionCircleOutlined,
ReloadOutlined,
SettingOutlined,
} from "@ant-design/icons";
import { createPortal } from "react-dom";
import type { CSSProperties, ChangeEvent, DragEvent, MutableRefObject, RefObject } from "react";
import { useRef, useState } from "react";
type ProductSetOutputKey = "set" | "detail" | "model" | "video";
type CloneOutputKey = ProductSetOutputKey | "hot" | "video-outfit";
type CloneSetCountKey = "selling" | "white" | "scene";
type CloneModelPanelTab = "scene" | "model";
type CloneReferenceMode = "upload" | "link";
type CloneReplicateLevelKey = "style" | "high";
type CloneVideoQualityKey = "standard" | "high" | "ultra";
type CloneBasicSelectKey = "platform" | "market" | "language" | "ratio";
type CloneModelSelectKey = "gender" | "age" | "ethnicity" | "body";
interface CloneImageItem {
id: string;
src: string;
name: string;
}
interface CloneBasicSelectItem {
key: CloneBasicSelectKey;
label: string;
value: string;
options: string[];
onChange: (value: string) => void;
}
interface CloneModelSelectItem {
key: CloneModelSelectKey;
label: string;
value: string;
options: string[];
onChange: (value: string) => void;
}
interface CloneSetCountOption {
key: CloneSetCountKey;
title: string;
desc: string;
}
interface CloneOutputOption {
key: CloneOutputKey;
label: string;
}
interface CloneReplicateLevelOption {
key: CloneReplicateLevelKey;
title: string;
desc: string;
}
interface CloneVideoQualityOption {
key: CloneVideoQualityKey;
label: string;
desc: string;
}
interface CloneDetailModule {
id: string;
title: string;
desc: string;
}
interface EcommerceClonePanelProps {
productInputRef: RefObject<HTMLInputElement>;
cloneReferenceInputRef: RefObject<HTMLInputElement>;
productImages: CloneImageItem[];
isProductUploadDragging: boolean;
cloneOutput: CloneOutputKey;
cloneOutputOptions: CloneOutputOption[];
cloneBasicSelects: CloneBasicSelectItem[];
openCloneBasicSelect: CloneBasicSelectKey | null;
cloneReferenceMode: CloneReferenceMode;
cloneReferenceImages: CloneImageItem[];
maxCloneReferenceImages: number;
cloneReplicateLevel: CloneReplicateLevelKey;
cloneReplicateLevelOptions: CloneReplicateLevelOption[];
cloneSetCounts: Record<CloneSetCountKey, number>;
cloneSetCountOptions: CloneSetCountOption[];
cloneSetTotal: number;
minCloneSetTotal: number;
maxCloneSetTotal: number;
selectedCloneDetailModules: string[];
cloneDetailModules: CloneDetailModule[];
cloneModelPanelTab: CloneModelPanelTab;
tryOnScenes: string[];
selectedCloneModelScenes: string[];
cloneModelCustomScene: string;
cloneModelSelects: CloneModelSelectItem[];
openCloneModelSelect: CloneModelSelectKey | null;
cloneModelSelectDropUp: boolean;
cloneVideoQuality: CloneVideoQualityKey;
cloneVideoQualityOptions: CloneVideoQualityOption[];
cloneVideoDuration: number;
cloneVideoDurationMin: number;
cloneVideoDurationMax: number;
cloneVideoDurationStyle: CSSProperties;
cloneVideoSmart: boolean;
canGenerate: boolean;
status: string;
lastFailedActionRef: MutableRefObject<(() => void) | null>;
setIsProductUploadDragging: (value: boolean) => void;
handleProductDrop: (event: DragEvent<HTMLDivElement>) => void;
removeProductImage: (id: string) => void;
handleProductUpload: (event: ChangeEvent<HTMLInputElement>) => void;
handleCloneOutputChange: (value: CloneOutputKey) => void;
setOpenCloneBasicSelect: (value: CloneBasicSelectKey | null) => void;
setCloneReferenceMode: (value: CloneReferenceMode) => void;
handleCloneReferenceUpload: (event: ChangeEvent<HTMLInputElement>) => void;
isCloneReferenceDragging: boolean;
handleCloneReferenceDragOver: (event: DragEvent<HTMLButtonElement>) => void;
handleCloneReferenceDragLeave: (event: DragEvent<HTMLButtonElement>) => void;
handleCloneReferenceDrop: (event: DragEvent<HTMLButtonElement>) => void;
setCloneReplicateLevel: (value: CloneReplicateLevelKey) => void;
startCloneSetCountHold: (key: CloneSetCountKey, delta: -1 | 1, disabled: boolean) => void;
clearCloneSetCountHold: () => void;
toggleCloneDetailModule: (id: string) => void;
setCloneModelPanelTab: (value: CloneModelPanelTab) => void;
toggleCloneModelScene: (scene: string) => void;
setCloneModelCustomScene: (value: string) => void;
setOpenCloneModelSelect: (value: CloneModelSelectKey | null) => void;
setCloneModelSelectDropUp: (value: boolean) => void;
setCloneVideoQuality: (value: CloneVideoQualityKey) => void;
setCloneVideoDuration: (value: number) => void;
clampCloneVideoDuration: (value: number) => number;
setCloneVideoSmart: (updater: (current: boolean) => boolean) => void;
handleGenerate: () => void;
onCancelGenerate: () => void;
formatRatioDisplayValue: (value: string) => string;
setVideoOutfitFiles?: (video: File | null, ref: File | null) => void;
onStartVideoPlan?: () => void;
}
export default function EcommerceClonePanel({
productInputRef,
cloneReferenceInputRef,
productImages,
isProductUploadDragging,
cloneOutput,
cloneOutputOptions,
cloneBasicSelects,
openCloneBasicSelect,
cloneReferenceMode,
cloneReferenceImages,
maxCloneReferenceImages,
cloneReplicateLevel,
cloneReplicateLevelOptions,
cloneSetCounts,
cloneSetCountOptions,
cloneSetTotal,
minCloneSetTotal,
maxCloneSetTotal,
selectedCloneDetailModules,
cloneDetailModules,
cloneModelPanelTab,
tryOnScenes,
selectedCloneModelScenes,
cloneModelCustomScene,
cloneModelSelects,
openCloneModelSelect,
cloneModelSelectDropUp,
cloneVideoQuality,
cloneVideoQualityOptions,
cloneVideoDuration,
cloneVideoDurationMin,
cloneVideoDurationMax,
cloneVideoDurationStyle,
cloneVideoSmart,
canGenerate,
status,
lastFailedActionRef,
setIsProductUploadDragging,
handleProductDrop,
removeProductImage,
handleProductUpload,
handleCloneOutputChange,
setOpenCloneBasicSelect,
setCloneReferenceMode,
handleCloneReferenceUpload,
isCloneReferenceDragging,
handleCloneReferenceDragOver,
handleCloneReferenceDragLeave,
handleCloneReferenceDrop,
setCloneReplicateLevel,
startCloneSetCountHold,
clearCloneSetCountHold,
toggleCloneDetailModule,
setCloneModelPanelTab,
toggleCloneModelScene,
setCloneModelCustomScene,
setOpenCloneModelSelect,
setCloneModelSelectDropUp,
setCloneVideoQuality,
setCloneVideoDuration,
clampCloneVideoDuration,
setCloneVideoSmart,
handleGenerate,
onCancelGenerate,
formatRatioDisplayValue,
setVideoOutfitFiles,
onStartVideoPlan,
}: EcommerceClonePanelProps) {
const videoOutfitVideoRef = useRef<HTMLInputElement>(null);
const videoOutfitRefRef = useRef<HTMLInputElement>(null);
const [videoOutfitVideoUrl, setVideoOutfitVideoUrl] = useState<string | null>(null);
const [videoOutfitRefUrl, setVideoOutfitRefUrl] = useState<string | null>(null);
const [zoomImage, setZoomImage] = useState<{ src: string; x: number; y: number } | null>(null);
const handleFileMouseEnter = (src: string, event: React.MouseEvent<HTMLElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
setZoomImage({ src, x: rect.left + rect.width / 2, y: rect.top });
};
const handleFileMouseLeave = () => setZoomImage(null);
const platformSelect = cloneBasicSelects.find((item) => item.key === "platform");
const languageSelects = cloneBasicSelects.filter((item) => item.key === "market" || item.key === "language");
const ratioSelect = cloneBasicSelects.find((item) => item.key === "ratio");
const renderBasicSelect = (item: CloneBasicSelectItem) => {
const hasMultipleOptions = item.options.length > 1;
const isOpen = hasMultipleOptions && openCloneBasicSelect === item.key;
return (
<div key={item.key} className="clone-ai-basic-select" data-clone-basic-select>
<button
type="button"
className={`${isOpen ? "is-open" : ""}${hasMultipleOptions ? "" : " is-static"}`}
aria-expanded={hasMultipleOptions ? isOpen : undefined}
aria-haspopup={hasMultipleOptions ? "listbox" : undefined}
aria-controls={hasMultipleOptions ? `clone-basic-select-${item.key}` : undefined}
onClick={() => setOpenCloneBasicSelect(hasMultipleOptions ? (isOpen ? null : item.key) : null)}
>
<span>{item.label}</span>
<strong>{item.key === "ratio" ? formatRatioDisplayValue(item.value) : item.value}</strong>
{hasMultipleOptions ? <i aria-hidden="true" /> : null}
</button>
{hasMultipleOptions && isOpen ? (
<div id={`clone-basic-select-${item.key}`} className="clone-ai-basic-select__menu" role="listbox">
{item.options.map((option) => (
<button
key={option}
type="button"
className={item.value === option ? "is-active" : ""}
role="option"
aria-selected={item.value === option}
onClick={() => {
item.onChange(option);
setOpenCloneBasicSelect(null);
}}
>
{item.key === "ratio" ? formatRatioDisplayValue(option) : option}
</button>
))}
</div>
) : null}
</div>
);
};
const handleVideoOutfitVideoChange = () => {
const file = videoOutfitVideoRef.current?.files?.[0] || null;
if (file) setVideoOutfitVideoUrl(URL.createObjectURL(file));
setVideoOutfitFiles?.(file, videoOutfitRefRef.current?.files?.[0] || null);
};
const handleVideoOutfitRefChange = () => {
const file = videoOutfitRefRef.current?.files?.[0] || null;
if (file) setVideoOutfitRefUrl(URL.createObjectURL(file));
setVideoOutfitFiles?.(videoOutfitVideoRef.current?.files?.[0] || null, file);
};
return (
<>
<div className="product-clone-panel__scroll clone-ai-panel">
<header className="clone-ai-logo">
<span className="clone-ai-logo__mark">AI</span>
<strong></strong>
</header>
<section className="clone-ai-card">
<h2>
<CloudUploadOutlined />
</h2>
<div
role="button"
tabIndex={0}
className={`clone-ai-upload-zone${isProductUploadDragging ? " is-dragging" : ""}`}
onClick={() => productInputRef.current?.click()}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
productInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsProductUploadDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsProductUploadDragging(false)}
onDrop={handleProductDrop}
>
<div className="clone-ai-upload-main">
<span className="clone-ai-upload-icon">
<FileImageOutlined />
</span>
<span className="clone-ai-upload-title"></span>
<strong>
<span aria-hidden="true">+</span>
</strong>
<span className="clone-ai-upload-hint"> 7 </span>
</div>
{productImages.length ? (
<div className="clone-ai-uploaded-files" aria-label="已上传商品原图">
{productImages.map((item) => (
<figure key={item.id} className="clone-ai-uploaded-file">
<img src={item.src} alt={item.name} />
<span className="uploaded-image-zoom" aria-hidden="true">
<img src={item.src} alt="" />
</span>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
removeProductImage(item.id);
}}
aria-label={`删除${item.name}`}
>
<CloseOutlined />
</button>
</figure>
))}
</div>
) : null}
</div>
<input ref={productInputRef} type="file" accept="image/*" multiple onChange={handleProductUpload} />
</section>
<section className="clone-ai-card clone-ai-settings-card clone-ai-settings-card--mode">
<h2>
<SettingOutlined />
</h2>
<div className="clone-ai-settings-section">
<span className="clone-ai-settings-label"></span>
<div className="clone-ai-tag-group" role="radiogroup" aria-label="生成内容">
{cloneOutputOptions.map((option) => (
<button
key={option.key}
type="button"
className={cloneOutput === option.key ? "is-active" : ""}
aria-pressed={cloneOutput === option.key}
onClick={() => handleCloneOutputChange(option.key)}
>
{option.label}
</button>
))}
</div>
</div>
</section>
{platformSelect ? (
<section className="clone-ai-card clone-ai-settings-card clone-ai-settings-card--platform">
<span className="clone-ai-settings-label"></span>
<div className="clone-ai-select-group clone-ai-select-group--single">
{renderBasicSelect(platformSelect)}
</div>
</section>
) : null}
{languageSelects.length ? (
<section className="clone-ai-card clone-ai-settings-card clone-ai-settings-card--language">
<span className="clone-ai-settings-label"></span>
<div className="clone-ai-select-group clone-ai-select-group--language">
{languageSelects.map(renderBasicSelect)}
</div>
</section>
) : null}
{ratioSelect ? (
<section className="clone-ai-card clone-ai-settings-card clone-ai-settings-card--ratio">
<span className="clone-ai-settings-label"></span>
<div className="clone-ai-select-group clone-ai-select-group--single">
{renderBasicSelect(ratioSelect)}
</div>
</section>
) : null}
{cloneOutput === "hot" ? (
<section className="clone-ai-replicate-panel" aria-label="爆款图复刻设置">
<div className="clone-ai-dynamic-head">
<strong></strong>
<span></span>
</div>
<div className="clone-ai-replicate-section">
<span className="clone-ai-replicate-title"></span>
<div className="clone-ai-replicate-tabs" role="tablist" aria-label="参考内容来源">
<button
type="button"
className={cloneReferenceMode === "upload" ? "is-active" : ""}
aria-selected={cloneReferenceMode === "upload"}
onClick={() => setCloneReferenceMode("upload")}
>
</button>
<button
type="button"
className={cloneReferenceMode === "link" ? "is-active" : ""}
aria-selected={cloneReferenceMode === "link"}
onClick={() => setCloneReferenceMode("link")}
>
</button>
</div>
{cloneReferenceMode === "upload" ? (
<button
type="button"
className={`clone-ai-replicate-upload${isCloneReferenceDragging ? " is-dragging" : ""}${cloneReferenceImages.length ? " has-files" : ""}`}
onClick={() => cloneReferenceInputRef.current?.click()}
onDragOver={handleCloneReferenceDragOver}
onDragLeave={handleCloneReferenceDragLeave}
onDrop={handleCloneReferenceDrop}
>
{cloneReferenceImages.length ? (
<>
<div className="clone-ai-replicate-files">
{cloneReferenceImages.map((item) => (
<figure
key={item.id}
className="clone-ai-replicate-file"
onMouseEnter={(e) => handleFileMouseEnter(item.src, e)}
onMouseLeave={handleFileMouseLeave}
>
<img src={item.src} alt="" />
</figure>
))}
</div>
<span className="clone-ai-replicate-add-more">
<CloudUploadOutlined />
</span>
</>
) : (
<span>
<CloudUploadOutlined />
<span className="clone-ai-replicate-upload-text"></span>
</span>
)}
<em>{cloneReferenceImages.length ? `已选 ${cloneReferenceImages.length}/${maxCloneReferenceImages}` : `最多 ${maxCloneReferenceImages}`}</em>
{isCloneReferenceDragging ? (
<div className="clone-ai-replicate-upload-overlay">
<CloudUploadOutlined />
<span></span>
</div>
) : null}
</button>
) : (
<label className="clone-ai-replicate-link">
<input placeholder="粘贴商品图或详情页链接" />
</label>
)}
<input
ref={cloneReferenceInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
onChange={handleCloneReferenceUpload}
/>
</div>
<div className="clone-ai-replicate-section">
<span className="clone-ai-replicate-title"></span>
<div className="clone-ai-replicate-levels" role="radiogroup" aria-label="复刻程度">
{cloneReplicateLevelOptions.map((option) => (
<button
key={option.key}
type="button"
className={cloneReplicateLevel === option.key ? "is-active" : ""}
aria-pressed={cloneReplicateLevel === option.key}
onClick={() => setCloneReplicateLevel(option.key)}
>
<strong>{option.title}</strong>
<span>{option.desc}</span>
</button>
))}
</div>
</div>
</section>
) : null}
{cloneOutput === "set" ? (
<section className="clone-ai-count-panel" aria-label="套图图片数量">
<div className="clone-ai-dynamic-head">
<strong></strong>
<span></span>
</div>
<p> 1-16 </p>
<div className="clone-ai-count-list">
{cloneSetCountOptions.map((item) => {
const count = cloneSetCounts[item.key];
const decrementDisabled = count <= 0 || cloneSetTotal <= minCloneSetTotal;
const incrementDisabled = cloneSetTotal >= maxCloneSetTotal;
return (
<div key={item.key} className="clone-ai-count-row">
<div className="clone-ai-count-copy">
<strong>{item.title}</strong>
<span>{item.desc}</span>
</div>
<div className="clone-ai-count-stepper" aria-label={`${item.title}数量`}>
<button
type="button"
disabled={decrementDisabled}
onPointerDown={(event) => {
event.preventDefault();
startCloneSetCountHold(item.key, -1, decrementDisabled);
}}
onPointerUp={clearCloneSetCountHold}
onPointerLeave={clearCloneSetCountHold}
onPointerCancel={clearCloneSetCountHold}
onBlur={clearCloneSetCountHold}
aria-label={`减少${item.title}`}
>
-
</button>
<b>{count}</b>
<button
type="button"
disabled={incrementDisabled}
onPointerDown={(event) => {
event.preventDefault();
startCloneSetCountHold(item.key, 1, incrementDisabled);
}}
onPointerUp={clearCloneSetCountHold}
onPointerLeave={clearCloneSetCountHold}
onPointerCancel={clearCloneSetCountHold}
onBlur={clearCloneSetCountHold}
aria-label={`增加${item.title}`}
>
+
</button>
</div>
</div>
);
})}
</div>
</section>
) : null}
{cloneOutput === "detail" ? (
<section className="clone-ai-module-panel" aria-label="详情图包含模块">
<div className="clone-ai-dynamic-head">
<strong></strong>
<span></span>
</div>
<p>
<QuestionCircleOutlined />
</p>
<div className="clone-ai-module-list">
{cloneDetailModules.map((module) => {
const isSelected = selectedCloneDetailModules.includes(module.id);
return (
<button
key={module.id}
type="button"
className={isSelected ? "is-active" : ""}
aria-pressed={isSelected}
onClick={() => toggleCloneDetailModule(module.id)}
>
<strong>{module.title}</strong>
<span>{module.desc}</span>
</button>
);
})}
</div>
</section>
) : null}
{cloneOutput === "model" ? (
<section className="clone-ai-model-panel" aria-label="模特图设置">
<div className="clone-ai-dynamic-head">
<strong></strong>
<span></span>
</div>
<div className="clone-ai-model-tabs" role="tablist" aria-label="模特图设置类型">
<button
type="button"
className={cloneModelPanelTab === "scene" ? "is-active" : ""}
aria-selected={cloneModelPanelTab === "scene"}
onClick={() => setCloneModelPanelTab("scene")}
>
</button>
<button
type="button"
className={cloneModelPanelTab === "model" ? "is-active" : ""}
aria-selected={cloneModelPanelTab === "model"}
onClick={() => setCloneModelPanelTab("model")}
>
</button>
</div>
<div className="clone-ai-model-scroll">
{cloneModelPanelTab === "scene" ? (
<div className="clone-ai-model-scenes">
<div className="clone-ai-model-scene-grid">
{tryOnScenes.map((scene) => {
const isSelected = selectedCloneModelScenes.includes(scene);
return (
<button
key={scene}
type="button"
className={isSelected ? "is-active" : ""}
aria-pressed={isSelected}
onClick={() => toggleCloneModelScene(scene)}
>
<span aria-hidden="true" />
{scene}
</button>
);
})}
</div>
<label className="clone-ai-model-textarea">
<strong></strong>
<textarea
value={cloneModelCustomScene}
onChange={(event) => setCloneModelCustomScene(event.target.value)}
placeholder="描述你想要的场景:如秋季枫叶小径、暖色调午后阳光、模特倚靠树干..."
/>
</label>
</div>
) : (
<div className="clone-ai-model-profile">
<div className="clone-ai-model-select-grid">
{cloneModelSelects.map((item) => {
const isOpen = openCloneModelSelect === item.key;
return (
<div
key={item.key}
className={`clone-ai-model-select${isOpen ? " is-open" : ""}${
isOpen && cloneModelSelectDropUp ? " is-drop-up" : ""
}`}
data-clone-model-select
>
<span>{item.label}</span>
<button
type="button"
className={isOpen ? "is-open" : ""}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={`clone-model-select-${item.key}`}
onClick={(event) => {
setOpenCloneBasicSelect(null);
if (!isOpen) {
event.currentTarget.scrollIntoView({ block: "center", inline: "nearest" });
const triggerRect = event.currentTarget.getBoundingClientRect();
const scrollRect = event.currentTarget.closest(".clone-ai-model-scroll")?.getBoundingClientRect();
const lowerBoundary = Math.min(window.innerHeight, scrollRect?.bottom ?? window.innerHeight);
const upperBoundary = Math.max(0, scrollRect?.top ?? 0);
const estimatedMenuHeight = Math.min(150, item.options.length * 36 + 12);
const belowSpace = lowerBoundary - triggerRect.bottom;
const aboveSpace = triggerRect.top - upperBoundary;
setCloneModelSelectDropUp(belowSpace < estimatedMenuHeight && aboveSpace > belowSpace);
} else {
setCloneModelSelectDropUp(false);
}
setOpenCloneModelSelect(isOpen ? null : item.key);
}}
>
<strong>{item.value}</strong>
<i aria-hidden="true" />
</button>
{isOpen ? (
<div id={`clone-model-select-${item.key}`} className="clone-ai-model-select__menu" role="listbox">
{item.options.map((option) => (
<button
key={option}
type="button"
className={item.value === option ? "is-active" : ""}
role="option"
aria-selected={item.value === option}
onClick={() => {
item.onChange(option);
setOpenCloneModelSelect(null);
setCloneModelSelectDropUp(false);
}}
>
{option}
</button>
))}
</div>
) : null}
</div>
);
})}
</div>
</div>
)}
</div>
</section>
) : null}
{cloneOutput === "video" ? (
<section className="clone-ai-video-panel" aria-label="短视频设置">
<div className="clone-ai-dynamic-head">
<strong></strong>
<span></span>
</div>
<div className="clone-ai-video-section">
<span className="clone-ai-video-title"></span>
<div className="clone-ai-video-options">
{cloneVideoQualityOptions.map((option) => (
<button
key={option.key}
type="button"
className={cloneVideoQuality === option.key ? "is-active" : ""}
aria-pressed={cloneVideoQuality === option.key}
onClick={() => setCloneVideoQuality(option.key)}
>
<strong>{option.label}</strong>
<span>{option.desc}</span>
</button>
))}
</div>
</div>
<div className="clone-ai-video-section">
<div className="clone-ai-video-title-row">
<span className="clone-ai-video-title"></span>
<strong>{cloneVideoDuration}</strong>
</div>
<div className="clone-ai-duration-control" style={cloneVideoDurationStyle}>
<input
type="range"
min={cloneVideoDurationMin}
max={cloneVideoDurationMax}
step={5}
value={cloneVideoDuration}
onChange={(event) => setCloneVideoDuration(clampCloneVideoDuration(Number(event.target.value)))}
aria-label="短视频时长"
/>
<div className="clone-ai-duration-scale" aria-hidden="true">
<span>5</span>
<span>15</span>
<span>30</span>
<span>45</span>
</div>
</div>
</div>
<button
type="button"
className={`clone-ai-video-smart${cloneVideoSmart ? " is-on" : ""}`}
aria-pressed={cloneVideoSmart}
onClick={() => setCloneVideoSmart((current) => !current)}
>
<span>
<strong></strong>
<em></em>
</span>
<i aria-hidden="true" />
</button>
</section>
) : null}
{cloneOutput === "video" && onStartVideoPlan ? (
<button type="button" className="clone-ai-generate" onClick={onStartVideoPlan}>
</button>
) : null}
{cloneOutput === "video-outfit" ? (
<section className="clone-ai-video-panel" aria-label="视频换装">
<div className="clone-ai-dynamic-head">
<strong></strong>
<span></span>
</div>
<div className="clone-ai-video-section">
<span className="clone-ai-video-title"></span>
<div className="clone-ai-video-outfit-upload">
<input
ref={videoOutfitVideoRef}
type="file"
accept="video/*"
onChange={handleVideoOutfitVideoChange}
style={{ display: "none" }}
/>
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitVideoRef.current?.click()}>
{videoOutfitVideoUrl ? "重新上传视频" : "点击上传视频"}
</button>
{videoOutfitVideoUrl ? <span className="clone-ai-video-outfit-info"></span> : null}
</div>
</div>
<div className="clone-ai-video-section">
<span className="clone-ai-video-title">/</span>
<div className="clone-ai-video-outfit-upload">
<input
ref={videoOutfitRefRef}
type="file"
accept="image/*"
onChange={handleVideoOutfitRefChange}
style={{ display: "none" }}
/>
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitRefRef.current?.click()}>
{videoOutfitRefUrl ? "重新上传参考图" : "点击上传参考图"}
</button>
{videoOutfitRefUrl ? <span className="clone-ai-video-outfit-info"></span> : null}
</div>
</div>
</section>
) : null}
<button type="button" className="clone-ai-generate" disabled={!canGenerate || cloneOutput === "video"} onClick={status === "failed" && lastFailedActionRef.current ? lastFailedActionRef.current : handleGenerate} style={cloneOutput === "video" ? { display: "none" } : undefined}>
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <ReloadOutlined /> : null}
{status === "generating" ? "生成中..." : status === "failed" ? "重新生成" : cloneOutput === "video-outfit" ? "✦ 开始换装" : "✦ 开始生成"}
</button>
{status === "generating" && cloneOutput !== "video" ? (
<button type="button" className="clone-ai-generate clone-ai-generate--cancel" onClick={onCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</div>
{zoomImage
? createPortal(
<div
className="clone-ai-zoom-portal"
style={{ left: zoomImage.x, top: zoomImage.y } as CSSProperties}
onMouseLeave={handleFileMouseLeave}
>
<img src={zoomImage.src} alt="" />
</div>,
document.body,
)
: null}
</>
);
}
@@ -0,0 +1,207 @@
import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons";
import { useState, type ChangeEvent, type DragEvent, type RefObject } from "react";
import { EcommerceProgressBar } from "../EcommerceProgressBar";
interface EcommerceDetailPanelProps {
detailInputRef: RefObject<HTMLInputElement>;
detailProductImages: Array<{ id: string; src: string; name: string }>;
detailPlatform: string;
detailMarket: string;
detailLanguage: string;
detailType: string;
detailRequirement: string;
selectedDetailModules: string[];
detailStatus: string;
canGenerateDetail: boolean;
detailPrimaryLabel: string;
platformOptions: string[];
marketOptions: string[];
detailLanguageOptions: string[];
detailTypeOptions: string[];
detailModules: Array<{ id: string; title: string; desc: string }>;
handleDetailUpload: (event: ChangeEvent<HTMLInputElement>) => void;
handleDetailPlatformChange: (value: string) => void;
handleDetailMarketChange: (value: string) => void;
setDetailLanguage: (value: string) => void;
setDetailType: (value: string) => void;
setDetailRequirement: (value: string) => void;
handleDetailAiWrite: () => void;
toggleDetailModule: (id: string) => void;
handleDetailGenerate: () => void;
onCancelGenerate: () => void;
}
export default function EcommerceDetailPanel({
detailInputRef,
detailProductImages,
detailPlatform,
detailMarket,
detailLanguage,
detailType,
detailRequirement,
selectedDetailModules,
detailStatus,
canGenerateDetail,
detailPrimaryLabel,
platformOptions,
marketOptions,
detailLanguageOptions,
detailTypeOptions,
detailModules,
handleDetailUpload,
handleDetailPlatformChange,
handleDetailMarketChange,
setDetailLanguage,
setDetailType,
setDetailRequirement,
handleDetailAiWrite,
toggleDetailModule,
handleDetailGenerate,
onCancelGenerate,
}: EcommerceDetailPanelProps) {
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = (e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes("Files")) setIsDragging(true);
};
const handleDragLeave = (e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragging(false);
}
};
const handleDrop = (e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer.files.length) {
handleDetailUpload({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
}
};
return (
<>
<div className="product-clone-panel__scroll">
<section className="product-clone-field">
<h2>
<QuestionCircleOutlined />
</h2>
<button
type="button"
className={`product-clone-upload-zone product-detail-upload${isDragging ? " is-dragging" : ""}`}
onClick={() => detailInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<strong>
<CloudUploadOutlined />
</strong>
<span>3</span>
</button>
<input ref={detailInputRef} type="file" accept="image/*" multiple onChange={handleDetailUpload} />
{detailProductImages.length ? (
<div className="product-clone-thumb-row" aria-label="已上传商品原图">
{detailProductImages.map((item) => (
<figure key={item.id} className="product-clone-uploaded-thumb">
<img src={item.src} alt={item.name} />
<span className="uploaded-image-zoom" aria-hidden="true">
<img src={item.src} alt="" />
</span>
</figure>
))}
</div>
) : null}
</section>
<section className="product-clone-field">
<h2></h2>
<div className="product-detail-settings-grid">
<select value={detailPlatform} onChange={(event) => handleDetailPlatformChange(event.target.value)}>
{platformOptions.map((item) => (
<option key={item}>{item}</option>
))}
</select>
<select value={detailMarket} onChange={(event) => handleDetailMarketChange(event.target.value)}>
{marketOptions.map((item) => (
<option key={item}>{item}</option>
))}
</select>
<select value={detailLanguage} onChange={(event) => setDetailLanguage(event.target.value)}>
{detailLanguageOptions.map((item) => (
<option key={item}>{item}</option>
))}
</select>
<select value={detailType} onChange={(event) => setDetailType(event.target.value)}>
{detailTypeOptions.map((item) => (
<option key={item}>{item}</option>
))}
</select>
</div>
</section>
<section className="product-clone-field product-detail-requirement">
<h2>
&
<QuestionCircleOutlined />
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleDetailAiWrite();
}}
>
AI
</button>
</h2>
<textarea
value={detailRequirement}
onChange={(event) => setDetailRequirement(event.target.value)}
placeholder={"建议包含以下信息生成更精准:\n1.产品名称\n2.核心卖点\n3.适用人群\n4.期望场景\n5.具体参数"}
/>
</section>
<section className="product-clone-field">
<h2>
<QuestionCircleOutlined />
</h2>
<div className="product-detail-module-grid">
{detailModules.map((module) => (
<button
key={module.id}
type="button"
className={selectedDetailModules.includes(module.id) ? "is-active" : ""}
onClick={() => toggleDetailModule(module.id)}
>
<strong>{module.title}</strong>
<span>{module.desc}</span>
</button>
))}
</div>
</section>
</div>
<footer className="product-clone-panel__footer">
{detailStatus === "generating" ? <EcommerceProgressBar status="generating" label="A+详情页" /> : null}
<button type="button" className="product-clone-primary" disabled={!canGenerateDetail} onClick={handleDetailGenerate}>
{detailStatus === "generating" ? <LoadingOutlined /> : null}
{detailPrimaryLabel}
</button>
{detailStatus === "generating" ? (
<button type="button" className="product-clone-primary product-clone-primary--cancel" onClick={onCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</footer>
</>
);
}
@@ -0,0 +1,171 @@
import { CloudUploadOutlined, CloseOutlined, FileImageOutlined, SettingOutlined } from "@ant-design/icons";
import type { ChangeEvent, DragEvent, RefObject } from "react";
type ProductSetOutputKey = "set" | "detail" | "model" | "video";
interface EcommerceSetPanelProps {
setInputRef: RefObject<HTMLInputElement>;
setImages: Array<{ id: string; src: string; name: string }>;
isSetUploadDragging: boolean;
productSetOutputOptions: Array<{ key: ProductSetOutputKey; label: string }>;
productSetOutput: ProductSetOutputKey;
platformOptions: string[];
marketOptions: string[];
productSetLanguageOptions: string[];
productSetRatioOptions: string[];
productSetPlatform: string;
productSetMarket: string;
productSetLanguage: string;
productSetRatio: string;
setIsSetUploadDragging: (value: boolean) => void;
handleSetDrop: (event: DragEvent<HTMLButtonElement>) => void;
handleSetUpload: (event: ChangeEvent<HTMLInputElement>) => void;
removeSetImage: (id: string) => void;
handleProductSetOutputChange: (value: ProductSetOutputKey) => void;
handleProductSetPlatformChange: (value: string) => void;
handleProductSetMarketChange: (value: string) => void;
setProductSetLanguage: (value: string) => void;
setProductSetRatio: (value: string) => void;
formatRatioDisplayValue: (value: string) => string;
}
export default function EcommerceSetPanel({
setInputRef,
setImages,
isSetUploadDragging,
productSetOutputOptions,
productSetOutput,
platformOptions,
marketOptions,
productSetLanguageOptions,
productSetRatioOptions,
productSetPlatform,
productSetMarket,
productSetLanguage,
productSetRatio,
setIsSetUploadDragging,
handleSetDrop,
handleSetUpload,
removeSetImage,
handleProductSetOutputChange,
handleProductSetPlatformChange,
handleProductSetMarketChange,
setProductSetLanguage,
setProductSetRatio,
formatRatioDisplayValue,
}: EcommerceSetPanelProps) {
return (
<>
<div className="product-clone-panel__scroll">
<section className="product-clone-field product-set-upload-section">
<h2>
<CloudUploadOutlined />
</h2>
<button
type="button"
className={`product-clone-upload-zone product-set-upload${isSetUploadDragging ? " is-dragging" : ""}`}
onClick={() => setInputRef.current?.click()}
onDragEnter={(event) => {
event.preventDefault();
setIsSetUploadDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsSetUploadDragging(false)}
onDrop={handleSetDrop}
>
<span className="product-set-upload-icon">
<FileImageOutlined />
</span>
<span className="product-set-upload-title"></span>
<strong>
<span aria-hidden="true">+</span>
</strong>
<span className="product-set-upload-note"> 3 </span>
</button>
<input ref={setInputRef} type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={handleSetUpload} />
{setImages.length ? (
<div className="product-clone-thumb-row product-set-thumb-row" aria-label="已上传商品原图">
{setImages.map((item) => (
<figure key={item.id} className="product-set-thumb">
<img src={item.src} alt={item.name} />
<span className="uploaded-image-zoom" aria-hidden="true">
<img src={item.src} alt="" />
</span>
<button type="button" onClick={() => removeSetImage(item.id)} aria-label={`删除${item.name}`}>
<CloseOutlined />
</button>
</figure>
))}
</div>
) : null}
</section>
<section className="product-clone-field product-set-settings-section">
<h2>
<SettingOutlined />
</h2>
<div className="product-set-setting-block">
<span className="product-set-setting-title"></span>
<div className="product-set-output-grid" role="radiogroup" aria-label="生成内容">
{productSetOutputOptions.map((option) => (
<button
key={option.key}
type="button"
className={productSetOutput === option.key ? "is-active" : ""}
aria-pressed={productSetOutput === option.key}
onClick={() => handleProductSetOutputChange(option.key)}
>
{option.label}
</button>
))}
</div>
</div>
<div className="product-set-setting-block">
<span className="product-set-setting-title"></span>
<div className="product-set-field-grid">
<label>
<span></span>
<select value={productSetPlatform} onChange={(event) => handleProductSetPlatformChange(event.target.value)}>
{platformOptions.map((item) => (
<option key={item}>{item}</option>
))}
</select>
</label>
<label>
<span></span>
<select value={productSetMarket} onChange={(event) => handleProductSetMarketChange(event.target.value)}>
{marketOptions.map((item) => (
<option key={item}>{item}</option>
))}
</select>
</label>
<label>
<span></span>
<select value={productSetLanguage} onChange={(event) => setProductSetLanguage(event.target.value)}>
{productSetLanguageOptions.map((item) => (
<option key={item}>{item}</option>
))}
</select>
</label>
<label>
<span>/</span>
<select
value={productSetRatio}
onChange={(event) => setProductSetRatio(event.target.value)}
disabled={productSetRatioOptions.length <= 1}
>
{productSetRatioOptions.map((item) => (
<option key={item} value={item}>{formatRatioDisplayValue(item)}</option>
))}
</select>
</label>
</div>
</div>
</section>
</div>
</>
);
}
@@ -0,0 +1,258 @@
import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons";
import { useState, type ChangeEvent, type DragEvent, type RefObject } from "react";
import { EcommerceProgressBar } from "../EcommerceProgressBar";
interface EcommerceTryOnPanelProps {
garmentInputRef: RefObject<HTMLInputElement>;
garmentImages: Array<{ id: string; src: string; name: string }>;
modelSource: string;
modelGender: string;
modelAge: string;
modelEthnicity: string;
modelBody: string;
appearance: string;
selectedScenes: string[];
customScene: string;
smartScene: boolean;
tryOnRatio: string;
tryOnStatus: string;
canGenerateTryOn: boolean;
tryOnPrimaryLabel: string;
tryOnModelOptions: { gender: string[]; age: string[]; ethnicity: string[]; body: string[] };
tryOnAssets: { modelWoman: string; modelMan: string; modelAsian: string };
tryOnScenes: string[];
tryOnRatioOptions: string[];
handleGarmentUpload: (event: ChangeEvent<HTMLInputElement>) => void;
setModelSource: (value: "ai" | "library") => void;
setModelGender: (value: string) => void;
setModelAge: (value: string) => void;
setModelEthnicity: (value: string) => void;
setModelBody: (value: string) => void;
setAppearance: (value: string) => void;
handleGenerateModel: () => void;
toggleScene: (scene: string) => void;
setCustomScene: (value: string) => void;
setSmartScene: (updater: (current: boolean) => boolean) => void;
setTryOnRatio: (value: string) => void;
handleTryOnGenerate: () => void;
onCancelGenerate: () => void;
}
export default function EcommerceTryOnPanel({
garmentInputRef,
garmentImages,
modelSource,
modelGender,
modelAge,
modelEthnicity,
modelBody,
appearance,
selectedScenes,
customScene,
smartScene,
tryOnRatio,
tryOnStatus,
canGenerateTryOn,
tryOnPrimaryLabel,
tryOnModelOptions,
tryOnAssets,
tryOnScenes,
tryOnRatioOptions,
handleGarmentUpload,
setModelSource,
setModelGender,
setModelAge,
setModelEthnicity,
setModelBody,
setAppearance,
handleGenerateModel,
toggleScene,
setCustomScene,
setSmartScene,
setTryOnRatio,
handleTryOnGenerate,
onCancelGenerate,
}: EcommerceTryOnPanelProps) {
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = (e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes("Files")) setIsDragging(true);
};
const handleDragLeave = (e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragging(false);
}
};
const handleDrop = (e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer.files.length) {
handleGarmentUpload({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
}
};
return (
<>
<div className="product-clone-panel__scroll">
<section className="product-clone-field">
<h2></h2>
<button
type="button"
className={`product-clone-upload-zone product-try-on-upload${isDragging ? " is-dragging" : ""}`}
onClick={() => garmentInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<strong>
<CloudUploadOutlined />
</strong>
<span>5</span>
</button>
<input ref={garmentInputRef} type="file" accept="image/*" multiple onChange={handleGarmentUpload} />
{garmentImages.length ? (
<div className="product-clone-thumb-row product-try-on-thumb-row" aria-label="已上传服装图片">
{garmentImages.map((item) => (
<figure key={item.id} className="product-clone-uploaded-thumb">
<img src={item.src} alt={item.name} />
<span className="uploaded-image-zoom" aria-hidden="true">
<img src={item.src} alt="" />
</span>
</figure>
))}
</div>
) : null}
</section>
<section className="product-clone-field">
<h2></h2>
<div className="product-clone-segment" role="tablist" aria-label="模特来源">
<button type="button" className={modelSource === "ai" ? "is-active" : ""} onClick={() => setModelSource("ai")}>
AI
</button>
<button type="button" className={modelSource === "library" ? "is-active" : ""} onClick={() => setModelSource("library")}>
<QuestionCircleOutlined />
</button>
</div>
{modelSource === "ai" ? (
<>
<div className="product-clone-model-grid">
<select value={modelGender} onChange={(event) => setModelGender(event.target.value)}>
{tryOnModelOptions.gender.map((item) => (
<option key={item}>{item}</option>
))}
</select>
<select value={modelAge} onChange={(event) => setModelAge(event.target.value)}>
{tryOnModelOptions.age.map((item) => (
<option key={item}>{item}</option>
))}
</select>
<select value={modelEthnicity} onChange={(event) => setModelEthnicity(event.target.value)}>
{tryOnModelOptions.ethnicity.map((item) => (
<option key={item}>{item}</option>
))}
</select>
<select value={modelBody} onChange={(event) => setModelBody(event.target.value)}>
{tryOnModelOptions.body.map((item) => (
<option key={item}>{item}</option>
))}
</select>
</div>
<label className="product-try-on-textarea-label">
<span></span>
<textarea
value={appearance}
onChange={(event) => setAppearance(event.target.value)}
placeholder="例如:小麦色皮肤、齐刘海、眼角有泪痣..."
/>
</label>
<button type="button" className="product-clone-model-button" onClick={handleGenerateModel} disabled={tryOnStatus === "modeling"}>
{tryOnStatus === "modeling" ? <LoadingOutlined /> : null}
{tryOnStatus === "modeling" ? "生成中..." : "生成基准模特"}
</button>
</>
) : (
<div className="product-try-on-library" aria-label="模特库">
{[tryOnAssets.modelWoman, tryOnAssets.modelMan, tryOnAssets.modelAsian].map((src, index) => (
<button key={src} type="button" className={index === 0 ? "is-active" : ""}>
<img src={src} alt={`模特 ${index + 1}`} />
</button>
))}
</div>
)}
</section>
<section className="product-clone-field">
<h2></h2>
<div className="product-clone-scene-grid">
{tryOnScenes.map((scene) => (
<button
key={scene}
type="button"
className={selectedScenes.includes(scene) ? "is-active" : ""}
onClick={() => toggleScene(scene)}
>
<span aria-hidden="true" />
{scene}
</button>
))}
</div>
</section>
<label className="product-clone-field product-try-on-scene-field">
<h2></h2>
<textarea
value={customScene}
onChange={(event) => setCustomScene(event.target.value)}
placeholder="描述你想要的场景:如秋季枫叶小径、暖色调午后阳光、模特倚靠树干..."
/>
</label>
<section className="product-clone-field">
<button type="button" className="product-clone-switch-row" onClick={() => setSmartScene((current) => !current)}>
<span>
<strong></strong>
<em></em>
</span>
<span className={`product-clone-switch${smartScene ? " is-on" : ""}`} role="switch" aria-checked={smartScene}>
<span />
</span>
</button>
</section>
<section className="product-clone-field">
<h2></h2>
<div className="product-clone-ratio-row">
{tryOnRatioOptions.map((item) => (
<button key={item} type="button" className={tryOnRatio === item ? "is-active" : ""} onClick={() => setTryOnRatio(item)}>
{item}
</button>
))}
</div>
</section>
</div>
<footer className="product-clone-panel__footer">
{tryOnStatus === "generating" ? <EcommerceProgressBar status="generating" label="服饰穿戴图" /> : null}
<button type="button" className="product-clone-primary" disabled={!canGenerateTryOn} onClick={handleTryOnGenerate}>
{tryOnStatus === "generating" ? <LoadingOutlined /> : null}
{tryOnPrimaryLabel}
</button>
{tryOnStatus === "generating" ? (
<button type="button" className="product-clone-primary product-clone-primary--cancel" onClick={onCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</footer>
</>
);
}
@@ -0,0 +1,185 @@
import { useCallback, useEffect, useState } from "react";
import {
CloseOutlined,
DeleteOutlined,
ExclamationCircleOutlined,
HistoryOutlined,
LoadingOutlined,
PlayCircleOutlined,
} from "@ant-design/icons";
import {
fetchVideoHistory,
deleteVideoHistory,
type VideoHistoryItem,
} from "../ecommerceVideoService";
interface EcommerceVideoHistoryPanelProps {
visible: boolean;
onClose: () => void;
}
export default function EcommerceVideoHistoryPanel({
visible,
onClose,
}: EcommerceVideoHistoryPanelProps) {
const [items, setItems] = useState<VideoHistoryItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [offset, setOffset] = useState(0);
const [previewMedia, setPreviewMedia] = useState<{
url: string;
type: "image" | "video";
} | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
const limit = 10;
const load = useCallback(async (off: number) => {
setLoading(true);
try {
const res = await fetchVideoHistory(limit, off);
setItems(res.items);
setTotal(res.total);
setOffset(off);
} catch { /* silent */ }
setLoading(false);
}, []);
useEffect(() => {
if (visible) load(0);
}, [visible, load]);
const handleDelete = async (id: number) => {
try {
await deleteVideoHistory(id);
setItems((prev) => prev.filter((i) => i.id !== id));
setTotal((t) => t - 1);
} catch { /* silent */ }
setConfirmDeleteId(null);
};
if (!visible) return null;
const totalPages = Math.ceil(total / limit);
const currentPage = Math.floor(offset / limit) + 1;
return (
<>
<div className="ecom-video-history-panel">
<div className="ecom-video-history-panel__header">
<HistoryOutlined />
<span></span>
<button className="ecom-video-history-panel__close" onClick={onClose}>
<CloseOutlined />
</button>
</div>
<div className="ecom-video-history-panel__body">
{loading && !items.length ? (
<div className="ecom-video-history-panel__empty">
<LoadingOutlined style={{ fontSize: 24 }} />
<span>...</span>
</div>
) : !items.length ? (
<div className="ecom-video-history-panel__empty">
<HistoryOutlined style={{ fontSize: 32, opacity: 0.3 }} />
<span></span>
</div>
) : (
items.map((item) => (
<div key={item.id} className="ecom-video-history-card">
<div className="ecom-video-history-card__header">
<span className="ecom-video-history-card__title">
{item.title || "未命名"}
</span>
<span className="ecom-video-history-card__date">
{new Date(item.createdAt).toLocaleDateString("zh-CN")}
</span>
<button
className="ecom-video-history-card__delete"
onClick={() => setConfirmDeleteId(item.id)}
title="删除"
>
<DeleteOutlined />
</button>
</div>
<div className="ecom-video-history-card__scenes">
{item.scenes.map((scene, idx) => (
<div key={idx} className="ecom-video-history-card__scene">
{scene.imageUrl && (
<img
src={scene.imageUrl}
alt={`分镜${idx + 1}`}
onClick={() =>
setPreviewMedia({ url: scene.imageUrl!, type: "image" })
}
/>
)}
{scene.videoUrl && (
<div
className="ecom-video-history-card__video-thumb"
onClick={() =>
setPreviewMedia({ url: scene.videoUrl!, type: "video" })
}
>
<PlayCircleOutlined />
</div>
)}
</div>
))}
</div>
</div>
))
)}
</div>
{totalPages > 1 && (
<div className="ecom-video-history-panel__pager">
<button disabled={currentPage <= 1} onClick={() => load(offset - limit)}>
</button>
<span>{currentPage}/{totalPages}</span>
<button disabled={currentPage >= totalPages} onClick={() => load(offset + limit)}>
</button>
</div>
)}
</div>
{confirmDeleteId !== null && (
<div className="ecom-video-confirm-dialog-backdrop" onClick={() => setConfirmDeleteId(null)}>
<div className="ecom-video-confirm-dialog" onClick={(e) => e.stopPropagation()}>
<ExclamationCircleOutlined className="ecom-video-confirm-dialog__icon" />
<p className="ecom-video-confirm-dialog__text">
</p>
<div className="ecom-video-confirm-dialog__actions">
<button onClick={() => setConfirmDeleteId(null)}></button>
<button className="is-danger" onClick={() => handleDelete(confirmDeleteId)}>
</button>
</div>
</div>
</div>
)}
{previewMedia && (
<div
className="ecom-video-preview-overlay"
onClick={() => setPreviewMedia(null)}
>
<button
className="ecom-video-preview-overlay__close"
onClick={() => setPreviewMedia(null)}
>
<CloseOutlined />
</button>
{previewMedia.type === "image" ? (
<img src={previewMedia.url} alt="preview" />
) : (
<video src={previewMedia.url} controls autoPlay />
)}
</div>
)}
</>
);
}