443 lines
16 KiB
TypeScript
443 lines
16 KiB
TypeScript
import {
|
||
FileImageOutlined,
|
||
PlusOutlined,
|
||
ThunderboltOutlined,
|
||
VideoCameraOutlined,
|
||
} from "@ant-design/icons";
|
||
import { useMemo, useRef, useState, type ChangeEvent, type DragEvent, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type RefObject } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import EcommerceVideoWorkspace from "../EcommerceVideoWorkspace";
|
||
|
||
interface CloneImageItem {
|
||
id: string;
|
||
src: string;
|
||
name: string;
|
||
file?: File;
|
||
}
|
||
|
||
type CloneVideoQualityKey = "standard" | "high" | "ultra";
|
||
|
||
interface EcommerceOneClickVideoPanelProps {
|
||
onClose: () => void;
|
||
isAuthenticated: boolean;
|
||
onRequestLogin: () => void;
|
||
productImages: CloneImageItem[];
|
||
productInputRef: RefObject<HTMLInputElement>;
|
||
isProductUploadDragging: boolean;
|
||
setIsProductUploadDragging: (value: boolean) => void;
|
||
handleProductDrop: (event: DragEvent<HTMLDivElement>) => void;
|
||
handleProductUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||
removeProductImage: (imageId: string) => void;
|
||
maxProductImages: number;
|
||
requirement: string;
|
||
onRequirementChange: (value: string) => void;
|
||
platform: string;
|
||
platformOptions: string[];
|
||
onPlatformChange: (value: string) => void;
|
||
ratio: string;
|
||
ratioOptions: string[];
|
||
onRatioChange: (value: string) => void;
|
||
videoQuality: CloneVideoQualityKey;
|
||
videoQualityOptions: Array<{ key: CloneVideoQualityKey; label: string; desc: string }>;
|
||
onVideoQualityChange: (value: CloneVideoQualityKey) => void;
|
||
videoDuration: number;
|
||
videoDurationMin: number;
|
||
videoDurationMax: number;
|
||
onVideoDurationChange: (value: number) => void;
|
||
videoSmart: boolean;
|
||
onVideoSmartChange: (value: boolean) => void;
|
||
onOpenHistory: () => void;
|
||
}
|
||
|
||
function getVideoAspectRatio(ratio: string): string {
|
||
if (ratio.includes("9:16")) return "9:16";
|
||
if (ratio.includes("16:9")) return "16:9";
|
||
if (ratio.includes("3:4")) return "3:4";
|
||
return "9:16";
|
||
}
|
||
|
||
function openQuickUploadWithKeyboard(
|
||
event: KeyboardEvent<HTMLDivElement>,
|
||
inputRef: { current: HTMLInputElement | null },
|
||
) {
|
||
if (event.key !== "Enter" && event.key !== " ") return;
|
||
event.preventDefault();
|
||
inputRef.current?.click();
|
||
}
|
||
|
||
export default function EcommerceOneClickVideoPanel({
|
||
onClose,
|
||
isAuthenticated,
|
||
onRequestLogin,
|
||
productImages,
|
||
productInputRef,
|
||
isProductUploadDragging,
|
||
setIsProductUploadDragging,
|
||
handleProductDrop,
|
||
handleProductUpload,
|
||
removeProductImage,
|
||
maxProductImages,
|
||
requirement,
|
||
onRequirementChange,
|
||
platform,
|
||
platformOptions,
|
||
onPlatformChange,
|
||
ratio,
|
||
ratioOptions,
|
||
onRatioChange,
|
||
videoQuality,
|
||
videoQualityOptions,
|
||
onVideoQualityChange,
|
||
videoDuration,
|
||
videoDurationMin,
|
||
videoDurationMax,
|
||
onVideoDurationChange,
|
||
videoSmart,
|
||
onVideoSmartChange,
|
||
onOpenHistory,
|
||
}: EcommerceOneClickVideoPanelProps) {
|
||
const [openSelect, setOpenSelect] = useState<"platform" | "ratio" | null>(null);
|
||
const [planTrigger, setPlanTrigger] = useState(0);
|
||
const [hoverZoom, setHoverZoom] = useState<{ src: string; x: number; y: number; placement: "right" | "left" } | null>(null);
|
||
const selectAnchorRef = useRef<HTMLDivElement>(null);
|
||
|
||
const productImageDataUrls = useMemo(() => productImages.map((img) => img.src), [productImages]);
|
||
const productImageFiles = useMemo(() => productImages.map((img) => img.file), [productImages]);
|
||
|
||
const canGenerate = productImages.length > 0 || requirement.trim().length > 0;
|
||
|
||
const handleGenerate = () => {
|
||
if (!isAuthenticated) {
|
||
onRequestLogin();
|
||
return;
|
||
}
|
||
setPlanTrigger((value) => value + 1);
|
||
};
|
||
|
||
const handlePlatformSelect = (value: string) => {
|
||
onPlatformChange(value);
|
||
setOpenSelect(null);
|
||
};
|
||
|
||
const handleRatioSelect = (value: string) => {
|
||
onRatioChange(value);
|
||
setOpenSelect(null);
|
||
};
|
||
|
||
const toggleSelect = (key: "platform" | "ratio") => {
|
||
setOpenSelect((current) => (current === key ? null : key));
|
||
};
|
||
|
||
const handleThumbMouseEnter = (src: string, event: ReactMouseEvent<HTMLElement>) => {
|
||
const rect = event.currentTarget.getBoundingClientRect();
|
||
const previewWidth = 300;
|
||
const previewHeight = 190;
|
||
const gap = 12;
|
||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
|
||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||
const canShowRight = rect.right + gap + previewWidth <= viewportWidth - gap;
|
||
const placement: "right" | "left" = canShowRight ? "right" : "left";
|
||
const x = placement === "right" ? rect.right + gap : Math.max(gap, rect.left - gap);
|
||
const y = Math.min(
|
||
Math.max(rect.top + rect.height / 2, previewHeight / 2 + gap),
|
||
Math.max(previewHeight / 2 + gap, viewportHeight - previewHeight / 2 - gap),
|
||
);
|
||
setHoverZoom({ src, x, y, placement });
|
||
};
|
||
|
||
const renderThumbs = () => (
|
||
<div className="ecom-quick-upload-thumbs" aria-label="已上传商品原图">
|
||
{productImages.map((item) => (
|
||
<figure
|
||
key={item.id}
|
||
className="ecom-command-asset-thumb ecom-quick-upload-thumb"
|
||
onMouseEnter={(event) => handleThumbMouseEnter(item.src, event)}
|
||
onMouseLeave={() => setHoverZoom(null)}
|
||
>
|
||
<img src={item.src} alt={item.name} />
|
||
<button
|
||
type="button"
|
||
className="ecom-hot-material-delete"
|
||
aria-label="删除图片"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setHoverZoom(null);
|
||
removeProductImage(item.id);
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</figure>
|
||
))}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<main className="ecom-one-click-video-page ecom-quick-hot-page ecom-quick-set-page ecom-tool-page-enter" aria-label="一键视频">
|
||
<div className="ecom-quick-set-body">
|
||
<aside className="ecom-quick-set-panel" aria-label="一键视频设置">
|
||
<header className="ecom-quick-set-panel-head">
|
||
<strong className="ecom-quick-set-page-title">
|
||
<VideoCameraOutlined /> 一键视频
|
||
</strong>
|
||
<button type="button" className="ecom-quick-set-back" onClick={onClose}>
|
||
首页
|
||
</button>
|
||
<button type="button" className="ecom-quick-set-back" onClick={onClose}>
|
||
上一页
|
||
</button>
|
||
</header>
|
||
|
||
<section>
|
||
<strong><FileImageOutlined /> 上传商品原图</strong>
|
||
{productImages.length ? (
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
className={`ecom-quick-set-upload ecom-quick-hot-material has-images${isProductUploadDragging ? " is-dragging" : ""}`}
|
||
onClick={() => productInputRef.current?.click()}
|
||
onKeyDown={(event) => openQuickUploadWithKeyboard(event, productInputRef)}
|
||
onDragOver={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (event.dataTransfer.types.includes("Files")) setIsProductUploadDragging(true);
|
||
}}
|
||
onDragLeave={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) {
|
||
setIsProductUploadDragging(false);
|
||
}
|
||
}}
|
||
onDrop={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setIsProductUploadDragging(false);
|
||
handleProductDrop(event);
|
||
}}
|
||
>
|
||
{renderThumbs()}
|
||
{productImages.length < maxProductImages ? (
|
||
<button
|
||
type="button"
|
||
className="ecom-quick-hot-add-btn"
|
||
aria-label="添加更多素材"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
productInputRef.current?.click();
|
||
}}
|
||
>
|
||
<PlusOutlined />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
) : (
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
className={`ecom-quick-set-upload ecom-quick-hot-material${isProductUploadDragging ? " is-dragging" : ""}`}
|
||
onClick={() => productInputRef.current?.click()}
|
||
onKeyDown={(event) => openQuickUploadWithKeyboard(event, productInputRef)}
|
||
onDragOver={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (event.dataTransfer.types.includes("Files")) setIsProductUploadDragging(true);
|
||
}}
|
||
onDragLeave={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) {
|
||
setIsProductUploadDragging(false);
|
||
}
|
||
}}
|
||
onDrop={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setIsProductUploadDragging(false);
|
||
handleProductDrop(event);
|
||
}}
|
||
>
|
||
<FileImageOutlined />
|
||
<span>拖拽或点击上传</span>
|
||
<em>上传商品素材图,最多 {maxProductImages} 张</em>
|
||
<b>+ 上传图片</b>
|
||
</div>
|
||
)}
|
||
<input
|
||
ref={productInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
className="ecom-command-hidden-file"
|
||
onChange={handleProductUpload}
|
||
aria-label="上传商品图片"
|
||
/>
|
||
</section>
|
||
|
||
<section className="ecom-quick-hot-requirement">
|
||
<div className="ecom-quick-hot-requirement__head">
|
||
<strong>视频需求</strong>
|
||
</div>
|
||
<div className="ecom-quick-hot-requirement__input">
|
||
<textarea
|
||
value={requirement}
|
||
onChange={(event) => onRequirementChange(event.target.value.slice(0, 500))}
|
||
placeholder="建议包含以下信息:产品名称、核心卖点、期望场景、口播风格、具体参数"
|
||
maxLength={500}
|
||
rows={4}
|
||
/>
|
||
<span>{requirement.length}/500</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="ecom-quick-set-basic-section">
|
||
<span className="ecom-quick-set-label">基础设置</span>
|
||
<div className="ecom-quick-set-select-anchor" ref={selectAnchorRef}>
|
||
<div className="ecom-quick-set-selects">
|
||
<button
|
||
type="button"
|
||
className={openSelect === "platform" ? "is-active" : ""}
|
||
onClick={() => toggleSelect("platform")}
|
||
>
|
||
<span>平台</span>
|
||
<strong>{platform}</strong>
|
||
<em>⌄</em>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={openSelect === "ratio" ? "is-active" : ""}
|
||
onClick={() => toggleSelect("ratio")}
|
||
>
|
||
<span>尺寸比例</span>
|
||
<strong>{ratio.replace(/\s+/g, " ").trim()}</strong>
|
||
<em>⌄</em>
|
||
</button>
|
||
</div>
|
||
{openSelect ? (
|
||
<div
|
||
className="ecom-quick-set-dropdown"
|
||
role="listbox"
|
||
aria-label={openSelect === "platform" ? "平台" : "尺寸比例"}
|
||
>
|
||
{(openSelect === "platform" ? platformOptions : ratioOptions).map((option) => (
|
||
<button
|
||
key={option}
|
||
type="button"
|
||
className={
|
||
(openSelect === "platform" ? platform === option : ratio === option) ? "is-active" : ""
|
||
}
|
||
role="option"
|
||
aria-selected={openSelect === "platform" ? platform === option : ratio === option}
|
||
onClick={() => {
|
||
if (openSelect === "platform") {
|
||
handlePlatformSelect(option);
|
||
} else {
|
||
handleRatioSelect(option);
|
||
}
|
||
}}
|
||
>
|
||
{option.replace(/\s+/g, " ").trim()}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<strong>视频画质</strong>
|
||
<div className="ecom-quick-detail-modules">
|
||
{videoQualityOptions.map((option) => (
|
||
<button
|
||
key={option.key}
|
||
type="button"
|
||
className={videoQuality === option.key ? "is-active" : ""}
|
||
aria-pressed={videoQuality === option.key}
|
||
onClick={() => onVideoQualityChange(option.key)}
|
||
>
|
||
<strong>{option.label}</strong>
|
||
<span>{option.desc}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<strong>视频时长</strong>
|
||
<div className="ecom-one-click-video-duration">
|
||
<span>{videoDuration} 秒</span>
|
||
<input
|
||
type="range"
|
||
className="ecom-one-click-video-range"
|
||
min={videoDurationMin}
|
||
max={videoDurationMax}
|
||
step={5}
|
||
value={videoDuration}
|
||
onChange={(event) => onVideoDurationChange(Number(event.target.value))}
|
||
aria-label="视频时长"
|
||
/>
|
||
<div className="ecom-one-click-video-duration-scale" aria-hidden="true">
|
||
<span>{videoDurationMin}秒</span>
|
||
<span>{videoDurationMax}秒</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<button
|
||
type="button"
|
||
className={`ecom-one-click-video-smart${videoSmart ? " is-on" : ""}`}
|
||
aria-pressed={videoSmart}
|
||
onClick={() => onVideoSmartChange(!videoSmart)}
|
||
>
|
||
<span>
|
||
<strong>智能优化</strong>
|
||
<em>根据平台、商品图和尺寸自动匹配推荐参数</em>
|
||
</span>
|
||
<i aria-hidden="true" />
|
||
</button>
|
||
</section>
|
||
|
||
<div className="ecom-quick-hot-actions">
|
||
<button
|
||
type="button"
|
||
className="ecom-quick-set-primary ecom-one-click-video-generate"
|
||
onClick={handleGenerate}
|
||
disabled={!canGenerate}
|
||
>
|
||
<ThunderboltOutlined /> {isAuthenticated ? "一键生成视频" : "登录后生成"}
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
{hoverZoom && typeof document !== "undefined"
|
||
? createPortal(
|
||
<div
|
||
className={`ecom-hot-material-zoom-portal is-${hoverZoom.placement}`}
|
||
style={{ left: hoverZoom.x, top: hoverZoom.y }}
|
||
>
|
||
<img src={hoverZoom.src} alt="" />
|
||
</div>,
|
||
document.body,
|
||
)
|
||
: null}
|
||
|
||
<section className="ecom-quick-set-stage">
|
||
<EcommerceVideoWorkspace
|
||
isAuthenticated={isAuthenticated}
|
||
productImageDataUrls={productImageDataUrls}
|
||
productImageFiles={productImageFiles}
|
||
requirement={requirement}
|
||
platform={platform}
|
||
aspectRatio={getVideoAspectRatio(ratio)}
|
||
durationSeconds={videoDuration}
|
||
resolution={videoQuality === "standard" ? "720P" : "1080P"}
|
||
onRequestLogin={onRequestLogin}
|
||
onOpenHistory={onOpenHistory}
|
||
triggerPlan={planTrigger}
|
||
/>
|
||
</section>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|