Files
vf_react/src/views/Community/components/HeroPanel.js
2025-11-30 22:51:24 +08:00

597 lines
18 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.

// src/views/Community/components/HeroPanel.js
// 顶部说明面板组件:事件中心 + 沪深指数K线图 + 热门概念3D动画
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import {
Box,
Card,
CardBody,
Flex,
VStack,
HStack,
Text,
Heading,
useColorModeValue,
Icon,
Spinner,
Center,
} from '@chakra-ui/react';
import { AlertCircle, Clock, TrendingUp, Info } from 'lucide-react';
import ReactECharts from 'echarts-for-react';
import { logger } from '../../../utils/logger';
// 定义动画
const animations = `
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(1.1); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes floatSlow {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
`;
// 注入样式
if (typeof document !== 'undefined') {
const styleId = 'hero-panel-animations';
if (!document.getElementById(styleId)) {
const styleSheet = document.createElement('style');
styleSheet.id = styleId;
styleSheet.innerText = animations;
document.head.appendChild(styleSheet);
}
}
/**
* 获取指数行情数据(日线数据)
*/
const fetchIndexKline = async (indexCode) => {
try {
const response = await fetch(`/api/index/${indexCode}/kline?type=daily`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
return data;
} catch (error) {
logger.error('HeroPanel', 'fetchIndexKline error', { indexCode, error: error.message });
return null;
}
};
/**
* 获取热门概念数据
*/
const fetchPopularConcepts = async () => {
try {
const response = await fetch('/concept-api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: '', size: 60, page: 1, sort_by: 'change_pct' })
});
const data = await response.json();
if (data.results?.length > 0) {
return data.results.map(item => ({
name: item.concept,
change_pct: item.price_info?.avg_change_pct || 0,
}));
}
return [];
} catch (error) {
logger.error('HeroPanel', 'fetchPopularConcepts error', error);
return [];
}
};
/**
* 判断当前是否在交易时间内
*/
const isInTradingTime = () => {
const now = new Date();
const timeInMinutes = now.getHours() * 60 + now.getMinutes();
return timeInMinutes >= 570 && timeInMinutes <= 900;
};
/**
* 紧凑型K线指数卡片
*/
const CompactIndexCard = ({ indexCode, indexName }) => {
const [chartData, setChartData] = useState(null);
const [loading, setLoading] = useState(true);
const [latestData, setLatestData] = useState(null);
const upColor = '#ec0000';
const downColor = '#00da3c';
const loadData = useCallback(async () => {
const data = await fetchIndexKline(indexCode);
if (data?.data?.length > 0) {
const latest = data.data[data.data.length - 1];
const prevClose = latest.prev_close || latest.close;
setLatestData({
close: latest.close,
change: prevClose ? (((latest.close - prevClose) / prevClose) * 100).toFixed(2) : '0.00',
isPositive: latest.close >= prevClose
});
const recentData = data.data.slice(-40);
setChartData({
dates: recentData.map(item => item.time),
klineData: recentData.map(item => [item.open, item.close, item.low, item.high]),
rawData: recentData
});
}
setLoading(false);
}, [indexCode]);
useEffect(() => {
loadData();
}, [loadData]);
const chartOption = useMemo(() => {
if (!chartData) return {};
return {
backgroundColor: 'transparent',
grid: { left: 5, right: 5, top: 5, bottom: 5, containLabel: false },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross', lineStyle: { color: 'rgba(255, 215, 0, 0.5)', width: 1, type: 'dashed' } },
backgroundColor: 'rgba(20, 20, 20, 0.95)',
borderColor: '#FFD700',
borderWidth: 1,
textStyle: { color: '#fff', fontSize: 10, fontFamily: 'monospace' },
padding: [6, 10],
formatter: (params) => {
const idx = params[0].dataIndex;
const raw = chartData.rawData[idx];
if (!raw) return '';
const prevClose = raw.prev_close || raw.open;
const changePct = prevClose ? (((raw.close - prevClose) / prevClose) * 100).toFixed(2) : '0.00';
const isUp = raw.close >= prevClose;
const color = isUp ? '#ec0000' : '#00da3c';
return `<div style="font-size:11px"><b>${raw.time}</b><br/>收盘: ${raw.close.toFixed(2)}<br/><span style="color:${color}">${isUp ? '+' : ''}${changePct}%</span></div>`;
}
},
xAxis: { type: 'category', data: chartData.dates, show: false },
yAxis: { type: 'value', show: false, scale: true },
series: [{
type: 'candlestick',
data: chartData.klineData,
itemStyle: { color: upColor, color0: downColor, borderColor: upColor, borderColor0: downColor },
barWidth: '70%'
}]
};
}, [chartData, upColor, downColor]);
if (loading) return <Center h="80px"><Spinner size="sm" color="gold" /></Center>;
return (
<Flex align="center" gap={4} h="80px">
{/* 左侧数据 */}
<VStack align="start" spacing={0} minW="100px">
<Text fontSize="xs" color="whiteAlpha.600" fontWeight="medium">{indexName}</Text>
<Text fontSize="xl" fontWeight="bold" color="white">
{latestData?.close.toFixed(2)}
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={latestData?.isPositive ? '#ec0000' : '#00da3c'}
>
{latestData?.isPositive ? '▲' : '▼'} {latestData?.isPositive ? '+' : ''}{latestData?.change}%
</Text>
</VStack>
{/* 右侧K线图 */}
<Box flex="1" h="70px">
<ReactECharts option={chartOption} style={{ height: '100%', width: '100%' }} opts={{ renderer: 'canvas' }} />
</Box>
</Flex>
);
};
/**
* 概念漂浮动画 - 稀疏美观版
*/
const ConceptFloat = () => {
const [concepts, setConcepts] = useState([]);
const [loading, setLoading] = useState(true);
const [hoveredIdx, setHoveredIdx] = useState(null);
const containerRef = useRef(null);
useEffect(() => {
const load = async () => {
const data = await fetchPopularConcepts();
// 只取前12个更稀疏
setConcepts(data.slice(0, 12));
setLoading(false);
};
load();
}, []);
const getColor = (pct) => {
if (pct > 5) return '#ff1744';
if (pct > 2) return '#ff5252';
if (pct > 0) return '#ff8a80';
if (pct === 0) return '#FFD700';
if (pct > -2) return '#69f0ae';
if (pct > -5) return '#00e676';
return '#00c853';
};
const handleClick = (name) => {
window.open(`https://valuefrontier.cn/htmls/${name}.html`, '_blank');
};
if (loading) return <Center h="100%"><Spinner size="sm" color="gold" /></Center>;
// 预设12个位置均匀分布且不重叠
const positions = [
{ x: 15, y: 20 }, { x: 50, y: 15 }, { x: 85, y: 25 },
{ x: 25, y: 50 }, { x: 60, y: 45 }, { x: 80, y: 55 },
{ x: 10, y: 75 }, { x: 40, y: 80 }, { x: 70, y: 75 },
{ x: 20, y: 35 }, { x: 55, y: 65 }, { x: 90, y: 40 },
];
return (
<Box
ref={containerRef}
position="relative"
h="100%"
w="100%"
overflow="hidden"
>
{/* 概念标签 */}
{concepts.map((concept, idx) => {
const pos = positions[idx] || { x: 50, y: 50 };
const color = getColor(concept.change_pct);
const isActive = hoveredIdx === idx;
const size = 11 + (idx % 3) * 2; // 11-15px
return (
<Box
key={idx}
position="absolute"
left={`${pos.x}%`}
top={`${pos.y}%`}
transform={`translate(-50%, -50%) scale(${isActive ? 1.3 : 1})`}
opacity={isActive ? 1 : 0.85}
zIndex={isActive ? 100 : 10}
cursor="pointer"
transition="all 0.3s ease"
onMouseEnter={() => setHoveredIdx(idx)}
onMouseLeave={() => setHoveredIdx(null)}
onClick={() => handleClick(concept.name)}
animation={`floatSlow ${5 + (idx % 3)}s ease-in-out infinite ${idx * 0.3}s`}
>
<Text
fontSize={`${size}px`}
fontWeight={isActive ? 'bold' : 'medium'}
color={color}
textShadow={isActive
? `0 0 15px ${color}, 0 0 30px ${color}50`
: `0 0 8px ${color}40`
}
whiteSpace="nowrap"
>
{concept.name}
</Text>
{/* 悬停提示 */}
{isActive && (
<Box
position="absolute"
top="-28px"
left="50%"
transform="translateX(-50%)"
bg="rgba(0,0,0,0.9)"
border="1px solid"
borderColor={color}
borderRadius="md"
px={2}
py={1}
whiteSpace="nowrap"
>
<Text fontSize="xs" color={color} fontWeight="bold">
{concept.change_pct > 0 ? '+' : ''}{concept.change_pct.toFixed(2)}%
</Text>
</Box>
)}
</Box>
);
})}
</Box>
);
};
/**
* 极简提示标签 - 悬停显示详情
*/
const InfoTooltip = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<Box position="relative" display="inline-block">
{/* 触发器:小标签 */}
<HStack
spacing={1.5}
px={2.5}
py={1}
bg="rgba(255,215,0,0.08)"
border="1px solid rgba(255,215,0,0.2)"
borderRadius="full"
cursor="pointer"
transition="all 0.2s"
_hover={{
bg: 'rgba(255,215,0,0.15)',
borderColor: 'rgba(255,215,0,0.4)',
}}
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
onClick={() => setIsOpen(!isOpen)}
>
<Icon as={Info} color="gold" boxSize={3} />
<Text fontSize="xs" color="gold" fontWeight="medium">
使用说明
</Text>
</HStack>
{/* 悬浮提示框 */}
<Box
position="absolute"
top="calc(100% + 8px)"
left="0"
zIndex={1000}
minW="320px"
p={4}
bg="rgba(10,10,20,0.98)"
borderRadius="lg"
border="1px solid rgba(255,215,0,0.3)"
boxShadow="0 10px 40px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,215,0,0.1)"
opacity={isOpen ? 1 : 0}
visibility={isOpen ? 'visible' : 'hidden'}
transform={isOpen ? 'translateY(0)' : 'translateY(-10px)'}
transition="all 0.2s ease"
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
>
{/* 小箭头 */}
<Box
position="absolute"
top="-6px"
left="20px"
w="12px"
h="12px"
bg="rgba(10,10,20,0.98)"
borderTop="1px solid rgba(255,215,0,0.3)"
borderLeft="1px solid rgba(255,215,0,0.3)"
transform="rotate(45deg)"
/>
<VStack align="start" spacing={3}>
{/* SABC说明 */}
<HStack spacing={2} align="start">
<Text color="gold" fontWeight="bold" fontSize="xs" minW="50px">SABC</Text>
<Text fontSize="xs" color="whiteAlpha.800">
基于事件影响力评级非收益预测
</Text>
</HStack>
{/* 涨跌幅说明 */}
<HStack spacing={2} align="start">
<Text color="cyan.400" fontWeight="bold" fontSize="xs" minW="50px">涨跌幅</Text>
<Text fontSize="xs" color="whiteAlpha.800">
新闻发布时股价 当前价格的变化
</Text>
</HStack>
{/* 延迟提醒 */}
<HStack spacing={2} align="start">
<HStack spacing={1} minW="50px">
<Icon as={Clock} color="orange.400" boxSize={3} />
</HStack>
<Text fontSize="xs" color="whiteAlpha.800">
数据延迟<Text as="span" color="orange.400" fontWeight="bold"> 2-3分钟</Text>
<Text as="span" color="#ec0000" fontWeight="bold">切勿追高</Text>
</Text>
</HStack>
{/* 分隔线 */}
<Box w="100%" h="1px" bg="rgba(255,255,255,0.1)" />
{/* 盘前新闻 */}
<HStack spacing={2} align="start">
<Icon as={AlertCircle} color="blue.300" boxSize={3} mt={0.5} />
<Text fontSize="xs" color="whiteAlpha.800">
关注<Text as="span" color="blue.300" fontWeight="bold">盘前新闻</Text>
收盘后至次日开盘前的消息
</Text>
</HStack>
{/* 利好利空 */}
<HStack spacing={2} align="start">
<Icon as={TrendingUp} color="whiteAlpha.500" boxSize={3} mt={0.5} />
<Text fontSize="xs" color="whiteAlpha.800">
事件含
<Text as="span" color="#ec0000" fontWeight="bold"> 利好</Text>/
<Text as="span" color="#00da3c" fontWeight="bold">利空</Text>
需自行判断
</Text>
</HStack>
</VStack>
</Box>
</Box>
);
};
/**
* 顶部说明面板主组件
*/
const HeroPanel = () => {
const gradientBg = 'linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 25%, #16213e 50%, #1a1a2e 75%, #0a0a0a 100%)';
const borderColor = useColorModeValue('rgba(255, 215, 0, 0.3)', 'rgba(255, 215, 0, 0.25)');
return (
<Card
bg={gradientBg}
borderColor={borderColor}
borderWidth="1px"
boxShadow="0 20px 60px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,215,0,0.1) inset"
mb={6}
overflow="hidden"
position="relative"
>
{/* 装饰性光晕 */}
<Box
position="absolute"
top="-50%"
right="-30%"
width="600px"
height="600px"
borderRadius="full"
bg="radial-gradient(circle, rgba(255,215,0,0.08) 0%, transparent 70%)"
pointerEvents="none"
filter="blur(40px)"
/>
<Box
position="absolute"
bottom="-40%"
left="-20%"
width="400px"
height="400px"
borderRadius="full"
bg="radial-gradient(circle, rgba(100,150,255,0.05) 0%, transparent 70%)"
pointerEvents="none"
filter="blur(50px)"
/>
<CardBody p={{ base: 4, md: 5 }}>
{/* 标题行:标题 + 使用说明 + 交易状态 */}
<Flex align="center" justify="space-between" mb={4} wrap="wrap" gap={2}>
<HStack spacing={3}>
<Heading size="lg">
<Text
bgGradient="linear(to-r, #FFD700, #FFA500, #FFD700)"
bgClip="text"
backgroundSize="200% 100%"
animation="shimmer 3s linear infinite"
fontWeight="extrabold"
>
事件中心
</Text>
</Heading>
{/* 使用说明 - 悬浮提示 */}
<InfoTooltip />
</HStack>
{/* 右侧:交易状态 */}
{isInTradingTime() && (
<HStack
spacing={1.5}
px={2.5}
py={1}
borderRadius="full"
bg="rgba(0,218,60,0.1)"
border="1px solid rgba(0,218,60,0.3)"
>
<Box
w="6px"
h="6px"
borderRadius="full"
bg="#00da3c"
animation="pulse 1.5s infinite"
boxShadow="0 0 8px #00da3c"
/>
<Text fontSize="xs" color="#00da3c" fontWeight="bold">
交易中
</Text>
</HStack>
)}
</Flex>
{/* 内容区:指数 + 概念 */}
<Flex
direction={{ base: 'column', lg: 'row' }}
gap={4}
>
{/* 左侧:双指数横向排列 */}
<Flex
direction={{ base: 'column', md: 'row' }}
gap={4}
flex={{ lg: '0 0 58%' }}
>
{/* 上证指数 */}
<Box
flex="1"
p={3}
bg="rgba(255,255,255,0.03)"
borderRadius="xl"
border="1px solid rgba(255,255,255,0.08)"
transition="all 0.3s"
_hover={{
bg: 'rgba(255,255,255,0.05)',
borderColor: 'rgba(236,0,0,0.3)',
boxShadow: '0 4px 20px rgba(236,0,0,0.1)',
}}
>
<CompactIndexCard indexCode="000001" indexName="上证指数" />
</Box>
{/* 深证成指 */}
<Box
flex="1"
p={3}
bg="rgba(255,255,255,0.03)"
borderRadius="xl"
border="1px solid rgba(255,255,255,0.08)"
transition="all 0.3s"
_hover={{
bg: 'rgba(255,255,255,0.05)',
borderColor: 'rgba(0,218,60,0.3)',
boxShadow: '0 4px 20px rgba(0,218,60,0.1)',
}}
>
<CompactIndexCard indexCode="399001" indexName="深证成指" />
</Box>
</Flex>
{/* 右侧:热门概念 */}
<Box
flex={{ lg: '1' }}
minH={{ base: '150px', lg: '100px' }}
bg="rgba(0,0,0,0.2)"
borderRadius="xl"
border="1px solid rgba(255,215,0,0.1)"
overflow="hidden"
position="relative"
>
{/* 标题 */}
<HStack
position="absolute"
top={2}
left={3}
zIndex={10}
spacing={2}
>
<Icon as={TrendingUp} color="gold" boxSize={3.5} />
<Text fontSize="xs" fontWeight="bold" color="gold">
热门概念
</Text>
<Text fontSize="xs" color="whiteAlpha.400">
点击查看
</Text>
</HStack>
<Box pt={7} h="100%">
<ConceptFloat />
</Box>
</Box>
</Flex>
</CardBody>
</Card>
);
};
export default HeroPanel;