// src/components/StockChart/StockChartModal.js - 统一的股票图表组件 import React, { useState, useEffect, useRef } from 'react'; import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, Button, ButtonGroup, VStack, HStack, Text, Badge, Box, Flex, CircularProgress } from '@chakra-ui/react'; import ReactECharts from 'echarts-for-react'; import * as echarts from 'echarts'; import moment from 'moment'; import { stockService } from '../../services/eventService'; import { logger } from '../../utils/logger'; import RiskDisclaimer from '../RiskDisclaimer'; const StockChartModal = ({ isOpen, onClose, stock, eventTime, isChakraUI = true, // 是否使用Chakra UI,默认true;如果false则使用Antd size = "6xl" }) => { const chartRef = useRef(null); const chartInstanceRef = useRef(null); const [chartType, setChartType] = useState('timeline'); const [loading, setLoading] = useState(false); const [chartData, setChartData] = useState(null); const [preloadedData, setPreloadedData] = useState({}); // 预加载数据 const preloadData = async (type) => { if (!stock || preloadedData[type]) return; try { let adjustedEventTime = eventTime; if (eventTime) { try { const eventMoment = moment(eventTime); if (eventMoment.isValid() && eventMoment.hour() >= 15) { const nextDay = eventMoment.clone().add(1, 'day'); nextDay.hour(9).minute(30).second(0).millisecond(0); adjustedEventTime = nextDay.format('YYYY-MM-DD HH:mm'); } } catch (e) { logger.warn('StockChartModal', '事件时间解析失败', { eventTime, error: e.message }); } } const response = await stockService.getKlineData(stock.stock_code, type, adjustedEventTime); setPreloadedData(prev => ({...prev, [type]: response})); } catch (err) { logger.error('StockChartModal', 'preloadData', err, { stockCode: stock?.stock_code, type }); } }; useEffect(() => { if (isOpen && stock) { // 预加载两种图表类型的数据 preloadData('timeline'); preloadData('daily'); // 清理图表实例 return () => { if (chartInstanceRef.current) { window.removeEventListener('resize', chartInstanceRef.current.resizeHandler); chartInstanceRef.current.dispose(); chartInstanceRef.current = null; } }; } }, [isOpen, stock, eventTime]); useEffect(() => { if (isOpen && stock) { loadChartData(chartType); } }, [chartType, isOpen, stock]); const loadChartData = async (type) => { if (!stock) return; try { setLoading(true); // 先尝试使用预加载的数据 let response = preloadedData[type]; if (!response) { // 如果预加载数据不存在,则立即请求 let adjustedEventTime = eventTime; if (eventTime) { try { const eventMoment = moment(eventTime); if (eventMoment.isValid() && eventMoment.hour() >= 15) { const nextDay = eventMoment.clone().add(1, 'day'); nextDay.hour(9).minute(30).second(0).millisecond(0); adjustedEventTime = nextDay.format('YYYY-MM-DD HH:mm'); } } catch (e) { logger.warn('StockChartModal', '事件时间解析失败', { eventTime, error: e.message }); } } response = await stockService.getKlineData(stock.stock_code, type, adjustedEventTime); } setChartData(response); // 初始化图表 if (chartRef.current && !chartInstanceRef.current) { const chart = echarts.init(chartRef.current); chart.resizeHandler = () => chart.resize(); window.addEventListener('resize', chart.resizeHandler); chartInstanceRef.current = chart; } if (chartInstanceRef.current) { const option = generateChartOption(response, type, eventTime); chartInstanceRef.current.setOption(option, true); } } catch (err) { logger.error('StockChartModal', 'loadChartData', err, { stockCode: stock?.stock_code, chartType: type }); } finally { setLoading(false); } }; const generateChartOption = (data, type, originalEventTime, adjustedEventTime) => { if (!data || !data.data || data.data.length === 0) { return { title: { text: '暂无数据', left: 'center', top: 'center', textStyle: { color: '#999', fontSize: 16 } } }; } const stockData = data.data; const tradeDate = data.trade_date; // 分时图 if (type === 'timeline') { const times = stockData.map(item => item.time); const prices = stockData.map(item => item.close || item.price); const avgPrices = stockData.map(item => item.avg_price); const volumes = stockData.map(item => item.volume); // 获取昨收盘价作为基准 const prevClose = data.prev_close || (prices.length > 0 ? prices[0] : 0); // 计算涨跌幅数据 const changePercentData = prices.map(price => ((price - prevClose) / prevClose * 100)); const avgChangePercentData = avgPrices.map(avgPrice => ((avgPrice - prevClose) / prevClose * 100)); const currentPrice = prices[prices.length - 1]; const currentChange = ((currentPrice - prevClose) / prevClose * 100); const isUp = currentChange >= 0; const lineColor = isUp ? '#ef5350' : '#26a69a'; // 计算事件标记线位置 let eventMarkLineData = []; if (originalEventTime && times.length > 0) { const eventMoment = moment(originalEventTime); const eventDate = eventMoment.format('YYYY-MM-DD'); const eventTime = eventMoment.format('HH:mm'); if (eventDate === tradeDate) { // 找到最接近的时间点 let nearestIdx = 0; const eventMinutes = eventMoment.hour() * 60 + eventMoment.minute(); for (let i = 0; i < times.length; i++) { const [h, m] = times[i].split(':').map(Number); const timeMinutes = h * 60 + m; const currentDiff = Math.abs(timeMinutes - eventMinutes); const nearestDiff = Math.abs( (times[nearestIdx].split(':').map(Number)[0] * 60 + times[nearestIdx].split(':').map(Number)[1]) - eventMinutes ); if (currentDiff < nearestDiff) { nearestIdx = i; } } eventMarkLineData = [{ name: '事件发生', xAxis: nearestIdx, label: { formatter: '事件发生', position: 'middle', color: '#FFD700', fontSize: 12 }, lineStyle: { color: '#FFD700', type: 'solid', width: 2 } }]; } } return { title: { text: `${stock.stock_name || stock.stock_code} - 分时图`, left: 'center', textStyle: { fontSize: 16, fontWeight: 'bold' } }, tooltip: { trigger: 'axis', axisPointer: { type: 'cross' }, formatter: function(params) { if (!params || params.length === 0) return ''; const point = params[0]; const idx = point.dataIndex; const priceChangePercent = ((prices[idx] - prevClose) / prevClose * 100); const avgChangePercent = ((avgPrices[idx] - prevClose) / prevClose * 100); const priceColor = priceChangePercent >= 0 ? '#ef5350' : '#26a69a'; const avgColor = avgChangePercent >= 0 ? '#ef5350' : '#26a69a'; return `时间:${times[idx]}
现价:¥${prices[idx]?.toFixed(2)} (${priceChangePercent >= 0 ? '+' : ''}${priceChangePercent.toFixed(2)}%)
均价:¥${avgPrices[idx]?.toFixed(2)} (${avgChangePercent >= 0 ? '+' : ''}${avgChangePercent.toFixed(2)}%)
昨收:¥${prevClose?.toFixed(2)}
成交量:${Math.round(volumes[idx]/100)}手`; } }, grid: [ { left: '10%', right: '10%', height: '60%', top: '15%' }, { left: '10%', right: '10%', top: '80%', height: '15%' } ], xAxis: [ { type: 'category', data: times, gridIndex: 0, boundaryGap: false }, { type: 'category', data: times, gridIndex: 1, axisLabel: { show: false } } ], yAxis: [ { type: 'value', gridIndex: 0, scale: false, position: 'left', axisLabel: { formatter: function(value) { return (value >= 0 ? '+' : '') + value.toFixed(2) + '%'; } }, splitLine: { show: true, lineStyle: { color: '#f0f0f0' } } }, { type: 'value', gridIndex: 0, scale: false, position: 'right', axisLabel: { formatter: function(value) { return (value >= 0 ? '+' : '') + value.toFixed(2) + '%'; } } }, { type: 'value', gridIndex: 1, scale: true, axisLabel: { formatter: v => Math.round(v/100) + '手' } } ], dataZoom: [ { type: 'inside', xAxisIndex: [0, 1], start: 0, end: 100 }, { show: true, xAxisIndex: [0, 1], type: 'slider', bottom: '0%' } ], series: [ { name: '分时价', type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: changePercentData, smooth: true, showSymbol: false, lineStyle: { color: lineColor, width: 2 }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: isUp ? 'rgba(239, 83, 80, 0.3)' : 'rgba(38, 166, 154, 0.3)' }, { offset: 1, color: isUp ? 'rgba(239, 83, 80, 0.05)' : 'rgba(38, 166, 154, 0.05)' } ]) }, markLine: { symbol: 'none', data: [ // 昨收盘价基准线 (0%) { yAxis: 0, lineStyle: { color: '#666', type: 'dashed', width: 1.5, opacity: 0.8 }, label: { show: true, formatter: '昨收盘价', position: 'insideEndTop', color: '#666', fontSize: 12 } }, ...eventMarkLineData ], animation: false } }, { name: '均价线', type: 'line', xAxisIndex: 0, yAxisIndex: 1, data: avgChangePercentData, smooth: true, showSymbol: false, lineStyle: { color: '#FFA500', width: 1 } }, { name: '成交量', type: 'bar', xAxisIndex: 1, yAxisIndex: 2, data: volumes, itemStyle: { color: '#b0c4de', opacity: 0.6 } } ] }; } // 日K线 if (type === 'daily') { const dates = stockData.map(item => item.time || item.date); const klineData = stockData.map(item => [item.open, item.close, item.low, item.high]); const volumes = stockData.map(item => item.volume); // 计算事件标记线位置(重要修复) let eventMarkLineData = []; if (originalEventTime && dates.length > 0) { const eventMoment = moment(originalEventTime); const eventDate = eventMoment.format('YYYY-MM-DD'); // 找到事件发生日期或最接近的交易日 let targetIndex = -1; // 1. 先尝试找到完全匹配的日期 targetIndex = dates.findIndex(date => date === eventDate); // 2. 如果没有完全匹配,找到第一个大于等于事件日期的交易日 if (targetIndex === -1) { for (let i = 0; i < dates.length; i++) { if (dates[i] >= eventDate) { targetIndex = i; break; } } } // 3. 如果事件日期晚于所有交易日,则标记在最后一个交易日 if (targetIndex === -1 && eventDate > dates[dates.length - 1]) { targetIndex = dates.length - 1; } // 4. 如果事件日期早于所有交易日,则标记在第一个交易日 if (targetIndex === -1 && eventDate < dates[0]) { targetIndex = 0; } if (targetIndex >= 0) { let labelText = '事件发生'; let labelPosition = 'middle'; // 根据事件时间和交易日的关系调整标签 if (eventDate === dates[targetIndex]) { if (eventMoment.hour() >= 15) { labelText = '事件发生\n(盘后)'; } else if (eventMoment.hour() < 9 || (eventMoment.hour() === 9 && eventMoment.minute() < 30)) { labelText = '事件发生\n(盘前)'; } } else if (eventDate < dates[targetIndex]) { labelText = '事件发生\n(前一日)'; labelPosition = 'start'; } else { labelText = '事件发生\n(影响日)'; labelPosition = 'end'; } eventMarkLineData = [{ name: '事件发生', xAxis: targetIndex, label: { formatter: labelText, position: labelPosition, color: '#FFD700', fontSize: 12, backgroundColor: 'rgba(0,0,0,0.5)', padding: [4, 8], borderRadius: 4 }, lineStyle: { color: '#FFD700', type: 'solid', width: 2 } }]; } } return { title: { text: `${stock.stock_name || stock.stock_code} - 日K线`, left: 'center', textStyle: { fontSize: 16, fontWeight: 'bold' } }, tooltip: { trigger: 'axis', axisPointer: { type: 'cross' }, formatter: function(params) { if (!params || params.length === 0) return ''; const kline = params[0]; const volume = params[1]; if (!kline || !kline.data) return ''; let tooltipHtml = `日期: ${kline.axisValue}
开盘: ¥${kline.data[0]}
收盘: ¥${kline.data[1]}
最低: ¥${kline.data[2]}
最高: ¥${kline.data[3]}`; if (volume && volume.data) { tooltipHtml += `
成交量: ${Math.round(volume.data/100)}手`; } return tooltipHtml; } }, grid: [ { left: '10%', right: '10%', height: '60%' }, { left: '10%', right: '10%', top: '75%', height: '20%' } ], xAxis: [ { type: 'category', data: dates, scale: true, boundaryGap: true, gridIndex: 0 }, { type: 'category', data: dates, gridIndex: 1, axisLabel: { show: false } } ], yAxis: [ { scale: true, splitArea: { show: true }, gridIndex: 0 }, { scale: true, gridIndex: 1, axisLabel: { formatter: (value) => Math.round(value/100) + '手' } } ], dataZoom: [ { type: 'inside', xAxisIndex: [0, 1], start: 70, end: 100 }, { show: true, xAxisIndex: [0, 1], type: 'slider', bottom: '0%', start: 70, end: 100 } ], series: [ { name: 'K线', type: 'candlestick', yAxisIndex: 0, data: klineData, markLine: { symbol: 'none', data: eventMarkLineData, animation: false }, itemStyle: { color: '#ef5350', color0: '#26a69a', borderColor: '#ef5350', borderColor0: '#26a69a' } }, { name: '成交量', type: 'bar', xAxisIndex: 1, yAxisIndex: 1, data: volumes.map((volume, index) => ({ value: volume, itemStyle: { color: stockData[index].close >= stockData[index].open ? '#ef5350' : '#26a69a' } })) } ] }; } return {}; }; if (!stock) return null; return ( {stock.stock_name || stock.stock_code} ({stock.stock_code}) - 股票详情 {chartData && ( {chartData.trade_date} )} {loading && ( 加载图表数据... )}
{stock?.relation_desc && ( 关联描述: {stock.relation_desc} )} {/* 风险提示 */} {process.env.NODE_ENV === 'development' && chartData && ( 调试信息: 数据条数: {chartData.data ? chartData.data.length : 0} 交易日期: {chartData.trade_date} 图表类型: {chartType} 原始事件时间: {eventTime} )} ); }; export default StockChartModal;