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