558 lines
24 KiB
Plaintext
558 lines
24 KiB
Plaintext
// src/components/StockChart/StockChartModal.js - 统一的股票图表组件
|
||
import React, { useState, useEffect, useRef, useMemo } 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 dayjs from 'dayjs';
|
||
import { stockService } from '../../services/eventService';
|
||
import { logger } from '../../utils/logger';
|
||
import RiskDisclaimer from '../RiskDisclaimer';
|
||
import { RelationDescription } from '../StockRelation';
|
||
|
||
const StockChartModal = ({
|
||
isOpen,
|
||
onClose,
|
||
stock,
|
||
eventTime,
|
||
isChakraUI = true, // 是否使用Chakra UI,默认true;如果false则使用Antd
|
||
size = "6xl",
|
||
initialChartType = 'timeline' // 初始图表类型(timeline/daily)
|
||
}) => {
|
||
const chartRef = useRef(null);
|
||
const chartInstanceRef = useRef(null);
|
||
const [chartType, setChartType] = useState(initialChartType);
|
||
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 = dayjs(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 = dayjs(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 = dayjs(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]}<br/>现价:<span style="color: ${priceColor}">¥${prices[idx]?.toFixed(2)} (${priceChangePercent >= 0 ? '+' : ''}${priceChangePercent.toFixed(2)}%)</span><br/>均价:<span style="color: ${avgColor}">¥${avgPrices[idx]?.toFixed(2)} (${avgChangePercent >= 0 ? '+' : ''}${avgChangePercent.toFixed(2)}%)</span><br/>昨收:¥${prevClose?.toFixed(2)}<br/>成交量:${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 = dayjs(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}<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: 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 (
|
||
<Modal isOpen={isOpen} onClose={onClose} size={size}>
|
||
<ModalOverlay />
|
||
<ModalContent maxW="90vw" maxH="90vh" overflow="hidden">
|
||
<ModalHeader pb={4}>
|
||
<VStack align="flex-start" spacing={2}>
|
||
<HStack>
|
||
<Text fontSize="lg" fontWeight="bold">
|
||
{stock.stock_name || stock.stock_code} ({stock.stock_code}) - 股票详情
|
||
</Text>
|
||
{chartData && (
|
||
<Badge colorScheme="blue">{chartData.trade_date}</Badge>
|
||
)}
|
||
</HStack>
|
||
<ButtonGroup size="sm">
|
||
<Button
|
||
variant={chartType === 'timeline' ? 'solid' : 'outline'}
|
||
onClick={() => setChartType('timeline')}
|
||
colorScheme="blue"
|
||
>
|
||
分时线
|
||
</Button>
|
||
<Button
|
||
variant={chartType === 'daily' ? 'solid' : 'outline'}
|
||
onClick={() => setChartType('daily')}
|
||
colorScheme="blue"
|
||
>
|
||
日K线
|
||
</Button>
|
||
</ButtonGroup>
|
||
</VStack>
|
||
</ModalHeader>
|
||
<ModalCloseButton />
|
||
<ModalBody p={0} overflowY="auto" maxH="calc(90vh - 120px)">
|
||
{/* 图表区域 */}
|
||
<Box h="500px" w="100%" position="relative">
|
||
{loading && (
|
||
<Flex
|
||
position="absolute"
|
||
top="0" left="0" right="0" bottom="0"
|
||
bg="rgba(255, 255, 255, 0.7)"
|
||
zIndex="10"
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
>
|
||
<VStack spacing={4}>
|
||
<CircularProgress isIndeterminate color="blue.300" />
|
||
<Text>加载图表数据...</Text>
|
||
</VStack>
|
||
</Flex>
|
||
)}
|
||
<div ref={chartRef} style={{ height: '100%', width: '100%', minHeight: '500px' }} />
|
||
</Box>
|
||
|
||
{/* 关联描述 */}
|
||
<RelationDescription relationDesc={stock?.relation_desc} />
|
||
|
||
{/* 风险提示 */}
|
||
<Box px={4} pb={4}>
|
||
<RiskDisclaimer variant="default" />
|
||
</Box>
|
||
</ModalBody>
|
||
</ModalContent>
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
export default StockChartModal; |