Initial commit: OmniAI Web Frontend

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:38:01 +08:00
commit bedee3ba8d
183 changed files with 94805 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,323 @@
import {
ArrowLeftOutlined,
CheckCircleOutlined,
DeleteOutlined,
PlayCircleOutlined,
SearchOutlined,
ShoppingOutlined,
TagsOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
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,347 @@
import { Fragment, useCallback, useRef, useState } from "react";
import {
CopyOutlined,
DownloadOutlined,
FolderAddOutlined,
LoadingOutlined,
PlayCircleOutlined,
ReloadOutlined,
SendOutlined,
StopOutlined,
} from "@ant-design/icons";
import { runVideoPlan, renderScene, buildSceneTasks } from "./ecommerceVideoService";
import {
PLAN_STEP_LABELS,
type EcommerceVideoStage,
type EcommerceVideoSceneTask,
type EcommerceVideoPlanResult,
type PlanStep,
} from "./ecommerceVideoTypes";
import type { AdVideoUserConfig } from "../../api/adVideoPlanClient";
import { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions";
import { useAppStore } from "../../stores";
interface EcommerceVideoWorkspaceProps {
isAuthenticated: boolean;
productImageDataUrls: string[];
requirement: string;
platform: string;
aspectRatio: string;
durationSeconds: number;
resolution: string;
onRequestLogin?: () => void;
}
const ALL_STEPS: PlanStep[] = [
"upload", "analyze", "summary", "selling",
"creative", "storyboard", "prompts", "compliance",
];
function mapResolutionToQuality(res: string): "720P" | "1080P" {
return res.includes("720") ? "720P" : "1080P";
}
export default function EcommerceVideoWorkspace({
isAuthenticated,
productImageDataUrls,
requirement,
platform,
aspectRatio,
durationSeconds,
resolution,
onRequestLogin,
}: EcommerceVideoWorkspaceProps) {
const [stage, setStage] = useState<EcommerceVideoStage>("idle");
const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null);
const [scenes, setScenes] = useState<EcommerceVideoSceneTask[]>([]);
const [completedSteps, setCompletedSteps] = useState<PlanStep[]>([]);
const [currentStep, setCurrentStep] = useState<PlanStep | null>(null);
const [error, setError] = useState<string | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const renderAbortRef = useRef({ current: false });
const setView = useAppStore((s) => s.setView);
const showNotice = (msg: string) => {
setActionNotice(msg);
setTimeout(() => 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 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]);
const handlePlan = async () => {
if (!isAuthenticated) { onRequestLogin?.(); return; }
if (!productImageDataUrls.length && !requirement.trim()) {
setError("请先上传产品图片或填写商品说明");
return;
}
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setStage("planning");
setError(null);
setCompletedSteps([]);
setCurrentStep(null);
setPlanResult(null);
setScenes([]);
try {
const result = await runVideoPlan(
productImageDataUrls, requirement, buildConfig(),
{
onStepStart: (step) => setCurrentStep(step),
onStepDone: (step) => setCompletedSteps((prev) => [...prev, step]),
signal: controller.signal,
},
);
setPlanResult(result);
setScenes(buildSceneTasks(result));
setStage("planned");
} catch (err) {
if ((err as Error).name === "AbortError") return;
setError(err instanceof Error ? err.message : "策划失败");
setStage("idle");
} finally {
setCurrentStep(null);
}
};
const handleRender = async () => {
if (!planResult || !scenes.length) return;
const imageUrl = productImageDataUrls[0] || "";
setStage("rendering");
setError(null);
renderAbortRef.current = { current: false };
const quality = mapResolutionToQuality(resolution);
for (const scene of scenes) {
if (renderAbortRef.current.current) break;
setScenes((prev) => prev.map((s) =>
s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
try {
await renderScene(
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl, aspectRatio, resolution: quality },
{
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 instanceof Error ? err.message : "生成失败" } : 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;
});
};
const handleCancel = () => {
abortControllerRef.current?.abort();
renderAbortRef.current.current = true;
setStage("cancelled");
};
const completedScenes = scenes.filter((s) => s.status === "completed" && s.resultUrl);
const primaryVideo = completedScenes[0]?.resultUrl;
const canRender = planResult?.compliance.allow_video_generation && stage === "planned";
const sourceImage = productImageDataUrls[0] || "";
const flowHasStarted = stage !== "idle" || completedSteps.length > 0 || scenes.length > 0;
const flowMeta = `${platform} / ${aspectRatio} / ${durationSeconds}s / ${resolution}`;
const planActionLabel = stage === "planning"
? "策划中"
: (stage === "planned" || stage === "completed" || stage === "partial_failed") ? "重新策划" : "一键策划";
const renderActionLabel = stage === "rendering" ? "生成中" : "确认生成";
return (
<div className="ecom-video-workspace" data-stage={stage}>
<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;
const cls = isDone ? "is-done" : isActive ? "is-active" : "";
return (
<span
key={step}
className={`ecom-video-step-dot ${cls}`}
title={PLAN_STEP_LABELS[step]}
aria-label={PLAN_STEP_LABELS[step]}
/>
);
})}
</div>
<div className="ecom-video-flowbar__actions">
{error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null}
<button
type="button"
className="ecom-video-flow-action"
disabled={stage === "planning" || stage === "rendering"}
onClick={() => void handlePlan()}
aria-label={planActionLabel}
title={planActionLabel}
>
{stage === "planning" ? <LoadingOutlined /> : (stage === "planned" || stage === "completed" || stage === "partial_failed") ? <ReloadOutlined /> : <PlayCircleOutlined />}
</button>
{(stage === "rendering" || stage === "planned") ? (
<button
type="button"
className="ecom-video-flow-action ecom-video-flow-action--ghost"
disabled={!canRender}
onClick={() => void handleRender()}
aria-label={renderActionLabel}
title={renderActionLabel}
>
{stage === "rendering" ? <LoadingOutlined /> : <SendOutlined />}
</button>
) : null}
{stage === "rendering" ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--danger" onClick={handleCancel} aria-label="取消生成" title="取消生成">
<StopOutlined />
</button>
) : null}
</div>
</header>
<section className="ecom-video-flow-canvas" aria-label="视频分镜流程图">
{completedScenes.length === 0 && !sourceImage ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", color: "#697486", fontSize: 13 }}>
<span>"一键策划"</span>
</div>
) : (
<div className="ecom-video-flow-map">
{sourceImage ? (
<article className="ecom-video-flow-node ecom-video-flow-node--source is-ready" aria-label="商品图节点">
<div className="ecom-video-flow-node__media">
<img src={sourceImage} alt="商品图" />
</div>
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
) : null}
{sourceImage && completedScenes.length > 0 ? (
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
) : null}
<div className="ecom-video-scene-strip" aria-label="已完成分镜节点">
{completedScenes.map((scene, index) => (
<Fragment key={scene.sceneId}>
<article
className="ecom-video-flow-node ecom-video-flow-node--scene is-completed"
aria-label={`镜头 ${scene.sceneId},完成`}
title={`镜头 ${scene.sceneId}`}
>
<div className="ecom-video-flow-node__media">
<video src={scene.resultUrl!} muted playsInline loop autoPlay />
</div>
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
{index < completedScenes.length - 1 ? (
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
) : null}
</Fragment>
))}
</div>
{completedScenes.length > 0 && primaryVideo ? (
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
) : null}
{primaryVideo ? (
<article className="ecom-video-flow-node ecom-video-flow-node--final is-completed" aria-label="成片节点,已完成">
<div className="ecom-video-flow-node__media">
<video src={primaryVideo} muted playsInline loop autoPlay />
</div>
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
) : null}
</div>
)}
{primaryVideo ? (
<div className="ecom-video-flow-dock" aria-label="视频交付操作">
<button type="button" aria-label="下载当前视频" title="下载当前视频" onClick={() => void handleDownload(primaryVideo)}><DownloadOutlined /></button>
<button type="button" aria-label="保存到资产库" title="保存到资产库" onClick={() => void handleSaveAsset(primaryVideo)}><FolderAddOutlined /></button>
<button type="button" aria-label="导入画布" title="导入画布" onClick={() => void handleImportToCanvas(primaryVideo)}><SendOutlined /></button>
<button type="button" aria-label="复制视频链接" title="复制视频链接" onClick={() => void navigator.clipboard.writeText(primaryVideo)}><CopyOutlined /></button>
</div>
) : null}
{actionNotice ? <div className="ecom-video-flow-notice">{actionNotice}</div> : null}
</section>
</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,129 @@
import ecommerceCarouselGenerated from "../../assets/ecommerce-carousel-generated.png";
import moreTemplateSlide1 from "../../assets/more-template-carousel/slide-1.jpg";
import moreTemplateSlide2 from "../../assets/more-template-carousel/slide-2.jpg";
import moreTemplateSlide3 from "../../assets/more-template-carousel/slide-3.jpg";
import moreTemplateSlide4 from "../../assets/more-template-carousel/slide-4.png";
import moreTemplateSlide5 from "../../assets/more-template-carousel/slide-5.gif";
import ecommerceHeroSlide1 from "../../assets/ecommerce-hero-carousel/slide-1.webp";
import ecommerceHeroSlide2 from "../../assets/ecommerce-hero-carousel/slide-2.webp";
import ecommerceHeroSlide3 from "../../assets/ecommerce-hero-carousel/slide-3.webp";
import ecommerceHeroSlide4 from "../../assets/ecommerce-hero-carousel/slide-4.webp";
import ecommerceHeroSlide5 from "../../assets/ecommerce-hero-carousel/slide-5.webp";
import ecommerceCarouselImage1 from "../../../tu/微信图片_20260514125332_8_2.png";
import ecommerceCarouselImage2 from "../../../tu/微信图片_20260514125332_9_2.png";
import ecommerceCarouselImage3 from "../../../tu/微信图片_20260514125332_7_2.png";
import ecommerceCarouselImage4 from "../../../tu/微信图片_20260514125332_12_2.png";
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: "https://picsum.photos/id/1080/900/620",
},
];
@@ -0,0 +1,144 @@
import {
analyzeProductImages,
buildProductSummary,
extractSellingPoints,
generateCreativeOptions,
generateStoryboard,
generateVideoPrompts,
checkCompliance,
type AdVideoUserConfig,
} from "../../api/adVideoPlanClient";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { uploadAssetWithProgress } from "../../api/uploadWithProgress";
import { waitForTask } from "../../api/taskSubscription";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import type {
EcommerceVideoPlanResult,
EcommerceVideoSceneTask,
PlanStep,
} from "./ecommerceVideoTypes";
export interface PlanCallbacks {
onStepStart: (step: PlanStep) => void;
onStepDone: (step: PlanStep) => void;
signal?: AbortSignal;
}
export async function runVideoPlan(
imageDataUrls: string[],
manualText: string,
config: AdVideoUserConfig,
callbacks: PlanCallbacks,
): Promise<EcommerceVideoPlanResult> {
const { onStepStart, onStepDone, signal } = callbacks;
onStepStart("upload");
const imageUrls: string[] = [];
for (const dataUrl of imageDataUrls) {
const result = await uploadAssetWithProgress(
{ dataUrl, scope: "ecommerce-product", mimeType: "image/png" },
{ signal },
);
imageUrls.push(result.url);
}
onStepDone("upload");
onStepStart("analyze");
const imageDesc = await analyzeProductImages(imageUrls, signal);
onStepDone("analyze");
onStepStart("summary");
const summary = await buildProductSummary(imageDesc, manualText, signal);
onStepDone("summary");
onStepStart("selling");
const selling = await extractSellingPoints(summary, signal);
onStepDone("selling");
onStepStart("creative");
const creatives = await generateCreativeOptions(selling, config, signal);
if (!creatives.length) throw new Error("未能生成有效的广告创意");
onStepDone("creative");
onStepStart("storyboard");
const storyboard = await generateStoryboard(creatives[0], summary, config, signal);
onStepDone("storyboard");
onStepStart("prompts");
const videoPrompts = await generateVideoPrompts(storyboard, summary, signal);
onStepDone("prompts");
onStepStart("compliance");
const compliance = await checkCompliance(summary, selling, storyboard, signal);
onStepDone("compliance");
return { summary, selling, creatives, storyboard, videoPrompts, compliance };
}
export interface RenderSceneInput {
sceneId: number;
prompt: string;
durationSeconds: number;
imageUrl: 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 model = resolveVideoRequestModel({
model: input.model || "happyhorse-1.0",
referenceUrls: [input.imageUrl],
});
const { taskId } = await aiGenerationClient.createVideoTask({
model,
prompt: input.prompt,
ratio: input.aspectRatio,
duration: input.durationSeconds,
quality: input.resolution,
resolution: input.resolution,
imageUrl: input.imageUrl,
referenceUrls: [input.imageUrl],
hasReferenceVideo: false,
});
callbacks.onSceneSubmitted(input.sceneId, taskId);
const resultUrl = await waitForTask(taskId, {
abortRef,
onProgress: (e) => callbacks.onSceneProgress(input.sceneId, e.progress),
});
if (resultUrl) {
callbacks.onSceneCompleted(input.sceneId, resultUrl);
} else {
callbacks.onSceneFailed(input.sceneId, "任务未返回结果");
}
}
export function buildSceneTasks(
plan: EcommerceVideoPlanResult,
): EcommerceVideoSceneTask[] {
return plan.storyboard.scenes.map((scene) => {
const prompt = plan.videoPrompts.find((p) => p.scene_id === scene.scene_id);
return {
sceneId: scene.scene_id,
prompt: prompt?.positive_prompt || scene.visual_description,
durationSeconds: Number.parseInt(scene.duration, 10) || 5,
status: "idle",
progress: 0,
};
});
}
@@ -0,0 +1,69 @@
import type {
ProductSummary,
SellingPointResult,
CreativeOption,
Storyboard,
VideoPrompt,
ComplianceCheck,
} from "../../api/adVideoPlanClient";
export type EcommerceVideoStage =
| "idle"
| "uploading"
| "planning"
| "planned"
| "rendering"
| "partial_failed"
| "completed"
| "cancelled";
export type SceneTaskStatus = "idle" | "pending" | "running" | "completed" | "failed" | "cancelled";
export interface EcommerceVideoSceneTask {
sceneId: number;
taskId?: string;
prompt: string;
durationSeconds: number;
status: SceneTaskStatus;
progress: number;
resultUrl?: string | null;
error?: string | null;
}
export interface EcommerceVideoPlanResult {
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: "合规检查",
};