Files
vf_react/src/views/StockOverview/components/HotspotOverview/index.js
2025-12-11 07:48:19 +08:00

485 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 热点概览组件 - 科技感设计
* 展示大盘分时走势 + 概念异动标注
*
* 布局设计:
* - 顶部:统计摘要(指数信息 + 异动统计)
* - 中部:大尺寸分时图(主要展示区域)
* - 底部:异动列表(横向滚动卡片)
*/
import React, { useState, useCallback } from 'react';
import {
Box,
Heading,
Text,
HStack,
VStack,
Spinner,
Center,
Icon,
Flex,
Spacer,
Tooltip,
useColorModeValue,
IconButton,
Collapse,
SimpleGrid,
} from '@chakra-ui/react';
import { keyframes } from '@emotion/react';
import {
Flame,
List,
LineChart,
ChevronDown,
ChevronUp,
Info,
Zap,
AlertCircle,
TrendingUp,
TrendingDown,
} from 'lucide-react';
import { useHotspotData } from './hooks';
import { IndexMinuteChart, ConceptAlertList, AlertSummary } from './components';
import { ALERT_TYPE_CONFIG, getAlertTypeLabel } from './utils/chartHelpers';
// 动画效果
const gradientShift = keyframes`
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
`;
const pulseGlow = keyframes`
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
`;
/**
* 紧凑型异动卡片(用于横向滚动)
*/
const CompactAlertCard = ({ alert, onClick, isSelected }) => {
const cardBg = useColorModeValue('white', '#0d0d0d');
const borderColor = useColorModeValue('gray.200', '#2d2d2d');
const textColor = useColorModeValue('gray.800', 'white');
const subTextColor = useColorModeValue('gray.500', 'gray.400');
const config = ALERT_TYPE_CONFIG[alert.alert_type] || ALERT_TYPE_CONFIG.surge;
const isUp = alert.alert_type !== 'surge_down';
return (
<Box
bg={cardBg}
borderRadius="xl"
borderWidth="2px"
borderColor={isSelected ? config.color : borderColor}
p={3}
minW="180px"
maxW="200px"
cursor="pointer"
onClick={() => onClick?.(alert)}
transition="all 0.2s"
_hover={{
borderColor: config.color,
transform: 'translateY(-2px)',
boxShadow: `0 4px 15px ${config.color}25`,
}}
position="relative"
overflow="hidden"
>
{/* 顶部渐变条 */}
<Box
position="absolute"
top={0}
left={0}
right={0}
h="3px"
bgGradient={`linear(to-r, ${config.gradient[0]}, ${config.gradient[1]})`}
/>
{/* 时间 + 类型 */}
<HStack justify="space-between" mb={1}>
<Text fontSize="xs" color={subTextColor} fontFamily="mono">
{alert.time}
</Text>
<HStack
spacing={1}
px={1.5}
py={0.5}
borderRadius="md"
bg={`${config.color}15`}
>
<Icon
as={isUp ? TrendingUp : TrendingDown}
boxSize={3}
color={config.color}
/>
<Text fontSize="10px" fontWeight="bold" color={config.color}>
{getAlertTypeLabel(alert.alert_type)}
</Text>
</HStack>
</HStack>
{/* 概念名称 */}
<Text
fontWeight="bold"
fontSize="sm"
color={textColor}
noOfLines={1}
mb={1}
>
{alert.concept_name}
</Text>
{/* 分数 + Alpha */}
<HStack justify="space-between" fontSize="xs">
<HStack spacing={1}>
<Text color={subTextColor}>评分</Text>
<Text fontWeight="bold" color={config.color}>
{Math.round(alert.final_score || 0)}
</Text>
</HStack>
{alert.alpha != null && (
<Text
fontWeight="bold"
color={alert.alpha >= 0 ? '#ff4d4f' : '#52c41a'}
>
α {alert.alpha >= 0 ? '+' : ''}{alert.alpha.toFixed(1)}%
</Text>
)}
</HStack>
</Box>
);
};
/**
* 热点概览主组件
* @param {Object} props
* @param {Date|null} props.selectedDate - 选中的交易日期
*/
const HotspotOverview = ({ selectedDate }) => {
const [selectedAlert, setSelectedAlert] = useState(null);
const [showDetailList, setShowDetailList] = useState(false);
// 获取数据
const { loading, error, data } = useHotspotData(selectedDate);
// 颜色主题
const cardBg = useColorModeValue('white', '#0a0a0a');
const borderColor = useColorModeValue('gray.200', '#1f1f1f');
const textColor = useColorModeValue('gray.800', 'white');
const subTextColor = useColorModeValue('gray.600', 'gray.400');
const sectionBg = useColorModeValue('gray.50', '#0d0d0d');
const scrollbarColor = useColorModeValue('#ddd', '#333');
// 点击异动标注
const handleAlertClick = useCallback((alert) => {
setSelectedAlert(alert);
}, []);
// 渲染加载状态
if (loading) {
return (
<Box
bg={cardBg}
borderRadius="2xl"
borderWidth="1px"
borderColor={borderColor}
overflow="hidden"
>
<Box
h="4px"
bgGradient="linear(to-r, orange.400, red.500, pink.500)"
backgroundSize="200% 200%"
css={{ animation: `${gradientShift} 3s ease infinite` }}
/>
<Center h="500px" p={6}>
<VStack spacing={4}>
<Box position="relative">
<Spinner size="xl" color="orange.400" thickness="3px" speed="0.8s" />
<Box
position="absolute"
inset={0}
borderRadius="full"
css={{ animation: `${pulseGlow} 2s ease-in-out infinite` }}
boxShadow="0 0 30px rgba(251, 146, 60, 0.3)"
/>
</Box>
<VStack spacing={1}>
<Text color={textColor} fontWeight="medium">加载热点概览数据</Text>
<Text color={subTextColor} fontSize="sm">正在获取市场异动信息...</Text>
</VStack>
</VStack>
</Center>
</Box>
);
}
// 渲染错误状态
if (error) {
return (
<Box
bg={cardBg}
borderRadius="2xl"
borderWidth="1px"
borderColor={borderColor}
overflow="hidden"
>
<Box h="4px" bg="red.500" />
<Center h="400px" p={6}>
<VStack spacing={4}>
<Box p={4} borderRadius="full" bg="red.50">
<Icon as={AlertCircle} boxSize={10} color="red.400" />
</Box>
<VStack spacing={1}>
<Text color="red.400" fontWeight="medium">数据加载失败</Text>
<Text color={subTextColor} fontSize="sm" textAlign="center">{error}</Text>
</VStack>
</VStack>
</Center>
</Box>
);
}
if (!data) return null;
const { index, alerts, alert_summary } = data;
return (
<Box
bg={cardBg}
borderRadius="2xl"
borderWidth="1px"
borderColor={borderColor}
overflow="hidden"
transition="all 0.3s"
>
{/* 顶部装饰条 */}
<Box
h="4px"
bgGradient="linear(to-r, orange.400, red.500, pink.500)"
backgroundSize="200% 200%"
css={{ animation: `${gradientShift} 3s ease infinite` }}
/>
<Box p={5}>
{/* 头部 */}
<Flex align="center" mb={4}>
<HStack spacing={3}>
<Box
p={2}
borderRadius="xl"
bgGradient="linear(to-br, orange.400, red.500)"
boxShadow="0 4px 15px rgba(251, 146, 60, 0.4)"
>
<Icon as={Flame} boxSize={5} color="white" />
</Box>
<VStack align="flex-start" spacing={0}>
<Heading size="md" color={textColor} fontWeight="bold">热点概览</Heading>
<Text fontSize="xs" color={subTextColor}>实时概念异动监控</Text>
</VStack>
</HStack>
<Spacer />
<HStack spacing={2}>
{alerts.length > 0 && (
<HStack spacing={1} px={3} py={1.5} borderRadius="full" bg="orange.50">
<Icon as={Zap} boxSize={3.5} color="orange.500" />
<Text fontSize="sm" fontWeight="bold" color="orange.500">{alerts.length}</Text>
</HStack>
)}
<Tooltip label="展示大盘走势与概念异动的关联" hasArrow maxW="200px">
<Box cursor="help">
<Icon as={Info} color={subTextColor} boxSize={4} />
</Box>
</Tooltip>
</HStack>
</Flex>
{/* 统计摘要 - 简化版 */}
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4} mb={4}>
{/* 指数信息 */}
<Box bg={sectionBg} borderRadius="xl" p={4} borderWidth="1px" borderColor={borderColor}>
<HStack justify="space-between" align="flex-start">
<VStack align="flex-start" spacing={0}>
<Text fontSize="xs" color={subTextColor}>{index?.name || '上证指数'}</Text>
<Text
fontSize="2xl"
fontWeight="bold"
color={(index?.change_pct || 0) >= 0 ? '#ff4d4f' : '#52c41a'}
>
{index?.latest_price?.toFixed(2) || '-'}
</Text>
</VStack>
<VStack align="flex-end" spacing={0}>
<HStack
spacing={1}
px={2}
py={1}
borderRadius="full"
bg={(index?.change_pct || 0) >= 0 ? 'red.50' : 'green.50'}
>
<Icon
as={(index?.change_pct || 0) >= 0 ? TrendingUp : TrendingDown}
boxSize={3}
color={(index?.change_pct || 0) >= 0 ? '#ff4d4f' : '#52c41a'}
/>
<Text
fontSize="sm"
fontWeight="bold"
color={(index?.change_pct || 0) >= 0 ? '#ff4d4f' : '#52c41a'}
>
{(index?.change_pct || 0) >= 0 ? '+' : ''}{(index?.change_pct || 0).toFixed(2)}%
</Text>
</HStack>
<HStack spacing={3} mt={1} fontSize="xs" color={subTextColor}>
<Text> <Text as="span" color="#ff4d4f" fontWeight="bold">{index?.high?.toFixed(2)}</Text></Text>
<Text> <Text as="span" color="#52c41a" fontWeight="bold">{index?.low?.toFixed(2)}</Text></Text>
</HStack>
</VStack>
</HStack>
</Box>
{/* 异动统计 */}
<Box bg={sectionBg} borderRadius="xl" p={4} borderWidth="1px" borderColor={borderColor}>
<HStack justify="space-between" mb={2}>
<Text fontSize="sm" fontWeight="medium" color={textColor}>今日异动</Text>
<Text fontSize="xs" color="orange.500" fontWeight="bold">{alerts.length} </Text>
</HStack>
<Flex gap={2} flexWrap="wrap">
{Object.entries(alert_summary || {})
.filter(([_, count]) => count > 0)
.slice(0, 4)
.map(([type, count]) => {
const config = ALERT_TYPE_CONFIG[type];
if (!config) return null;
return (
<HStack
key={type}
spacing={1}
px={2}
py={1}
borderRadius="md"
bg={`${config.color}10`}
>
<Text fontSize="xs" color={config.color}>{config.label}</Text>
<Text fontSize="xs" fontWeight="bold" color={config.color}>{count}</Text>
</HStack>
);
})}
</Flex>
</Box>
</SimpleGrid>
{/* 大尺寸分时图 */}
<Box
bg={sectionBg}
borderRadius="xl"
borderWidth="1px"
borderColor={borderColor}
p={4}
mb={4}
>
<HStack spacing={2} mb={3}>
<Box p={1.5} borderRadius="lg" bg="purple.50">
<Icon as={LineChart} boxSize={4} color="purple.500" />
</Box>
<Text fontSize="sm" fontWeight="bold" color={textColor}>大盘分时走势</Text>
<Tooltip label="图表上的标记点表示概念异动时刻,点击可查看详情" hasArrow>
<Icon as={Info} boxSize={3} color={subTextColor} cursor="help" />
</Tooltip>
</HStack>
<IndexMinuteChart
indexData={index}
alerts={alerts}
onAlertClick={handleAlertClick}
height="420px"
/>
</Box>
{/* 异动列表 - 横向滚动 */}
{alerts.length > 0 && (
<Box>
<Flex justify="space-between" align="center" mb={3}>
<HStack spacing={2}>
<Box p={1.5} borderRadius="lg" bg="orange.50">
<Icon as={List} boxSize={4} color="orange.500" />
</Box>
<Text fontSize="sm" fontWeight="bold" color={textColor}>异动记录</Text>
<Text fontSize="xs" color={subTextColor}>横向滚动查看更多</Text>
</HStack>
<Tooltip label={showDetailList ? '收起详细列表' : '展开详细列表'} hasArrow>
<IconButton
icon={<Icon as={showDetailList ? ChevronUp : ChevronDown} boxSize={4} />}
size="sm"
variant="ghost"
borderRadius="lg"
onClick={() => setShowDetailList(!showDetailList)}
aria-label="切换详细列表"
/>
</Tooltip>
</Flex>
{/* 横向滚动卡片 */}
<Box
overflowX="auto"
pb={2}
sx={{
'&::-webkit-scrollbar': { height: '6px' },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
background: scrollbarColor,
borderRadius: '3px',
},
}}
>
<HStack spacing={3} pb={1}>
{[...alerts]
.sort((a, b) => (b.time || '').localeCompare(a.time || ''))
.map((alert, idx) => (
<CompactAlertCard
key={`${alert.concept_id}-${alert.time}-${idx}`}
alert={alert}
onClick={handleAlertClick}
isSelected={selectedAlert?.concept_id === alert.concept_id && selectedAlert?.time === alert.time}
/>
))}
</HStack>
</Box>
{/* 详细列表(可展开) */}
<Collapse in={showDetailList} animateOpacity>
<Box
mt={4}
bg={sectionBg}
borderRadius="xl"
borderWidth="1px"
borderColor={borderColor}
p={4}
>
<ConceptAlertList
alerts={alerts}
onAlertClick={handleAlertClick}
selectedAlert={selectedAlert}
maxHeight="400px"
/>
</Box>
</Collapse>
</Box>
)}
{/* 无异动提示 */}
{alerts.length === 0 && (
<Center py={8} bg={sectionBg} borderRadius="xl">
<VStack spacing={2}>
<Icon as={Zap} boxSize={8} color={subTextColor} opacity={0.5} />
<Text color={subTextColor} fontSize="sm">当日暂无概念异动数据</Text>
</VStack>
</Center>
)}
</Box>
</Box>
);
};
export default HotspotOverview;