Merge branch 'feature_2025/251117_pref' into feature_2025/251121_h5UI

* feature_2025/251117_pref: (159 commits)
  feat: UI调整
  feat: 将滚动事件移东到组件内部
  feat: 去掉背景组件
  feat: 拆分左侧栏、中间聊天区、右侧栏组件, Hooks 提取
  feat: 简化主组件 index.js - 使用组件组合方式重构
  feat: 创建 ChatArea 组件(含 MessageRenderer、ExecutionStepsDisplay 子组件)
  feat:拆分工具函数
  feat: 拆分BackgroundEffects 背景渐变装饰层
  feat: RightSidebar (~420 行) - 模型/工具/统计 Tab 面板(单文件)
  feat:  LeftSidebar (~280 行) - 对话历史列表 + 用户信息卡片
  feat: 修复bug
  pref:移除黑夜模式
  feat: 修复警告
  feat: 提取常量配置
  feat: 修复ts报错
  feat:  StockChartModal.tsx 替换 KLine 实现
  update pay function
  update pay function
  update pay function
  update pay function
  ...
This commit is contained in:
zdl
2025-11-24 16:32:24 +08:00
159 changed files with 22541 additions and 11876 deletions

View File

@@ -0,0 +1,309 @@
/**
* 图片灯箱组件
* 点击图片放大查看
*/
import React, { useState } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalBody,
ModalCloseButton,
Image,
Box,
IconButton,
HStack,
useDisclosure,
} from '@chakra-ui/react';
import { ChevronLeft, ChevronRight, X, ZoomIn } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
const MotionBox = motion(Box);
/**
* 单图片灯箱
*/
export const ImageLightbox = ({ src, alt, ...props }) => {
const { isOpen, onOpen, onClose } = useDisclosure();
return (
<>
{/* 缩略图 */}
<Box
position="relative"
cursor="pointer"
onClick={onOpen}
_hover={{
'& .zoom-icon': {
opacity: 1,
},
}}
{...props}
>
<Image
src={src}
alt={alt}
w="100%"
h="100%"
objectFit="cover"
borderRadius="md"
transition="all 0.3s"
_hover={{
transform: 'scale(1.05)',
filter: 'brightness(0.8)',
}}
/>
{/* 放大图标 */}
<Box
className="zoom-icon"
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
opacity={0}
transition="opacity 0.3s"
pointerEvents="none"
>
<Box
bg="blackAlpha.700"
borderRadius="full"
p="3"
>
<ZoomIn size={32} color="white" />
</Box>
</Box>
</Box>
{/* 灯箱模态框 */}
<Modal isOpen={isOpen} onClose={onClose} size="full" isCentered>
<ModalOverlay bg="blackAlpha.900" backdropFilter="blur(10px)" />
<ModalContent bg="transparent" boxShadow="none">
<ModalCloseButton
position="fixed"
top="4"
right="4"
size="lg"
color="white"
bg="blackAlpha.600"
_hover={{ bg: 'blackAlpha.800' }}
borderRadius="full"
zIndex={2}
/>
<ModalBody display="flex" alignItems="center" justifyContent="center" p="0">
<MotionBox
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.3 }}
maxW="90vw"
maxH="90vh"
>
<Image
src={src}
alt={alt}
maxW="100%"
maxH="90vh"
objectFit="contain"
borderRadius="lg"
/>
</MotionBox>
</ModalBody>
</ModalContent>
</Modal>
</>
);
};
/**
* 多图片轮播灯箱
*/
export const ImageGalleryLightbox = ({ images, initialIndex = 0, ...props }) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const handleOpen = (index) => {
setCurrentIndex(index);
onOpen();
};
const handlePrev = () => {
setCurrentIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1));
};
const handleNext = () => {
setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1));
};
const handleKeyDown = (e) => {
if (e.key === 'ArrowLeft') handlePrev();
if (e.key === 'ArrowRight') handleNext();
if (e.key === 'Escape') onClose();
};
return (
<>
{/* 缩略图网格 */}
<HStack spacing="2" flexWrap="wrap" {...props}>
{images.map((image, index) => (
<Box
key={index}
position="relative"
cursor="pointer"
onClick={() => handleOpen(index)}
_hover={{
'& .zoom-icon': {
opacity: 1,
},
}}
>
<Image
src={image.src || image}
alt={image.alt || `图片 ${index + 1}`}
w="150px"
h="150px"
objectFit="cover"
borderRadius="md"
transition="all 0.3s"
_hover={{
transform: 'scale(1.05)',
filter: 'brightness(0.8)',
}}
/>
{/* 放大图标 */}
<Box
className="zoom-icon"
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
opacity={0}
transition="opacity 0.3s"
pointerEvents="none"
>
<Box bg="blackAlpha.700" borderRadius="full" p="2">
<ZoomIn size={24} color="white" />
</Box>
</Box>
</Box>
))}
</HStack>
{/* 灯箱模态框(带轮播) */}
<Modal
isOpen={isOpen}
onClose={onClose}
size="full"
isCentered
onKeyDown={handleKeyDown}
>
<ModalOverlay bg="blackAlpha.900" backdropFilter="blur(10px)" />
<ModalContent bg="transparent" boxShadow="none">
{/* 关闭按钮 */}
<IconButton
icon={<X />}
position="fixed"
top="4"
right="4"
size="lg"
color="white"
bg="blackAlpha.600"
_hover={{ bg: 'blackAlpha.800' }}
borderRadius="full"
zIndex={2}
onClick={onClose}
/>
<ModalBody
display="flex"
alignItems="center"
justifyContent="center"
p="0"
position="relative"
>
{/* 左箭头 */}
{images.length > 1 && (
<IconButton
icon={<ChevronLeft />}
position="absolute"
left="4"
top="50%"
transform="translateY(-50%)"
size="lg"
color="white"
bg="blackAlpha.600"
_hover={{ bg: 'blackAlpha.800' }}
borderRadius="full"
zIndex={2}
onClick={handlePrev}
/>
)}
{/* 图片 */}
<AnimatePresence mode="wait">
<MotionBox
key={currentIndex}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.3 }}
maxW="90vw"
maxH="90vh"
>
<Image
src={images[currentIndex].src || images[currentIndex]}
alt={images[currentIndex].alt || `图片 ${currentIndex + 1}`}
maxW="100%"
maxH="90vh"
objectFit="contain"
borderRadius="lg"
/>
</MotionBox>
</AnimatePresence>
{/* 右箭头 */}
{images.length > 1 && (
<IconButton
icon={<ChevronRight />}
position="absolute"
right="4"
top="50%"
transform="translateY(-50%)"
size="lg"
color="white"
bg="blackAlpha.600"
_hover={{ bg: 'blackAlpha.800' }}
borderRadius="full"
zIndex={2}
onClick={handleNext}
/>
)}
{/* 图片计数 */}
{images.length > 1 && (
<Box
position="absolute"
bottom="4"
left="50%"
transform="translateX(-50%)"
bg="blackAlpha.700"
color="white"
px="4"
py="2"
borderRadius="full"
fontSize="sm"
fontWeight="600"
>
{currentIndex + 1} / {images.length}
</Box>
)}
</ModalBody>
</ModalContent>
</Modal>
</>
);
};
export default ImageLightbox;

View File

@@ -0,0 +1,270 @@
/**
* 图片预览弹窗组件
* 支持多张图片左右切换、缩放、下载
*/
import React, { useState } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalBody,
ModalCloseButton,
Image,
IconButton,
HStack,
Text,
Box,
} from '@chakra-ui/react';
import { ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
const MotionBox = motion(Box);
const ImagePreviewModal = ({ isOpen, onClose, images = [], initialIndex = 0 }) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [scale, setScale] = useState(1);
// 切换到上一张
const handlePrevious = () => {
setCurrentIndex((prev) => (prev - 1 + images.length) % images.length);
setScale(1); // 重置缩放
};
// 切换到下一张
const handleNext = () => {
setCurrentIndex((prev) => (prev + 1) % images.length);
setScale(1); // 重置缩放
};
// 放大
const handleZoomIn = () => {
setScale((prev) => Math.min(prev + 0.25, 3));
};
// 缩小
const handleZoomOut = () => {
setScale((prev) => Math.max(prev - 0.25, 0.5));
};
// 下载图片
const handleDownload = () => {
const link = document.createElement('a');
link.href = images[currentIndex];
link.download = `image-${currentIndex + 1}.jpg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// 键盘快捷键
React.useEffect(() => {
const handleKeyDown = (e) => {
if (!isOpen) return;
switch (e.key) {
case 'ArrowLeft':
handlePrevious();
break;
case 'ArrowRight':
handleNext();
break;
case 'Escape':
onClose();
break;
case '+':
case '=':
handleZoomIn();
break;
case '-':
handleZoomOut();
break;
default:
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, currentIndex]);
// 关闭时重置状态
const handleClose = () => {
setScale(1);
setCurrentIndex(initialIndex);
onClose();
};
if (!images || images.length === 0) return null;
return (
<Modal isOpen={isOpen} onClose={handleClose} size="full" isCentered>
<ModalOverlay bg="blackAlpha.900" backdropFilter="blur(10px)" />
<ModalContent bg="transparent" boxShadow="none" m="0">
<ModalCloseButton
size="lg"
color="white"
bg="blackAlpha.600"
_hover={{ bg: 'blackAlpha.800' }}
zIndex="2"
top="20px"
right="20px"
/>
<ModalBody
display="flex"
alignItems="center"
justifyContent="center"
position="relative"
p="0"
>
{/* 图片显示区域 */}
<AnimatePresence mode="wait">
<MotionBox
key={currentIndex}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.3 }}
display="flex"
alignItems="center"
justifyContent="center"
maxH="90vh"
maxW="90vw"
>
<Image
src={images[currentIndex]}
alt={`图片 ${currentIndex + 1}`}
maxH="90vh"
maxW="90vw"
objectFit="contain"
transform={`scale(${scale})`}
transition="transform 0.3s"
cursor={scale > 1 ? 'grab' : 'default'}
userSelect="none"
/>
</MotionBox>
</AnimatePresence>
{/* 左右切换按钮(仅多张图片时显示) */}
{images.length > 1 && (
<>
<IconButton
icon={<ChevronLeft size={32} />}
position="absolute"
left="20px"
top="50%"
transform="translateY(-50%)"
onClick={handlePrevious}
size="lg"
borderRadius="full"
bg="blackAlpha.600"
color="white"
_hover={{ bg: 'blackAlpha.800', transform: 'translateY(-50%) scale(1.1)' }}
_active={{ transform: 'translateY(-50%) scale(0.95)' }}
aria-label="上一张"
zIndex="2"
/>
<IconButton
icon={<ChevronRight size={32} />}
position="absolute"
right="20px"
top="50%"
transform="translateY(-50%)"
onClick={handleNext}
size="lg"
borderRadius="full"
bg="blackAlpha.600"
color="white"
_hover={{ bg: 'blackAlpha.800', transform: 'translateY(-50%) scale(1.1)' }}
_active={{ transform: 'translateY(-50%) scale(0.95)' }}
aria-label="下一张"
zIndex="2"
/>
</>
)}
{/* 底部工具栏 */}
<Box
position="absolute"
bottom="30px"
left="50%"
transform="translateX(-50%)"
bg="blackAlpha.700"
borderRadius="full"
px="6"
py="3"
backdropFilter="blur(10px)"
zIndex="2"
>
<HStack spacing="4">
{/* 缩放控制 */}
<HStack spacing="2">
<IconButton
icon={<ZoomOut size={18} />}
size="sm"
variant="ghost"
color="white"
onClick={handleZoomOut}
isDisabled={scale <= 0.5}
_hover={{ bg: 'whiteAlpha.200' }}
aria-label="缩小"
/>
<Text color="white" fontSize="sm" fontWeight="500" minW="60px" textAlign="center">
{Math.round(scale * 100)}%
</Text>
<IconButton
icon={<ZoomIn size={18} />}
size="sm"
variant="ghost"
color="white"
onClick={handleZoomIn}
isDisabled={scale >= 3}
_hover={{ bg: 'whiteAlpha.200' }}
aria-label="放大"
/>
</HStack>
{/* 下载按钮 */}
<IconButton
icon={<Download size={18} />}
size="sm"
variant="ghost"
color="white"
onClick={handleDownload}
_hover={{ bg: 'whiteAlpha.200' }}
aria-label="下载图片"
/>
{/* 图片计数(仅多张图片时显示) */}
{images.length > 1 && (
<Text color="white" fontSize="sm" fontWeight="500">
{currentIndex + 1} / {images.length}
</Text>
)}
</HStack>
</Box>
{/* 快捷键提示 */}
<Box
position="absolute"
top="80px"
left="20px"
bg="blackAlpha.600"
borderRadius="md"
px="4"
py="2"
backdropFilter="blur(10px)"
>
<Text color="whiteAlpha.800" fontSize="xs">
快捷键: 切换 | + - 缩放 | ESC 关闭
</Text>
</Box>
</ModalBody>
</ModalContent>
</Modal>
);
};
export default ImagePreviewModal;

View File

@@ -54,13 +54,11 @@ import { WatchlistMenu, FollowingEventsMenu } from './components/FeatureMenus';
import { useWatchlist } from '../../hooks/useWatchlist';
import { useFollowingEvents } from '../../hooks/useFollowingEvents';
// Phase 7 优化: 提取的二级导航、资料完整性、右侧功能区组件
import SecondaryNav from './components/SecondaryNav';
// Phase 7 优化: 提取的资料完整性、右侧功能区组件
import ProfileCompletenessAlert from './components/ProfileCompletenessAlert';
import { useProfileCompleteness } from '../../hooks/useProfileCompleteness';
import NavbarActions from './components/NavbarActions';
// Phase 7: SecondaryNav 组件已提取到 ./components/SecondaryNav/index.js
// Phase 4: MoreNavMenu 和 NavItems 组件已提取到 Navigation 目录
export default function HomeNavbar() {
@@ -152,8 +150,10 @@ export default function HomeNavbar() {
)}
<Box
position="sticky"
position="fixed"
top={showCompletenessAlert ? "60px" : 0}
left={0}
right={0}
zIndex={1000}
bg={navbarBg}
backdropFilter="blur(10px)"
@@ -199,9 +199,6 @@ export default function HomeNavbar() {
/>
</Box>
{/* 二级导航栏 - 显示当前页面所属的二级菜单 */}
{!isMobile && <SecondaryNav showCompletenessAlert={showCompletenessAlert} />}
{/* 投资日历 Modal - 已移至 CalendarButton 组件内部 */}
</>
);

View File

@@ -41,9 +41,6 @@ const NavbarActions = memo(({
}) => {
return (
<HStack spacing={{ base: 2, md: 4 }}>
{/* 主题切换按钮 */}
<ThemeToggleButton />
{/* 显示加载状态 */}
{isLoading ? (
<Spinner size="sm" color="blue.500" />

View File

@@ -1,144 +0,0 @@
// src/components/Navbars/components/SecondaryNav/config.js
// 二级导航配置数据
/**
* 二级导航配置结构
* - key: 匹配的路径前缀
* - title: 导航组标题
* - items: 导航项列表
* - path: 路径
* - label: 显示文本
* - badges: 徽章列表 (可选)
* - external: 是否外部链接 (可选)
*/
export const secondaryNavConfig = {
'/community': {
title: '高频跟踪',
items: [
{
path: '/community',
label: '事件中心',
badges: [
{ text: 'HOT', colorScheme: 'green' },
{ text: 'NEW', colorScheme: 'red' }
]
},
{
path: '/concepts',
label: '概念中心',
badges: [{ text: 'NEW', colorScheme: 'red' }]
},
{
path: '/data-browser',
label: '数据浏览器',
badges: [{ text: 'NEW', colorScheme: 'red' }]
}
]
},
'/concepts': {
title: '高频跟踪',
items: [
{
path: '/community',
label: '事件中心',
badges: [
{ text: 'HOT', colorScheme: 'green' },
{ text: 'NEW', colorScheme: 'red' }
]
},
{
path: '/concepts',
label: '概念中心',
badges: [{ text: 'NEW', colorScheme: 'red' }]
},
{
path: '/data-browser',
label: '数据浏览器',
badges: [{ text: 'NEW', colorScheme: 'red' }]
}
]
},
'/data-browser': {
title: '高频跟踪',
items: [
{
path: '/community',
label: '事件中心',
badges: [
{ text: 'HOT', colorScheme: 'green' },
{ text: 'NEW', colorScheme: 'red' }
]
},
{
path: '/concepts',
label: '概念中心',
badges: [{ text: 'NEW', colorScheme: 'red' }]
},
{
path: '/data-browser',
label: '数据浏览器',
badges: [{ text: 'NEW', colorScheme: 'red' }]
}
]
},
'/limit-analyse': {
title: '行情复盘',
items: [
{
path: '/limit-analyse',
label: '涨停分析',
badges: [{ text: 'FREE', colorScheme: 'blue' }]
},
{
path: '/stocks',
label: '个股中心',
badges: [{ text: 'HOT', colorScheme: 'green' }]
},
{
path: '/trading-simulation',
label: '模拟盘',
badges: [{ text: 'NEW', colorScheme: 'red' }]
}
]
},
'/stocks': {
title: '行情复盘',
items: [
{
path: '/limit-analyse',
label: '涨停分析',
badges: [{ text: 'FREE', colorScheme: 'blue' }]
},
{
path: '/stocks',
label: '个股中心',
badges: [{ text: 'HOT', colorScheme: 'green' }]
},
{
path: '/trading-simulation',
label: '模拟盘',
badges: [{ text: 'NEW', colorScheme: 'red' }]
}
]
},
'/trading-simulation': {
title: '行情复盘',
items: [
{
path: '/limit-analyse',
label: '涨停分析',
badges: [{ text: 'FREE', colorScheme: 'blue' }]
},
{
path: '/stocks',
label: '个股中心',
badges: [{ text: 'HOT', colorScheme: 'green' }]
},
{
path: '/trading-simulation',
label: '模拟盘',
badges: [{ text: 'NEW', colorScheme: 'red' }]
}
]
}
};

View File

@@ -1,138 +0,0 @@
// src/components/Navbars/components/SecondaryNav/index.js
// 二级导航栏组件 - 显示当前一级菜单下的所有二级菜单项
import React, { memo } from 'react';
import {
Box,
Container,
HStack,
Text,
Button,
Flex,
Badge,
useColorModeValue
} from '@chakra-ui/react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigationEvents } from '../../../../hooks/useNavigationEvents';
import { secondaryNavConfig } from './config';
/**
* 二级导航栏组件
* 根据当前路径显示对应的二级菜单项
*
* @param {Object} props
* @param {boolean} props.showCompletenessAlert - 是否显示完整性提醒(影响 sticky top 位置)
*/
const SecondaryNav = memo(({ showCompletenessAlert }) => {
const navigate = useNavigate();
const location = useLocation();
// 颜色模式
const navbarBg = useColorModeValue('gray.50', 'gray.700');
const itemHoverBg = useColorModeValue('white', 'gray.600');
const borderColorValue = useColorModeValue('gray.200', 'gray.600');
// 导航埋点
const navEvents = useNavigationEvents({ component: 'secondary_nav' });
// 找到当前路径对应的二级导航配置
const currentConfig = Object.keys(secondaryNavConfig).find(key =>
location.pathname.includes(key)
);
// 如果没有匹配的二级导航,不显示
if (!currentConfig) return null;
const config = secondaryNavConfig[currentConfig];
return (
<Box
bg={navbarBg}
borderBottom="1px"
borderColor={borderColorValue}
py={2}
position="sticky"
top={showCompletenessAlert ? "120px" : "60px"}
zIndex={100}
>
<Container maxW="container.xl" px={4}>
<HStack spacing={1}>
{/* 显示一级菜单标题 */}
<Text fontSize="sm" color="gray.500" mr={2}>
{config.title}:
</Text>
{/* 二级菜单项 */}
{config.items.map((item, index) => {
const isActive = location.pathname.includes(item.path);
return item.external ? (
<Button
key={index}
as="a"
href={item.path}
size="sm"
variant="ghost"
bg="transparent"
color="inherit"
fontWeight="normal"
_hover={{ bg: itemHoverBg }}
borderRadius="md"
px={3}
>
<Flex align="center" gap={2}>
<Text>{item.label}</Text>
{item.badges && item.badges.length > 0 && (
<HStack spacing={1}>
{item.badges.map((badge, bIndex) => (
<Badge key={bIndex} size="xs" colorScheme={badge.colorScheme}>
{badge.text}
</Badge>
))}
</HStack>
)}
</Flex>
</Button>
) : (
<Button
key={index}
onClick={() => {
// 追踪侧边栏菜单点击
navEvents.trackSidebarMenuClicked(item.label, item.path, 2, false);
navigate(item.path);
}}
size="sm"
variant="ghost"
bg={isActive ? 'blue.50' : 'transparent'}
color={isActive ? 'blue.600' : 'inherit'}
fontWeight={isActive ? 'bold' : 'normal'}
borderBottom={isActive ? '2px solid' : 'none'}
borderColor="blue.600"
borderRadius={isActive ? '0' : 'md'}
_hover={{ bg: isActive ? 'blue.100' : itemHoverBg }}
px={3}
>
<Flex align="center" gap={2}>
<Text>{item.label}</Text>
{item.badges && item.badges.length > 0 && (
<HStack spacing={1}>
{item.badges.map((badge, bIndex) => (
<Badge key={bIndex} size="xs" colorScheme={badge.colorScheme}>
{badge.text}
</Badge>
))}
</HStack>
)}
</Flex>
</Button>
);
})}
</HStack>
</Container>
</Box>
);
});
SecondaryNav.displayName = 'SecondaryNav';
export default SecondaryNav;

View File

@@ -1,51 +0,0 @@
// src/components/Navbars/components/ThemeToggleButton.js
// 主题切换按钮组件 - Phase 7 优化:添加导航埋点支持
import React, { memo } from 'react';
import { IconButton, useColorMode } from '@chakra-ui/react';
import { SunIcon, MoonIcon } from '@chakra-ui/icons';
import { useNavigationEvents } from '../../../hooks/useNavigationEvents';
/**
* 主题切换按钮组件
* 支持在亮色和暗色主题之间切换,包含导航埋点
*
* 性能优化:
* - 使用 memo 避免父组件重新渲染时的不必要更新
* - 只依赖 colorMode当主题切换时才重新渲染
*
* @param {Object} props
* @param {string} props.size - 按钮大小,默认 'sm'
* @param {string} props.variant - 按钮样式,默认 'ghost'
* @returns {JSX.Element}
*/
const ThemeToggleButton = memo(({ size = 'sm', variant = 'ghost' }) => {
const { colorMode, toggleColorMode } = useColorMode();
const navEvents = useNavigationEvents({ component: 'theme_toggle' });
const handleToggle = () => {
// 追踪主题切换
const fromTheme = colorMode;
const toTheme = colorMode === 'light' ? 'dark' : 'light';
navEvents.trackThemeChanged(fromTheme, toTheme);
// 切换主题
toggleColorMode();
};
return (
<IconButton
aria-label="切换主题"
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
onClick={handleToggle}
variant={variant}
size={size}
minW={{ base: '36px', md: '40px' }}
minH={{ base: '36px', md: '40px' }}
/>
);
});
ThemeToggleButton.displayName = 'ThemeToggleButton';
export default ThemeToggleButton;

View File

@@ -1,138 +0,0 @@
import { useRef, useState } from "react";
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
import Button from "@/components/Button";
import Image from "@/components/Image";
import { pricing } from "@/mocks/pricing";
type PricingListProps = {
monthly?: boolean;
};
const PricingList = ({ monthly = true }: PricingListProps) => {
const [activeIndex, setActiveIndex] = useState<number>(0);
const ref = useRef<any>(null);
const handleClick = (index: number) => {
setActiveIndex(index);
ref.current?.go(index);
};
return (
<Splide
className="splide-pricing splide-visible"
options={{
mediaQuery: "min",
autoWidth: true,
pagination: false,
arrows: false,
gap: "1rem",
breakpoints: {
1024: {
destroy: true,
},
},
}}
onMoved={(e, newIndex) => setActiveIndex(newIndex)}
hasTrack={false}
ref={ref}
>
<SplideTrack>
{pricing.map((item, index) => (
<SplideSlide
className={`${index === 1 ? "" : "py-3"}`}
key={item.id}
>
<div
className={`w-[19rem] h-full px-6 ${
index === 1 ? "py-12" : "py-8"
} bg-n-8 border border-n-6 rounded-[2rem] lg:w-auto`}
key={item.id}
>
<h4
className={`h4 mb-4 ${
index === 0 ? "text-color-2" : ""
} ${index === 1 ? "text-color-1" : ""} ${
index === 2 ? "text-color-3" : ""
}`}
>
{item.title}
</h4>
<p className="body-2 min-h-[4rem] mb-3 text-n-1/50">
{item.description}
</p>
<div className="flex items-center h-[5.5rem] mb-6">
{item.price && (
<>
<div className="h3">$</div>
<div className="text-[5.5rem] leading-none font-bold">
{monthly
? item.price
: item.price !== "0"
? (
+item.price *
12 *
0.9
).toFixed(1)
: item.price}
</div>
</>
)}
</div>
<Button
className="w-full mb-6"
href={
item.price
? "/pricing"
: "mailto:info@ui8.net"
}
white={!!item.price}
>
{item.price ? "Get started" : "Contact us"}
</Button>
<ul>
{item.features.map((feature, index) => (
<li
className="flex items-start py-5 border-t border-n-6"
key={index}
>
<Image
src="/images/check.svg"
width={24}
height={24}
alt="Check"
/>
<p className="body-2 ml-4">{feature}</p>
</li>
))}
</ul>
</div>
</SplideSlide>
))}
</SplideTrack>
<div className="flex justify-center mt-8 -mx-2 md:mt-15 lg:hidden">
{pricing.map((item, index) => (
<button
className="relative w-6 h-6 mx-2"
onClick={() => handleClick(index)}
key={item.id}
>
<span
className={`absolute inset-0 bg-conic-gradient rounded-full transition-opacity ${
index === activeIndex
? "opacity-100"
: "opacity-0"
}`}
></span>
<span className="absolute inset-0.25 bg-n-8 rounded-full">
<span className="absolute inset-2 bg-n-1 rounded-full"></span>
</span>
</button>
))}
</div>
</Splide>
);
};
export default PricingList;

View File

@@ -1,138 +0,0 @@
import { useRef, useState } from "react";
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
import Button from "@/components/Button";
import Image from "@/components/Image";
import { pricing } from "@/mocks/pricing";
type PricingListProps = {
monthly?: boolean;
};
const PricingList = ({ monthly = true }: PricingListProps) => {
const [activeIndex, setActiveIndex] = useState<number>(0);
const ref = useRef<any>(null);
const handleClick = (index: number) => {
setActiveIndex(index);
ref.current?.go(index);
};
return (
<Splide
className="splide-pricing splide-visible"
options={{
mediaQuery: "min",
autoWidth: true,
pagination: false,
arrows: false,
gap: "1rem",
breakpoints: {
1024: {
destroy: true,
},
},
}}
onMoved={(e, newIndex) => setActiveIndex(newIndex)}
hasTrack={false}
ref={ref}
>
<SplideTrack>
{pricing.map((item, index) => (
<SplideSlide
className={`${index === 1 ? "" : "py-3"}`}
key={item.id}
>
<div
className={`w-[19rem] h-full px-6 ${
index === 1 ? "py-12" : "py-8"
} bg-n-8 border border-n-6 rounded-[2rem] lg:w-auto`}
key={item.id}
>
<h4
className={`h4 mb-4 ${
index === 0 ? "text-color-2" : ""
} ${index === 1 ? "text-color-1" : ""} ${
index === 2 ? "text-color-3" : ""
}`}
>
{item.title}
</h4>
<p className="body-2 min-h-[4rem] mb-3 text-n-1/50">
{item.description}
</p>
<div className="flex items-center h-[5.5rem] mb-6">
{item.price && (
<>
<div className="h3">$</div>
<div className="text-[5.5rem] leading-none font-bold">
{monthly
? item.price
: item.price !== "0"
? (
+item.price *
12 *
0.9
).toFixed(1)
: item.price}
</div>
</>
)}
</div>
<Button
className="w-full mb-6"
href={
item.price
? "/pricing"
: "mailto:info@ui8.net"
}
white={!!item.price}
>
{item.price ? "Get started" : "Contact us"}
</Button>
<ul>
{item.features.map((feature, index) => (
<li
className="flex items-start py-5 border-t border-n-6"
key={index}
>
<Image
src="/images/check.svg"
width={24}
height={24}
alt="Check"
/>
<p className="body-2 ml-4">{feature}</p>
</li>
))}
</ul>
</div>
</SplideSlide>
))}
</SplideTrack>
<div className="flex justify-center mt-8 -mx-2 md:mt-15 lg:hidden">
{pricing.map((item, index) => (
<button
className="relative w-6 h-6 mx-2"
onClick={() => handleClick(index)}
key={item.id}
>
<span
className={`absolute inset-0 bg-conic-gradient rounded-full transition-opacity ${
index === activeIndex
? "opacity-100"
: "opacity-0"
}`}
></span>
<span className="absolute inset-0.25 bg-n-8 rounded-full">
<span className="absolute inset-2 bg-n-1 rounded-full"></span>
</span>
</button>
))}
</div>
</Splide>
);
};
export default PricingList;

View File

@@ -0,0 +1,287 @@
/**
* StockChartKLineModal - K 线图表模态框组件
*
* 使用 KLineChart 库实现的专业金融图表组件
* 替换原有的 ECharts 实现StockChartAntdModal.js
*/
import React, { useState, useCallback, useMemo } from 'react';
import { Modal, Button, Radio, Select, Space, Spin, Alert } from 'antd';
import type { RadioChangeEvent } from 'antd';
import {
LineChartOutlined,
BarChartOutlined,
SettingOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { Box } from '@chakra-ui/react';
// 自定义 Hooks
import { useKLineChart, useKLineData, useEventMarker } from './hooks';
// 类型定义
import type { ChartType, StockInfo } from './types';
// 配置常量
import {
CHART_TYPE_CONFIG,
CHART_HEIGHTS,
INDICATORS,
DEFAULT_SUB_INDICATORS,
} from './config';
// 工具函数
import { createSubIndicators } from './utils';
// 日志
import { logger } from '@utils/logger';
// ==================== 组件 Props ====================
export interface StockChartKLineModalProps {
/** 是否显示模态框 */
visible: boolean;
/** 关闭模态框回调 */
onClose: () => void;
/** 股票信息 */
stock: StockInfo;
/** 事件时间ISO 字符串,可选) */
eventTime?: string;
/** 事件标题(用于标记标签,可选) */
eventTitle?: string;
}
// ==================== 主组件 ====================
const StockChartKLineModal: React.FC<StockChartKLineModalProps> = ({
visible,
onClose,
stock,
eventTime,
eventTitle,
}) => {
// ==================== 状态管理 ====================
/** 图表类型(分时图/日K线 */
const [chartType, setChartType] = useState<ChartType>('daily');
/** 选中的副图指标 */
const [selectedIndicators, setSelectedIndicators] = useState<string[]>(
DEFAULT_SUB_INDICATORS
);
// ==================== 自定义 Hooks ====================
/** 图表实例管理 */
const { chart, chartRef, isInitialized, error: chartError } = useKLineChart({
containerId: `kline-chart-${stock.stock_code}`,
height: CHART_HEIGHTS.main,
autoResize: true,
});
/** 数据加载管理 */
const {
data,
loading: dataLoading,
error: dataError,
loadData,
} = useKLineData({
chart,
stockCode: stock.stock_code,
chartType,
eventTime,
autoLoad: visible, // 模态框打开时自动加载
});
/** 事件标记管理 */
const { marker } = useEventMarker({
chart,
data,
eventTime,
eventTitle,
autoCreate: true,
});
// ==================== 事件处理 ====================
/**
* 切换图表类型(分时图 ↔ 日K线
*/
const handleChartTypeChange = useCallback((e: RadioChangeEvent) => {
const newType = e.target.value as ChartType;
setChartType(newType);
logger.debug('StockChartKLineModal', 'handleChartTypeChange', '切换图表类型', {
newType,
});
}, []);
/**
* 切换副图指标
*/
const handleIndicatorChange = useCallback(
(values: string[]) => {
setSelectedIndicators(values);
if (!chart) {
return;
}
// 先移除所有副图指标KLineChart 会自动移除)
// 然后创建新的指标
createSubIndicators(chart, values);
logger.debug('StockChartKLineModal', 'handleIndicatorChange', '切换副图指标', {
indicators: values,
});
},
[chart]
);
/**
* 刷新数据
*/
const handleRefresh = useCallback(() => {
loadData();
logger.debug('StockChartKLineModal', 'handleRefresh', '刷新数据');
}, [loadData]);
// ==================== 计算属性 ====================
/** 是否有错误 */
const hasError = useMemo(() => {
return !!chartError || !!dataError;
}, [chartError, dataError]);
/** 错误消息 */
const errorMessage = useMemo(() => {
if (chartError) {
return `图表初始化失败: ${chartError.message}`;
}
if (dataError) {
return `数据加载失败: ${dataError.message}`;
}
return null;
}, [chartError, dataError]);
/** 模态框标题 */
const modalTitle = useMemo(() => {
return `${stock.stock_name}${stock.stock_code} - ${CHART_TYPE_CONFIG[chartType].label}`;
}, [stock, chartType]);
/** 是否显示加载状态 */
const showLoading = useMemo(() => {
return dataLoading || !isInitialized;
}, [dataLoading, isInitialized]);
// ==================== 副作用 ====================
// 无副作用,都在 Hooks 中管理
// ==================== 渲染 ====================
return (
<Modal
title={modalTitle}
open={visible}
onCancel={onClose}
width={1200}
footer={null}
centered
destroyOnClose // 关闭时销毁组件(释放图表资源)
>
{/* 工具栏 */}
<Box mb={4}>
<Space wrap>
{/* 图表类型切换 */}
<Radio.Group value={chartType} onChange={handleChartTypeChange}>
<Radio.Button value="timeline">
<LineChartOutlined />
</Radio.Button>
<Radio.Button value="daily">
<BarChartOutlined /> K线
</Radio.Button>
</Radio.Group>
{/* 副图指标选择 */}
<Select
mode="multiple"
placeholder="选择副图指标"
value={selectedIndicators}
onChange={handleIndicatorChange}
style={{ minWidth: 200 }}
maxTagCount={2}
>
{INDICATORS.sub.map((indicator) => (
<Select.Option key={indicator.name} value={indicator.name}>
<SettingOutlined /> {indicator.label}
</Select.Option>
))}
</Select>
{/* 刷新按钮 */}
<Button
icon={<ReloadOutlined />}
onClick={handleRefresh}
loading={dataLoading}
>
</Button>
</Space>
</Box>
{/* 错误提示 */}
{hasError && (
<Alert
message="加载失败"
description={errorMessage}
type="error"
closable
showIcon
style={{ marginBottom: 16 }}
/>
)}
{/* 图表容器 */}
<Box position="relative">
{/* 加载遮罩 */}
{showLoading && (
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
zIndex={10}
>
<Spin size="large" tip="加载中..." />
</Box>
)}
{/* KLineChart 容器 */}
<div
ref={chartRef}
id={`kline-chart-${stock.stock_code}`}
style={{
width: '100%',
height: `${CHART_HEIGHTS.main}px`,
opacity: showLoading ? 0.5 : 1,
transition: 'opacity 0.3s',
}}
/>
</Box>
{/* 数据信息(调试用,生产环境可移除) */}
{process.env.NODE_ENV === 'development' && (
<Box mt={2} fontSize="12px" color="gray.500">
<Space split="|">
<span>: {data.length}</span>
<span>: {marker ? marker.label : '无'}</span>
<span>ID: {chart?.id || '未初始化'}</span>
</Space>
</Box>
)}
</Modal>
);
};
export default StockChartKLineModal;

View File

@@ -1,5 +1,5 @@
// src/components/StockChart/StockChartModal.js - 统一的股票图表组件
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, Button, ButtonGroup, VStack, HStack, Text, Badge, Box, Flex, CircularProgress } from '@chakra-ui/react';
import ReactECharts from 'echarts-for-react';
import * as echarts from 'echarts';
@@ -7,6 +7,7 @@ import dayjs from 'dayjs';
import { stockService } from '../../services/eventService';
import { logger } from '../../utils/logger';
import RiskDisclaimer from '../RiskDisclaimer';
import { RelationDescription } from '../StockRelation';
const StockChartModal = ({
isOpen,
@@ -14,34 +15,16 @@ const StockChartModal = ({
stock,
eventTime,
isChakraUI = true, // 是否使用Chakra UI默认true如果false则使用Antd
size = "6xl"
size = "6xl",
initialChartType = 'timeline' // 初始图表类型timeline/daily
}) => {
const chartRef = useRef(null);
const chartInstanceRef = useRef(null);
const [chartType, setChartType] = useState('timeline');
const [chartType, setChartType] = useState(initialChartType);
const [loading, setLoading] = useState(false);
const [chartData, setChartData] = useState(null);
const [preloadedData, setPreloadedData] = useState({});
// 处理关联描述(兼容对象和字符串格式)
const getRelationDesc = () => {
const relationDesc = stock?.relation_desc;
if (!relationDesc) return null;
if (typeof relationDesc === 'string') {
return relationDesc;
} else if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
// 新格式:{data: [{query_part: "...", sentences: "..."}]}
return relationDesc.data
.map(item => item.query_part || item.sentences || '')
.filter(s => s)
.join('') || null;
}
return null;
};
// 预加载数据
const preloadData = async (type) => {
if (!stock || preloadedData[type]) return;
@@ -539,7 +522,8 @@ const StockChartModal = ({
</ModalHeader>
<ModalCloseButton />
<ModalBody p={0} overflowY="auto" maxH="calc(90vh - 120px)">
<Box h="400px" w="100%" position="relative">
{/* 图表区域 */}
<Box h="500px" w="100%" position="relative">
{loading && (
<Flex
position="absolute"
@@ -558,27 +542,13 @@ const StockChartModal = ({
<div ref={chartRef} style={{ height: '100%', width: '100%', minHeight: '500px' }} />
</Box>
{getRelationDesc() && (
<Box p={4} borderTop="1px solid" borderTopColor="gray.200">
<Text fontSize="sm" fontWeight="bold" mb={2}>关联描述:</Text>
<Text fontSize="sm" color="gray.600">{getRelationDesc()}</Text>
</Box>
)}
{/* 关联描述 */}
<RelationDescription relationDesc={stock?.relation_desc} />
{/* 风险提示 */}
<Box px={4} pb={4}>
<RiskDisclaimer variant="default" />
</Box>
{process.env.NODE_ENV === 'development' && chartData && (
<Box p={4} bg="gray.50" fontSize="xs" color="gray.600">
<Text fontWeight="bold">调试信息:</Text>
<Text>数据条数: {chartData.data ? chartData.data.length : 0}</Text>
<Text>交易日期: {chartData.trade_date}</Text>
<Text>图表类型: {chartType}</Text>
<Text>原始事件时间: {eventTime}</Text>
</Box>
)}
</ModalBody>
</ModalContent>
</Modal>

View File

@@ -0,0 +1,213 @@
// src/components/StockChart/StockChartModal.tsx - 统一的股票图表组件KLineChart 实现)
import React, { useState } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
Button,
ButtonGroup,
VStack,
HStack,
Text,
Badge,
Box,
Flex,
CircularProgress,
} from '@chakra-ui/react';
import RiskDisclaimer from '../RiskDisclaimer';
import { RelationDescription } from '../StockRelation';
import type { RelationDescType } from '../StockRelation';
import { useKLineChart, useKLineData, useEventMarker } from './hooks';
import { Alert, AlertIcon } from '@chakra-ui/react';
/**
* 图表类型
*/
type ChartType = 'timeline' | 'daily';
/**
* 股票信息
*/
interface StockInfo {
stock_code: string;
stock_name?: string;
relation_desc?: RelationDescType;
}
/**
* StockChartModal 组件 Props
*/
export interface StockChartModalProps {
/** 模态框是否打开 */
isOpen: boolean;
/** 关闭回调 */
onClose: () => void;
/** 股票信息 */
stock: StockInfo | null;
/** 事件时间 */
eventTime?: string | null;
/** 是否使用 Chakra UI保留字段当前未使用 */
isChakraUI?: boolean;
/** 模态框大小 */
size?: string;
/** 初始图表类型 */
initialChartType?: ChartType;
}
const StockChartModal: React.FC<StockChartModalProps> = ({
isOpen,
onClose,
stock,
eventTime,
isChakraUI = true,
size = '6xl',
initialChartType = 'timeline',
}) => {
// 状态管理
const [chartType, setChartType] = useState<ChartType>(initialChartType);
// KLineChart Hooks
const { chart, chartRef, isInitialized, error: chartError } = useKLineChart({
containerId: `kline-chart-${stock?.stock_code || 'default'}`,
height: 500,
autoResize: true,
chartType, // ✅ 传递 chartType让 Hook 根据类型应用不同样式
});
const { data, loading, error: dataError } = useKLineData({
chart,
stockCode: stock?.stock_code || '',
chartType,
eventTime: eventTime || undefined,
autoLoad: true, // 改为 true让 Hook 内部根据 stockCode 和 chart 判断是否加载
});
const { marker } = useEventMarker({
chart,
data,
eventTime: eventTime || undefined,
eventTitle: '事件发生',
autoCreate: true,
});
// 守卫子句
if (!stock) return null;
return (
<Modal isOpen={isOpen} onClose={onClose} size={size}>
<ModalOverlay />
<ModalContent maxW="90vw" maxH="90vh" overflow="hidden">
<ModalHeader pb={4} position="relative">
<VStack align="flex-start" spacing={2}>
<HStack>
<Text fontSize="lg" fontWeight="bold">
{stock.stock_name || stock.stock_code} ({stock.stock_code}) -
</Text>
{data.length > 0 && <Badge colorScheme="blue">: {data.length}</Badge>}
</HStack>
<ButtonGroup size="sm">
<Button
variant={chartType === 'timeline' ? 'solid' : 'outline'}
onClick={() => setChartType('timeline')}
colorScheme="blue"
>
线
</Button>
<Button
variant={chartType === 'daily' ? 'solid' : 'outline'}
onClick={() => setChartType('daily')}
colorScheme="blue"
>
K线
</Button>
</ButtonGroup>
</VStack>
{/* 重件发生标签 - 仅在有 eventTime 时显示 */}
{eventTime && (
<Badge
colorScheme="yellow"
fontSize="sm"
px={3}
py={1}
borderRadius="md"
position="absolute"
top="4"
right="12"
boxShadow="sm"
>
()
</Badge>
)}
</ModalHeader>
<ModalCloseButton />
<ModalBody p={0} overflowY="auto" maxH="calc(90vh - 120px)">
{/* 错误提示 */}
{(chartError || dataError) && (
<Alert status="error" mx={4} mt={4}>
<AlertIcon />
{chartError?.message || dataError?.message}
</Alert>
)}
{/* 图表区域 - 响应式高度 */}
<Box
h={{
base: "calc(60vh - 100px)", // 移动端60% 视口高度 - 100px
md: "calc(70vh - 150px)", // 平板70% 视口高度 - 150px
lg: "calc(80vh - 200px)" // 桌面80% 视口高度 - 200px
}}
minH="350px" // 最小高度:确保可用性
maxH="650px" // 最大高度:避免过大
w="100%"
position="relative"
>
{loading && (
<Flex
position="absolute"
top="0"
left="0"
right="0"
bottom="0"
bg="rgba(255, 255, 255, 0.7)"
zIndex="10"
alignItems="center"
justifyContent="center"
>
<VStack spacing={4}>
<CircularProgress isIndeterminate color="blue.300" />
<Text>...</Text>
</VStack>
</Flex>
)}
<div
ref={chartRef}
id={`kline-chart-${stock.stock_code}`}
style={{ width: '100%', height: '100%' }}
/>
</Box>
{/* 关联描述 */}
<RelationDescription relationDesc={stock?.relation_desc} />
{/* 风险提示 */}
<Box px={4} pb={4}>
<RiskDisclaimer text="" variant="default" sx={{}} />
</Box>
</ModalBody>
</ModalContent>
</Modal>
);
};
export default StockChartModal;

View File

@@ -0,0 +1,205 @@
/**
* KLineChart 图表常量配置
*
* 包含图表默认配置、技术指标列表、事件标记配置等
*/
import type { ChartConfig, ChartType } from '../types';
/**
* 图表默认高度px
*/
export const CHART_HEIGHTS = {
/** 主图高度 */
main: 400,
/** 副图高度(技术指标) */
sub: 150,
/** 移动端主图高度 */
mainMobile: 300,
/** 移动端副图高度 */
subMobile: 100,
} as const;
/**
* 技术指标配置
*/
export const INDICATORS = {
/** 主图指标(叠加在 K 线图上) */
main: [
{
name: 'MA',
label: '均线',
params: [5, 10, 20, 30],
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A'],
},
{
name: 'EMA',
label: '指数移动平均',
params: [5, 10, 20, 30],
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A'],
},
{
name: 'BOLL',
label: '布林带',
params: [20, 2],
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1'],
},
],
/** 副图指标(单独窗口显示) */
sub: [
{
name: 'VOL',
label: '成交量',
params: [5, 10, 20],
colors: ['#ef5350', '#26a69a'],
},
{
name: 'MACD',
label: 'MACD',
params: [12, 26, 9],
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1'],
},
{
name: 'KDJ',
label: 'KDJ',
params: [9, 3, 3],
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1'],
},
{
name: 'RSI',
label: 'RSI',
params: [6, 12, 24],
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1'],
},
],
} as const;
/**
* 默认主图指标(初始显示)
*/
export const DEFAULT_MAIN_INDICATOR = 'MA';
/**
* 默认副图指标(初始显示)
*/
export const DEFAULT_SUB_INDICATORS = ['VOL', 'MACD'];
/**
* 图表类型配置
*/
export const CHART_TYPE_CONFIG: Record<ChartType, { label: string; dateFormat: string }> = {
timeline: {
label: '分时图',
dateFormat: 'HH:mm', // 时间格式09:30
},
daily: {
label: '日K线',
dateFormat: 'YYYY-MM-DD', // 日期格式2024-01-01
},
} as const;
/**
* 事件标记配置
*/
export const EVENT_MARKER_CONFIG = {
/** 默认颜色 */
defaultColor: '#ff9800',
/** 默认位置 */
defaultPosition: 'top' as const,
/** 默认图标 */
defaultIcon: '📌',
/** 标记大小 */
size: {
point: 8, // 标记点半径
icon: 20, // 图标大小
},
/** 文本配置 */
text: {
fontSize: 12,
fontFamily: 'Helvetica, Arial, sans-serif',
color: '#ffffff',
padding: 4,
borderRadius: 4,
},
} as const;
/**
* 数据加载配置
*/
export const DATA_LOADER_CONFIG = {
/** 最大数据点数(避免性能问题) */
maxDataPoints: 1000,
/** 初始加载数据点数 */
initialLoadCount: 100,
/** 加载更多时的数据点数 */
loadMoreCount: 50,
} as const;
/**
* 缩放配置
*/
export const ZOOM_CONFIG = {
/** 最小缩放比例(显示更多 K 线) */
minZoom: 0.5,
/** 最大缩放比例(显示更少 K 线) */
maxZoom: 2.0,
/** 默认缩放比例 */
defaultZoom: 1.0,
/** 缩放步长 */
zoomStep: 0.1,
} as const;
/**
* 默认图表配置
*/
export const DEFAULT_CHART_CONFIG: ChartConfig = {
type: 'daily',
showIndicators: true,
defaultIndicators: DEFAULT_SUB_INDICATORS,
height: CHART_HEIGHTS.main,
showGrid: true,
showCrosshair: true,
} as const;
/**
* 图表初始化选项(传递给 KLineChart.init
*/
export const CHART_INIT_OPTIONS = {
/** 时区(中国标准时间) */
timezone: 'Asia/Shanghai',
/** 语言 */
locale: 'zh-CN',
/** 自定义配置 */
customApi: {
formatDate: (timestamp: number, format: string) => {
// 可在此处自定义日期格式化逻辑
return new Date(timestamp).toLocaleString('zh-CN');
},
},
} as const;
/**
* 分时图特殊配置
*/
export const TIMELINE_CONFIG = {
/** 交易时段A 股) */
tradingSessions: [
{ start: '09:30', end: '11:30' }, // 上午
{ start: '13:00', end: '15:00' }, // 下午
],
/** 是否显示均价线 */
showAverageLine: true,
/** 均价线颜色 */
averageLineColor: '#FFB74D',
} as const;
/**
* 日K线特殊配置
*/
export const DAILY_KLINE_CONFIG = {
/** 最大显示天数 */
maxDays: 250, // 约一年交易日
/** 默认显示天数 */
defaultDays: 60,
} as const;

View File

@@ -0,0 +1,32 @@
/**
* StockChart 配置统一导出
*
* 使用方式:
* import { lightTheme, DEFAULT_CHART_CONFIG } from '@components/StockChart/config';
*/
// 主题配置(仅浅色主题)
export {
CHART_COLORS,
lightTheme,
// darkTheme, // ❌ 已删除深色主题
timelineTheme,
getTheme,
getTimelineTheme,
} from './klineTheme';
// 图表配置
export {
CHART_HEIGHTS,
INDICATORS,
DEFAULT_MAIN_INDICATOR,
DEFAULT_SUB_INDICATORS,
CHART_TYPE_CONFIG,
EVENT_MARKER_CONFIG,
DATA_LOADER_CONFIG,
ZOOM_CONFIG,
DEFAULT_CHART_CONFIG,
CHART_INIT_OPTIONS,
TIMELINE_CONFIG,
DAILY_KLINE_CONFIG,
} from './chartConfig';

View File

@@ -0,0 +1,370 @@
/**
* KLineChart 主题配置(仅浅色主题)
*
* 适配 klinecharts@10.0.0-beta1
* 参考: https://github.com/klinecharts/KLineChart/blob/main/docs/en-US/guide/styles.md
*
* ⚠️ 重要说明:
* - 本项目已移除深色模式支持2025-01
* - 应用通过 colorModeManager 强制使用浅色主题
* - 已删除 darkTheme 和 timelineThemeDark 配置
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
// ⚠️ 使用 any 类型绕过 KLineChart 类型定义的限制beta 版本类型不完整)
// import type { DeepPartial, Styles } from 'klinecharts'; // ⚠️ 未使用(保留以便将来扩展)
/**
* 图表主题颜色配置(浅色主题)
* ⚠️ 已移除深色模式相关颜色常量
*/
export const CHART_COLORS = {
// 涨跌颜色(中国市场习惯:红涨绿跌)
up: '#ef5350', // 上涨红色
down: '#26a69a', // 下跌绿色
neutral: '#888888', // 平盘灰色
// 主题色(继承自 Argon Dashboard
primary: '#1b3bbb', // Navy 500
secondary: '#728fea', // Navy 300
background: '#ffffff',
// 文本颜色
text: '#333333',
textSecondary: '#888888',
// 网格颜色
grid: '#e0e0e0',
// 边框颜色
border: '#e0e0e0',
// 事件标记颜色
eventMarker: '#ff9800',
eventMarkerText: '#ffffff',
};
/**
* 浅色主题配置(默认)
*/
export const lightTheme: any = {
candle: {
type: 'candle_solid', // 实心蜡烛图
bar: {
upColor: CHART_COLORS.up,
downColor: CHART_COLORS.down,
noChangeColor: CHART_COLORS.neutral,
},
priceMark: {
show: true,
high: {
color: CHART_COLORS.up,
},
low: {
color: CHART_COLORS.down,
},
},
tooltip: {
showRule: 'always',
showType: 'standard',
// labels: ['时间: ', '开: ', '收: ', '高: ', '低: ', '成交量: '], // ❌ KLineChart 类型不支持自定义 labels
text: {
size: 12,
family: 'Helvetica, Arial, sans-serif',
weight: 'normal',
color: CHART_COLORS.text,
},
},
},
indicator: {
tooltip: {
showRule: 'always',
showType: 'standard',
text: {
size: 12,
family: 'Helvetica, Arial, sans-serif',
weight: 'normal',
color: CHART_COLORS.text,
},
},
},
xAxis: {
axisLine: {
show: true,
color: CHART_COLORS.border,
},
tickLine: {
show: true,
length: 3,
color: CHART_COLORS.border,
},
tickText: {
show: true,
color: CHART_COLORS.textSecondary,
family: 'Helvetica, Arial, sans-serif',
weight: 'normal',
size: 12,
},
},
yAxis: {
axisLine: {
show: true,
color: CHART_COLORS.border,
},
tickLine: {
show: true,
length: 3,
color: CHART_COLORS.border,
},
tickText: {
show: true,
color: CHART_COLORS.textSecondary,
family: 'Helvetica, Arial, sans-serif',
weight: 'normal',
size: 12,
},
type: 'normal', // 'normal' | 'percentage' | 'log'
},
grid: {
show: true,
horizontal: {
show: true,
size: 1,
color: CHART_COLORS.grid,
style: 'dashed',
},
vertical: {
show: false, // 垂直网格线通常关闭,避免过于密集
},
},
separator: {
size: 1,
color: CHART_COLORS.border,
},
crosshair: {
show: true,
horizontal: {
show: true,
line: {
show: true,
style: 'dashed',
dashedValue: [4, 2], // ✅ 修复: 使用 dashedValue 而非 dashValue
size: 1,
color: CHART_COLORS.primary,
},
text: {
show: true,
color: '#ffffff', // 白色文字(十字线标签背景是深蓝色)
size: 12,
family: 'Helvetica, Arial, sans-serif',
weight: 'normal',
backgroundColor: CHART_COLORS.primary,
},
},
vertical: {
show: true,
line: {
show: true,
style: 'dashed',
dashedValue: [4, 2], // ✅ 修复: 使用 dashedValue 而非 dashValue
size: 1,
color: CHART_COLORS.primary,
},
text: {
show: true,
color: '#ffffff', // 白色文字(十字线标签背景是深蓝色)
size: 12,
family: 'Helvetica, Arial, sans-serif',
weight: 'normal',
backgroundColor: CHART_COLORS.primary,
},
},
},
overlay: {
// 事件标记覆盖层样式
point: {
color: CHART_COLORS.eventMarker,
borderColor: CHART_COLORS.eventMarker,
borderSize: 1,
radius: 5,
activeColor: CHART_COLORS.eventMarker,
activeBorderColor: CHART_COLORS.eventMarker,
activeBorderSize: 2,
activeRadius: 6,
},
line: {
style: 'solid',
smooth: false,
color: CHART_COLORS.eventMarker,
size: 1,
dashedValue: [2, 2],
},
text: {
style: 'fill',
color: CHART_COLORS.eventMarkerText,
size: 12,
family: 'Helvetica, Arial, sans-serif',
weight: 'normal',
offset: [0, 0],
},
rect: {
style: 'fill',
color: CHART_COLORS.eventMarker,
borderColor: CHART_COLORS.eventMarker,
borderSize: 1,
borderRadius: 4,
borderStyle: 'solid',
borderDashedValue: [2, 2],
},
},
};
// ❌ 已删除 darkTheme 配置(不再支持深色模式)
/**
* 分时图专用主题配置
* 特点面积图样式、均价线、百分比Y轴
*/
export const timelineTheme: any = {
...lightTheme,
candle: {
type: 'area', // ✅ 面积图模式(分时线)
area: {
lineSize: 2,
lineColor: CHART_COLORS.up, // 默认红色,实际会根据涨跌动态调整
value: 'close',
backgroundColor: [
{
offset: 0,
color: 'rgba(239, 83, 80, 0.2)', // 红色半透明渐变(顶部)
},
{
offset: 1,
color: 'rgba(239, 83, 80, 0.01)', // 红色几乎透明(底部)
},
],
},
priceMark: {
show: true,
high: {
show: false, // 分时图不显示最高最低价标记
},
low: {
show: false,
},
last: {
show: true,
upColor: CHART_COLORS.up,
downColor: CHART_COLORS.down,
noChangeColor: CHART_COLORS.neutral,
line: {
show: true,
style: 'dashed',
dashedValue: [4, 2], // ✅ 修复: 使用 dashedValue 而非 dashValue
size: 1,
},
text: {
show: true,
size: 12,
paddingLeft: 4,
paddingTop: 2,
paddingRight: 4,
paddingBottom: 2,
borderRadius: 2,
},
},
},
tooltip: {
showRule: 'always',
showType: 'standard',
// ❌ KLineChart 类型不支持自定义 labels 和 formatter需要在运行时通过 API 设置)
// labels: ['时间: ', '现价: ', '涨跌: ', '均价: ', '昨收: ', '成交量: '],
// formatter: (data: any, indicator: any) => { ... },
text: {
size: 12,
family: 'Helvetica, Arial, sans-serif',
weight: 'normal',
color: CHART_COLORS.text,
},
},
},
yAxis: {
...lightTheme.yAxis,
type: 'percentage', // ✅ 百分比模式
position: 'left', // Y轴在左侧
inside: false,
reverse: false,
tickText: {
...lightTheme.yAxis?.tickText,
// ❌ KLineChart 类型不支持自定义 formatter需要在运行时通过 API 设置)
// formatter: (value: any) => {
// const percent = (value * 100).toFixed(2);
// if (Math.abs(value) < 0.0001) return '0.00%';
// return value > 0 ? `+${percent}%` : `${percent}%`;
// },
},
},
grid: {
show: true,
horizontal: {
show: true,
size: 1,
color: CHART_COLORS.grid,
style: 'solid', // 分时图使用实线网格
},
vertical: {
show: false,
},
},
};
// ❌ 已删除 timelineThemeDark 配置(不再支持深色模式)
/**
* 获取主题配置(固定返回浅色主题)
* ⚠️ 已移除深色模式支持
* @deprecated colorMode 参数已废弃,始终返回浅色主题
*/
export const getTheme = (_colorMode?: 'light' | 'dark'): any => {
// ✅ 始终返回浅色主题
return lightTheme;
};
/**
* 获取分时图主题配置(固定返回浅色主题)
* ⚠️ 已移除深色模式支持
* @deprecated colorMode 参数已废弃,始终返回浅色主题
*/
export const getTimelineTheme = (_colorMode?: 'light' | 'dark'): any => {
// ✅ 始终使用浅色主题
const baseTheme = timelineTheme;
// ✅ 添加成交量指标样式(蓝色渐变柱状图)+ 成交量单位格式化
return {
...baseTheme,
indicator: {
...baseTheme.indicator,
bars: [
{
upColor: 'rgba(59, 130, 246, 0.6)', // 蓝色(涨)
downColor: 'rgba(59, 130, 246, 0.6)', // 蓝色(跌)- 分时图成交量统一蓝色
noChangeColor: 'rgba(59, 130, 246, 0.6)',
}
],
// ❌ KLineChart 类型不支持自定义 formatter需要在运行时通过 API 设置)
tooltip: {
...baseTheme.indicator?.tooltip,
// formatter: (params: any) => {
// if (params.name === 'VOL' && params.calcParamsText) {
// const volume = params.calcParamsText.match(/\d+/)?.[0];
// if (volume) {
// const hands = Math.floor(Number(volume) / 100);
// return `成交量: ${hands.toLocaleString()}手`;
// }
// }
// return params.calcParamsText || '';
// },
},
},
};
};

View File

@@ -0,0 +1,15 @@
/**
* StockChart 自定义 Hooks 统一导出
*
* 使用方式:
* import { useKLineChart, useKLineData, useEventMarker } from '@components/StockChart/hooks';
*/
export { useKLineChart } from './useKLineChart';
export type { UseKLineChartOptions, UseKLineChartReturn } from './useKLineChart';
export { useKLineData } from './useKLineData';
export type { UseKLineDataOptions, UseKLineDataReturn } from './useKLineData';
export { useEventMarker } from './useEventMarker';
export type { UseEventMarkerOptions, UseEventMarkerReturn } from './useEventMarker';

View File

@@ -0,0 +1,238 @@
/**
* useEventMarker Hook
*
* 管理事件标记的创建、更新和删除
*/
import { useEffect, useState, useCallback } from 'react';
import type { Chart } from 'klinecharts';
import type { EventMarker, KLineDataPoint } from '../types';
import {
createEventMarkerFromTime,
createEventMarkerOverlay,
createEventHighlightOverlay,
removeAllEventMarkers,
} from '../utils/eventMarkerUtils';
import { logger } from '@utils/logger';
export interface UseEventMarkerOptions {
/** KLineChart 实例 */
chart: Chart | null;
/** K 线数据(用于定位标记) */
data: KLineDataPoint[];
/** 事件时间ISO 字符串) */
eventTime?: string;
/** 事件标题(用于标记标签) */
eventTitle?: string;
/** 是否自动创建标记 */
autoCreate?: boolean;
}
export interface UseEventMarkerReturn {
/** 当前标记 */
marker: EventMarker | null;
/** 标记 ID已添加到图表 */
markerId: string | null;
/** 创建标记 */
createMarker: (time: string, label: string, color?: string) => void;
/** 移除标记 */
removeMarker: () => void;
/** 移除所有标记 */
removeAllMarkers: () => void;
}
/**
* 事件标记管理 Hook
*
* @param options 配置选项
* @returns UseEventMarkerReturn
*
* @example
* const { marker, createMarker, removeMarker } = useEventMarker({
* chart,
* data,
* eventTime: '2024-01-01 10:00:00',
* eventTitle: '重大公告',
* autoCreate: true,
* });
*/
export const useEventMarker = (
options: UseEventMarkerOptions
): UseEventMarkerReturn => {
const {
chart,
data,
eventTime,
eventTitle = '事件发生',
autoCreate = true,
} = options;
const [marker, setMarker] = useState<EventMarker | null>(null);
const [markerId, setMarkerId] = useState<string | null>(null);
const [highlightId, setHighlightId] = useState<string | null>(null);
/**
* 创建事件标记
*/
const createMarker = useCallback(
(time: string, label: string, color?: string) => {
if (!chart || !data || data.length === 0) {
logger.warn('useEventMarker', 'createMarker', '图表或数据未准备好', {
hasChart: !!chart,
dataLength: data?.length || 0,
});
return;
}
try {
// 1. 创建事件标记配置
const eventMarker = createEventMarkerFromTime(time, label, color);
setMarker(eventMarker);
// 2. 创建 Overlay
const overlay = createEventMarkerOverlay(eventMarker, data);
if (!overlay) {
logger.warn('useEventMarker', 'createMarker', 'Overlay 创建失败', {
eventMarker,
});
return;
}
// 3. 添加到图表
const id = chart.createOverlay(overlay);
if (!id || (Array.isArray(id) && id.length === 0)) {
logger.warn('useEventMarker', 'createMarker', '标记添加失败', {
overlay,
});
return;
}
const actualId = Array.isArray(id) ? id[0] : id;
setMarkerId(actualId as string);
// 4. 创建黄色高亮背景(事件影响日)
const highlightOverlay = createEventHighlightOverlay(time, data);
if (highlightOverlay) {
const highlightResult = chart.createOverlay(highlightOverlay);
const actualHighlightId = Array.isArray(highlightResult) ? highlightResult[0] : highlightResult;
setHighlightId(actualHighlightId as string);
logger.info('useEventMarker', 'createMarker', '事件高亮背景创建成功', {
highlightId: actualHighlightId,
});
}
logger.info('useEventMarker', 'createMarker', '事件标记创建成功', {
markerId: actualId,
label,
time,
chartId: chart.id,
});
} catch (err) {
logger.error('useEventMarker', 'createMarker', err as Error, {
time,
label,
});
}
},
[chart, data]
);
/**
* 移除事件标记
*/
const removeMarker = useCallback(() => {
if (!chart) {
return;
}
try {
if (markerId) {
chart.removeOverlay(markerId);
}
if (highlightId) {
chart.removeOverlay(highlightId);
}
setMarker(null);
setMarkerId(null);
setHighlightId(null);
logger.debug('useEventMarker', 'removeMarker', '移除事件标记和高亮', {
markerId,
highlightId,
chartId: chart.id,
});
} catch (err) {
logger.error('useEventMarker', 'removeMarker', err as Error, {
markerId,
highlightId,
});
}
}, [chart, markerId, highlightId]);
/**
* 移除所有标记
*/
const removeAllMarkers = useCallback(() => {
if (!chart) {
return;
}
try {
removeAllEventMarkers(chart);
setMarker(null);
setMarkerId(null);
setHighlightId(null);
logger.debug('useEventMarker', 'removeAllMarkers', '移除所有事件标记和高亮', {
chartId: chart.id,
});
} catch (err) {
logger.error('useEventMarker', 'removeAllMarkers', err as Error);
}
}, [chart]);
// 自动创建标记(当 eventTime 和数据都准备好时)
useEffect(() => {
if (
autoCreate &&
eventTime &&
chart &&
data &&
data.length > 0 &&
!markerId // 避免重复创建
) {
createMarker(eventTime, eventTitle);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventTime, chart, data, autoCreate]);
// 清理:组件卸载时移除所有标记
useEffect(() => {
return () => {
if (chart) {
try {
if (markerId) {
chart.removeOverlay(markerId);
}
if (highlightId) {
chart.removeOverlay(highlightId);
}
} catch (err) {
// 忽略清理时的错误
}
}
};
}, [chart, markerId, highlightId]);
return {
marker,
markerId,
createMarker,
removeMarker,
removeAllMarkers,
};
};

View File

@@ -0,0 +1,247 @@
/**
* useKLineChart Hook
*
* 管理 KLineChart 实例的初始化、配置和销毁
*/
import { useEffect, useRef, useState } from 'react';
import { init, dispose, registerIndicator } from 'klinecharts';
import type { Chart } from 'klinecharts';
// import { useColorMode } from '@chakra-ui/react'; // ❌ 已移除深色模式支持
import { getTheme, getTimelineTheme } from '../config/klineTheme';
import { CHART_INIT_OPTIONS } from '../config';
import { logger } from '@utils/logger';
import { avgPriceIndicator } from '../indicators/avgPriceIndicator';
export interface UseKLineChartOptions {
/** 图表容器 ID */
containerId: string;
/** 图表高度px */
height?: number;
/** 是否自动调整大小 */
autoResize?: boolean;
/** 图表类型timeline/daily */
chartType?: 'timeline' | 'daily';
}
export interface UseKLineChartReturn {
/** KLineChart 实例 */
chart: Chart | null;
/** 容器 Ref */
chartRef: React.RefObject<HTMLDivElement>;
/** 是否已初始化 */
isInitialized: boolean;
/** 初始化错误 */
error: Error | null;
}
/**
* KLineChart 初始化和生命周期管理 Hook
*
* @param options 配置选项
* @returns UseKLineChartReturn
*
* @example
* const { chart, chartRef, isInitialized } = useKLineChart({
* containerId: 'kline-chart',
* height: 400,
* autoResize: true,
* });
*/
export const useKLineChart = (
options: UseKLineChartOptions
): UseKLineChartReturn => {
const { containerId, height = 400, autoResize = true, chartType = 'daily' } = options;
const chartRef = useRef<HTMLDivElement>(null);
const chartInstanceRef = useRef<Chart | null>(null);
const [chartInstance, setChartInstance] = useState<Chart | null>(null); // ✅ 新增chart state触发重渲染
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState<Error | null>(null);
// ✅ 固定使用浅色主题(已移除 useColorMode
const colorMode = 'light';
// 全局注册自定义均价线指标(只执行一次)
useEffect(() => {
try {
registerIndicator(avgPriceIndicator);
logger.debug('useKLineChart', '✅ 自定义均价线指标(AVG)注册成功');
} catch (err) {
// 如果已注册会报错,忽略即可
logger.debug('useKLineChart', 'AVG指标已注册或注册失败', err);
}
}, []);
// 图表初始化(添加延迟重试机制,处理 Modal 动画延迟)
useEffect(() => {
// 图表初始化函数
const initChart = (): boolean => {
if (!chartRef.current) {
logger.warn('useKLineChart', 'init', '图表容器未挂载,将在 50ms 后重试', { containerId });
return false;
}
try {
logger.debug('useKLineChart', 'init', '开始初始化图表', {
containerId,
height,
colorMode,
});
// 初始化图表实例KLineChart 10.0 API
// ✅ 根据 chartType 选择主题
const themeStyles = chartType === 'timeline'
? getTimelineTheme(colorMode)
: getTheme(colorMode);
const chartInstance = init(chartRef.current, {
...CHART_INIT_OPTIONS,
// 设置初始样式(根据主题和图表类型)
styles: themeStyles,
});
if (!chartInstance) {
throw new Error('图表初始化失败:返回 null');
}
chartInstanceRef.current = chartInstance;
setChartInstance(chartInstance); // ✅ 新增:更新 state触发重渲染
setIsInitialized(true);
setError(null);
// ✅ 新增:创建成交量指标窗格
try {
const volumePaneId = chartInstance.createIndicator('VOL', false, {
height: 100, // 固定高度 100px约占整体的 20-25%
});
logger.debug('useKLineChart', 'init', '成交量窗格创建成功', {
volumePaneId,
});
} catch (err) {
logger.warn('useKLineChart', 'init', '成交量窗格创建失败', {
error: err,
});
// 不阻塞主流程,继续执行
}
logger.info('useKLineChart', 'init', '✅ 图表初始化成功', {
containerId,
chartId: chartInstance.id,
});
return true;
} catch (err) {
const error = err as Error;
logger.error('useKLineChart', 'init', error, { containerId });
setError(error);
setIsInitialized(false);
return false;
}
};
// 立即尝试初始化
if (initChart()) {
// 成功,直接返回清理函数
return () => {
if (chartInstanceRef.current) {
logger.debug('useKLineChart', 'dispose', '销毁图表实例', {
containerId,
chartId: chartInstanceRef.current.id,
});
dispose(chartInstanceRef.current);
chartInstanceRef.current = null;
setChartInstance(null); // ✅ 新增:清空 state
setIsInitialized(false);
}
};
}
// 失败则延迟重试(处理 Modal 动画延迟导致的 DOM 未挂载)
const timer = setTimeout(() => {
logger.debug('useKLineChart', 'init', '执行延迟重试', { containerId });
initChart();
}, 50);
// 清理函数:清除定时器和销毁图表实例
return () => {
clearTimeout(timer);
if (chartInstanceRef.current) {
logger.debug('useKLineChart', 'dispose', '销毁图表实例', {
containerId,
chartId: chartInstanceRef.current.id,
});
dispose(chartInstanceRef.current);
chartInstanceRef.current = null;
setChartInstance(null); // ✅ 新增:清空 state
setIsInitialized(false);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [containerId, chartType]); // containerId 或 chartType 变化时重新初始化
// 主题切换:更新图表样式
useEffect(() => {
if (!chartInstanceRef.current || !isInitialized) {
return;
}
try {
// ✅ 根据 chartType 选择主题
const newTheme = chartType === 'timeline'
? getTimelineTheme(colorMode)
: getTheme(colorMode);
chartInstanceRef.current.setStyles(newTheme);
logger.debug('useKLineChart', 'updateTheme', '更新图表主题', {
colorMode,
chartType,
chartId: chartInstanceRef.current.id,
});
} catch (err) {
logger.error('useKLineChart', 'updateTheme', err as Error, { colorMode, chartType });
}
}, [colorMode, chartType, isInitialized]);
// 容器尺寸变化:调整图表大小
useEffect(() => {
if (!chartInstanceRef.current || !isInitialized || !autoResize) {
return;
}
const handleResize = () => {
if (chartInstanceRef.current) {
chartInstanceRef.current.resize();
logger.debug('useKLineChart', 'resize', '调整图表大小');
}
};
// 监听窗口大小变化
window.addEventListener('resize', handleResize);
// 使用 ResizeObserver 监听容器大小变化(更精确)
let resizeObserver: ResizeObserver | null = null;
if (chartRef.current && typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(chartRef.current);
}
return () => {
window.removeEventListener('resize', handleResize);
if (resizeObserver && chartRef.current) {
resizeObserver.unobserve(chartRef.current);
resizeObserver.disconnect();
}
};
}, [isInitialized, autoResize]);
return {
chart: chartInstance, // ✅ 返回 state 而非 ref确保变化触发重渲染
chartRef,
isInitialized,
error,
};
};

View File

@@ -0,0 +1,329 @@
/**
* useKLineData Hook
*
* 管理 K 线数据的加载、转换和更新
*/
import { useEffect, useState, useCallback } from 'react';
import type { Chart } from 'klinecharts';
import type { ChartType, KLineDataPoint, RawDataPoint } from '../types';
import { processChartData } from '../utils/dataAdapter';
import { logger } from '@utils/logger';
import { stockService } from '@services/eventService';
import { klineDataCache, getCacheKey } from '@views/Community/components/StockDetailPanel/utils/klineDataCache';
export interface UseKLineDataOptions {
/** KLineChart 实例 */
chart: Chart | null;
/** 股票代码 */
stockCode: string;
/** 图表类型 */
chartType: ChartType;
/** 事件时间(用于调整数据加载范围) */
eventTime?: string;
/** 是否自动加载数据 */
autoLoad?: boolean;
}
export interface UseKLineDataReturn {
/** 处理后的 K 线数据 */
data: KLineDataPoint[];
/** 原始数据 */
rawData: RawDataPoint[];
/** 是否加载中 */
loading: boolean;
/** 加载错误 */
error: Error | null;
/** 手动加载数据 */
loadData: () => Promise<void>;
/** 更新数据 */
updateData: (newData: KLineDataPoint[]) => void;
/** 清空数据 */
clearData: () => void;
}
/**
* K 线数据加载和管理 Hook
*
* @param options 配置选项
* @returns UseKLineDataReturn
*
* @example
* const { data, loading, error, loadData } = useKLineData({
* chart,
* stockCode: '600000.SH',
* chartType: 'daily',
* eventTime: '2024-01-01 10:00:00',
* autoLoad: true,
* });
*/
export const useKLineData = (
options: UseKLineDataOptions
): UseKLineDataReturn => {
const {
chart,
stockCode,
chartType,
eventTime,
autoLoad = true,
} = options;
const [data, setData] = useState<KLineDataPoint[]>([]);
const [rawData, setRawData] = useState<RawDataPoint[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
/**
* 加载数据(从后端 API
*/
const loadData = useCallback(async () => {
if (!stockCode) {
logger.warn('useKLineData', 'loadData', '股票代码为空', { chartType });
return;
}
setLoading(true);
setError(null);
try {
logger.debug('useKLineData', 'loadData', '开始加载数据', {
stockCode,
chartType,
eventTime,
});
// 1. 先检查缓存
const cacheKey = getCacheKey(stockCode, eventTime, chartType);
const cachedData = klineDataCache.get(cacheKey);
let rawDataList;
if (cachedData && cachedData.length > 0) {
// 使用缓存数据
rawDataList = cachedData;
} else {
// 2. 缓存没有数据,调用 API 请求
const response = await stockService.getKlineData(
stockCode,
chartType,
eventTime
);
if (!response || !response.data) {
throw new Error('后端返回数据为空');
}
rawDataList = response.data;
// 3. 将数据写入缓存(避免下次重复请求)
klineDataCache.set(cacheKey, rawDataList);
}
setRawData(rawDataList);
// 数据转换和处理
const processedData = processChartData(rawDataList, chartType, eventTime);
setData(processedData);
logger.info('useKLineData', 'loadData', '数据加载成功', {
stockCode,
chartType,
rawCount: rawDataList.length,
processedCount: processedData.length,
});
} catch (err) {
const error = err as Error;
logger.error('useKLineData', 'loadData', error, {
stockCode,
chartType,
});
setError(error);
setData([]);
setRawData([]);
} finally {
setLoading(false);
}
}, [stockCode, chartType, eventTime]);
/**
* 更新图表数据(使用 setDataLoader 方法)
*/
const updateChartData = useCallback(
(klineData: KLineDataPoint[]) => {
if (!chart || klineData.length === 0) {
return;
}
try {
// 步骤 1: 设置 symbol必需getBars 调用的前置条件)
(chart as any).setSymbol({
ticker: stockCode || 'UNKNOWN', // 股票代码
pricePrecision: 2, // 价格精度2位小数
volumePrecision: 0 // 成交量精度(整数)
});
// 步骤 2: 设置 period必需getBars 调用的前置条件)
const periodType = chartType === 'timeline' ? 'minute' : 'day';
(chart as any).setPeriod({
type: periodType, // 分时图=minute日K=day
span: 1 // 周期跨度1分钟/1天
});
// 步骤 3: 设置 DataLoader同步数据加载器
(chart as any).setDataLoader({
getBars: (params: any) => {
if (params.type === 'init') {
// 初始化加载:返回完整数据
params.callback(klineData, false); // false = 无更多数据可加载
} else if (params.type === 'forward' || params.type === 'backward') {
// 向前/向后加载:我们没有更多数据,返回空数组
params.callback([], false);
}
}
});
// 步骤 4: 触发初始化加载(这会调用 getBars with type="init"
(chart as any).resetData();
// 步骤 5: 根据数据量调整可见范围和柱子间距(让 K 线柱子填满图表区域)
setTimeout(() => {
try {
const dataLength = klineData.length;
if (dataLength > 0) {
// 获取图表容器宽度
const chartDom = (chart as any).getDom();
const chartWidth = chartDom?.clientWidth || 1200;
// 计算最优柱子间距
// 公式barSpace = (图表宽度 / 数据数量) * 0.7
// 0.7 是为了留出一些间距,让图表不会太拥挤
const optimalBarSpace = Math.max(8, Math.min(50, (chartWidth / dataLength) * 0.7));
(chart as any).setBarSpace(optimalBarSpace);
// 减少右侧空白(默认值可能是 100-200调小会减少右侧空白
(chart as any).setOffsetRightDistance(50);
}
} catch (err) {
logger.error('useKLineData', 'updateChartData', err as Error, {
step: '调整可见范围失败',
});
}
}, 100); // 延迟 100ms 确保数据已加载和渲染
// ✅ 步骤 4: 分时图添加均价线(使用自定义 AVG 指标)
if (chartType === 'timeline' && klineData.length > 0) {
setTimeout(() => {
try {
// 在主图窗格创建 AVG 均价线指标
(chart as any).createIndicator('AVG', true, {
id: 'candle_pane', // 主图窗格
});
console.log('[DEBUG] ✅ 均价线AVG指标添加成功');
} catch (err) {
console.error('[DEBUG] ❌ 均价线添加失败:', err);
}
}, 150); // 延迟 150ms确保数据加载完成后再创建指标
// ✅ 步骤 5: 添加昨收价基准线(灰色虚线)
setTimeout(() => {
try {
const prevClose = klineData[0]?.prev_close;
if (prevClose && prevClose > 0) {
// 创建水平线覆盖层
(chart as any).createOverlay({
name: 'horizontalStraightLine',
id: 'prev_close_line',
points: [{ value: prevClose }],
styles: {
line: {
style: 'dashed',
dashValue: [4, 2],
size: 1,
color: '#888888', // 灰色虚线
},
},
extendData: {
label: `昨收: ${prevClose.toFixed(2)}`,
},
});
console.log('[DEBUG] ✅ 昨收价基准线添加成功:', prevClose);
}
} catch (err) {
console.error('[DEBUG] ❌ 昨收价基准线添加失败:', err);
}
}, 200); // 延迟 200ms确保均价线创建完成后再添加
}
logger.debug(
'useKLineData',
`updateChartData - ${stockCode} (${chartType}) - ${klineData.length}条数据加载成功`
);
} catch (err) {
logger.error('useKLineData', 'updateChartData', err as Error, {
dataCount: klineData.length,
});
}
},
[chart, stockCode, chartType]
);
/**
* 手动更新数据(外部调用)
*/
const updateData = useCallback(
(newData: KLineDataPoint[]) => {
setData(newData);
updateChartData(newData);
logger.debug(
'useKLineData',
`updateData - ${stockCode} (${chartType}) - ${newData.length}条数据手动更新`
);
},
[updateChartData]
);
/**
* 清空数据
*/
const clearData = useCallback(() => {
setData([]);
setRawData([]);
setError(null);
if (chart) {
chart.resetData();
logger.debug('useKLineData', `clearData - chartId: ${(chart as any).id}`);
}
}, [chart]);
// 自动加载数据(当 stockCode/chartType/eventTime 变化时)
useEffect(() => {
if (autoLoad && stockCode && chart) {
loadData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stockCode, chartType, eventTime, autoLoad, chart]);
// 数据变化时更新图表
useEffect(() => {
if (data.length > 0 && chart) {
updateChartData(data);
}
}, [data, chart, updateChartData]);
return {
data,
rawData,
loading,
error,
loadData,
updateData,
clearData,
};
};

View File

@@ -0,0 +1,93 @@
/**
* 自定义均价线指标
*
* 用于分时图显示橙黄色均价线
* 计算公式:累计成交额 / 累计成交量
*/
import type { Indicator, KLineData } from 'klinecharts';
export const avgPriceIndicator: Indicator = {
name: 'AVG',
shortName: 'AVG',
calcParams: [],
shouldOhlc: false, // 不显示 OHLC 信息
shouldFormatBigNumber: false,
precision: 2,
minValue: null,
maxValue: null,
figures: [
{
key: 'avg',
title: '均价: ',
type: 'line',
},
],
/**
* 计算均价
* @param dataList K线数据列表
* @returns 均价数据
*/
calc: (dataList: KLineData[]) => {
let totalAmount = 0; // 累计成交额
let totalVolume = 0; // 累计成交量
return dataList.map((kLineData) => {
const { close = 0, volume = 0 } = kLineData;
totalAmount += close * volume;
totalVolume += volume;
const avgPrice = totalVolume > 0 ? totalAmount / totalVolume : close;
return { avg: avgPrice };
});
},
/**
* 绘制样式配置
*/
styles: {
lines: [
{
color: '#FF9800', // 橙黄色
size: 2,
style: 'solid',
smooth: true,
},
],
},
/**
* Tooltip 格式化(显示均价 + 涨跌幅)
*/
createTooltipDataSource: ({ kLineData, indicator, defaultStyles }: any) => {
if (!indicator?.avg) {
return {
title: { text: '均价', color: defaultStyles.tooltip.text.color },
value: { text: '--', color: '#FF9800' },
};
}
const avgPrice = indicator.avg;
const prevClose = kLineData?.prev_close;
// 计算均价涨跌幅
let changeText = `¥${avgPrice.toFixed(2)}`;
if (prevClose && prevClose > 0) {
const changePercent = ((avgPrice - prevClose) / prevClose * 100).toFixed(2);
const changeValue = (avgPrice - prevClose).toFixed(2);
changeText = `¥${avgPrice.toFixed(2)} (${changeValue}, ${changePercent}%)`;
}
return {
title: { text: '均价', color: defaultStyles.tooltip.text.color },
value: {
text: changeText,
color: '#FF9800',
},
};
},
};

View File

@@ -0,0 +1,126 @@
/**
* KLineChart 图表类型定义
*
* 适配 klinecharts@10.0.0-beta1
* 文档: https://github.com/klinecharts/KLineChart
*/
/**
* K 线数据点(符合 KLineChart 10.0 规范)
*
* 注意: 10.0 版本要求 timestamp 为数字类型(毫秒时间戳)
*/
export interface KLineDataPoint {
/** 时间戳(毫秒) */
timestamp: number;
/** 开盘价 */
open: number;
/** 最高价 */
high: number;
/** 最低价 */
low: number;
/** 收盘价 */
close: number;
/** 成交量 */
volume: number;
/** 成交额(可选) */
turnover?: number;
/** 昨收价(用于百分比计算和基准线)- 分时图专用 */
prev_close?: number;
}
/**
* 后端原始数据格式
*
* 支持多种时间字段格式time/date/timestamp
*/
export interface RawDataPoint {
/** 时间字符串分时图格式HH:mm */
time?: string;
/** 日期字符串日线格式YYYY-MM-DD */
date?: string;
/** 时间戳字符串或数字 */
timestamp?: string | number;
/** 开盘价 */
open: number;
/** 最高价 */
high: number;
/** 最低价 */
low: number;
/** 收盘价 */
close: number;
/** 成交量 */
volume: number;
/** 均价(分时图专用) */
avg_price?: number;
/** 昨收价(用于百分比计算和基准线)- 分时图专用 */
prev_close?: number;
}
/**
* 图表类型枚举
*/
export type ChartType = 'timeline' | 'daily';
/**
* 图表配置接口
*/
export interface ChartConfig {
/** 图表类型 */
type: ChartType;
/** 显示技术指标 */
showIndicators: boolean;
/** 默认技术指标列表 */
defaultIndicators?: string[];
/** 图表高度px */
height?: number;
/** 是否显示网格 */
showGrid?: boolean;
/** 是否显示十字光标 */
showCrosshair?: boolean;
}
/**
* 事件标记接口
*
* 用于在 K 线图上标记重要事件发生时间点
*/
export interface EventMarker {
/** 唯一标识 */
id: string;
/** 时间戳(毫秒) */
timestamp: number;
/** 标签文本 */
label: string;
/** 标记位置 */
position: 'top' | 'middle' | 'bottom';
/** 标记颜色 */
color: string;
/** 图标(可选) */
icon?: string;
/** 是否可拖动(默认 false */
draggable?: boolean;
}
/**
* DataLoader 回调参数KLineChart 10.0 新增)
*/
export interface DataLoaderCallbackParams {
/** K 线数据 */
data: KLineDataPoint[];
/** 是否还有更多数据 */
more: boolean;
}
/**
* DataLoader getBars 参数KLineChart 10.0 新增)
*/
export interface DataLoaderGetBarsParams {
/** 回调函数 */
callback: (data: KLineDataPoint[], options?: { more: boolean }) => void;
/** 范围参数(可选) */
range?: {
from: number;
to: number;
};
}

View File

@@ -0,0 +1,25 @@
/**
* StockChart 类型定义统一导出
*
* 使用方式:
* import type { KLineDataPoint, StockInfo } from '@components/StockChart/types';
*/
// 图表相关类型
export type {
KLineDataPoint,
RawDataPoint,
ChartType,
ChartConfig,
EventMarker,
DataLoaderCallbackParams,
DataLoaderGetBarsParams,
} from './chart.types';
// 股票相关类型
export type {
StockInfo,
ChartDataResponse,
StockQuote,
EventInfo,
} from './stock.types';

View File

@@ -0,0 +1,80 @@
/**
* 股票相关类型定义
*
* 用于股票信息和图表数据的类型声明
*/
import type { RawDataPoint } from './chart.types';
/**
* 股票基础信息
*/
export interface StockInfo {
/** 股票代码600000.SH */
stock_code: string;
/** 股票名称(如:浦发银行) */
stock_name: string;
/** 关联描述(可能是字符串或对象) */
relation_desc?:
| string
| {
/** 数据字段 */
data?: string;
/** 内容字段 */
content?: string;
};
}
/**
* 图表数据 API 响应格式
*/
export interface ChartDataResponse {
/** K 线数据数组 */
data: RawDataPoint[];
/** 交易日期YYYY-MM-DD */
trade_date?: string;
/** 昨收价 */
prev_close?: number;
/** 状态码(可选) */
code?: number;
/** 消息(可选) */
message?: string;
}
/**
* 股票实时行情
*/
export interface StockQuote {
/** 股票代码 */
stock_code: string;
/** 当前价 */
price: number;
/** 涨跌幅(% */
change_percent: number;
/** 涨跌额 */
change_amount: number;
/** 成交量 */
volume: number;
/** 成交额 */
turnover: number;
/** 更新时间 */
update_time: string;
}
/**
* 事件信息(用于事件中心)
*/
export interface EventInfo {
/** 事件 ID */
id: number | string;
/** 事件标题 */
title: string;
/** 事件内容 */
content: string;
/** 事件发生时间ISO 字符串) */
event_time: string;
/** 重要性等级1-5 */
importance?: number;
/** 关联股票列表 */
related_stocks?: StockInfo[];
}

View File

@@ -0,0 +1,295 @@
/**
* 图表通用工具函数
*
* 包含图表初始化、技术指标管理等通用逻辑
*/
import type { Chart } from 'klinecharts';
import { logger } from '@utils/logger';
/**
* 安全地执行图表操作(捕获异常)
*
* @param operation 操作名称
* @param fn 执行函数
* @returns T | null 执行结果或 null
*/
export const safeChartOperation = <T>(
operation: string,
fn: () => T
): T | null => {
try {
return fn();
} catch (error) {
logger.error('chartUtils', operation, error as Error);
return null;
}
};
/**
* 创建技术指标
*
* @param chart KLineChart 实例
* @param indicatorName 指标名称(如 'MA', 'MACD', 'VOL'
* @param params 指标参数(可选)
* @param isStack 是否叠加(主图指标为 true副图为 false
* @returns string | null 指标 ID
*/
export const createIndicator = (
chart: Chart,
indicatorName: string,
params?: number[],
isStack: boolean = false
): string | null => {
return safeChartOperation(`createIndicator:${indicatorName}`, () => {
const indicatorId = chart.createIndicator(
{
name: indicatorName,
...(params && { calcParams: params }),
},
isStack
);
logger.debug('chartUtils', 'createIndicator', '创建技术指标', {
indicatorName,
params,
isStack,
indicatorId,
});
return indicatorId;
});
};
/**
* 移除技术指标
*
* @param chart KLineChart 实例
* @param indicatorId 指标 ID不传则移除所有指标
*/
export const removeIndicator = (chart: Chart, indicatorId?: string): void => {
safeChartOperation('removeIndicator', () => {
chart.removeIndicator(indicatorId);
logger.debug('chartUtils', 'removeIndicator', '移除技术指标', { indicatorId });
});
};
/**
* 批量创建副图指标
*
* @param chart KLineChart 实例
* @param indicators 指标名称数组
* @returns string[] 指标 ID 数组
*/
export const createSubIndicators = (
chart: Chart,
indicators: string[]
): string[] => {
const ids: string[] = [];
indicators.forEach((name) => {
const id = createIndicator(chart, name, undefined, false);
if (id) {
ids.push(id);
}
});
logger.debug('chartUtils', 'createSubIndicators', '批量创建副图指标', {
indicators,
createdIds: ids,
});
return ids;
};
/**
* 设置图表缩放级别
*
* @param chart KLineChart 实例
* @param zoom 缩放级别0.5 - 2.0
*/
export const setChartZoom = (chart: Chart, zoom: number): void => {
safeChartOperation('setChartZoom', () => {
// KLineChart 10.0: 使用 setBarSpace 方法调整 K 线宽度(实现缩放效果)
const baseBarSpace = 8; // 默认 K 线宽度px
const newBarSpace = Math.max(4, Math.min(16, baseBarSpace * zoom));
// 注意KLineChart 10.0 可能没有直接的 zoom API需要通过调整样式实现
chart.setStyles({
candle: {
bar: {
upBorderColor: undefined, // 保持默认
upColor: undefined,
downBorderColor: undefined,
downColor: undefined,
},
// 通过调整蜡烛图宽度实现缩放效果
tooltip: {
showRule: 'always',
},
},
});
logger.debug('chartUtils', 'setChartZoom', '设置图表缩放', {
zoom,
newBarSpace,
});
});
};
/**
* 滚动到指定时间
*
* @param chart KLineChart 实例
* @param timestamp 目标时间戳
*/
export const scrollToTimestamp = (chart: Chart, timestamp: number): void => {
safeChartOperation('scrollToTimestamp', () => {
// KLineChart 10.0: 使用 scrollToTimestamp 方法
chart.scrollToTimestamp(timestamp);
logger.debug('chartUtils', 'scrollToTimestamp', '滚动到指定时间', { timestamp });
});
};
/**
* 调整图表大小(响应式)
*
* @param chart KLineChart 实例
*/
export const resizeChart = (chart: Chart): void => {
safeChartOperation('resizeChart', () => {
chart.resize();
logger.debug('chartUtils', 'resizeChart', '调整图表大小');
});
};
/**
* 获取图表可见数据范围
*
* @param chart KLineChart 实例
* @returns { from: number, to: number } | null 可见范围
*/
export const getVisibleRange = (chart: Chart): { from: number; to: number } | null => {
return safeChartOperation('getVisibleRange', () => {
const data = chart.getDataList();
if (!data || data.length === 0) {
return null;
}
// 简化实现:返回所有数据范围
// 实际项目中可通过 chart 的内部状态获取可见范围
return {
from: 0,
to: data.length - 1,
};
});
};
/**
* 清空图表数据
*
* @param chart KLineChart 实例
*/
export const clearChartData = (chart: Chart): void => {
safeChartOperation('clearChartData', () => {
chart.resetData();
logger.debug('chartUtils', 'clearChartData', '清空图表数据');
});
};
/**
* 截图(导出图表为图片)
*
* @param chart KLineChart 实例
* @param includeOverlay 是否包含 overlay
* @returns string | null Base64 图片数据
*/
export const exportChartImage = (
chart: Chart,
includeOverlay: boolean = true
): string | null => {
return safeChartOperation('exportChartImage', () => {
// KLineChart 10.0: 使用 getConvertPictureUrl 方法
const imageData = chart.getConvertPictureUrl(includeOverlay, 'png', '#ffffff');
logger.debug('chartUtils', 'exportChartImage', '导出图表图片', {
includeOverlay,
hasData: !!imageData,
});
return imageData;
});
};
/**
* 切换十字光标显示
*
* @param chart KLineChart 实例
* @param show 是否显示
*/
export const toggleCrosshair = (chart: Chart, show: boolean): void => {
safeChartOperation('toggleCrosshair', () => {
chart.setStyles({
crosshair: {
show,
},
});
logger.debug('chartUtils', 'toggleCrosshair', '切换十字光标', { show });
});
};
/**
* 切换网格显示
*
* @param chart KLineChart 实例
* @param show 是否显示
*/
export const toggleGrid = (chart: Chart, show: boolean): void => {
safeChartOperation('toggleGrid', () => {
chart.setStyles({
grid: {
show,
},
});
logger.debug('chartUtils', 'toggleGrid', '切换网格', { show });
});
};
/**
* 订阅图表事件
*
* @param chart KLineChart 实例
* @param eventName 事件名称
* @param handler 事件处理函数
*/
export const subscribeChartEvent = (
chart: Chart,
eventName: string,
handler: (...args: any[]) => void
): void => {
safeChartOperation(`subscribeChartEvent:${eventName}`, () => {
chart.subscribeAction(eventName, handler);
logger.debug('chartUtils', 'subscribeChartEvent', '订阅图表事件', { eventName });
});
};
/**
* 取消订阅图表事件
*
* @param chart KLineChart 实例
* @param eventName 事件名称
* @param handler 事件处理函数
*/
export const unsubscribeChartEvent = (
chart: Chart,
eventName: string,
handler: (...args: any[]) => void
): void => {
safeChartOperation(`unsubscribeChartEvent:${eventName}`, () => {
chart.unsubscribeAction(eventName, handler);
logger.debug('chartUtils', 'unsubscribeChartEvent', '取消订阅图表事件', { eventName });
});
};

View File

@@ -0,0 +1,320 @@
/**
* 数据转换适配器
*
* 将后端返回的各种格式数据转换为 KLineChart 10.0 所需的标准格式
*/
import dayjs from 'dayjs';
import type { KLineDataPoint, RawDataPoint, ChartType } from '../types';
import { logger } from '@utils/logger';
/**
* 将后端原始数据转换为 KLineChart 标准格式
*
* @param rawData 后端原始数据数组
* @param chartType 图表类型timeline/daily
* @param eventTime 事件时间(用于日期基准)
* @returns KLineDataPoint[] 标准K线数据
*/
export const convertToKLineData = (
rawData: RawDataPoint[],
chartType: ChartType,
eventTime?: string
): KLineDataPoint[] => {
if (!rawData || !Array.isArray(rawData) || rawData.length === 0) {
logger.warn('dataAdapter', 'convertToKLineData', '原始数据为空', { chartType });
return [];
}
try {
return rawData.map((item, index) => {
const timestamp = parseTimestamp(item, chartType, eventTime, index);
return {
timestamp,
open: Number(item.open) || 0,
high: Number(item.high) || 0,
low: Number(item.low) || 0,
close: Number(item.close) || 0,
volume: Number(item.volume) || 0,
turnover: item.turnover ? Number(item.turnover) : undefined,
prev_close: item.prev_close ? Number(item.prev_close) : undefined, // ✅ 新增:昨收价(用于百分比计算和基准线)
};
});
} catch (error) {
logger.error('dataAdapter', 'convertToKLineData', error as Error, {
chartType,
dataLength: rawData.length,
});
return [];
}
};
/**
* 解析时间戳(兼容多种时间格式)
*
* @param item 原始数据项
* @param chartType 图表类型
* @param eventTime 事件时间
* @param index 数据索引(用于分时图时间推算)
* @returns number 毫秒时间戳
*/
const parseTimestamp = (
item: RawDataPoint,
chartType: ChartType,
eventTime?: string,
index?: number
): number => {
// 优先级1: 使用 timestamp 字段
if (item.timestamp) {
const ts = typeof item.timestamp === 'number' ? item.timestamp : Number(item.timestamp);
// 判断是秒级还是毫秒级时间戳
return ts > 10000000000 ? ts : ts * 1000;
}
// 优先级2: 使用 date 字段日K线
if (item.date) {
return dayjs(item.date).valueOf();
}
// 优先级3: 使用 time 字段(分时图)
if (item.time && eventTime) {
return parseTimelineTimestamp(item.time, eventTime);
}
// 优先级4: 根据 chartType 和 index 推算(兜底逻辑)
if (chartType === 'timeline' && eventTime && typeof index === 'number') {
// 分时图:从事件时间推算(假设 09:30 开盘)
const baseTime = dayjs(eventTime).startOf('day').add(9, 'hour').add(30, 'minute');
return baseTime.add(index, 'minute').valueOf();
}
// 默认返回当前时间(避免图表崩溃)
logger.warn('dataAdapter', 'parseTimestamp', '无法解析时间戳,使用当前时间', { item });
return Date.now();
};
/**
* 解析分时图时间戳
*
* 将 "HH:mm" 格式转换为完整时间戳
*
* @param time 时间字符串(如 "09:30"
* @param eventTime 事件时间YYYY-MM-DD HH:mm:ss
* @returns number 毫秒时间戳
*/
const parseTimelineTimestamp = (time: string, eventTime: string): number => {
try {
const [hours, minutes] = time.split(':').map(Number);
const eventDate = dayjs(eventTime).startOf('day');
return eventDate.hour(hours).minute(minutes).second(0).valueOf();
} catch (error) {
logger.error('dataAdapter', 'parseTimelineTimestamp', error as Error, { time, eventTime });
return dayjs(eventTime).valueOf();
}
};
/**
* 数据验证和清洗
*
* 移除无效数据(价格/成交量异常)
*
* @param data K线数据
* @returns KLineDataPoint[] 清洗后的数据
*/
export const validateAndCleanData = (data: KLineDataPoint[]): KLineDataPoint[] => {
return data.filter((item) => {
// 移除价格为 0 或负数的数据
if (item.open <= 0 || item.high <= 0 || item.low <= 0 || item.close <= 0) {
logger.warn('dataAdapter', 'validateAndCleanData', '价格异常,已移除', { item });
return false;
}
// 移除 high < low 的数据(数据错误)
if (item.high < item.low) {
logger.warn('dataAdapter', 'validateAndCleanData', '最高价 < 最低价,已移除', { item });
return false;
}
// 移除成交量为负数的数据
if (item.volume < 0) {
logger.warn('dataAdapter', 'validateAndCleanData', '成交量异常,已移除', { item });
return false;
}
return true;
});
};
/**
* 数据排序(按时间升序)
*
* @param data K线数据
* @returns KLineDataPoint[] 排序后的数据
*/
export const sortDataByTime = (data: KLineDataPoint[]): KLineDataPoint[] => {
return [...data].sort((a, b) => a.timestamp - b.timestamp);
};
/**
* 数据去重(移除时间戳重复的数据,保留最后一条)
*
* @param data K线数据
* @returns KLineDataPoint[] 去重后的数据
*/
export const deduplicateData = (data: KLineDataPoint[]): KLineDataPoint[] => {
const map = new Map<number, KLineDataPoint>();
data.forEach((item) => {
map.set(item.timestamp, item); // 相同时间戳会覆盖
});
return Array.from(map.values());
};
/**
* 根据事件时间裁剪数据范围前后2周
*
* @param data K线数据
* @param eventTime 事件时间ISO字符串
* @param chartType 图表类型
* @returns KLineDataPoint[] 裁剪后的数据
*/
export const trimDataByEventTime = (
data: KLineDataPoint[],
eventTime: string,
chartType: ChartType
): KLineDataPoint[] => {
if (!eventTime || !data || data.length === 0) {
return data;
}
try {
const eventTimestamp = dayjs(eventTime).valueOf();
// 根据图表类型设置不同的时间范围
let beforeDays: number;
let afterDays: number;
if (chartType === 'timeline') {
// 分时图只显示事件当天前后0天
beforeDays = 0;
afterDays = 0;
} else {
// 日K线显示前后14天2周
beforeDays = 14;
afterDays = 14;
}
const startTime = dayjs(eventTime).subtract(beforeDays, 'day').startOf('day').valueOf();
const endTime = dayjs(eventTime).add(afterDays, 'day').endOf('day').valueOf();
const trimmedData = data.filter((item) => {
return item.timestamp >= startTime && item.timestamp <= endTime;
});
logger.debug('dataAdapter', 'trimDataByEventTime', '数据时间范围裁剪完成', {
originalLength: data.length,
trimmedLength: trimmedData.length,
eventTime,
chartType,
dateRange: `${dayjs(startTime).format('YYYY-MM-DD')} ~ ${dayjs(endTime).format('YYYY-MM-DD')}`,
});
return trimmedData;
} catch (error) {
logger.error('dataAdapter', 'trimDataByEventTime', error as Error, { eventTime });
return data; // 出错时返回原始数据
}
};
/**
* 完整的数据处理流程
*
* 转换 → 验证 → 去重 → 排序 → 时间裁剪(如果有 eventTime
*
* @param rawData 后端原始数据
* @param chartType 图表类型
* @param eventTime 事件时间
* @returns KLineDataPoint[] 处理后的数据
*/
export const processChartData = (
rawData: RawDataPoint[],
chartType: ChartType,
eventTime?: string
): KLineDataPoint[] => {
// 1. 转换数据格式
let data = convertToKLineData(rawData, chartType, eventTime);
// 2. 验证和清洗
data = validateAndCleanData(data);
// 3. 去重
data = deduplicateData(data);
// 4. 排序
data = sortDataByTime(data);
// 5. 根据事件时间裁剪范围(如果提供了 eventTime
if (eventTime) {
data = trimDataByEventTime(data, eventTime, chartType);
}
logger.debug('dataAdapter', 'processChartData', '数据处理完成', {
rawLength: rawData.length,
processedLength: data.length,
chartType,
hasEventTime: !!eventTime,
});
return data;
};
/**
* 获取数据时间范围
*
* @param data K线数据
* @returns { start: number, end: number } 时间范围(毫秒时间戳)
*/
export const getDataTimeRange = (
data: KLineDataPoint[]
): { start: number; end: number } | null => {
if (!data || data.length === 0) {
return null;
}
const timestamps = data.map((item) => item.timestamp);
return {
start: Math.min(...timestamps),
end: Math.max(...timestamps),
};
};
/**
* 查找最接近指定时间的数据点
*
* @param data K线数据
* @param targetTime 目标时间戳
* @returns KLineDataPoint | null 最接近的数据点
*/
export const findClosestDataPoint = (
data: KLineDataPoint[],
targetTime: number
): KLineDataPoint | null => {
if (!data || data.length === 0) {
return null;
}
let closest = data[0];
let minDiff = Math.abs(data[0].timestamp - targetTime);
data.forEach((item) => {
const diff = Math.abs(item.timestamp - targetTime);
if (diff < minDiff) {
minDiff = diff;
closest = item;
}
});
return closest;
};

View File

@@ -0,0 +1,360 @@
/**
* 事件标记工具函数
*
* 用于在 K 线图上创建、管理事件标记Overlay
*/
import dayjs from 'dayjs';
import type { OverlayCreate } from 'klinecharts';
import type { EventMarker, KLineDataPoint } from '../types';
import { EVENT_MARKER_CONFIG } from '../config';
import { findClosestDataPoint } from './dataAdapter';
import { logger } from '@utils/logger';
/**
* 创建事件标记 OverlayKLineChart 10.0 格式)
*
* @param marker 事件标记配置
* @param data K线数据用于定位标记位置
* @returns OverlayCreate | null Overlay 配置对象
*/
export const createEventMarkerOverlay = (
marker: EventMarker,
data: KLineDataPoint[]
): OverlayCreate | null => {
try {
// 查找最接近事件时间的数据点
const closestPoint = findClosestDataPoint(data, marker.timestamp);
if (!closestPoint) {
logger.warn('eventMarkerUtils', 'createEventMarkerOverlay', '未找到匹配的数据点', {
markerId: marker.id,
timestamp: marker.timestamp,
});
return null;
}
// 根据位置计算 Y 坐标
const yValue = calculateMarkerYPosition(closestPoint, marker.position);
// 创建 Overlay 配置KLineChart 10.0 规范)
const overlay: OverlayCreate = {
name: 'simpleAnnotation', // 使用内置的简单标注类型
id: marker.id,
points: [
{
timestamp: closestPoint.timestamp,
value: yValue,
},
],
styles: {
point: {
color: marker.color,
borderColor: marker.color,
borderSize: 2,
radius: EVENT_MARKER_CONFIG.size.point,
},
text: {
color: EVENT_MARKER_CONFIG.text.color,
size: EVENT_MARKER_CONFIG.text.fontSize,
family: EVENT_MARKER_CONFIG.text.fontFamily,
weight: 'bold',
},
rect: {
style: 'fill',
color: marker.color,
borderRadius: EVENT_MARKER_CONFIG.text.borderRadius,
paddingLeft: EVENT_MARKER_CONFIG.text.padding,
paddingRight: EVENT_MARKER_CONFIG.text.padding,
paddingTop: EVENT_MARKER_CONFIG.text.padding,
paddingBottom: EVENT_MARKER_CONFIG.text.padding,
},
},
// 标记文本内容
extendData: {
label: marker.label,
icon: marker.icon,
},
};
logger.debug('eventMarkerUtils', 'createEventMarkerOverlay', '创建事件标记', {
markerId: marker.id,
timestamp: closestPoint.timestamp,
label: marker.label,
});
return overlay;
} catch (error) {
logger.error('eventMarkerUtils', 'createEventMarkerOverlay', error as Error, {
markerId: marker.id,
});
return null;
}
};
/**
* 创建事件日K线黄色高亮覆盖层垂直矩形背景
*
* @param eventTime 事件时间ISO字符串
* @param data K线数据
* @returns OverlayCreate | null 高亮覆盖层配置
*/
export const createEventHighlightOverlay = (
eventTime: string,
data: KLineDataPoint[]
): OverlayCreate | null => {
try {
const eventTimestamp = dayjs(eventTime).valueOf();
const closestPoint = findClosestDataPoint(data, eventTimestamp);
if (!closestPoint) {
logger.warn('eventMarkerUtils', 'createEventHighlightOverlay', '未找到匹配的数据点');
return null;
}
// 创建垂直矩形覆盖层(从图表顶部到底部的黄色半透明背景)
const overlay: OverlayCreate = {
name: 'rect', // 矩形覆盖层
id: `event-highlight-${eventTimestamp}`,
points: [
{
timestamp: closestPoint.timestamp,
value: closestPoint.high * 1.05, // 顶部位置高于最高价5%
},
{
timestamp: closestPoint.timestamp,
value: closestPoint.low * 0.95, // 底部位置低于最低价5%
},
],
styles: {
style: 'fill',
color: 'rgba(255, 193, 7, 0.15)', // 黄色半透明背景15%透明度)
borderColor: '#FFD54F', // 黄色边框
borderSize: 2,
borderStyle: 'solid',
},
};
logger.debug('eventMarkerUtils', 'createEventHighlightOverlay', '创建事件高亮覆盖层', {
timestamp: closestPoint.timestamp,
eventTime,
});
return overlay;
} catch (error) {
logger.error('eventMarkerUtils', 'createEventHighlightOverlay', error as Error);
return null;
}
};
/**
* 计算标记的 Y 轴位置
*
* @param dataPoint K线数据点
* @param position 标记位置top/middle/bottom
* @returns number Y轴数值
*/
const calculateMarkerYPosition = (
dataPoint: KLineDataPoint,
position: 'top' | 'middle' | 'bottom'
): number => {
switch (position) {
case 'top':
return dataPoint.high * 1.02; // 在最高价上方 2%
case 'bottom':
return dataPoint.low * 0.98; // 在最低价下方 2%
case 'middle':
default:
return (dataPoint.high + dataPoint.low) / 2; // 中间位置
}
};
/**
* 从事件时间创建标记配置
*
* @param eventTime 事件时间字符串ISO 格式)
* @param label 标记标签(可选,默认为"事件发生"
* @param color 标记颜色(可选,使用默认颜色)
* @returns EventMarker 事件标记配置
*/
export const createEventMarkerFromTime = (
eventTime: string,
label: string = '事件发生',
color: string = EVENT_MARKER_CONFIG.defaultColor
): EventMarker => {
const timestamp = dayjs(eventTime).valueOf();
return {
id: `event-${timestamp}`,
timestamp,
label,
position: EVENT_MARKER_CONFIG.defaultPosition,
color,
icon: EVENT_MARKER_CONFIG.defaultIcon,
draggable: false,
};
};
/**
* 批量创建事件标记 Overlays
*
* @param markers 事件标记配置数组
* @param data K线数据
* @returns OverlayCreate[] Overlay 配置数组
*/
export const createEventMarkerOverlays = (
markers: EventMarker[],
data: KLineDataPoint[]
): OverlayCreate[] => {
if (!markers || markers.length === 0) {
return [];
}
const overlays: OverlayCreate[] = [];
markers.forEach((marker) => {
const overlay = createEventMarkerOverlay(marker, data);
if (overlay) {
overlays.push(overlay);
}
});
logger.debug('eventMarkerUtils', 'createEventMarkerOverlays', '批量创建事件标记', {
totalMarkers: markers.length,
createdOverlays: overlays.length,
});
return overlays;
};
/**
* 移除事件标记
*
* @param chart KLineChart 实例
* @param markerId 标记 ID
*/
export const removeEventMarker = (chart: any, markerId: string): void => {
try {
chart.removeOverlay(markerId);
logger.debug('eventMarkerUtils', 'removeEventMarker', '移除事件标记', { markerId });
} catch (error) {
logger.error('eventMarkerUtils', 'removeEventMarker', error as Error, { markerId });
}
};
/**
* 移除所有事件标记
*
* @param chart KLineChart 实例
*/
export const removeAllEventMarkers = (chart: any): void => {
try {
// KLineChart 10.0 API: removeOverlay() 不传参数时移除所有 overlays
chart.removeOverlay();
logger.debug('eventMarkerUtils', 'removeAllEventMarkers', '移除所有事件标记');
} catch (error) {
logger.error('eventMarkerUtils', 'removeAllEventMarkers', error as Error);
}
};
/**
* 更新事件标记
*
* @param chart KLineChart 实例
* @param markerId 标记 ID
* @param updates 更新内容(部分字段)
*/
export const updateEventMarker = (
chart: any,
markerId: string,
updates: Partial<EventMarker>
): void => {
try {
// 先移除旧标记
removeEventMarker(chart, markerId);
// 重新创建标记KLineChart 10.0 不支持直接更新 overlay
// 注意:需要在调用方重新创建并添加 overlay
logger.debug('eventMarkerUtils', 'updateEventMarker', '更新事件标记', {
markerId,
updates,
});
} catch (error) {
logger.error('eventMarkerUtils', 'updateEventMarker', error as Error, { markerId });
}
};
/**
* 高亮事件标记(改变样式)
*
* @param chart KLineChart 实例
* @param markerId 标记 ID
* @param highlight 是否高亮
*/
export const highlightEventMarker = (
chart: any,
markerId: string,
highlight: boolean
): void => {
try {
// KLineChart 10.0: 通过 overrideOverlay 修改样式
chart.overrideOverlay({
id: markerId,
styles: {
point: {
activeRadius: highlight ? 10 : EVENT_MARKER_CONFIG.size.point,
activeBorderSize: highlight ? 3 : 2,
},
},
});
logger.debug('eventMarkerUtils', 'highlightEventMarker', '高亮事件标记', {
markerId,
highlight,
});
} catch (error) {
logger.error('eventMarkerUtils', 'highlightEventMarker', error as Error, { markerId });
}
};
/**
* 格式化事件标记标签
*
* @param eventTitle 事件标题
* @param maxLength 最大长度(默认 10
* @returns string 格式化后的标签
*/
export const formatEventMarkerLabel = (eventTitle: string, maxLength: number = 10): string => {
if (!eventTitle) {
return '事件';
}
if (eventTitle.length <= maxLength) {
return eventTitle;
}
return `${eventTitle.substring(0, maxLength)}...`;
};
/**
* 判断事件时间是否在数据范围内
*
* @param eventTime 事件时间戳
* @param data K线数据
* @returns boolean 是否在范围内
*/
export const isEventTimeInDataRange = (
eventTime: number,
data: KLineDataPoint[]
): boolean => {
if (!data || data.length === 0) {
return false;
}
const timestamps = data.map((item) => item.timestamp);
const minTime = Math.min(...timestamps);
const maxTime = Math.max(...timestamps);
return eventTime >= minTime && eventTime <= maxTime;
};

View File

@@ -0,0 +1,48 @@
/**
* StockChart 工具函数统一导出
*
* 使用方式:
* import { processChartData, createEventMarkerOverlay } from '@components/StockChart/utils';
*/
// 数据转换适配器
export {
convertToKLineData,
validateAndCleanData,
sortDataByTime,
deduplicateData,
processChartData,
getDataTimeRange,
findClosestDataPoint,
} from './dataAdapter';
// 事件标记工具
export {
createEventMarkerOverlay,
createEventMarkerFromTime,
createEventMarkerOverlays,
removeEventMarker,
removeAllEventMarkers,
updateEventMarker,
highlightEventMarker,
formatEventMarkerLabel,
isEventTimeInDataRange,
} from './eventMarkerUtils';
// 图表通用工具
export {
safeChartOperation,
createIndicator,
removeIndicator,
createSubIndicators,
setChartZoom,
scrollToTimestamp,
resizeChart,
getVisibleRange,
clearChartData,
exportChartImage,
toggleCrosshair,
toggleGrid,
subscribeChartEvent,
unsubscribeChartEvent,
} from './chartUtils';

View File

@@ -0,0 +1,121 @@
/**
* 关联描述组件
*
* 用于显示股票与事件的关联描述信息
* 固定标题为"关联描述:"
* 自动处理多种数据格式(字符串、对象数组)
*
* @example
* ```tsx
* // 基础使用 - 传入原始 relation_desc 数据
* <RelationDescription relationDesc={stock.relation_desc} />
*
* // 自定义样式
* <RelationDescription
* relationDesc={stock.relation_desc}
* fontSize="md"
* titleColor="blue.700"
* />
* ```
*/
import React, { useMemo } from 'react';
import { Box, Text, BoxProps } from '@chakra-ui/react';
/**
* 关联描述数据类型
* - 字符串格式:直接的描述文本
* - 对象格式:包含多个句子的数组
*/
export type RelationDescType =
| string
| {
data: Array<{
query_part?: string;
sentences?: string;
}>;
}
| null
| undefined;
export interface RelationDescriptionProps {
/** 原始关联描述数据(支持字符串或对象格式) */
relationDesc: RelationDescType;
/** 字体大小,默认 'sm' */
fontSize?: string;
/** 标题颜色,默认 'gray.700' */
titleColor?: string;
/** 文本颜色,默认 'gray.600' */
textColor?: string;
/** 行高,默认 '1.7' */
lineHeight?: string;
/** 容器额外属性 */
containerProps?: BoxProps;
}
export const RelationDescription: React.FC<RelationDescriptionProps> = ({
relationDesc,
fontSize = 'sm',
titleColor = 'gray.700',
textColor = 'gray.600',
lineHeight = '1.7',
containerProps = {}
}) => {
// 处理关联描述(兼容对象和字符串格式)
const processedDesc = useMemo(() => {
if (!relationDesc) return null;
// 字符串格式:直接返回
if (typeof relationDesc === 'string') {
return relationDesc;
}
// 对象格式:提取并拼接文本
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
return (
relationDesc.data
.map((item) => item.query_part || item.sentences || '')
.filter((s) => s)
.join('') || null
);
}
return null;
}, [relationDesc]);
// 如果没有有效的描述内容,不渲染组件
if (!processedDesc) {
return null;
}
return (
<Box
p={4}
borderTop="1px solid"
borderTopColor="gray.200"
{...containerProps}
>
<Text
fontSize={fontSize}
fontWeight="bold"
mb={2}
color={titleColor}
>
:
</Text>
<Text
fontSize={fontSize}
color={textColor}
lineHeight={lineHeight}
whiteSpace="pre-wrap"
>
{processedDesc}
</Text>
</Box>
);
};

View File

@@ -0,0 +1,6 @@
/**
* StockRelation 组件导出入口
*/
export { RelationDescription } from './RelationDescription';
export type { RelationDescriptionProps, RelationDescType } from './RelationDescription';

View File

@@ -2,9 +2,12 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter as Router } from 'react-router-dom';
// 导入 Tailwind CSS 和 Brainwave 样式
// 导入 Brainwave 样式(空文件,保留以避免错误)
import './styles/brainwave.css';
import './styles/brainwave-colors.css';
// 导入 Select 下拉框颜色修复样式
import './styles/select-fix.css';
// 导入 Bytedesk 客服系统 z-index 覆盖样式(必须在所有样式之后导入)
import './styles/bytedesk-override.css';

View File

@@ -30,12 +30,12 @@ const MemoizedAppFooter = memo(AppFooter);
*/
export default function MainLayout() {
return (
<Box minH="100vh" display="flex" flexDirection="column">
<Box flex="1" h="100vh" w="100%" position="relative" display="flex" flexDirection="column">
{/* 导航栏 - 在所有页面间共享memo 后不会在路由切换时重新渲染 */}
<MemoizedHomeNavbar />
{/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */}
<Box flex="1" w="100%" position="relative" overflow="hidden">
<Box flex="1" pt="72px">
<ErrorBoundary>
<Suspense fallback={<PageLoader message="页面加载中..." />}>
<Outlet />
@@ -47,11 +47,11 @@ export default function MainLayout() {
<MemoizedAppFooter />
{/* 返回顶部按钮 - 滚动超过阈值时显示 */}
<BackToTopButton
{/* <BackToTopButton
scrollThreshold={BACK_TO_TOP_CONFIG.scrollThreshold}
position={BACK_TO_TOP_CONFIG.position}
zIndex={BACK_TO_TOP_CONFIG.zIndex}
/>
/> */}
</Box>
);
}

View File

@@ -78,10 +78,16 @@ function generateTimeRange(data, startTime, endTime, basePrice, session) {
const volume = Math.floor(Math.random() * 500000000 + 100000000);
// ✅ 修复:为分时图添加完整的 OHLC 字段
const closePrice = parseFloat(price.toFixed(2));
data.push({
time: formatTime(current),
price: parseFloat(price.toFixed(2)),
close: parseFloat(price.toFixed(2)),
timestamp: current.getTime(), // ✅ 新增:毫秒时间戳
open: parseFloat((price * 0.9999).toFixed(2)), // ✅ 新增:开盘价(略低于收盘)
high: parseFloat((price * 1.0002).toFixed(2)), // ✅ 新增:最高价(略高于收盘)
low: parseFloat((price * 0.9997).toFixed(2)), // ✅ 新增:最低价(略低于收盘)
close: closePrice, // ✅ 保留:收盘价
price: closePrice, // ✅ 保留:兼容字段(供 MiniTimelineChart 使用)
volume: volume,
prev_close: basePrice
});

View File

@@ -21,11 +21,12 @@ import { NotificationProvider } from '../contexts/NotificationContext';
*
* Provider 层级顺序 (从外到内):
* 1. ReduxProvider - 状态管理层
* 2. ChakraProvider - UI 框架层
* 2. ChakraProvider - UI 框架层(主要)
* 3. NotificationProvider - 通知系统
* 4. AuthProvider - 认证系统
*
* 注意:
* - HeroUI v3 不再需要 HeroUIProvider样式通过 CSS 导入加载 (src/styles/heroui.css)
* - AuthModal 已迁移到 Redux (authModalSlice + useAuthModal Hook)
* - ErrorBoundary 在各 Layout 层实现,不在全局层,以实现精细化错误隔离
* - MainLayout: PageTransitionWrapper 包含 ErrorBoundary (页面错误不影响导航栏)
@@ -39,6 +40,13 @@ export function AppProviders({ children }) {
<ReduxProvider store={store}>
<ChakraProvider
theme={theme}
// ✅ 强制使用浅色主题(禁用深色模式)
colorModeManager={{
type: 'cookie',
ssr: false,
get: () => 'light', // 始终返回 'light'
set: () => {}, // 禁止设置(忽略切换操作)
}}
toastOptions={{
defaultOptions: {
position: 'top',

View File

@@ -11,7 +11,7 @@ export const lazyComponents = {
// Home 模块
HomePage: React.lazy(() => import('../views/Home/HomePage')),
CenterDashboard: React.lazy(() => import('../views/Dashboard/Center')),
ProfilePage: React.lazy(() => import('../views/Profile/ProfilePage')),
ProfilePage: React.lazy(() => import('../views/Profile')),
SettingsPage: React.lazy(() => import('../views/Settings/SettingsPage')),
Subscription: React.lazy(() => import('../views/Pages/Account/Subscription')),
PrivacyPolicy: React.lazy(() => import('../views/Pages/PrivacyPolicy')),
@@ -42,6 +42,7 @@ export const lazyComponents = {
// 价值论坛模块
ValueForum: React.lazy(() => import('../views/ValueForum')),
ForumPostDetail: React.lazy(() => import('../views/ValueForum/PostDetail')),
PredictionTopicDetail: React.lazy(() => import('../views/ValueForum/PredictionTopicDetail')),
// 数据浏览器模块
DataBrowser: React.lazy(() => import('../views/DataBrowser')),

View File

@@ -181,16 +181,26 @@ export const routeConfig = [
description: '论坛帖子详细内容'
}
},
{
path: 'value-forum/prediction/:topicId',
component: lazyComponents.PredictionTopicDetail,
protection: PROTECTION_MODES.MODAL,
layout: 'main',
meta: {
title: '预测话题详情',
description: '预测市场话题详细信息'
}
},
// ==================== Agent模块 ====================
{
path: 'agent-chat',
component: lazyComponents.AgentChat,
protection: PROTECTION_MODES.MODAL,
layout: 'main',
layout: 'main', // 使用主布局(带导航栏)
meta: {
title: '价小前投研',
description: '北京价值前沿科技公司的AI投研聊天助手'
title: '价小前投研 AI',
description: '超炫酷的 AI 投研聊天助手 - 基于 Hero UI'
}
},
];

View File

@@ -0,0 +1,492 @@
/**
* 积分系统服务
* 管理用户积分账户、交易、奖励等
*/
// ==================== 常量配置 ====================
export const CREDIT_CONFIG = {
INITIAL_BALANCE: 10000, // 初始积分
MIN_BALANCE: 100, // 最低保留余额(破产保护)
MAX_SINGLE_BET: 1000, // 单次下注上限
DAILY_BONUS: 100, // 每日签到奖励
CREATE_TOPIC_COST: 100, // 创建话题费用
};
// 积分账户存储(生产环境应使用数据库)
const userAccounts = new Map();
// 交易记录存储
const transactions = [];
// ==================== 账户管理 ====================
/**
* 获取用户账户
* @param {string} userId - 用户ID
* @returns {Object} 用户账户信息
*/
export const getUserAccount = (userId) => {
if (!userAccounts.has(userId)) {
// 首次访问,创建新账户
const newAccount = {
user_id: userId,
balance: CREDIT_CONFIG.INITIAL_BALANCE,
frozen: 0,
total: CREDIT_CONFIG.INITIAL_BALANCE,
total_earned: CREDIT_CONFIG.INITIAL_BALANCE,
total_spent: 0,
total_profit: 0,
active_positions: [],
stats: {
total_topics: 0,
win_count: 0,
loss_count: 0,
win_rate: 0,
best_profit: 0,
},
last_daily_bonus: null,
};
userAccounts.set(userId, newAccount);
}
return userAccounts.get(userId);
};
/**
* 更新用户账户
* @param {string} userId - 用户ID
* @param {Object} updates - 更新内容
*/
export const updateUserAccount = (userId, updates) => {
const account = getUserAccount(userId);
const updated = { ...account, ...updates };
userAccounts.set(userId, updated);
return updated;
};
/**
* 获取用户积分余额
* @param {string} userId - 用户ID
* @returns {number} 可用余额
*/
export const getBalance = (userId) => {
const account = getUserAccount(userId);
return account.balance;
};
/**
* 检查用户是否能支付
* @param {string} userId - 用户ID
* @param {number} amount - 金额
* @returns {boolean} 是否能支付
*/
export const canAfford = (userId, amount) => {
const account = getUserAccount(userId);
const afterBalance = account.balance - amount;
// 必须保留最低余额
return afterBalance >= CREDIT_CONFIG.MIN_BALANCE;
};
// ==================== 积分操作 ====================
/**
* 增加积分
* @param {string} userId - 用户ID
* @param {number} amount - 金额
* @param {string} reason - 原因
*/
export const addCredits = (userId, amount, reason = '系统增加') => {
const account = getUserAccount(userId);
const updated = {
balance: account.balance + amount,
total: account.total + amount,
total_earned: account.total_earned + amount,
};
updateUserAccount(userId, updated);
// 记录交易
logTransaction({
user_id: userId,
type: 'earn',
amount,
reason,
balance_after: updated.balance,
});
return updated;
};
/**
* 扣除积分
* @param {string} userId - 用户ID
* @param {number} amount - 金额
* @param {string} reason - 原因
* @throws {Error} 如果余额不足
*/
export const deductCredits = (userId, amount, reason = '系统扣除') => {
if (!canAfford(userId, amount)) {
throw new Error(`积分不足,需要${amount}积分,但只有${getBalance(userId)}积分`);
}
const account = getUserAccount(userId);
const updated = {
balance: account.balance - amount,
total_spent: account.total_spent + amount,
};
updateUserAccount(userId, updated);
// 记录交易
logTransaction({
user_id: userId,
type: 'spend',
amount: -amount,
reason,
balance_after: updated.balance,
});
return updated;
};
/**
* 冻结积分(席位占用)
* @param {string} userId - 用户ID
* @param {number} amount - 金额
*/
export const freezeCredits = (userId, amount) => {
const account = getUserAccount(userId);
if (account.balance < amount) {
throw new Error('可用余额不足');
}
const updated = {
balance: account.balance - amount,
frozen: account.frozen + amount,
};
updateUserAccount(userId, updated);
return updated;
};
/**
* 解冻积分
* @param {string} userId - 用户ID
* @param {number} amount - 金额
*/
export const unfreezeCredits = (userId, amount) => {
const account = getUserAccount(userId);
const updated = {
balance: account.balance + amount,
frozen: account.frozen - amount,
};
updateUserAccount(userId, updated);
return updated;
};
// ==================== 每日奖励 ====================
/**
* 领取每日签到奖励
* @param {string} userId - 用户ID
* @returns {Object} 奖励信息
*/
export const claimDailyBonus = (userId) => {
const account = getUserAccount(userId);
const today = new Date().toDateString();
// 检查是否已领取
if (account.last_daily_bonus === today) {
return {
success: false,
message: '今日已领取',
};
}
// 发放奖励
addCredits(userId, CREDIT_CONFIG.DAILY_BONUS, '每日签到');
// 更新领取时间
updateUserAccount(userId, { last_daily_bonus: today });
return {
success: true,
amount: CREDIT_CONFIG.DAILY_BONUS,
message: `获得${CREDIT_CONFIG.DAILY_BONUS}积分`,
};
};
/**
* 检查今天是否已签到
* @param {string} userId - 用户ID
* @returns {boolean}
*/
export const hasClaimedToday = (userId) => {
const account = getUserAccount(userId);
const today = new Date().toDateString();
return account.last_daily_bonus === today;
};
// ==================== 持仓管理 ====================
/**
* 添加持仓
* @param {string} userId - 用户ID
* @param {Object} position - 持仓信息
*/
export const addPosition = (userId, position) => {
const account = getUserAccount(userId);
const updated = {
active_positions: [...account.active_positions, position],
stats: {
...account.stats,
total_topics: account.stats.total_topics + 1,
},
};
updateUserAccount(userId, updated);
return updated;
};
/**
* 移除持仓
* @param {string} userId - 用户ID
* @param {string} positionId - 持仓ID
*/
export const removePosition = (userId, positionId) => {
const account = getUserAccount(userId);
const updated = {
active_positions: account.active_positions.filter((p) => p.id !== positionId),
};
updateUserAccount(userId, updated);
return updated;
};
/**
* 更新持仓
* @param {string} userId - 用户ID
* @param {string} positionId - 持仓ID
* @param {Object} updates - 更新内容
*/
export const updatePosition = (userId, positionId, updates) => {
const account = getUserAccount(userId);
const updated = {
active_positions: account.active_positions.map((p) =>
p.id === positionId ? { ...p, ...updates } : p
),
};
updateUserAccount(userId, updated);
return updated;
};
/**
* 获取用户持仓
* @param {string} userId - 用户ID
* @param {string} topicId - 话题ID可选
* @returns {Array} 持仓列表
*/
export const getUserPositions = (userId, topicId = null) => {
const account = getUserAccount(userId);
if (topicId) {
return account.active_positions.filter((p) => p.topic_id === topicId);
}
return account.active_positions;
};
// ==================== 统计更新 ====================
/**
* 记录胜利
* @param {string} userId - 用户ID
* @param {number} profit - 盈利金额
*/
export const recordWin = (userId, profit) => {
const account = getUserAccount(userId);
const newWinCount = account.stats.win_count + 1;
const totalGames = newWinCount + account.stats.loss_count;
const winRate = (newWinCount / totalGames) * 100;
const updated = {
total_profit: account.total_profit + profit,
stats: {
...account.stats,
win_count: newWinCount,
win_rate: winRate,
best_profit: Math.max(account.stats.best_profit, profit),
},
};
updateUserAccount(userId, updated);
return updated;
};
/**
* 记录失败
* @param {string} userId - 用户ID
* @param {number} loss - 损失金额
*/
export const recordLoss = (userId, loss) => {
const account = getUserAccount(userId);
const newLossCount = account.stats.loss_count + 1;
const totalGames = account.stats.win_count + newLossCount;
const winRate = (account.stats.win_count / totalGames) * 100;
const updated = {
total_profit: account.total_profit - loss,
stats: {
...account.stats,
loss_count: newLossCount,
win_rate: winRate,
},
};
updateUserAccount(userId, updated);
return updated;
};
// ==================== 排行榜 ====================
/**
* 获取积分排行榜
* @param {number} limit - 返回数量
* @returns {Array} 排行榜数据
*/
export const getLeaderboard = (limit = 100) => {
const accounts = Array.from(userAccounts.values());
return accounts
.sort((a, b) => b.total - a.total)
.slice(0, limit)
.map((account, index) => ({
rank: index + 1,
user_id: account.user_id,
total: account.total,
total_profit: account.total_profit,
win_rate: account.stats.win_rate,
}));
};
/**
* 获取用户排名
* @param {string} userId - 用户ID
* @returns {number} 排名
*/
export const getUserRank = (userId) => {
const leaderboard = getLeaderboard(1000);
const index = leaderboard.findIndex((item) => item.user_id === userId);
return index >= 0 ? index + 1 : -1;
};
// ==================== 交易记录 ====================
/**
* 记录交易
* @param {Object} transaction - 交易信息
*/
const logTransaction = (transaction) => {
const record = {
id: `tx_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
timestamp: new Date().toISOString(),
...transaction,
};
transactions.push(record);
return record;
};
/**
* 获取用户交易记录
* @param {string} userId - 用户ID
* @param {number} limit - 返回数量
* @returns {Array} 交易记录
*/
export const getUserTransactions = (userId, limit = 50) => {
return transactions
.filter((tx) => tx.user_id === userId)
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
.slice(0, limit);
};
// ==================== 批量操作 ====================
/**
* 批量发放积分(如活动奖励)
* @param {Array} recipients - [{user_id, amount, reason}]
*/
export const batchAddCredits = (recipients) => {
const results = recipients.map(({ user_id, amount, reason }) => {
try {
return {
user_id,
success: true,
account: addCredits(user_id, amount, reason),
};
} catch (error) {
return {
user_id,
success: false,
error: error.message,
};
}
});
return results;
};
// ==================== 导出所有功能 ====================
export default {
CREDIT_CONFIG,
// 账户管理
getUserAccount,
updateUserAccount,
getBalance,
canAfford,
// 积分操作
addCredits,
deductCredits,
freezeCredits,
unfreezeCredits,
// 每日奖励
claimDailyBonus,
hasClaimedToday,
// 持仓管理
addPosition,
removePosition,
updatePosition,
getUserPositions,
// 统计更新
recordWin,
recordLoss,
// 排行榜
getLeaderboard,
getUserRank,
// 交易记录
getUserTransactions,
// 批量操作
batchAddCredits,
};

View File

@@ -0,0 +1,325 @@
/**
* 预测市场服务 - API 版本
* 调用真实的后端 API数据存储到 MySQL 数据库
*/
import axios from 'axios';
import { getApiBase } from '@utils/apiConfig';
const api = axios.create({
baseURL: getApiBase(),
timeout: 10000,
withCredentials: true, // 携带 Cookiesession
});
// ==================== 积分系统 API ====================
/**
* 获取用户积分账户
*/
export const getUserAccount = async () => {
try {
const response = await api.get('/api/prediction/credit/account');
return response.data;
} catch (error) {
console.error('获取积分账户失败:', error);
throw error;
}
};
/**
* 领取每日奖励100积分
*/
export const claimDailyBonus = async () => {
try {
const response = await api.post('/api/prediction/credit/daily-bonus');
return response.data;
} catch (error) {
console.error('领取每日奖励失败:', error);
throw error;
}
};
// ==================== 预测话题 API ====================
/**
* 创建预测话题
* @param {Object} topicData - { title, description, category, deadline }
*/
export const createTopic = async (topicData) => {
try {
const response = await api.post('/api/prediction/topics', topicData);
return response.data;
} catch (error) {
console.error('创建预测话题失败:', error);
throw error;
}
};
/**
* 获取预测话题列表
* @param {Object} params - { status, category, sort_by, page, per_page }
*/
export const getTopics = async (params = {}) => {
try {
const response = await api.get('/api/prediction/topics', { params });
return response.data;
} catch (error) {
console.error('获取话题列表失败:', error);
throw error;
}
};
/**
* 获取预测话题详情
* @param {number} topicId
*/
export const getTopicDetail = async (topicId) => {
try {
const response = await api.get(`/api/prediction/topics/${topicId}`);
return response.data;
} catch (error) {
console.error('获取话题详情失败:', error);
throw error;
}
};
/**
* 结算预测话题(仅创建者可操作)
* @param {number} topicId
* @param {string} result - 'yes' | 'no' | 'draw'
*/
export const settleTopic = async (topicId, result) => {
try {
const response = await api.post(`/api/prediction/topics/${topicId}/settle`, { result });
return response.data;
} catch (error) {
console.error('结算话题失败:', error);
throw error;
}
};
// ==================== 交易 API ====================
/**
* 买入预测份额
* @param {Object} tradeData - { topic_id, direction, shares }
*/
export const buyShares = async (tradeData) => {
try {
const response = await api.post('/api/prediction/trade/buy', tradeData);
return response.data;
} catch (error) {
console.error('买入份额失败:', error);
throw error;
}
};
/**
* 获取用户持仓列表
*/
export const getUserPositions = async () => {
try {
const response = await api.get('/api/prediction/positions');
return response.data;
} catch (error) {
console.error('获取持仓列表失败:', error);
throw error;
}
};
// ==================== 评论 API ====================
/**
* 发表话题评论
* @param {number} topicId
* @param {Object} commentData - { content, parent_id }
*/
export const createComment = async (topicId, commentData) => {
try {
const response = await api.post(`/api/prediction/topics/${topicId}/comments`, commentData);
return response.data;
} catch (error) {
console.error('发表评论失败:', error);
throw error;
}
};
/**
* 获取话题评论列表
* @param {number} topicId
* @param {Object} params - { page, per_page }
*/
export const getComments = async (topicId, params = {}) => {
try {
const response = await api.get(`/api/prediction/topics/${topicId}/comments`, { params });
return response.data;
} catch (error) {
console.error('获取评论列表失败:', error);
throw error;
}
};
/**
* 点赞/取消点赞评论
* @param {number} commentId
*/
export const likeComment = async (commentId) => {
try {
const response = await api.post(`/api/prediction/comments/${commentId}/like`);
return response.data;
} catch (error) {
console.error('点赞评论失败:', error);
throw error;
}
};
// ==================== 观点IPO API ====================
/**
* 投资评论观点IPO
* @param {number} commentId - 评论ID
* @param {number} shares - 投资份额
*/
export const investComment = async (commentId, shares) => {
try {
const response = await api.post(`/api/prediction/comments/${commentId}/invest`, { shares });
return response.data;
} catch (error) {
console.error('投资评论失败:', error);
throw error;
}
};
/**
* 获取评论的投资列表
* @param {number} commentId - 评论ID
*/
export const getCommentInvestments = async (commentId) => {
try {
const response = await api.get(`/api/prediction/comments/${commentId}/investments`);
return response.data;
} catch (error) {
console.error('获取投资列表失败:', error);
throw error;
}
};
/**
* 验证评论结果(仅创建者可操作)
* @param {number} commentId - 评论ID
* @param {string} result - 'correct' | 'incorrect'
*/
export const verifyComment = async (commentId, result) => {
try {
const response = await api.post(`/api/prediction/comments/${commentId}/verify`, { result });
return response.data;
} catch (error) {
console.error('验证评论失败:', error);
throw error;
}
};
// ==================== 工具函数(价格计算保留在前端,用于实时预览)====================
export const MARKET_CONFIG = {
MAX_SEATS_PER_SIDE: 5,
TAX_RATE: 0.02,
MIN_PRICE: 50,
MAX_PRICE: 950,
BASE_PRICE: 500,
};
/**
* 计算当前价格简化版AMM
* @param {number} yesShares - Yes方总份额
* @param {number} noShares - No方总份额
* @returns {Object} {yes: price, no: price}
*/
export const calculatePrice = (yesShares, noShares) => {
const totalShares = yesShares + noShares;
if (totalShares === 0) {
return {
yes: MARKET_CONFIG.BASE_PRICE,
no: MARKET_CONFIG.BASE_PRICE,
};
}
const yesProb = yesShares / totalShares;
const noProb = noShares / totalShares;
let yesPrice = yesProb * 1000;
let noPrice = noProb * 1000;
yesPrice = Math.max(MARKET_CONFIG.MIN_PRICE, Math.min(MARKET_CONFIG.MAX_PRICE, yesPrice));
noPrice = Math.max(MARKET_CONFIG.MIN_PRICE, Math.min(MARKET_CONFIG.MAX_PRICE, noPrice));
return { yes: Math.round(yesPrice), no: Math.round(noPrice) };
};
/**
* 计算交易税
* @param {number} amount - 交易金额
* @returns {number} 税费
*/
export const calculateTax = (amount) => {
return Math.floor(amount * MARKET_CONFIG.TAX_RATE);
};
/**
* 计算买入成本(用于前端预览)
* @param {number} currentShares - 当前方总份额
* @param {number} otherShares - 对手方总份额
* @param {number} buyAmount - 买入数量
* @returns {Object} { amount, tax, total }
*/
export const calculateBuyCost = (currentShares, otherShares, buyAmount) => {
const currentPrice = calculatePrice(currentShares, otherShares);
const afterShares = currentShares + buyAmount;
const afterPrice = calculatePrice(afterShares, otherShares);
const avgPrice = (currentPrice.yes + afterPrice.yes) / 2;
const amount = avgPrice * buyAmount;
const tax = calculateTax(amount);
const total = amount + tax;
return {
amount: Math.round(amount),
tax: Math.round(tax),
total: Math.round(total),
avgPrice: Math.round(avgPrice),
};
};
export default {
// 积分系统
getUserAccount,
claimDailyBonus,
// 话题管理
createTopic,
getTopics,
getTopicDetail,
settleTopic,
// 交易
buyShares,
getUserPositions,
// 评论
createComment,
getComments,
likeComment,
// 观点IPO
investComment,
getCommentInvestments,
verifyComment,
// 工具函数
calculatePrice,
calculateTax,
calculateBuyCost,
MARKET_CONFIG,
};

View File

@@ -0,0 +1,738 @@
/**
* 预测市场服务
* 核心功能:话题管理、席位交易、动态定价、领主系统、奖池分配
*/
import {
addCredits,
deductCredits,
canAfford,
addPosition,
removePosition,
updatePosition,
getUserPositions,
recordWin,
recordLoss,
CREDIT_CONFIG,
} from './creditSystemService';
// ==================== 常量配置 ====================
export const MARKET_CONFIG = {
MAX_SEATS_PER_SIDE: 5, // 每个方向最多5个席位
TAX_RATE: 0.02, // 交易税率 2%
MIN_PRICE: 50, // 最低价格
MAX_PRICE: 950, // 最高价格
BASE_PRICE: 500, // 基础价格
};
// 话题存储生产环境应使用Elasticsearch
const topics = new Map();
// 席位存储
const positions = new Map();
// 交易记录
const trades = [];
// ==================== 动态定价算法 ====================
/**
* 计算当前价格简化版AMM
* @param {number} yesShares - Yes方总份额
* @param {number} noShares - No方总份额
* @returns {Object} {yes: price, no: price}
*/
export const calculatePrice = (yesShares, noShares) => {
const totalShares = yesShares + noShares;
if (totalShares === 0) {
// 初始状态,双方价格相同
return {
yes: MARKET_CONFIG.BASE_PRICE,
no: MARKET_CONFIG.BASE_PRICE,
};
}
// 概率加权定价
const yesProb = yesShares / totalShares;
const noProb = noShares / totalShares;
// 价格 = 概率 * 1000限制在 [MIN_PRICE, MAX_PRICE]
const yesPrice = Math.max(
MARKET_CONFIG.MIN_PRICE,
Math.min(MARKET_CONFIG.MAX_PRICE, yesProb * 1000)
);
const noPrice = Math.max(
MARKET_CONFIG.MIN_PRICE,
Math.min(MARKET_CONFIG.MAX_PRICE, noProb * 1000)
);
return { yes: yesPrice, no: noPrice };
};
/**
* 计算购买成本(含滑点)
* @param {number} currentShares - 当前份额
* @param {number} otherShares - 对手方份额
* @param {number} buyAmount - 购买数量
* @returns {number} 总成本
*/
export const calculateBuyCost = (currentShares, otherShares, buyAmount) => {
let totalCost = 0;
let tempShares = currentShares;
// 模拟逐步购买,累计成本
for (let i = 0; i < buyAmount; i++) {
tempShares += 1;
const prices = calculatePrice(tempShares, otherShares);
// 假设购买的是yes方
totalCost += prices.yes;
}
return totalCost;
};
/**
* 计算卖出收益(含滑点)
* @param {number} currentShares - 当前份额
* @param {number} otherShares - 对手方份额
* @param {number} sellAmount - 卖出数量
* @returns {number} 总收益
*/
export const calculateSellRevenue = (currentShares, otherShares, sellAmount) => {
let totalRevenue = 0;
let tempShares = currentShares;
// 模拟逐步卖出,累计收益
for (let i = 0; i < sellAmount; i++) {
const prices = calculatePrice(tempShares, otherShares);
totalRevenue += prices.yes;
tempShares -= 1;
}
return totalRevenue;
};
/**
* 计算交易税
* @param {number} amount - 交易金额
* @returns {number} 税费
*/
export const calculateTax = (amount) => {
return Math.floor(amount * MARKET_CONFIG.TAX_RATE);
};
// ==================== 话题管理 ====================
/**
* 创建预测话题
* @param {Object} topicData - 话题数据
* @returns {Object} 创建的话题
*/
export const createTopic = (topicData) => {
const { author_id, title, description, category, tags, deadline, settlement_date } = topicData;
// 扣除创建费用
deductCredits(author_id, CREDIT_CONFIG.CREATE_TOPIC_COST, '创建预测话题');
const topic = {
id: `topic_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
type: 'prediction',
// 基础信息
title,
description,
category,
tags: tags || [],
// 作者信息
author_id,
author_name: topicData.author_name,
author_avatar: topicData.author_avatar,
// 时间管理
created_at: new Date().toISOString(),
deadline: deadline || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 默认7天
settlement_date: settlement_date || new Date(Date.now() + 8 * 24 * 60 * 60 * 1000).toISOString(),
status: 'active',
// 预测选项
options: [
{ id: 'yes', label: '看涨 / Yes', color: '#48BB78' },
{ id: 'no', label: '看跌 / No', color: '#F56565' },
],
// 市场数据
total_pool: CREDIT_CONFIG.CREATE_TOPIC_COST, // 创建费用进入奖池
tax_rate: MARKET_CONFIG.TAX_RATE,
// 席位数据
positions: {
yes: {
seats: [],
total_shares: 0,
current_price: MARKET_CONFIG.BASE_PRICE,
lord_id: null,
},
no: {
seats: [],
total_shares: 0,
current_price: MARKET_CONFIG.BASE_PRICE,
lord_id: null,
},
},
// 交易统计
stats: {
total_volume: 0,
total_transactions: 0,
unique_traders: new Set(),
},
// 结果
settlement: {
result: null,
evidence: null,
settled_by: null,
settled_at: null,
},
};
topics.set(topic.id, topic);
return topic;
};
/**
* 获取话题详情
* @param {string} topicId - 话题ID
* @returns {Object} 话题详情
*/
export const getTopic = (topicId) => {
return topics.get(topicId);
};
/**
* 更新话题
* @param {string} topicId - 话题ID
* @param {Object} updates - 更新内容
*/
export const updateTopic = (topicId, updates) => {
const topic = getTopic(topicId);
const updated = { ...topic, ...updates };
topics.set(topicId, updated);
return updated;
};
/**
* 获取所有话题列表
* @param {Object} filters - 筛选条件
* @returns {Array} 话题列表
*/
export const getTopics = (filters = {}) => {
let topicList = Array.from(topics.values());
// 按状态筛选
if (filters.status) {
topicList = topicList.filter((t) => t.status === filters.status);
}
// 按分类筛选
if (filters.category) {
topicList = topicList.filter((t) => t.category === filters.category);
}
// 排序
const sortBy = filters.sortBy || 'created_at';
topicList.sort((a, b) => {
if (sortBy === 'created_at') {
return new Date(b.created_at) - new Date(a.created_at);
}
if (sortBy === 'total_pool') {
return b.total_pool - a.total_pool;
}
if (sortBy === 'total_volume') {
return b.stats.total_volume - a.stats.total_volume;
}
return 0;
});
return topicList;
};
// ==================== 席位管理 ====================
/**
* 创建席位
* @param {Object} positionData - 席位数据
* @returns {Object} 创建的席位
*/
const createPosition = (positionData) => {
const position = {
id: `pos_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
...positionData,
acquired_at: new Date().toISOString(),
last_traded_at: new Date().toISOString(),
is_lord: false,
};
positions.set(position.id, position);
return position;
};
/**
* 获取席位
* @param {string} positionId - 席位ID
* @returns {Object} 席位信息
*/
export const getPosition = (positionId) => {
return positions.get(positionId);
};
/**
* 分配席位取份额最高的前5名
* @param {Array} allPositions - 所有持仓
* @returns {Array} 席位列表
*/
const allocateSeats = (allPositions) => {
// 按份额排序
const sorted = [...allPositions].sort((a, b) => b.shares - a.shares);
// 取前5名
return sorted.slice(0, MARKET_CONFIG.MAX_SEATS_PER_SIDE);
};
/**
* 确定领主(份额最多的人)
* @param {Array} seats - 席位列表
* @returns {string|null} 领主用户ID
*/
const determineLord = (seats) => {
if (seats.length === 0) return null;
const lord = seats.reduce((max, seat) => (seat.shares > max.shares ? seat : max));
return lord.holder_id;
};
/**
* 更新领主标识
* @param {string} topicId - 话题ID
* @param {string} optionId - 选项ID
*/
const updateLordStatus = (topicId, optionId) => {
const topic = getTopic(topicId);
const sideData = topic.positions[optionId];
// 重新分配席位
const allPositions = Array.from(positions.values()).filter(
(p) => p.topic_id === topicId && p.option_id === optionId
);
const seats = allocateSeats(allPositions);
const lordId = determineLord(seats);
// 更新所有席位的领主标识
allPositions.forEach((position) => {
const isLord = position.holder_id === lordId;
positions.set(position.id, { ...position, is_lord: isLord });
});
// 更新话题数据
updateTopic(topicId, {
positions: {
...topic.positions,
[optionId]: {
...sideData,
seats,
lord_id: lordId,
},
},
});
return lordId;
};
// ==================== 交易执行 ====================
/**
* 购买席位
* @param {Object} tradeData - 交易数据
* @returns {Object} 交易结果
*/
export const buyPosition = (tradeData) => {
const { user_id, user_name, user_avatar, topic_id, option_id, shares } = tradeData;
// 验证
const topic = getTopic(topic_id);
if (!topic) throw new Error('话题不存在');
if (topic.status !== 'active') throw new Error('话题已关闭交易');
if (topic.author_id === user_id) throw new Error('不能参与自己发起的话题');
// 检查购买上限
if (shares * MARKET_CONFIG.BASE_PRICE > CREDIT_CONFIG.MAX_SINGLE_BET) {
throw new Error(`单次购买上限为${CREDIT_CONFIG.MAX_SINGLE_BET}积分`);
}
// 获取当前市场数据
const sideData = topic.positions[option_id];
const otherOptionId = option_id === 'yes' ? 'no' : 'yes';
const otherSideData = topic.positions[otherOptionId];
// 计算成本
const cost = calculateBuyCost(sideData.total_shares, otherSideData.total_shares, shares);
const tax = calculateTax(cost);
const totalCost = cost + tax;
// 检查余额
if (!canAfford(user_id, totalCost)) {
throw new Error(`积分不足,需要${totalCost}积分`);
}
// 扣除积分
deductCredits(user_id, totalCost, `购买预测席位 - ${topic.title}`);
// 税费进入奖池
updateTopic(topic_id, {
total_pool: topic.total_pool + tax,
stats: {
...topic.stats,
total_volume: topic.stats.total_volume + totalCost,
total_transactions: topic.stats.total_transactions + 1,
unique_traders: topic.stats.unique_traders.add(user_id),
},
});
// 查找用户是否已有该选项的席位
let userPosition = Array.from(positions.values()).find(
(p) => p.topic_id === topic_id && p.option_id === option_id && p.holder_id === user_id
);
if (userPosition) {
// 更新现有席位
const newShares = userPosition.shares + shares;
const newAvgCost = (userPosition.avg_cost * userPosition.shares + cost) / newShares;
positions.set(userPosition.id, {
...userPosition,
shares: newShares,
avg_cost: newAvgCost,
last_traded_at: new Date().toISOString(),
});
// 更新用户账户持仓
updatePosition(user_id, userPosition.id, {
shares: newShares,
avg_cost: newAvgCost,
});
} else {
// 创建新席位
const newPosition = createPosition({
topic_id,
option_id,
holder_id: user_id,
holder_name: user_name,
holder_avatar: user_avatar,
shares,
avg_cost: cost / shares,
current_value: cost,
unrealized_pnl: 0,
});
// 添加到用户账户
addPosition(user_id, {
id: newPosition.id,
topic_id,
option_id,
shares,
avg_cost: cost / shares,
});
userPosition = newPosition;
}
// 更新话题席位数据
updateTopic(topic_id, {
positions: {
...topic.positions,
[option_id]: {
...sideData,
total_shares: sideData.total_shares + shares,
},
},
});
// 更新价格
const newPrices = calculatePrice(
topic.positions[option_id].total_shares + shares,
topic.positions[otherOptionId].total_shares
);
updateTopic(topic_id, {
positions: {
...topic.positions,
yes: { ...topic.positions.yes, current_price: newPrices.yes },
no: { ...topic.positions.no, current_price: newPrices.no },
},
});
// 更新领主状态
const newLordId = updateLordStatus(topic_id, option_id);
// 记录交易
const trade = {
id: `trade_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
topic_id,
option_id,
buyer_id: user_id,
seller_id: null,
type: 'buy',
shares,
price: cost / shares,
total_cost: totalCost,
tax,
created_at: new Date().toISOString(),
};
trades.push(trade);
return {
success: true,
position: userPosition,
trade,
new_lord_id: newLordId,
current_price: newPrices[option_id],
};
};
/**
* 卖出席位
* @param {Object} tradeData - 交易数据
* @returns {Object} 交易结果
*/
export const sellPosition = (tradeData) => {
const { user_id, topic_id, option_id, shares } = tradeData;
// 验证
const topic = getTopic(topic_id);
if (!topic) throw new Error('话题不存在');
if (topic.status !== 'active') throw new Error('话题已关闭交易');
// 查找用户席位
const userPosition = Array.from(positions.values()).find(
(p) => p.topic_id === topic_id && p.option_id === option_id && p.holder_id === user_id
);
if (!userPosition) throw new Error('未持有该席位');
if (userPosition.shares < shares) throw new Error('持有份额不足');
// 获取当前市场数据
const sideData = topic.positions[option_id];
const otherOptionId = option_id === 'yes' ? 'no' : 'yes';
const otherSideData = topic.positions[otherOptionId];
// 计算收益
const revenue = calculateSellRevenue(sideData.total_shares, otherSideData.total_shares, shares);
const tax = calculateTax(revenue);
const netRevenue = revenue - tax;
// 返还积分
addCredits(user_id, netRevenue, `卖出预测席位 - ${topic.title}`);
// 税费进入奖池
updateTopic(topic_id, {
total_pool: topic.total_pool + tax,
stats: {
...topic.stats,
total_volume: topic.stats.total_volume + revenue,
total_transactions: topic.stats.total_transactions + 1,
},
});
// 更新席位
const newShares = userPosition.shares - shares;
if (newShares === 0) {
// 完全卖出,删除席位
positions.delete(userPosition.id);
removePosition(user_id, userPosition.id);
} else {
// 部分卖出,更新份额
positions.set(userPosition.id, {
...userPosition,
shares: newShares,
last_traded_at: new Date().toISOString(),
});
updatePosition(user_id, userPosition.id, { shares: newShares });
}
// 更新话题席位数据
updateTopic(topic_id, {
positions: {
...topic.positions,
[option_id]: {
...sideData,
total_shares: sideData.total_shares - shares,
},
},
});
// 更新价格
const newPrices = calculatePrice(
topic.positions[option_id].total_shares - shares,
topic.positions[otherOptionId].total_shares
);
updateTopic(topic_id, {
positions: {
...topic.positions,
yes: { ...topic.positions.yes, current_price: newPrices.yes },
no: { ...topic.positions.no, current_price: newPrices.no },
},
});
// 更新领主状态
const newLordId = updateLordStatus(topic_id, option_id);
// 记录交易
const trade = {
id: `trade_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
topic_id,
option_id,
buyer_id: null,
seller_id: user_id,
type: 'sell',
shares,
price: revenue / shares,
total_cost: netRevenue,
tax,
created_at: new Date().toISOString(),
};
trades.push(trade);
return {
success: true,
trade,
new_lord_id: newLordId,
current_price: newPrices[option_id],
};
};
// ==================== 结算 ====================
/**
* 结算话题
* @param {string} topicId - 话题ID
* @param {string} result - 结果 'yes' | 'no'
* @param {string} evidence - 证据说明
* @param {string} settledBy - 裁决者ID
* @returns {Object} 结算结果
*/
export const settleTopic = (topicId, result, evidence, settledBy) => {
const topic = getTopic(topicId);
if (!topic) throw new Error('话题不存在');
if (topic.status === 'settled') throw new Error('话题已结算');
// 只有作者可以结算
if (topic.author_id !== settledBy) throw new Error('无权结算');
// 获取获胜方和失败方
const winningOption = result;
const losingOption = result === 'yes' ? 'no' : 'yes';
const winners = Array.from(positions.values()).filter(
(p) => p.topic_id === topicId && p.option_id === winningOption
);
const losers = Array.from(positions.values()).filter(
(p) => p.topic_id === topicId && p.option_id === losingOption
);
// 分配奖池
if (winners.length === 0) {
// 无人获胜,奖池返还给作者
addCredits(topic.author_id, topic.total_pool, '话题奖池返还');
} else {
// 计算获胜方总份额
const totalWinningShares = winners.reduce((sum, p) => sum + p.shares, 0);
// 按份额分配
winners.forEach((position) => {
const share = position.shares / totalWinningShares;
const reward = Math.floor(topic.total_pool * share);
// 返还本金 + 奖池分成
const refund = Math.floor(position.avg_cost * position.shares);
const total = refund + reward;
addCredits(position.holder_id, total, `预测获胜 - ${topic.title}`);
// 记录胜利
recordWin(position.holder_id, reward);
// 删除席位
positions.delete(position.id);
removePosition(position.holder_id, position.id);
});
}
// 失败方损失本金
losers.forEach((position) => {
const loss = Math.floor(position.avg_cost * position.shares);
// 记录失败
recordLoss(position.holder_id, loss);
// 删除席位
positions.delete(position.id);
removePosition(position.holder_id, position.id);
});
// 更新话题状态
updateTopic(topicId, {
status: 'settled',
settlement: {
result,
evidence,
settled_by: settledBy,
settled_at: new Date().toISOString(),
},
});
return {
success: true,
winners_count: winners.length,
losers_count: losers.length,
total_distributed: topic.total_pool,
};
};
// ==================== 数据导出 ====================
export default {
MARKET_CONFIG,
// 定价算法
calculatePrice,
calculateBuyCost,
calculateSellRevenue,
calculateTax,
// 话题管理
createTopic,
getTopic,
updateTopic,
getTopics,
// 席位管理
getPosition,
// 交易
buyPosition,
sellPosition,
// 结算
settleTopic,
};

View File

@@ -1,49 +0,0 @@
/* Brainwave 色彩变量定义 */
:root {
/* Brainwave 中性色系 */
--color-n-1: #FFFFFF;
--color-n-2: #CAC6DD;
--color-n-3: #ADA8C3;
--color-n-4: #757185;
--color-n-5: #3F3A52;
--color-n-6: #252134;
--color-n-7: #15131D;
--color-n-8: #0E0C15;
/* Brainwave 主题色 */
--color-1: #AC6AFF;
--color-2: #FFC876;
--color-3: #FF776F;
--color-4: #7ADB78;
--color-5: #858DFF;
--color-6: #FF98E2;
/* 描边色 */
--stroke-1: #26242C;
}
/* CSS类名映射到变量 */
.bg-n-8 { background-color: var(--color-n-8) !important; }
.bg-n-7 { background-color: var(--color-n-7) !important; }
.bg-n-6 { background-color: var(--color-n-6) !important; }
.text-n-1 { color: var(--color-n-1) !important; }
.text-n-2 { color: var(--color-n-2) !important; }
.text-n-3 { color: var(--color-n-3) !important; }
.text-n-4 { color: var(--color-n-4) !important; }
.border-n-6 { border-color: var(--color-n-6) !important; }
.border-n-1\/10 { border-color: rgba(255, 255, 255, 0.1) !important; }
.border-n-2\/5 { border-color: rgba(202, 198, 221, 0.05) !important; }
.border-n-2\/10 { border-color: rgba(202, 198, 221, 0.1) !important; }
.bg-stroke-1 { background-color: var(--stroke-1) !important; }
/* 渐变背景 */
.bg-conic-gradient {
background: conic-gradient(from 225deg, #FFC876, #79FFF7, #9F53FF, #FF98E2, #FFC876) !important;
}
.bg-gradient-to-br {
background-image: linear-gradient(to bottom right, var(--tw-gradient-stops)) !important;
}

View File

@@ -1,35 +1,12 @@
/* Tailwind CSS 入口文件 */
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
}
/* styles for splide carousel */
@layer components {
.splide-custom .splide__arrow {
@apply relative top-0 left-0 right-0 flex items-center justify-center w-12 h-12 bg-transparent border border-solid border-n-4/50 rounded-full transform-none transition-colors hover:border-n-3;
}
.splide-custom .splide__arrow:hover svg {
@apply fill-n-1;
}
.splide-custom .splide__arrow svg {
@apply w-4 h-4 fill-n-4 transform-none transition-colors;
}
.splide-visible .splide__track {
@apply overflow-visible;
}
.splide-pricing .splide__list {
@apply lg:grid !important;
@apply lg:grid-cols-3 lg:gap-4;
}
.splide-benefits .splide__list {
@apply md:grid !important;
@apply md:grid-cols-3 md:gap-x-10 md:gap-y-[4.5rem] xl:gap-y-[6rem];
}
/* 自定义工具类 */
@layer utilities {
/* 毛玻璃效果 */
.backdrop-blur-xl {
backdrop-filter: blur(24px);
}
}

View File

@@ -12,13 +12,16 @@
[class*="bytedesk"],
[id*="bytedesk"],
[class*="BytedeskWeb"] {
position: fixed !important;
z-index: 999999 !important;
pointer-events: auto !important;
}
/* Bytedesk iframe - 聊天窗口 */
iframe[src*="bytedesk"],
iframe[src*="/chat/"],
iframe[src*="/visitor/"] {
position: fixed !important;
z-index: 999999 !important;
}

89
src/styles/select-fix.css Normal file
View File

@@ -0,0 +1,89 @@
/**
* 修复 Chakra UI Select 组件的下拉选项颜色问题
* 黑金主题下,下拉选项需要深色背景和白色文字
*/
/* 所有 select 元素的 option 样式 */
select option {
background-color: #1A1A1A !important; /* 深色背景 */
color: #FFFFFF !important; /* 白色文字 */
padding: 8px !important;
}
/* 选中的 option */
select option:checked {
background-color: #2A2A2A !important;
color: #FFC107 !important; /* 金色高亮 */
}
/* hover 状态的 option (某些浏览器支持) */
select option:hover {
background-color: #222222 !important;
color: #FFD700 !important;
}
/* 禁用的 option */
select option:disabled {
color: #808080 !important;
background-color: #151515 !important;
}
/* Firefox 特殊处理 */
@-moz-document url-prefix() {
select option {
background-color: #1A1A1A !important;
color: #FFFFFF !important;
}
}
/* Webkit/Chrome 特殊处理 */
select {
/* 自定义下拉箭头颜色 */
color-scheme: dark;
}
/* 修复 Chakra UI Select 组件的特定样式 */
.chakra-select {
background-color: #1A1A1A !important;
color: #FFFFFF !important;
border-color: #333333 !important;
}
.chakra-select:hover {
border-color: #404040 !important;
}
.chakra-select:focus {
border-color: #FFC107 !important;
box-shadow: 0 0 0 1px rgba(255, 193, 7, 0.3) !important;
}
/* 下拉箭头图标 */
.chakra-select__icon-wrapper {
color: #FFFFFF !important;
}
/* 修复所有表单 select 元素 */
select[class*="chakra-select"],
select[class*="select"] {
background-color: #1A1A1A !important;
color: #FFFFFF !important;
}
/* 自定义滚动条 (适用于下拉列表) */
select::-webkit-scrollbar {
width: 8px;
}
select::-webkit-scrollbar-track {
background: #0A0A0A;
}
select::-webkit-scrollbar-thumb {
background: #333333;
border-radius: 4px;
}
select::-webkit-scrollbar-thumb:hover {
background: #FFC107;
}

View File

@@ -1,83 +0,0 @@
import { useRef, useState } from "react";
import Link from "next/link";
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
import Section from "@/components/Section";
import Image from "@/components/Image";
import { benefits } from "@/mocks/benefits";
type BenefitsProps = {};
const Benefits = ({}: BenefitsProps) => {
const [activeIndex, setActiveIndex] = useState<number>(0);
const ref = useRef<any>(null);
const handleClick = (index: number) => {
setActiveIndex(index);
ref.current?.go(index);
};
return (
<Section className="overflow-hidden">
<div className="container relative z-2">
<Splide
className="splide-benefits splide-visible max-w-[16rem] md:max-w-none"
options={{
mediaQuery: "min",
pagination: false,
arrows: false,
gap: "1.5rem",
breakpoints: {
768: {
destroy: true,
},
},
}}
onMoved={(e, newIndex) => setActiveIndex(newIndex)}
hasTrack={false}
ref={ref}
>
<SplideTrack>
{benefits.map((item) => (
<SplideSlide key={item.id}>
<div className="flex items-center mb-6">
<Image
src={item.iconUrl}
width={48}
height={48}
alt={item.title}
/>
</div>
<h5 className="h6 mb-4">{item.title}</h5>
<p className="body-2 text-n-3">{item.text}</p>
</SplideSlide>
))}
</SplideTrack>
</Splide>
<div className="flex mt-12 -mx-2 md:hidden">
{benefits.map((item, index) => (
<button
className="relative w-6 h-6 mx-2"
onClick={() => handleClick(index)}
key={item.id}
>
<span
className={`absolute inset-0 bg-conic-gradient rounded-full transition-opacity ${
index === activeIndex
? "opacity-100"
: "opacity-0"
}`}
></span>
<span className="absolute inset-0.25 bg-n-8 rounded-full">
<span className="absolute inset-2 bg-n-1 rounded-full"></span>
</span>
</button>
))}
</div>
</div>
</Section>
);
};
export default Benefits;

View File

@@ -1,83 +0,0 @@
import { useRef, useState } from "react";
import Link from "next/link";
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
import Section from "@/components/Section";
import Image from "@/components/Image";
import { benefits } from "@/mocks/benefits";
type BenefitsProps = {};
const Benefits = ({}: BenefitsProps) => {
const [activeIndex, setActiveIndex] = useState<number>(0);
const ref = useRef<any>(null);
const handleClick = (index: number) => {
setActiveIndex(index);
ref.current?.go(index);
};
return (
<Section className="overflow-hidden">
<div className="container relative z-2">
<Splide
className="splide-benefits splide-visible max-w-[16rem] md:max-w-none"
options={{
mediaQuery: "min",
pagination: false,
arrows: false,
gap: "1.5rem",
breakpoints: {
768: {
destroy: true,
},
},
}}
onMoved={(e, newIndex) => setActiveIndex(newIndex)}
hasTrack={false}
ref={ref}
>
<SplideTrack>
{benefits.map((item) => (
<SplideSlide key={item.id}>
<div className="flex items-center mb-6">
<Image
src={item.iconUrl}
width={48}
height={48}
alt={item.title}
/>
</div>
<h5 className="h6 mb-4">{item.title}</h5>
<p className="body-2 text-n-3">{item.text}</p>
</SplideSlide>
))}
</SplideTrack>
</Splide>
<div className="flex mt-12 -mx-2 md:hidden">
{benefits.map((item, index) => (
<button
className="relative w-6 h-6 mx-2"
onClick={() => handleClick(index)}
key={item.id}
>
<span
className={`absolute inset-0 bg-conic-gradient rounded-full transition-opacity ${
index === activeIndex
? "opacity-100"
: "opacity-0"
}`}
></span>
<span className="absolute inset-0.25 bg-n-8 rounded-full">
<span className="absolute inset-2 bg-n-1 rounded-full"></span>
</span>
</button>
))}
</div>
</div>
</Section>
);
};
export default Benefits;

View File

@@ -1,107 +0,0 @@
import { useRef, useState } from "react";
import { Splide, SplideSlide } from "@splidejs/react-splide";
import Section from "@/components/Section";
import Image from "@/components/Image";
import { community } from "@/mocks/community";
type CommunityProps = {};
const Community = ({}: CommunityProps) => {
const [activeIndex, setActiveIndex] = useState<number>(0);
const ref = useRef<any>(null);
const handleClick = (index: number) => {
setActiveIndex(index);
ref.current?.go(index);
};
return (
<Section className="">
<div className="container">
<div className="relative p-0.5 bg-gradient-to-b from-color-2/80 from-[4.5rem] via-color-1/40 via-[9rem] to-n-1/15 rounded-3xl">
<div className="pt-20 px-5 py-10 bg-n-8 rounded-[1.375rem] md:pt-20 md:px-20 mb:pb-16 lg:py-28 lg:pr-48">
<Splide
options={{
type: "fade",
pagination: false,
arrows: false,
}}
onMoved={(e, newIndex) => setActiveIndex(newIndex)}
ref={ref}
>
{community.map((comment) => (
<SplideSlide className="flex" key={comment.id}>
<div className="flex flex-col lg:flex-row lg:items-start">
<div className="quote mb-6 md:mb-12 lg:mb-0 lg:text-[1.75rem] lg:leading-[2.25rem]">
{comment.text}
</div>
<div className="flex items-center mt-auto lg:block lg:mt-0 lg:ml-20">
<div className="w-20 mr-6 lg:w-40 lg:mr-0 lg:mb-11">
<Image
className="w-full rounded-2xl"
src={comment.avatarUrl}
width={160}
height={160}
alt={comment.name}
/>
</div>
<div>
<h6 className="h6">
{comment.name}
</h6>
<div className="caption text-n-1/25">
{comment.role}
</div>
</div>
</div>
</div>
</SplideSlide>
))}
</Splide>
<div
className="flex justify-center mt-10 -mx-2 md:mt-12 md:justify-start lg:absolute lg:top-0
lg:right-20 lg:h-full lg:flex-col lg:justify-center lg:m-0"
>
{community.map((item: any, index: number) => (
<button
className="relative w-6 h-6 mx-2 lg:my-2 lg:mx-0"
onClick={() => handleClick(index)}
key={item.id}
>
<span
className={`absolute inset-0 bg-conic-gradient rounded-full transition-opacity ${
index === activeIndex
? "opacity-100"
: "opacity-0"
}`}
></span>
<span className="absolute inset-0.25 bg-n-8 rounded-full">
<span className="absolute inset-2 bg-n-1 rounded-full"></span>
</span>
</button>
))}
</div>
</div>
<div className="absolute -top-14 left-0 z-2 font-code text-[11.25rem] text-color-1 leading-none md:left-12">
</div>
<div className="absolute top-0 right-0 bg-n-8">
<svg width="72" height="72" viewBox="0 0 72 72">
<path
fill="#0E0C15"
stroke="#FFC876"
strokeWidth="2"
strokeOpacity=".8"
d="M-1176,1 L6.15,1 C13.89,1 21.35,3.89547 27.06,9.11714 L60.91,40.0541 C67.34,45.9271 71,54.2315 71,62.937 L71,444 C71,461.121 57.12,475 40,475 L-1176,475 C-1193.1209,475 -1207,461.121 -1207,444 L-1207,32 C-1207,14.8792 -1193.1208,1 -1176,1 Z"
/>
</svg>
</div>
</div>
</div>
</Section>
);
};
export default Community;

View File

@@ -1,107 +0,0 @@
import { useRef, useState } from "react";
import { Splide, SplideSlide } from "@splidejs/react-splide";
import Section from "@/components/Section";
import Image from "@/components/Image";
import { community } from "@/mocks/community";
type CommunityProps = {};
const Community = ({}: CommunityProps) => {
const [activeIndex, setActiveIndex] = useState<number>(0);
const ref = useRef<any>(null);
const handleClick = (index: number) => {
setActiveIndex(index);
ref.current?.go(index);
};
return (
<Section className="">
<div className="container">
<div className="relative p-0.5 bg-gradient-to-b from-color-2/80 from-[4.5rem] via-color-1/40 via-[9rem] to-n-1/15 rounded-3xl">
<div className="pt-20 px-5 py-10 bg-n-8 rounded-[1.375rem] md:pt-20 md:px-20 mb:pb-16 lg:py-28 lg:pr-48">
<Splide
options={{
type: "fade",
pagination: false,
arrows: false,
}}
onMoved={(e, newIndex) => setActiveIndex(newIndex)}
ref={ref}
>
{community.map((comment) => (
<SplideSlide className="flex" key={comment.id}>
<div className="flex flex-col lg:flex-row lg:items-start">
<div className="quote mb-6 md:mb-12 lg:mb-0 lg:text-[1.75rem] lg:leading-[2.25rem]">
{comment.text}
</div>
<div className="flex items-center mt-auto lg:block lg:mt-0 lg:ml-20">
<div className="w-20 mr-6 lg:w-40 lg:mr-0 lg:mb-11">
<Image
className="w-full rounded-2xl"
src={comment.avatarUrl}
width={160}
height={160}
alt={comment.name}
/>
</div>
<div>
<h6 className="h6">
{comment.name}
</h6>
<div className="caption text-n-1/25">
{comment.role}
</div>
</div>
</div>
</div>
</SplideSlide>
))}
</Splide>
<div
className="flex justify-center mt-10 -mx-2 md:mt-12 md:justify-start lg:absolute lg:top-0
lg:right-20 lg:h-full lg:flex-col lg:justify-center lg:m-0"
>
{community.map((item: any, index: number) => (
<button
className="relative w-6 h-6 mx-2 lg:my-2 lg:mx-0"
onClick={() => handleClick(index)}
key={item.id}
>
<span
className={`absolute inset-0 bg-conic-gradient rounded-full transition-opacity ${
index === activeIndex
? "opacity-100"
: "opacity-0"
}`}
></span>
<span className="absolute inset-0.25 bg-n-8 rounded-full">
<span className="absolute inset-2 bg-n-1 rounded-full"></span>
</span>
</button>
))}
</div>
</div>
<div className="absolute -top-14 left-0 z-2 font-code text-[11.25rem] text-color-1 leading-none md:left-12">
</div>
<div className="absolute top-0 right-0 bg-n-8">
<svg width="72" height="72" viewBox="0 0 72 72">
<path
fill="#0E0C15"
stroke="#FFC876"
strokeWidth="2"
strokeOpacity=".8"
d="M-1176,1 L6.15,1 C13.89,1 21.35,3.89547 27.06,9.11714 L60.91,40.0541 C67.34,45.9271 71,54.2315 71,62.937 L71,444 C71,461.121 57.12,475 40,475 L-1176,475 C-1193.1209,475 -1207,461.121 -1207,444 L-1207,32 C-1207,14.8792 -1193.1208,1 -1176,1 Z"
/>
</svg>
</div>
</div>
</div>
</Section>
);
};
export default Community;

View File

@@ -1,101 +0,0 @@
import Section from "@/components/Section";
import Image from "@/components/Image";
type FeaturesProps = {};
const Features = ({}: FeaturesProps) => {
const content = [
{
id: "0",
title: "Seamless Integration",
text: "With smart automation and top-notch security, it's the perfect solution for teams looking to work smarter.",
},
{
id: "1",
title: "Smart Automation",
},
{
id: "2",
title: "Top-notch Security",
},
];
return (
<Section>
<div className="container">
<div className="-mb-16">
{[
{ id: "0", imageUrl: "/images/features/image-1.jpg" },
{ id: "1", imageUrl: "/images/features/image-1.jpg" },
{ id: "2", imageUrl: "/images/features/image-1.jpg" },
].map((item, index) => (
<div
className="mb-16 md:grid md:grid-cols-2 md:items-center lg:gap-20 xl:gap-40"
key={item.id}
>
<div
className={`mb-8 bg-n-6 rounded-3xl md:relative md:mb-0 ${
index % 2 === 0 ? "" : "md:order-1"
}`}
>
<Image
className="w-full rounded-3xl"
src={item.imageUrl}
width={550}
height={600}
alt="Image"
/>
<div
className={`hidden absolute top-5 -right-8 bottom-5 grid-cols-2 w-8 md:grid ${
index % 2 === 0
? "-right-8"
: "-left-8 rotate-180"
}`}
>
<div className="rounded-r-[1.25rem] bg-[#1B1B2E]"></div>
<div className="my-5 rounded-r-[1.25rem] bg-[#1B1B2E]/50"></div>
</div>
</div>
<div
className={
index % 2 === 0 ? "md:pl-16" : "md:pr-16"
}
>
<h2 className="h2 mb-4 md:mb-8">
Customization Options
</h2>
<ul className="">
{content.map((item) => (
<li
className="py-4 border-b border-n-1/5 md:py-6"
key={item.id}
>
<div className="flex items-center">
<Image
src="/images/check.svg"
width={24}
height={24}
alt="Check"
/>
<h6 className="body-2 ml-5">
{item.title}
</h6>
</div>
{item.text && (
<p className="body-2 mt-3 text-n-4">
{item.text}
</p>
)}
</li>
))}
</ul>
</div>
</div>
))}
</div>
</div>
</Section>
);
};
export default Features;

View File

@@ -1,101 +0,0 @@
import Section from "@/components/Section";
import Image from "@/components/Image";
type FeaturesProps = {};
const Features = ({}: FeaturesProps) => {
const content = [
{
id: "0",
title: "Seamless Integration",
text: "With smart automation and top-notch security, it's the perfect solution for teams looking to work smarter.",
},
{
id: "1",
title: "Smart Automation",
},
{
id: "2",
title: "Top-notch Security",
},
];
return (
<Section>
<div className="container">
<div className="-mb-16">
{[
{ id: "0", imageUrl: "/images/features/image-1.jpg" },
{ id: "1", imageUrl: "/images/features/image-1.jpg" },
{ id: "2", imageUrl: "/images/features/image-1.jpg" },
].map((item, index) => (
<div
className="mb-16 md:grid md:grid-cols-2 md:items-center lg:gap-20 xl:gap-40"
key={item.id}
>
<div
className={`mb-8 bg-n-6 rounded-3xl md:relative md:mb-0 ${
index % 2 === 0 ? "" : "md:order-1"
}`}
>
<Image
className="w-full rounded-3xl"
src={item.imageUrl}
width={550}
height={600}
alt="Image"
/>
<div
className={`hidden absolute top-5 -right-8 bottom-5 grid-cols-2 w-8 md:grid ${
index % 2 === 0
? "-right-8"
: "-left-8 rotate-180"
}`}
>
<div className="rounded-r-[1.25rem] bg-[#1B1B2E]"></div>
<div className="my-5 rounded-r-[1.25rem] bg-[#1B1B2E]/50"></div>
</div>
</div>
<div
className={
index % 2 === 0 ? "md:pl-16" : "md:pr-16"
}
>
<h2 className="h2 mb-4 md:mb-8">
Customization Options
</h2>
<ul className="">
{content.map((item) => (
<li
className="py-4 border-b border-n-1/5 md:py-6"
key={item.id}
>
<div className="flex items-center">
<Image
src="/images/check.svg"
width={24}
height={24}
alt="Check"
/>
<h6 className="body-2 ml-5">
{item.title}
</h6>
</div>
{item.text && (
<p className="body-2 mt-3 text-n-4">
{item.text}
</p>
)}
</li>
))}
</ul>
</div>
</div>
))}
</div>
</div>
</Section>
);
};
export default Features;

View File

@@ -1,38 +0,0 @@
import Heading from "@/components/Heading";
import Image from "@/components/Image";
import Section from "@/components/Section";
type HeroProps = {};
const Hero = ({}: HeroProps) => (
<Section className="overflow-hidden md:-mb-10 xl:-mb-20">
<div className="container relative z-2 md:grid md:grid-cols-2 md:items-center md:gap-10 lg:gap-48">
<Heading
className="md:mt-12 lg:max-w-[30rem] lg:mt-20"
textAlignClassName="md:text-left"
titleLarge="Main features of Brainwave"
textLarge="Here are some of the core features of Brainwavethat make it stand out from other chat applications"
/>
<div className="relative">
<Image
className="w-full md:min-w-[125%] xl:min-w-full"
src="/images/features/features.png"
width={547}
height={588}
alt="Features"
/>
<div className="absolute top-0 left-1/2 w-full">
<Image
className="w-full"
src="/images/grid.png"
width={550}
height={550}
alt="Grid"
/>
</div>
</div>
</div>
</Section>
);
export default Hero;

View File

@@ -1,38 +0,0 @@
import Heading from "@/components/Heading";
import Image from "@/components/Image";
import Section from "@/components/Section";
type HeroProps = {};
const Hero = ({}: HeroProps) => (
<Section className="overflow-hidden md:-mb-10 xl:-mb-20">
<div className="container relative z-2 md:grid md:grid-cols-2 md:items-center md:gap-10 lg:gap-48">
<Heading
className="md:mt-12 lg:max-w-[30rem] lg:mt-20"
textAlignClassName="md:text-left"
titleLarge="Main features of Brainwave"
textLarge="Here are some of the core features of Brainwavethat make it stand out from other chat applications"
/>
<div className="relative">
<Image
className="w-full md:min-w-[125%] xl:min-w-full"
src="/images/features/features.png"
width={547}
height={588}
alt="Features"
/>
<div className="absolute top-0 left-1/2 w-full">
<Image
className="w-full"
src="/images/grid.png"
width={550}
height={550}
alt="Grid"
/>
</div>
</div>
</div>
</Section>
);
export default Hero;

View File

@@ -1,24 +0,0 @@
"use client";
import Layout from "@/components/Layout";
import Services from "@/components/Services";
import Join from "@/components/Join";
import Hero from "./Hero";
import Benefits from "./Benefits";
import Features from "./Features";
import Community from "./Community";
const FeaturesPage = () => {
return (
<Layout>
<Hero />
<Benefits />
<Features />
<Community />
<Services containerClassName="md:pb-10" />
<Join />
</Layout>
);
};
export default FeaturesPage;

View File

@@ -1,24 +0,0 @@
"use client";
import Layout from "@/components/Layout";
import Services from "@/components/Services";
import Join from "@/components/Join";
import Hero from "./Hero";
import Benefits from "./Benefits";
import Features from "./Features";
import Community from "./Community";
const FeaturesPage = () => {
return (
<Layout>
<Hero />
<Benefits />
<Features />
<Community />
<Services containerClassName="md:pb-10" />
<Join />
</Layout>
);
};
export default FeaturesPage;

View File

@@ -1,165 +0,0 @@
import { useRef, useState } from "react";
import { Link } from "react-router-dom";
// import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
import Section from "../../../components/Section";
import Heading from "../../../components/Heading/index.js";
import Image from "../../../components/Image";
// 简化版数据避免依赖外部mock文件
const benefits = [
{
id: "0",
title: "智能问答",
text: "让用户能够快速找到问题答案,无需在多个信息源中搜索,提升投研效率。",
backgroundUrl: "/images/benefits/card-1.svg",
iconUrl: "/images/benefits/icon-1.svg",
imageUrl: "/images/benefits/image-2.png",
light: true,
},
{
id: "1",
title: "持续学习",
text: "系统采用自然语言处理技术理解用户查询,提供准确相关的投研分析结果。",
backgroundUrl: "/images/benefits/card-2.svg",
iconUrl: "/images/benefits/icon-2.svg",
imageUrl: "/images/benefits/image-2.png",
},
{
id: "2",
title: "全域连接",
text: "随时随地连接AI投研助手支持多设备访问让专业分析更便捷。",
backgroundUrl: "/images/benefits/card-3.svg",
iconUrl: "/images/benefits/icon-3.svg",
imageUrl: "/images/benefits/image-2.png",
},
{
id: "3",
title: "快速响应",
text: "毫秒级响应速度,让用户快速获得投研洞察,把握市场先机。",
backgroundUrl: "/images/benefits/card-4.svg",
iconUrl: "/images/benefits/icon-4.svg",
imageUrl: "/images/benefits/image-2.png",
light: true,
},
{
id: "4",
title: "深度分析",
text: "基于海量数据训练的专业投研模型,提供超越传统分析工具的深度洞察。",
backgroundUrl: "/images/benefits/card-5.svg",
iconUrl: "/images/benefits/icon-1.svg",
imageUrl: "/images/benefits/image-2.png",
},
{
id: "5",
title: "智能预测",
text: "结合机器学习算法,为投资决策提供智能预测和风险评估建议。",
backgroundUrl: "/images/benefits/card-6.svg",
iconUrl: "/images/benefits/icon-2.svg",
imageUrl: "/images/benefits/image-2.png",
},
];
const Benefits = () => {
const [activeIndex, setActiveIndex] = useState(0);
const handleClick = (index) => {
setActiveIndex(index);
};
return (
<Section className="overflow-hidden bg-n-8">
<div className="container relative z-2">
<Heading
className="md:max-w-md lg:max-w-2xl"
title="智能投研,让分析更简单"
text="利用先进的人工智能技术,为您提供专业的投资研究分析服务"
/>
{/* 简化版网格布局暂时不使用Splide */}
<div className="max-w-[24rem] md:max-w-none grid grid-cols-1 md:grid-cols-3 gap-6">
{benefits.map((item, index) => (
<div key={item.id}>
<Link
className="block relative p-0.5 bg-no-repeat bg-[length:100%_100%] md:max-w-[24rem] rounded-xl"
to="/features"
style={{
backgroundImage: `url(${item.backgroundUrl})`,
}}
>
<div className="relative z-2 flex flex-col h-[22.625rem] p-[2.375rem] pointer-events-none">
<h5 className="h5 mb-5">
{item.title}
</h5>
<p className="body-2 mb-6 text-n-3">
{item.text}
</p>
<div className="flex items-center mt-auto">
<Image
className=""
src={item.iconUrl}
width={48}
height={48}
alt={item.title}
/>
<div className="ml-auto font-code text-xs font-bold text-n-1 uppercase tracking-wider">
了解更多
</div>
<svg
className="ml-5 fill-n-1"
width="24"
height="24"
>
<path d="M8.293 5.293a1 1 0 0 1 1.414 0l6 6a1 1 0 0 1 0 1.414l-6 6a1 1 0 0 1-1.414-1.414L13.586 12 8.293 6.707a1 1 0 0 1 0-1.414z" />
</svg>
</div>
</div>
{item.light && (
<div className="absolute top-0 left-1/4 w-full aspect-square bg-radial-gradient from-[#28206C] to-[#28206C]/0 to-70% pointer-events-none"></div>
)}
<div
className="absolute inset-0.5 bg-n-8 rounded-xl"
>
<div className="absolute inset-0 opacity-0 transition-opacity hover:opacity-10">
{item.imageUrl && (
<Image
className="w-full h-full object-cover rounded-xl"
src={item.imageUrl}
width={380}
height={362}
alt={item.title}
/>
)}
</div>
</div>
</Link>
</div>
))}
</div>
{/* 指示器 */}
<div className="flex mt-12 -mx-2 md:mt-15 lg:justify-center xl:mt-20">
{benefits.map((item, index) => (
<button
className="relative w-6 h-6 mx-2"
onClick={() => handleClick(index)}
key={item.id}
>
<span
className={`absolute inset-0 bg-conic-gradient rounded-full transition-opacity ${
index === activeIndex
? "opacity-100"
: "opacity-0"
}`}
></span>
<span className="absolute inset-0.25 bg-n-8 rounded-full">
<span className="absolute inset-2 bg-n-1 rounded-full"></span>
</span>
</button>
))}
</div>
</div>
</Section>
);
};
export default Benefits;

View File

@@ -1,130 +0,0 @@
import Section from "@/components/Section";
import Button from "@/components/Button";
import Image from "@/components/Image";
import { text, content, apps } from "@/mocks/collaboration";
type CollaborationProps = {};
const Collaboration = ({}: CollaborationProps) => {
return (
<Section crosses>
<div className="container lg:flex">
<div className="max-w-[25rem]">
<h2 className="h2 mb-4 md:mb-8">
AI chat app for seamless collaboration
</h2>
<ul className="max-w-[22.5rem] mb-10 md:mb-14">
{content.map((item) => (
<li className="mb-3 py-3" key={item.id}>
<div className="flex items-center">
<Image
src="/images/check.svg"
width={24}
height={24}
alt="Check"
/>
<h6 className="body-2 ml-5">
{item.title}
</h6>
</div>
{item.text && (
<p className="body-2 mt-3 text-n-4">
{item.text}
</p>
)}
</li>
))}
</ul>
<Button>Try it now</Button>
</div>
<div className="mt-15 lg:mt-0 lg:ml-auto xl:w-[37.5rem]">
<div className="relative lg:w-[22.5rem] lg:mx-auto">
<p className="body-2 mb-4 text-n-4 md:mb-16 lg:mb-32">
{text}
</p>
<div className="relative left-1/2 flex w-[22.5rem] aspect-square border border-n-6 rounded-full -translate-x-1/2 scale-75 md:scale-100">
<div className="flex w-60 aspect-square m-auto border border-n-6 rounded-full">
<div className="w-[5.75rem] aspect-square m-auto p-[0.1875rem] bg-conic-gradient rounded-full">
<div className="flex items-center justify-center w-full h-full bg-n-8 rounded-full">
<Image
src="/images/brainwave-symbol.svg"
width={48}
height={48}
alt="Brainwave"
/>
</div>
</div>
</div>
<ul>
{apps.map((app, index) => (
<li
className={`absolute top-0 left-1/2 h-1/2 -ml-[1.625rem] ${
index === 1 && "rotate-[45deg]"
} ${index === 2 && "rotate-[90deg]"} ${
index === 3 && "rotate-[135deg]"
} ${index === 4 && "rotate-[180deg]"} ${
index === 5 && "rotate-[225deg]"
} ${index === 6 && "rotate-[270deg]"} ${
index === 7 && "rotate-[315deg]"
} origin-bottom`}
key={app.id}
>
<div
className={`relative -top-[1.625rem] flex w-[3.25rem] h-[3.25rem] bg-n-7 border border-n-1/15 rounded-xl ${
index === 1 && "-rotate-[45deg]"
} ${
index === 2 && "-rotate-[90deg]"
} ${
index === 3 &&
"-rotate-[135deg]"
} ${
index === 4 &&
"-rotate-[180deg]"
} ${
index === 5 &&
"-rotate-[225deg]"
} ${
index === 6 &&
"-rotate-[270deg]"
} ${
index === 7 &&
"-rotate-[315deg]"
}`}
>
<Image
className="m-auto"
src={app.icon}
width={app.width}
height={app.height}
alt={app.title}
/>
</div>
</li>
))}
</ul>
<div className="hidden absolute top-1/2 right-full w-[32.625rem] -mt-1 mr-10 pointer-events-none xl:block">
<Image
src="/images/collaboration/curve-1.svg"
width={522}
height={182}
alt="Curve 1"
/>
</div>
<div className="hidden absolute top-1/2 left-full w-[10.125rem] -mt-1 ml-10 pointer-events-none xl:block">
<Image
src="/images/collaboration/curve-2.svg"
width={162}
height={76}
alt="Curve 2"
/>
</div>
</div>
</div>
</div>
</div>
</Section>
);
};
export default Collaboration;

View File

@@ -1,130 +0,0 @@
import Section from "@/components/Section";
import Button from "@/components/Button";
import Image from "@/components/Image";
import { text, content, apps } from "@/mocks/collaboration";
type CollaborationProps = {};
const Collaboration = ({}: CollaborationProps) => {
return (
<Section crosses>
<div className="container lg:flex">
<div className="max-w-[25rem]">
<h2 className="h2 mb-4 md:mb-8">
AI chat app for seamless collaboration
</h2>
<ul className="max-w-[22.5rem] mb-10 md:mb-14">
{content.map((item) => (
<li className="mb-3 py-3" key={item.id}>
<div className="flex items-center">
<Image
src="/images/check.svg"
width={24}
height={24}
alt="Check"
/>
<h6 className="body-2 ml-5">
{item.title}
</h6>
</div>
{item.text && (
<p className="body-2 mt-3 text-n-4">
{item.text}
</p>
)}
</li>
))}
</ul>
<Button>Try it now</Button>
</div>
<div className="mt-15 lg:mt-0 lg:ml-auto xl:w-[37.5rem]">
<div className="relative lg:w-[22.5rem] lg:mx-auto">
<p className="body-2 mb-4 text-n-4 md:mb-16 lg:mb-32">
{text}
</p>
<div className="relative left-1/2 flex w-[22.5rem] aspect-square border border-n-6 rounded-full -translate-x-1/2 scale-75 md:scale-100">
<div className="flex w-60 aspect-square m-auto border border-n-6 rounded-full">
<div className="w-[5.75rem] aspect-square m-auto p-[0.1875rem] bg-conic-gradient rounded-full">
<div className="flex items-center justify-center w-full h-full bg-n-8 rounded-full">
<Image
src="/images/brainwave-symbol.svg"
width={48}
height={48}
alt="Brainwave"
/>
</div>
</div>
</div>
<ul>
{apps.map((app, index) => (
<li
className={`absolute top-0 left-1/2 h-1/2 -ml-[1.625rem] ${
index === 1 && "rotate-[45deg]"
} ${index === 2 && "rotate-[90deg]"} ${
index === 3 && "rotate-[135deg]"
} ${index === 4 && "rotate-[180deg]"} ${
index === 5 && "rotate-[225deg]"
} ${index === 6 && "rotate-[270deg]"} ${
index === 7 && "rotate-[315deg]"
} origin-bottom`}
key={app.id}
>
<div
className={`relative -top-[1.625rem] flex w-[3.25rem] h-[3.25rem] bg-n-7 border border-n-1/15 rounded-xl ${
index === 1 && "-rotate-[45deg]"
} ${
index === 2 && "-rotate-[90deg]"
} ${
index === 3 &&
"-rotate-[135deg]"
} ${
index === 4 &&
"-rotate-[180deg]"
} ${
index === 5 &&
"-rotate-[225deg]"
} ${
index === 6 &&
"-rotate-[270deg]"
} ${
index === 7 &&
"-rotate-[315deg]"
}`}
>
<Image
className="m-auto"
src={app.icon}
width={app.width}
height={app.height}
alt={app.title}
/>
</div>
</li>
))}
</ul>
<div className="hidden absolute top-1/2 right-full w-[32.625rem] -mt-1 mr-10 pointer-events-none xl:block">
<Image
src="/images/collaboration/curve-1.svg"
width={522}
height={182}
alt="Curve 1"
/>
</div>
<div className="hidden absolute top-1/2 left-full w-[10.125rem] -mt-1 ml-10 pointer-events-none xl:block">
<Image
src="/images/collaboration/curve-2.svg"
width={162}
height={76}
alt="Curve 2"
/>
</div>
</div>
</div>
</div>
</div>
</Section>
);
};
export default Collaboration;

View File

@@ -1,99 +0,0 @@
import { useRef, useState } from "react";
import Section from "../../../components/Section";
import Button from "../../../components/Button";
import Image from "../../../components/Image";
import Notification from "../../../components/Notification";
// 简化版特性数据
const features = [
{
id: "0",
title: "智能投研分析",
text: "利用先进的AI技术为您提供全面的投资研究分析包括市场趋势、公司基本面、技术指标等多维度分析帮助您做出更明智的投资决策。",
imageUrl: "/images/features/features.png",
iconUrl: "/images/icons/recording-01.svg",
notification: "AI分析完成 - 发现3个潜在投资机会",
},
{
id: "1",
title: "实时市场监控",
text: "24/7全天候监控全球金融市场动态实时捕捉市场变化和投资机会。智能预警系统会在关键时刻及时提醒您确保不错过任何重要的投资时机。",
imageUrl: "/images/features/image-1.jpg",
iconUrl: "/images/icons/chrome-cast.svg",
notification: "市场异动提醒 - 科技股出现上涨信号",
},
];
const Features = () => {
const [currentFeature, setCurrentFeature] = useState(0);
return (
<Section
className="py-10 md:pb-20 lg:pt-16 lg:pb-32 xl:pb-40 overflow-hidden bg-n-7"
customPaddings
>
<div className="container relative z-2">
{features.map((item, index) => (
<div key={item.id} className={index === currentFeature ? "block" : "hidden"}>
<div className="lg:flex">
<div className="lg:flex lg:flex-col lg:items-start lg:max-w-[18.75rem] lg:mr-auto">
<h2 className="h2 mb-6">
{item.title}
</h2>
<p className="body-2 mb-10 text-n-3">
{item.text}
</p>
<Button className="" onClick={null} px="px-7">
了解工作原理
</Button>
</div>
<div className="relative h-[27.5rem] border border-n-1/20 rounded-3xl md:rounded-[2.5rem] lg:flex-1 lg:max-w-[34.625rem] lg:h-[34.5rem] lg:ml-24 xl:h-[36rem] mt-10 lg:mt-0">
<div className="absolute top-[8.5rem] -left-[2rem] w-[21rem] md:w-[25.25rem] md:top-[6.4rem] md:-left-[4.5rem] lg:top-[12rem] lg:-left-[3rem] xl:top-[7.625rem] xl:-left-[4.5rem] xl:w-[32.75rem]">
<Image
className="w-full rounded-xl"
src={item.imageUrl}
width={512}
height={512}
alt="Feature"
/>
</div>
<div className="absolute left-4 right-4 bottom-4 bg-n-8/95 md:left-8 md:right-8 md:bottom-8 rounded-xl">
<Notification
className=""
title={item.notification}
/>
</div>
<div className="absolute top-6 right-6 flex items-center justify-center w-15 h-15 bg-n-1 rounded-full xl:top-8 xl:right-8">
<Image
className=""
src={item.iconUrl}
width={24}
height={24}
alt="Icon"
/>
</div>
<div className="hidden absolute top-0 left-full ml-5 w-full h-full bg-n-8/50 border border-n-1/10 rounded-[2.5rem] md:block"></div>
</div>
</div>
</div>
))}
{/* 简化版导航 */}
<div className="flex justify-center mt-12 gap-4">
{features.map((_, index) => (
<button
key={index}
className={`w-3 h-3 rounded-full transition-colors ${
index === currentFeature ? 'bg-color-1' : 'bg-n-4'
}`}
onClick={() => setCurrentFeature(index)}
/>
))}
</div>
</div>
</Section>
);
};
export default Features;

View File

@@ -1,91 +0,0 @@
import { useRef } from "react";
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
import Section from "@/components/Section";
import Button from "@/components/Button";
import Image from "@/components/Image";
import Notification from "@/components/Notification";
import { features } from "@/mocks/features";
import Arrows from "@/components/Arrows";
type FeaturesProps = {};
const Features = ({}: FeaturesProps) => {
const ref = useRef<any>(null);
return (
<Section
className="py-10 md:pb-20 lg:pt-16 lg:pb-32 xl:pb-40 overflow-hidden"
customPaddings
>
<div className="container relative z-2">
<Splide
className="splide-custom splide-visible"
options={{
type: "fade",
rewind: true,
pagination: false,
}}
hasTrack={false}
ref={ref}
>
<SplideTrack>
{features.map((item) => (
<SplideSlide key={item.id}>
<div className="lg:flex" key={item.id}>
<div className="lg:flex lg:flex-col lg:items-start lg:max-w-[18.75rem] lg:mr-auto">
<h2 className="h2 mb-6">
{item.title}
</h2>
<p className="body-2 mb-10 text-n-3">
{item.text}
</p>
<Button>See how it work</Button>
</div>
<Arrows
className="my-10 lg:hidden"
prevClassName="mr-3"
onPrev={() => ref.current?.go("<")}
onNext={() => ref.current?.go(">")}
/>
<div className="relative h-[27.5rem] border border-n-1/20 rounded-3xl md:rounded-[2.5rem] lg:flex-1 lg:max-w-[34.625rem] lg:h-[34.5rem] lg:ml-24 xl:h-[36rem]">
<div className="absolute top-[8.5rem] -left-[2rem] w-[21rem] md:w-[25.25rem] md:top-[6.4rem] md:-left-[4.5rem] lg:top-[12rem] lg:-left-[3rem] xl:top-[7.625rem] xl:-left-[4.5rem] xl:w-[32.75rem]">
<Image
className="w-full"
src={item.imageUrl}
width={512}
height={512}
alt="Figure"
/>
</div>
<Notification
className="absolute left-4 right-4 bottom-4 bg-n-8/95 md:left-8 md:right-8 md:bottom-8"
title={item.notification}
/>
<div className="absolute top-6 right-6 flex items-center justify-center w-15 h-15 bg-n-1 rounded-full xl:top-8 xl:right-8">
<Image
src={item.iconUrl}
width={24}
height={24}
alt="Icon"
/>
</div>
<div className="hidden absolute top-0 left-full ml-5 w-full h-full bg-n-8/50 border border-n-1/10 rounded-[2.5rem] md:block"></div>
</div>
</div>
</SplideSlide>
))}
</SplideTrack>
<Arrows
className="hidden -mt-12 lg:flex"
prevClassName="mr-3"
onPrev={() => ref.current?.go("<")}
onNext={() => ref.current?.go(">")}
/>
</Splide>
</div>
</Section>
);
};
export default Features;

View File

@@ -1,236 +0,0 @@
import { useEffect, useRef, useState } from "react";
// import { MouseParallax, ScrollParallax } from "react-just-parallax";
import Section from "../../../components/Section";
import Button from "../../../components/Button";
import Image from "../../../components/Image";
import Generating from "../../../components/Generating";
import Notification from "../../../components/Notification";
import Logos from "../../../components/Logos";
const Hero = () => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const parallaxRef = useRef(null);
return (
<Section
className="-mt-[4.75rem] pt-[8.25rem] pb-4 overflow-hidden md:pt-[9.75rem] md:pb-[4.8rem] lg:-mt-[5.25rem] lg:-mb-40 lg:pt-[12.25rem] lg:pb-[13.8rem]"
crosses
crossesOffset="lg:translate-y-[5.25rem]"
customPaddings
>
{/* 添加深色渐变背景 */}
<div className="absolute inset-0 bg-gradient-to-br from-n-8 via-n-7 to-n-6"></div>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(172,106,255,0.1)_0%,transparent_70%)]"></div>
<div className="container relative" ref={parallaxRef}>
<div
className="relative z-1 max-w-[62rem] mx-auto mb-[3.875rem] text-center md:mb-20 lg:mb-[6.25rem]"
style={{ position: 'relative', zIndex: 10 }}
>
<h1
className="h1 mb-6"
style={{
color: '#FFFFFF',
fontSize: '3.75rem',
lineHeight: '4.5rem',
fontWeight: '600',
marginBottom: '24px'
}}
>
探索&nbsp;
<span style={{
background: 'linear-gradient(to right, #AC6AFF, #FFC876)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text'
}}>
AI投研
</span>
&nbsp;的无限可能性 {" "}
<span className="inline-block relative">
<span style={{
background: 'linear-gradient(to right, #FFC876, #AC6AFF)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text'
}}>
价值前沿
</span>
<Image
className="absolute top-full left-0 w-full xl:-mt-2"
src="/images/curve.png"
width={624}
height={28}
alt="Curve"
/>
</span>
</h1>
<p
className="body-1 max-w-3xl mx-auto mb-6 lg:mb-8"
style={{
color: '#CAC6DD',
fontSize: '1.25rem',
lineHeight: '2rem',
marginBottom: '32px',
maxWidth: '48rem',
margin: '0 auto 32px'
}}
>
释放AI的力量升级您的投研效率
体验专业的开放式AI投研平台超越传统分析工具
</p>
<Button href="/community" white className="" onClick={null} px="px-7">
开始使用
</Button>
</div>
<div className="relative max-w-[23.25rem] mx-auto md:max-w-5xl xl:mb-24">
<div className="relative z-1 p-0.5 rounded-2xl bg-conic-gradient">
<div className="relative bg-n-8 rounded-[0.875rem]">
<div className="h-[1.375rem] bg-[#43435C] rounded-t-[0.875rem]"></div>
<div className="aspect-[33/40] rounded-b-[0.875rem] overflow-hidden md:aspect-[688/490] lg:aspect-[1024/490]">
<Image
className="w-full scale-[1.7] translate-y-[8%] md:scale-[1] md:-translate-y-[10.5%] lg:-translate-y-[23.5%]"
src="/images/hero/robot.jpg"
width={1024}
height={490}
alt="AI"
/>
</div>
<Generating className="absolute left-4 right-4 bottom-5 md:left-1/2 md:right-auto md:bottom-8 md:w-[30.5rem] md:-translate-x-1/2" />
{/* 简化版本暂时不使用ScrollParallax */}
<div className="hidden absolute -left-[5.5rem] bottom-[7.625rem] px-1 py-1 bg-[#474060]/40 backdrop-blur border border-n-1/10 rounded-2xl xl:flex">
{[
"/images/icons/home-smile.svg",
"/images/icons/file-02.svg",
"/images/icons/search-md.svg",
"/images/icons/plus-square.svg",
].map((icon, index) => (
<div className="p-5" key={index}>
<Image
className=""
src={icon}
width={24}
height={25}
alt={`Icon ${index}`}
/>
</div>
))}
</div>
<div className="hidden absolute -right-[5.5rem] bottom-[11.25rem] w-[18.375rem] xl:flex">
<Notification
className=""
title="AI投研分析完成"
/>
</div>
</div>
</div>
<div className="relative z-1 h-6 mx-2.5 bg-[#1B1B2E] shadow-xl rounded-b-[1.25rem] lg:h-6 lg:mx-8"></div>
<div className="relative z-1 h-6 mx-6 bg-[#1B1B2E]/70 shadow-xl rounded-b-[1.25rem] lg:h-6 lg:mx-20"></div>
<div className="absolute -top-[54%] left-1/2 w-[234%] -translate-x-1/2 md:-top-[46%] md:w-[138%] lg:-top-[104%]">
<Image
className="w-full"
src="/images/hero/background.jpg"
width={1440}
height={1800}
alt="Hero"
/>
</div>
<div className="absolute -top-[42.375rem] left-1/2 w-[78rem] aspect-square border border-n-2/5 rounded-full -translate-x-1/2 md:-top-[38.5rem] xl:-top-[32rem]">
<div className="absolute top-1/2 left-1/2 w-[65.875rem] aspect-square border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
<div className="absolute top-1/2 left-1/2 w-[51.375rem] aspect-square border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
<div className="absolute top-1/2 left-1/2 w-[36.125rem] aspect-square border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
<div className="absolute top-1/2 left-1/2 w-[23.125rem] aspect-square border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
{/* 浮动装饰点 */}
<div className="absolute bottom-1/2 left-1/2 w-0.25 h-1/2 origin-bottom rotate-[46deg]">
<div
className={`w-2 h-2 -ml-1 -mt-36 bg-gradient-to-b from-[#DD734F] to-[#1A1A32] rounded-full transition-transform duration-500 ease-out ${
mounted
? "translate-y-0 opacity-100"
: "translate-y-10 opacity-0"
}`}
></div>
</div>
<div className="absolute bottom-1/2 left-1/2 w-0.25 h-1/2 origin-bottom -rotate-[56deg]">
<div
className={`w-4 h-4 -ml-1 -mt-32 bg-gradient-to-b from-[#DD734F] to-[#1A1A32] rounded-full transition-transform duration-500 ease-out ${
mounted
? "translate-y-0 opacity-100"
: "translate-y-10 opacity-0"
}`}
></div>
</div>
<div className="absolute bottom-1/2 left-1/2 w-0.25 h-1/2 origin-bottom rotate-[54deg]">
<div
className={`hidden w-4 h-4 -ml-1 mt-[12.9rem] bg-gradient-to-b from-[#B9AEDF] to-[#1A1A32] rounded-full xl:block transition-transform duration-500 ease-out ${
mounted
? "translate-y-0 opacity-100"
: "translate-y-10 opacity-0"
}`}
></div>
</div>
<div className="absolute bottom-1/2 left-1/2 w-0.25 h-1/2 origin-bottom -rotate-[65deg]">
<div
className={`w-3 h-3 -ml-1.5 mt-52 bg-gradient-to-b from-[#B9AEDF] to-[#1A1A32] rounded-full transition-transform duration-500 ease-out ${
mounted
? "translate-y-0 opacity-100"
: "translate-y-10 opacity-0"
}`}
></div>
</div>
<div className="absolute bottom-1/2 left-1/2 w-0.25 h-1/2 origin-bottom -rotate-[85deg]">
<div
className={`w-6 h-6 -ml-3 -mt-3 bg-gradient-to-b from-[#88E5BE] to-[#1A1A32] rounded-full transition-transform duration-500 ease-out ${
mounted
? "translate-y-0 opacity-100"
: "translate-y-10 opacity-0"
}`}
></div>
</div>
<div className="absolute bottom-1/2 left-1/2 w-0.25 h-1/2 origin-bottom rotate-[70deg]">
<div
className={`w-6 h-6 -ml-3 -mt-3 bg-gradient-to-b from-[#88E5BE] to-[#1A1A32] rounded-full transition-transform duration-500 ease-out ${
mounted
? "translate-y-0 opacity-100"
: "translate-y-10 opacity-0"
}`}
></div>
</div>
</div>
</div>
<Logos className="hidden relative z-10 mt-20 lg:block" />
</div>
<div className="hidden absolute top-[55.25rem] left-10 right-10 h-0.25 bg-n-6 pointer-events-none xl:block"></div>
<svg
className="hidden absolute top-[54.9375rem] left-[2.1875rem] z-2 pointer-events-none xl:block"
width="11"
height="11"
fill="none"
>
<path
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
fill="#ada8c4"
/>
</svg>
<svg
className="hidden absolute top-[54.9375rem] right-[2.1875rem] z-2 pointer-events-none xl:block"
width="11"
height="11"
fill="none"
>
<path
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
fill="#ada8c4"
/>
</svg>
</Section>
);
};
export default Hero;

View File

@@ -1,152 +0,0 @@
import { useRef, useState } from "react";
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
import Section from "@/components/Section";
import Image from "@/components/Image";
import Button from "@/components/Button";
import Tagline from "@/components/Tagline";
import Arrows from "@/components/Arrows";
import { howItWorks } from "@/mocks/how-it-works";
type HowItWorksProps = {};
const HowItWorks = ({}: HowItWorksProps) => {
const [activeIndex, setActiveIndex] = useState<number>(0);
const ref = useRef<any>(null);
const handleClick = (index: number) => {
setActiveIndex(index);
ref.current?.go(index);
};
return (
<Section className="lg:-mb-16" crosses>
<div className="container">
<Splide
className="splide-custom"
options={{
type: "fade",
rewind: true,
pagination: false,
}}
hasTrack={false}
ref={ref}
>
<SplideTrack>
{howItWorks.map((item, index) => (
<SplideSlide key={item.id}>
<div className="lg:flex lg:flex-row-reverse lg:items-center">
<div className="">
<Tagline className="mb-4 lg:mb-6">
How it work: 0{index + 1}.
</Tagline>
<h2 className="h2 mb-4 lg:mb-6">
{item.title}
</h2>
<p className="body-2 mb-10 text-n-3">
{item.text}
</p>
<Button href="/login">
Connect now
</Button>
<Arrows
className="my-10 lg:hidden"
prevClassName="mr-3"
onPrev={() => ref.current?.go("<")}
onNext={() => ref.current?.go(">")}
/>
</div>
<div className="relative lg:w-[29.375rem] lg:flex-shrink-0 lg:mr-[7.125rem] xl:w-[34.375rem] xl:mr-40">
<div className="pt-0.25 pl-0.25 overflow-hidden bg-gradient-to-tl from-n-1/0 via-n-1/0 to-n-1/15 rounded-3xl">
<div className="h-[30.5rem] bg-n-7 rounded-[1.4375rem] xl:h-[35.625rem]">
<Image
className="w-full h-full object-contain"
src={item.image}
width={550}
height={570}
alt={item.title}
/>
<div className="absolute left-4 right-4 bottom-4 flex items-center h-16 px-5 bg-n-8 border border-n-1/10 rounded-xl lg:left-6 lg:right-6 lg:bottom-6">
<div className="flex items-center justify-center w-6 h-6 mr-5 bg-color-1 rounded-full">
<svg
className="w-2.5 h-2.5 fill-n-1"
viewBox="0 0 10 10"
>
<path d="M5 0a1 1 0 0 1 .993.883L6 1v3h3a1 1 0 0 1 .117 1.993L9 6H6v3a1 1 0 0 1-1.993.117L4 9V6H1a1 1 0 0 1-.117-1.993L1 4h3V1a1 1 0 0 1 1-1z" />
</svg>
</div>
<div className="text-base text-n-3/75">
Ask anything
</div>
<div className="w-6 h-6 ml-auto opacity-50">
<Image
className="w-full"
src="/images/icons/recording-01.svg"
width={24}
height={24}
alt="Recording"
/>
</div>
</div>
</div>
</div>
<div className="absolute -right-6 top-8 bottom-8 w-6 bg-[#1B1B2E] rounded-r-3xl"></div>
<div className="absolute -right-12 top-16 bottom-16 w-6 bg-[#1B1B2E]/50 rounded-r-3xl"></div>
</div>
</div>
</SplideSlide>
))}
</SplideTrack>
<Arrows
className="hidden justify-center mt-12 lg:flex lg:mt-15 xl:hidden"
prevClassName="mr-3"
onPrev={() => ref.current?.go("<")}
onNext={() => ref.current?.go(">")}
/>
<div className="absolute top-0 -left-[10rem] w-[29.5rem] h-[29.5rem] mix-blend-color-dodge opacity-20 pointer-events-none">
<Image
className="absolute top-1/2 left-1/2 w-[55.5rem] max-w-[55.5rem] h-[61.5rem] -translate-x-1/2 -translate-y-1/2"
src="/images/how-it-works/gradient.png"
width={984}
height={984}
alt="Gradient"
/>
</div>
<div className="hidden grid-cols-4 gap-6 mt-20 xl:grid">
{howItWorks.map((item, index) => (
<div
className="group cursor-pointer"
onClick={() => handleClick(index)}
key={item.id}
>
<div
className={`h-[0.125rem] mb-10 transition-colors ${
index === activeIndex
? "bg-color-1"
: "bg-[#D9D9D9]/10 group-hover:bg-[#D9D9D9]/50"
}`}
></div>
<div className="tagline mb-1 text-n-3">
0{index + 1}.
</div>
<h2 className="mb-[0.625rem] text-2xl leading-8">
{item.title}
</h2>
<p
className={`body-2 text-n-3 line-clamp-3 transition-opacity ${
index !== activeIndex && "opacity-0"
}`}
>
{item.text}
</p>
</div>
))}
</div>
</Splide>
</div>
</Section>
);
};
export default HowItWorks;

View File

@@ -1,152 +0,0 @@
import { useRef, useState } from "react";
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
import Section from "@/components/Section";
import Image from "@/components/Image";
import Button from "@/components/Button";
import Tagline from "@/components/Tagline";
import Arrows from "@/components/Arrows";
import { howItWorks } from "@/mocks/how-it-works";
type HowItWorksProps = {};
const HowItWorks = ({}: HowItWorksProps) => {
const [activeIndex, setActiveIndex] = useState<number>(0);
const ref = useRef<any>(null);
const handleClick = (index: number) => {
setActiveIndex(index);
ref.current?.go(index);
};
return (
<Section className="lg:-mb-16" crosses>
<div className="container">
<Splide
className="splide-custom"
options={{
type: "fade",
rewind: true,
pagination: false,
}}
hasTrack={false}
ref={ref}
>
<SplideTrack>
{howItWorks.map((item, index) => (
<SplideSlide key={item.id}>
<div className="lg:flex lg:flex-row-reverse lg:items-center">
<div className="">
<Tagline className="mb-4 lg:mb-6">
How it work: 0{index + 1}.
</Tagline>
<h2 className="h2 mb-4 lg:mb-6">
{item.title}
</h2>
<p className="body-2 mb-10 text-n-3">
{item.text}
</p>
<Button href="/login">
Connect now
</Button>
<Arrows
className="my-10 lg:hidden"
prevClassName="mr-3"
onPrev={() => ref.current?.go("<")}
onNext={() => ref.current?.go(">")}
/>
</div>
<div className="relative lg:w-[29.375rem] lg:flex-shrink-0 lg:mr-[7.125rem] xl:w-[34.375rem] xl:mr-40">
<div className="pt-0.25 pl-0.25 overflow-hidden bg-gradient-to-tl from-n-1/0 via-n-1/0 to-n-1/15 rounded-3xl">
<div className="h-[30.5rem] bg-n-7 rounded-[1.4375rem] xl:h-[35.625rem]">
<Image
className="w-full h-full object-contain"
src={item.image}
width={550}
height={570}
alt={item.title}
/>
<div className="absolute left-4 right-4 bottom-4 flex items-center h-16 px-5 bg-n-8 border border-n-1/10 rounded-xl lg:left-6 lg:right-6 lg:bottom-6">
<div className="flex items-center justify-center w-6 h-6 mr-5 bg-color-1 rounded-full">
<svg
className="w-2.5 h-2.5 fill-n-1"
viewBox="0 0 10 10"
>
<path d="M5 0a1 1 0 0 1 .993.883L6 1v3h3a1 1 0 0 1 .117 1.993L9 6H6v3a1 1 0 0 1-1.993.117L4 9V6H1a1 1 0 0 1-.117-1.993L1 4h3V1a1 1 0 0 1 1-1z" />
</svg>
</div>
<div className="text-base text-n-3/75">
Ask anything
</div>
<div className="w-6 h-6 ml-auto opacity-50">
<Image
className="w-full"
src="/images/icons/recording-01.svg"
width={24}
height={24}
alt="Recording"
/>
</div>
</div>
</div>
</div>
<div className="absolute -right-6 top-8 bottom-8 w-6 bg-[#1B1B2E] rounded-r-3xl"></div>
<div className="absolute -right-12 top-16 bottom-16 w-6 bg-[#1B1B2E]/50 rounded-r-3xl"></div>
</div>
</div>
</SplideSlide>
))}
</SplideTrack>
<Arrows
className="hidden justify-center mt-12 lg:flex lg:mt-15 xl:hidden"
prevClassName="mr-3"
onPrev={() => ref.current?.go("<")}
onNext={() => ref.current?.go(">")}
/>
<div className="absolute top-0 -left-[10rem] w-[29.5rem] h-[29.5rem] mix-blend-color-dodge opacity-20 pointer-events-none">
<Image
className="absolute top-1/2 left-1/2 w-[55.5rem] max-w-[55.5rem] h-[61.5rem] -translate-x-1/2 -translate-y-1/2"
src="/images/how-it-works/gradient.png"
width={984}
height={984}
alt="Gradient"
/>
</div>
<div className="hidden grid-cols-4 gap-6 mt-20 xl:grid">
{howItWorks.map((item, index) => (
<div
className="group cursor-pointer"
onClick={() => handleClick(index)}
key={item.id}
>
<div
className={`h-[0.125rem] mb-10 transition-colors ${
index === activeIndex
? "bg-color-1"
: "bg-[#D9D9D9]/10 group-hover:bg-[#D9D9D9]/50"
}`}
></div>
<div className="tagline mb-1 text-n-3">
0{index + 1}.
</div>
<h2 className="mb-[0.625rem] text-2xl leading-8">
{item.title}
</h2>
<p
className={`body-2 text-n-3 line-clamp-3 transition-opacity ${
index !== activeIndex && "opacity-0"
}`}
>
{item.text}
</p>
</div>
))}
</div>
</Splide>
</div>
</Section>
);
};
export default HowItWorks;

View File

@@ -1,69 +0,0 @@
import Link from "next/link";
import Section from "@/components/Section";
import Image from "@/components/Image";
import Heading from "@/components/Heading";
import PricingList from "@/components/PricingList";
type PricingProps = {};
const Pricing = ({}: PricingProps) => {
return (
<Section className="overflow-hidden">
<div className="container relative z-2">
<div className="hidden relative justify-center mb-[6.5rem] lg:flex">
<Image
className="relative z-1"
src="/images/figures/4-small.png"
width={255}
height={255}
alt="Sphere"
/>
<div className="absolute top-1/2 left-1/2 w-[59.5rem] -translate-x-1/2 -translate-y-1/2 pointer-events-none">
<Image
className="w-full"
src="/images/pricing/stars.svg"
width={952}
height={396}
alt="Stars"
/>
</div>
</div>
<Heading
tag="Get started with Brainwave"
title="Pay once, use forever"
/>
<div className="relative">
<PricingList />
<div className="hidden lg:block absolute top-1/2 right-full w-[92.5rem] h-[11.0625rem] -translate-y-1/2 pointer-events-none">
<Image
className="w-full"
src="/images/pricing/lines.svg"
width={1480}
height={177}
alt="Lines"
/>
</div>
<div className="hidden lg:block absolute top-1/2 left-full w-[92.5rem] h-[11.0625rem] -translate-y-1/2 -scale-x-100 pointer-events-none">
<Image
className="w-full"
src="/images/pricing/lines.svg"
width={1480}
height={177}
alt="Lines"
/>
</div>
</div>
<div className="flex justify-center mt-8 md:mt-15 xl:mt-20">
<Link
className="text-xs font-code font-bold tracking-wider uppercase border-b border-n-1 transition-colors hover:border-n-1/0"
href="/pricing"
>
See the full details
</Link>
</div>
</div>
</Section>
);
};
export default Pricing;

View File

@@ -1,69 +0,0 @@
import Link from "next/link";
import Section from "@/components/Section";
import Image from "@/components/Image";
import Heading from "@/components/Heading";
import PricingList from "@/components/PricingList";
type PricingProps = {};
const Pricing = ({}: PricingProps) => {
return (
<Section className="overflow-hidden">
<div className="container relative z-2">
<div className="hidden relative justify-center mb-[6.5rem] lg:flex">
<Image
className="relative z-1"
src="/images/figures/4-small.png"
width={255}
height={255}
alt="Sphere"
/>
<div className="absolute top-1/2 left-1/2 w-[59.5rem] -translate-x-1/2 -translate-y-1/2 pointer-events-none">
<Image
className="w-full"
src="/images/pricing/stars.svg"
width={952}
height={396}
alt="Stars"
/>
</div>
</div>
<Heading
tag="Get started with Brainwave"
title="Pay once, use forever"
/>
<div className="relative">
<PricingList />
<div className="hidden lg:block absolute top-1/2 right-full w-[92.5rem] h-[11.0625rem] -translate-y-1/2 pointer-events-none">
<Image
className="w-full"
src="/images/pricing/lines.svg"
width={1480}
height={177}
alt="Lines"
/>
</div>
<div className="hidden lg:block absolute top-1/2 left-full w-[92.5rem] h-[11.0625rem] -translate-y-1/2 -scale-x-100 pointer-events-none">
<Image
className="w-full"
src="/images/pricing/lines.svg"
width={1480}
height={177}
alt="Lines"
/>
</div>
</div>
<div className="flex justify-center mt-8 md:mt-15 xl:mt-20">
<Link
className="text-xs font-code font-bold tracking-wider uppercase border-b border-n-1 transition-colors hover:border-n-1/0"
href="/pricing"
>
See the full details
</Link>
</div>
</div>
</Section>
);
};
export default Pricing;

View File

@@ -1,97 +0,0 @@
import Section from "@/components/Section";
import Tagline from "@/components/Tagline";
import Image from "@/components/Image";
import { roadmap } from "@/mocks/roadmap";
import Button from "@/components/Button";
import Heading from "@/components/Heading";
type RoadmapProps = {};
const Roadmap = ({}: RoadmapProps) => (
<Section className="overflow-hidden">
<div className="container md:pb-10">
<Heading tag="Ready to get started" title="What were working on" />
<div className="relative grid gap-6 md:grid-cols-2 md:gap-4 md:pb-[7rem]">
{roadmap.map((item, index) => (
<div
className={`md:flex ${
index % 2 !== 0 ? "md:translate-y-[7rem]" : ""
} p-0.25 rounded-[2.5rem] ${
item.colorful ? "bg-conic-gradient" : "bg-n-6"
}`}
key={item.id}
>
<div className="relative p-8 bg-n-8 rounded-[2.4375rem] overflow-hidden xl:p-15">
<div className="absolute top-0 left-0 max-w-full">
<Image
className="w-full"
src="/images/grid.png"
width={550}
height={550}
alt="Grid"
/>
</div>
<div className="relative z-1">
<div className="flex items-center justify-between max-w-[27rem] mb-8 md:mb-20">
<Tagline>{item.date}</Tagline>
<div className="flex items-center px-4 py-1 bg-n-1 rounded text-n-8">
<Image
className="mr-2.5"
src={
item.status === "done"
? "/images/icons/check.svg"
: "/images/icons/loading-01.svg"
}
width={16}
height={16}
alt={
item.status === "done"
? "Done"
: "In progress"
}
/>
<div className="tagline">
{item.status === "done"
? "Done"
: "In progress"}
</div>
</div>
</div>
<div className="mb-8 md:mb-20">
<div className="-my-10 -mx-15">
<Image
className="w-full"
src={item.imageUrl}
width={628}
height={426}
alt={item.title}
/>
</div>
</div>
<h4 className="h4 mb-4">{item.title}</h4>
<p className="body-2 text-n-4">{item.text}</p>
</div>
</div>
</div>
))}
<div className="absolute top-[18.25rem] -left-[30.375rem] w-[56.625rem] opacity-60 mix-blend-color-dodge pointer-events-none">
<div className="absolute top-1/2 left-1/2 w-[58.85rem] h-[58.85rem] -translate-x-3/4 -translate-y-1/2">
<Image
className="w-full"
src="/images/gradient.png"
width={942}
height={942}
alt="Gradient"
/>
</div>
</div>
</div>
<div className="flex justify-center mt-12 md:mt-15 xl:mt-20">
<Button href="/roadmap">Our roadmap</Button>
</div>
</div>
</Section>
);
export default Roadmap;

View File

@@ -1,97 +0,0 @@
import Section from "@/components/Section";
import Tagline from "@/components/Tagline";
import Image from "@/components/Image";
import { roadmap } from "@/mocks/roadmap";
import Button from "@/components/Button";
import Heading from "@/components/Heading";
type RoadmapProps = {};
const Roadmap = ({}: RoadmapProps) => (
<Section className="overflow-hidden">
<div className="container md:pb-10">
<Heading tag="Ready to get started" title="What were working on" />
<div className="relative grid gap-6 md:grid-cols-2 md:gap-4 md:pb-[7rem]">
{roadmap.map((item, index) => (
<div
className={`md:flex ${
index % 2 !== 0 ? "md:translate-y-[7rem]" : ""
} p-0.25 rounded-[2.5rem] ${
item.colorful ? "bg-conic-gradient" : "bg-n-6"
}`}
key={item.id}
>
<div className="relative p-8 bg-n-8 rounded-[2.4375rem] overflow-hidden xl:p-15">
<div className="absolute top-0 left-0 max-w-full">
<Image
className="w-full"
src="/images/grid.png"
width={550}
height={550}
alt="Grid"
/>
</div>
<div className="relative z-1">
<div className="flex items-center justify-between max-w-[27rem] mb-8 md:mb-20">
<Tagline>{item.date}</Tagline>
<div className="flex items-center px-4 py-1 bg-n-1 rounded text-n-8">
<Image
className="mr-2.5"
src={
item.status === "done"
? "/images/icons/check.svg"
: "/images/icons/loading-01.svg"
}
width={16}
height={16}
alt={
item.status === "done"
? "Done"
: "In progress"
}
/>
<div className="tagline">
{item.status === "done"
? "Done"
: "In progress"}
</div>
</div>
</div>
<div className="mb-8 md:mb-20">
<div className="-my-10 -mx-15">
<Image
className="w-full"
src={item.imageUrl}
width={628}
height={426}
alt={item.title}
/>
</div>
</div>
<h4 className="h4 mb-4">{item.title}</h4>
<p className="body-2 text-n-4">{item.text}</p>
</div>
</div>
</div>
))}
<div className="absolute top-[18.25rem] -left-[30.375rem] w-[56.625rem] opacity-60 mix-blend-color-dodge pointer-events-none">
<div className="absolute top-1/2 left-1/2 w-[58.85rem] h-[58.85rem] -translate-x-3/4 -translate-y-1/2">
<Image
className="w-full"
src="/images/gradient.png"
width={942}
height={942}
alt="Gradient"
/>
</div>
</div>
</div>
<div className="flex justify-center mt-12 md:mt-15 xl:mt-20">
<Button href="/roadmap">Our roadmap</Button>
</div>
</div>
</Section>
);
export default Roadmap;

View File

@@ -1,90 +0,0 @@
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
import Section from "@/components/Section";
import Tagline from "@/components/Tagline";
import Button from "@/components/Button";
import Image from "@/components/Image";
import { testimonials } from "@/mocks/testimonials";
import Arrows from "@/components/Arrows";
import Heading from "@/components/Heading";
type TestimonialsProps = {};
const Testimonials = ({}: TestimonialsProps) => (
<Section className="overflow-hidden">
<div className="container relative z-2">
<Heading
tag="Ready to get started"
title="What the community is saying"
/>
<Splide
className="splide-custom splide-visible"
options={{
mediaQuery: "min",
gap: "1.5rem",
breakpoints: {
1024: {
autoWidth: true,
},
},
rewind: true,
pagination: false,
}}
hasTrack={false}
>
<SplideTrack>
{testimonials.map((item) => (
<SplideSlide key={item.id}>
<div className="relative flex h-full p-4 rounded-t-xl overflow-hidden lg:w-[46.125rem]">
<div className="absolute top-0 left-0 right-0 bottom-[3.25rem] border border-n-4/50 rounded-3xl"></div>
<div className="absolute inset-px rounded-t-[1.4375rem] overflow-hidden">
<div className="absolute -inset-0.25">
<Image
className="w-full h-full object-cover"
src={item.imageUrl}
width={739}
height={472}
alt={item.name}
/>
</div>
</div>
<div className="absolute inset-0 bg-gradient-to-r from-n-8/50 to-n-8/0"></div>
<div className="hidden relative z-1 md:flex flex-col flex-1 pt-12 px-4 pb-16">
<div className="w-[12.75rem] h-10 mb-auto">
<Image
className="w-full h-full object-contain"
src={item.logoUrl}
width={204}
height={40}
alt={item.name}
/>
</div>
<div className="h5">{item.name}</div>
<div className="h5 text-n-4">
{item.role}
</div>
</div>
<div className="relative flex z-1 bg-conic-gradient p-0.25 rounded-2xl md:ml-auto">
<div className="flex flex-col items-start p-8 bg-n-8 rounded-[0.9375rem] md:w-[21.75rem]">
<p className="quote mb-8">
{item.text}
</p>
<Button className="mt-auto">
Visit link
</Button>
</div>
</div>
</div>
</SplideSlide>
))}
</SplideTrack>
<Arrows
className="justify-center mt-12 md:mt-15 xl:mt-20"
prevClassName="mr-8"
/>
</Splide>
</div>
</Section>
);
export default Testimonials;

View File

@@ -1,90 +0,0 @@
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
import Section from "@/components/Section";
import Tagline from "@/components/Tagline";
import Button from "@/components/Button";
import Image from "@/components/Image";
import { testimonials } from "@/mocks/testimonials";
import Arrows from "@/components/Arrows";
import Heading from "@/components/Heading";
type TestimonialsProps = {};
const Testimonials = ({}: TestimonialsProps) => (
<Section className="overflow-hidden">
<div className="container relative z-2">
<Heading
tag="Ready to get started"
title="What the community is saying"
/>
<Splide
className="splide-custom splide-visible"
options={{
mediaQuery: "min",
gap: "1.5rem",
breakpoints: {
1024: {
autoWidth: true,
},
},
rewind: true,
pagination: false,
}}
hasTrack={false}
>
<SplideTrack>
{testimonials.map((item) => (
<SplideSlide key={item.id}>
<div className="relative flex h-full p-4 rounded-t-xl overflow-hidden lg:w-[46.125rem]">
<div className="absolute top-0 left-0 right-0 bottom-[3.25rem] border border-n-4/50 rounded-3xl"></div>
<div className="absolute inset-px rounded-t-[1.4375rem] overflow-hidden">
<div className="absolute -inset-0.25">
<Image
className="w-full h-full object-cover"
src={item.imageUrl}
width={739}
height={472}
alt={item.name}
/>
</div>
</div>
<div className="absolute inset-0 bg-gradient-to-r from-n-8/50 to-n-8/0"></div>
<div className="hidden relative z-1 md:flex flex-col flex-1 pt-12 px-4 pb-16">
<div className="w-[12.75rem] h-10 mb-auto">
<Image
className="w-full h-full object-contain"
src={item.logoUrl}
width={204}
height={40}
alt={item.name}
/>
</div>
<div className="h5">{item.name}</div>
<div className="h5 text-n-4">
{item.role}
</div>
</div>
<div className="relative flex z-1 bg-conic-gradient p-0.25 rounded-2xl md:ml-auto">
<div className="flex flex-col items-start p-8 bg-n-8 rounded-[0.9375rem] md:w-[21.75rem]">
<p className="quote mb-8">
{item.text}
</p>
<Button className="mt-auto">
Visit link
</Button>
</div>
</div>
</div>
</SplideSlide>
))}
</SplideTrack>
<Arrows
className="justify-center mt-12 md:mt-15 xl:mt-20"
prevClassName="mr-8"
/>
</Splide>
</div>
</Section>
);
export default Testimonials;

View File

@@ -1,30 +0,0 @@
import Layout from "../../components/Layout";
import Hero from "./Hero/index.js";
import Benefits from "./Benefits/index.js";
import Features from "./Features/index.js";
import Collaboration from "./Collaboration";
import HowItWorks from "./HowItWorks";
import Pricing from "./Pricing";
import Testimonials from "./Testimonials";
import Roadmap from "./Roadmap";
import Services from "../../components/Services";
import Join from "../../components/Join";
const HomePage = () => {
return (
<Layout>
<Hero />
<Benefits />
<Features />
<Collaboration />
<HowItWorks />
<Services />
<Pricing />
<Testimonials />
<Roadmap />
<Join />
</Layout>
);
};
export default HomePage;

View File

@@ -1,31 +0,0 @@
import Layout from "../../components/Layout/index.js";
import Hero from "./Hero/index.js";
import Benefits from "./Benefits/index.js";
import Features from "./Features/index.js";
// import Collaboration from "./Collaboration";
// import HowItWorks from "./HowItWorks";
// import Pricing from "./Pricing";
// import Testimonials from "./Testimonials";
// import Roadmap from "./Roadmap";
// import Services from "../../components/Services";
// import Join from "../../components/Join";
const HomePage = () => {
return (
<Layout>
<Hero />
<Benefits />
<Features />
{/* 其他组件将在后续逐步修复 */}
{/* <Collaboration />
<HowItWorks />
<Services />
<Pricing />
<Testimonials />
<Roadmap />
<Join /> */}
</Layout>
);
};
export default HomePage;

View File

@@ -1,62 +0,0 @@
import Section from "@/components/Section";
import Image from "@/components/Image";
import Button from "@/components/Button";
type HelpProps = {};
const Help = ({}: HelpProps) => (
<Section crosses>
<div className="container pt-10 pb-10 lg:grid lg:grid-cols-2 lg:gap-20 lg:items-center lg:p-0">
<div className="hidden lg:block">
<div>
<Image
src="/images/help/help.png"
width={756}
height={756}
alt="Help"
/>
</div>
</div>
<div>
<h2 className="h2 mb-4 md:mb-6">Need help?</h2>
<p className="body-2 mb-5 text-n-3">
Cant find your answer, contact us
</p>
<ul>
{[
{
id: "0",
title: "Join our community",
text: "Discuss anything with other users",
},
{
id: "1",
title: "Email us",
text: "hello@brainwave.com",
},
].map((item) => (
<li
className="flex items-center py-10 border-b border-n-1/15"
key={item.id}
>
<div className="flex items-center justify-center w-15 h-15 mr-10 bg-n-7 border border-n-1/15 rounded-xl">
<Image
src="/images/icons/building-01.svg"
width={24}
height={24}
alt="Contact"
/>
</div>
<div>
<h6 className="h6 text-n-3">{item.title}</h6>
<p className="body-2 text-n-2">{item.text}</p>
</div>
</li>
))}
</ul>
</div>
</div>
</Section>
);
export default Help;

View File

@@ -1,62 +0,0 @@
import Section from "@/components/Section";
import Image from "@/components/Image";
import Button from "@/components/Button";
type HelpProps = {};
const Help = ({}: HelpProps) => (
<Section crosses>
<div className="container pt-10 pb-10 lg:grid lg:grid-cols-2 lg:gap-20 lg:items-center lg:p-0">
<div className="hidden lg:block">
<div>
<Image
src="/images/help/help.png"
width={756}
height={756}
alt="Help"
/>
</div>
</div>
<div>
<h2 className="h2 mb-4 md:mb-6">Need help?</h2>
<p className="body-2 mb-5 text-n-3">
Cant find your answer, contact us
</p>
<ul>
{[
{
id: "0",
title: "Join our community",
text: "Discuss anything with other users",
},
{
id: "1",
title: "Email us",
text: "hello@brainwave.com",
},
].map((item) => (
<li
className="flex items-center py-10 border-b border-n-1/15"
key={item.id}
>
<div className="flex items-center justify-center w-15 h-15 mr-10 bg-n-7 border border-n-1/15 rounded-xl">
<Image
src="/images/icons/building-01.svg"
width={24}
height={24}
alt="Contact"
/>
</div>
<div>
<h6 className="h6 text-n-3">{item.title}</h6>
<p className="body-2 text-n-2">{item.text}</p>
</div>
</li>
))}
</ul>
</div>
</div>
</Section>
);
export default Help;

View File

@@ -1,261 +0,0 @@
import { useState } from "react";
import ScrollIntoView from "react-scroll-into-view";
import Section from "@/components/Section";
import Heading from "@/components/Heading";
import Image from "@/components/Image";
import Tagline from "@/components/Tagline";
import Button from "@/components/Button";
import { navigation } from "@/mocks/how-to-use";
type HowToUseProps = {};
const HowToUse = ({}: HowToUseProps) => {
const [openNavigation, setOpenNavigation] = useState<boolean>(false);
const [openGroupId, setOpenGroudId] = useState<string | null>("g0");
return (
<Section>
<div className="container md:py-10 lg:pt-16 xl:pt-20">
<Heading
textAlignClassName="text-center"
titleLarge="How to use"
textLarge="Get started with Brainwave - AI chat app today and experience the power of AI in your conversations!"
/>
<div className="relative max-w-[75rem] mb-15 mx-auto">
<Image
className="absolute top-6 left-6 w-6 opacity-30 pointer-events-none"
src="/images/icons/search-md.svg"
width={24}
height={24}
alt="Search"
/>
<input
className="w-full h-[4.5rem] pl-[3.5rem] pr-10 bg-transparent border border-n-6 rounded-[2.25rem] outline-none transition-colors focus:border-n-5"
type="text"
placeholder="Search topic"
/>
</div>
<div className="lg:flex">
<div className="mb-16 lg:flex-shrink-0 lg:w-[19rem] lg:mr-10 xl:mr-20">
<div
className="flex items-center justify-between w-full h-16 px-6 bg-n-7 rounded-xl cursor-pointer lg:hidden"
onClick={() => setOpenNavigation(!openNavigation)}
>
<div className="h6 text-n-1/50">
Getting started
</div>
<Image
src="/images/icons/chevron-down.svg"
width={24}
height={24}
alt="Arrow"
/>
</div>
<div
className={`pt-4 ${
openNavigation ? "block" : "hidden"
} lg:block lg:pt-0`}
>
{navigation.map((group) => (
<div className="mb-5" key={group.id}>
<button
className="flex items-start py-3.5"
onClick={() => setOpenGroudId(group.id)}
>
<div
className={`relative w-6 h-6 my-0.5 mr-5 p-0.5 ${
group.id === openGroupId
? "bg-n-7 border-[0.125rem] border-n-1/15 rounded-md"
: ""
}`}
>
<Image
className={`relative z-1 w-full transition-transform ${
group.id === openGroupId
? "rotate-90"
: ""
}`}
src="/images/icons/chevron-right.svg"
width={16}
height={16}
alt="Arrow"
/>
</div>
<div className="text-xl">
{group.title}
</div>
</button>
<div
className={`grid grid-rows-[0fr] transition-all ${
group.id === openGroupId
? "grid-rows-[1fr]"
: ""
}`}
>
<ul className="overflow-hidden">
{group.items.map((item) => (
<li key={item.id}>
<ScrollIntoView
className="body-2 block py-3 pl-11 text-n-3 transition-colors hover:text-color-1 cursor-pointer"
selector={`#anchor-${group.id}-${item.id}`}
>
<span>
{item.title}
</span>
</ScrollIntoView>
</li>
))}
</ul>
</div>
</div>
))}
</div>
</div>
<div className="flex-1">
<h3 className="h3 mb-16 pb-8 border-b border-n-1/15">
Getting started
</h3>
<div className="relative mb-16">
<div className="flex items-start mb-10">
<h4 className="h4 mr-auto">Sign up</h4>
<Tagline className="ml-4 mt-4">01</Tagline>
</div>
<div className="mb-10">
<Image
className="w-full h-full object-cover rounded-3xl"
src="/images/how-to-use/image-1.jpg"
width={896}
height={600}
alt="Image 1"
/>
</div>
<div className="body-2 text-n-2">
<p className="mb-6">
{`To create an account with Brainwave - AI
chat app, all you need to do is provide
your name, email address, and password.
Once you have signed up, you will be
able to start exploring the app's
various features. Brainwave's AI chat
system is designed to provide you with
an intuitive, easy-to-use interface that
makes it simple to chat with friends and
family, or even with new acquaintances.`}
</p>
<p className="mb-6">
In addition, the app is constantly being
updated with new features and improvements,
so you can expect it to continue to evolve
and improve over time. Whether you are
looking for a simple chat app, or a more
advanced platform that can help you stay
connected with people from all over the
world, Brainwave is the perfect choice.
</p>
</div>
<div
id={`anchor-${openGroupId}-0`}
className="absolute -top-32 left-0 w-full h-0.25"
></div>
</div>
<div className="relative mb-16">
<div className="flex items-start mb-10">
<h4 className="h4 mr-auto">
Connect with AI Chatbot
</h4>
<Tagline className="ml-4 mt-4">02</Tagline>
</div>
<div className="mb-10">
<Image
className="w-full h-full object-cover rounded-3xl"
src="/images/how-to-use/image-2.jpg"
width={896}
height={600}
alt="Image 2"
/>
</div>
<div className="body-2 text-n-2">
<p className="mb-6">
Connect with the AI chatbot to start the
conversation. The chatbot uses natural
language processing to understand your
queries and provide relevant responses.
</p>
</div>
<div
id={`anchor-${openGroupId}-1`}
className="absolute -top-32 left-0 w-full h-0.25"
></div>
</div>
<div className="relative mb-16">
<div className="flex items-start mb-10">
<h4 className="h4 mr-auto">
Get Personalized Advices
</h4>
<Tagline className="ml-4 mt-4">03</Tagline>
</div>
<div className="mb-10">
<Image
className="w-full h-full object-cover rounded-3xl"
src="/images/how-to-use/image-3.jpg"
width={896}
height={600}
alt="Image 3"
/>
</div>
<div className="body-2 text-n-2">
<p className="mb-6">
Based on the conversation with the AI
chatbot, you will receive personalized
recommendations related to your queries. The
chatbot is trained to understand your
preferences and provide customized
suggestions.
</p>
</div>
<div
id={`anchor-${openGroupId}-2`}
className="absolute -top-32 left-0 w-full h-0.25"
></div>
</div>
<div className="relative mb-16">
<div className="flex items-start mb-10">
<h4 className="h4 mr-auto">
Explore and Engage
</h4>
<Tagline className="ml-4 mt-4">04</Tagline>
</div>
<div className="mb-10">
<Image
className="w-full h-full object-cover rounded-3xl"
src="/images/how-to-use/image-4.jpg"
width={896}
height={600}
alt="Image 4"
/>
</div>
<div className="body-2 text-n-2">
<p className="mb-6">
Explore the recommendations provided by the
AI chatbot and engage with the app. You can
ask questions, provide feedback, and share
your experience with the chatbot.
</p>
</div>
<div
id={`anchor-${openGroupId}-3`}
className="absolute -top-32 left-0 w-full h-0.25"
></div>
</div>
<div className="flex justify-center">
<Button>Read more</Button>
</div>
</div>
</div>
</div>
</Section>
);
};
export default HowToUse;

View File

@@ -1,261 +0,0 @@
import { useState } from "react";
import ScrollIntoView from "react-scroll-into-view";
import Section from "@/components/Section";
import Heading from "@/components/Heading";
import Image from "@/components/Image";
import Tagline from "@/components/Tagline";
import Button from "@/components/Button";
import { navigation } from "@/mocks/how-to-use";
type HowToUseProps = {};
const HowToUse = ({}: HowToUseProps) => {
const [openNavigation, setOpenNavigation] = useState<boolean>(false);
const [openGroupId, setOpenGroudId] = useState<string | null>("g0");
return (
<Section>
<div className="container md:py-10 lg:pt-16 xl:pt-20">
<Heading
textAlignClassName="text-center"
titleLarge="How to use"
textLarge="Get started with Brainwave - AI chat app today and experience the power of AI in your conversations!"
/>
<div className="relative max-w-[75rem] mb-15 mx-auto">
<Image
className="absolute top-6 left-6 w-6 opacity-30 pointer-events-none"
src="/images/icons/search-md.svg"
width={24}
height={24}
alt="Search"
/>
<input
className="w-full h-[4.5rem] pl-[3.5rem] pr-10 bg-transparent border border-n-6 rounded-[2.25rem] outline-none transition-colors focus:border-n-5"
type="text"
placeholder="Search topic"
/>
</div>
<div className="lg:flex">
<div className="mb-16 lg:flex-shrink-0 lg:w-[19rem] lg:mr-10 xl:mr-20">
<div
className="flex items-center justify-between w-full h-16 px-6 bg-n-7 rounded-xl cursor-pointer lg:hidden"
onClick={() => setOpenNavigation(!openNavigation)}
>
<div className="h6 text-n-1/50">
Getting started
</div>
<Image
src="/images/icons/chevron-down.svg"
width={24}
height={24}
alt="Arrow"
/>
</div>
<div
className={`pt-4 ${
openNavigation ? "block" : "hidden"
} lg:block lg:pt-0`}
>
{navigation.map((group) => (
<div className="mb-5" key={group.id}>
<button
className="flex items-start py-3.5"
onClick={() => setOpenGroudId(group.id)}
>
<div
className={`relative w-6 h-6 my-0.5 mr-5 p-0.5 ${
group.id === openGroupId
? "bg-n-7 border-[0.125rem] border-n-1/15 rounded-md"
: ""
}`}
>
<Image
className={`relative z-1 w-full transition-transform ${
group.id === openGroupId
? "rotate-90"
: ""
}`}
src="/images/icons/chevron-right.svg"
width={16}
height={16}
alt="Arrow"
/>
</div>
<div className="text-xl">
{group.title}
</div>
</button>
<div
className={`grid grid-rows-[0fr] transition-all ${
group.id === openGroupId
? "grid-rows-[1fr]"
: ""
}`}
>
<ul className="overflow-hidden">
{group.items.map((item) => (
<li key={item.id}>
<ScrollIntoView
className="body-2 block py-3 pl-11 text-n-3 transition-colors hover:text-color-1 cursor-pointer"
selector={`#anchor-${group.id}-${item.id}`}
>
<span>
{item.title}
</span>
</ScrollIntoView>
</li>
))}
</ul>
</div>
</div>
))}
</div>
</div>
<div className="flex-1">
<h3 className="h3 mb-16 pb-8 border-b border-n-1/15">
Getting started
</h3>
<div className="relative mb-16">
<div className="flex items-start mb-10">
<h4 className="h4 mr-auto">Sign up</h4>
<Tagline className="ml-4 mt-4">01</Tagline>
</div>
<div className="mb-10">
<Image
className="w-full h-full object-cover rounded-3xl"
src="/images/how-to-use/image-1.jpg"
width={896}
height={600}
alt="Image 1"
/>
</div>
<div className="body-2 text-n-2">
<p className="mb-6">
{`To create an account with Brainwave - AI
chat app, all you need to do is provide
your name, email address, and password.
Once you have signed up, you will be
able to start exploring the app's
various features. Brainwave's AI chat
system is designed to provide you with
an intuitive, easy-to-use interface that
makes it simple to chat with friends and
family, or even with new acquaintances.`}
</p>
<p className="mb-6">
In addition, the app is constantly being
updated with new features and improvements,
so you can expect it to continue to evolve
and improve over time. Whether you are
looking for a simple chat app, or a more
advanced platform that can help you stay
connected with people from all over the
world, Brainwave is the perfect choice.
</p>
</div>
<div
id={`anchor-${openGroupId}-0`}
className="absolute -top-32 left-0 w-full h-0.25"
></div>
</div>
<div className="relative mb-16">
<div className="flex items-start mb-10">
<h4 className="h4 mr-auto">
Connect with AI Chatbot
</h4>
<Tagline className="ml-4 mt-4">02</Tagline>
</div>
<div className="mb-10">
<Image
className="w-full h-full object-cover rounded-3xl"
src="/images/how-to-use/image-2.jpg"
width={896}
height={600}
alt="Image 2"
/>
</div>
<div className="body-2 text-n-2">
<p className="mb-6">
Connect with the AI chatbot to start the
conversation. The chatbot uses natural
language processing to understand your
queries and provide relevant responses.
</p>
</div>
<div
id={`anchor-${openGroupId}-1`}
className="absolute -top-32 left-0 w-full h-0.25"
></div>
</div>
<div className="relative mb-16">
<div className="flex items-start mb-10">
<h4 className="h4 mr-auto">
Get Personalized Advices
</h4>
<Tagline className="ml-4 mt-4">03</Tagline>
</div>
<div className="mb-10">
<Image
className="w-full h-full object-cover rounded-3xl"
src="/images/how-to-use/image-3.jpg"
width={896}
height={600}
alt="Image 3"
/>
</div>
<div className="body-2 text-n-2">
<p className="mb-6">
Based on the conversation with the AI
chatbot, you will receive personalized
recommendations related to your queries. The
chatbot is trained to understand your
preferences and provide customized
suggestions.
</p>
</div>
<div
id={`anchor-${openGroupId}-2`}
className="absolute -top-32 left-0 w-full h-0.25"
></div>
</div>
<div className="relative mb-16">
<div className="flex items-start mb-10">
<h4 className="h4 mr-auto">
Explore and Engage
</h4>
<Tagline className="ml-4 mt-4">04</Tagline>
</div>
<div className="mb-10">
<Image
className="w-full h-full object-cover rounded-3xl"
src="/images/how-to-use/image-4.jpg"
width={896}
height={600}
alt="Image 4"
/>
</div>
<div className="body-2 text-n-2">
<p className="mb-6">
Explore the recommendations provided by the
AI chatbot and engage with the app. You can
ask questions, provide feedback, and share
your experience with the chatbot.
</p>
</div>
<div
id={`anchor-${openGroupId}-3`}
className="absolute -top-32 left-0 w-full h-0.25"
></div>
</div>
<div className="flex justify-center">
<Button>Read more</Button>
</div>
</div>
</div>
</div>
</Section>
);
};
export default HowToUse;

View File

@@ -1,16 +0,0 @@
"use client";
import Layout from "@/components/Layout";
import HowToUse from "./HowToUse";
import Help from "./Help";
const HowToUsePage = () => {
return (
<Layout>
<HowToUse />
<Help />
</Layout>
);
};
export default HowToUsePage;

View File

@@ -1,16 +0,0 @@
"use client";
import Layout from "@/components/Layout";
import HowToUse from "./HowToUse";
import Help from "./Help";
const HowToUsePage = () => {
return (
<Layout>
<HowToUse />
<Help />
</Layout>
);
};
export default HowToUsePage;

View File

@@ -1,152 +0,0 @@
"use client";
import { useSearchParams } from "next/navigation";
import Button from "@/components/Button";
import Image from "@/components/Image";
import Layout from "@/components/Layout";
import Section from "@/components/Section";
const LoginPage = ({}) => {
const searchParams = useSearchParams();
const signUp = searchParams.has("new");
return (
<Layout hideFooter>
<Section className="flex min-h-[calc(100vh-4.8125rem)] overflow-hidden lg:min-h-[calc(100vh-5.3125rem)]">
<div className="container relative z-2 max-w-[68rem] m-auto lg:flex lg:justify-between">
<div className="max-w-[32.875rem] mx-auto mb-12 text-center md:mb-16 lg:flex lg:flex-col lg:justify-around lg:max-w-[23.75rem] lg:m-0 lg:text-left">
<h2 className="h2">
Join the AI revolution with Brainwave
</h2>
<p className="hidden body-2 mt-4 text-n-4 md:block">
Get started with Brainwave - AI chat app today and
experience the power of AI in your conversations!
</p>
</div>
<form
className="relative max-w-[23.5rem] mx-auto p-0.25 bg-conic-gradient rounded-3xl lg:flex-1 lg:max-w-[27.5rem] lg:m-0 xl:mr-12"
action=""
>
<div className="px-9 py-10 bg-n-8 rounded-[1.4375rem] lg:px-16 lg:py-[3.25rem]">
{signUp && (
<div className="relative mb-4 lg:mb-5">
<Image
className="absolute top-4 left-0 w-6 pointer-events-none"
src="/images/icons/mail-01.svg"
width={24}
height={24}
alt="Mail"
/>
<input
className="w-full h-14 pl-12 bg-transparent border-b border-n-1/15 font-light placeholder:text-n-4 outline-none transition-colors focus:border-n-1/30"
type="text"
placeholder="Name"
/>
</div>
)}
<div className="relative mb-4 lg:mb-5">
<Image
className="absolute top-4 left-0 w-6 pointer-events-none"
src="/images/icons/mail-01.svg"
width={24}
height={24}
alt="Mail"
/>
<input
className="w-full h-14 pl-12 bg-transparent border-b border-n-1/15 font-light placeholder:text-n-4 outline-none transition-colors focus:border-n-1/30"
type="text"
placeholder="Email"
/>
</div>
<div className="relative mb-4 lg:mb-5">
<Image
className="absolute top-4 left-0 w-6 pointer-events-none"
src="/images/icons/lock-03.svg"
width={24}
height={24}
alt="Lock"
/>
<input
className="w-full h-14 pl-12 bg-transparent border-b border-n-1/15 font-light placeholder:text-n-4 outline-none transition-colors focus:border-n-1/30"
type="text"
placeholder="Password"
/>
</div>
<Button className="w-full" white>
{signUp ? "Sign up now" : "Sign in"}
</Button>
<div className="mt-10">
<div className="caption mb-6 text-n-4 text-center">
Or start your Brainwave with
</div>
<div className="flex justify-center">
<a
className="flex items-center justify-center w-12 h-12 mx-3 border border-n-1/5 rounded-full transition-colors hover:border-n-1/15"
href="#"
>
<svg width="24" height="24">
<path
fill="#757185"
d="M23.049 10h-10.5v4.5h5.951c-.951 3-3.3 4-5.999 4a6.5 6.5 0 0 1-5.33-2.768 6.5 6.5 0 0 1-.787-5.954 6.5 6.5 0 0 1 4.428-4.057 6.5 6.5 0 0 1 5.863 1.302l3.27-3.117a11 11 0 0 0-9.931-2.623 11 11 0 0 0-7.768 6.721A11 11 0 0 0 3.414 18.21 11 11 0 0 0 12.501 23c6.066 0 11.55-4 10.548-13z"
/>
</svg>
</a>
<a
className="flex items-center justify-center w-12 h-12 mx-3 border border-n-1/5 rounded-full transition-colors hover:border-n-1/15"
href="#"
>
<svg width="24" height="24">
<path
fill="#757185"
d="M21.356 16.252c-1.338-.506-2.233-1.721-2.334-3.17-.099-1.412.593-2.666 1.851-3.355l1.046-.573-.747-.93c-1.255-1.563-3.051-2.497-4.804-2.497-1.215 0-2.058.318-2.735.574-.478.181-.855.323-1.269.323-.472 0-.938-.166-1.478-.358-.708-.252-1.51-.538-2.54-.538-1.99 0-3.997 1.188-5.237 3.098-1.851 2.849-1.343 7.734 1.208 11.616 1.011 1.538 2.428 3.305 4.435 3.323h.039c1.643 0 2.003-.876 3.598-.886 1.742.082 1.962.893 3.589.882 1.961-.018 3.375-1.771 4.499-3.484.664-1.007.921-1.534 1.438-2.678l.438-.97-.997-.377zM15.103 3.214c.65-.834 1.143-2.011.964-3.214-1.062.073-2.302.748-3.027 1.628-.658.799-1.201 1.983-.99 3.135 1.158.036 2.357-.656 3.053-1.549z"
/>
</svg>
</a>
</div>
</div>
</div>
<div className="hidden absolute top-6 -right-12 bottom-6 xl:flex">
<div className="w-6 bg-[#1B1B2E] rounded-r-3xl"></div>
<div className="w-6 my-12 bg-[#1B1B2E]/50 rounded-r-3xl"></div>
</div>
</form>
</div>
<div className="hidden absolute left-5 right-5 bottom-5 z-4 h-0.25 bg-n-6 pointer-events-none md:block lg:left-7.5 lg:right-7.5 lg:bottom-7.5 xl:left-10 xl:right-10 xl:bottom-10"></div>
<svg
className="hidden absolute left-[0.9375rem] bottom-[0.9375rem] z-4 pointer-events-none md:block lg:left-[1.5625rem] lg:bottom-[1.5625rem] xl:left-[2.1875rem] xl:bottom-[2.1875rem]"
width="11"
height="11"
fill="none"
>
<path
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
fill="#ada8c4"
/>
</svg>
<svg
className="hidden absolute right-[0.9375rem] bottom-[0.9375rem] z-4 pointer-events-none md:block lg:right-[1.5625rem] lg:bottom-[1.5625rem] xl:right-[2.1875rem] xl:bottom-[2.1875rem]"
width="11"
height="11"
fill="none"
>
<path
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
fill="#ada8c4"
/>
</svg>
<div className="absolute inset-0">
<Image
className="w-full h-full object-cover"
src="/images/login/background.jpg"
width={1920}
height={1080}
quality={100}
alt="Background"
/>
</div>
</Section>
</Layout>
);
};
export default LoginPage;

View File

@@ -1,152 +0,0 @@
"use client";
import { useSearchParams } from "next/navigation";
import Button from "@/components/Button";
import Image from "@/components/Image";
import Layout from "@/components/Layout";
import Section from "@/components/Section";
const LoginPage = ({}) => {
const searchParams = useSearchParams();
const signUp = searchParams.has("new");
return (
<Layout hideFooter>
<Section className="flex min-h-[calc(100vh-4.8125rem)] overflow-hidden lg:min-h-[calc(100vh-5.3125rem)]">
<div className="container relative z-2 max-w-[68rem] m-auto lg:flex lg:justify-between">
<div className="max-w-[32.875rem] mx-auto mb-12 text-center md:mb-16 lg:flex lg:flex-col lg:justify-around lg:max-w-[23.75rem] lg:m-0 lg:text-left">
<h2 className="h2">
Join the AI revolution with Brainwave
</h2>
<p className="hidden body-2 mt-4 text-n-4 md:block">
Get started with Brainwave - AI chat app today and
experience the power of AI in your conversations!
</p>
</div>
<form
className="relative max-w-[23.5rem] mx-auto p-0.25 bg-conic-gradient rounded-3xl lg:flex-1 lg:max-w-[27.5rem] lg:m-0 xl:mr-12"
action=""
>
<div className="px-9 py-10 bg-n-8 rounded-[1.4375rem] lg:px-16 lg:py-[3.25rem]">
{signUp && (
<div className="relative mb-4 lg:mb-5">
<Image
className="absolute top-4 left-0 w-6 pointer-events-none"
src="/images/icons/mail-01.svg"
width={24}
height={24}
alt="Mail"
/>
<input
className="w-full h-14 pl-12 bg-transparent border-b border-n-1/15 font-light placeholder:text-n-4 outline-none transition-colors focus:border-n-1/30"
type="text"
placeholder="Name"
/>
</div>
)}
<div className="relative mb-4 lg:mb-5">
<Image
className="absolute top-4 left-0 w-6 pointer-events-none"
src="/images/icons/mail-01.svg"
width={24}
height={24}
alt="Mail"
/>
<input
className="w-full h-14 pl-12 bg-transparent border-b border-n-1/15 font-light placeholder:text-n-4 outline-none transition-colors focus:border-n-1/30"
type="text"
placeholder="Email"
/>
</div>
<div className="relative mb-4 lg:mb-5">
<Image
className="absolute top-4 left-0 w-6 pointer-events-none"
src="/images/icons/lock-03.svg"
width={24}
height={24}
alt="Lock"
/>
<input
className="w-full h-14 pl-12 bg-transparent border-b border-n-1/15 font-light placeholder:text-n-4 outline-none transition-colors focus:border-n-1/30"
type="text"
placeholder="Password"
/>
</div>
<Button className="w-full" white>
{signUp ? "Sign up now" : "Sign in"}
</Button>
<div className="mt-10">
<div className="caption mb-6 text-n-4 text-center">
Or start your Brainwave with
</div>
<div className="flex justify-center">
<a
className="flex items-center justify-center w-12 h-12 mx-3 border border-n-1/5 rounded-full transition-colors hover:border-n-1/15"
href="#"
>
<svg width="24" height="24">
<path
fill="#757185"
d="M23.049 10h-10.5v4.5h5.951c-.951 3-3.3 4-5.999 4a6.5 6.5 0 0 1-5.33-2.768 6.5 6.5 0 0 1-.787-5.954 6.5 6.5 0 0 1 4.428-4.057 6.5 6.5 0 0 1 5.863 1.302l3.27-3.117a11 11 0 0 0-9.931-2.623 11 11 0 0 0-7.768 6.721A11 11 0 0 0 3.414 18.21 11 11 0 0 0 12.501 23c6.066 0 11.55-4 10.548-13z"
/>
</svg>
</a>
<a
className="flex items-center justify-center w-12 h-12 mx-3 border border-n-1/5 rounded-full transition-colors hover:border-n-1/15"
href="#"
>
<svg width="24" height="24">
<path
fill="#757185"
d="M21.356 16.252c-1.338-.506-2.233-1.721-2.334-3.17-.099-1.412.593-2.666 1.851-3.355l1.046-.573-.747-.93c-1.255-1.563-3.051-2.497-4.804-2.497-1.215 0-2.058.318-2.735.574-.478.181-.855.323-1.269.323-.472 0-.938-.166-1.478-.358-.708-.252-1.51-.538-2.54-.538-1.99 0-3.997 1.188-5.237 3.098-1.851 2.849-1.343 7.734 1.208 11.616 1.011 1.538 2.428 3.305 4.435 3.323h.039c1.643 0 2.003-.876 3.598-.886 1.742.082 1.962.893 3.589.882 1.961-.018 3.375-1.771 4.499-3.484.664-1.007.921-1.534 1.438-2.678l.438-.97-.997-.377zM15.103 3.214c.65-.834 1.143-2.011.964-3.214-1.062.073-2.302.748-3.027 1.628-.658.799-1.201 1.983-.99 3.135 1.158.036 2.357-.656 3.053-1.549z"
/>
</svg>
</a>
</div>
</div>
</div>
<div className="hidden absolute top-6 -right-12 bottom-6 xl:flex">
<div className="w-6 bg-[#1B1B2E] rounded-r-3xl"></div>
<div className="w-6 my-12 bg-[#1B1B2E]/50 rounded-r-3xl"></div>
</div>
</form>
</div>
<div className="hidden absolute left-5 right-5 bottom-5 z-4 h-0.25 bg-n-6 pointer-events-none md:block lg:left-7.5 lg:right-7.5 lg:bottom-7.5 xl:left-10 xl:right-10 xl:bottom-10"></div>
<svg
className="hidden absolute left-[0.9375rem] bottom-[0.9375rem] z-4 pointer-events-none md:block lg:left-[1.5625rem] lg:bottom-[1.5625rem] xl:left-[2.1875rem] xl:bottom-[2.1875rem]"
width="11"
height="11"
fill="none"
>
<path
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
fill="#ada8c4"
/>
</svg>
<svg
className="hidden absolute right-[0.9375rem] bottom-[0.9375rem] z-4 pointer-events-none md:block lg:right-[1.5625rem] lg:bottom-[1.5625rem] xl:right-[2.1875rem] xl:bottom-[2.1875rem]"
width="11"
height="11"
fill="none"
>
<path
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
fill="#ada8c4"
/>
</svg>
<div className="absolute inset-0">
<Image
className="w-full h-full object-cover"
src="/images/login/background.jpg"
width={1920}
height={1080}
quality={100}
alt="Background"
/>
</div>
</Section>
</Layout>
);
};
export default LoginPage;

View File

@@ -1,64 +0,0 @@
import { useRef, useState } from "react";
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
import Comment from "./Comment";
type CarouselProps = {
items: any;
};
const Carousel = ({ items }: CarouselProps) => {
const [activeIndex, setActiveIndex] = useState<number>(0);
const ref = useRef<any>(null);
const handleClick = (index: number) => {
setActiveIndex(index);
ref.current?.go(index);
};
return (
<Splide
className="splide-visible relative z-2"
options={{
pagination: false,
arrows: false,
gap: "1.5rem",
}}
onMoved={(e, newIndex) => setActiveIndex(newIndex)}
hasTrack={false}
ref={ref}
>
<SplideTrack>
{items.map((item: any) => (
<SplideSlide key={item.id}>
<div className="flex h-full">
<Comment comment={item} />
</div>
</SplideSlide>
))}
</SplideTrack>
<div className="flex justify-center mt-8 -mx-2 md:mt-15 lg:hidden">
{items.map((item: any, index: number) => (
<button
className="relative w-6 h-6 mx-2"
onClick={() => handleClick(index)}
key={item.id}
>
<span
className={`absolute inset-0 bg-conic-gradient rounded-full transition-opacity ${
index === activeIndex
? "opacity-100"
: "opacity-0"
}`}
></span>
<span className="absolute inset-0.25 bg-n-8 rounded-full">
<span className="absolute inset-2 bg-n-1 rounded-full"></span>
</span>
</button>
))}
</div>
</Splide>
);
};
export default Carousel;

View File

@@ -1,64 +0,0 @@
import { useRef, useState } from "react";
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
import Comment from "./Comment";
type CarouselProps = {
items: any;
};
const Carousel = ({ items }: CarouselProps) => {
const [activeIndex, setActiveIndex] = useState<number>(0);
const ref = useRef<any>(null);
const handleClick = (index: number) => {
setActiveIndex(index);
ref.current?.go(index);
};
return (
<Splide
className="splide-visible relative z-2"
options={{
pagination: false,
arrows: false,
gap: "1.5rem",
}}
onMoved={(e, newIndex) => setActiveIndex(newIndex)}
hasTrack={false}
ref={ref}
>
<SplideTrack>
{items.map((item: any) => (
<SplideSlide key={item.id}>
<div className="flex h-full">
<Comment comment={item} />
</div>
</SplideSlide>
))}
</SplideTrack>
<div className="flex justify-center mt-8 -mx-2 md:mt-15 lg:hidden">
{items.map((item: any, index: number) => (
<button
className="relative w-6 h-6 mx-2"
onClick={() => handleClick(index)}
key={item.id}
>
<span
className={`absolute inset-0 bg-conic-gradient rounded-full transition-opacity ${
index === activeIndex
? "opacity-100"
: "opacity-0"
}`}
></span>
<span className="absolute inset-0.25 bg-n-8 rounded-full">
<span className="absolute inset-2 bg-n-1 rounded-full"></span>
</span>
</button>
))}
</div>
</Splide>
);
};
export default Carousel;

View File

@@ -1,28 +0,0 @@
import Image from "@/components/Image";
type CommentProps = {
comment: any;
};
const Comment = ({ comment }: CommentProps) => (
<div className="flex flex-col bg-n-8 border border-n-1/5 rounded-2xl">
<div className="quote flex-1 px-5 py-10 md:px-10">{comment.text}</div>
<div className="flex items-center px-5 py-6 bg-n-7 rounded-b-[0.9375rem] md:px-10">
<div className="mr-5">
<h6 className="h6">{comment.name}</h6>
<div className="caption text-n-1/25">{comment.role}</div>
</div>
<div className="ml-auto">
<Image
className="w-full rounded-full"
src={comment.avatarUrl}
width={60}
height={60}
alt={comment.name}
/>
</div>
</div>
</div>
);
export default Comment;

View File

@@ -1,28 +0,0 @@
import Image from "@/components/Image";
type CommentProps = {
comment: any;
};
const Comment = ({ comment }: CommentProps) => (
<div className="flex flex-col bg-n-8 border border-n-1/5 rounded-2xl">
<div className="quote flex-1 px-5 py-10 md:px-10">{comment.text}</div>
<div className="flex items-center px-5 py-6 bg-n-7 rounded-b-[0.9375rem] md:px-10">
<div className="mr-5">
<h6 className="h6">{comment.name}</h6>
<div className="caption text-n-1/25">{comment.role}</div>
</div>
<div className="ml-auto">
<Image
className="w-full rounded-full"
src={comment.avatarUrl}
width={60}
height={60}
alt={comment.name}
/>
</div>
</div>
</div>
);
export default Comment;

View File

@@ -1,25 +0,0 @@
import Masonry, { ResponsiveMasonry } from "react-responsive-masonry";
import Comment from "./Comment";
type GridProps = {
items: any;
};
const Grid = ({ items }: GridProps) => {
return (
<ResponsiveMasonry
className="relative z-2"
columnsCountBreakPoints={{ 768: 2, 1280: 3 }}
>
<Masonry gutter="1.5rem">
{items.map((item: any) => (
<div key={item.id}>
<Comment comment={item} />
</div>
))}
</Masonry>
</ResponsiveMasonry>
);
};
export default Grid;

View File

@@ -1,25 +0,0 @@
import Masonry, { ResponsiveMasonry } from "react-responsive-masonry";
import Comment from "./Comment";
type GridProps = {
items: any;
};
const Grid = ({ items }: GridProps) => {
return (
<ResponsiveMasonry
className="relative z-2"
columnsCountBreakPoints={{ 768: 2, 1280: 3 }}
>
<Masonry gutter="1.5rem">
{items.map((item: any) => (
<div key={item.id}>
<Comment comment={item} />
</div>
))}
</Masonry>
</ResponsiveMasonry>
);
};
export default Grid;

View File

@@ -1,50 +0,0 @@
import dynamic from "next/dynamic";
import { useMediaQuery } from "react-responsive";
import Section from "@/components/Section";
import Heading from "@/components/Heading";
import Image from "@/components/Image";
const Grid = dynamic(() => import("./Grid"), { ssr: false });
const Carousel = dynamic(() => import("./Carousel"), { ssr: false });
import { community } from "@/mocks/community";
type CommunityProps = {};
const Community = ({}: CommunityProps) => {
const isTablet = useMediaQuery({
query: "(min-width: 768px)",
});
return (
<Section>
<div className="container">
<Heading
className="md:text-center"
tagClassName="md:justify-center"
tag="ready to get started"
title="What the community is saying"
/>
<div className="relative">
{isTablet ? (
<Grid items={community} />
) : (
<Carousel items={community} />
)}
<div className="absolute top-[18.25rem] -left-[30.375rem] w-[56.625rem] opacity-60 mix-blend-color-dodge pointer-events-none">
<div className="absolute top-1/2 left-1/2 w-[58.85rem] h-[58.85rem] -translate-x-3/4 -translate-y-1/2">
<Image
className="w-full"
src="/images/gradient.png"
width={942}
height={942}
alt="Gradient"
/>
</div>
</div>
</div>
</div>
</Section>
);
};
export default Community;

View File

@@ -1,50 +0,0 @@
import dynamic from "next/dynamic";
import { useMediaQuery } from "react-responsive";
import Section from "@/components/Section";
import Heading from "@/components/Heading";
import Image from "@/components/Image";
const Grid = dynamic(() => import("./Grid"), { ssr: false });
const Carousel = dynamic(() => import("./Carousel"), { ssr: false });
import { community } from "@/mocks/community";
type CommunityProps = {};
const Community = ({}: CommunityProps) => {
const isTablet = useMediaQuery({
query: "(min-width: 768px)",
});
return (
<Section>
<div className="container">
<Heading
className="md:text-center"
tagClassName="md:justify-center"
tag="ready to get started"
title="What the community is saying"
/>
<div className="relative">
{isTablet ? (
<Grid items={community} />
) : (
<Carousel items={community} />
)}
<div className="absolute top-[18.25rem] -left-[30.375rem] w-[56.625rem] opacity-60 mix-blend-color-dodge pointer-events-none">
<div className="absolute top-1/2 left-1/2 w-[58.85rem] h-[58.85rem] -translate-x-3/4 -translate-y-1/2">
<Image
className="w-full"
src="/images/gradient.png"
width={942}
height={942}
alt="Gradient"
/>
</div>
</div>
</div>
</div>
</Section>
);
};
export default Community;

View File

@@ -1,101 +0,0 @@
import Tippy from "@tippyjs/react";
import Heading from "@/components/Heading";
import Image from "@/components/Image";
import Section from "@/components/Section";
import { comparison } from "@/mocks/comparison";
type ComparisonProps = {};
const Comparison = ({}: ComparisonProps) => {
const check = (value: any, enterprise?: boolean) =>
typeof value === "boolean" ? (
value === true ? (
<Image
src={
enterprise
? "/images/check-yellow.svg"
: "/images/check.svg"
}
width={24}
height={24}
alt="Check"
/>
) : null
) : (
value
);
return (
<Section>
<div className="container">
<Heading
className="md:text-center"
title="Compare plans & features"
/>
<div className="-mx-5 px-5 overflow-auto">
<table className="table-fixed w-full min-w-[32rem]">
<tbody>
<tr className="h6">
<td className="w-[35%] py-4 pr-10">Features</td>
<td className="p-4 text-center text-color-2">
Basic
</td>
<td className="p-4 text-center text-color-1">
Premium
</td>
<td className="p-4 text-center text-color-3">
Enterprise
</td>
</tr>
{comparison.map((item) => (
<tr className="body-2" key={item.id}>
<td className="w-[35%] h-[4.75rem] py-2.5 pr-2.5 border-t border-n-1/5">
<div className="flex items-center">
{item.title}
<Tippy
className="p-2.5 bg-n-1 text-n-8 rounded-xl"
content="Provide dedicated servers for enterprises to ensure maximum security, performance, and uptime."
placement="right"
animation="shift-toward"
>
<div className="flex-shrink-0 ml-3 opacity-30 transition-opacity hover:opacity-100">
<Image
src="/images/icons/help-circle.svg"
width={24}
height={24}
alt="Help"
/>
</div>
</Tippy>
</div>
</td>
<td className="h-[4.75rem] p-2.5 border-t border-n-1/5 text-center">
{check(
item.pricing[0],
item.enterprise
)}
</td>
<td className="h-[4.75rem] p-2.5 border-t border-n-1/5 text-center">
{check(
item.pricing[1],
item.enterprise
)}
</td>
<td className="h-[4.75rem] p-2.5 border-t border-n-1/5 text-center">
{check(
item.pricing[2],
item.enterprise
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Section>
);
};
export default Comparison;

View File

@@ -1,101 +0,0 @@
import Tippy from "@tippyjs/react";
import Heading from "@/components/Heading";
import Image from "@/components/Image";
import Section from "@/components/Section";
import { comparison } from "@/mocks/comparison";
type ComparisonProps = {};
const Comparison = ({}: ComparisonProps) => {
const check = (value: any, enterprise?: boolean) =>
typeof value === "boolean" ? (
value === true ? (
<Image
src={
enterprise
? "/images/check-yellow.svg"
: "/images/check.svg"
}
width={24}
height={24}
alt="Check"
/>
) : null
) : (
value
);
return (
<Section>
<div className="container">
<Heading
className="md:text-center"
title="Compare plans & features"
/>
<div className="-mx-5 px-5 overflow-auto">
<table className="table-fixed w-full min-w-[32rem]">
<tbody>
<tr className="h6">
<td className="w-[35%] py-4 pr-10">Features</td>
<td className="p-4 text-center text-color-2">
Basic
</td>
<td className="p-4 text-center text-color-1">
Premium
</td>
<td className="p-4 text-center text-color-3">
Enterprise
</td>
</tr>
{comparison.map((item) => (
<tr className="body-2" key={item.id}>
<td className="w-[35%] h-[4.75rem] py-2.5 pr-2.5 border-t border-n-1/5">
<div className="flex items-center">
{item.title}
<Tippy
className="p-2.5 bg-n-1 text-n-8 rounded-xl"
content="Provide dedicated servers for enterprises to ensure maximum security, performance, and uptime."
placement="right"
animation="shift-toward"
>
<div className="flex-shrink-0 ml-3 opacity-30 transition-opacity hover:opacity-100">
<Image
src="/images/icons/help-circle.svg"
width={24}
height={24}
alt="Help"
/>
</div>
</Tippy>
</div>
</td>
<td className="h-[4.75rem] p-2.5 border-t border-n-1/5 text-center">
{check(
item.pricing[0],
item.enterprise
)}
</td>
<td className="h-[4.75rem] p-2.5 border-t border-n-1/5 text-center">
{check(
item.pricing[1],
item.enterprise
)}
</td>
<td className="h-[4.75rem] p-2.5 border-t border-n-1/5 text-center">
{check(
item.pricing[2],
item.enterprise
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Section>
);
};
export default Comparison;

View File

@@ -1,78 +0,0 @@
import { useState } from "react";
import Section from "@/components/Section";
import Heading from "@/components/Heading";
import { faq } from "@/mocks/faq";
type FaqProps = {};
const Faq = ({}: FaqProps) => {
const [activeId, setActiveId] = useState<string | null>(faq[0].id);
return (
<Section>
<div className="container lg:flex">
<Heading
className="lg:min-w-[22.75rem] lg:mr-12 lg:pt-8 xl:min-w-[32.75rem]"
textAlignClassName="md:text-center lg:text-left"
title="Frequently asked questions"
text={
<>
Havent found what youre looking for?{" "}
<a
className="text-n-1 hover:text-color-2"
href="mailto:info@ui8.net"
>
Contact us
</a>
</>
}
/>
<div className="-mt-8 lg:mt-0">
{faq.map((item) => (
<div
className="py-8 border-b border-n-1/5"
key={item.id}
>
<div
className="flex items-start justify-between cursor-pointer"
onClick={() =>
setActiveId(
activeId === item.id ? null : item.id
)
}
>
<div className="text-[1.25rem] leading-8">
{item.title}
</div>
<div className="relative w-6 h-6 mt-1 ml-10">
<div className="absolute top-[0.6875rem] left-1 w-4 h-0.5 bg-n-1 rounded-sm"></div>
<div
className={`absolute top-[0.6875rem] left-1 w-4 h-0.5 bg-n-1 rounded-sm transition-transform ${
item.id === activeId
? ""
: "rotate-90"
}`}
></div>
</div>
</div>
<div
className={`grid grid-rows-[0fr] transition-all ${
item.id === activeId
? "grid-rows-[1fr]"
: ""
}`}
>
<div className="body-2 text-n-3 overflow-hidden">
<div className="pt-6">{item.text}</div>
</div>
</div>
</div>
))}
</div>
</div>
</Section>
);
};
export default Faq;

View File

@@ -1,78 +0,0 @@
import { useState } from "react";
import Section from "@/components/Section";
import Heading from "@/components/Heading";
import { faq } from "@/mocks/faq";
type FaqProps = {};
const Faq = ({}: FaqProps) => {
const [activeId, setActiveId] = useState<string | null>(faq[0].id);
return (
<Section>
<div className="container lg:flex">
<Heading
className="lg:min-w-[22.75rem] lg:mr-12 lg:pt-8 xl:min-w-[32.75rem]"
textAlignClassName="md:text-center lg:text-left"
title="Frequently asked questions"
text={
<>
Havent found what youre looking for?{" "}
<a
className="text-n-1 hover:text-color-2"
href="mailto:info@ui8.net"
>
Contact us
</a>
</>
}
/>
<div className="-mt-8 lg:mt-0">
{faq.map((item) => (
<div
className="py-8 border-b border-n-1/5"
key={item.id}
>
<div
className="flex items-start justify-between cursor-pointer"
onClick={() =>
setActiveId(
activeId === item.id ? null : item.id
)
}
>
<div className="text-[1.25rem] leading-8">
{item.title}
</div>
<div className="relative w-6 h-6 mt-1 ml-10">
<div className="absolute top-[0.6875rem] left-1 w-4 h-0.5 bg-n-1 rounded-sm"></div>
<div
className={`absolute top-[0.6875rem] left-1 w-4 h-0.5 bg-n-1 rounded-sm transition-transform ${
item.id === activeId
? ""
: "rotate-90"
}`}
></div>
</div>
</div>
<div
className={`grid grid-rows-[0fr] transition-all ${
item.id === activeId
? "grid-rows-[1fr]"
: ""
}`}
>
<div className="body-2 text-n-3 overflow-hidden">
<div className="pt-6">{item.text}</div>
</div>
</div>
</div>
))}
</div>
</div>
</Section>
);
};
export default Faq;

View File

@@ -1,50 +0,0 @@
import Section from "@/components/Section";
import Heading from "@/components/Heading";
import PricingList from "@/components/PricingList";
import { useState } from "react";
import Logos from "@/components/Logos";
type PricingProps = {};
const Pricing = ({}: PricingProps) => {
const [monthly, setMonthly] = useState<boolean>(false);
return (
<Section className="overflow-hidden">
<div className="container relative z-2 md:pt-10 lg:pt-16 xl:pt-20">
<Heading
textAlignClassName="text-center"
titleLarge="Pay once, use forever"
textLarge="Get started with Brainwave - AI chat app today and experience the power of AI in your conversations!"
/>
<div className="w-[19rem] mx-auto mb-10 p-0.25 bg-gradient-to-b from-[#D77DEE]/90 to-n-1/15 rounded-xl">
<div className="flex p-[0.1875rem] bg-n-8 rounded-[0.6875rem]">
<button
className={`button flex-1 h-10 rounded-lg transition-colors ${
monthly ? "bg-n-6" : ""
}`}
onClick={() => setMonthly(true)}
>
monthly
</button>
<button
className={`button flex-1 h-10 rounded-lg transition-colors ${
monthly ? "" : "bg-n-6"
}`}
onClick={() => setMonthly(false)}
>
annually
<span className="ml-2.5 p-1 bg-color-1 rounded">
-10%
</span>
</button>
</div>
</div>
<PricingList monthly={monthly} />
<Logos className="hidden mt-20 lg:block" />
</div>
</Section>
);
};
export default Pricing;

View File

@@ -1,50 +0,0 @@
import Section from "@/components/Section";
import Heading from "@/components/Heading";
import PricingList from "@/components/PricingList";
import { useState } from "react";
import Logos from "@/components/Logos";
type PricingProps = {};
const Pricing = ({}: PricingProps) => {
const [monthly, setMonthly] = useState<boolean>(false);
return (
<Section className="overflow-hidden">
<div className="container relative z-2 md:pt-10 lg:pt-16 xl:pt-20">
<Heading
textAlignClassName="text-center"
titleLarge="Pay once, use forever"
textLarge="Get started with Brainwave - AI chat app today and experience the power of AI in your conversations!"
/>
<div className="w-[19rem] mx-auto mb-10 p-0.25 bg-gradient-to-b from-[#D77DEE]/90 to-n-1/15 rounded-xl">
<div className="flex p-[0.1875rem] bg-n-8 rounded-[0.6875rem]">
<button
className={`button flex-1 h-10 rounded-lg transition-colors ${
monthly ? "bg-n-6" : ""
}`}
onClick={() => setMonthly(true)}
>
monthly
</button>
<button
className={`button flex-1 h-10 rounded-lg transition-colors ${
monthly ? "" : "bg-n-6"
}`}
onClick={() => setMonthly(false)}
>
annually
<span className="ml-2.5 p-1 bg-color-1 rounded">
-10%
</span>
</button>
</div>
</div>
<PricingList monthly={monthly} />
<Logos className="hidden mt-20 lg:block" />
</div>
</Section>
);
};
export default Pricing;

View File

@@ -1,22 +0,0 @@
"use client";
import Layout from "@/components/Layout";
import Pricing from "./Pricing";
import Comparison from "./Comparison";
import Community from "./Community";
import Join from "@/components/Join";
import Faq from "./Faq";
const PricingPage = () => {
return (
<Layout>
<Pricing />
<Comparison />
<Community />
<Faq />
<Join />
</Layout>
);
};
export default PricingPage;

View File

@@ -1,22 +0,0 @@
"use client";
import Layout from "@/components/Layout";
import Pricing from "./Pricing";
import Comparison from "./Comparison";
import Community from "./Community";
import Join from "@/components/Join";
import Faq from "./Faq";
const PricingPage = () => {
return (
<Layout>
<Pricing />
<Comparison />
<Community />
<Faq />
<Join />
</Layout>
);
};
export default PricingPage;

View File

@@ -1,58 +0,0 @@
import Section from "@/components/Section";
import Heading from "@/components/Heading";
import Button from "@/components/Button";
import Generating from "@/components/Generating";
import Image from "@/components/Image";
type HeroProps = {};
const Hero = ({}: HeroProps) => (
<Section>
<div className="container md:pt-10 lg:pt-16 xl:pt-20">
<Heading
className="md:mb-15"
textAlignClassName="text-center"
titleLarge="Roadmap"
textLarge="This document provides a comprehensive guide to developing a chat AI app, including its key features and development steps."
>
<Button
className="mt-8 md:mt-12"
href="mailto:info@ui8.net"
white
>
Suggest features
</Button>
</Heading>
<div className="relative max-w-5xl mx-auto">
<div className="relative z-1 p-0.5 rounded-2xl bg-conic-gradient">
<div className="relative bg-n-8 rounded-[0.875rem]">
<div className="h-[1.375rem] bg-[#43435C] rounded-t-[0.875rem]"></div>
<div className="relative h-[30.625rem] rounded-b-[0.875rem] overflow-hidden">
<Image
className="absolute top-1/2 left-1/2 max-w-none -translate-x-[51%] -translate-y-[58%]"
src="/images/roadmap/hero.png"
width={654}
height={932}
alt="Hero"
/>
</div>
<div className="hidden absolute -top-20 left-[14%] md:block pointer-events-none">
<Image
className="w-full"
src="/images/roadmap/coins.png"
width={76}
height={190}
alt="Coins"
/>
</div>
<Generating className="absolute left-4 right-4 bottom-5 border border-n-1/10 md:left-1/2 md:right-auto md:bottom-8 md:w-[30.5rem] md:-translate-x-1/2" />
</div>
</div>
<div className="relative z-1 h-6 mx-2.5 bg-[#1B1B2E] shadow-xl rounded-b-[1.25rem] lg:h-6 lg:mx-8"></div>
<div className="relative z-1 h-6 mx-6 bg-[#1B1B2E]/70 shadow-xl rounded-b-[1.25rem] lg:h-6 lg:mx-20"></div>
</div>
</div>
</Section>
);
export default Hero;

View File

@@ -1,58 +0,0 @@
import Section from "@/components/Section";
import Heading from "@/components/Heading";
import Button from "@/components/Button";
import Generating from "@/components/Generating";
import Image from "@/components/Image";
type HeroProps = {};
const Hero = ({}: HeroProps) => (
<Section>
<div className="container md:pt-10 lg:pt-16 xl:pt-20">
<Heading
className="md:mb-15"
textAlignClassName="text-center"
titleLarge="Roadmap"
textLarge="This document provides a comprehensive guide to developing a chat AI app, including its key features and development steps."
>
<Button
className="mt-8 md:mt-12"
href="mailto:info@ui8.net"
white
>
Suggest features
</Button>
</Heading>
<div className="relative max-w-5xl mx-auto">
<div className="relative z-1 p-0.5 rounded-2xl bg-conic-gradient">
<div className="relative bg-n-8 rounded-[0.875rem]">
<div className="h-[1.375rem] bg-[#43435C] rounded-t-[0.875rem]"></div>
<div className="relative h-[30.625rem] rounded-b-[0.875rem] overflow-hidden">
<Image
className="absolute top-1/2 left-1/2 max-w-none -translate-x-[51%] -translate-y-[58%]"
src="/images/roadmap/hero.png"
width={654}
height={932}
alt="Hero"
/>
</div>
<div className="hidden absolute -top-20 left-[14%] md:block pointer-events-none">
<Image
className="w-full"
src="/images/roadmap/coins.png"
width={76}
height={190}
alt="Coins"
/>
</div>
<Generating className="absolute left-4 right-4 bottom-5 border border-n-1/10 md:left-1/2 md:right-auto md:bottom-8 md:w-[30.5rem] md:-translate-x-1/2" />
</div>
</div>
<div className="relative z-1 h-6 mx-2.5 bg-[#1B1B2E] shadow-xl rounded-b-[1.25rem] lg:h-6 lg:mx-8"></div>
<div className="relative z-1 h-6 mx-6 bg-[#1B1B2E]/70 shadow-xl rounded-b-[1.25rem] lg:h-6 lg:mx-20"></div>
</div>
</div>
</Section>
);
export default Hero;

View File

@@ -1,60 +0,0 @@
import Image from "@/components/Image";
import Section from "@/components/Section";
import Tagline from "@/components/Tagline";
import { roadmapFull } from "@/mocks/roadmap";
type RoadmapProps = {};
const Roadmap = ({}: RoadmapProps) => (
<Section>
<div className="container max-w-[70rem]">
<ul className="-mb-15 md:mb-0">
{roadmapFull.map((item) => (
<li
className="relative mb-15 md:flex md:mb-0"
key={item.id}
>
<div className="flex items-center h-14 mb-3 md:w-[16rem] md:h-[6.5rem] md:mb-0 lg:h-[9.5rem]">
<Tagline className="mr-8">{item.date}</Tagline>
<div className="w-32 h-0.25 bg-n-6 md:flex-1"></div>
</div>
<div className="md:flex md:flex-1 md:p-6 md:border-l md:border-n-6 lg:py-12 lg:px-20">
<div className="mb-6 md:flex-shrink-0 md:w-14 md:mr-6 md:mb-0">
<Image
src={
item.status === "done"
? "/images/roadmap/done.svg"
: "/images/roadmap/undone.svg"
}
width={56}
height={56}
alt="Done"
/>
</div>
<div className="md:flex-1">
<div className="mb-5 md:flex md:items-center md:justify-between md:min-h-[3.5rem] md:mb-6">
<h5 className="h5">{item.title}</h5>
{item.status === "progress" && (
<div className="absolute top-4 right-0 flex items-center px-4 py-1 bg-n-1 rounded text-n-8 md:static md:flex-shrink-0 md:self-start md:mt-4 md:ml-6">
<Image
className="mr-2.5"
src="/images/icons/loading-01.svg"
width={16}
height={16}
alt="In progress"
/>
<div className="tagline">WIP</div>
</div>
)}
</div>
<p className="body-2 text-n-4">{item.text}</p>
</div>
</div>
</li>
))}
</ul>
</div>
</Section>
);
export default Roadmap;

View File

@@ -1,60 +0,0 @@
import Image from "@/components/Image";
import Section from "@/components/Section";
import Tagline from "@/components/Tagline";
import { roadmapFull } from "@/mocks/roadmap";
type RoadmapProps = {};
const Roadmap = ({}: RoadmapProps) => (
<Section>
<div className="container max-w-[70rem]">
<ul className="-mb-15 md:mb-0">
{roadmapFull.map((item) => (
<li
className="relative mb-15 md:flex md:mb-0"
key={item.id}
>
<div className="flex items-center h-14 mb-3 md:w-[16rem] md:h-[6.5rem] md:mb-0 lg:h-[9.5rem]">
<Tagline className="mr-8">{item.date}</Tagline>
<div className="w-32 h-0.25 bg-n-6 md:flex-1"></div>
</div>
<div className="md:flex md:flex-1 md:p-6 md:border-l md:border-n-6 lg:py-12 lg:px-20">
<div className="mb-6 md:flex-shrink-0 md:w-14 md:mr-6 md:mb-0">
<Image
src={
item.status === "done"
? "/images/roadmap/done.svg"
: "/images/roadmap/undone.svg"
}
width={56}
height={56}
alt="Done"
/>
</div>
<div className="md:flex-1">
<div className="mb-5 md:flex md:items-center md:justify-between md:min-h-[3.5rem] md:mb-6">
<h5 className="h5">{item.title}</h5>
{item.status === "progress" && (
<div className="absolute top-4 right-0 flex items-center px-4 py-1 bg-n-1 rounded text-n-8 md:static md:flex-shrink-0 md:self-start md:mt-4 md:ml-6">
<Image
className="mr-2.5"
src="/images/icons/loading-01.svg"
width={16}
height={16}
alt="In progress"
/>
<div className="tagline">WIP</div>
</div>
)}
</div>
<p className="body-2 text-n-4">{item.text}</p>
</div>
</div>
</li>
))}
</ul>
</div>
</Section>
);
export default Roadmap;

View File

@@ -1,18 +0,0 @@
"use client";
import Layout from "@/components/Layout";
import Join from "@/components/Join";
import Hero from "./Hero";
import Roadmap from "./Roadmap";
const RoadmapPage = () => {
return (
<Layout>
<Hero />
<Roadmap />
<Join />
</Layout>
);
};
export default RoadmapPage;

View File

@@ -1,18 +0,0 @@
"use client";
import Layout from "@/components/Layout";
import Join from "@/components/Join";
import Hero from "./Hero";
import Roadmap from "./Roadmap";
const RoadmapPage = () => {
return (
<Layout>
<Hero />
<Roadmap />
<Join />
</Layout>
);
};
export default RoadmapPage;

View File

@@ -53,6 +53,59 @@ export const forumColors = {
info: '#2196F3',
},
// 扩展功能色系(类似 Chakra UI 的颜色阶梯)
success: {
50: '#E8F5E9',
100: '#C8E6C9',
200: '#A5D6A7',
300: '#81C784',
400: '#66BB6A',
500: '#4CAF50', // 主绿色
600: '#43A047',
700: '#388E3C',
800: '#2E7D32',
900: '#1B5E20',
},
error: {
50: '#FFEBEE',
100: '#FFCDD2',
200: '#EF9A9A',
300: '#E57373',
400: '#EF5350',
500: '#F44336', // 主红色
600: '#E53935',
700: '#D32F2F',
800: '#C62828',
900: '#B71C1C',
},
warning: {
50: '#FFF3E0',
100: '#FFE0B2',
200: '#FFCC80',
300: '#FFB74D',
400: '#FFA726',
500: '#FF9800', // 主橙色
600: '#FB8C00',
700: '#F57C00',
800: '#EF6C00',
900: '#E65100',
},
info: {
50: '#E3F2FD',
100: '#BBDEFB',
200: '#90CAF9',
300: '#64B5F6',
400: '#42A5F5',
500: '#2196F3', // 主蓝色
600: '#1E88E5',
700: '#1976D2',
800: '#1565C0',
900: '#0D47A1',
},
// 金色渐变系列
gradients: {
goldPrimary: 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)',
@@ -164,6 +217,32 @@ export const forumComponentStyles = {
},
},
// Select下拉框样式修复白色文字问题
Select: {
baseStyle: {
field: {
bg: forumColors.background.card,
color: forumColors.text.primary,
borderColor: forumColors.border.default,
_hover: {
borderColor: forumColors.border.light,
},
_focus: {
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
},
// 修复下拉选项颜色
option: {
bg: forumColors.background.card,
color: forumColors.text.primary,
},
},
icon: {
color: forumColors.text.primary,
},
},
},
// 标签样式
Tag: {
variants: {

View File

@@ -0,0 +1,313 @@
# Agent Chat - 超炫酷 AI 投研助手
> 🎨 基于 **Hero UI** (NextUI) 构建的现代化 AI 聊天界面,模仿 Google AI Studio 风格
## ✨ 设计亮点
### 🚀 技术栈
- **Hero UI** - 现代化 React UI 组件库NextUI 的继任者)
- **Framer Motion 12** - 物理动画引擎(已升级!)
- **Tailwind CSS** - 原子化 CSS 框架Hero UI 内置)
- **Lucide Icons** - 现代化图标库
- **Chakra UI Toast** - 通知提示(待迁移到 Hero UI Toast
### 🎨 视觉特性
1. **毛玻璃效果Glassmorphism**
- 侧边栏和顶栏采用半透明毛玻璃质感
- `backdrop-blur-xl` + 渐变背景
- 深色模式完美支持
2. **流畅动画Framer Motion 12 物理引擎)**
- ✨ 侧边栏 **Spring 弹性动画**滑入/滑出stiffness: 300, damping: 30
- ✨ 消息 **淡入上移** + **错开延迟**staggerChildren: 0.05
- ✨ 按钮 **悬停缩放**1.05x + **点击压缩**0.95x
- ✨ AI 头像 **360度持续旋转**duration: 3s, linear
- ✨ 快捷问题卡片 **退出动画**AnimatePresence
3. **渐变色设计**
- 标题:蓝到紫渐变
- 用户消息气泡:蓝到紫渐变
- 发送按钮:蓝到紫渐变
- AI 头像:紫到粉渐变
4. **响应式布局**
- 三栏式设计(左侧历史 + 中间聊天 + 右侧配置)
- 侧边栏可折叠
- 暗黑模式支持
- 集成主导航栏MainLayout
### 🎯 核心功能
#### 1. 左侧历史面板
- ✅ 会话列表展示(带搜索)
- ✅ 新建对话
- ✅ 切换会话
- ✅ 会话元信息(时间、消息数)
- ✅ 用户信息展示
- ✅ 折叠/展开动画
#### 2. 中间聊天区域
- ✅ 消息流展示(用户/AI
- ✅ AI 思考状态(脉冲动画)
- ✅ 消息操作(复制、点赞、点踩)
- ✅ 执行步骤详情(可折叠 Accordion
- ✅ 快捷问题按钮2x2 网格)
- ✅ 键盘快捷键Enter 发送Shift+Enter 换行)
- ✅ 自动滚动到底部
- ✅ Hero UI ScrollShadow 组件
#### 3. 右侧配置面板
- ✅ Tabs 切换(模型 / 工具 / 统计)
- ✅ 模型选择3 个模型,卡片式)
- ✅ 工具选择Checkbox 多选)
- ✅ 统计信息(会话数、消息数、工具数)
## 🔧 使用方法
### 访问路径
```
/agent-chat
```
### 模型选择
| 模型 | 图标 | 描述 | 适用场景 |
|------|------|------|----------|
| **Kimi K2 Thinking** | 🧠 | 深度思考模型 | 复杂分析、深度研究 |
| **Kimi K2** | ⚡ | 快速响应模型 | 简单查询、快速问答 |
| **DeepMoney** | 📈 | 金融专业模型 | 金融数据分析 |
### 工具选择
| 工具 | 功能 |
|------|------|
| 📰 新闻搜索 | 搜索最新财经新闻 |
| 📈 涨停分析 | 分析涨停股票 |
| 💾 概念板块 | 查询概念板块信息 |
| 📚 研报搜索 | 搜索研究报告 |
| 📊 路演信息 | 查询路演活动 |
### 快捷键
| 快捷键 | 功能 |
|--------|------|
| `Enter` | 发送消息 |
| `Shift + Enter` | 换行 |
## 📦 Hero UI 组件使用
### 核心组件
```javascript
import {
Button, // 按钮
Card, // 卡片
Input, // 输入框
Avatar, // 头像
Chip, // 标签
Badge, // 徽章
Spinner, // 加载器
Tooltip, // 工具提示
Checkbox, // 复选框
Tabs, Tab, // 标签页
ScrollShadow, // 滚动阴影
Kbd, // 键盘按键
Accordion, // 手风琴
} from '@heroui/react';
```
### 特色功能
1. **isPressable / isHoverable**
```javascript
<Card isPressable isHoverable onPress={handleClick}>
内容
</Card>
```
2. **ScrollShadow**(自动滚动阴影)
```javascript
<ScrollShadow className="flex-1">
长内容...
</ScrollShadow>
```
3. **渐变背景Tailwind**
```javascript
<div className="bg-gradient-to-br from-gray-50 to-blue-50">
内容
</div>
```
4. **毛玻璃效果**
```javascript
<div className="bg-white/80 backdrop-blur-xl">
内容
</div>
```
## 🔌 API 集成
### 后端接口
#### 1. 获取会话列表
```http
GET /mcp/agent/sessions?user_id={user_id}&limit=50
```
#### 2. 获取会话历史
```http
GET /mcp/agent/history/{session_id}?limit=100
```
#### 3. 发送消息
```http
POST /mcp/agent/chat
Content-Type: application/json
{
"message": "用户问题",
"conversation_history": [],
"user_id": "user_id",
"session_id": "uuid或null",
"model": "kimi-k2-thinking",
"tools": ["search_news", "search_limit_up"]
}
```
## 🎨 Hero UI 特性
### 为什么选择 Hero UI
1. **基于 Tailwind CSS**
- 编译时 CSS零运行时开销
- 原子化类名,易于定制
- 深色模式内置支持
2. **基于 React Aria**
- 完整的无障碍支持
- 键盘导航内置
- ARIA 属性自动处理
3. **TypeScript 优先**
- 完整的类型支持
- 智能提示
4. **物理动画**
- 集成 Framer Motion
- 性能优化
5. **模块化架构**
- npm 包分发(非复制粘贴)
- 按需引入
- Tree-shaking 友好
### Hero UI vs Chakra UI
| 特性 | Hero UI | Chakra UI |
|------|---------|-----------|
| CSS 方案 | Tailwind CSS编译时 | Emotion运行时 |
| 包大小 | 更小Tree-shaking | 较大 |
| 性能 | 更快(无运行时 CSS | 较慢 |
| 定制性 | Tailwind 配置 | Theme 对象 |
| 学习曲线 | 需要熟悉 Tailwind | 纯 Props API |
| 组件数量 | 210+ | 100+ |
| 动画 | Framer Motion | Framer Motion |
| 无障碍 | React Aria | 自实现 |
## 📁 文件结构
```
src/views/AgentChat/
├── index.js # Hero UI 版本(当前)
├── index_old_chakra.js # Chakra UI 旧版本(备份)
└── README.md # 本文档
```
## 🎯 组件层次
```
AgentChat
├── MotionDiv (背景渐变)
├── LeftSidebar (历史会话)
│ ├── SearchInput (Hero UI Input)
│ ├── SessionList (Hero UI Card)
│ └── UserInfo (Hero UI Avatar)
├── ChatArea (中间区域)
│ ├── ChatHeader (Hero UI)
│ ├── MessageList (Hero UI ScrollShadow)
│ │ └── MessageRenderer
│ ├── QuickQuestions (Hero UI Button)
│ └── InputBox (Hero UI Input + Button)
└── RightSidebar (配置面板)
├── Tabs (Hero UI Tabs)
├── ModelSelector (Hero UI Card)
├── ToolSelector (Hero UI CheckboxGroup)
└── Statistics (Hero UI Badge)
```
## 🚀 性能优化
1. **代码分割**
- React.lazy() 懒加载
- 路由级别分割
2. **动画优化**
- Framer Motion 硬件加速
- AnimatePresence 动画退出
3. **Tailwind CSS**
- JIT 模式(即时编译,构建速度提升 50%
- 编译时生成 CSS
- 零运行时开销
- PurgeCSS 自动清理
4. **Hero UI**
- Tree-shaking 优化
- 按需引入组件
5. **构建优化craco.config.js**
- 文件系统缓存(二次构建提速 50-80%
- ESLint 插件移除(构建提速 20-30%
- 生产环境禁用 source map提速 40-60%
- 激进的代码分割策略(按库分离)
- Babel 缓存启用
## 🐛 已知问题
- ~~深色模式下某些颜色对比度不足~~ ✅ 已修复
- ~~会话删除功能需要后端 API 支持~~ ⏳ 待实现
## 📝 开发日志
### 2025-11-22
- ✅ 完成 Hero UI 迁移
- ✅ 实现三栏式布局
- ✅ 添加毛玻璃效果
- ✅ 集成 Framer Motion 动画
- ✅ 添加模型和工具选择功能
- ✅ 优化深色模式
## 🔮 未来计划
- [ ] 支持流式响应SSE
- [ ] Markdown 渲染react-markdown
- [ ] 代码高亮Prism.js
- [ ] 图片上传和分析
- [ ] 语音输入/输出
- [ ] 导出为 PDF/Word
- [ ] 分享对话链接
- [ ] 对话模板功能
## 📖 参考资源
- [Hero UI 官方文档](https://www.heroui.com/docs)
- [Framer Motion 文档](https://www.framer.com/motion/)
- [Tailwind CSS 文档](https://tailwindcss.com/docs)
- [Lucide Icons](https://lucide.dev/)
## 📄 许可证
本项目基于 Argon Dashboard Chakra PRO 模板开发。

Some files were not shown because too many files have changed in this diff Show More