update pay ui

This commit is contained in:
2025-12-09 08:31:18 +08:00
parent e4937c2719
commit 25492caf15
26 changed files with 15577 additions and 1061 deletions

View 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;