Files
vf_react/src/views/StockOverview/components/HotspotOverview/index.js
2025-12-11 13:53:23 +08:00

817 lines
26 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.

/**
* 热点概览组件 - Modern Spatial & Glassmorphism 设计
* 展示大盘分时走势 + 概念异动标注
*
* 布局设计:
* - 顶部:统计摘要(指数信息 + 异动统计)
* - 中部:大尺寸分时图(主要展示区域)
* - 底部:异动列表(横向滚动卡片)
*/
import React, { useState, useCallback } from 'react';
import {
Box,
Heading,
Text,
HStack,
VStack,
Spinner,
Center,
Icon,
Flex,
Spacer,
Tooltip,
IconButton,
Collapse,
SimpleGrid,
useDisclosure,
} from '@chakra-ui/react';
import { keyframes, css } from '@emotion/react';
import {
Flame,
List,
LineChart,
ChevronDown,
ChevronUp,
Info,
Zap,
AlertCircle,
TrendingUp,
TrendingDown,
Sparkles,
} from 'lucide-react';
import { useHotspotData } from './hooks';
import { IndexMinuteChart, ConceptAlertList, AlertSummary, AlertDetailDrawer } from './components';
import { ALERT_TYPE_CONFIG, getAlertTypeLabel } from './utils/chartHelpers';
import {
glassEffect,
colors,
glowEffects,
getMarketColor,
getMarketGlow,
} from '../../theme/glassTheme';
// 动画效果
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.6; transform: scale(1); }
50% { opacity: 1; transform: scale(1.02); }
`;
const floatAnimation = keyframes`
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-3px); }
`;
const shimmer = keyframes`
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
`;
/**
* 紧凑型异动卡片(用于横向滚动)- Glassmorphism 风格
*/
const CompactAlertCard = ({ alert, onClick, isSelected }) => {
const config = ALERT_TYPE_CONFIG[alert.alert_type] || ALERT_TYPE_CONFIG.surge;
const isUp = alert.alert_type !== 'surge_down';
return (
<Box
bg={glassEffect.light.bg}
backdropFilter={glassEffect.light.backdropFilter}
borderRadius="16px"
border={isSelected ? `1px solid ${config.color}60` : glassEffect.light.border}
p={3}
minW="180px"
maxW="200px"
cursor="pointer"
onClick={() => onClick?.(alert)}
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
position="relative"
overflow="hidden"
_hover={{
bg: 'rgba(255, 255, 255, 0.05)',
border: `1px solid ${config.color}50`,
transform: 'translateY(-4px)',
boxShadow: `0 8px 25px ${config.color}20, inset 0 1px 0 rgba(255,255,255,0.1)`,
}}
css={isSelected ? css`animation: ${floatAnimation} 3s ease-in-out infinite;` : undefined}
>
{/* 顶部渐变发光条 */}
<Box
position="absolute"
top={0}
left={0}
right={0}
h="2px"
bgGradient={`linear(to-r, ${config.gradient[0]}, ${config.gradient[1]})`}
opacity={isSelected ? 1 : 0.7}
boxShadow={isSelected ? `0 0 15px ${config.color}60` : 'none'}
/>
{/* 背景光晕 */}
{isSelected && (
<Box
position="absolute"
top="-30px"
right="-30px"
w="80px"
h="80px"
borderRadius="full"
bg={`${config.color}15`}
filter="blur(25px)"
pointerEvents="none"
/>
)}
{/* 时间 + 类型 */}
<HStack justify="space-between" mb={1.5}>
<Text fontSize="xs" color={colors.text.muted} fontFamily="mono">
{alert.time}
</Text>
<HStack
spacing={1}
px={2}
py={0.5}
borderRadius="full"
bg={`${config.color}20`}
border={`1px solid ${config.color}30`}
>
<Icon
as={isUp ? TrendingUp : TrendingDown}
boxSize={3}
color={config.color}
css={css`filter: drop-shadow(0 0 4px ${config.color}80);`}
/>
<Text fontSize="10px" fontWeight="bold" color={config.color}>
{getAlertTypeLabel(alert.alert_type)}
</Text>
</HStack>
</HStack>
{/* 概念名称 */}
<Text
fontWeight="bold"
fontSize="sm"
color={colors.text.primary}
noOfLines={1}
mb={1.5}
css={isSelected ? css`text-shadow: 0 0 20px ${config.color}40;` : undefined}
>
{alert.concept_name}
</Text>
{/* 分数 + Alpha */}
<HStack justify="space-between" fontSize="xs">
<HStack spacing={1}>
<Text color={colors.text.tertiary}>评分</Text>
<Text
fontWeight="bold"
color={config.color}
css={css`text-shadow: 0 0 10px ${config.color}50;`}
>
{Math.round(alert.final_score || 0)}
</Text>
</HStack>
{alert.alpha != null && (
<Text
fontWeight="bold"
color={getMarketColor(alert.alpha)}
css={css`text-shadow: 0 0 10px ${getMarketColor(alert.alpha)}50;`}
>
α {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 [autoExpandAlertKey, setAutoExpandAlertKey] = useState(null);
const [drawerAlertData, setDrawerAlertData] = useState(null);
// 右边栏抽屉控制
const { isOpen: isDrawerOpen, onOpen: onDrawerOpen, onClose: onDrawerClose } = useDisclosure();
// 获取数据
const { loading, error, data } = useHotspotData(selectedDate);
// Glassmorphism 颜色主题
const cardBg = glassEffect.card.bg;
const borderColor = colors.border.primary;
const textColor = colors.text.primary;
const subTextColor = colors.text.secondary;
const sectionBg = glassEffect.light.bg;
const scrollbarColor = 'rgba(139, 92, 246, 0.3)';
// 点击分时图上的异动标注 - 打开右边栏抽屉显示详情
const handleChartAlertClick = useCallback((alertGroupData) => {
// alertGroupData 包含 { alerts, timeRange, alertCount, time }
setDrawerAlertData(alertGroupData);
onDrawerOpen();
}, [onDrawerOpen]);
// 点击底部异动卡片 - 展开详细列表并选中
const handleAlertClick = useCallback((alert) => {
setSelectedAlert(alert);
// 自动展开详细列表并设置需要展开的项
setShowDetailList(true);
const alertKey = `${alert.concept_id}-${alert.time}`;
setAutoExpandAlertKey(alertKey);
}, []);
// 渲染加载状态 - Glassmorphism 风格
if (loading) {
return (
<Box
bg={cardBg}
backdropFilter={glassEffect.card.backdropFilter}
borderRadius="24px"
border={glassEffect.card.border}
boxShadow={glassEffect.card.boxShadow}
overflow="hidden"
position="relative"
>
{/* 极光背景 */}
<Box
position="absolute"
inset={0}
bgGradient="radial(ellipse at 30% 20%, rgba(139, 92, 246, 0.15) 0%, transparent 50%)"
pointerEvents="none"
/>
{/* 顶部发光条 */}
<Box
h="3px"
bgGradient="linear(to-r, #8b5cf6, #ec4899, #f97316)"
backgroundSize="200% 200%"
css={css`animation: ${gradientShift} 3s ease infinite;`}
boxShadow="0 0 20px rgba(139, 92, 246, 0.5)"
/>
<Center h="500px" p={6}>
<VStack spacing={6}>
<Box position="relative">
<Spinner size="xl" color="#8b5cf6" thickness="3px" speed="0.8s" />
<Box
position="absolute"
inset={-4}
borderRadius="full"
css={css`animation: ${pulseGlow} 2s ease-in-out infinite;`}
boxShadow="0 0 40px rgba(139, 92, 246, 0.4)"
/>
</Box>
<VStack spacing={2}>
<Text
color={textColor}
fontWeight="bold"
fontSize="lg"
css={css`text-shadow: 0 0 20px rgba(139, 92, 246, 0.5);`}
>
加载热点概览数据
</Text>
<Text color={subTextColor} fontSize="sm">正在获取市场异动信息...</Text>
</VStack>
</VStack>
</Center>
</Box>
);
}
// 渲染错误状态 - Glassmorphism 风格
if (error) {
return (
<Box
bg={cardBg}
backdropFilter={glassEffect.card.backdropFilter}
borderRadius="24px"
border="1px solid rgba(239, 68, 68, 0.3)"
boxShadow="0 8px 32px rgba(239, 68, 68, 0.1)"
overflow="hidden"
position="relative"
>
<Box h="3px" bg="#ef4444" boxShadow="0 0 15px rgba(239, 68, 68, 0.5)" />
<Center h="400px" p={6}>
<VStack spacing={4}>
<Box
p={4}
borderRadius="full"
bg="rgba(239, 68, 68, 0.1)"
border="1px solid rgba(239, 68, 68, 0.2)"
>
<Icon
as={AlertCircle}
boxSize={10}
color="#ef4444"
css={css`filter: drop-shadow(0 0 10px rgba(239, 68, 68, 0.5));`}
/>
</Box>
<VStack spacing={1}>
<Text
color="#ef4444"
fontWeight="bold"
css={css`text-shadow: 0 0 15px rgba(239, 68, 68, 0.5);`}
>
数据加载失败
</Text>
<Text color={subTextColor} fontSize="sm" textAlign="center">{error}</Text>
</VStack>
</VStack>
</Center>
</Box>
);
}
if (!data) return null;
const { index, alerts, alert_summary } = data;
// 计算市场颜色
const marketColor = getMarketColor(index?.change_pct || 0);
const marketGlow = getMarketGlow(index?.change_pct || 0);
return (
<Box
bg={cardBg}
backdropFilter={glassEffect.card.backdropFilter}
borderRadius="24px"
border={glassEffect.card.border}
boxShadow={glassEffect.card.boxShadow}
overflow="hidden"
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
position="relative"
>
{/* 极光背景装饰 */}
<Box
position="absolute"
inset={0}
bgGradient="radial(ellipse at 20% 10%, rgba(139, 92, 246, 0.12) 0%, transparent 50%)"
pointerEvents="none"
/>
<Box
position="absolute"
inset={0}
bgGradient="radial(ellipse at 80% 90%, rgba(236, 72, 153, 0.08) 0%, transparent 50%)"
pointerEvents="none"
/>
{/* 顶部发光装饰条 */}
<Box
h="3px"
bgGradient="linear(to-r, #8b5cf6, #ec4899, #f97316)"
backgroundSize="200% 200%"
css={css`animation: ${gradientShift} 3s ease infinite;`}
boxShadow="0 0 20px rgba(139, 92, 246, 0.5)"
/>
<Box p={6} position="relative">
{/* 头部 - Glassmorphism */}
<Flex align="center" mb={5}>
<HStack spacing={4}>
<Box
p={3}
borderRadius="16px"
bgGradient="linear(to-br, #8b5cf6, #ec4899)"
boxShadow="0 8px 25px rgba(139, 92, 246, 0.4)"
position="relative"
overflow="hidden"
>
{/* 图标发光效果 */}
<Box
position="absolute"
inset={0}
bgGradient="linear(to-br, rgba(255,255,255,0.2), transparent)"
/>
<Icon
as={Flame}
boxSize={6}
color="white"
css={css`filter: drop-shadow(0 0 8px rgba(255,255,255,0.5));`}
/>
</Box>
<VStack align="flex-start" spacing={0}>
<Heading
size="md"
color={textColor}
fontWeight="bold"
css={css`text-shadow: 0 0 30px rgba(139, 92, 246, 0.3);`}
>
热点概览
</Heading>
<HStack spacing={1}>
<Icon as={Sparkles} boxSize={3} color={colors.accent.purple} />
<Text fontSize="xs" color={subTextColor}>实时概念异动监控</Text>
</HStack>
</VStack>
</HStack>
<Spacer />
<HStack spacing={3}>
{alerts.length > 0 && (
<HStack
spacing={2}
px={4}
py={2}
borderRadius="full"
bg="rgba(139, 92, 246, 0.15)"
border="1px solid rgba(139, 92, 246, 0.3)"
boxShadow="0 0 15px rgba(139, 92, 246, 0.2)"
>
<Icon
as={Zap}
boxSize={4}
color={colors.accent.purple}
css={css`filter: drop-shadow(0 0 6px #8b5cf6);`}
/>
<Text
fontSize="sm"
fontWeight="bold"
color={colors.accent.purple}
css={css`text-shadow: 0 0 10px rgba(139, 92, 246, 0.5);`}
>
{alerts.length}
</Text>
</HStack>
)}
<Tooltip label="展示大盘走势与概念异动的关联" hasArrow maxW="200px">
<Box cursor="help" p={2} borderRadius="full" _hover={{ bg: 'rgba(255,255,255,0.05)' }}>
<Icon as={Info} color={subTextColor} boxSize={4} />
</Box>
</Tooltip>
</HStack>
</Flex>
{/* 统计摘要 - Glassmorphism Bento Grid */}
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4} mb={5}>
{/* 指数信息卡片 */}
<Box
bg={sectionBg}
backdropFilter={glassEffect.light.backdropFilter}
borderRadius="20px"
border={glassEffect.light.border}
p={5}
position="relative"
overflow="hidden"
transition="all 0.3s"
_hover={{
border: `1px solid ${marketColor}30`,
boxShadow: `0 8px 30px ${marketColor}15`,
}}
>
{/* 背景光晕 */}
<Box
position="absolute"
top="-20px"
right="-20px"
w="100px"
h="100px"
borderRadius="full"
bg={`${marketColor}10`}
filter="blur(30px)"
pointerEvents="none"
/>
<HStack justify="space-between" align="flex-start">
<VStack align="flex-start" spacing={1}>
<Text fontSize="xs" color={colors.text.muted} letterSpacing="1px" textTransform="uppercase">
{index?.name || '上证指数'}
</Text>
<Text
fontSize="3xl"
fontWeight="bold"
color={marketColor}
css={css`text-shadow: 0 0 30px ${marketColor}60;`}
>
{index?.latest_price?.toFixed(2) || '-'}
</Text>
</VStack>
<VStack align="flex-end" spacing={2}>
<HStack
spacing={2}
px={3}
py={1.5}
borderRadius="full"
bg={`${marketColor}15`}
border={`1px solid ${marketColor}25`}
>
<Icon
as={(index?.change_pct || 0) >= 0 ? TrendingUp : TrendingDown}
boxSize={4}
color={marketColor}
css={css`filter: drop-shadow(0 0 4px ${marketColor});`}
/>
<Text
fontSize="sm"
fontWeight="bold"
color={marketColor}
css={css`text-shadow: 0 0 10px ${marketColor}50;`}
>
{(index?.change_pct || 0) >= 0 ? '+' : ''}{(index?.change_pct || 0).toFixed(2)}%
</Text>
</HStack>
<HStack spacing={4} fontSize="xs" color={colors.text.tertiary}>
<HStack spacing={1}>
<Text></Text>
<Text color={colors.market.up} fontWeight="bold">{index?.high?.toFixed(2)}</Text>
</HStack>
<HStack spacing={1}>
<Text></Text>
<Text color={colors.market.down} fontWeight="bold">{index?.low?.toFixed(2)}</Text>
</HStack>
</HStack>
</VStack>
</HStack>
</Box>
{/* 异动统计卡片 */}
<Box
bg={sectionBg}
backdropFilter={glassEffect.light.backdropFilter}
borderRadius="20px"
border={glassEffect.light.border}
p={5}
position="relative"
overflow="hidden"
transition="all 0.3s"
_hover={{
border: '1px solid rgba(139, 92, 246, 0.3)',
boxShadow: '0 8px 30px rgba(139, 92, 246, 0.1)',
}}
>
<HStack justify="space-between" mb={3}>
<Text fontSize="sm" fontWeight="bold" color={textColor}>今日异动</Text>
<Text
fontSize="sm"
color={colors.accent.purple}
fontWeight="bold"
css={css`text-shadow: 0 0 10px rgba(139, 92, 246, 0.5);`}
>
{alerts.length}
</Text>
</HStack>
<Flex gap={2} flexWrap="wrap">
{Object.entries(alert_summary || {})
.filter(([_, count]) => count > 0)
.slice(0, 5)
.map(([type, count]) => {
const config = ALERT_TYPE_CONFIG[type];
if (!config) return null;
return (
<HStack
key={type}
spacing={1.5}
px={3}
py={1.5}
borderRadius="full"
bg={`${config.color}15`}
border={`1px solid ${config.color}25`}
transition="all 0.2s"
_hover={{
bg: `${config.color}25`,
boxShadow: `0 0 15px ${config.color}30`,
}}
>
<Text fontSize="xs" color={config.color}>{config.label}</Text>
<Text
fontSize="xs"
fontWeight="bold"
color={config.color}
css={css`text-shadow: 0 0 8px ${config.color}50;`}
>
{count}
</Text>
</HStack>
);
})}
</Flex>
</Box>
</SimpleGrid>
{/* 大尺寸分时图 - Glassmorphism */}
<Box
bg={sectionBg}
backdropFilter={glassEffect.light.backdropFilter}
borderRadius="20px"
border={glassEffect.light.border}
p={5}
mb={5}
position="relative"
overflow="hidden"
>
{/* 图表区域背景光晕 */}
<Box
position="absolute"
bottom="-50px"
left="50%"
transform="translateX(-50%)"
w="60%"
h="100px"
borderRadius="full"
bg="rgba(139, 92, 246, 0.08)"
filter="blur(40px)"
pointerEvents="none"
/>
<HStack spacing={3} mb={4}>
<Box
p={2}
borderRadius="12px"
bg="rgba(139, 92, 246, 0.15)"
border="1px solid rgba(139, 92, 246, 0.25)"
>
<Icon
as={LineChart}
boxSize={5}
color={colors.accent.purple}
css={css`filter: drop-shadow(0 0 6px #8b5cf6);`}
/>
</Box>
<Text
fontSize="sm"
fontWeight="bold"
color={textColor}
css={css`text-shadow: 0 0 20px rgba(139, 92, 246, 0.3);`}
>
大盘分时走势
</Text>
<Tooltip label="图表上的标记点表示概念异动时刻,点击可查看详情" hasArrow>
<Icon as={Info} boxSize={3.5} color={colors.text.muted} cursor="help" />
</Tooltip>
</HStack>
<IndexMinuteChart
indexData={index}
alerts={alerts}
onAlertClick={handleChartAlertClick}
height="420px"
/>
</Box>
{/* 异动列表 - Glassmorphism 横向滚动 */}
{alerts.length > 0 && (
<Box>
<Flex justify="space-between" align="center" mb={4}>
<HStack spacing={3}>
<Box
p={2}
borderRadius="12px"
bg="rgba(249, 115, 22, 0.15)"
border="1px solid rgba(249, 115, 22, 0.25)"
>
<Icon
as={List}
boxSize={5}
color={colors.accent.orange}
css={css`filter: drop-shadow(0 0 6px #f97316);`}
/>
</Box>
<Text fontSize="sm" fontWeight="bold" color={textColor}>异动记录</Text>
<Text fontSize="xs" color={colors.text.muted}>点击卡片查看个股详情</Text>
</HStack>
<Tooltip label={showDetailList ? '收起详细列表' : '展开详细列表'} hasArrow>
<IconButton
icon={<Icon as={showDetailList ? ChevronUp : ChevronDown} boxSize={4} />}
size="sm"
variant="ghost"
borderRadius="12px"
color={colors.text.secondary}
_hover={{
bg: 'rgba(255,255,255,0.05)',
color: textColor,
}}
onClick={() => setShowDetailList(!showDetailList)}
aria-label="切换详细列表"
/>
</Tooltip>
</Flex>
{/* 横向滚动卡片 */}
<Box
overflowX="auto"
pb={3}
sx={{
'&::-webkit-scrollbar': { height: '6px' },
'&::-webkit-scrollbar-track': { background: 'rgba(255,255,255,0.02)', borderRadius: '3px' },
'&::-webkit-scrollbar-thumb': {
background: scrollbarColor,
borderRadius: '3px',
'&:hover': { background: 'rgba(139, 92, 246, 0.5)' },
},
}}
>
<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>
{/* 详细列表(可展开) - Glassmorphism */}
<Collapse in={showDetailList} animateOpacity>
<Box
mt={4}
bg={sectionBg}
backdropFilter={glassEffect.light.backdropFilter}
borderRadius="20px"
border={glassEffect.light.border}
p={5}
position="relative"
overflow="hidden"
>
{/* 背景光晕 */}
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
w="80%"
h="200px"
borderRadius="full"
bg="rgba(139, 92, 246, 0.05)"
filter="blur(60px)"
pointerEvents="none"
/>
<ConceptAlertList
alerts={alerts}
onAlertClick={handleAlertClick}
selectedAlert={selectedAlert}
maxHeight="400px"
autoExpandAlertKey={autoExpandAlertKey}
onAutoExpandComplete={() => setAutoExpandAlertKey(null)}
/>
</Box>
</Collapse>
</Box>
)}
{/* 无异动提示 - Glassmorphism */}
{alerts.length === 0 && (
<Center
py={12}
bg={sectionBg}
backdropFilter={glassEffect.light.backdropFilter}
borderRadius="20px"
border={glassEffect.light.border}
position="relative"
overflow="hidden"
>
{/* 背景光晕 */}
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
w="150px"
h="150px"
borderRadius="full"
bg="rgba(139, 92, 246, 0.1)"
filter="blur(40px)"
pointerEvents="none"
/>
<VStack spacing={3}>
<Box
p={4}
borderRadius="full"
bg="rgba(139, 92, 246, 0.1)"
border="1px solid rgba(139, 92, 246, 0.2)"
>
<Icon
as={Zap}
boxSize={8}
color={colors.accent.purple}
opacity={0.6}
css={css`filter: drop-shadow(0 0 10px rgba(139, 92, 246, 0.3));`}
/>
</Box>
<Text color={colors.text.tertiary} fontSize="sm">当日暂无概念异动数据</Text>
</VStack>
</Center>
)}
</Box>
{/* 异动详情右边栏抽屉 */}
<AlertDetailDrawer
isOpen={isDrawerOpen}
onClose={onDrawerClose}
alertData={drawerAlertData}
/>
</Box>
);
};
export default HotspotOverview;