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:
309
src/components/ImageLightbox/index.js
Normal file
309
src/components/ImageLightbox/index.js
Normal 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;
|
||||
270
src/components/ImagePreviewModal/index.js
Normal file
270
src/components/ImagePreviewModal/index.js
Normal 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;
|
||||
@@ -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 组件内部 */}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -41,9 +41,6 @@ const NavbarActions = memo(({
|
||||
}) => {
|
||||
return (
|
||||
<HStack spacing={{ base: 2, md: 4 }}>
|
||||
{/* 主题切换按钮 */}
|
||||
<ThemeToggleButton />
|
||||
|
||||
{/* 显示加载状态 */}
|
||||
{isLoading ? (
|
||||
<Spinner size="sm" color="blue.500" />
|
||||
|
||||
@@ -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' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
287
src/components/StockChart/StockChartKLineModal.tsx
Normal file
287
src/components/StockChart/StockChartKLineModal.tsx
Normal 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;
|
||||
@@ -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>
|
||||
213
src/components/StockChart/StockChartModal.tsx
Normal file
213
src/components/StockChart/StockChartModal.tsx
Normal 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;
|
||||
205
src/components/StockChart/config/chartConfig.ts
Normal file
205
src/components/StockChart/config/chartConfig.ts
Normal 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;
|
||||
32
src/components/StockChart/config/index.ts
Normal file
32
src/components/StockChart/config/index.ts
Normal 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';
|
||||
370
src/components/StockChart/config/klineTheme.ts
Normal file
370
src/components/StockChart/config/klineTheme.ts
Normal 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 || '';
|
||||
// },
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
15
src/components/StockChart/hooks/index.ts
Normal file
15
src/components/StockChart/hooks/index.ts
Normal 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';
|
||||
238
src/components/StockChart/hooks/useEventMarker.ts
Normal file
238
src/components/StockChart/hooks/useEventMarker.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
247
src/components/StockChart/hooks/useKLineChart.ts
Normal file
247
src/components/StockChart/hooks/useKLineChart.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
329
src/components/StockChart/hooks/useKLineData.ts
Normal file
329
src/components/StockChart/hooks/useKLineData.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
93
src/components/StockChart/indicators/avgPriceIndicator.ts
Normal file
93
src/components/StockChart/indicators/avgPriceIndicator.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
126
src/components/StockChart/types/chart.types.ts
Normal file
126
src/components/StockChart/types/chart.types.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
25
src/components/StockChart/types/index.ts
Normal file
25
src/components/StockChart/types/index.ts
Normal 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';
|
||||
80
src/components/StockChart/types/stock.types.ts
Normal file
80
src/components/StockChart/types/stock.types.ts
Normal 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[];
|
||||
}
|
||||
295
src/components/StockChart/utils/chartUtils.ts
Normal file
295
src/components/StockChart/utils/chartUtils.ts
Normal 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 });
|
||||
});
|
||||
};
|
||||
320
src/components/StockChart/utils/dataAdapter.ts
Normal file
320
src/components/StockChart/utils/dataAdapter.ts
Normal 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;
|
||||
};
|
||||
360
src/components/StockChart/utils/eventMarkerUtils.ts
Normal file
360
src/components/StockChart/utils/eventMarkerUtils.ts
Normal 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';
|
||||
|
||||
/**
|
||||
* 创建事件标记 Overlay(KLineChart 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;
|
||||
};
|
||||
48
src/components/StockChart/utils/index.ts
Normal file
48
src/components/StockChart/utils/index.ts
Normal 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';
|
||||
121
src/components/StockRelation/RelationDescription.tsx
Normal file
121
src/components/StockRelation/RelationDescription.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
6
src/components/StockRelation/index.ts
Normal file
6
src/components/StockRelation/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* StockRelation 组件导出入口
|
||||
*/
|
||||
|
||||
export { RelationDescription } from './RelationDescription';
|
||||
export type { RelationDescriptionProps, RelationDescType } from './RelationDescription';
|
||||
Reference in New Issue
Block a user