Files
omniai-web/src/features/home/HomePage.tsx
T
stringadmin 16bf7bbdad fix: replace hardcoded local image paths with OSS URLs
Remove all local file imports referencing ../../../tu/ and
../../assets/ directories. Replace with OSS muban prefix URLs:
https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban/

This fixes build failure on server (missing local image files)
and ensures images load from centralized OSS storage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 14:34:55 +08:00

348 lines
13 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 WelcomeSplash from "./WelcomeSplash";
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;
}
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: "token",
eyebrow: "Team Tokens",
title: "团队 Token 监控",
description: "实时追踪团队 Token 消耗、项目分布和成员使用情况,让预算、配额和成本都能被清楚管理。",
imageUrl: featureTokenImage,
actionLabel: "查看面板",
icon: <DashboardOutlined />,
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_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 HomePage({ onOpenGenerate, onOpenEcommerce, onOpenScriptReview, onOpenTokenMonitor }: 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 === "token") {
(onOpenTokenMonitor ?? 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">
<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>
</main>
</section>
</>
);
}
export default HomePage;