MarketDataView (股票行情): - 初始只加载 summary + tradeData(2个接口) - funding/bigDeal/unusual/pledge 数据在切换 Tab 时按需加载 - 新增 loadDataByType 方法支持懒加载 FinancialPanorama (财务全景): - 初始只加载 stockInfo + metrics + comparison + mainBusiness(4个接口) - 从9个接口优化到4个接口 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
280 lines
10 KiB
JavaScript
280 lines
10 KiB
JavaScript
// src/components/Charts/Stock/MiniTimelineChart.js
|
||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||
import ReactECharts from 'echarts-for-react';
|
||
import { echarts } from '@lib/echarts';
|
||
import dayjs from 'dayjs';
|
||
import {
|
||
fetchKlineData,
|
||
getCacheKey,
|
||
klineDataCache,
|
||
batchPendingRequests
|
||
} from '@utils/stock/klineDataCache';
|
||
|
||
/**
|
||
* 迷你分时图组件
|
||
* 显示股票的分时价格走势,支持事件时间标记
|
||
*
|
||
* @param {string} stockCode - 股票代码
|
||
* @param {string} eventTime - 事件时间(可选)
|
||
* @param {Function} onClick - 点击回调(可选)
|
||
* @param {Array} preloadedData - 预加载的K线数据(可选,由父组件批量加载后传入)
|
||
* @param {boolean} loading - 外部加载状态(可选)
|
||
* @returns {JSX.Element}
|
||
*/
|
||
const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eventTime, onClick, preloadedData, loading: externalLoading }) {
|
||
const [data, setData] = useState([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const mountedRef = useRef(true);
|
||
const loadedRef = useRef(false); // 标记是否已加载过数据
|
||
const dataFetchedRef = useRef(false); // 防止重复请求的标记
|
||
|
||
// 稳定的事件时间,避免因为格式化导致的重复请求
|
||
const stableEventTime = useMemo(() => {
|
||
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||
}, [eventTime]);
|
||
|
||
useEffect(() => {
|
||
mountedRef.current = true;
|
||
return () => {
|
||
mountedRef.current = false;
|
||
};
|
||
}, []);
|
||
|
||
// 从缓存或API获取数据的函数
|
||
const loadData = useCallback(() => {
|
||
if (!stockCode || !mountedRef.current) return false;
|
||
|
||
// 检查缓存(使用 'minute' 类型)
|
||
const cacheKey = getCacheKey(stockCode, stableEventTime, 'minute');
|
||
const cachedData = klineDataCache.get(cacheKey);
|
||
|
||
// 如果有缓存数据(包括空数组,表示已请求过但无数据),直接使用
|
||
if (cachedData !== undefined) {
|
||
setData(cachedData || []);
|
||
setLoading(false);
|
||
loadedRef.current = true;
|
||
dataFetchedRef.current = true;
|
||
return true; // 表示数据已加载(或确认无数据)
|
||
}
|
||
return false; // 表示需要请求
|
||
}, [stockCode, stableEventTime]);
|
||
|
||
useEffect(() => {
|
||
if (!stockCode) {
|
||
setData([]);
|
||
loadedRef.current = false;
|
||
dataFetchedRef.current = false;
|
||
return;
|
||
}
|
||
|
||
// 优先使用预加载的数据(由父组件批量请求后传入)
|
||
if (preloadedData !== undefined) {
|
||
setData(preloadedData || []);
|
||
setLoading(false);
|
||
loadedRef.current = true;
|
||
dataFetchedRef.current = true;
|
||
return;
|
||
}
|
||
|
||
// 如果外部正在加载,显示loading状态,不发起单独请求
|
||
// 父组件(StockTable)会通过 preloadedData 传入数据
|
||
if (externalLoading) {
|
||
setLoading(true);
|
||
return;
|
||
}
|
||
|
||
// 如果已经请求过数据,不再重复请求
|
||
if (dataFetchedRef.current) {
|
||
return;
|
||
}
|
||
|
||
// 尝试从缓存加载
|
||
if (loadData()) {
|
||
return;
|
||
}
|
||
|
||
// 检查批量请求的函数
|
||
const checkBatchAndLoad = () => {
|
||
// 再次检查缓存(批量请求可能已完成,使用 'minute' 类型)
|
||
const cacheKey = getCacheKey(stockCode, stableEventTime, 'minute');
|
||
const cachedData = klineDataCache.get(cacheKey);
|
||
if (cachedData !== undefined) {
|
||
setData(cachedData || []);
|
||
setLoading(false);
|
||
loadedRef.current = true;
|
||
dataFetchedRef.current = true;
|
||
return true; // 从缓存加载成功
|
||
}
|
||
|
||
const batchKey = `${stableEventTime || 'today'}|minute`;
|
||
const pendingBatch = batchPendingRequests.get(batchKey);
|
||
|
||
if (pendingBatch) {
|
||
// 等待批量请求完成后再从缓存读取
|
||
setLoading(true);
|
||
dataFetchedRef.current = true;
|
||
pendingBatch.then(() => {
|
||
if (mountedRef.current) {
|
||
const newCachedData = klineDataCache.get(cacheKey);
|
||
setData(newCachedData || []);
|
||
setLoading(false);
|
||
loadedRef.current = true;
|
||
}
|
||
}).catch(() => {
|
||
if (mountedRef.current) {
|
||
setData([]);
|
||
setLoading(false);
|
||
}
|
||
});
|
||
return true; // 找到批量请求
|
||
}
|
||
return false; // 没有批量请求
|
||
};
|
||
|
||
// 先立即检查一次
|
||
if (checkBatchAndLoad()) {
|
||
return;
|
||
}
|
||
|
||
// 延迟检查(等待批量请求启动)
|
||
// 注意:如果父组件正在批量加载,会传入 externalLoading=true,不会执行到这里
|
||
setLoading(true);
|
||
const timeoutId = setTimeout(() => {
|
||
if (!mountedRef.current || dataFetchedRef.current) return;
|
||
|
||
// 再次检查批量请求
|
||
if (checkBatchAndLoad()) {
|
||
return;
|
||
}
|
||
|
||
// 仍然没有批量请求,发起单独请求(备用方案 - 用于非批量加载场景)
|
||
dataFetchedRef.current = true;
|
||
|
||
// 使用 'minute' 类型获取分钟线数据
|
||
fetchKlineData(stockCode, stableEventTime, 'minute')
|
||
.then((result) => {
|
||
if (mountedRef.current) {
|
||
setData(result);
|
||
setLoading(false);
|
||
loadedRef.current = true;
|
||
}
|
||
})
|
||
.catch(() => {
|
||
if (mountedRef.current) {
|
||
setData([]);
|
||
setLoading(false);
|
||
loadedRef.current = true;
|
||
}
|
||
});
|
||
}, 200); // 延迟 200ms 等待批量请求(增加等待时间)
|
||
|
||
return () => clearTimeout(timeoutId);
|
||
}, [stockCode, stableEventTime, loadData, preloadedData, externalLoading]); // 添加 preloadedData 和 externalLoading 依赖
|
||
|
||
const chartOption = useMemo(() => {
|
||
const prices = data.map(item => item.close ?? item.price).filter(v => typeof v === 'number');
|
||
const times = data.map(item => item.time);
|
||
const hasData = prices.length > 0;
|
||
|
||
if (!hasData) {
|
||
return {
|
||
title: {
|
||
text: loading ? '加载中...' : '无数据',
|
||
left: 'center',
|
||
top: 'middle',
|
||
textStyle: { color: '#999', fontSize: 10 }
|
||
}
|
||
};
|
||
}
|
||
|
||
const minPrice = Math.min(...prices);
|
||
const maxPrice = Math.max(...prices);
|
||
const isUp = prices[prices.length - 1] >= prices[0];
|
||
const lineColor = isUp ? '#ef5350' : '#26a69a';
|
||
|
||
// 计算事件时间对应的分时索引
|
||
let eventMarkLineData = [];
|
||
if (stableEventTime && Array.isArray(times) && times.length > 0) {
|
||
try {
|
||
const eventMinute = dayjs(stableEventTime, 'YYYY-MM-DD HH:mm').format('HH:mm');
|
||
const parseMinuteTime = (timeStr) => {
|
||
const [h, m] = String(timeStr).split(':').map(Number);
|
||
return h * 60 + m;
|
||
};
|
||
const eventMin = parseMinuteTime(eventMinute);
|
||
let nearestIdx = 0;
|
||
for (let i = 1; i < times.length; i++) {
|
||
if (Math.abs(parseMinuteTime(times[i]) - eventMin) < Math.abs(parseMinuteTime(times[nearestIdx]) - eventMin)) {
|
||
nearestIdx = i;
|
||
}
|
||
}
|
||
eventMarkLineData.push({
|
||
xAxis: nearestIdx,
|
||
lineStyle: { color: '#FFD700', type: 'solid', width: 1.5 },
|
||
label: { show: false }
|
||
});
|
||
} catch (e) {
|
||
// 忽略事件时间解析异常
|
||
}
|
||
}
|
||
|
||
return {
|
||
grid: { left: 2, right: 2, top: 2, bottom: 2, containLabel: false },
|
||
xAxis: { type: 'category', data: times, show: false, boundaryGap: false },
|
||
yAxis: { type: 'value', show: false, min: minPrice * 0.995, max: maxPrice * 1.005, scale: true },
|
||
series: [{
|
||
data: prices,
|
||
type: 'line',
|
||
smooth: true,
|
||
symbol: 'none',
|
||
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: {
|
||
silent: true,
|
||
symbol: 'none',
|
||
label: { show: false },
|
||
data: [
|
||
...(prices.length ? [{ yAxis: prices[0], lineStyle: { color: '#aaa', type: 'dashed', width: 1 } }] : []),
|
||
...eventMarkLineData
|
||
]
|
||
}
|
||
}],
|
||
tooltip: { show: false },
|
||
animation: false
|
||
};
|
||
}, [data, loading, stableEventTime]);
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
minHeight: '35px',
|
||
cursor: onClick ? 'pointer' : 'default'
|
||
}}
|
||
onClick={onClick}
|
||
>
|
||
<ReactECharts
|
||
option={chartOption}
|
||
style={{ width: '100%', height: '100%' }}
|
||
notMerge={true}
|
||
lazyUpdate={true}
|
||
/>
|
||
</div>
|
||
);
|
||
}, (prevProps, nextProps) => {
|
||
// 自定义比较函数
|
||
return prevProps.stockCode === nextProps.stockCode &&
|
||
prevProps.eventTime === nextProps.eventTime &&
|
||
prevProps.onClick === nextProps.onClick &&
|
||
prevProps.preloadedData === nextProps.preloadedData &&
|
||
prevProps.loading === nextProps.loading;
|
||
});
|
||
|
||
export default MiniTimelineChart;
|