Files
vf_react/src/views/StockOverview/components/HotspotOverview/index.js
zdl 494d9c8918 feat(HotspotOverview): 重构筛选区布局,与分时图标题同行显示
- 新增 AlertFilterSection 组件,支持内联显示
- 筛选标签(类型+数量)、异动总数徽章、日期选择器整合到标题行
- 移除与灵活屏重复的三指数卡片组件
- 简化热点概览整体布局结构

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-31 14:43:35 +08:00

652 lines
20 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,
Spacer,
Icon,
Flex,
Tooltip,
useDisclosure,
} from '@chakra-ui/react';
import { keyframes, css } from '@emotion/react';
import {
Flame,
List,
LineChart,
Info,
Zap,
AlertCircle,
TrendingUp,
TrendingDown,
Sparkles,
} from 'lucide-react';
import { useHotspotData } from './hooks';
import { IndexMinuteChart, AlertDetailDrawer, AlertFilterSection } from './components';
import { ALERT_TYPE_CONFIG, getAlertTypeLabel } from './utils/chartHelpers';
import {
glassEffect,
colors,
getMarketColor,
} 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 - 选中的交易日期
* @param {Function} props.onDateChange - 日期变更回调
* @param {Date} props.minDate - 最小可选日期
* @param {Date} props.maxDate - 最大可选日期
*/
const HotspotOverview = ({ selectedDate, onDateChange, minDate, maxDate, onDataLoaded }) => {
const [selectedAlert, setSelectedAlert] = useState(null);
const [drawerAlertData, setDrawerAlertData] = useState(null);
// 选中的异动类型过滤器null 表示全部)
const [selectedAlertType, setSelectedAlertType] = useState(null);
// 右边栏抽屉控制
const { isOpen: isDrawerOpen, onOpen: onDrawerOpen, onClose: onDrawerClose } = useDisclosure();
// 获取数据
const { loading, refreshing, error, data } = useHotspotData(selectedDate);
// 当数据加载完成时,通知父组件
React.useEffect(() => {
if (data && onDataLoaded) {
onDataLoaded(data);
}
}, [data, onDataLoaded]);
// Glassmorphism 颜色主题
const cardBg = glassEffect.card.bg;
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 handleCardAlertClick = useCallback((alert) => {
setSelectedAlert(alert);
// 构造单个异动的数据格式
setDrawerAlertData({
alerts: [alert],
timeRange: alert.time,
alertCount: 1,
time: alert.time,
});
onDrawerOpen();
}, [onDrawerOpen]);
// 点击异动类型标签 - 切换过滤器
const handleAlertTypeClick = useCallback((type) => {
setSelectedAlertType(prevType => prevType === type ? null : type);
}, []);
// 渲染加载状态 - 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 filteredAlerts = selectedAlertType
? alerts.filter(alert => alert.alert_type === selectedAlertType)
: alerts;
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>
</Flex>
{/* 大尺寸分时图 - 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"
/>
{/* 标题行:大盘分时走势 + 筛选区 */}
<Flex align="center" mb={4} flexWrap="wrap" gap={3}>
<HStack spacing={3} flexShrink={0}>
<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>
<Spacer />
{/* 筛选区内联 */}
<AlertFilterSection
alertSummary={alert_summary}
selectedAlertType={selectedAlertType}
onAlertTypeClick={handleAlertTypeClick}
onClearFilter={() => setSelectedAlertType(null)}
totalCount={alerts.length}
filteredCount={filteredAlerts.length}
selectedDate={selectedDate}
onDateChange={onDateChange}
minDate={minDate}
maxDate={maxDate}
/>
</Flex>
<Box position="relative">
<IndexMinuteChart
indexData={index}
alerts={alerts}
onAlertClick={handleChartAlertClick}
height="420px"
/>
{/* 刷新时的轻量级加载指示器 */}
{refreshing && (
<Center
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
bg="rgba(0, 0, 0, 0.3)"
borderRadius="12px"
zIndex={10}
>
<Spinner size="lg" color="#8b5cf6" thickness="3px" />
</Center>
)}
</Box>
</Box>
{/* 异动列表 - Glassmorphism 横向滚动 */}
{alerts.length > 0 && (
<Box>
<HStack spacing={3} mb={4}>
<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}>
{selectedAlertType
? `(已筛选 ${ALERT_TYPE_CONFIG[selectedAlertType]?.label || selectedAlertType},共 ${filteredAlerts.length} 条)`
: '(点击卡片查看详情)'}
</Text>
</HStack>
{/* 横向滚动卡片 */}
<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}>
{[...filteredAlerts]
.sort((a, b) => (b.time || '').localeCompare(a.time || ''))
.map((alert, idx) => (
<CompactAlertCard
key={`${alert.concept_id}-${alert.time}-${idx}`}
alert={alert}
onClick={handleCardAlertClick}
isSelected={selectedAlert?.concept_id === alert.concept_id && selectedAlert?.time === alert.time}
/>
))}
</HStack>
</Box>
</Box>
)}
{/* 无异动提示 - Glassmorphism */}
{filteredAlerts.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">
{selectedAlertType
? `未找到「${ALERT_TYPE_CONFIG[selectedAlertType]?.label || selectedAlertType}」类型的异动`
: '当日暂无概念异动数据'}
</Text>
{selectedAlertType && (
<Text
fontSize="xs"
color={colors.accent.purple}
cursor="pointer"
onClick={() => setSelectedAlertType(null)}
_hover={{ textDecoration: 'underline' }}
>
查看全部异动
</Text>
)}
</VStack>
</Center>
)}
</Box>
{/* 异动详情右边栏抽屉 */}
<AlertDetailDrawer
isOpen={isDrawerOpen}
onClose={onDrawerClose}
alertData={drawerAlertData}
/>
</Box>
);
};
export default HotspotOverview;