refactor: 重构 StockDetailPanel 目录结构,清理未使用代码
- 将 MiniTimelineChart 和 useEventStocks 迁移到 src/components/Charts/Stock/ - 更新 DynamicNewsDetailPanel 和 StockListItem 的导入路径 - 删除未使用的 SearchBox.js 和 useSearchEvents.js(约 300 行) - 建立统一的股票图表组件目录结构 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
278
src/components/Charts/Stock/MiniTimelineChart.js
Normal file
278
src/components/Charts/Stock/MiniTimelineChart.js
Normal file
@@ -0,0 +1,278 @@
|
||||
// src/components/Charts/Stock/MiniTimelineChart.js
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from '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;
|
||||
|
||||
// 检查缓存
|
||||
const cacheKey = getCacheKey(stockCode, stableEventTime);
|
||||
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 = () => {
|
||||
// 再次检查缓存(批量请求可能已完成)
|
||||
const cacheKey = getCacheKey(stockCode, stableEventTime);
|
||||
const cachedData = klineDataCache.get(cacheKey);
|
||||
if (cachedData !== undefined) {
|
||||
setData(cachedData || []);
|
||||
setLoading(false);
|
||||
loadedRef.current = true;
|
||||
dataFetchedRef.current = true;
|
||||
return true; // 从缓存加载成功
|
||||
}
|
||||
|
||||
const batchKey = `${stableEventTime || 'today'}|timeline`;
|
||||
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;
|
||||
|
||||
fetchKlineData(stockCode, stableEventTime)
|
||||
.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;
|
||||
4
src/components/Charts/Stock/hooks/index.js
Normal file
4
src/components/Charts/Stock/hooks/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
// src/components/Charts/Stock/hooks/index.js
|
||||
// 股票图表 Hooks 统一导出
|
||||
|
||||
export { useEventStocks } from './useEventStocks';
|
||||
173
src/components/Charts/Stock/hooks/useEventStocks.js
Normal file
173
src/components/Charts/Stock/hooks/useEventStocks.js
Normal file
@@ -0,0 +1,173 @@
|
||||
// src/components/Charts/Stock/hooks/useEventStocks.js
|
||||
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
|
||||
import { useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
fetchEventStocks,
|
||||
fetchStockQuotes,
|
||||
fetchEventDetail,
|
||||
fetchHistoricalEvents,
|
||||
fetchChainAnalysis,
|
||||
fetchExpectationScore
|
||||
} from '@store/slices/stockSlice';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
/**
|
||||
* 事件股票数据 Hook
|
||||
* 封装事件相关的所有数据加载逻辑
|
||||
*
|
||||
* @param {string} eventId - 事件ID
|
||||
* @param {string} eventTime - 事件时间
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {boolean} options.autoLoad - 是否自动加载数据(默认true)
|
||||
* @param {boolean} options.autoLoadQuotes - 是否自动加载行情数据(默认true,设为false可延迟到展开时加载)
|
||||
* @returns {Object} 事件数据和加载状态
|
||||
*/
|
||||
export const useEventStocks = (eventId, eventTime, { autoLoad = true, autoLoadQuotes = true } = {}) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// 从 Redux 获取数据
|
||||
const stocks = useSelector(state =>
|
||||
eventId ? (state.stock.eventStocksCache[eventId] || []) : [],
|
||||
shallowEqual // 防止不必要的引用变化
|
||||
);
|
||||
const quotes = useSelector(state => state.stock.quotes, shallowEqual);
|
||||
const eventDetail = useSelector(state =>
|
||||
eventId ? state.stock.eventDetailsCache[eventId] : null
|
||||
);
|
||||
const historicalEvents = useSelector(state =>
|
||||
eventId ? (state.stock.historicalEventsCache[eventId] || []) : [],
|
||||
shallowEqual // 防止不必要的引用变化
|
||||
);
|
||||
const chainAnalysis = useSelector(state =>
|
||||
eventId ? state.stock.chainAnalysisCache[eventId] : null
|
||||
);
|
||||
const expectationScore = useSelector(state =>
|
||||
eventId ? state.stock.expectationScores[eventId] : null
|
||||
);
|
||||
|
||||
// 加载状态
|
||||
const loading = useSelector(state => state.stock.loading, shallowEqual);
|
||||
|
||||
// 拆分加载函数 - 相关股票数据
|
||||
const loadStocksData = useCallback(() => {
|
||||
if (!eventId) return;
|
||||
logger.debug('useEventStocks', '加载股票数据', { eventId });
|
||||
dispatch(fetchEventStocks({ eventId }));
|
||||
}, [dispatch, eventId]);
|
||||
|
||||
// 拆分加载函数 - 历史事件数据
|
||||
const loadHistoricalData = useCallback(() => {
|
||||
if (!eventId) return;
|
||||
logger.debug('useEventStocks', '加载历史事件数据', { eventId });
|
||||
dispatch(fetchHistoricalEvents({ eventId }));
|
||||
dispatch(fetchExpectationScore({ eventId }));
|
||||
}, [dispatch, eventId]);
|
||||
|
||||
// 拆分加载函数 - 传导链分析数据
|
||||
const loadChainAnalysis = useCallback(() => {
|
||||
if (!eventId) return;
|
||||
logger.debug('useEventStocks', '加载传导链数据', { eventId });
|
||||
dispatch(fetchChainAnalysis({ eventId }));
|
||||
}, [dispatch, eventId]);
|
||||
|
||||
// 加载所有数据(保留用于兼容性)
|
||||
const loadAllData = useCallback(() => {
|
||||
if (!eventId) {
|
||||
logger.warn('useEventStocks', 'eventId 为空,跳过数据加载');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('useEventStocks', '开始加载事件所有数据', { eventId });
|
||||
|
||||
// 并发加载所有数据
|
||||
dispatch(fetchEventDetail({ eventId }));
|
||||
loadStocksData();
|
||||
loadHistoricalData();
|
||||
loadChainAnalysis();
|
||||
}, [dispatch, eventId, loadStocksData, loadHistoricalData, loadChainAnalysis]);
|
||||
|
||||
// 强制刷新所有数据
|
||||
const refreshAllData = useCallback(() => {
|
||||
if (!eventId) return;
|
||||
|
||||
logger.debug('useEventStocks', '强制刷新事件数据', { eventId });
|
||||
|
||||
dispatch(fetchEventStocks({ eventId, forceRefresh: true }));
|
||||
dispatch(fetchEventDetail({ eventId, forceRefresh: true }));
|
||||
dispatch(fetchHistoricalEvents({ eventId, forceRefresh: true }));
|
||||
dispatch(fetchChainAnalysis({ eventId, forceRefresh: true }));
|
||||
dispatch(fetchExpectationScore({ eventId }));
|
||||
}, [dispatch, eventId]);
|
||||
|
||||
// 只刷新行情数据
|
||||
const refreshQuotes = useCallback(() => {
|
||||
if (stocks.length === 0) return;
|
||||
|
||||
const codes = stocks.map(s => s.stock_code);
|
||||
logger.debug('useEventStocks', '刷新行情数据', {
|
||||
stockCount: codes.length,
|
||||
eventTime
|
||||
});
|
||||
|
||||
dispatch(fetchStockQuotes({ codes, eventTime }));
|
||||
}, [dispatch, stocks, eventTime]);
|
||||
|
||||
// 自动加载事件数据(可通过 autoLoad 参数控制)
|
||||
useEffect(() => {
|
||||
if (eventId && autoLoad) {
|
||||
logger.debug('useEventStocks', '自动加载已启用,加载所有数据', { eventId, autoLoad });
|
||||
loadAllData();
|
||||
} else if (eventId && !autoLoad) {
|
||||
logger.debug('useEventStocks', '自动加载已禁用,等待手动触发', { eventId, autoLoad });
|
||||
// 禁用自动加载时,不加载任何数据
|
||||
}
|
||||
}, [eventId, autoLoad, loadAllData]); // 添加 loadAllData 依赖
|
||||
|
||||
// 自动加载行情数据(可通过 autoLoadQuotes 参数控制)
|
||||
useEffect(() => {
|
||||
if (stocks.length > 0 && autoLoadQuotes) {
|
||||
const codes = stocks.map(s => s.stock_code);
|
||||
logger.debug('useEventStocks', '自动加载行情数据', {
|
||||
stockCount: codes.length,
|
||||
eventTime
|
||||
});
|
||||
dispatch(fetchStockQuotes({ codes, eventTime }));
|
||||
}
|
||||
}, [stocks, eventTime, autoLoadQuotes, dispatch]); // 直接使用 stocks 而不是 refreshQuotes
|
||||
|
||||
// 计算股票行情合并数据
|
||||
const stocksWithQuotes = useMemo(() => {
|
||||
return stocks.map(stock => ({
|
||||
...stock,
|
||||
quote: quotes[stock.stock_code] || null
|
||||
}));
|
||||
}, [stocks, quotes]);
|
||||
|
||||
return {
|
||||
// 数据
|
||||
stocks,
|
||||
stocksWithQuotes,
|
||||
quotes,
|
||||
eventDetail,
|
||||
historicalEvents,
|
||||
chainAnalysis,
|
||||
expectationScore,
|
||||
|
||||
// 加载状态
|
||||
loading: {
|
||||
stocks: loading.stocks,
|
||||
quotes: loading.quotes,
|
||||
eventDetail: loading.eventDetail,
|
||||
historicalEvents: loading.historicalEvents,
|
||||
chainAnalysis: loading.chainAnalysis
|
||||
},
|
||||
|
||||
// 方法
|
||||
loadAllData,
|
||||
loadStocksData, // 新增:加载股票数据
|
||||
loadHistoricalData, // 新增:加载历史事件数据
|
||||
loadChainAnalysis, // 新增:加载传导链数据(重命名避免冲突)
|
||||
refreshAllData,
|
||||
refreshQuotes
|
||||
};
|
||||
};
|
||||
5
src/components/Charts/Stock/index.js
Normal file
5
src/components/Charts/Stock/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
// src/components/Charts/Stock/index.js
|
||||
// 股票图表组件统一导出
|
||||
|
||||
export { default as MiniTimelineChart } from './MiniTimelineChart';
|
||||
export { useEventStocks } from './hooks/useEventStocks';
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { getImportanceConfig } from '@constants/importanceLevels';
|
||||
import { eventService } from '@services/eventService';
|
||||
import { useEventStocks } from '@views/Community/components/StockDetailPanel/hooks/useEventStocks';
|
||||
import { useEventStocks } from '@components/Charts/Stock';
|
||||
import { toggleEventFollow, selectEventFollowStatus } from '@store/slices/communityDataSlice';
|
||||
import { useAuth } from '@contexts/AuthContext';
|
||||
import EventHeaderInfo from './EventHeaderInfo';
|
||||
|
||||
@@ -20,7 +20,7 @@ import { StarIcon } from '@chakra-ui/icons';
|
||||
import { Tag } from 'antd';
|
||||
import { RobotOutlined } from '@ant-design/icons';
|
||||
import { selectIsMobile } from '@store/slices/deviceSlice';
|
||||
import MiniTimelineChart from '@views/Community/components/StockDetailPanel/components/MiniTimelineChart';
|
||||
import { MiniTimelineChart } from '@components/Charts/Stock';
|
||||
import MiniKLineChart from './MiniKLineChart';
|
||||
import TimelineChartModal from '@components/StockChart/TimelineChartModal';
|
||||
import KLineChartModal from '@components/StockChart/KLineChartModal';
|
||||
|
||||
Reference in New Issue
Block a user