Initial commit

This commit is contained in:
2025-10-11 11:55:25 +08:00
parent 467dad8449
commit 8107dee8d3
2879 changed files with 610575 additions and 0 deletions

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