refactor: DynamicNewsDetailPanel 组件优化

- 使用 useReducer 整合 7 个折叠状态为统一的 sectionState
- 提取自选股逻辑到 useWatchlist Hook,移除 70 行重复代码
- 扩展 useWatchlist 添加 handleAddToWatchlist、isInWatchlist 方法
- 清理未使用的导入(HStack、useColorModeValue)
- 移除调试 console.log 日志
- RelatedStocksSection 改用 isInWatchlist 函数替代 watchlistSet

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-04 13:29:59 +08:00
parent 3f518def09
commit e8c21f7863
3 changed files with 141 additions and 172 deletions

View File

@@ -10,7 +10,7 @@ const WATCHLIST_PAGE_SIZE = 10;
/** /**
* 自选股管理 Hook * 自选股管理 Hook
* 提供自选股加载、分页、移除等功能 * 提供自选股加载、分页、添加、移除等功能
* *
* @returns {{ * @returns {{
* watchlistQuotes: Array, * watchlistQuotes: Array,
@@ -19,7 +19,9 @@ const WATCHLIST_PAGE_SIZE = 10;
* setWatchlistPage: Function, * setWatchlistPage: Function,
* WATCHLIST_PAGE_SIZE: number, * WATCHLIST_PAGE_SIZE: number,
* loadWatchlistQuotes: Function, * loadWatchlistQuotes: Function,
* handleRemoveFromWatchlist: Function * handleAddToWatchlist: Function,
* handleRemoveFromWatchlist: Function,
* isInWatchlist: Function
* }} * }}
*/ */
export const useWatchlist = () => { export const useWatchlist = () => {
@@ -58,6 +60,32 @@ export const useWatchlist = () => {
} }
}, []); }, []);
// 添加到自选股
const handleAddToWatchlist = useCallback(async (stockCode, stockName) => {
try {
const base = getApiBase();
const resp = await fetch(base + '/api/account/watchlist/add', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stock_code: stockCode, stock_name: stockName })
});
const data = await resp.json().catch(() => ({}));
if (resp.ok && data.success) {
// 刷新自选股列表
loadWatchlistQuotes();
toast({ title: '已添加至自选股', status: 'success', duration: 1500 });
return true;
} else {
toast({ title: '添加失败', status: 'error', duration: 2000 });
return false;
}
} catch (e) {
toast({ title: '网络错误,添加失败', status: 'error', duration: 2000 });
return false;
}
}, [toast, loadWatchlistQuotes]);
// 从自选股移除 // 从自选股移除
const handleRemoveFromWatchlist = useCallback(async (stockCode) => { const handleRemoveFromWatchlist = useCallback(async (stockCode) => {
try { try {
@@ -85,9 +113,20 @@ export const useWatchlist = () => {
} }
} catch (e) { } catch (e) {
toast({ title: '网络错误,移除失败', status: 'error', duration: 2000 }); toast({ title: '网络错误,移除失败', status: 'error', duration: 2000 });
return false;
} }
}, [toast]); }, [toast]);
// 判断股票是否在自选股中
const isInWatchlist = useCallback((stockCode) => {
const normalize6 = (code) => {
const m = String(code || '').match(/(\d{6})/);
return m ? m[1] : String(code || '');
};
const target = normalize6(stockCode);
return watchlistQuotes.some(item => normalize6(item.stock_code) === target);
}, [watchlistQuotes]);
return { return {
watchlistQuotes, watchlistQuotes,
watchlistLoading, watchlistLoading,
@@ -95,6 +134,8 @@ export const useWatchlist = () => {
setWatchlistPage, setWatchlistPage,
WATCHLIST_PAGE_SIZE, WATCHLIST_PAGE_SIZE,
loadWatchlistQuotes, loadWatchlistQuotes,
handleRemoveFromWatchlist handleAddToWatchlist,
handleRemoveFromWatchlist,
isInWatchlist
}; };
}; };

View File

@@ -1,21 +1,18 @@
// src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js // src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js
// 动态新闻详情面板主组件(组装所有子组件) // 动态新闻详情面板主组件(组装所有子组件)
import React, { useState, useCallback, useEffect } from 'react'; import React, { useState, useCallback, useEffect, useReducer } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { import {
Box,
Card, Card,
CardBody, CardBody,
VStack, VStack,
HStack,
Text, Text,
Spinner, Spinner,
Center, Center,
Wrap, Wrap,
WrapItem, WrapItem,
useColorModeValue, Box,
useToast,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { getImportanceConfig } from '@constants/importanceLevels'; import { getImportanceConfig } from '@constants/importanceLevels';
import { eventService } from '@services/eventService'; import { eventService } from '@services/eventService';
@@ -34,9 +31,52 @@ import TransmissionChainAnalysis from '@views/EventDetail/components/Transmissio
import SubscriptionBadge from '@components/SubscriptionBadge'; import SubscriptionBadge from '@components/SubscriptionBadge';
import SubscriptionUpgradeModal from '@components/SubscriptionUpgradeModal'; import SubscriptionUpgradeModal from '@components/SubscriptionUpgradeModal';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme'; import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
import { getApiBase } from '@utils/apiConfig'; import { useWatchlist } from '@hooks/useWatchlist';
import EventCommentSection from '@components/EventCommentSection'; import EventCommentSection from '@components/EventCommentSection';
// 折叠区块状态管理 - 使用 useReducer 整合
const initialSectionState = {
stocks: { isOpen: true, hasLoaded: false, hasLoadedQuotes: false },
concepts: { isOpen: false },
historical: { isOpen: false, hasLoaded: false },
transmission: { isOpen: false, hasLoaded: false }
};
const sectionReducer = (state, action) => {
switch (action.type) {
case 'TOGGLE':
return {
...state,
[action.section]: {
...state[action.section],
isOpen: !state[action.section].isOpen
}
};
case 'SET_LOADED':
return {
...state,
[action.section]: {
...state[action.section],
hasLoaded: true
}
};
case 'SET_QUOTES_LOADED':
return {
...state,
stocks: { ...state.stocks, hasLoadedQuotes: true }
};
case 'RESET_ALL':
return {
stocks: { isOpen: true, hasLoaded: false, hasLoadedQuotes: false },
concepts: { isOpen: false },
historical: { isOpen: false, hasLoaded: false },
transmission: { isOpen: false, hasLoaded: false }
};
default:
return state;
}
};
/** /**
* 动态新闻详情面板主组件 * 动态新闻详情面板主组件
* @param {Object} props * @param {Object} props
@@ -49,7 +89,14 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const cardBg = PROFESSIONAL_COLORS.background.card; const cardBg = PROFESSIONAL_COLORS.background.card;
const borderColor = PROFESSIONAL_COLORS.border.default; const borderColor = PROFESSIONAL_COLORS.border.default;
const textColor = PROFESSIONAL_COLORS.text.secondary; const textColor = PROFESSIONAL_COLORS.text.secondary;
const toast = useToast();
// 使用 useWatchlist Hook 管理自选股
const {
handleAddToWatchlist,
handleRemoveFromWatchlist,
isInWatchlist,
loadWatchlistQuotes
} = useWatchlist();
// 获取用户会员等级(修复:字段名从 subscription_tier 改为 subscription_type // 获取用户会员等级(修复:字段名从 subscription_tier 改为 subscription_type
const userTier = user?.subscription_type || 'free'; const userTier = user?.subscription_type || 'free';
@@ -102,11 +149,6 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const response = await eventService.getEventDetail(event.id); const response = await eventService.getEventDetail(event.id);
if (response.success) { if (response.success) {
setFullEventDetail(response.data); setFullEventDetail(response.data);
console.log('%c📊 [浏览量] 事件详情加载成功', 'color: #10B981; font-weight: bold;', {
eventId: event.id,
viewCount: response.data.view_count,
title: response.data.title
});
} }
} catch (error) { } catch (error) {
console.error('[DynamicNewsDetailPanel] loadEventDetail 失败:', error, { console.error('[DynamicNewsDetailPanel] loadEventDetail 失败:', error, {
@@ -123,30 +165,8 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const canAccessHistorical = hasAccess('pro'); const canAccessHistorical = hasAccess('pro');
const canAccessTransmission = hasAccess('max'); const canAccessTransmission = hasAccess('max');
// 子区块折叠状态管理 + 加载追踪 // 子区块折叠状态管理 - 使用 useReducer 整合
// 相关股票默认展开 const [sectionState, dispatchSection] = useReducer(sectionReducer, initialSectionState);
const [isStocksOpen, setIsStocksOpen] = useState(true);
const [hasLoadedStocks, setHasLoadedStocks] = useState(false); // 股票列表是否已加载(获取数量)
const [hasLoadedQuotes, setHasLoadedQuotes] = useState(false); // 行情数据是否已加载
const [isConceptsOpen, setIsConceptsOpen] = useState(false);
// 历史事件默认折叠,但预加载数量
const [isHistoricalOpen, setIsHistoricalOpen] = useState(false);
const [hasLoadedHistorical, setHasLoadedHistorical] = useState(false);
const [isTransmissionOpen, setIsTransmissionOpen] = useState(false);
const [hasLoadedTransmission, setHasLoadedTransmission] = useState(false);
// 自选股管理(使用 localStorage
const [watchlistSet, setWatchlistSet] = useState(() => {
try {
const saved = localStorage.getItem('stock_watchlist');
return saved ? new Set(JSON.parse(saved)) : new Set();
} catch {
return new Set();
}
});
// 锁定点击处理 - 弹出升级弹窗 // 锁定点击处理 - 弹出升级弹窗
const handleLockedClick = useCallback((featureName, requiredLevel) => { const handleLockedClick = useCallback((featureName, requiredLevel) => {
@@ -167,87 +187,62 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
}, []); }, []);
// 相关股票 - 展开时加载行情(需要 PRO 权限) // 相关股票 - 展开时加载行情(需要 PRO 权限)
// 股票列表在事件切换时预加载(显示数量),行情在展开时才加载
const handleStocksToggle = useCallback(() => { const handleStocksToggle = useCallback(() => {
const newState = !isStocksOpen; const willOpen = !sectionState.stocks.isOpen;
setIsStocksOpen(newState); dispatchSection({ type: 'TOGGLE', section: 'stocks' });
// 展开时加载行情数据(如果还没加载过) // 展开时加载行情数据(如果还没加载过)
if (newState && !hasLoadedQuotes && stocks.length > 0) { if (willOpen && !sectionState.stocks.hasLoadedQuotes && stocks.length > 0) {
console.log('%c📈 [相关股票] 首次展开,加载行情数据', 'color: #10B981; font-weight: bold;', {
eventId: event?.id,
stockCount: stocks.length
});
refreshQuotes(); refreshQuotes();
setHasLoadedQuotes(true); dispatchSection({ type: 'SET_QUOTES_LOADED' });
} }
}, [isStocksOpen, hasLoadedQuotes, stocks.length, refreshQuotes, event?.id]); }, [sectionState.stocks, stocks.length, refreshQuotes]);
// 相关概念 - 展开/收起(无需加载) // 相关概念 - 展开/收起(无需加载)
const handleConceptsToggle = useCallback(() => { const handleConceptsToggle = useCallback(() => {
setIsConceptsOpen(!isConceptsOpen); dispatchSection({ type: 'TOGGLE', section: 'concepts' });
}, [isConceptsOpen]); }, []);
// 历史事件对比 - 数据已预加载,只需切换展开状态 // 历史事件对比 - 数据已预加载,只需切换展开状态
const handleHistoricalToggle = useCallback(() => { const handleHistoricalToggle = useCallback(() => {
const newState = !isHistoricalOpen; dispatchSection({ type: 'TOGGLE', section: 'historical' });
setIsHistoricalOpen(newState); }, []);
// 数据已在事件切换时预加载,这里只需展开
if (newState) {
console.log('%c📜 [历史事件] 展开(数据已预加载)', 'color: #3B82F6; font-weight: bold;', {
eventId: event?.id,
count: historicalEvents?.length || 0
});
}
}, [isHistoricalOpen, event?.id, historicalEvents?.length]);
// 传导链分析 - 展开时加载 // 传导链分析 - 展开时加载
const handleTransmissionToggle = useCallback(() => { const handleTransmissionToggle = useCallback(() => {
const newState = !isTransmissionOpen; const willOpen = !sectionState.transmission.isOpen;
setIsTransmissionOpen(newState); dispatchSection({ type: 'TOGGLE', section: 'transmission' });
if (newState && !hasLoadedTransmission) { if (willOpen && !sectionState.transmission.hasLoaded) {
console.log('%c🔗 [传导链] 首次展开,加载传导链数据', 'color: #8B5CF6; font-weight: bold;', { eventId: event?.id });
loadChainAnalysis(); loadChainAnalysis();
setHasLoadedTransmission(true); dispatchSection({ type: 'SET_LOADED', section: 'transmission' });
} }
}, [isTransmissionOpen, hasLoadedTransmission, loadChainAnalysis, event?.id]); }, [sectionState.transmission, loadChainAnalysis]);
// 事件切换时重置所有子模块状态 // 事件切换时重置所有子模块状态
useEffect(() => { useEffect(() => {
console.log('%c🔄 [事件切换] 重置所有子模块状态', 'color: #F59E0B; font-weight: bold;', { eventId: event?.id }); // 加载事件详情(增加浏览量)
// 🎯 加载事件详情(增加浏览量)
loadEventDetail(); loadEventDetail();
// 重置所有加载状态 // 加载自选股数据(用于判断股票是否已关注)
setHasLoadedStocks(false); loadWatchlistQuotes();
setHasLoadedQuotes(false); // 重置行情加载状态
setHasLoadedHistorical(false); // 重置所有折叠区块状态
setHasLoadedTransmission(false); dispatchSection({ type: 'RESET_ALL' });
// 相关股票默认展开,预加载股票列表和行情数据 // 相关股票默认展开,预加载股票列表和行情数据
setIsStocksOpen(true);
if (canAccessStocks) { if (canAccessStocks) {
console.log('%c📊 [相关股票] 事件切换,预加载股票列表和行情数据', 'color: #10B981; font-weight: bold;', { eventId: event?.id });
loadStocksData(); loadStocksData();
setHasLoadedStocks(true); dispatchSection({ type: 'SET_LOADED', section: 'stocks' });
// 由于默认展开,直接加载行情数据 dispatchSection({ type: 'SET_QUOTES_LOADED' });
setHasLoadedQuotes(true);
} }
// 历史事件默认折叠,但预加载数据(显示数量吸引点击) // 历史事件默认折叠,但预加载数据(显示数量吸引点击)
setIsHistoricalOpen(false);
if (canAccessHistorical) { if (canAccessHistorical) {
console.log('%c📜 [历史事件] 事件切换,预加载历史事件(获取数量)', 'color: #3B82F6; font-weight: bold;', { eventId: event?.id });
loadHistoricalData(); loadHistoricalData();
setHasLoadedHistorical(true); dispatchSection({ type: 'SET_LOADED', section: 'historical' });
} }
}, [event?.id, canAccessStocks, canAccessHistorical, userTier, loadStocksData, loadHistoricalData, loadEventDetail, loadWatchlistQuotes]);
setIsConceptsOpen(false);
setIsTransmissionOpen(false);
}, [event?.id, canAccessStocks, canAccessHistorical, userTier, loadStocksData, loadHistoricalData, loadEventDetail]);
// 切换关注状态 // 切换关注状态
const handleToggleFollow = useCallback(async () => { const handleToggleFollow = useCallback(async () => {
@@ -255,76 +250,14 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
dispatch(toggleEventFollow(event.id)); dispatch(toggleEventFollow(event.id));
}, [dispatch, event?.id]); }, [dispatch, event?.id]);
// 切换自选股 // 切换自选股(使用 useWatchlist Hook
const handleWatchlistToggle = useCallback(async (stockCode, stockName, isInWatchlist) => { const handleWatchlistToggle = useCallback(async (stockCode, stockName, currentlyInWatchlist) => {
try { if (currentlyInWatchlist) {
const base = getApiBase(); await handleRemoveFromWatchlist(stockCode);
if (isInWatchlist) {
// 移除自选股
const resp = await fetch(base + `/api/account/watchlist/${stockCode}`, {
method: 'DELETE',
credentials: 'include'
});
const data = await resp.json().catch(() => ({}));
if (resp.ok && data.success !== false) {
const newWatchlist = new Set(watchlistSet);
newWatchlist.delete(stockCode);
setWatchlistSet(newWatchlist);
localStorage.setItem('stock_watchlist', JSON.stringify(Array.from(newWatchlist)));
toast({
title: '已移除自选股',
status: 'info',
duration: 2000,
isClosable: true,
});
} else { } else {
toast({ await handleAddToWatchlist(stockCode, stockName);
title: '移除失败',
status: 'error',
duration: 2000,
isClosable: true,
});
} }
} else { }, [handleAddToWatchlist, handleRemoveFromWatchlist]);
// 添加自选股
const resp = await fetch(base + '/api/account/watchlist/add', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stock_code: stockCode, stock_name: stockName })
});
const data = await resp.json().catch(() => ({}));
if (resp.ok && data.success) {
const newWatchlist = new Set(watchlistSet);
newWatchlist.add(stockCode);
setWatchlistSet(newWatchlist);
localStorage.setItem('stock_watchlist', JSON.stringify(Array.from(newWatchlist)));
toast({
title: '已添加至自选股',
status: 'success',
duration: 2000,
isClosable: true,
});
} else {
toast({
title: '添加失败',
status: 'error',
duration: 2000,
isClosable: true,
});
}
}
} catch (error) {
console.error('切换自选股失败:', error);
toast({
title: '网络错误',
status: 'error',
duration: 2000,
isClosable: true,
});
}
}, [watchlistSet, toast]);
// 空状态 // 空状态
if (!event) { if (!event) {
@@ -373,15 +306,10 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
{/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 - 支持精简/详细模式 */} {/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 - 支持精简/详细模式 */}
<CollapsibleSection <CollapsibleSection
title="相关股票" title="相关股票"
isOpen={isStocksOpen} isOpen={sectionState.stocks.isOpen}
onToggle={handleStocksToggle} onToggle={handleStocksToggle}
count={stocks?.length || 0} count={stocks?.length || 0}
subscriptionBadge={(() => { subscriptionBadge={!canAccessStocks ? <SubscriptionBadge tier="pro" size="sm" /> : null}
if (!canAccessStocks) {
return <SubscriptionBadge tier="pro" size="sm" />;
}
return null;
})()}
isLocked={!canAccessStocks} isLocked={!canAccessStocks}
onLockedClick={() => handleLockedClick('相关股票', 'pro')} onLockedClick={() => handleLockedClick('相关股票', 'pro')}
showModeToggle={canAccessStocks} showModeToggle={canAccessStocks}
@@ -416,7 +344,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
stocks={stocks} stocks={stocks}
quotes={quotes} quotes={quotes}
eventTime={event.created_at} eventTime={event.created_at}
watchlistSet={watchlistSet} isInWatchlist={isInWatchlist}
onWatchlistToggle={handleWatchlistToggle} onWatchlistToggle={handleWatchlistToggle}
/> />
)} )}
@@ -427,7 +355,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
eventTitle={event.title} eventTitle={event.title}
effectiveTradingDate={event.trading_date || event.created_at} effectiveTradingDate={event.trading_date || event.created_at}
eventTime={event.created_at} eventTime={event.created_at}
isOpen={isConceptsOpen} isOpen={sectionState.concepts.isOpen}
onToggle={handleConceptsToggle} onToggle={handleConceptsToggle}
subscriptionBadge={!canAccessConcepts ? <SubscriptionBadge tier="pro" size="sm" /> : null} subscriptionBadge={!canAccessConcepts ? <SubscriptionBadge tier="pro" size="sm" /> : null}
isLocked={!canAccessConcepts} isLocked={!canAccessConcepts}
@@ -437,7 +365,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
{/* 历史事件对比(可折叠) - 懒加载 - 需要 PRO 权限 */} {/* 历史事件对比(可折叠) - 懒加载 - 需要 PRO 权限 */}
<CollapsibleSection <CollapsibleSection
title="历史事件对比" title="历史事件对比"
isOpen={isHistoricalOpen} isOpen={sectionState.historical.isOpen}
onToggle={handleHistoricalToggle} onToggle={handleHistoricalToggle}
count={historicalEvents?.length || 0} count={historicalEvents?.length || 0}
subscriptionBadge={!canAccessHistorical ? <SubscriptionBadge tier="pro" size="sm" /> : null} subscriptionBadge={!canAccessHistorical ? <SubscriptionBadge tier="pro" size="sm" /> : null}
@@ -460,7 +388,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
{/* 传导链分析(可折叠) - 懒加载 - 需要 MAX 权限 */} {/* 传导链分析(可折叠) - 懒加载 - 需要 MAX 权限 */}
<CollapsibleSection <CollapsibleSection
title="传导链分析" title="传导链分析"
isOpen={isTransmissionOpen} isOpen={sectionState.transmission.isOpen}
onToggle={handleTransmissionToggle} onToggle={handleTransmissionToggle}
subscriptionBadge={!canAccessTransmission ? <SubscriptionBadge tier="max" size="sm" /> : null} subscriptionBadge={!canAccessTransmission ? <SubscriptionBadge tier="max" size="sm" /> : null}
isLocked={!canAccessTransmission} isLocked={!canAccessTransmission}

View File

@@ -15,14 +15,14 @@ import { logger } from '../../../../utils/logger';
* @param {Array<Object>} props.stocks - 股票数组 * @param {Array<Object>} props.stocks - 股票数组
* @param {Object} props.quotes - 股票行情字典 { [stockCode]: { change: number } } * @param {Object} props.quotes - 股票行情字典 { [stockCode]: { change: number } }
* @param {string} props.eventTime - 事件时间 * @param {string} props.eventTime - 事件时间
* @param {Set} props.watchlistSet - 自选股代码集合 * @param {Function} props.isInWatchlist - 检查股票是否在自选股中的函数
* @param {Function} props.onWatchlistToggle - 切换自选股回调 * @param {Function} props.onWatchlistToggle - 切换自选股回调
*/ */
const RelatedStocksSection = ({ const RelatedStocksSection = ({
stocks, stocks,
quotes = {}, quotes = {},
eventTime = null, eventTime = null,
watchlistSet = new Set(), isInWatchlist = () => false,
onWatchlistToggle onWatchlistToggle
}) => { }) => {
// 分时图数据状态:{ [stockCode]: data[] } // 分时图数据状态:{ [stockCode]: data[] }
@@ -167,7 +167,7 @@ const RelatedStocksSection = ({
stock={stock} stock={stock}
quote={quotes[stock.stock_code]} quote={quotes[stock.stock_code]}
eventTime={eventTime} eventTime={eventTime}
isInWatchlist={watchlistSet.has(stock.stock_code)} isInWatchlist={isInWatchlist(stock.stock_code)}
onWatchlistToggle={onWatchlistToggle} onWatchlistToggle={onWatchlistToggle}
timelineData={timelineDataMap[stock.stock_code]} timelineData={timelineDataMap[stock.stock_code]}
timelineLoading={shouldShowTimelineLoading && !timelineDataMap[stock.stock_code]} timelineLoading={shouldShowTimelineLoading && !timelineDataMap[stock.stock_code]}