Initial commit
This commit is contained in:
552
src/components/StockChart/StockChartAntdModal.js
Normal file
552
src/components/StockChart/StockChartAntdModal.js
Normal file
@@ -0,0 +1,552 @@
|
||||
// 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';
|
||||
|
||||
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]}<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 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 调试信息 */}
|
||||
{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;
|
||||
Reference in New Issue
Block a user