Initial ecommerce standalone package
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
|
||||
|
||||
interface EcommerceProgressBarProps {
|
||||
status: "idle" | "generating" | "done" | "failed" | string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function mapStatus(status: string): "running" | "completed" | "failed" {
|
||||
if (status === "done") return "completed";
|
||||
if (status === "failed") return "failed";
|
||||
if (status === "generating" || status === "modeling") return "running";
|
||||
return "running";
|
||||
}
|
||||
|
||||
export function EcommerceProgressBar({ status, label }: EcommerceProgressBarProps) {
|
||||
const progress = mapStatus(status) === "running" ? 50 : 100;
|
||||
const smoothed = useSmoothedProgress(progress, mapStatus(status));
|
||||
|
||||
if (status === "idle") return null;
|
||||
|
||||
return (
|
||||
<div className="ecommerce-progress-bar">
|
||||
<span className="ecommerce-progress-bar__label">{label || "AI 正在生成"}</span>
|
||||
<div className="ecommerce-progress-bar__track">
|
||||
<div className="ecommerce-progress-bar__fill" style={{ width: `${smoothed}%` }} />
|
||||
</div>
|
||||
<span className="ecommerce-progress-bar__value">{smoothed}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CheckCircleOutlined,
|
||||
DeleteOutlined,
|
||||
PlayCircleOutlined,
|
||||
SearchOutlined,
|
||||
ShoppingOutlined,
|
||||
TagsOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import "../../styles/pages/more-tools.css";
|
||||
import "../../styles/pages/image-workbench.css";
|
||||
import "../../styles/pages/ecommerce.css";
|
||||
import "../../styles/pages/local-theme-parity.css";
|
||||
import type { WebProjectSummary } from "../../types";
|
||||
import { useDebounce } from "../../hooks/useDebounce";
|
||||
import { templateCarouselCases, templateCases, templateCategories, type TemplateCase } from "./ecommerceTemplates";
|
||||
|
||||
interface EcommerceTemplatesPageProps {
|
||||
projects: WebProjectSummary[];
|
||||
onOpenMore?: () => void;
|
||||
onOpenEcommerce?: () => void;
|
||||
onSelectTemplate?: (template: TemplateCase) => void;
|
||||
onStartCreate?: () => void;
|
||||
onOpenProject: (project: WebProjectSummary) => void;
|
||||
onDeleteProject?: (project: WebProjectSummary) => void;
|
||||
}
|
||||
|
||||
const APPLE_CAROUSEL_SLOTS = [-4, -3, -2, -1, 0, 1, 2, 3, 4];
|
||||
const APPLE_CAROUSEL_TRANSITION_MS = 760;
|
||||
|
||||
interface AppleCarouselMotion {
|
||||
direction: number;
|
||||
progress: 0 | 1;
|
||||
}
|
||||
|
||||
function getPositiveModulo(value: number, length: number) {
|
||||
return ((value % length) + length) % length;
|
||||
}
|
||||
|
||||
function getAppleCarouselCardStyle(offset: number): CSSProperties {
|
||||
const depth = Math.abs(offset);
|
||||
const direction = Math.sign(offset);
|
||||
const isActive = depth === 0;
|
||||
const xByDepth = [0, 286, 456, 610, 735, 840];
|
||||
const yByDepth = [8, -2, -8, -13, -18, -24];
|
||||
const rotateByDepth = [0, 0, 0, 0, 0];
|
||||
const scaleByDepth = [1, 0.98, 0.94, 0.91, 0.88, 0.84];
|
||||
const x = direction * (xByDepth[depth] ?? xByDepth[xByDepth.length - 1]!);
|
||||
const y = yByDepth[depth] ?? yByDepth[yByDepth.length - 1]!;
|
||||
const z = isActive ? 90 : 28 - depth;
|
||||
const rotateY = 0;
|
||||
const rotateZ = direction * (rotateByDepth[depth] ?? rotateByDepth[rotateByDepth.length - 1]!);
|
||||
const scale = scaleByDepth[depth] ?? scaleByDepth[scaleByDepth.length - 1]!;
|
||||
|
||||
return {
|
||||
"--apple-card-offset": offset,
|
||||
"--apple-card-depth": depth,
|
||||
"--apple-card-z": 80 - depth,
|
||||
"--apple-card-x": `${x}px`,
|
||||
"--apple-card-y": `${y}px`,
|
||||
"--apple-card-z-offset": `${z}px`,
|
||||
"--apple-card-rotate-y": `${rotateY}deg`,
|
||||
"--apple-card-rotate-z": `${rotateZ}deg`,
|
||||
"--apple-card-scale": String(scale),
|
||||
"--apple-card-opacity": String(depth > 4 ? 0 : 1),
|
||||
} as CSSProperties;
|
||||
}
|
||||
|
||||
function EcommerceTemplatesPage({
|
||||
projects,
|
||||
onOpenMore,
|
||||
onOpenEcommerce,
|
||||
onSelectTemplate,
|
||||
onStartCreate,
|
||||
onOpenProject,
|
||||
onDeleteProject,
|
||||
}: EcommerceTemplatesPageProps) {
|
||||
const [activeTemplateCategory, setActiveTemplateCategory] = useState("全部");
|
||||
const [templateSearch, setTemplateSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(templateSearch, 300);
|
||||
const [carouselIndex, setCarouselIndex] = useState(0);
|
||||
const [carouselMotion, setCarouselMotion] = useState<AppleCarouselMotion | null>(null);
|
||||
const [carouselIsResetting, setCarouselIsResetting] = useState(false);
|
||||
const carouselFrameRef = useRef<number | null>(null);
|
||||
const carouselResetFrameRef = useRef<number | null>(null);
|
||||
const carouselTimerRef = useRef<number | null>(null);
|
||||
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const keyword = debouncedSearch.trim();
|
||||
return templateCases.filter((item) => {
|
||||
const categoryMatches = activeTemplateCategory === "全部" || item.category === activeTemplateCategory;
|
||||
const keywordMatches =
|
||||
!keyword || item.title.includes(keyword) || item.summary.includes(keyword) || item.category.includes(keyword);
|
||||
return categoryMatches && keywordMatches;
|
||||
});
|
||||
}, [activeTemplateCategory, debouncedSearch]);
|
||||
const carouselItems = useMemo(() => templateCarouselCases.slice(0, 5), []);
|
||||
const carouselSlotOffsets = useMemo(() => {
|
||||
const direction = carouselMotion?.direction ?? 0;
|
||||
const minSlot = APPLE_CAROUSEL_SLOTS[0]! + Math.min(direction, 0);
|
||||
const maxSlot = APPLE_CAROUSEL_SLOTS[APPLE_CAROUSEL_SLOTS.length - 1]! + Math.max(direction, 0);
|
||||
return Array.from({ length: maxSlot - minSlot + 1 }, (_, index) => minSlot + index);
|
||||
}, [carouselMotion?.direction]);
|
||||
|
||||
const startCarouselShift = useCallback(
|
||||
(rawDirection: number) => {
|
||||
const direction = Math.sign(rawDirection);
|
||||
if (!direction || carouselItems.length <= 1 || carouselMotion) return;
|
||||
|
||||
if (carouselFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(carouselFrameRef.current);
|
||||
}
|
||||
if (carouselTimerRef.current !== null) {
|
||||
window.clearTimeout(carouselTimerRef.current);
|
||||
}
|
||||
if (carouselResetFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(carouselResetFrameRef.current);
|
||||
}
|
||||
|
||||
setCarouselIsResetting(false);
|
||||
setCarouselMotion({ direction, progress: 0 });
|
||||
carouselFrameRef.current = window.requestAnimationFrame(() => {
|
||||
carouselFrameRef.current = window.requestAnimationFrame(() => {
|
||||
setCarouselMotion((current) => (current?.direction === direction ? { direction, progress: 1 } : current));
|
||||
});
|
||||
});
|
||||
carouselTimerRef.current = window.setTimeout(() => {
|
||||
setCarouselIsResetting(true);
|
||||
setCarouselIndex((current) => getPositiveModulo(current + direction, carouselItems.length));
|
||||
setCarouselMotion(null);
|
||||
carouselResetFrameRef.current = window.requestAnimationFrame(() => {
|
||||
carouselResetFrameRef.current = window.requestAnimationFrame(() => {
|
||||
setCarouselIsResetting(false);
|
||||
});
|
||||
});
|
||||
}, APPLE_CAROUSEL_TRANSITION_MS);
|
||||
},
|
||||
[carouselItems.length, carouselMotion],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (carouselItems.length <= 1) return undefined;
|
||||
const intervalId = window.setInterval(() => {
|
||||
startCarouselShift(-1);
|
||||
}, 2200);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [carouselItems.length, startCarouselShift]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (carouselFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(carouselFrameRef.current);
|
||||
}
|
||||
if (carouselTimerRef.current !== null) {
|
||||
window.clearTimeout(carouselTimerRef.current);
|
||||
}
|
||||
if (carouselResetFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(carouselResetFrameRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="image-workbench-page ecommerce-template-page" aria-label="示例模板">
|
||||
<header className="image-workbench-topbar">
|
||||
<button type="button" className="image-workbench-back-to-more" onClick={onOpenMore}>
|
||||
工具盒
|
||||
</button>
|
||||
<div className="image-workbench-tool-strip" aria-label="电商工具入口">
|
||||
<button type="button" onClick={onOpenEcommerce}>
|
||||
<ShoppingOutlined />
|
||||
电商生成
|
||||
</button>
|
||||
<button type="button" className="is-active">
|
||||
<TagsOutlined />
|
||||
示例模板
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="image-workbench-subbar">
|
||||
<button type="button" className="image-workbench-icon-btn" aria-label="返回工具盒" onClick={onOpenMore}>
|
||||
<ArrowLeftOutlined />
|
||||
</button>
|
||||
<strong>示例模板</strong>
|
||||
<div className="image-workbench-camera-summary" aria-label="模板数量">
|
||||
<strong>{filteredTemplates.length} 个模板</strong>
|
||||
<span>最近项目 {projects.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="ecommerce-template-page__scroll omni-commerce-page">
|
||||
<section className={`ecommerce-template-apple-carousel${carouselIsResetting ? " is-resetting" : ""}`} aria-label="苹果卡片式模板轮播">
|
||||
<div className="ecommerce-template-apple-carousel__stage" aria-roledescription="carousel">
|
||||
<div className="ecommerce-template-apple-carousel__deck">
|
||||
{carouselSlotOffsets.map((slotOffset) => {
|
||||
const itemIndex = getPositiveModulo(carouselIndex + slotOffset, carouselItems.length);
|
||||
const item = carouselItems[itemIndex];
|
||||
const visualOffset = slotOffset - (carouselMotion?.direction ?? 0) * (carouselMotion?.progress ?? 0);
|
||||
const isActive = visualOffset === 0;
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={slotOffset}
|
||||
type="button"
|
||||
className={`ecommerce-template-apple-card${isActive ? " is-active" : ""}`}
|
||||
style={getAppleCarouselCardStyle(visualOffset)}
|
||||
aria-label={`套用模板:${item.title}`}
|
||||
aria-pressed={isActive}
|
||||
onClick={() => {
|
||||
if (isActive) {
|
||||
onSelectTemplate?.(item);
|
||||
return;
|
||||
}
|
||||
startCarouselShift(slotOffset);
|
||||
}}
|
||||
>
|
||||
<img src={item.imageUrl} alt="" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="omni-commerce-band ecommerce-template-page__band" aria-labelledby="commerce-template-title">
|
||||
<div className="omni-commerce-section-head">
|
||||
<div>
|
||||
<span>
|
||||
<TagsOutlined />
|
||||
示例模板
|
||||
</span>
|
||||
<h2 id="commerce-template-title">常用电商场景</h2>
|
||||
</div>
|
||||
<label className="omni-commerce-search">
|
||||
<SearchOutlined />
|
||||
<input value={templateSearch} placeholder="搜索模板" onChange={(event) => setTemplateSearch(event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="omni-commerce-filter" role="tablist" aria-label="模板分类">
|
||||
{templateCategories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTemplateCategory === category}
|
||||
className={activeTemplateCategory === category ? "is-active" : ""}
|
||||
onClick={() => setActiveTemplateCategory(category)}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="omni-commerce-template-grid motion-stagger">
|
||||
{filteredTemplates.map((item) => (
|
||||
<article key={item.title} className="omni-commerce-template">
|
||||
<button type="button" className="omni-commerce-template__select" onClick={() => onSelectTemplate?.(item)}>
|
||||
<img src={item.imageUrl} alt={item.title} />
|
||||
<div>
|
||||
<span>{item.category}</span>
|
||||
<strong>{item.title}</strong>
|
||||
<p>{item.summary}</p>
|
||||
<em>选择此场景</em>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="omni-commerce-band ecommerce-template-page__band omni-commerce-band--projects" aria-labelledby="commerce-project-title">
|
||||
<div className="omni-commerce-section-head">
|
||||
<div>
|
||||
<span>
|
||||
<CheckCircleOutlined />
|
||||
最近处理
|
||||
</span>
|
||||
<h2 id="commerce-project-title">最近项目</h2>
|
||||
</div>
|
||||
<button type="button" className="omni-commerce-text-button" onClick={onStartCreate}>
|
||||
新建项目
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{projects.length ? (
|
||||
<div className="omni-commerce-project-grid motion-stagger">
|
||||
{projects.slice(0, 4).map((project, index) => (
|
||||
<article key={project.id} className="omni-commerce-project">
|
||||
<button type="button" onClick={() => onOpenProject(project)}>
|
||||
<img src={project.thumbnailUrl || templateCases[index % templateCases.length]!.imageUrl} alt="" />
|
||||
<span>{project.description || "最近更新的电商项目"}</span>
|
||||
<strong>{project.name || `预览项目 ${index + 1}`}</strong>
|
||||
</button>
|
||||
{onDeleteProject ? (
|
||||
<button type="button" className="omni-commerce-project__delete" aria-label={`删除项目 ${project.name}`} onClick={() => onDeleteProject(project)}>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="omni-commerce-empty">
|
||||
<PlayCircleOutlined />
|
||||
<strong>还没有最近项目</strong>
|
||||
<span>完成一次生成后,这里会显示最近处理的商品内容。</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="image-workbench-status">
|
||||
<span>模板</span>
|
||||
<p>选择模板后会回到电商生成并自动套用场景。</p>
|
||||
<em>{activeTemplateCategory}</em>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default EcommerceTemplatesPage;
|
||||
@@ -0,0 +1,817 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import "../../styles/pages/ecommerce-video.css";
|
||||
import {
|
||||
CloseOutlined,
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
FolderAddOutlined,
|
||||
HistoryOutlined,
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
SendOutlined,
|
||||
StopOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks, saveVideoHistory } from "./ecommerceVideoService";
|
||||
import {
|
||||
PLAN_STEP_LABELS,
|
||||
PLAN_STEPS_DISPLAY,
|
||||
type EcommerceVideoStage,
|
||||
type EcommerceVideoSceneTask,
|
||||
type EcommerceVideoPlanProgress,
|
||||
type EcommerceVideoPlanResult,
|
||||
type PlanStep,
|
||||
} from "./ecommerceVideoTypes";
|
||||
import type { AdVideoUserConfig } from "../../api/adVideoPlanClient";
|
||||
import { ServerRequestError } from "../../api/serverConnection";
|
||||
import { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions";
|
||||
import { useAppStore } from "../../stores";
|
||||
import { useGenerationTasks } from "../../hooks/useGenerationTasks";
|
||||
import {
|
||||
saveEcommerceVideoState,
|
||||
loadEcommerceVideoState,
|
||||
clearEcommerceVideoState,
|
||||
} from "./ecommerceVideoKeepalive";
|
||||
|
||||
interface EcommerceVideoWorkspaceProps {
|
||||
isAuthenticated: boolean;
|
||||
productImageDataUrls: string[];
|
||||
productImageFiles?: Array<File | undefined>;
|
||||
requirement: string;
|
||||
platform: string;
|
||||
aspectRatio: string;
|
||||
durationSeconds: number;
|
||||
resolution: string;
|
||||
onRequestLogin?: () => void;
|
||||
onOpenHistory?: () => void;
|
||||
triggerPlan?: number;
|
||||
}
|
||||
|
||||
const ALL_STEPS: PlanStep[] = [
|
||||
"upload", "analyze", "summary", "selling",
|
||||
"creative", "storyboard", "prompts", "compliance",
|
||||
];
|
||||
|
||||
function hashString(value: string): string {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
function buildInputFingerprint(input: {
|
||||
productImageDataUrls: string[];
|
||||
requirement: string;
|
||||
platform: string;
|
||||
aspectRatio: string;
|
||||
durationSeconds: number;
|
||||
resolution: string;
|
||||
}): string {
|
||||
const imageCount = input.productImageDataUrls.length;
|
||||
return hashString([
|
||||
String(imageCount),
|
||||
input.requirement.trim(),
|
||||
input.platform,
|
||||
input.aspectRatio,
|
||||
input.durationSeconds,
|
||||
input.resolution,
|
||||
].join("::"));
|
||||
}
|
||||
|
||||
function mapResolutionToQuality(res: string): "720P" | "1080P" {
|
||||
return res.includes("720") ? "720P" : "1080P";
|
||||
}
|
||||
|
||||
function stepCompletedFromProgress(step: PlanStep, p: EcommerceVideoPlanProgress): boolean {
|
||||
switch (step) {
|
||||
case "upload": return Boolean(p.imageUrls?.length);
|
||||
case "analyze": return p.imageDescription !== undefined;
|
||||
case "summary": return Boolean(p.summary);
|
||||
case "selling": return Boolean(p.selling);
|
||||
case "creative": return Boolean(p.creatives?.length);
|
||||
case "storyboard": return Boolean(p.storyboard);
|
||||
case "prompts": return Boolean(p.videoPrompts);
|
||||
case "compliance": return Boolean(p.compliance);
|
||||
}
|
||||
}
|
||||
|
||||
export default function EcommerceVideoWorkspace({
|
||||
isAuthenticated,
|
||||
productImageDataUrls,
|
||||
productImageFiles = [],
|
||||
requirement,
|
||||
platform,
|
||||
aspectRatio,
|
||||
durationSeconds,
|
||||
resolution,
|
||||
onRequestLogin,
|
||||
onOpenHistory,
|
||||
triggerPlan,
|
||||
}: EcommerceVideoWorkspaceProps) {
|
||||
const [stage, setStage] = useState<EcommerceVideoStage>("idle");
|
||||
const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null);
|
||||
const [planProgress, setPlanProgress] = useState<EcommerceVideoPlanProgress | null>(null);
|
||||
const [scenes, setScenes] = useState<EcommerceVideoSceneTask[]>([]);
|
||||
const [completedSteps, setCompletedSteps] = useState<PlanStep[]>([]);
|
||||
const [sourceImageUrls, setSourceImageUrls] = useState<string[]>([]);
|
||||
const [currentStep, setCurrentStep] = useState<PlanStep | null>(null);
|
||||
const [failedStep, setFailedStep] = useState<PlanStep | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
||||
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null);
|
||||
const [flowZoom, setFlowZoom] = useState(1);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const renderAbortRef = useRef({ current: false });
|
||||
const actionNoticeTimerRef = useRef<number | null>(null);
|
||||
const setView = useAppStore((s) => s.setView);
|
||||
const keepaliveRestoredFingerprintRef = useRef<string | null>(null);
|
||||
const keepalivePollingStartedRef = useRef(false);
|
||||
const generation = useGenerationTasks({ sourceView: "ecommerce" });
|
||||
const sceneStoreIdMap = useRef<Map<number, string>>(new Map());
|
||||
const inputFingerprint = useMemo(
|
||||
() => buildInputFingerprint({ productImageDataUrls, requirement, platform, aspectRatio, durationSeconds, resolution }),
|
||||
[productImageDataUrls, requirement, platform, aspectRatio, durationSeconds, resolution],
|
||||
);
|
||||
|
||||
// ── Keep-alive: restore saved state on mount ─────────────
|
||||
useEffect(() => {
|
||||
if (keepaliveRestoredFingerprintRef.current === inputFingerprint) return;
|
||||
keepaliveRestoredFingerprintRef.current = inputFingerprint;
|
||||
const saved = loadEcommerceVideoState(inputFingerprint);
|
||||
if (!saved) return;
|
||||
if (saved.stage === "idle" || saved.stage === "cancelled") return;
|
||||
// Restore completed / in-progress states — results persist across page switches
|
||||
setStage(saved.stage);
|
||||
setCompletedSteps(saved.completedSteps || []);
|
||||
setPlanResult(saved.planResult);
|
||||
setPlanProgress((saved as { planProgress?: EcommerceVideoPlanProgress | null }).planProgress || null);
|
||||
setScenes(saved.scenes || []);
|
||||
setSourceImageUrls(saved.sourceImageUrls || saved.planResult?.imageUrls || []);
|
||||
}, [inputFingerprint]);
|
||||
|
||||
// ── Keep-alive: save state on changes ───────────────────
|
||||
useEffect(() => {
|
||||
if (stage === "idle" || stage === "cancelled") return;
|
||||
saveEcommerceVideoState({ inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls });
|
||||
}, [inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]);
|
||||
|
||||
// ── Auto-advance: automatically run the full pipeline ─────────
|
||||
useEffect(() => {
|
||||
const delay = 600;
|
||||
if (stage === "planned" && planResult && scenes.length > 0) {
|
||||
const timer = setTimeout(() => { void handleGenerateImages(); }, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
if (stage === "imaged" && scenes.every((s) => s.imageUrl)) {
|
||||
const timer = setTimeout(() => { void handleRenderVideos(); }, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [stage, scenes, planResult]);
|
||||
|
||||
// ── External trigger: start plan from parent ────────────────
|
||||
const triggerPlanPrevRef = useRef(triggerPlan);
|
||||
useEffect(() => {
|
||||
if (triggerPlan != null && triggerPlan !== triggerPlanPrevRef.current) {
|
||||
triggerPlanPrevRef.current = triggerPlan;
|
||||
void handlePlan();
|
||||
}
|
||||
}, [triggerPlan]);
|
||||
|
||||
// ── Auto-save: persist completed results to server ──────────
|
||||
const historySavedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (stage !== "completed") { historySavedRef.current = false; return; }
|
||||
if (historySavedRef.current) return;
|
||||
if (!planResult || !scenes.length) return;
|
||||
historySavedRef.current = true;
|
||||
const title = planResult.storyboard?.video_title || planResult.summary?.product_name || "电商广告视频";
|
||||
saveVideoHistory({
|
||||
title,
|
||||
config: { platform, aspectRatio, durationSeconds, resolution },
|
||||
plan: planResult as unknown as Record<string, unknown>,
|
||||
scenes: scenes.map((s) => ({ sceneId: s.sceneId, prompt: s.prompt, imageUrl: s.imageUrl, videoUrl: s.resultUrl })),
|
||||
sourceImageUrls,
|
||||
}).catch(() => {});
|
||||
}, [stage, planResult, scenes, sourceImageUrls, platform, aspectRatio, durationSeconds, resolution]);
|
||||
|
||||
// ── Keep-alive: resume polling for running tasks ──────────
|
||||
useEffect(() => {
|
||||
if (keepalivePollingStartedRef.current) return;
|
||||
if (!scenes.length || stage === "idle" || stage === "cancelled" || stage === "completed") return;
|
||||
|
||||
const hasRunningScenes = scenes.some((s) => s.status === "running" || s.status === "pending");
|
||||
if (!hasRunningScenes) return;
|
||||
keepalivePollingStartedRef.current = true;
|
||||
|
||||
// Resume polling for image generation tasks
|
||||
if (stage === "imaging") {
|
||||
renderAbortRef.current = { current: false };
|
||||
void (async () => {
|
||||
for (const scene of scenes) {
|
||||
if (renderAbortRef.current.current) break;
|
||||
if (scene.status !== "running" && scene.status !== "pending") continue;
|
||||
if (!scene.imageTaskId) continue;
|
||||
try {
|
||||
const { waitForTask } = await import("../../api/taskSubscription");
|
||||
const resultUrl = await waitForTask(scene.imageTaskId, {
|
||||
abortRef: renderAbortRef.current,
|
||||
onProgress: (e) =>
|
||||
setScenes((prev) => prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, progress: e.progress } : s))),
|
||||
});
|
||||
if (resultUrl) {
|
||||
setScenes((prev) =>
|
||||
prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "idle", progress: 100, imageUrl: resultUrl } : s)),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setScenes((prev) =>
|
||||
prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "idle", error: "恢复任务失败" } : s)),
|
||||
);
|
||||
}
|
||||
}
|
||||
setScenes((current) => {
|
||||
const allImaged = current.every((s) => s.imageUrl);
|
||||
if (allImaged) setStage("imaged");
|
||||
return current;
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
// Resume polling for video rendering tasks
|
||||
if (stage === "rendering") {
|
||||
renderAbortRef.current = { current: false };
|
||||
void (async () => {
|
||||
for (const scene of scenes) {
|
||||
if (renderAbortRef.current.current) break;
|
||||
if (scene.status !== "running" && scene.status !== "pending") continue;
|
||||
if (!scene.taskId) continue;
|
||||
try {
|
||||
const { waitForTask } = await import("../../api/taskSubscription");
|
||||
const resultUrl = await waitForTask(scene.taskId, {
|
||||
abortRef: renderAbortRef.current,
|
||||
onProgress: (e) =>
|
||||
setScenes((prev) => prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, progress: e.progress } : s))),
|
||||
});
|
||||
if (resultUrl) {
|
||||
setScenes((prev) =>
|
||||
prev.map((s) =>
|
||||
s.sceneId === scene.sceneId ? { ...s, status: "completed", progress: 100, resultUrl: resultUrl } : s,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setScenes((prev) =>
|
||||
prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "failed", error: "恢复任务失败" } : s)),
|
||||
);
|
||||
}
|
||||
}
|
||||
setScenes((current) => {
|
||||
const hasFailed = current.some((s) => s.status === "failed");
|
||||
const allDone = current.every((s) => s.status === "completed" || s.status === "failed");
|
||||
if (allDone) setStage(hasFailed ? "partial_failed" : "completed");
|
||||
return current;
|
||||
});
|
||||
})();
|
||||
}
|
||||
}, [scenes, stage]);
|
||||
|
||||
// Note: keep-alive is NOT cleared on completion — results persist across page switches.
|
||||
// Only cleared when user explicitly starts a new plan via handlePlan.
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (actionNoticeTimerRef.current !== null) {
|
||||
window.clearTimeout(actionNoticeTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showNotice = (msg: string) => {
|
||||
setActionNotice(msg);
|
||||
if (actionNoticeTimerRef.current !== null) {
|
||||
window.clearTimeout(actionNoticeTimerRef.current);
|
||||
}
|
||||
actionNoticeTimerRef.current = window.setTimeout(() => {
|
||||
actionNoticeTimerRef.current = null;
|
||||
setActionNotice(null);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleDownload = async (url: string) => {
|
||||
try {
|
||||
await saveToolResultToLocal({
|
||||
url, name: `ecommerce-video-${Date.now()}`, type: "video",
|
||||
isVideo: true, tags: ["电商", "短视频", "生成视频"],
|
||||
});
|
||||
showNotice("下载完成");
|
||||
} catch {
|
||||
const a = document.createElement("a"); a.href = url; a.download = "ecommerce-video.mp4"; a.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAsset = async (url: string) => {
|
||||
try {
|
||||
const result = await addToolResultToAssetLibrary({
|
||||
url, name: `电商短视频-${Date.now()}.mp4`, description: "电商广告视频生成结果",
|
||||
type: "video", isVideo: true, tags: ["电商", "短视频", "广告视频"],
|
||||
metadata: { source: "ecommerce-video", platform },
|
||||
});
|
||||
showNotice(result === "server" ? "已保存到资产库" : "已保存到本地资产库");
|
||||
} catch { showNotice("保存失败"); }
|
||||
};
|
||||
|
||||
const handleSaveAllAssets = async () => {
|
||||
if (!completedScenes.length) return;
|
||||
let saved = 0;
|
||||
for (const scene of completedScenes) {
|
||||
try {
|
||||
await addToolResultToAssetLibrary({
|
||||
url: scene.resultUrl!, name: `电商短视频-镜头${scene.sceneId}-${Date.now()}.mp4`,
|
||||
description: `电商广告视频 - 镜头${scene.sceneId}`,
|
||||
type: "video", isVideo: true, tags: ["电商", "短视频", "广告视频"],
|
||||
metadata: { source: "ecommerce-video", platform, sceneId: scene.sceneId },
|
||||
});
|
||||
saved++;
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
showNotice(saved > 0 ? `已保存 ${saved}/${completedScenes.length} 个视频到资产库` : "保存失败");
|
||||
};
|
||||
|
||||
const handleDownloadAll = async () => {
|
||||
for (const scene of completedScenes) {
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const a = document.createElement("a");
|
||||
a.href = scene.resultUrl!;
|
||||
a.download = `ecommerce-video-scene-${scene.sceneId}.mp4`;
|
||||
a.click();
|
||||
}
|
||||
showNotice(`正在下载 ${completedScenes.length} 个视频`);
|
||||
};
|
||||
|
||||
const handleImportToCanvas = async (url: string) => {
|
||||
try {
|
||||
await addToolResultToAssetLibrary({
|
||||
url, name: `电商短视频-${Date.now()}.mp4`, description: "电商广告视频 - 导入画布",
|
||||
type: "video", isVideo: true, tags: ["电商", "短视频", "画布导入"],
|
||||
metadata: { source: "ecommerce-video", platform },
|
||||
});
|
||||
setView("canvas");
|
||||
showNotice("已保存资产并跳转画布");
|
||||
} catch { showNotice("导入失败"); }
|
||||
};
|
||||
|
||||
const buildConfig = useCallback((): AdVideoUserConfig => ({
|
||||
platform, aspectRatio, durationSeconds,
|
||||
style: "痛点解决", language: "中文", market: "中国",
|
||||
needVoiceover: true, needSubtitle: true, conversionFocus: "conversion",
|
||||
}), [platform, aspectRatio, durationSeconds]);
|
||||
|
||||
// ── Phase 1: Planning ──────────────────────────────────────
|
||||
|
||||
const runPlanFlow = async (resume: EcommerceVideoPlanProgress | null) => {
|
||||
abortControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
setStage("planning"); setError(null); setFailedStep(null);
|
||||
if (!resume) {
|
||||
setCompletedSteps([]); setPlanResult(null); setScenes([]); setSourceImageUrls([]); setPlanProgress(null);
|
||||
}
|
||||
setCurrentStep(null);
|
||||
// Mutable snapshot — async handlers must persist to localStorage directly since the component may unmount
|
||||
let livePlanProgress: EcommerceVideoPlanProgress = resume ? { ...resume } : {};
|
||||
let liveCompletedSteps: PlanStep[] = resume
|
||||
? ALL_STEPS.filter((s) => stepCompletedFromProgress(s, resume))
|
||||
: [];
|
||||
const persist = (stageNow: EcommerceVideoStage) => {
|
||||
saveEcommerceVideoState({
|
||||
inputFingerprint,
|
||||
stage: stageNow,
|
||||
completedSteps: liveCompletedSteps,
|
||||
planResult: null,
|
||||
planProgress: livePlanProgress,
|
||||
scenes: [],
|
||||
sourceImageUrls: livePlanProgress.imageUrls || [],
|
||||
});
|
||||
};
|
||||
try {
|
||||
const productImageSources = productImageDataUrls.map((url, index) => productImageFiles[index] ?? url);
|
||||
const result = await runVideoPlan(
|
||||
productImageSources, requirement, buildConfig(),
|
||||
{
|
||||
onStepStart: (step) => setCurrentStep(step),
|
||||
onStepDone: (step) => {
|
||||
liveCompletedSteps = [...liveCompletedSteps, step];
|
||||
setCompletedSteps((prev) => [...prev, step]);
|
||||
},
|
||||
onImagesUploaded: (urls) => {
|
||||
setSourceImageUrls(urls);
|
||||
livePlanProgress = { ...livePlanProgress, imageUrls: urls };
|
||||
persist("planning");
|
||||
},
|
||||
onUploadRejected: (messages) => {
|
||||
if (messages.length) showNotice(`已跳过 ${messages.length} 张上传失败的图片`);
|
||||
},
|
||||
onPartialProgress: (progress) => {
|
||||
livePlanProgress = progress;
|
||||
setPlanProgress(progress);
|
||||
persist("planning");
|
||||
},
|
||||
resumeFrom: resume || undefined,
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
const builtScenes = buildSceneTasks(result);
|
||||
setPlanResult(result);
|
||||
setPlanProgress(null);
|
||||
setScenes(builtScenes);
|
||||
setStage("planned");
|
||||
saveEcommerceVideoState({ inputFingerprint, stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, planProgress: null, scenes: builtScenes, sourceImageUrls: result.imageUrls });
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError" && controller.signal.aborted) return;
|
||||
const message = err instanceof Error ? err.message : "策划失败";
|
||||
setError(message);
|
||||
// Mark the step that was in-progress as failed so user can resume
|
||||
setFailedStep((prev) => prev || currentStep);
|
||||
setStage("idle");
|
||||
// Persist partial progress so the user can resume after a page switch
|
||||
persist("idle");
|
||||
} finally { setCurrentStep(null); }
|
||||
};
|
||||
|
||||
const handlePlan = async () => {
|
||||
if (!isAuthenticated) { onRequestLogin?.(); return; }
|
||||
if (!productImageDataUrls.length && !requirement.trim()) {
|
||||
setError("请先上传产品图片或填写商品说明"); return;
|
||||
}
|
||||
await runPlanFlow(null);
|
||||
};
|
||||
|
||||
const handleResumePlan = async () => {
|
||||
if (!isAuthenticated) { onRequestLogin?.(); return; }
|
||||
if (!planProgress) { void handlePlan(); return; }
|
||||
await runPlanFlow(planProgress);
|
||||
};
|
||||
|
||||
// ── Phase 2: Image generation per scene ──────────────────────
|
||||
|
||||
const handleGenerateImages = async () => {
|
||||
if (!planResult || !scenes.length) return;
|
||||
setStage("imaging"); setError(null);
|
||||
renderAbortRef.current = { current: false };
|
||||
const ratio = aspectRatio.includes("9:16") || aspectRatio.includes("9:16") ? "9:16"
|
||||
: aspectRatio.includes("16:9") || aspectRatio.includes("16:9") ? "16:9"
|
||||
: "1:1";
|
||||
let currentScenes = [...scenes];
|
||||
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
|
||||
currentScenes = next;
|
||||
setScenes(next);
|
||||
saveEcommerceVideoState({ inputFingerprint, stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls });
|
||||
};
|
||||
// Only redo scenes missing imageUrl — preserves successfully generated images on partial retry
|
||||
const scenesToProcess = currentScenes.filter((s) => !s.imageUrl);
|
||||
if (!scenesToProcess.length) { setStage("imaged"); return; }
|
||||
for (const scene of scenesToProcess) {
|
||||
if (renderAbortRef.current.current) break;
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
|
||||
try {
|
||||
await renderSceneImage(
|
||||
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio, productImageUrls: sourceImageUrls },
|
||||
{
|
||||
onSceneImageSubmitted: (id, taskId) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s));
|
||||
const storeId = generation.submitTask({ title: `分镜${id}图片`, type: "image", status: "running", progress: 0, prompt: scene.prompt, sourceView: "ecommerce", taskId, params: { sceneId: id, phase: "imaging" } });
|
||||
sceneStoreIdMap.current.set(id, storeId);
|
||||
},
|
||||
onSceneImageProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
|
||||
onSceneImageCompleted: (id, url) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s));
|
||||
const sid = sceneStoreIdMap.current.get(id);
|
||||
if (sid) generation.markCompleted(sid, url);
|
||||
},
|
||||
onSceneImageFailed: (id, err2) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", error: err2 } : s));
|
||||
const sid = sceneStoreIdMap.current.get(id);
|
||||
if (sid) generation.markFailed(sid, err2);
|
||||
},
|
||||
},
|
||||
renderAbortRef.current,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "图片生成失败";
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "idle", error: message } : s));
|
||||
}
|
||||
}
|
||||
const allHaveImages = currentScenes.every((s) => s.imageUrl);
|
||||
const finalStage = allHaveImages ? "imaged" as const : "partial_failed" as const;
|
||||
setStage(finalStage);
|
||||
saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
||||
};
|
||||
|
||||
// ── Phase 3: Video rendering from generated images ──────────
|
||||
|
||||
const handleRenderVideos = async () => {
|
||||
if (!scenes.length) return;
|
||||
if (!scenes.some((s) => s.imageUrl)) { setError("请先生成分镜图片"); return; }
|
||||
setStage("rendering"); setError(null);
|
||||
renderAbortRef.current = { current: false };
|
||||
const quality = mapResolutionToQuality(resolution);
|
||||
let currentScenes = [...scenes];
|
||||
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
|
||||
currentScenes = next;
|
||||
setScenes(next);
|
||||
saveEcommerceVideoState({ inputFingerprint, stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls });
|
||||
};
|
||||
// Only render scenes that haven't completed yet — preserves successful videos on partial retry
|
||||
const scenesToProcess = currentScenes.filter((s) => s.imageUrl && s.status !== "completed");
|
||||
if (!scenesToProcess.length) { setStage(currentScenes.every((s) => s.status === "completed") ? "completed" : "partial_failed"); return; }
|
||||
for (const scene of scenesToProcess) {
|
||||
if (renderAbortRef.current.current) break;
|
||||
if (!scene.imageUrl) continue;
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
|
||||
try {
|
||||
await renderScene(
|
||||
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, productImageUrls: sourceImageUrls, aspectRatio, resolution: quality },
|
||||
{
|
||||
onSceneSubmitted: (id, taskId) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s));
|
||||
const storeId = generation.submitTask({ title: `分镜${id}视频`, type: "video", status: "running", progress: 0, prompt: scene.prompt, sourceView: "ecommerce", taskId, params: { sceneId: id, phase: "rendering" } });
|
||||
sceneStoreIdMap.current.set(id, storeId);
|
||||
},
|
||||
onSceneProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
|
||||
onSceneCompleted: (id, url) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s));
|
||||
const sid = sceneStoreIdMap.current.get(id);
|
||||
if (sid) generation.markCompleted(sid, url);
|
||||
},
|
||||
onSceneFailed: (id, err2) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s));
|
||||
const sid = sceneStoreIdMap.current.get(id);
|
||||
if (sid) generation.markFailed(sid, err2);
|
||||
},
|
||||
},
|
||||
renderAbortRef.current,
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "生成失败";
|
||||
const isPayment = err instanceof ServerRequestError && err.status === 402;
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "failed", error: isPayment ? "余额不足,请充值后继续" : msg } : s));
|
||||
if (isPayment) { setError("余额不足,请充值后再生成视频"); renderAbortRef.current.current = true; break; }
|
||||
}
|
||||
}
|
||||
const hasFailed = currentScenes.some((s) => s.status === "failed");
|
||||
const allDone = currentScenes.every((s) => s.status === "completed" || s.status === "failed");
|
||||
const finalStage = allDone ? (hasFailed ? "partial_failed" as const : "completed" as const) : "rendering" as const;
|
||||
setScenes(currentScenes);
|
||||
setStage(finalStage);
|
||||
saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
||||
};
|
||||
|
||||
const handleCancel = () => { abortControllerRef.current?.abort(); renderAbortRef.current.current = true; setStage("cancelled"); };
|
||||
|
||||
const handleRetryScene = async (scene: EcommerceVideoSceneTask) => {
|
||||
if (!scene.imageUrl) return;
|
||||
setScenes((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
|
||||
try {
|
||||
await renderScene(
|
||||
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl!, productImageUrls: sourceImageUrls, aspectRatio, resolution: mapResolutionToQuality(resolution) },
|
||||
{
|
||||
onSceneSubmitted: (id, taskId) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)),
|
||||
onSceneProgress: (id, progress) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, progress } : s)),
|
||||
onSceneCompleted: (id, url) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)),
|
||||
onSceneFailed: (id, err2) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)),
|
||||
},
|
||||
renderAbortRef.current,
|
||||
);
|
||||
} catch (err) {
|
||||
setScenes((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "failed", error: (err as Error).message } : s));
|
||||
}
|
||||
};
|
||||
|
||||
// ── Derived state ───────────────────────────────────────────
|
||||
|
||||
const completedScenes = scenes.filter((s) => s.status === "completed" && s.resultUrl);
|
||||
const imagedScenes = scenes.filter((s) => s.imageUrl);
|
||||
const primaryVideo = completedScenes[0]?.resultUrl;
|
||||
const sourceImage = sourceImageUrls[0] || planResult?.imageUrls[0] || productImageDataUrls[0] || "";
|
||||
const flowHasStarted = stage !== "idle" || completedSteps.length > 0;
|
||||
const flowMeta = `${platform} / ${aspectRatio} / ${durationSeconds}s / ${resolution}`;
|
||||
const hasImaging = stage === "imaging" || stage === "imaged" || stage === "rendering" || stage === "completed" || stage === "partial_failed";
|
||||
const hasRendering = stage === "rendering" || stage === "completed" || stage === "partial_failed";
|
||||
const visiblePlanSteps = PLAN_STEPS_DISPLAY.filter((s) => completedSteps.includes(s));
|
||||
|
||||
return (
|
||||
<div className="ecom-video-workspace" data-stage={stage}>
|
||||
{/* ── Flow bar ──────────────────────────────────── */}
|
||||
<header className="ecom-video-flowbar">
|
||||
<div className="ecom-video-flowbar__title" aria-label={`短视频分镜流,${flowMeta}`} title={flowMeta}>
|
||||
<span className={`ecom-video-flowbar__pulse${flowHasStarted ? " is-active" : ""}`} aria-hidden="true" />
|
||||
<span className="ecom-video-flowbar__wave" aria-hidden="true"><i /><i /><i /></span>
|
||||
</div>
|
||||
|
||||
<div className="ecom-video-step-dots" aria-label="策划进度">
|
||||
{ALL_STEPS.map((step) => {
|
||||
const isDone = completedSteps.includes(step);
|
||||
const isActive = currentStep === step;
|
||||
return <span key={step} className={`ecom-video-step-dot ${isDone ? "is-done" : isActive ? "is-active" : ""}`} title={PLAN_STEP_LABELS[step]} />;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="ecom-video-flowbar__zoom">
|
||||
<button type="button" onClick={() => setFlowZoom((z) => Math.max(0.25, z - 0.1))} disabled={flowZoom <= 0.25} aria-label="缩小">−</button>
|
||||
<span>{Math.round(flowZoom * 100)}%</span>
|
||||
<button type="button" onClick={() => setFlowZoom((z) => Math.min(2, z + 0.1))} disabled={flowZoom >= 2} aria-label="放大">+</button>
|
||||
</div>
|
||||
|
||||
<div className="ecom-video-flowbar__actions">
|
||||
{onOpenHistory ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost" onClick={onOpenHistory} title="生成记录">
|
||||
<HistoryOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
{error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null}
|
||||
{stage === "idle" && planProgress && (planProgress.summary || planProgress.creatives || planProgress.storyboard) ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||||
onClick={() => void handleResumePlan()} title={`从「${failedStep ? PLAN_STEP_LABELS[failedStep] : "已中断处"}」继续策划`}>
|
||||
<ReloadOutlined /> 继续
|
||||
</button>
|
||||
) : null}
|
||||
{stage === "planned" || stage === "imaged" ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||||
onClick={() => void handleGenerateImages()} title={stage === "imaged" ? "重新生成分镜图" : "生成图片"}>
|
||||
{stage === "imaged" ? <ReloadOutlined /> : <SendOutlined />}
|
||||
</button>
|
||||
) : null}
|
||||
{stage === "imaged" || (stage === "partial_failed" && imagedScenes.length > 0) ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||||
onClick={() => void handleRenderVideos()} title={stage === "partial_failed" ? "重新生成失败的视频" : "生成视频"}>
|
||||
<SendOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
{stage === "planning" ? (
|
||||
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> {currentStep ? PLAN_STEP_LABELS[currentStep] : "策划中"}</span>
|
||||
) : null}
|
||||
{stage === "imaging" ? (
|
||||
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> 生成图片中</span>
|
||||
) : null}
|
||||
{stage === "rendering" ? (
|
||||
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> 生成视频中</span>
|
||||
) : null}
|
||||
{stage === "planning" || stage === "imaging" || stage === "rendering" ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--danger" onClick={handleCancel} title="终止">
|
||||
<StopOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Flow canvas ──────────────────────────────────── */}
|
||||
<section className="ecom-video-flow-canvas" aria-label="视频分镜流程图">
|
||||
<div style={{ zoom: flowZoom, flexShrink: 0, display: "flex", alignItems: "flex-start", justifyContent: "center", minWidth: "max-content" }}>
|
||||
{!sourceImage ? (
|
||||
<div className="ecom-video-empty">
|
||||
<span>上传商品图并点击"一键策划"开始</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ecom-video-tree">
|
||||
{/* Source Node — 附件原图 */}
|
||||
<div className="ecom-video-tree__source">
|
||||
<article className="ecom-video-tree-node ecom-video-tree-node--source">
|
||||
<img src={sourceImage} alt="商品原图" />
|
||||
</article>
|
||||
<span className="ecom-video-tree-node__label">附件原图</span>
|
||||
</div>
|
||||
|
||||
{/* Branch Connector — 分支连接线 */}
|
||||
<div className="ecom-video-tree__trunk" aria-hidden="true">
|
||||
<div className="ecom-video-tree__trunk-line" />
|
||||
<div className="ecom-video-tree__branches-line">
|
||||
{scenes.length > 0 ? scenes.map((s) => (
|
||||
<div key={`trunk-${s.sceneId}`} className="ecom-video-tree__branch-tap" />
|
||||
)) : (
|
||||
<div className="ecom-video-tree__branch-tap" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Branches — 每个场景一条分支 */}
|
||||
<div className="ecom-video-tree__rows">
|
||||
{scenes.length > 0 ? scenes.map((scene, idx) => {
|
||||
const planDone = completedSteps.length >= ALL_STEPS.length;
|
||||
const imgReady = !!scene.imageUrl;
|
||||
const imgRunning = stage === "imaging" && (scene.status === "running" || scene.status === "pending") && !scene.imageUrl;
|
||||
const vidReady = scene.status === "completed" && scene.resultUrl;
|
||||
const vidRunning = stage === "rendering" && (scene.status === "running" || scene.status === "pending");
|
||||
const vidFailed = scene.status === "failed";
|
||||
|
||||
return (
|
||||
<div key={scene.sceneId} className="ecom-video-tree__row" style={{ animationDelay: `${idx * 120}ms` }}>
|
||||
<article className={`ecom-video-tree-node ecom-video-tree-node--text${planDone ? " is-completed" : currentStep ? " is-active" : ""}`}>
|
||||
<div className="ecom-video-tree-node__inner">
|
||||
<span className="ecom-video-tree-node__title">分镜文本{scene.sceneId}</span>
|
||||
<span className="ecom-video-tree-node__desc">
|
||||
{planDone ? "已完成" : stage === "planning" ? "策划中..." : "等待策划"}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</div>
|
||||
|
||||
<article className={`ecom-video-tree-node ecom-video-tree-node--image${imgReady ? " is-completed" : imgRunning ? " is-active" : ""}`} onClick={imgReady ? () => setPreviewMedia({ url: scene.imageUrl!, type: "image" }) : undefined} style={imgReady ? { cursor: "pointer" } : undefined}>
|
||||
{imgReady ? (
|
||||
<img src={scene.imageUrl!} alt={`分镜${scene.sceneId}`} />
|
||||
) : (
|
||||
<div className="ecom-video-tree-node__placeholder">
|
||||
{imgRunning ? <LoadingOutlined /> : <span>待生成</span>}
|
||||
</div>
|
||||
)}
|
||||
{imgRunning ? <span className="ecom-video-tree-node__progress">{scene.progress || 0}%</span> : null}
|
||||
<span className="ecom-video-tree-node__tag">分镜图{scene.sceneId}</span>
|
||||
</article>
|
||||
|
||||
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</div>
|
||||
|
||||
<article className={`ecom-video-tree-node ecom-video-tree-node--video${vidReady ? " is-completed" : vidRunning ? " is-active" : vidFailed ? " is-failed" : ""}`} onClick={vidReady ? () => setPreviewMedia({ url: scene.resultUrl!, type: "video" }) : undefined} style={vidReady ? { cursor: "pointer" } : undefined}>
|
||||
{vidReady ? (
|
||||
<video src={scene.resultUrl!} muted playsInline loop autoPlay />
|
||||
) : (
|
||||
<div className="ecom-video-tree-node__placeholder">
|
||||
{vidRunning ? <LoadingOutlined /> : vidFailed ? <span>失败</span> : <span>待生成</span>}
|
||||
</div>
|
||||
)}
|
||||
{vidRunning ? <span className="ecom-video-tree-node__progress">{scene.progress || 0}%</span> : null}
|
||||
<span className="ecom-video-tree-node__tag">分镜视频{scene.sceneId}</span>
|
||||
{vidFailed ? (
|
||||
<button type="button" className="ecom-video-tree-node__retry"
|
||||
onClick={(e) => { e.stopPropagation(); void handleRetryScene(scene); }}
|
||||
title="重试此镜头">
|
||||
<ReloadOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<div className={`ecom-video-tree__row ecom-video-tree__row--empty${stage === "planning" ? " is-planning" : ""}`}>
|
||||
<article className="ecom-video-tree-node ecom-video-tree-node--text">
|
||||
<div className="ecom-video-tree-node__inner">
|
||||
<span className="ecom-video-tree-node__title">分镜策划</span>
|
||||
<span className="ecom-video-tree-node__desc">{stage === "planning" ? "策划中..." : "点击一键策划开始"}</span>
|
||||
</div>
|
||||
</article>
|
||||
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</div>
|
||||
<article className="ecom-video-tree-node ecom-video-tree-node--image">
|
||||
<div className="ecom-video-tree-node__placeholder">
|
||||
{stage === "planning" ? <LoadingOutlined /> : <span>待生成</span>}
|
||||
</div>
|
||||
<span className="ecom-video-tree-node__tag">分镜图</span>
|
||||
</article>
|
||||
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</div>
|
||||
<article className="ecom-video-tree-node ecom-video-tree-node--video">
|
||||
<div className="ecom-video-tree-node__placeholder">
|
||||
{stage === "planning" ? <LoadingOutlined /> : <span>待生成</span>}
|
||||
</div>
|
||||
<span className="ecom-video-tree-node__tag">分镜视频</span>
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Delivery dock ────────────────────────────── */}
|
||||
{primaryVideo ? (
|
||||
<div className="ecom-video-flow-dock" aria-label="视频交付操作">
|
||||
<button type="button" onClick={() => void handleDownloadAll()} title={`下载全部 ${completedScenes.length} 个视频`}><DownloadOutlined /></button>
|
||||
<button type="button" onClick={() => void handleSaveAllAssets()} title={`保存全部 ${completedScenes.length} 个视频到资产库`}><FolderAddOutlined /></button>
|
||||
{primaryVideo ? <button type="button" onClick={() => void handleImportToCanvas(primaryVideo)} title="导入画布"><SendOutlined /></button> : null}
|
||||
{primaryVideo ? <button type="button" onClick={() => void navigator.clipboard.writeText(primaryVideo)} title="复制链接"><CopyOutlined /></button> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{actionNotice ? <div className="ecom-video-flow-notice">{actionNotice}</div> : null}
|
||||
</section>
|
||||
|
||||
{previewMedia ? (
|
||||
<div className="ecom-video-preview-overlay" onClick={() => setPreviewMedia(null)}>
|
||||
<button type="button" className="ecom-video-preview-overlay__close" onClick={() => setPreviewMedia(null)}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
{previewMedia.type === "image" ? (
|
||||
<img src={previewMedia.url} alt="预览" onClick={(e) => e.stopPropagation()} />
|
||||
) : (
|
||||
<video src={previewMedia.url} controls autoPlay onClick={(e) => e.stopPropagation()} />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
export interface MentionImageOption {
|
||||
id: string;
|
||||
src: string;
|
||||
name: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function getImageMentionQuery(value: string, selectionStart: number | null | undefined): string | null {
|
||||
if (selectionStart === null || selectionStart === undefined) return null;
|
||||
const beforeCursor = value.slice(0, selectionStart);
|
||||
const match = beforeCursor.match(/(?:^|\s)@([^\s@]*)$/);
|
||||
return match ? match[1] ?? "" : null;
|
||||
}
|
||||
|
||||
export function insertImageMentionValue(
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
imageName: string,
|
||||
maxLength: number,
|
||||
): { value: string; selectionStart: number } {
|
||||
const beforeCursor = value.slice(0, selectionStart);
|
||||
const afterCursor = value.slice(selectionStart);
|
||||
const match = beforeCursor.match(/(?:^|\s)@([^\s@]*)$/);
|
||||
const mentionStart = match ? beforeCursor.length - match[0].length + (match[0].startsWith("@") ? 0 : 1) : selectionStart;
|
||||
const mentionText = `@${imageName.trim() || "uploaded-image"} `;
|
||||
const nextValue = `${value.slice(0, mentionStart)}${mentionText}${afterCursor}`.slice(0, maxLength);
|
||||
const nextSelectionStart = Math.min(mentionStart + mentionText.length, nextValue.length);
|
||||
return { value: nextValue, selectionStart: nextSelectionStart };
|
||||
}
|
||||
|
||||
interface ImageMentionMenuProps {
|
||||
images: MentionImageOption[];
|
||||
query?: string;
|
||||
onSelect: (image: MentionImageOption) => void;
|
||||
}
|
||||
|
||||
function ImageMentionMenu({ images, query = "", onSelect }: ImageMentionMenuProps) {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const visibleImages = normalizedQuery
|
||||
? images.filter((image) => `${image.label ?? ""} ${image.name}`.toLowerCase().includes(normalizedQuery))
|
||||
: images;
|
||||
|
||||
return (
|
||||
<div className="image-mention-menu" role="listbox" aria-label="选择上传图片">
|
||||
{visibleImages.length ? (
|
||||
visibleImages.map((image) => (
|
||||
<button
|
||||
key={image.id}
|
||||
type="button"
|
||||
className="image-mention-menu__item"
|
||||
role="option"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onSelect(image);
|
||||
}}
|
||||
>
|
||||
<img src={image.src} alt="" />
|
||||
<span>
|
||||
<strong>{image.label ?? image.name}</strong>
|
||||
<em>{image.name}</em>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="image-mention-menu__empty">没有匹配的上传图片</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageMentionMenu;
|
||||
@@ -0,0 +1,37 @@
|
||||
export const ECOMMERCE_SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
||||
export const ECOMMERCE_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export interface EcommerceImageValidationResult {
|
||||
accepted: File[];
|
||||
rejected: Array<{ name: string; reason: string }>;
|
||||
}
|
||||
|
||||
export function validateEcommerceImageFiles(files: File[]): EcommerceImageValidationResult {
|
||||
const accepted: File[] = [];
|
||||
const rejected: EcommerceImageValidationResult["rejected"] = [];
|
||||
|
||||
files.forEach((file) => {
|
||||
if (!ECOMMERCE_SUPPORTED_IMAGE_TYPES.has(file.type)) {
|
||||
rejected.push({ name: file.name, reason: "不支持的图片格式" });
|
||||
return;
|
||||
}
|
||||
if (file.size > ECOMMERCE_MAX_IMAGE_BYTES) {
|
||||
rejected.push({ name: file.name, reason: "图片超过 10MB" });
|
||||
return;
|
||||
}
|
||||
accepted.push(file);
|
||||
});
|
||||
|
||||
return { accepted, rejected };
|
||||
}
|
||||
|
||||
export function summarizeRejectedImages(rejected: EcommerceImageValidationResult["rejected"]): string {
|
||||
if (!rejected.length) return "";
|
||||
const first = rejected[0];
|
||||
const suffix = rejected.length > 1 ? ` 等 ${rejected.length} 个文件` : "";
|
||||
return `${first.name}${suffix} 已跳过:${first.reason}`;
|
||||
}
|
||||
|
||||
export function normalizeEcommerceImageMime(type: string): string {
|
||||
return ECOMMERCE_SUPPORTED_IMAGE_TYPES.has(type) ? type : "image/png";
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { ossAssets } from "../../data/ossAssets";
|
||||
|
||||
const [
|
||||
moreTemplateSlide1,
|
||||
moreTemplateSlide2,
|
||||
moreTemplateSlide3,
|
||||
moreTemplateSlide4,
|
||||
moreTemplateSlide5,
|
||||
] = ossAssets.ecommerce.templateSlides;
|
||||
const [
|
||||
ecommerceHeroSlide1,
|
||||
ecommerceHeroSlide2,
|
||||
ecommerceHeroSlide3,
|
||||
ecommerceHeroSlide4,
|
||||
ecommerceHeroSlide5,
|
||||
] = ossAssets.ecommerce.heroSlides;
|
||||
const [
|
||||
ecommerceCarouselImage1,
|
||||
ecommerceCarouselImage2,
|
||||
ecommerceCarouselImage3,
|
||||
ecommerceCarouselImage4,
|
||||
ecommerceCarouselImage5,
|
||||
ecommerceCarouselImage6,
|
||||
] = ossAssets.ecommerce.templateCases;
|
||||
const ecommerceCarouselGenerated = ossAssets.ecommerce.generated;
|
||||
|
||||
export interface TemplateCase {
|
||||
title: string;
|
||||
category: string;
|
||||
summary: string;
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
export const templateCategories = ["全部", "详情图", "主图", "模特图", "短视频", "营销图"];
|
||||
|
||||
export const templateCarouselCases: TemplateCase[] = [
|
||||
{
|
||||
title: "霓虹水花动态猫",
|
||||
category: "短视频",
|
||||
summary: "适合展示高动势、强色彩和奇幻质感的视觉参考。",
|
||||
imageUrl: moreTemplateSlide1,
|
||||
},
|
||||
{
|
||||
title: "古风人物光影",
|
||||
category: "模特图",
|
||||
summary: "适合古装人物、柔光棚拍和氛围大片参考。",
|
||||
imageUrl: moreTemplateSlide2,
|
||||
},
|
||||
{
|
||||
title: "绘本郊游场景",
|
||||
category: "营销图",
|
||||
summary: "适合童趣插画、故事场景和清新户外主题。",
|
||||
imageUrl: moreTemplateSlide3,
|
||||
},
|
||||
{
|
||||
title: "动画乐队片段",
|
||||
category: "短视频",
|
||||
summary: "适合复古动画、角色表演和音乐节奏参考。",
|
||||
imageUrl: moreTemplateSlide4,
|
||||
},
|
||||
{
|
||||
title: "猫咪动态贴图",
|
||||
category: "主图",
|
||||
summary: "适合轻量动图、角色素材和趣味展示参考。",
|
||||
imageUrl: moreTemplateSlide5,
|
||||
},
|
||||
];
|
||||
|
||||
export const ecommerceHeroCarouselCases: TemplateCase[] = [
|
||||
{
|
||||
title: "生成电商图 1",
|
||||
category: "电商轮播",
|
||||
summary: "电商页面轮播素材",
|
||||
imageUrl: ecommerceHeroSlide1,
|
||||
},
|
||||
{
|
||||
title: "生成电商图 2",
|
||||
category: "电商轮播",
|
||||
summary: "电商页面轮播素材",
|
||||
imageUrl: ecommerceHeroSlide2,
|
||||
},
|
||||
{
|
||||
title: "生成电商图 3",
|
||||
category: "电商轮播",
|
||||
summary: "电商页面轮播素材",
|
||||
imageUrl: ecommerceHeroSlide3,
|
||||
},
|
||||
{
|
||||
title: "生成电商图 4",
|
||||
category: "电商轮播",
|
||||
summary: "电商页面轮播素材",
|
||||
imageUrl: ecommerceHeroSlide4,
|
||||
},
|
||||
{
|
||||
title: "生成电商图 5",
|
||||
category: "电商轮播",
|
||||
summary: "电商页面轮播素材",
|
||||
imageUrl: ecommerceHeroSlide5,
|
||||
},
|
||||
];
|
||||
|
||||
export const templateCases: TemplateCase[] = [
|
||||
{
|
||||
title: "香氛详情页场景图",
|
||||
category: "详情图",
|
||||
summary: "适合家居香氛、礼盒、美妆类目,突出氛围和质感。",
|
||||
imageUrl: ecommerceCarouselImage1,
|
||||
},
|
||||
{
|
||||
title: "护肤套装主图",
|
||||
category: "主图",
|
||||
summary: "白底构图清晰,适合天猫、淘宝、京东主图位。",
|
||||
imageUrl: ecommerceCarouselImage3,
|
||||
},
|
||||
{
|
||||
title: "美妆营销海报",
|
||||
category: "营销图",
|
||||
summary: "强化利益点和视觉冲击,适合活动页和投放素材。",
|
||||
imageUrl: ecommerceCarouselGenerated,
|
||||
},
|
||||
{
|
||||
title: "岩境香氛短视频",
|
||||
category: "短视频",
|
||||
summary: "首帧质感强,适合慢展示和品牌调性视频。",
|
||||
imageUrl: ecommerceCarouselImage4,
|
||||
},
|
||||
{
|
||||
title: "花漾香氛模特手持图",
|
||||
category: "模特图",
|
||||
summary: "增加真实使用感,适合礼品和生活方式类商品。",
|
||||
imageUrl: ecommerceCarouselImage2,
|
||||
},
|
||||
{
|
||||
title: "促销卖点组合图",
|
||||
category: "详情图",
|
||||
summary: "把成分、规格、卖点拆成清晰的详情页模块。",
|
||||
imageUrl: ecommerceCarouselImage6,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,66 @@
|
||||
import type {
|
||||
EcommerceVideoStage,
|
||||
EcommerceVideoSceneTask,
|
||||
EcommerceVideoPlanProgress,
|
||||
EcommerceVideoPlanResult,
|
||||
PlanStep,
|
||||
} from "./ecommerceVideoTypes";
|
||||
|
||||
const KEEPALIVE_KEY = "omniai:ecommerce-video-workspace";
|
||||
|
||||
interface EcommerceVideoKeepalive {
|
||||
inputFingerprint: string;
|
||||
stage: EcommerceVideoStage;
|
||||
completedSteps: PlanStep[];
|
||||
planResult: EcommerceVideoPlanResult | null;
|
||||
planProgress?: EcommerceVideoPlanProgress | null;
|
||||
scenes: EcommerceVideoSceneTask[];
|
||||
sourceImageUrls: string[];
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
export function saveEcommerceVideoState(state: {
|
||||
inputFingerprint: string;
|
||||
stage: EcommerceVideoStage;
|
||||
completedSteps: PlanStep[];
|
||||
planResult: EcommerceVideoPlanResult | null;
|
||||
planProgress?: EcommerceVideoPlanProgress | null;
|
||||
scenes: EcommerceVideoSceneTask[];
|
||||
sourceImageUrls?: string[];
|
||||
}): void {
|
||||
try {
|
||||
const entry: EcommerceVideoKeepalive = {
|
||||
...state,
|
||||
sourceImageUrls: state.sourceImageUrls || [],
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
window.localStorage.setItem(KEEPALIVE_KEY, JSON.stringify(entry));
|
||||
} catch {
|
||||
// quota exceeded — silently drop
|
||||
}
|
||||
}
|
||||
|
||||
export function loadEcommerceVideoState(inputFingerprint: string): EcommerceVideoKeepalive | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEEPALIVE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as EcommerceVideoKeepalive;
|
||||
// Discard entries older than 2 hours
|
||||
if (Date.now() - (parsed.savedAt || 0) > 2 * 60 * 60 * 1000) {
|
||||
clearEcommerceVideoState();
|
||||
return null;
|
||||
}
|
||||
if (parsed.inputFingerprint !== inputFingerprint) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearEcommerceVideoState(): void {
|
||||
try {
|
||||
window.localStorage.removeItem(KEEPALIVE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import {
|
||||
analyzeProductImages,
|
||||
buildProductSummary,
|
||||
extractSellingPoints,
|
||||
generateCreativeOptions,
|
||||
generateStoryboard,
|
||||
generateVideoPrompts,
|
||||
checkCompliance,
|
||||
type AdVideoUserConfig,
|
||||
} from "../../api/adVideoPlanClient";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { serverRequest } from "../../api/serverConnection";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
||||
import { normalizeEcommerceImageMime } from "./ecommerceImageValidation";
|
||||
import type {
|
||||
EcommerceVideoPlanProgress,
|
||||
EcommerceVideoPlanResult,
|
||||
EcommerceVideoSceneTask,
|
||||
PlanStep,
|
||||
} from "./ecommerceVideoTypes";
|
||||
|
||||
type UploadAssetByUrl = typeof aiGenerationClient.uploadAssetByUrl;
|
||||
|
||||
interface DurableMediaUrl {
|
||||
url: string | null;
|
||||
originalUrl?: string | null;
|
||||
ossKey?: string | null;
|
||||
}
|
||||
|
||||
const TEMP_MEDIA_HOST_RE = /^file\d*\.aitohumanize\.com$/i;
|
||||
const OSS_MEDIA_HOST_RE = /\.oss-[^.]+\.aliyuncs\.com$/i;
|
||||
|
||||
function isTemporaryProviderUrl(url: string): boolean {
|
||||
try {
|
||||
return TEMP_MEDIA_HOST_RE.test(new URL(url).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDurableOssUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "https:" && OSS_MEDIA_HOST_RE.test(parsed.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getMediaExtension(url: string, mimeType: string): string {
|
||||
const normalizedMime = mimeType.split(";")[0]?.trim().toLowerCase();
|
||||
if (normalizedMime === "image/jpeg") return "jpg";
|
||||
if (normalizedMime === "image/png") return "png";
|
||||
if (normalizedMime === "image/webp") return "webp";
|
||||
if (normalizedMime === "image/gif") return "gif";
|
||||
if (normalizedMime === "video/mp4") return "mp4";
|
||||
if (normalizedMime === "video/webm") return "webm";
|
||||
if (normalizedMime === "video/quicktime") return "mov";
|
||||
|
||||
try {
|
||||
const matched = new URL(url).pathname.match(/\.([a-z0-9]{2,5})$/i);
|
||||
if (matched?.[1]) return matched[1].toLowerCase();
|
||||
} catch {
|
||||
// Keep mime fallback below.
|
||||
}
|
||||
|
||||
return mimeType.startsWith("video/") ? "mp4" : "png";
|
||||
}
|
||||
|
||||
function buildDurableMediaName(prefix: string, url: string, mimeType: string): string {
|
||||
const normalized = prefix
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|]+/g, "-")
|
||||
.replace(/\s+/g, " ")
|
||||
.slice(0, 80)
|
||||
.trim();
|
||||
return `${normalized || "ecommerce-video-media"}.${getMediaExtension(url, mimeType)}`;
|
||||
}
|
||||
|
||||
export async function resolveDurableMediaUrl(
|
||||
url: string | null | undefined,
|
||||
options: {
|
||||
mediaType: "image" | "video";
|
||||
namePrefix: string;
|
||||
scope?: string;
|
||||
uploadAssetByUrl?: UploadAssetByUrl;
|
||||
},
|
||||
): Promise<DurableMediaUrl> {
|
||||
const sourceUrl = String(url || "").trim();
|
||||
if (!sourceUrl) return { url: null };
|
||||
if (isDurableOssUrl(sourceUrl)) return { url: sourceUrl };
|
||||
|
||||
const mimeType = options.mediaType === "video" ? "video/mp4" : "image/png";
|
||||
const uploadAssetByUrl = options.uploadAssetByUrl || aiGenerationClient.uploadAssetByUrl.bind(aiGenerationClient);
|
||||
|
||||
try {
|
||||
const uploaded = await uploadAssetByUrl({
|
||||
sourceUrl,
|
||||
name: buildDurableMediaName(options.namePrefix, sourceUrl, mimeType),
|
||||
mimeType,
|
||||
scope: options.scope || "ecommerce-video-history",
|
||||
});
|
||||
return {
|
||||
url: uploaded.url || null,
|
||||
originalUrl: sourceUrl,
|
||||
ossKey: uploaded.ossKey || null,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error || "");
|
||||
console.warn("[ecommerce-video] history media persistence failed:", message);
|
||||
if (isTemporaryProviderUrl(sourceUrl)) {
|
||||
return { url: null, originalUrl: sourceUrl };
|
||||
}
|
||||
return { url: sourceUrl };
|
||||
}
|
||||
}
|
||||
|
||||
export interface PlanCallbacks {
|
||||
onStepStart: (step: PlanStep) => void;
|
||||
onStepDone: (step: PlanStep) => void;
|
||||
onImagesUploaded?: (urls: string[]) => void;
|
||||
onUploadRejected?: (messages: string[]) => void;
|
||||
onPartialProgress?: (progress: EcommerceVideoPlanProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
/** Partial state from a previous run; steps with existing data are skipped. */
|
||||
resumeFrom?: EcommerceVideoPlanProgress;
|
||||
}
|
||||
|
||||
const LOCAL_PREVIEW_MISSING_FILE_MESSAGE = "Please re-upload the product image before generating the short video.";
|
||||
|
||||
function readBlobAsDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(reader.error || new Error("File read failed"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRemoteImageUrl(source: string): string | null {
|
||||
try {
|
||||
const url = new URL(source, typeof window !== "undefined" ? window.location.href : undefined);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadProductImageSource(source: string | Blob): Promise<string> {
|
||||
if (typeof source === "string") {
|
||||
if (source.startsWith("blob:")) {
|
||||
throw new Error(LOCAL_PREVIEW_MISSING_FILE_MESSAGE);
|
||||
}
|
||||
|
||||
if (source.startsWith("data:")) {
|
||||
const mimeType = normalizeEcommerceImageMime(source.match(/^data:([^;,]+)/)?.[1] || "image/png");
|
||||
const result = await aiGenerationClient.uploadAsset({ dataUrl: source, mimeType, scope: "ecommerce-product" });
|
||||
return result.url;
|
||||
}
|
||||
|
||||
const remoteUrl = normalizeRemoteImageUrl(source);
|
||||
if (remoteUrl) {
|
||||
const result = await aiGenerationClient.uploadAssetByUrl({ sourceUrl: remoteUrl, scope: "ecommerce-product" });
|
||||
return result.url;
|
||||
}
|
||||
|
||||
throw new Error("Unsupported product image URL. Please re-upload the product image.");
|
||||
}
|
||||
|
||||
const mimeType = normalizeEcommerceImageMime(source.type || "image/png");
|
||||
const blob = source.type === mimeType ? source : new Blob([source], { type: mimeType });
|
||||
const dataUrl = await readBlobAsDataUrl(blob);
|
||||
const result = await aiGenerationClient.uploadAsset({ dataUrl, mimeType, scope: "ecommerce-product" });
|
||||
return result.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full ad video planning pipeline.
|
||||
* Supports resumption: if `resumeFrom` contains data for a step, that step is skipped.
|
||||
* After each step, `onPartialProgress` fires so callers can persist intermediate state.
|
||||
*/
|
||||
export async function runVideoPlan(
|
||||
imageSources: Array<string | Blob>,
|
||||
manualText: string,
|
||||
config: AdVideoUserConfig,
|
||||
callbacks: PlanCallbacks,
|
||||
): Promise<EcommerceVideoPlanResult> {
|
||||
const { onStepStart, onStepDone, signal, resumeFrom = {} } = callbacks;
|
||||
const progress: EcommerceVideoPlanProgress = { ...resumeFrom };
|
||||
const emit = () => callbacks.onPartialProgress?.({ ...progress });
|
||||
|
||||
// Step: upload
|
||||
if (!progress.imageUrls?.length) {
|
||||
onStepStart("upload");
|
||||
const imageUrls: string[] = [];
|
||||
const rejected: string[] = [];
|
||||
for (const source of imageSources) {
|
||||
try {
|
||||
imageUrls.push(await uploadProductImageSource(source));
|
||||
} catch (err) {
|
||||
rejected.push(err instanceof Error ? err.message : "Image upload failed");
|
||||
}
|
||||
}
|
||||
if (rejected.length) {
|
||||
progress.uploadWarnings = rejected;
|
||||
callbacks.onUploadRejected?.(rejected);
|
||||
}
|
||||
if (!imageUrls.length) throw new Error("Image upload failed. Please check the image format or network and try again.");
|
||||
progress.imageUrls = imageUrls;
|
||||
onStepDone("upload");
|
||||
callbacks.onImagesUploaded?.(imageUrls);
|
||||
emit();
|
||||
}
|
||||
|
||||
// Step: analyze
|
||||
if (progress.imageDescription === undefined) {
|
||||
onStepStart("analyze");
|
||||
progress.imageDescription = await analyzeProductImages(progress.imageUrls!, signal);
|
||||
onStepDone("analyze");
|
||||
emit();
|
||||
}
|
||||
|
||||
// Step: summary
|
||||
if (!progress.summary) {
|
||||
onStepStart("summary");
|
||||
progress.summary = await buildProductSummary(progress.imageDescription || "", manualText, signal);
|
||||
onStepDone("summary");
|
||||
emit();
|
||||
}
|
||||
|
||||
// Step: selling
|
||||
if (!progress.selling) {
|
||||
onStepStart("selling");
|
||||
progress.selling = await extractSellingPoints(progress.summary, signal);
|
||||
onStepDone("selling");
|
||||
emit();
|
||||
}
|
||||
|
||||
// Step: creative
|
||||
if (!progress.creatives?.length) {
|
||||
onStepStart("creative");
|
||||
progress.creatives = await generateCreativeOptions(progress.selling, config, signal);
|
||||
if (!progress.creatives.length) throw new Error("Failed to generate valid ad creatives.");
|
||||
onStepDone("creative");
|
||||
emit();
|
||||
}
|
||||
|
||||
// Step: storyboard
|
||||
if (!progress.storyboard) {
|
||||
onStepStart("storyboard");
|
||||
progress.storyboard = await generateStoryboard(progress.creatives[0], progress.summary, config, signal);
|
||||
onStepDone("storyboard");
|
||||
emit();
|
||||
}
|
||||
|
||||
// Step: prompts
|
||||
if (!progress.videoPrompts) {
|
||||
onStepStart("prompts");
|
||||
progress.videoPrompts = await generateVideoPrompts(progress.storyboard, progress.summary, signal);
|
||||
onStepDone("prompts");
|
||||
emit();
|
||||
}
|
||||
|
||||
// Step: compliance
|
||||
if (!progress.compliance) {
|
||||
onStepStart("compliance");
|
||||
progress.compliance = await checkCompliance(progress.summary, progress.selling, progress.storyboard, signal);
|
||||
onStepDone("compliance");
|
||||
emit();
|
||||
}
|
||||
|
||||
return {
|
||||
imageUrls: progress.imageUrls!,
|
||||
imageDescription: progress.imageDescription,
|
||||
summary: progress.summary!,
|
||||
selling: progress.selling!,
|
||||
creatives: progress.creatives!,
|
||||
storyboard: progress.storyboard!,
|
||||
videoPrompts: progress.videoPrompts!,
|
||||
compliance: progress.compliance!,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RenderSceneImageInput {
|
||||
sceneId: number;
|
||||
prompt: string;
|
||||
aspectRatio: string;
|
||||
productImageUrls: string[];
|
||||
}
|
||||
|
||||
export interface RenderImageCallbacks {
|
||||
onSceneImageSubmitted: (sceneId: number, taskId: string) => void;
|
||||
onSceneImageProgress: (sceneId: number, progress: number) => void;
|
||||
onSceneImageCompleted: (sceneId: number, resultUrl: string) => void;
|
||||
onSceneImageFailed: (sceneId: number, error: string) => void;
|
||||
}
|
||||
|
||||
export async function renderSceneImage(
|
||||
input: RenderSceneImageInput,
|
||||
callbacks: RenderImageCallbacks,
|
||||
abortRef: { current: boolean },
|
||||
): Promise<void> {
|
||||
const { taskId } = await aiGenerationClient.createImageTask({
|
||||
model: "gpt-image-2",
|
||||
prompt: input.prompt,
|
||||
ratio: input.aspectRatio,
|
||||
quality: "2K",
|
||||
referenceUrls: input.productImageUrls,
|
||||
});
|
||||
|
||||
callbacks.onSceneImageSubmitted(input.sceneId, taskId);
|
||||
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
abortRef,
|
||||
kind: "image",
|
||||
model: "gpt-image-2",
|
||||
onProgress: (e) => callbacks.onSceneImageProgress(input.sceneId, e.progress),
|
||||
});
|
||||
|
||||
if (resultUrl) {
|
||||
callbacks.onSceneImageCompleted(input.sceneId, resultUrl);
|
||||
} else {
|
||||
callbacks.onSceneImageFailed(input.sceneId, "Image generation returned no result.");
|
||||
}
|
||||
}
|
||||
|
||||
export interface RenderSceneInput {
|
||||
sceneId: number;
|
||||
prompt: string;
|
||||
durationSeconds: number;
|
||||
imageUrl: string;
|
||||
productImageUrls: string[];
|
||||
aspectRatio: string;
|
||||
resolution: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface RenderCallbacks {
|
||||
onSceneSubmitted: (sceneId: number, taskId: string) => void;
|
||||
onSceneProgress: (sceneId: number, progress: number) => void;
|
||||
onSceneCompleted: (sceneId: number, resultUrl: string) => void;
|
||||
onSceneFailed: (sceneId: number, error: string) => void;
|
||||
}
|
||||
|
||||
export async function renderScene(
|
||||
input: RenderSceneInput,
|
||||
callbacks: RenderCallbacks,
|
||||
abortRef: { current: boolean },
|
||||
): Promise<void> {
|
||||
const allReferenceUrls = [...input.productImageUrls, input.imageUrl];
|
||||
const model = resolveVideoRequestModel({
|
||||
model: input.model || "happyhorse-1.0",
|
||||
referenceUrls: allReferenceUrls,
|
||||
});
|
||||
|
||||
const { taskId } = await aiGenerationClient.createVideoTask({
|
||||
model,
|
||||
prompt: input.prompt,
|
||||
ratio: input.aspectRatio,
|
||||
duration: input.durationSeconds,
|
||||
quality: input.resolution,
|
||||
resolution: input.resolution,
|
||||
frameMode: "start-end",
|
||||
referenceUrls: allReferenceUrls,
|
||||
hasReferenceVideo: false,
|
||||
});
|
||||
|
||||
callbacks.onSceneSubmitted(input.sceneId, taskId);
|
||||
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
abortRef,
|
||||
kind: "video",
|
||||
model,
|
||||
onProgress: (e) => callbacks.onSceneProgress(input.sceneId, e.progress),
|
||||
});
|
||||
|
||||
if (resultUrl) {
|
||||
callbacks.onSceneCompleted(input.sceneId, resultUrl);
|
||||
} else {
|
||||
callbacks.onSceneFailed(input.sceneId, "Task returned no result.");
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSceneTasks(
|
||||
plan: EcommerceVideoPlanResult,
|
||||
): EcommerceVideoSceneTask[] {
|
||||
return plan.storyboard.scenes.map((scene) => {
|
||||
const matchedPrompt = plan.videoPrompts.find((p) => p.scene_id === scene.scene_id);
|
||||
return {
|
||||
sceneId: scene.scene_id,
|
||||
prompt: matchedPrompt?.positive_prompt || scene.visual_description,
|
||||
durationSeconds: Number.parseInt(scene.duration, 10) || 5,
|
||||
status: "idle" as const,
|
||||
progress: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Video History API
|
||||
|
||||
export interface VideoHistoryScene {
|
||||
sceneId: number;
|
||||
prompt: string;
|
||||
imageUrl?: string | null;
|
||||
videoUrl?: string | null;
|
||||
}
|
||||
|
||||
interface SaveVideoHistoryPayload {
|
||||
title: string;
|
||||
config: Record<string, unknown>;
|
||||
plan: Record<string, unknown>;
|
||||
scenes: VideoHistoryScene[];
|
||||
sourceImageUrls: string[];
|
||||
uploadAssetByUrl?: UploadAssetByUrl;
|
||||
}
|
||||
|
||||
export interface VideoHistoryItem {
|
||||
id: number;
|
||||
title: string;
|
||||
config: Record<string, unknown>;
|
||||
scenes: VideoHistoryScene[];
|
||||
sourceImageUrls: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface VideoHistoryListResponse {
|
||||
items: VideoHistoryItem[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise<SaveVideoHistoryPayload> {
|
||||
const uploadAssetByUrl = payload.uploadAssetByUrl;
|
||||
const scenes = await Promise.all(
|
||||
payload.scenes.map(async (scene) => {
|
||||
const [image, video] = await Promise.all([
|
||||
resolveDurableMediaUrl(scene.imageUrl, {
|
||||
mediaType: "image",
|
||||
namePrefix: `ecommerce-scene-${scene.sceneId}-image`,
|
||||
uploadAssetByUrl,
|
||||
}),
|
||||
resolveDurableMediaUrl(scene.videoUrl, {
|
||||
mediaType: "video",
|
||||
namePrefix: `ecommerce-scene-${scene.sceneId}-video`,
|
||||
uploadAssetByUrl,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
...scene,
|
||||
imageUrl: image.url,
|
||||
videoUrl: video.url,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const sourceImageUrls = (
|
||||
await Promise.all(
|
||||
payload.sourceImageUrls.map((url, index) =>
|
||||
resolveDurableMediaUrl(url, {
|
||||
mediaType: "image",
|
||||
namePrefix: `ecommerce-source-${index + 1}`,
|
||||
uploadAssetByUrl,
|
||||
}),
|
||||
),
|
||||
)
|
||||
)
|
||||
.map((item) => item.url)
|
||||
.filter((url): url is string => Boolean(url));
|
||||
|
||||
return {
|
||||
...payload,
|
||||
scenes,
|
||||
sourceImageUrls,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promise<{ id: number; createdAt: string }> {
|
||||
const { uploadAssetByUrl: _uploadAssetByUrl, ...historyPayload } = await buildDurableVideoHistoryPayload(payload);
|
||||
return serverRequest<{ id: number; createdAt: string }>("ai/ecommerce/video-history", {
|
||||
method: "POST",
|
||||
body: historyPayload,
|
||||
maxRetries: 0,
|
||||
fallbackMessage: "Failed to save video history",
|
||||
});
|
||||
}
|
||||
|
||||
function removeTemporaryHistoryUrls(item: VideoHistoryItem): VideoHistoryItem {
|
||||
return {
|
||||
...item,
|
||||
scenes: item.scenes.map((scene) => ({
|
||||
...scene,
|
||||
imageUrl: scene.imageUrl && !isTemporaryProviderUrl(scene.imageUrl) ? scene.imageUrl : null,
|
||||
videoUrl: scene.videoUrl && !isTemporaryProviderUrl(scene.videoUrl) ? scene.videoUrl : null,
|
||||
})),
|
||||
sourceImageUrls: item.sourceImageUrls.filter((url) => !isTemporaryProviderUrl(url)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchVideoHistory(
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
): Promise<VideoHistoryListResponse> {
|
||||
const search = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||
const history = await serverRequest<VideoHistoryListResponse>(`ai/ecommerce/video-history?${search}`, {
|
||||
fallbackMessage: "Failed to fetch video history",
|
||||
});
|
||||
return {
|
||||
...history,
|
||||
items: history.items.map(removeTemporaryHistoryUrls),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteVideoHistory(id: number): Promise<void> {
|
||||
await serverRequest<void>(`ai/ecommerce/video-history/${id}`, {
|
||||
method: "DELETE",
|
||||
maxRetries: 0,
|
||||
fallbackMessage: "Failed to delete video history",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type {
|
||||
ProductSummary,
|
||||
SellingPointResult,
|
||||
CreativeOption,
|
||||
Storyboard,
|
||||
VideoPrompt,
|
||||
ComplianceCheck,
|
||||
} from "../../api/adVideoPlanClient";
|
||||
|
||||
export type EcommerceVideoStage =
|
||||
| "idle"
|
||||
| "uploading"
|
||||
| "planning"
|
||||
| "planned"
|
||||
| "imaging"
|
||||
| "imaged"
|
||||
| "rendering"
|
||||
| "partial_failed"
|
||||
| "completed"
|
||||
| "cancelled";
|
||||
|
||||
export type SceneTaskStatus = "idle" | "pending" | "running" | "completed" | "failed" | "cancelled";
|
||||
|
||||
export interface EcommerceVideoSceneTask {
|
||||
sceneId: number;
|
||||
taskId?: string;
|
||||
imageTaskId?: string;
|
||||
prompt: string;
|
||||
durationSeconds: number;
|
||||
status: SceneTaskStatus;
|
||||
progress: number;
|
||||
resultUrl?: string | null;
|
||||
imageUrl?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface EcommerceVideoPlanResult {
|
||||
imageUrls: string[];
|
||||
imageDescription?: string;
|
||||
summary: ProductSummary;
|
||||
selling: SellingPointResult;
|
||||
creatives: CreativeOption[];
|
||||
storyboard: Storyboard;
|
||||
videoPrompts: VideoPrompt[];
|
||||
compliance: ComplianceCheck;
|
||||
}
|
||||
|
||||
/** Partial plan state — used as resume input when an earlier run failed mid-flow. */
|
||||
export interface EcommerceVideoPlanProgress {
|
||||
imageUrls?: string[];
|
||||
imageDescription?: string;
|
||||
uploadWarnings?: string[];
|
||||
summary?: ProductSummary;
|
||||
selling?: SellingPointResult;
|
||||
creatives?: CreativeOption[];
|
||||
storyboard?: Storyboard;
|
||||
videoPrompts?: VideoPrompt[];
|
||||
compliance?: ComplianceCheck;
|
||||
}
|
||||
|
||||
export interface EcommerceVideoDelivery {
|
||||
planResult: EcommerceVideoPlanResult | null;
|
||||
scenes: EcommerceVideoSceneTask[];
|
||||
completedCount: number;
|
||||
failedCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export type PlanStep =
|
||||
| "upload"
|
||||
| "analyze"
|
||||
| "summary"
|
||||
| "selling"
|
||||
| "creative"
|
||||
| "storyboard"
|
||||
| "prompts"
|
||||
| "compliance";
|
||||
|
||||
export const PLAN_STEP_LABELS: Record<PlanStep, string> = {
|
||||
upload: "上传商品图",
|
||||
analyze: "分析商品图",
|
||||
summary: "生成商品理解",
|
||||
selling: "提炼卖点",
|
||||
creative: "生成广告创意",
|
||||
storyboard: "生成视频分镜",
|
||||
prompts: "生成镜头提示词",
|
||||
compliance: "合规检查",
|
||||
};
|
||||
|
||||
/** Completed plan steps that should show as text nodes in the flow map (skip upload). */
|
||||
export const PLAN_STEPS_DISPLAY: PlanStep[] = [
|
||||
"analyze", "summary", "selling", "creative", "storyboard", "prompts", "compliance",
|
||||
];
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user