Files
omniai-web/src/features/home/HomePage.tsx
T

442 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ArrowRightOutlined,
DashboardOutlined,
FileSearchOutlined,
PlayCircleOutlined,
PlusOutlined,
ShoppingOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
import { useScrollEntrance } from "../../hooks/useScrollEntrance";
import WelcomeSplash from "./WelcomeSplash";
import ToolboxSection from "./ToolboxSection";
import ScriptReviewShowcase from "./ScriptReviewShowcase";
import ModelGenerationShowcase from "./ModelGenerationShowcase";
import ecommerceTemplate1 from "../../assets/home-features/home-ecommerce-template-1.png";
import ecommerceTemplate2 from "../../assets/home-features/home-ecommerce-template-2.png";
import ecommerceTemplate3 from "../../assets/home-features/home-ecommerce-template-3.png";
function ScrollEntrance({ children, className, ...rest }: { children: React.ReactNode; className?: string } & React.HTMLAttributes<HTMLElement>) {
const { ref, isVisible } = useScrollEntrance<HTMLElement>();
return (
<section ref={ref} className={`${className ?? ""} scroll-entrance${isVisible ? " is-visible" : ""}`} {...rest}>
{children}
</section>
);
}
const OSS_MUBAN = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban";
const heroImage1 = `${OSS_MUBAN}/hero-1.png`;
const heroImage2 = `${OSS_MUBAN}/hero-2.png`;
const heroImage3 = `${OSS_MUBAN}/hero-3.png`;
const featureEcommerceImage = `${OSS_MUBAN}/feature-ecommerce.jpg`;
const featureScriptImage = `${OSS_MUBAN}/feature-script.jpg`;
const featureTokenImage = `${OSS_MUBAN}/feature-token.jpg`;
interface HomePageProps {
onOpenGenerate: () => void;
onOpenCanvas?: () => void;
onOpenEcommerce: () => void;
onOpenScriptReview?: () => void;
onOpenTokenMonitor?: () => void;
onSelectView: (view: WebViewKey) => void;
onOpenImageTool?: (tool: WebImageWorkbenchTool) => void;
}
const HOME_BACKGROUND_VIDEO = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/%E6%A0%B7%E7%89%87.mp4";
const HOME_CAROUSEL_IMAGES = [
{ imageUrl: heroImage1, title: "灵感生成" },
{ imageUrl: heroImage2, title: "画布创作" },
{ imageUrl: heroImage3, title: "商业素材" },
];
const HOME_FEATURES = [
{
key: "script",
eyebrow: "Script Review",
title: "剧本智能测评",
description: "用六维雷达评分拆解剧本质量,从结构、节奏、人物到商业潜力给出可执行的优化路径。",
imageUrl: featureScriptImage,
actionLabel: "开始测评",
icon: <FileSearchOutlined />,
stats: ["六维评分", "质量量化", "逐项优化"],
},
{
key: "model",
eyebrow: "AI Generation",
title: "模型生成",
description: "通过AI模型生成文本、图片、视频,三种模式覆盖全内容类型,Agent对话式交互智能产出。",
imageUrl: featureTokenImage,
actionLabel: "开始生成",
icon: <ThunderboltOutlined />,
stats: ["文本生成", "图片生成", "视频生成"],
},
{
key: "ecommerce",
eyebrow: "AI Commerce",
title: "AI 电商生成",
description: "上传产品图后自动生成主图、场景图、详情素材和短视频方案,快速覆盖多平台商品视觉。",
imageUrl: featureEcommerceImage,
actionLabel: "开始生成",
icon: <ShoppingOutlined />,
stats: ["多场景", "多角度", "批量输出"],
},
];
const HOME_EXPERIENCE_POINTS = [
{ label: "生成", meta: "图像 / 视频", tone: "green" },
{ label: "测评", meta: "剧本质量", tone: "cyan" },
{ label: "成本", meta: "Token 用量", tone: "violet" },
{ label: "电商", meta: "商品视觉", tone: "amber" },
];
const HOME_ECOMMERCE_TEMPLATES = [
{
title: "卖点详情图",
tag: "详情",
meta: "中文卖点标注",
imageUrl: ecommerceTemplate1,
},
{
title: "场景主图",
tag: "主图",
meta: "商品氛围构图",
imageUrl: ecommerceTemplate2,
},
{
title: "虚拟模特",
tag: "模特",
meta: "使用场景延展",
imageUrl: ecommerceTemplate3,
},
];
const HOME_ECOMMERCE_TOOLS = [
{ title: "主图", meta: "平台首图" },
{ title: "详情", meta: "卖点拆解" },
{ title: "模特", meta: "虚拟模特" },
{ title: "短视频", meta: "首帧方案" },
];
const HOME_CAROUSEL_SLOTS = [-4, -3, -2, -1, 0, 1, 2, 3, 4];
const HOME_CAROUSEL_TRANSITION_MS = 860;
interface HomeCarouselMotion {
direction: number;
progress: 0 | 1;
}
function getPositiveModulo(value: number, length: number) {
return ((value % length) + length) % length;
}
function getHomeCarouselCardStyle(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 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 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": "0deg",
"--apple-card-rotate-z": "0deg",
"--apple-card-scale": String(scale),
"--apple-card-opacity": String(depth > 4 ? 0 : 1),
} as CSSProperties;
}
function EcommerceFeatureShowcase() {
return (
<div className="omni-home-ecommerce-showcase">
<div className="omni-home-ecommerce-showcase__depth" />
<div className="omni-home-ecommerce-showcase__grain" />
<div className="omni-home-ecommerce-showcase__prompt">
<span> + </span>
<strong></strong>
<p></p>
</div>
<div className="omni-home-ecommerce-showcase__tools" aria-hidden="true">
{HOME_ECOMMERCE_TOOLS.map((item) => (
<div key={item.title} className="omni-home-ecommerce-showcase__tool">
<b>{item.title}</b>
<small>{item.meta}</small>
</div>
))}
</div>
<div className="omni-home-ecommerce-showcase__gallery" aria-hidden="true">
{HOME_ECOMMERCE_TEMPLATES.map((item, index) => (
<article key={item.title} className={`omni-home-ecommerce-showcase__shot is-${index + 1}`}>
<img src={item.imageUrl} alt="" />
<div>
<span>{item.tag}</span>
<strong>{item.title}</strong>
<small>{item.meta}</small>
</div>
</article>
))}
</div>
</div>
);
}
function HomePage({ onOpenGenerate, onOpenEcommerce, onOpenScriptReview, onOpenTokenMonitor, onSelectView, onOpenImageTool }: HomePageProps) {
const [splashDismissed, setSplashDismissed] = useState(() => sessionStorage.getItem("omniai:splash-seen") === "1");
const [activeSlideIndex, setActiveSlideIndex] = useState(0);
const [carouselMotion, setCarouselMotion] = useState<HomeCarouselMotion | 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 carouselSlotOffsets = useMemo(() => {
const direction = carouselMotion?.direction ?? 0;
const minSlot = HOME_CAROUSEL_SLOTS[0]! + Math.min(direction, 0);
const maxSlot = HOME_CAROUSEL_SLOTS[HOME_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 || HOME_CAROUSEL_IMAGES.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);
setActiveSlideIndex((current) => getPositiveModulo(current + direction, HOME_CAROUSEL_IMAGES.length));
setCarouselMotion(null);
carouselResetFrameRef.current = window.requestAnimationFrame(() => {
carouselResetFrameRef.current = window.requestAnimationFrame(() => {
setCarouselIsResetting(false);
});
});
}, HOME_CAROUSEL_TRANSITION_MS);
},
[carouselMotion],
);
useEffect(() => {
const timerId = window.setInterval(() => {
startCarouselShift(-1);
}, 2600);
return () => window.clearInterval(timerId);
}, [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);
}
},
[],
);
const handleFeatureOpen = (featureKey: string) => {
if (featureKey === "script") {
(onOpenScriptReview ?? onOpenGenerate)();
return;
}
if (featureKey === "model") {
onOpenGenerate();
return;
}
if (featureKey === "ecommerce") {
onOpenEcommerce();
return;
}
onOpenGenerate();
};
return (
<>
{!splashDismissed && (
<WelcomeSplash
onEnter={() => {
sessionStorage.setItem("omniai:splash-seen", "1");
setSplashDismissed(true);
}}
/>
)}
<section className="omni-home page-motion">
{splashDismissed && (
<video
className="omni-home__bg-video"
src={HOME_BACKGROUND_VIDEO}
autoPlay
muted
loop
playsInline
preload="metadata"
aria-hidden="true"
/>
)}
<div className="omni-home__scrim" aria-hidden="true" />
<div className="omni-home__shell">
<section className="omni-home__hero" aria-label="OmniAI 首页">
<div className="omni-home__copy">
<h1>OmniAI </h1>
<p>绿</p>
</div>
<div className={`omni-home__carousel${carouselIsResetting ? " is-resetting" : ""}`} aria-label="创作案例轮播">
<div className="omni-home__carousel-stage">
<div className="omni-home__carousel-deck">
{carouselSlotOffsets.map((slotOffset) => {
const itemIndex = getPositiveModulo(activeSlideIndex + slotOffset, HOME_CAROUSEL_IMAGES.length);
const slide = HOME_CAROUSEL_IMAGES[itemIndex];
const visualOffset = slotOffset - (carouselMotion?.direction ?? 0) * (carouselMotion?.progress ?? 0);
const isActive = visualOffset === 0;
if (!slide) return null;
return (
<button
key={slotOffset}
type="button"
className={`omni-home__carousel-card${isActive ? " is-active" : ""}`}
style={getHomeCarouselCardStyle(visualOffset)}
aria-label={`切换到${slide.title}`}
aria-pressed={isActive}
onClick={() => {
if (!isActive) startCarouselShift(slotOffset);
}}
>
<img src={slide.imageUrl} alt={slide.title} />
</button>
);
})}
</div>
</div>
</div>
<div className="omni-home__actions" aria-label="首页入口">
<button type="button" className="omni-home__entry" onClick={onOpenGenerate}>
<PlusOutlined />
<span></span>
</button>
<button type="button" className="omni-home__entry omni-home__entry--primary" onClick={onOpenGenerate}>
<PlayCircleOutlined />
<span></span>
</button>
<button type="button" className="omni-home__entry" onClick={onOpenEcommerce}>
<ShoppingOutlined />
<span></span>
</button>
</div>
</section>
</div>
<main className="omni-home__feature-pages" aria-label="OmniAI 功能介绍">
{HOME_FEATURES.map((feature, index) => (
<section key={feature.key} className={`omni-home__feature-page is-${feature.key}${index % 2 ? " is-alt" : ""}`}>
<div className="omni-home__feature-copy">
<span>
{feature.icon}
{feature.eyebrow}
</span>
<h2>{feature.title}</h2>
<p>{feature.description}</p>
<button type="button" onClick={() => handleFeatureOpen(feature.key)}>
{feature.actionLabel}
<ArrowRightOutlined />
</button>
</div>
<div className="omni-home__feature-visual" aria-hidden="true">
{feature.key === "script" ? (
<ScriptReviewShowcase />
) : feature.key === "model" ? (
<ModelGenerationShowcase />
) : feature.key === "ecommerce" ? (
<EcommerceFeatureShowcase />
) : (
<img src={feature.imageUrl} alt="" />
)}
</div>
<div className="omni-home__feature-stats" aria-hidden="true">
{feature.stats.map((item) => (
<span key={item}>{item}</span>
))}
</div>
</section>
))}
<section className="omni-home__experience" aria-label="点击体验">
<div className="omni-home__experience-copy">
<span>
<ThunderboltOutlined />
Click To Experience
</span>
<h2> OmniAI</h2>
<p></p>
</div>
<div className="omni-home__experience-visual" aria-hidden="true">
<div className="omni-home__experience-line is-top" />
<div className="omni-home__experience-line is-bottom" />
<div className="omni-home__experience-routes">
{HOME_EXPERIENCE_POINTS.map((point) => (
<span key={point.label} className={`omni-home__experience-route is-${point.tone}`}>
<b>{point.label}</b>
<small>{point.meta}</small>
</span>
))}
</div>
</div>
<div className="omni-home__experience-actions">
<button type="button" className="is-primary" onClick={onOpenGenerate}>
<PlayCircleOutlined />
</button>
<button type="button" onClick={onOpenEcommerce}>
<ShoppingOutlined />
</button>
</div>
</section>
<ToolboxSection onSelectView={onSelectView} onOpenImageTool={onOpenImageTool} />
</main>
</section>
</>
);
}
export default HomePage;