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
* 提供自选股加载、分页、移除等功能
* 提供自选股加载、分页、添加、移除等功能
*
* @returns {{
* watchlistQuotes: Array,
@@ -19,7 +19,9 @@ const WATCHLIST_PAGE_SIZE = 10;
* setWatchlistPage: Function,
* WATCHLIST_PAGE_SIZE: number,
* loadWatchlistQuotes: Function,
* handleRemoveFromWatchlist: Function
* handleAddToWatchlist: Function,
* handleRemoveFromWatchlist: Function,
* isInWatchlist: Function
* }}
*/
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) => {
try {
@@ -85,9 +113,20 @@ export const useWatchlist = () => {
}
} catch (e) {
toast({ title: '网络错误,移除失败', status: 'error', duration: 2000 });
return false;
}
}, [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 {
watchlistQuotes,
watchlistLoading,
@@ -95,6 +134,8 @@ export const useWatchlist = () => {
setWatchlistPage,
WATCHLIST_PAGE_SIZE,
loadWatchlistQuotes,
handleRemoveFromWatchlist
handleAddToWatchlist,
handleRemoveFromWatchlist,
isInWatchlist
};
};

View File

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

View File

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