update pay ui
This commit is contained in:
539
src/views/StockOverview/components/HotspotOverview/index.js
Normal file
539
src/views/StockOverview/components/HotspotOverview/index.js
Normal file
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* 热点概览组件
|
||||
* 展示大盘分时走势 + 概念异动标注
|
||||
*/
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardBody,
|
||||
Heading,
|
||||
Text,
|
||||
HStack,
|
||||
VStack,
|
||||
Badge,
|
||||
Spinner,
|
||||
Center,
|
||||
Icon,
|
||||
Flex,
|
||||
Spacer,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
SimpleGrid,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaFire, FaRocket, FaChartLine, FaBolt, FaArrowDown } from 'react-icons/fa';
|
||||
import { InfoIcon } from '@chakra-ui/icons';
|
||||
import * as echarts from 'echarts';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
const HotspotOverview = ({ selectedDate }) => {
|
||||
const chartRef = useRef(null);
|
||||
const chartInstance = useRef(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// 颜色主题
|
||||
const cardBg = useColorModeValue('white', '#1a1a1a');
|
||||
const borderColor = useColorModeValue('gray.200', '#333333');
|
||||
const textColor = useColorModeValue('gray.800', 'white');
|
||||
const subTextColor = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
// 获取数据
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const dateParam = selectedDate
|
||||
? `?date=${selectedDate.toISOString().split('T')[0]}`
|
||||
: '';
|
||||
const response = await fetch(`/api/market/hotspot-overview${dateParam}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError(result.error || '获取数据失败');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('HotspotOverview', 'fetchData', err);
|
||||
setError('网络请求失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// 渲染图表
|
||||
const renderChart = useCallback(() => {
|
||||
if (!chartRef.current || !data) return;
|
||||
|
||||
if (!chartInstance.current) {
|
||||
chartInstance.current = echarts.init(chartRef.current);
|
||||
}
|
||||
|
||||
const { index, alerts } = data;
|
||||
const timeline = index.timeline || [];
|
||||
|
||||
// 准备数据
|
||||
const times = timeline.map((d) => d.time);
|
||||
const prices = timeline.map((d) => d.price);
|
||||
const volumes = timeline.map((d) => d.volume);
|
||||
const changePcts = timeline.map((d) => d.change_pct);
|
||||
|
||||
// 计算Y轴范围
|
||||
const priceMin = Math.min(...prices.filter(Boolean));
|
||||
const priceMax = Math.max(...prices.filter(Boolean));
|
||||
const priceRange = priceMax - priceMin;
|
||||
const yAxisMin = priceMin - priceRange * 0.1;
|
||||
const yAxisMax = priceMax + priceRange * 0.2; // 上方留更多空间给标注
|
||||
|
||||
// 准备异动标注 - 按重要性排序,限制显示数量
|
||||
const sortedAlerts = [...alerts]
|
||||
.sort((a, b) => (b.importance_score || 0) - (a.importance_score || 0))
|
||||
.slice(0, 15); // 最多显示15个标注,避免图表过于密集
|
||||
|
||||
const markPoints = sortedAlerts.map((alert) => {
|
||||
// 找到对应时间的价格
|
||||
const timeIndex = times.indexOf(alert.time);
|
||||
const price = timeIndex >= 0 ? prices[timeIndex] : (alert.index_price || priceMax);
|
||||
|
||||
// 根据异动类型设置颜色和符号
|
||||
let color = '#ff6b6b';
|
||||
let symbol = 'pin';
|
||||
let symbolSize = 35;
|
||||
|
||||
// 暴涨
|
||||
if (alert.alert_type === 'surge_up' || alert.alert_type === 'surge') {
|
||||
color = '#ff4757';
|
||||
symbol = 'triangle';
|
||||
symbolSize = 30 + Math.min((alert.importance_score || 0.5) * 20, 15); // 根据重要性调整大小
|
||||
}
|
||||
// 暴跌
|
||||
else if (alert.alert_type === 'surge_down') {
|
||||
color = '#2ed573';
|
||||
symbol = 'path://M0,0 L10,0 L5,10 Z'; // 向下三角形
|
||||
symbolSize = 30 + Math.min((alert.importance_score || 0.5) * 20, 15);
|
||||
}
|
||||
// 涨停增加
|
||||
else if (alert.alert_type === 'limit_up') {
|
||||
color = '#ff6348';
|
||||
symbol = 'diamond';
|
||||
symbolSize = 28;
|
||||
}
|
||||
// 排名跃升
|
||||
else if (alert.alert_type === 'rank_jump') {
|
||||
color = '#3742fa';
|
||||
symbol = 'circle';
|
||||
symbolSize = 25;
|
||||
}
|
||||
|
||||
// 格式化标签 - 简化显示
|
||||
let label = alert.concept_name;
|
||||
// 截断过长的名称
|
||||
if (label.length > 8) {
|
||||
label = label.substring(0, 7) + '...';
|
||||
}
|
||||
|
||||
// 添加变化信息
|
||||
const changeDelta = alert.change_delta;
|
||||
if (changeDelta) {
|
||||
const sign = changeDelta > 0 ? '+' : '';
|
||||
label += `\n${sign}${changeDelta.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
return {
|
||||
name: alert.concept_name,
|
||||
coord: [alert.time, price],
|
||||
value: label,
|
||||
symbol: symbol,
|
||||
symbolSize: symbolSize,
|
||||
itemStyle: {
|
||||
color: color,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 1,
|
||||
shadowBlur: 3,
|
||||
shadowColor: 'rgba(0,0,0,0.2)',
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: alert.alert_type === 'surge_down' ? 'bottom' : 'top', // 暴跌标签在下方
|
||||
formatter: '{b}',
|
||||
fontSize: 9,
|
||||
color: textColor,
|
||||
backgroundColor: alert.alert_type === 'surge_down'
|
||||
? 'rgba(46, 213, 115, 0.9)'
|
||||
: 'rgba(255,255,255,0.9)',
|
||||
padding: [2, 4],
|
||||
borderRadius: 2,
|
||||
borderColor: color,
|
||||
borderWidth: 1,
|
||||
},
|
||||
// 存储额外信息用于 tooltip
|
||||
alertData: alert,
|
||||
};
|
||||
});
|
||||
|
||||
// 渐变色 - 根据涨跌
|
||||
const latestChangePct = changePcts[changePcts.length - 1] || 0;
|
||||
const areaColorStops = latestChangePct >= 0
|
||||
? [
|
||||
{ offset: 0, color: 'rgba(255, 77, 77, 0.4)' },
|
||||
{ offset: 1, color: 'rgba(255, 77, 77, 0.05)' },
|
||||
]
|
||||
: [
|
||||
{ offset: 0, color: 'rgba(34, 197, 94, 0.4)' },
|
||||
{ offset: 1, color: 'rgba(34, 197, 94, 0.05)' },
|
||||
];
|
||||
|
||||
const lineColor = latestChangePct >= 0 ? '#ff4d4d' : '#22c55e';
|
||||
|
||||
const option = {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
crossStyle: {
|
||||
color: '#999',
|
||||
},
|
||||
},
|
||||
formatter: function (params) {
|
||||
if (!params || params.length === 0) return '';
|
||||
|
||||
const dataIndex = params[0].dataIndex;
|
||||
const time = times[dataIndex];
|
||||
const price = prices[dataIndex];
|
||||
const changePct = changePcts[dataIndex];
|
||||
const volume = volumes[dataIndex];
|
||||
|
||||
let html = `
|
||||
<div style="padding: 8px;">
|
||||
<div style="font-weight: bold; margin-bottom: 4px;">${time}</div>
|
||||
<div>指数: <span style="color: ${changePct >= 0 ? '#ff4d4d' : '#22c55e'}; font-weight: bold;">${price?.toFixed(2)}</span></div>
|
||||
<div>涨跌: <span style="color: ${changePct >= 0 ? '#ff4d4d' : '#22c55e'};">${changePct >= 0 ? '+' : ''}${changePct?.toFixed(2)}%</span></div>
|
||||
<div>成交量: ${(volume / 10000).toFixed(0)}万手</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 检查是否有异动
|
||||
const alertsAtTime = alerts.filter((a) => a.time === time);
|
||||
if (alertsAtTime.length > 0) {
|
||||
html += '<div style="border-top: 1px solid #eee; margin-top: 4px; padding-top: 4px;">';
|
||||
html += '<div style="font-weight: bold; color: #ff6b6b;">概念异动:</div>';
|
||||
alertsAtTime.forEach((alert) => {
|
||||
const typeLabel = {
|
||||
surge: '急涨',
|
||||
surge_up: '暴涨',
|
||||
surge_down: '暴跌',
|
||||
limit_up: '涨停增加',
|
||||
rank_jump: '排名跃升',
|
||||
}[alert.alert_type] || alert.alert_type;
|
||||
const typeColor = alert.alert_type === 'surge_down' ? '#2ed573' : '#ff6b6b';
|
||||
const delta = alert.change_delta ? ` (${alert.change_delta > 0 ? '+' : ''}${alert.change_delta.toFixed(2)}%)` : '';
|
||||
const zscore = alert.zscore ? ` Z=${alert.zscore.toFixed(1)}` : '';
|
||||
html += `<div style="color: ${typeColor}">• ${alert.concept_name} (${typeLabel}${delta}${zscore})</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
return html;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
grid: [
|
||||
{
|
||||
left: '8%',
|
||||
right: '3%',
|
||||
top: '8%',
|
||||
height: '55%',
|
||||
},
|
||||
{
|
||||
left: '8%',
|
||||
right: '3%',
|
||||
top: '70%',
|
||||
height: '20%',
|
||||
},
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
data: times,
|
||||
axisLine: { lineStyle: { color: '#ddd' } },
|
||||
axisLabel: {
|
||||
color: subTextColor,
|
||||
fontSize: 10,
|
||||
interval: Math.floor(times.length / 6),
|
||||
},
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
gridIndex: 1,
|
||||
data: times,
|
||||
axisLine: { lineStyle: { color: '#ddd' } },
|
||||
axisLabel: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
min: yAxisMin,
|
||||
max: yAxisMax,
|
||||
axisLine: { show: false },
|
||||
axisLabel: {
|
||||
color: subTextColor,
|
||||
fontSize: 10,
|
||||
formatter: (val) => val.toFixed(0),
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: { color: '#eee', type: 'dashed' },
|
||||
},
|
||||
// 右侧显示涨跌幅
|
||||
axisPointer: {
|
||||
label: {
|
||||
formatter: function (params) {
|
||||
const pct = ((params.value - index.prev_close) / index.prev_close) * 100;
|
||||
return `${params.value.toFixed(2)} (${pct >= 0 ? '+' : ''}${pct.toFixed(2)}%)`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
gridIndex: 1,
|
||||
axisLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
// 分时线
|
||||
{
|
||||
name: '上证指数',
|
||||
type: 'line',
|
||||
data: prices,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
color: lineColor,
|
||||
width: 1.5,
|
||||
},
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, areaColorStops),
|
||||
},
|
||||
markPoint: {
|
||||
symbol: 'pin',
|
||||
symbolSize: 40,
|
||||
data: markPoints,
|
||||
animation: true,
|
||||
},
|
||||
},
|
||||
// 成交量
|
||||
{
|
||||
name: '成交量',
|
||||
type: 'bar',
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 1,
|
||||
data: volumes.map((v, i) => ({
|
||||
value: v,
|
||||
itemStyle: {
|
||||
color: changePcts[i] >= 0 ? 'rgba(255, 77, 77, 0.6)' : 'rgba(34, 197, 94, 0.6)',
|
||||
},
|
||||
})),
|
||||
barWidth: '60%',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
chartInstance.current.setOption(option, true);
|
||||
}, [data, textColor, subTextColor]);
|
||||
|
||||
// 数据变化时重新渲染
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
renderChart();
|
||||
}
|
||||
}, [data, renderChart]);
|
||||
|
||||
// 窗口大小变化时重新渲染
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.resize();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.dispose();
|
||||
chartInstance.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 异动类型标签
|
||||
const AlertTypeBadge = ({ type, count }) => {
|
||||
const config = {
|
||||
surge: { label: '急涨', color: 'red', icon: FaBolt },
|
||||
surge_up: { label: '暴涨', color: 'red', icon: FaBolt },
|
||||
surge_down: { label: '暴跌', color: 'green', icon: FaArrowDown },
|
||||
limit_up: { label: '涨停', color: 'orange', icon: FaRocket },
|
||||
rank_jump: { label: '排名跃升', color: 'blue', icon: FaChartLine },
|
||||
};
|
||||
|
||||
const cfg = config[type] || { label: type, color: 'gray', icon: FaFire };
|
||||
|
||||
return (
|
||||
<Badge colorScheme={cfg.color} variant="subtle" px={2} py={1} borderRadius="md">
|
||||
<HStack spacing={1}>
|
||||
<Icon as={cfg.icon} boxSize={3} />
|
||||
<Text>{cfg.label}</Text>
|
||||
<Text fontWeight="bold">{count}</Text>
|
||||
</HStack>
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card bg={cardBg} borderWidth="1px" borderColor={borderColor}>
|
||||
<CardBody>
|
||||
<Center h="400px">
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" color="purple.500" thickness="4px" />
|
||||
<Text color={subTextColor}>加载热点概览数据...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card bg={cardBg} borderWidth="1px" borderColor={borderColor}>
|
||||
<CardBody>
|
||||
<Center h="400px">
|
||||
<VStack spacing={4}>
|
||||
<Icon as={InfoIcon} boxSize={10} color="red.400" />
|
||||
<Text color="red.500">{error}</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { index, alerts, alert_summary } = data;
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} borderWidth="1px" borderColor={borderColor}>
|
||||
<CardBody>
|
||||
{/* 头部信息 */}
|
||||
<Flex align="center" mb={4}>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FaFire} boxSize={6} color="orange.500" />
|
||||
<Heading size="md" color={textColor}>
|
||||
热点概览
|
||||
</Heading>
|
||||
</HStack>
|
||||
<Spacer />
|
||||
<Tooltip label="展示大盘走势与概念异动的关联">
|
||||
<Icon as={InfoIcon} color={subTextColor} />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
{/* 指数统计 */}
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4} mb={4}>
|
||||
<Stat size="sm">
|
||||
<StatLabel color={subTextColor}>{index.name}</StatLabel>
|
||||
<StatNumber
|
||||
fontSize="xl"
|
||||
color={index.change_pct >= 0 ? 'red.500' : 'green.500'}
|
||||
>
|
||||
{index.latest_price?.toFixed(2)}
|
||||
</StatNumber>
|
||||
<StatHelpText mb={0}>
|
||||
<StatArrow type={index.change_pct >= 0 ? 'increase' : 'decrease'} />
|
||||
{index.change_pct?.toFixed(2)}%
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
|
||||
<Stat size="sm">
|
||||
<StatLabel color={subTextColor}>最高</StatLabel>
|
||||
<StatNumber fontSize="xl" color="red.500">
|
||||
{index.high?.toFixed(2)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
|
||||
<Stat size="sm">
|
||||
<StatLabel color={subTextColor}>最低</StatLabel>
|
||||
<StatNumber fontSize="xl" color="green.500">
|
||||
{index.low?.toFixed(2)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
|
||||
<Stat size="sm">
|
||||
<StatLabel color={subTextColor}>异动次数</StatLabel>
|
||||
<StatNumber fontSize="xl" color="orange.500">
|
||||
{alerts.length}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 异动类型统计 */}
|
||||
{alerts.length > 0 && (
|
||||
<HStack spacing={2} mb={4} flexWrap="wrap">
|
||||
{(alert_summary.surge_up > 0 || alert_summary.surge > 0) && (
|
||||
<AlertTypeBadge type="surge_up" count={(alert_summary.surge_up || 0) + (alert_summary.surge || 0)} />
|
||||
)}
|
||||
{alert_summary.surge_down > 0 && (
|
||||
<AlertTypeBadge type="surge_down" count={alert_summary.surge_down} />
|
||||
)}
|
||||
{alert_summary.limit_up > 0 && (
|
||||
<AlertTypeBadge type="limit_up" count={alert_summary.limit_up} />
|
||||
)}
|
||||
{alert_summary.rank_jump > 0 && (
|
||||
<AlertTypeBadge type="rank_jump" count={alert_summary.rank_jump} />
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 图表 */}
|
||||
<Box ref={chartRef} h="400px" w="100%" />
|
||||
|
||||
{/* 无异动提示 */}
|
||||
{alerts.length === 0 && (
|
||||
<Center py={4}>
|
||||
<Text color={subTextColor} fontSize="sm">
|
||||
当日暂无概念异动数据
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default HotspotOverview;
|
||||
Reference in New Issue
Block a user