Files
vf_react/src/components/StockChart/StockChartAntdModal.js

584 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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';
import { logger } from '../../utils/logger';
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) {
logger.warn('StockChartAntdModal', '事件时间解析失败', {
eventTime,
error: e.message
});
}
}
const response = await stockService.getKlineData(stock.stock_code, type, adjustedEventTime);
setPreloadedData(prev => ({...prev, [type]: response}));
logger.debug('StockChartAntdModal', '数据预加载成功', {
stockCode: stock.stock_code,
type,
dataLength: response?.data?.length || 0
});
} catch (err) {
logger.error('StockChartAntdModal', 'preloadData', err, {
stockCode: stock?.stock_code,
type
});
}
};
// 预加载数据的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) {
logger.warn('StockChartAntdModal', '事件时间解析失败', {
eventTime,
error: e.message
});
}
}
data = await stockService.getKlineData(stock.stock_code, activeChartType, adjustedEventTime);
}
setChartData(data);
logger.debug('StockChartAntdModal', '图表数据加载成功', {
stockCode: stock.stock_code,
chartType: activeChartType,
dataLength: data?.data?.length || 0
});
} catch (error) {
logger.error('StockChartAntdModal', 'loadChartData', error, {
stockCode: stock?.stock_code,
chartType: activeChartType
});
} 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]}<br/>现价:<span style="color: ${priceColor}">¥${prices[d]?.toFixed(2)} (${priceChangePercent >= 0 ? '+' : ''}${priceChangePercent.toFixed(2)}%)</span><br/>均价:<span style="color: ${avgColor}">¥${avgPrices[d]?.toFixed(2)} (${avgChangePercent >= 0 ? '+' : ''}${avgChangePercent.toFixed(2)}%)</span><br/>昨收:¥${prevClose?.toFixed(2)}<br/>成交量:${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]}<br/>开盘: ¥${kline.data[0]}<br/>收盘: ¥${kline.data[1]}<br/>最低: ¥${kline.data[2]}<br/>最高: ¥${kline.data[3]}`;
if (volume && volume.data) {
tooltipHtml += `<br/>成交量: ${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 (
<Modal
open={open}
title={`${stock?.stock_name || stock?.stock_code} (${stock?.stock_code}) - 股票详情`}
footer={null}
onCancel={onCancel}
width={width}
style={{ position: fixed ? 'fixed' : 'absolute', left: fixed ? 50 : 0, top: fixed ? 50 : 80, zIndex: 2000 }}
mask={false}
destroyOnClose={true}
bodyStyle={{ maxHeight: 'calc(90vh - 120px)', overflowY: 'auto', padding: '16px' }}
>
<div style={{ width: '100%' }}>
{/* 图表类型切换按钮 */}
<div style={{ marginBottom: 16, display: 'flex', gap: 8 }}>
<Button
type={activeChartType === 'timeline' ? 'primary' : 'default'}
onClick={() => setActiveChartType('timeline')}
>
分时图
</Button>
<Button
type={activeChartType === 'daily' ? 'primary' : 'default'}
onClick={() => setActiveChartType('daily')}
>
日K线
</Button>
</div>
{/* 图表容器 */}
<div style={{ height: '400px', width: '100%' }}>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Spin size="large" />
</div>
) : (
<ReactECharts
option={getChartOption()}
style={{ height: '100%', width: '100%' }}
notMerge={true}
lazyUpdate={true}
onChartReady={(chart) => {
setTimeout(() => chart.resize(), 50);
}}
/>
)}
</div>
{/* 关联描述 */}
{stock?.relation_desc?.data ? (
// 使用引用组件(带研报来源)
<CitedContent
data={stock.relation_desc}
title="关联描述"
containerStyle={{ marginTop: 16 }}
/>
) : stock?.relation_desc ? (
// 降级显示(无引用数据)
<div style={{ marginTop: 16, padding: 16, backgroundColor: '#f5f5f5', borderRadius: 6 }}>
<Text strong style={{ display: 'block', marginBottom: 8 }}>关联描述:</Text>
<Text>{stock.relation_desc}AI合成</Text>
</div>
) : null}
{/* 调试信息 */}
{process.env.NODE_ENV === 'development' && chartData && (
<div style={{ marginTop: 16, padding: 12, backgroundColor: '#f0f0f0', borderRadius: 6, fontSize: '12px' }}>
<Text strong style={{ display: 'block', marginBottom: 4 }}>调试信息:</Text>
<Text>数据条数: {chartData.data ? chartData.data.length : 0}</Text>
<br />
<Text>交易日期: {chartData.trade_date}</Text>
<br />
<Text>图表类型: {activeChartType}</Text>
<br />
<Text>原始事件时间: {eventTime}</Text>
</div>
)}
</div>
</Modal>
);
};
export default StockChartAntdModal;