diff --git a/src/hooks/useWatchlist.js b/src/hooks/useWatchlist.js index 32eb7356..d9c83941 100644 --- a/src/hooks/useWatchlist.js +++ b/src/hooks/useWatchlist.js @@ -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 }; }; diff --git a/src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js b/src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js index 214d24ba..6e96beef 100644 --- a/src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js +++ b/src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js @@ -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, - }); - } else { - toast({ - title: '移除失败', - status: 'error', - duration: 2000, - isClosable: true, - }); - } - } 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, - }); + // 切换自选股(使用 useWatchlist Hook) + const handleWatchlistToggle = useCallback(async (stockCode, stockName, currentlyInWatchlist) => { + if (currentlyInWatchlist) { + await handleRemoveFromWatchlist(stockCode); + } else { + await handleAddToWatchlist(stockCode, stockName); } - }, [watchlistSet, toast]); + }, [handleAddToWatchlist, handleRemoveFromWatchlist]); // 空状态 if (!event) { @@ -373,15 +306,10 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => { {/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 - 支持精简/详细模式 */} { - if (!canAccessStocks) { - return ; - } - return null; - })()} + subscriptionBadge={!canAccessStocks ? : 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 ? : null} isLocked={!canAccessConcepts} @@ -437,7 +365,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => { {/* 历史事件对比(可折叠) - 懒加载 - 需要 PRO 权限 */} : null} @@ -460,7 +388,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => { {/* 传导链分析(可折叠) - 懒加载 - 需要 MAX 权限 */} : null} isLocked={!canAccessTransmission} @@ -488,7 +416,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => { featureName={upgradeModal.featureName} currentLevel={userTier} /> - ): null } + ) : null} ); }; diff --git a/src/views/Community/components/DynamicNewsDetail/RelatedStocksSection.js b/src/views/Community/components/DynamicNewsDetail/RelatedStocksSection.js index 49e1c25f..fe5c97e4 100644 --- a/src/views/Community/components/DynamicNewsDetail/RelatedStocksSection.js +++ b/src/views/Community/components/DynamicNewsDetail/RelatedStocksSection.js @@ -15,14 +15,14 @@ import { logger } from '../../../../utils/logger'; * @param {Array} 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]}