// src/components/StockChart/StockChartAntdModal.js - Antd版本的股票图表组件 import React, { useState, useEffect, useRef } from 'react'; import { Modal, Button, Spin, Typography } from 'antd'; import ReactECharts from 'echarts-for-react'; import * as echarts from 'echarts'; import moment from 'moment'; import { stockService } from '../../services/eventService'; import CitedContent from '../Citation/CitedContent'; const { Text } = Typography; const StockChartAntdModal = ({ open = false, onCancel, stock, eventTime, fixed = false, width = 800 }) => { const chartRef = useRef(null); const chartInstanceRef = useRef(null); const [activeChartType, setActiveChartType] = useState('timeline'); const [loading, setLoading] = useState(false); const [chartData, setChartData] = useState(null); const [preloadedData, setPreloadedData] = useState({}); // 预加载数据 const preloadData = async (type) => { if (!stock?.stock_code || preloadedData[type]) return; try { // 统一的事件时间处理逻辑:盘后事件推到次日开盘 let adjustedEventTime = eventTime; if (eventTime) { try { const eventMoment = moment(eventTime); if (eventMoment.isValid()) { // 如果是15:00之后的事件,推到下一个交易日的9:30 if (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) { console.warn('事件时间解析失败:', e); } } const response = await stockService.getKlineData(stock.stock_code, type, adjustedEventTime); setPreloadedData(prev => ({...prev, [type]: response})); } catch (err) { console.error(`预加载${type}数据失败:`, err); } }; // 预加载数据的effect useEffect(() => { if (open && stock?.stock_code) { // 预加载两种图表类型的数据 preloadData('timeline'); preloadData('daily'); } }, [open, stock?.stock_code, eventTime]); // 加载图表数据 useEffect(() => { const loadChartData = async () => { if (!stock?.stock_code) return; try { setLoading(true); // 先尝试使用预加载的数据 let data = preloadedData[activeChartType]; if (!data) { // 如果预加载数据不存在,则立即请求 let adjustedEventTime = eventTime; if (eventTime) { try { const eventMoment = moment(eventTime); if (eventMoment.isValid()) { // 如果是15:00之后的事件,推到下一个交易日的9:30 if (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) { console.warn('事件时间解析失败:', e); } } data = await stockService.getKlineData(stock.stock_code, activeChartType, adjustedEventTime); } setChartData(data); } catch (error) { console.error('加载图表数据失败:', error); } finally { setLoading(false); } }; if (stock && stock.stock_code) { loadChartData(); } }, [stock?.stock_code, activeChartType, eventTime]); // 生成图表配置 const getChartOption = () => { if (!chartData || !chartData.data) { return { title: { text: '暂无数据', left: 'center' }, xAxis: { type: 'category', data: [] }, yAxis: { type: 'value' }, series: [{ data: [], type: 'line' }] }; } const data = chartData.data; const tradeDate = chartData.trade_date; // 处理数据格式 let times = []; let prices = []; let opens = []; let highs = []; let lows = []; let closes = []; let volumes = []; if (Array.isArray(data)) { times = data.map(item => item.time || item.date || item.timestamp); prices = data.map(item => item.close || item.price || item.value); opens = data.map(item => item.open); highs = data.map(item => item.high); lows = data.map(item => item.low); closes = data.map(item => item.close); volumes = data.map(item => item.volume); } else if (data.times && data.prices) { times = data.times; prices = data.prices; opens = data.opens || []; highs = data.highs || []; lows = data.lows || []; closes = data.closes || []; volumes = data.volumes || []; } // 生成K线数据结构 const klineData = times.map((t, i) => [opens[i], closes[i], lows[i], highs[i]]); // 计算事件标记线位置 let markLineData = []; if (eventTime && times.length > 0) { const eventMoment = moment(eventTime); const eventDate = eventMoment.format('YYYY-MM-DD'); if (activeChartType === 'timeline') { // 分时图:在相同交易日内定位具体时间 if (eventDate === tradeDate) { const eventTime = eventMoment.format('HH:mm'); 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; } } markLineData = [{ name: '事件发生', xAxis: nearestIdx, label: { formatter: '事件发生', position: 'middle', color: '#FFD700', fontSize: 12 }, lineStyle: { color: '#FFD700', type: 'solid', width: 2 } }]; } } else if (activeChartType === 'daily') { // 日K线:定位到交易日 let targetIndex = -1; // 1. 先尝试找到完全匹配的日期 targetIndex = times.findIndex(time => time === eventDate); // 2. 如果没有完全匹配,找到第一个大于等于事件日期的交易日 if (targetIndex === -1) { for (let i = 0; i < times.length; i++) { if (times[i] >= eventDate) { targetIndex = i; break; } } } // 3. 如果事件日期晚于所有交易日,则标记在最后一个交易日 if (targetIndex === -1 && eventDate > times[times.length - 1]) { targetIndex = times.length - 1; } // 4. 如果事件日期早于所有交易日,则标记在第一个交易日 if (targetIndex === -1 && eventDate < times[0]) { targetIndex = 0; } if (targetIndex >= 0) { let labelText = '事件发生'; let labelPosition = 'middle'; // 根据事件时间和交易日的关系调整标签 if (eventDate === times[targetIndex]) { if (eventMoment.hour() >= 15) { labelText = '事件发生\n(盘后)'; } else if (eventMoment.hour() < 9 || (eventMoment.hour() === 9 && eventMoment.minute() < 30)) { labelText = '事件发生\n(盘前)'; } } else if (eventDate < times[targetIndex]) { labelText = '事件发生\n(前一日)'; labelPosition = 'start'; } else { labelText = '事件发生\n(影响日)'; labelPosition = 'end'; } markLineData = [{ 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 } }]; } } } // 分时图 if (activeChartType === 'timeline') { const avgPrices = data.map(item => item.avg_price); // 获取昨收盘价作为基准 const prevClose = chartData.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'; 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) { const d = params[0]?.dataIndex ?? 0; const priceChangePercent = ((prices[d] - prevClose) / prevClose * 100); const avgChangePercent = ((avgPrices[d] - prevClose) / prevClose * 100); const priceColor = priceChangePercent >= 0 ? '#ef5350' : '#26a69a'; const avgColor = avgChangePercent >= 0 ? '#ef5350' : '#26a69a'; return `时间:${times[d]}
现价:¥${prices[d]?.toFixed(2)} (${priceChangePercent >= 0 ? '+' : ''}${priceChangePercent.toFixed(2)}%)
均价:¥${avgPrices[d]?.toFixed(2)} (${avgChangePercent >= 0 ? '+' : ''}${avgChangePercent.toFixed(2)}%)
昨收:¥${prevClose?.toFixed(2)}
成交量:${Math.round(volumes[d]/100)}手`; } }, grid: [ { left: '10%', right: '10%', height: '50%', top: '15%' }, { left: '10%', right: '10%', top: '70%', height: '20%' } ], 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 } }, ...markLineData ], 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 (activeChartType === 'daily') { 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) { const kline = params[0]; const volume = params[1]; if (!kline || !kline.data) return ''; let tooltipHtml = `日期: ${times[kline.dataIndex]}
开盘: ¥${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: times, scale: true, boundaryGap: true, gridIndex: 0 }, { type: 'category', gridIndex: 1, data: times, 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: markLineData, 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: closes[index] >= opens[index] ? '#ef5350' : '#26a69a' } })) } ] }; } }; return (
{/* 图表类型切换按钮 */}
{/* 图表容器 */}
{loading ? (
) : ( { setTimeout(() => chart.resize(), 50); }} /> )}
{/* 关联描述 */} {stock?.relation_desc?.data ? ( // 使用引用组件(带研报来源) ) : stock?.relation_desc ? ( // 降级显示(无引用数据)
关联描述: {stock.relation_desc}(AI合成)
) : null} {/* 调试信息 */} {process.env.NODE_ENV === 'development' && chartData && (
调试信息: 数据条数: {chartData.data ? chartData.data.length : 0}
交易日期: {chartData.trade_date}
图表类型: {activeChartType}
原始事件时间: {eventTime}
)}
); }; export default StockChartAntdModal;