From 06475f82a44780d9e402f21bba84ede3591575df Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Tue, 23 Dec 2025 20:09:20 +0800 Subject: [PATCH] =?UTF-8?q?refactor(events):=20=E5=85=B3=E6=B3=A8=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E6=95=B0=E6=8D=AE=E6=BA=90=E7=BB=9F=E4=B8=80=E5=88=B0?= =?UTF-8?q?=20Redux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useFollowingEvents: 改用 Redux selector 获取关注事件 - GlobalSidebarContext: 移除本地 followingEvents 状态,使用 Redux - 侧边栏和导航栏共享同一数据源,保持状态同步 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/contexts/GlobalSidebarContext.js | 194 ++++++++++----------------- src/hooks/useFollowingEvents.js | 132 +++++++++--------- 2 files changed, 137 insertions(+), 189 deletions(-) diff --git a/src/contexts/GlobalSidebarContext.js b/src/contexts/GlobalSidebarContext.js index 8e42888c..d8c8246a 100644 --- a/src/contexts/GlobalSidebarContext.js +++ b/src/contexts/GlobalSidebarContext.js @@ -2,12 +2,21 @@ * GlobalSidebarContext - 全局右侧工具栏状态管理 * * 管理侧边栏的展开/收起状态和数据加载 + * 自选股和关注事件数据都从 Redux 获取,与导航栏共用数据源 */ import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; import { useAuth } from './AuthContext'; import { logger } from '@/utils/logger'; -import { getApiBase } from '@/utils/apiConfig'; +import { + loadWatchlist, + loadWatchlistQuotes, + toggleWatchlist, + loadFollowingEvents, + loadEventComments, + toggleFollowEvent +} from '@/store/slices/stockSlice'; const GlobalSidebarContext = createContext(null); @@ -17,17 +26,30 @@ const GlobalSidebarContext = createContext(null); export const GlobalSidebarProvider = ({ children }) => { const { user } = useAuth(); const userId = user?.id; + const dispatch = useDispatch(); // 侧边栏展开/收起状态(默认折叠) const [isOpen, setIsOpen] = useState(false); - // 数据状态 - const [watchlist, setWatchlist] = useState([]); - const [realtimeQuotes, setRealtimeQuotes] = useState({}); - const [followingEvents, setFollowingEvents] = useState([]); - const [eventComments, setEventComments] = useState([]); - const [loading, setLoading] = useState(false); - const [quotesLoading, setQuotesLoading] = useState(false); + // 从 Redux 获取自选股数据(与导航栏共用) + const watchlist = useSelector(state => state.stock.watchlist || []); + const watchlistQuotes = useSelector(state => state.stock.watchlistQuotes || []); + const watchlistLoading = useSelector(state => state.stock.loading?.watchlist); + const quotesLoading = useSelector(state => state.stock.loading?.watchlistQuotes); + + // 将 watchlistQuotes 数组转换为 { stock_code: quote } 格式(兼容现有组件) + const realtimeQuotes = React.useMemo(() => { + const quotesMap = {}; + watchlistQuotes.forEach(item => { + quotesMap[item.stock_code] = item; + }); + return quotesMap; + }, [watchlistQuotes]); + + // 从 Redux 获取关注事件数据(与导航栏共用) + const followingEvents = useSelector(state => state.stock.followingEvents || []); + const eventComments = useSelector(state => state.stock.eventComments || []); + const eventsLoading = useSelector(state => state.stock.loading?.followingEvents || false); // 防止重复加载 const hasLoadedRef = useRef(false); @@ -40,86 +62,27 @@ export const GlobalSidebarProvider = ({ children }) => { }, []); /** - * 加载实时行情 + * 加载实时行情(通过 Redux) */ - const loadRealtimeQuotes = useCallback(async () => { + const loadRealtimeQuotes = useCallback(() => { if (!userId) return; - - try { - setQuotesLoading(true); - const base = getApiBase(); - const response = await fetch(base + '/api/account/watchlist/realtime', { - credentials: 'include', - cache: 'no-store' - }); - - if (response.ok) { - const data = await response.json(); - if (data.success) { - const quotesMap = {}; - data.data.forEach(item => { - quotesMap[item.stock_code] = item; - }); - setRealtimeQuotes(quotesMap); - } - } - } catch (error) { - logger.error('GlobalSidebar', 'loadRealtimeQuotes', error, { - userId, - timestamp: new Date().toISOString() - }); - } finally { - setQuotesLoading(false); - } - }, [userId]); + dispatch(loadWatchlistQuotes()); + }, [userId, dispatch]); /** - * 加载所有数据(自选股、关注事件、评论) + * 加载所有数据(自选股和关注事件都从 Redux 获取) */ - const loadData = useCallback(async () => { + const loadData = useCallback(() => { if (!userId) return; - try { - setLoading(true); - const base = getApiBase(); - const ts = Date.now(); + // 自选股通过 Redux 加载 + dispatch(loadWatchlist()); + dispatch(loadWatchlistQuotes()); - const [w, e, c] = await Promise.all([ - fetch(base + `/api/account/watchlist?_=${ts}`, { credentials: 'include', cache: 'no-store' }), - fetch(base + `/api/account/events/following?_=${ts}`, { credentials: 'include', cache: 'no-store' }), - fetch(base + `/api/account/events/posts?_=${ts}`, { credentials: 'include', cache: 'no-store' }), - ]); - - const jw = await w.json(); - const je = await e.json(); - const jc = await c.json(); - - if (jw.success) { - const watchlistData = Array.isArray(jw.data) ? jw.data : []; - setWatchlist(watchlistData); - - // 加载实时行情 - if (watchlistData.length > 0) { - loadRealtimeQuotes(); - } - } - - if (je.success) { - setFollowingEvents(Array.isArray(je.data) ? je.data : []); - } - - if (jc.success) { - setEventComments(Array.isArray(jc.data) ? jc.data : []); - } - } catch (err) { - logger.error('GlobalSidebar', 'loadData', err, { - userId, - timestamp: new Date().toISOString() - }); - } finally { - setLoading(false); - } - }, [userId, loadRealtimeQuotes]); + // 关注事件和评论通过 Redux 加载 + dispatch(loadFollowingEvents()); + dispatch(loadEventComments()); + }, [userId, dispatch]); /** * 刷新数据 @@ -129,53 +92,47 @@ export const GlobalSidebarProvider = ({ children }) => { }, [loadData]); /** - * 取消关注股票 + * 取消关注股票(通过 Redux) */ const unwatchStock = useCallback(async (stockCode) => { if (!userId) return; try { - const base = getApiBase(); - const response = await fetch(base + '/api/account/watchlist/remove', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ stock_code: stockCode }), - }); + // 找到股票名称 + const stockItem = watchlist.find(s => s.stock_code === stockCode); + const stockName = stockItem?.stock_name || ''; - if (response.ok) { - // 本地更新,不用重新请求 - setWatchlist(prev => prev.filter(s => s.stock_code !== stockCode)); - setRealtimeQuotes(prev => { - const newQuotes = { ...prev }; - delete newQuotes[stockCode]; - return newQuotes; - }); - } + // 通过 Redux action 移除(乐观更新) + await dispatch(toggleWatchlist({ + stockCode, + stockName, + isInWatchlist: true // 表示当前在自选股中,需要移除 + })).unwrap(); + + logger.debug('GlobalSidebar', 'unwatchStock 成功', { stockCode }); } catch (error) { logger.error('GlobalSidebar', 'unwatchStock', error, { stockCode, userId }); } - }, [userId]); + }, [userId, dispatch, watchlist]); /** - * 取消关注事件 + * 取消关注事件(通过 Redux) */ const unfollowEvent = useCallback(async (eventId) => { if (!userId) return; try { - const base = getApiBase(); - const response = await fetch(base + `/api/events/${eventId}/unfollow`, { - method: 'POST', - credentials: 'include', - }); + // 通过 Redux action 取消关注(乐观更新) + await dispatch(toggleFollowEvent({ + eventId, + isFollowing: true // 表示当前已关注,需要取消 + })).unwrap(); - if (response.ok) { - // 本地更新 - setFollowingEvents(prev => prev.filter(e => e.id !== eventId)); - } + logger.debug('GlobalSidebar', 'unfollowEvent 成功', { eventId }); } catch (error) { logger.error('GlobalSidebar', 'unfollowEvent', error, { eventId, userId }); + // 失败时重新加载列表 + dispatch(loadFollowingEvents()); } - }, [userId]); + }, [userId, dispatch]); // 用户登录后加载数据 useEffect(() => { @@ -185,13 +142,9 @@ export const GlobalSidebarProvider = ({ children }) => { loadData(); } - // 用户登出时重置 + // 用户登出时重置(所有状态由 Redux 管理) if (!user) { hasLoadedRef.current = false; - setWatchlist([]); - setRealtimeQuotes({}); - setFollowingEvents([]); - setEventComments([]); } }, [user, loadData]); @@ -208,30 +161,31 @@ export const GlobalSidebarProvider = ({ children }) => { return () => document.removeEventListener('visibilitychange', onVisibilityChange); }, [user, loadData]); - // 定时刷新实时行情(每分钟一次) + // 定时刷新实时行情(每分钟一次,两个面板共用) useEffect(() => { - if (watchlist.length > 0) { + if (watchlist.length > 0 && userId) { const interval = setInterval(() => { - loadRealtimeQuotes(); + console.log('[GlobalSidebar] 定时刷新行情'); + dispatch(loadWatchlistQuotes()); }, 60000); return () => clearInterval(interval); } - }, [watchlist.length, loadRealtimeQuotes]); + }, [watchlist.length, userId, dispatch]); const value = { // 状态 isOpen, toggle, - // 数据 + // 数据(watchlist 和 realtimeQuotes 从 Redux 获取) watchlist, realtimeQuotes, followingEvents, eventComments, // 加载状态 - loading, + loading: watchlistLoading || eventsLoading, quotesLoading, // 方法 diff --git a/src/hooks/useFollowingEvents.js b/src/hooks/useFollowingEvents.js index 1013d51f..0496a3b5 100644 --- a/src/hooks/useFollowingEvents.js +++ b/src/hooks/useFollowingEvents.js @@ -1,16 +1,21 @@ // src/hooks/useFollowingEvents.js -// 关注事件管理自定义 Hook +// 关注事件管理自定义 Hook(与 Redux 状态同步,支持多组件共用) -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; import { useToast } from '@chakra-ui/react'; import { logger } from '../utils/logger'; -import { getApiBase } from '../utils/apiConfig'; +import { + loadFollowingEvents as loadFollowingEventsAction, + toggleFollowEvent +} from '../store/slices/stockSlice'; const EVENTS_PAGE_SIZE = 8; /** - * 关注事件管理 Hook - * 提供事件加载、分页、取消关注等功能 + * 关注事件管理 Hook(导航栏专用) + * 提供关注事件加载、分页、取消关注等功能 + * 监听 Redux 中的 followingEvents 变化,自动同步 * * @returns {{ * followingEvents: Array, @@ -24,77 +29,66 @@ const EVENTS_PAGE_SIZE = 8; */ export const useFollowingEvents = () => { const toast = useToast(); - const [followingEvents, setFollowingEvents] = useState([]); - const [eventsLoading, setEventsLoading] = useState(false); + const dispatch = useDispatch(); const [eventsPage, setEventsPage] = useState(1); - // 加载关注的事件 - const loadFollowingEvents = useCallback(async () => { - try { - setEventsLoading(true); - const base = getApiBase(); - const resp = await fetch(base + '/api/account/events/following', { - credentials: 'include', - cache: 'no-store' - }); - if (resp.ok) { - const data = await resp.json(); - if (data && data.success && Array.isArray(data.data)) { - // 合并重复的事件(用最新的数据) - const eventMap = new Map(); - for (const evt of data.data) { - if (evt && evt.id) { - eventMap.set(evt.id, evt); - } - } - const merged = Array.from(eventMap.values()); - // 按创建时间降序排列(假设事件有 created_at 或 id) - if (merged.length > 0 && merged[0].created_at) { - merged.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0)); - } else { - merged.sort((a, b) => (b.id || 0) - (a.id || 0)); - } - setFollowingEvents(merged); - } else { - setFollowingEvents([]); - } - } else { - setFollowingEvents([]); - } - } catch (e) { - logger.warn('useFollowingEvents', '加载关注事件失败', { - error: e.message - }); - setFollowingEvents([]); - } finally { - setEventsLoading(false); - } - }, []); + // 从 Redux 获取关注事件数据(与 GlobalSidebar 共用) + const followingEvents = useSelector(state => state.stock.followingEvents || []); + const eventsLoading = useSelector(state => state.stock.loading?.followingEvents || false); - // 取消关注事件 + // 从 Redux 获取关注事件列表长度(用于监听变化) + const reduxEventsLength = useSelector(state => state.stock.followingEvents?.length || 0); + + // 用于跟踪上一次的事件长度 + const prevEventsLengthRef = useRef(-1); + + // 初始化时加载 Redux followingEvents(确保 Redux 状态被初始化) + const hasInitializedRef = useRef(false); + useEffect(() => { + if (!hasInitializedRef.current) { + hasInitializedRef.current = true; + logger.debug('useFollowingEvents', '初始化 Redux followingEvents'); + dispatch(loadFollowingEventsAction()); + } + }, [dispatch]); + + // 加载关注事件(通过 Redux) + const loadFollowingEvents = useCallback(() => { + logger.debug('useFollowingEvents', '触发 loadFollowingEvents'); + dispatch(loadFollowingEventsAction()); + }, [dispatch]); + + // 监听 Redux followingEvents 长度变化,自动更新分页 + useEffect(() => { + const currentLength = reduxEventsLength; + const prevLength = prevEventsLengthRef.current; + + // 当事件列表长度变化时,更新分页(确保不超出范围) + if (prevLength !== -1 && currentLength !== prevLength) { + const newMaxPage = Math.max(1, Math.ceil(currentLength / EVENTS_PAGE_SIZE)); + setEventsPage(p => Math.min(p, newMaxPage)); + } + + prevEventsLengthRef.current = currentLength; + }, [reduxEventsLength]); + + // 取消关注事件(通过 Redux) const handleUnfollowEvent = useCallback(async (eventId) => { try { - const base = getApiBase(); - const resp = await fetch(base + `/api/events/${eventId}/follow`, { - method: 'POST', - credentials: 'include' - }); - const data = await resp.json().catch(() => ({})); - if (resp.ok && data && data.success !== false) { - setFollowingEvents((prev) => { - const updated = (prev || []).filter((x) => x.id !== eventId); - const newMaxPage = Math.max(1, Math.ceil((updated.length || 0) / EVENTS_PAGE_SIZE)); - setEventsPage((p) => Math.min(p, newMaxPage)); - return updated; - }); - toast({ title: '已取消关注该事件', status: 'info', duration: 1500 }); - } else { - toast({ title: '操作失败', status: 'error', duration: 2000 }); - } + // 通过 Redux action 取消关注(乐观更新) + await dispatch(toggleFollowEvent({ + eventId, + isFollowing: true // 表示当前已关注,需要取消 + })).unwrap(); + + toast({ title: '已取消关注该事件', status: 'info', duration: 1500 }); } catch (e) { - toast({ title: '网络错误,操作失败', status: 'error', duration: 2000 }); + logger.error('useFollowingEvents', '取消关注事件失败', e); + toast({ title: e.message || '操作失败', status: 'error', duration: 2000 }); + // 失败时重新加载列表 + dispatch(loadFollowingEventsAction()); } - }, [toast]); + }, [dispatch, toast]); return { followingEvents,