Files
vf_react/src/views/Community/components/HeroPanel.js
2025-11-13 17:45:09 +08:00

838 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.

// src/views/Community/components/HeroPanel.js
// 顶部说明面板组件:产品功能介绍 + 沪深指数折线图 + 热门概念词云图
import React, { useEffect, useState, useMemo, useCallback } from 'react';
// 定义 pulse 动画
const pulseAnimation = `
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.1);
}
}
`;
// 注入样式到页面
if (typeof document !== 'undefined') {
const styleSheet = document.createElement('style');
styleSheet.type = 'text/css';
styleSheet.innerText = pulseAnimation;
document.head.appendChild(styleSheet);
}
import {
Box,
Card,
CardBody,
Flex,
VStack,
HStack,
Text,
Heading,
useColorModeValue,
SimpleGrid,
Icon,
Spinner,
Center,
} from '@chakra-ui/react';
import { TrendingUp, Activity, Globe, Zap } from 'lucide-react';
import ReactECharts from 'echarts-for-react';
import { logger } from '../../../utils/logger';
/**
* 获取指数行情数据(日线数据)
*/
const fetchIndexKline = async (indexCode) => {
try {
// 使用日线数据获取最近60个交易日
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();
logger.debug('HeroPanel', 'fetchIndexKline success', { indexCode, dataLength: data?.data?.length });
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: 80, // 获取前80个概念
page: 1,
sort_by: 'change_pct'
})
});
const data = await response.json();
logger.debug('HeroPanel', 'fetchPopularConcepts response', {
total: data.total,
resultsCount: data.results?.length
});
if (data.results && data.results.length > 0) {
return data.results.map(item => ({
name: item.concept,
value: Math.abs(item.price_info?.avg_change_pct || 1) + 5, // 使用涨跌幅绝对值 + 基础权重
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 hours = now.getHours();
const minutes = now.getMinutes();
const timeInMinutes = hours * 60 + minutes;
// 9:30 - 15:00 (570分钟 - 900分钟)
return timeInMinutes >= 570 && timeInMinutes <= 900;
};
/**
* 迷你K线图组件支持实时更新
*/
const MiniIndexChart = ({ indexCode, indexName }) => {
const [chartData, setChartData] = useState(null);
const [loading, setLoading] = useState(true);
const [latestData, setLatestData] = useState(null);
const [currentDate, setCurrentDate] = useState('');
const chartBg = useColorModeValue('transparent', 'transparent');
// 中国市场惯例:涨红跌绿
const upColor = '#ec0000'; // 上涨:红色
const downColor = '#00da3c'; // 下跌:绿色
// 加载日线数据
const loadDailyData = useCallback(async () => {
const data = await fetchIndexKline(indexCode);
if (data && data.data && 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
});
setCurrentDate(latest.time);
// 准备K线图数据最近60个交易日
const recentData = data.data.slice(-60);
setChartData({
dates: recentData.map(item => item.time),
klineData: recentData.map(item => [
item.open,
item.close,
item.low,
item.high
]),
rawData: recentData // 保存原始数据用于 tooltip
});
}
setLoading(false);
}, [indexCode]);
// 加载分钟线数据(仅在交易时间)
const loadMinuteData = useCallback(async () => {
try {
const response = await fetch(`/api/index/${indexCode}/kline?type=minute`);
if (!response.ok) return;
const data = await response.json();
if (data && data.data && data.data.length > 0) {
// 取最新分钟数据
const latest = data.data[data.data.length - 1];
// 分钟线没有 prev_close使用第一条数据的 open 作为开盘价
const dayOpen = data.data[0].open;
setLatestData({
close: latest.close,
change: dayOpen ? (((latest.close - dayOpen) / dayOpen) * 100).toFixed(2) : '0.00',
isPositive: latest.close >= dayOpen
});
logger.debug('HeroPanel', 'Minute data updated', {
indexCode,
close: latest.close,
time: latest.time,
change: (((latest.close - dayOpen) / dayOpen) * 100).toFixed(2)
});
}
} catch (error) {
logger.error('HeroPanel', 'loadMinuteData error', error);
}
}, [indexCode]);
// 初始加载和定时更新
useEffect(() => {
let isMounted = true;
let intervalId = null;
const init = async () => {
setLoading(true);
await loadDailyData();
if (isMounted) {
// 如果在交易时间,立即加载一次分钟数据
if (isInTradingTime()) {
await loadMinuteData();
}
setLoading(false);
}
};
init();
// 设置定时器:交易时间内每分钟更新
if (isInTradingTime()) {
intervalId = setInterval(() => {
if (isInTradingTime()) {
loadMinuteData();
} else {
// 如果超出交易时间,清除定时器
if (intervalId) {
clearInterval(intervalId);
}
}
}, 60000); // 每60秒更新一次
}
return () => {
isMounted = false;
if (intervalId) {
clearInterval(intervalId);
}
};
}, [indexCode, loadDailyData, loadMinuteData]);
const chartOption = useMemo(() => {
if (!chartData) return {};
return {
backgroundColor: chartBg,
grid: {
left: 10,
right: 10,
top: 5,
bottom: 20,
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: 11,
fontFamily: 'monospace'
},
padding: [8, 12],
formatter: function (params) {
const dataIndex = params[0].dataIndex;
const rawDataItem = chartData.rawData[dataIndex];
if (!rawDataItem) return '';
const open = rawDataItem.open;
const high = rawDataItem.high;
const low = rawDataItem.low;
const close = rawDataItem.close;
const prevClose = rawDataItem.prev_close || open;
const change = close - prevClose;
const changePct = prevClose ? ((change / prevClose) * 100).toFixed(2) : '0.00';
const isUp = close >= prevClose;
// Bloomberg 风格格式化(涨红跌绿)
const changeColor = isUp ? '#ec0000' : '#00da3c';
const changeSign = isUp ? '+' : '';
return `
<div style="font-weight: bold; border-bottom: 1px solid #FFD700; padding-bottom: 6px; margin-bottom: 8px; font-size: 12px;">
📅 ${rawDataItem.time}
</div>
<div style="display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; line-height: 1.8;">
<span style="color: #999;">开盘</span><span style="font-weight: bold; font-size: 13px;">${open.toFixed(2)}</span>
<span style="color: #999;">最高</span><span style="font-weight: bold; font-size: 13px; color: #ec0000;">${high.toFixed(2)}</span>
<span style="color: #999;">最低</span><span style="font-weight: bold; font-size: 13px; color: #00da3c;">${low.toFixed(2)}</span>
<span style="color: #999;">收盘</span><span style="font-weight: bold; font-size: 13px;">${close.toFixed(2)}</span>
<span style="color: #999;">涨跌</span><span style="font-weight: bold; font-size: 13px; color: ${changeColor};">${changeSign}${change.toFixed(2)}</span>
<span style="color: #999;">涨跌幅</span><span style="font-weight: bold; font-size: 14px; color: ${changeColor};">${changeSign}${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: '60%'
}]
};
}, [chartData, chartBg, upColor, downColor]);
if (loading) {
return (
<Center h="120px">
<Spinner size="sm" color="gold" />
</Center>
);
}
return (
<VStack spacing={2} align="stretch" h="140px">
<HStack justify="space-between">
<VStack align="start" spacing={0}>
<Text fontSize="xs" color="whiteAlpha.700" fontWeight="medium">{indexName}</Text>
<Text
fontSize="2xl"
fontWeight="extrabold"
color="white"
textShadow="0 2px 4px rgba(0,0,0,0.3)"
>
{latestData?.close.toFixed(2)}
</Text>
<Text fontSize="xs" color="whiteAlpha.500">
📅 {currentDate}
</Text>
</VStack>
<VStack align="end" spacing={1}>
<Text
fontSize="lg"
fontWeight="extrabold"
color={latestData?.isPositive ? '#ec0000' : '#00da3c'}
textShadow={latestData?.isPositive ? '0 2px 8px rgba(236, 0, 0, 0.4)' : '0 2px 8px rgba(0, 218, 60, 0.4)'}
>
{latestData?.isPositive ? '↗' : '↘'} {latestData?.isPositive ? '+' : ''}{latestData?.change}%
</Text>
{isInTradingTime() && (
<HStack spacing={1}>
<Box
w="6px"
h="6px"
borderRadius="full"
bg="green.400"
animation="pulse 2s infinite"
boxShadow="0 0 6px rgba(72, 187, 120, 0.8)"
/>
<Text fontSize="xs" color="green.400" fontWeight="medium">
实时更新
</Text>
</HStack>
)}
</VStack>
</HStack>
<Box flex="1" position="relative">
<ReactECharts
option={chartOption}
style={{ height: '90px', width: '100%' }}
opts={{ renderer: 'canvas' }}
/>
</Box>
</VStack>
);
};
/**
* 概念流动动画组件(替代词云)
*/
const ConceptFlowAnimation = () => {
const [concepts, setConcepts] = useState([]);
const [loading, setLoading] = useState(true);
const [isPaused, setIsPaused] = useState(false);
const [hoveredConcept, setHoveredConcept] = useState(null);
useEffect(() => {
let isMounted = true;
const loadConcepts = async () => {
setLoading(true);
const data = await fetchPopularConcepts();
if (isMounted && data && data.length > 0) {
setConcepts(data);
}
if (isMounted) {
setLoading(false);
}
};
loadConcepts();
return () => {
isMounted = false;
};
}, []);
// 根据涨跌幅获取颜色
const getColor = (changePct) => {
if (changePct > 7) return '#ff0000'; // 超大涨:纯红
if (changePct > 5) return '#ff1744'; // 大涨:亮红色
if (changePct > 3) return '#ff4d4f'; // 中涨:红色
if (changePct > 1) return '#ff7875'; // 小涨:浅红
if (changePct > 0) return '#ffa940'; // 微涨:橙色
if (changePct === 0) return '#FFD700'; // 平盘:金色
if (changePct > -1) return '#73d13d'; // 微跌:浅绿
if (changePct > -3) return '#52c41a'; // 小跌:绿色
if (changePct > -5) return '#00c853'; // 中跌:深绿
if (changePct > -7) return '#00a152'; // 大跌:更深绿
return '#00796b'; // 超大跌:墨绿
};
// 处理概念点击
const handleConceptClick = (conceptName) => {
const url = `https://valuefrontier.cn/htmls/${conceptName}.html`;
window.open(url, '_blank');
};
if (loading) {
return (
<Center h="200px">
<Spinner size="md" color="gold" />
</Center>
);
}
if (concepts.length === 0) {
return (
<Center h="200px">
<Text fontSize="sm" color="whiteAlpha.600">
暂无热门概念数据
</Text>
</Center>
);
}
// 将 80 个概念均匀分成 5 行(每行 16 个)
const rowCount = 5;
const conceptsPerRow = Math.ceil(concepts.length / rowCount);
const rows = [];
for (let i = 0; i < rowCount; i++) {
const start = i * conceptsPerRow;
const end = Math.min(start + conceptsPerRow, concepts.length);
rows.push(concepts.slice(start, end));
}
return (
<VStack spacing={1} h="200px" justify="center" overflow="hidden" position="relative" w="100%">
{rows.map((row, rowIndex) => {
// 每行不同的速度第一行30秒第二行35秒依次递增
const duration = 30 + rowIndex * 5;
return (
<Box
key={rowIndex}
position="relative"
width="100%"
height="38px"
overflow="hidden"
>
<HStack
spacing={3}
position="absolute"
left={0}
h="100%"
align="center"
animation={!isPaused ? `flowRight${rowIndex} ${duration}s linear infinite` : 'none'}
_hover={{ animationPlayState: 'paused' }}
sx={{
[`@keyframes flowRight${rowIndex}`]: {
'0%': { transform: 'translateX(0)' },
'100%': { transform: 'translateX(-50%)' }
}
}}
>
{/* 渲染两次以实现无缝循环 */}
{[...row, ...row].map((concept, idx) => {
const changePct = concept.change_pct;
const color = getColor(changePct);
const sign = changePct > 0 ? '+' : '';
const icon = changePct > 0 ? '📈' : '📉';
const isHovered = hoveredConcept === `${rowIndex}-${idx}`;
return (
<Box
key={`${rowIndex}-${idx}`}
px={3}
py={1}
borderRadius="full"
bg="whiteAlpha.200"
borderWidth="2px"
borderColor={color}
color="white"
fontWeight="bold"
fontSize="xs"
whiteSpace="nowrap"
cursor="pointer"
position="relative"
transition="all 0.3s ease"
boxShadow={`0 0 10px ${color}40`}
onMouseEnter={() => {
setIsPaused(true);
setHoveredConcept(`${rowIndex}-${idx}`);
}}
onMouseLeave={() => {
setIsPaused(false);
setHoveredConcept(null);
}}
onClick={() => handleConceptClick(concept.name)}
_hover={{
transform: 'scale(1.1)',
bg: 'whiteAlpha.300',
boxShadow: `0 0 20px ${color}80`,
zIndex: 100
}}
>
{/* 概念名称 */}
<Text as="span" color={color}>
{concept.name}
</Text>
{/* 悬停时显示涨跌幅 */}
{isHovered && (
<Box
position="absolute"
top="-45px"
left="50%"
transform="translateX(-50%)"
bg="rgba(20, 20, 20, 0.95)"
borderWidth="1px"
borderColor="#FFD700"
borderRadius="md"
px={3}
py={2}
whiteSpace="nowrap"
boxShadow="0 4px 12px rgba(0,0,0,0.3)"
zIndex={200}
_before={{
content: '""',
position: 'absolute',
bottom: '-6px',
left: '50%',
transform: 'translateX(-50%)',
width: 0,
height: 0,
borderLeft: '6px solid transparent',
borderRight: '6px solid transparent',
borderTop: '6px solid #FFD700'
}}
>
<VStack spacing={1}>
<Text fontSize="xs" color="white" fontWeight="bold">
{icon} {concept.name}
</Text>
<Text fontSize="sm" color={color} fontWeight="bold">
{sign}{changePct.toFixed(2)}%
</Text>
</VStack>
</Box>
)}
</Box>
);
})}
</HStack>
</Box>
);
})}
</VStack>
);
};
/**
* 产品特性图标组件
*/
const FeatureIcon = ({ icon, title, description }) => {
return (
<HStack spacing={3} align="start">
<Box
p={2}
borderRadius="lg"
bg="whiteAlpha.200"
color="gold"
>
<Icon as={icon} boxSize={5} />
</Box>
<VStack align="start" spacing={0}>
<Text fontSize="sm" fontWeight="bold" color="white">
{title}
</Text>
<Text fontSize="xs" color="whiteAlpha.700">
{description}
</Text>
</VStack>
</HStack>
);
};
/**
* 顶部说明面板主组件
*/
const HeroPanel = () => {
const gradientBg = 'linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 50%, #1a1a1a 100%)';
const cardBorder = '1px solid';
const borderColor = useColorModeValue('rgba(255, 215, 0, 0.3)', 'rgba(255, 215, 0, 0.2)');
return (
<Card
bg={gradientBg}
borderColor={borderColor}
borderWidth={cardBorder}
boxShadow="0 12px 48px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 215, 0, 0.1) inset"
mb={6}
overflow="hidden"
position="relative"
transition="all 0.3s ease"
_hover={{
boxShadow: "0 16px 64px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 215, 0, 0.2) inset",
transform: "translateY(-2px)"
}}
>
{/* 装饰性背景图案 - 多层叠加 */}
<Box
position="absolute"
top="-30%"
right="-15%"
width="600px"
height="600px"
borderRadius="full"
bg="radial-gradient(circle, rgba(255, 215, 0, 0.15) 0%, rgba(255, 165, 0, 0.08) 40%, transparent 70%)"
pointerEvents="none"
filter="blur(40px)"
/>
<Box
position="absolute"
bottom="-20%"
left="-10%"
width="400px"
height="400px"
borderRadius="full"
bg="radial-gradient(circle, rgba(236, 0, 0, 0.08) 0%, transparent 60%)"
pointerEvents="none"
filter="blur(50px)"
/>
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
width="80%"
height="80%"
bg="radial-gradient(circle, rgba(255, 215, 0, 0.02) 0%, transparent 60%)"
pointerEvents="none"
filter="blur(60px)"
/>
<CardBody p={6}>
<SimpleGrid columns={{ base: 1, lg: 3 }} spacing={6}>
{/* 左侧:产品介绍 */}
<Box>
<VStack align="start" spacing={4}>
<Heading size="lg" color="white" fontWeight="extrabold">
<Text
bgGradient="linear(to-r, #FFD700, #FFA500)"
bgClip="text"
>
价值前沿
</Text>
</Heading>
<Text fontSize="sm" color="whiteAlpha.800" lineHeight="1.8">
实时捕捉市场动态智能分析投资机会
<br />
整合多维数据源为您提供专业的投资决策支持
</Text>
<VStack spacing={3} align="stretch" w="100%">
<FeatureIcon
icon={Activity}
title="实时监控"
description="7×24小时追踪市场动态"
/>
<FeatureIcon
icon={TrendingUp}
title="智能分析"
description="AI驱动的概念板块分析"
/>
<FeatureIcon
icon={Globe}
title="全面覆盖"
description="A股全市场深度数据"
/>
<FeatureIcon
icon={Zap}
title="极速响应"
description="毫秒级数据更新推送"
/>
</VStack>
</VStack>
</Box>
{/* 中间沪深指数K线图 */}
<Box>
<VStack spacing={4} h="100%">
<Box
w="100%"
p={4}
bg="whiteAlpha.100"
borderRadius="xl"
borderWidth="1px"
borderColor="whiteAlpha.200"
backdropFilter="blur(10px)"
boxShadow="0 4px 16px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.05)"
transition="all 0.3s ease"
_hover={{
bg: "whiteAlpha.150",
borderColor: "whiteAlpha.300",
boxShadow: "0 6px 24px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1)",
transform: "translateY(-1px)"
}}
>
<MiniIndexChart indexCode="000001" indexName="上证指数" />
</Box>
<Box
w="100%"
p={4}
bg="whiteAlpha.100"
borderRadius="xl"
borderWidth="1px"
borderColor="whiteAlpha.200"
backdropFilter="blur(10px)"
boxShadow="0 4px 16px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.05)"
transition="all 0.3s ease"
_hover={{
bg: "whiteAlpha.150",
borderColor: "whiteAlpha.300",
boxShadow: "0 6px 24px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1)",
transform: "translateY(-1px)"
}}
>
<MiniIndexChart indexCode="399001" indexName="深证成指" />
</Box>
</VStack>
</Box>
{/* 右侧:热门概念词云图 */}
<Box>
<VStack align="start" spacing={3} h="100%">
<HStack
spacing={2}
p={2}
borderRadius="md"
bg="whiteAlpha.50"
w="full"
justify="space-between"
>
<HStack spacing={2}>
<Text
fontSize="lg"
fontWeight="extrabold"
bgGradient="linear(to-r, #FFD700, #FFA500, #FF4500)"
bgClip="text"
>
🔥 热门概念
</Text>
</HStack>
<HStack spacing={1}>
<Box
w="6px"
h="6px"
borderRadius="full"
bg="orange.400"
animation="pulse 2s infinite"
boxShadow="0 0 6px rgba(251, 146, 60, 0.8)"
/>
<Text fontSize="xs" color="orange.400" fontWeight="medium">
实时更新
</Text>
</HStack>
</HStack>
<Box
w="100%"
flex="1"
bg="whiteAlpha.100"
borderRadius="xl"
borderWidth="1px"
borderColor="whiteAlpha.200"
backdropFilter="blur(10px)"
boxShadow="0 4px 16px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.05)"
p={3}
position="relative"
overflow="hidden"
transition="all 0.3s ease"
_hover={{
bg: "whiteAlpha.150",
borderColor: "whiteAlpha.300",
boxShadow: "0 6px 24px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1)"
}}
>
<ConceptFlowAnimation />
</Box>
<HStack spacing={2} fontSize="xs" color="whiteAlpha.600" w="100%" justify="center">
<Text>🌊 概念流动展示</Text>
<Text></Text>
<Text>🎨 颜色表示涨跌幅</Text>
<Text></Text>
<Text>👆 点击查看详情</Text>
</HStack>
</VStack>
</Box>
</SimpleGrid>
</CardBody>
</Card>
);
};
export default HeroPanel;