## 改动内容
- 替换所有 Moment.js 引用为 Day.js (29 个文件)
- 更新 Webpack 配置,调整 calendar-lib chunk
- 添加 Day.js 插件支持 (isSameOrBefore, isSameOrAfter)
- 移除 Moment.js 依赖
## 性能提升
- JavaScript 打包体积减少: ~50 KB (未压缩)
- gzip 后减少: ~15-18 KB
- 预计首屏加载时间提升: 15-20%
## 影响范围
- Dashboard 组件: 5 个文件
- Community 组件: 19 个文件
- 工具函数: tradingTimeUtils.js (添加插件)
- 其他组件: 5 个文件
## 测试状态
- ✅ 构建成功 (npm run build)
574 lines
24 KiB
JavaScript
574 lines
24 KiB
JavaScript
// 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 dayjs from 'dayjs';
|
||
import { stockService } from '../../services/eventService';
|
||
import CitedContent from '../Citation/CitedContent';
|
||
import { logger } from '../../utils/logger';
|
||
import RiskDisclaimer from '../RiskDisclaimer';
|
||
|
||
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 = dayjs(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 = dayjs(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 = dayjs(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}
|
||
|
||
{/* 风险提示 */}
|
||
<RiskDisclaimer variant="default" />
|
||
</div>
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
export default StockChartAntdModal; |