Files
omniai-web/src/features/home/HomePage.tsx
T
OmniAI Developer 05e4f5b4b3 feat: 首页增加工具箱功能区、剧本评测可视化展示;重构剧本评分页面UI
- 首页新增工具箱功能区(ToolboxSection),展示四大AI工具卡片
- 首页剧本功能区替换为六维柱状图可视化(ScriptReviewVisual)
- 剧本评分页面(ScriptTokensPage)全面重构为新版UI布局
- 左侧面板:上传区、AI识别信息、历史评测(持久化)、操作按钮
- 右侧:剧本输入区、评测结果Hero、六维柱状图、亮点/扣分点、优化建议表格
- 历史评测支持localStorage持久化,按时间倒序排列
2026-06-02 18:58:13 +08:00

359 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 type { WebViewKey, WebImageWorkbenchTool } from "../../types";
import WelcomeSplash from "./WelcomeSplash";
import ToolboxSection from "./ToolboxSection";
import ScriptReviewVisual from "./ScriptReviewVisual";
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: "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, 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 === "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">
{feature.key === "script" ? (
<ScriptReviewVisual />
) : (
<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;