diff --git a/src/mocks/handlers/event.js b/src/mocks/handlers/event.js index dd7ccf35..75639ef6 100644 --- a/src/mocks/handlers/event.js +++ b/src/mocks/handlers/event.js @@ -214,6 +214,41 @@ export const eventHandlers = [ } }), + // 切换事件关注状态 + http.post('/api/events/:eventId/follow', async ({ params }) => { + await delay(200); + + const { eventId } = params; + + console.log('[Mock] 切换事件关注状态, eventId:', eventId); + + try { + // 模拟切换逻辑:随机生成关注状态 + // 实际应用中,这里应该从某个状态存储中读取和更新 + const isFollowing = Math.random() > 0.5; + const followerCount = Math.floor(Math.random() * 1000) + 100; + + return HttpResponse.json({ + success: true, + data: { + is_following: isFollowing, + follower_count: followerCount + }, + message: isFollowing ? '关注成功' : '取消关注成功' + }); + } catch (error) { + console.error('[Mock] 切换事件关注状态失败:', error); + return HttpResponse.json( + { + success: false, + error: '切换关注状态失败', + data: null + }, + { status: 500 } + ); + } + }), + // 获取事件传导链分析数据 http.get('/api/events/:eventId/transmission', async ({ params }) => { await delay(500); diff --git a/src/store/slices/communityDataSlice.js b/src/store/slices/communityDataSlice.js index d05f3aaa..a0f5255b 100644 --- a/src/store/slices/communityDataSlice.js +++ b/src/store/slices/communityDataSlice.js @@ -157,15 +157,30 @@ export const fetchHotEvents = createAsyncThunk( ); /** - * 获取动态新闻(无缓存,每次都发起请求) - * 用于 DynamicNewsCard 组件,需要保持实时性 - * @param {Object} params - 分页参数 { page, per_page } + * 获取动态新闻(客户端缓存 + 智能请求) + * 用于 DynamicNewsCard 组件 + * @param {Object} params - 请求参数 + * @param {number} params.page - 页码 + * @param {number} params.per_page - 每页数量 + * @param {boolean} params.clearCache - 是否清空缓存(默认 false) + * @param {boolean} params.prependMode - 是否追加到头部(用于定时刷新,默认 false) */ export const fetchDynamicNews = createAsyncThunk( 'communityData/fetchDynamicNews', - async ({ page = 1, per_page = 5 } = {}, { rejectWithValue }) => { + async ({ + page = 1, + per_page = 5, + clearCache = false, + prependMode = false + } = {}, { rejectWithValue }) => { try { - logger.debug('CommunityData', '开始获取动态新闻', { page, per_page }); + logger.debug('CommunityData', '开始获取动态新闻', { + page, + per_page, + clearCache, + prependMode + }); + const response = await eventService.getEvents({ page, per_page, @@ -180,12 +195,19 @@ export const fetchDynamicNews = createAsyncThunk( }); return { events: response.data.events, - pagination: response.data.pagination || {} + total: response.data.pagination?.total || 0, + clearCache, + prependMode }; } logger.warn('CommunityData', '动态新闻返回数据为空', response); - return { events: [], pagination: {} }; + return { + events: [], + total: 0, + clearCache, + prependMode + }; } catch (error) { logger.error('CommunityData', '获取动态新闻失败', error); return rejectWithValue(error.message || '获取动态新闻失败'); @@ -193,6 +215,51 @@ export const fetchDynamicNews = createAsyncThunk( } ); +/** + * 切换事件关注状态 + * 复用 EventList.js 中的关注逻辑 + * @param {number} eventId - 事件ID + */ +export const toggleEventFollow = createAsyncThunk( + 'communityData/toggleEventFollow', + async (eventId, { rejectWithValue }) => { + try { + logger.debug('CommunityData', '切换事件关注状态', { eventId }); + + // 调用 API(自动切换关注状态,后端根据当前状态决定关注/取消关注) + const response = await fetch(`/api/events/${eventId}/follow`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include' + }); + + const data = await response.json(); + + if (!response.ok || !data.success) { + throw new Error(data.error || '操作失败'); + } + + const isFollowing = data.data?.is_following; + const followerCount = data.data?.follower_count ?? 0; + + logger.info('CommunityData', '关注状态切换成功', { + eventId, + isFollowing, + followerCount + }); + + return { + eventId, + isFollowing, + followerCount + }; + } catch (error) { + logger.error('CommunityData', '切换关注状态失败', error); + return rejectWithValue(error.message || '切换关注状态失败'); + } + } +); + // ==================== Slice 定义 ==================== const communityDataSlice = createSlice({ @@ -201,8 +268,9 @@ const communityDataSlice = createSlice({ // 数据 popularKeywords: [], hotEvents: [], - dynamicNews: [], // 动态新闻(无缓存) - dynamicNewsPagination: {}, // 动态新闻分页信息 + dynamicNews: [], // 动态新闻完整缓存列表 + dynamicNewsTotal: 0, // 服务端总数量 + eventFollowStatus: {}, // 事件关注状态 { [eventId]: { isFollowing: boolean, followerCount: number } } // 加载状态 loading: { @@ -278,6 +346,16 @@ const communityDataSlice = createSlice({ preloadData: (state) => { logger.info('CommunityData', '准备预加载数据'); // 实际的预加载逻辑在组件中调用 dispatch(fetchPopularKeywords()) 等 + }, + + /** + * 设置单个事件的关注状态(同步) + * @param {Object} action.payload - { eventId, isFollowing, followerCount } + */ + setEventFollowStatus: (state, action) => { + const { eventId, isFollowing, followerCount } = action.payload; + state.eventFollowStatus[eventId] = { isFollowing, followerCount }; + logger.debug('CommunityData', '设置事件关注状态', { eventId, isFollowing, followerCount }); } }, @@ -286,34 +364,71 @@ const communityDataSlice = createSlice({ createDataReducers(builder, fetchPopularKeywords, 'popularKeywords'); createDataReducers(builder, fetchHotEvents, 'hotEvents'); - // dynamicNews 需要特殊处理(包含 pagination) + // dynamicNews 需要特殊处理(缓存 + 追加模式) builder .addCase(fetchDynamicNews.pending, (state) => { state.loading.dynamicNews = true; state.error.dynamicNews = null; }) .addCase(fetchDynamicNews.fulfilled, (state, action) => { + const { events, total, clearCache, prependMode } = action.payload; + + if (clearCache) { + // 清空缓存模式:直接替换 + state.dynamicNews = events; + logger.debug('CommunityData', '清空缓存并加载新数据', { + count: events.length + }); + } else if (prependMode) { + // 追加到头部模式(用于定时刷新):去重后插入头部 + const existingIds = new Set(state.dynamicNews.map(e => e.id)); + const newEvents = events.filter(e => !existingIds.has(e.id)); + state.dynamicNews = [...newEvents, ...state.dynamicNews]; + logger.debug('CommunityData', '追加新数据到头部', { + newCount: newEvents.length, + totalCount: state.dynamicNews.length + }); + } else { + // 追加到尾部模式(默认):去重后追加 + const existingIds = new Set(state.dynamicNews.map(e => e.id)); + const newEvents = events.filter(e => !existingIds.has(e.id)); + state.dynamicNews = [...state.dynamicNews, ...newEvents]; + logger.debug('CommunityData', '追加新数据到尾部', { + newCount: newEvents.length, + totalCount: state.dynamicNews.length + }); + } + + state.dynamicNewsTotal = total; state.loading.dynamicNews = false; - state.dynamicNews = action.payload.events; - state.dynamicNewsPagination = action.payload.pagination; state.lastUpdated.dynamicNews = new Date().toISOString(); }) .addCase(fetchDynamicNews.rejected, (state, action) => { state.loading.dynamicNews = false; state.error.dynamicNews = action.payload; logger.error('CommunityData', 'dynamicNews 加载失败', new Error(action.payload)); + }) + // toggleEventFollow + .addCase(toggleEventFollow.fulfilled, (state, action) => { + const { eventId, isFollowing, followerCount } = action.payload; + state.eventFollowStatus[eventId] = { isFollowing, followerCount }; + logger.debug('CommunityData', 'toggleEventFollow fulfilled', { eventId, isFollowing, followerCount }); + }) + .addCase(toggleEventFollow.rejected, (state, action) => { + logger.error('CommunityData', 'toggleEventFollow rejected', action.payload); }); } }); // ==================== 导出 ==================== -export const { clearCache, clearSpecificCache, preloadData } = communityDataSlice.actions; +export const { clearCache, clearSpecificCache, preloadData, setEventFollowStatus } = communityDataSlice.actions; // 基础选择器(Selectors) export const selectPopularKeywords = (state) => state.communityData.popularKeywords; export const selectHotEvents = (state) => state.communityData.hotEvents; export const selectDynamicNews = (state) => state.communityData.dynamicNews; +export const selectEventFollowStatus = (state) => state.communityData.eventFollowStatus; export const selectLoading = (state) => state.communityData.loading; export const selectError = (state) => state.communityData.error; export const selectLastUpdated = (state) => state.communityData.lastUpdated; @@ -334,10 +449,11 @@ export const selectHotEventsWithLoading = (state) => ({ }); export const selectDynamicNewsWithLoading = (state) => ({ - data: state.communityData.dynamicNews, + data: state.communityData.dynamicNews, // 完整缓存列表 loading: state.communityData.loading.dynamicNews, error: state.communityData.error.dynamicNews, - pagination: state.communityData.dynamicNewsPagination, + total: state.communityData.dynamicNewsTotal, // 服务端总数量 + cachedCount: state.communityData.dynamicNews.length, // 已缓存数量 lastUpdated: state.communityData.lastUpdated.dynamicNews }); diff --git a/src/views/Community/components/DynamicNewsCard.js b/src/views/Community/components/DynamicNewsCard.js index 923a7849..229ca4da 100644 --- a/src/views/Community/components/DynamicNewsCard.js +++ b/src/views/Community/components/DynamicNewsCard.js @@ -1,8 +1,8 @@ // src/views/Community/components/DynamicNewsCard.js // 横向滚动事件卡片组件(实时要闻·动态追踪) -import React, { forwardRef, useState, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; +import React, { forwardRef, useState, useEffect, useMemo, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { Card, CardHeader, @@ -22,13 +22,14 @@ import { TimeIcon } from '@chakra-ui/icons'; import EventScrollList from './DynamicNewsCard/EventScrollList'; import DynamicNewsDetailPanel from './DynamicNewsDetail'; import UnifiedSearchBox from './UnifiedSearchBox'; -import { fetchDynamicNews } from '../../../store/slices/communityDataSlice'; +import { fetchDynamicNews, toggleEventFollow, selectEventFollowStatus } from '../../../store/slices/communityDataSlice'; /** * 实时要闻·动态追踪 - 事件展示卡片组件 - * @param {Array} events - 事件列表 + * @param {Array} allCachedEvents - 完整缓存事件列表(从 Redux 传入) * @param {boolean} loading - 加载状态 - * @param {Object} pagination - 分页信息 { page, per_page, total, total_pages } + * @param {number} total - 服务端总数量 + * @param {number} cachedCount - 已缓存数量 * @param {Object} filters - 筛选条件 * @param {Array} popularKeywords - 热门关键词 * @param {Date} lastUpdateTime - 最后更新时间 @@ -36,13 +37,13 @@ import { fetchDynamicNews } from '../../../store/slices/communityDataSlice'; * @param {Function} onSearchFocus - 搜索框获得焦点回调 * @param {Function} onEventClick - 事件点击回调 * @param {Function} onViewDetail - 查看详情回调 - * @param {string} mode - 展示模式:'carousel'(单排轮播5个)| 'grid'(双排网格10个) * @param {Object} ref - 用于滚动的ref */ const DynamicNewsCard = forwardRef(({ - events, + allCachedEvents = [], loading, - pagination = {}, + total = 0, + cachedCount = 0, filters = {}, popularKeywords = [], lastUpdateTime, @@ -55,41 +56,104 @@ const DynamicNewsCard = forwardRef(({ const dispatch = useDispatch(); const cardBg = useColorModeValue('white', 'gray.800'); const borderColor = useColorModeValue('gray.200', 'gray.700'); + + // 从 Redux 读取关注状态 + const eventFollowStatus = useSelector(selectEventFollowStatus); + + // 关注按钮点击处理 + const handleToggleFollow = useCallback((eventId) => { + dispatch(toggleEventFollow(eventId)); + }, [dispatch]); + + // 本地状态 const [selectedEvent, setSelectedEvent] = useState(null); const [mode, setMode] = useState('carousel'); // 'carousel' 或 'grid',默认单排 + const [currentPage, setCurrentPage] = useState(1); // 当前页码 // 根据模式决定每页显示数量 - const pageSize = mode === 'carousel' ? 5 : 10; // carousel: 5个, grid: 10个 - const currentPage = pagination.page || 1; - const totalPages = pagination.total_pages || 1; + const pageSize = mode === 'carousel' ? 5 : 10; + + // 计算总页数(基于缓存数量) + const totalPages = Math.ceil(cachedCount / pageSize) || 1; + + // 检查是否还有更多数据 + const hasMore = cachedCount < total; + + // 从缓存中切片获取当前页数据 + const currentPageEvents = useMemo(() => { + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + return allCachedEvents.slice(startIndex, endIndex); + }, [allCachedEvents, currentPage, pageSize]); + + // 检查是否需要请求更多数据 + const shouldFetchMore = useCallback((targetPage) => { + const requiredCount = targetPage * pageSize; + // 如果缓存不足,且服务端还有更多数据 + return cachedCount < requiredCount && hasMore; + }, [cachedCount, total, pageSize, hasMore]); + + // 翻页处理 + const handlePageChange = useCallback((newPage) => { + // 向后翻页(上一页):不请求,直接切换 + if (newPage < currentPage) { + setCurrentPage(newPage); + return; + } + + // 向前翻页(下一页):检查是否需要请求 + if (shouldFetchMore(newPage)) { + // 计算需要请求的页码(从缓存末尾继续) + const nextFetchPage = Math.ceil(cachedCount / pageSize) + 1; + + dispatch(fetchDynamicNews({ + page: nextFetchPage, + per_page: pageSize, + clearCache: false + })); + } + + setCurrentPage(newPage); + }, [currentPage, cachedCount, pageSize, shouldFetchMore, dispatch]); // 模式切换处理 - const handleModeToggle = (newMode) => { - if (newMode !== mode) { - setMode(newMode); - // 切换模式时重置到第1页并重新请求数据 - const newPageSize = newMode === 'carousel' ? 5 : 10; - dispatch(fetchDynamicNews({ page: 1, per_page: newPageSize })); - // 清除当前选中的事件 - setSelectedEvent(null); + const handleModeToggle = useCallback((newMode) => { + if (newMode === mode) return; + + setMode(newMode); + setCurrentPage(1); + + const newPageSize = newMode === 'carousel' ? 5 : 10; + + // 检查缓存是否足够显示第1页 + if (cachedCount < newPageSize) { + // 清空缓存,重新请求 + dispatch(fetchDynamicNews({ + page: 1, + per_page: newPageSize, + clearCache: true + })); } - }; + // 如果缓存足够,不发起请求,直接切换 + }, [mode, cachedCount, dispatch]); + + // 初始加载 + useEffect(() => { + if (allCachedEvents.length === 0) { + dispatch(fetchDynamicNews({ + page: 1, + per_page: 5, + clearCache: true + })); + } + }, [dispatch, allCachedEvents.length]); // 默认选中第一个事件 useEffect(() => { - if (events && events.length > 0 && !selectedEvent) { - setSelectedEvent(events[0]); + if (currentPageEvents.length > 0 && !selectedEvent) { + setSelectedEvent(currentPageEvents[0]); } - }, [events, selectedEvent]); - - // 页码改变时,触发服务端分页请求 - const handlePageChange = (newPage) => { - // 发起 Redux action 获取新页面数据 - dispatch(fetchDynamicNews({ page: newPage, per_page: pageSize })); - - // 保持当前选中事件,避免详情面板消失导致页面抖动 - // 新数据加载完成后,useEffect 会自动选中第一个事件 - }; + }, [currentPageEvents, selectedEvent]); return ( @@ -128,9 +192,9 @@ const DynamicNewsCard = forwardRef(({ {/* 主体内容 */} {/* 横向滚动事件列表 - 始终渲染(除非为空) */} - {events && events.length > 0 ? ( + {currentPageEvents && currentPageEvents.length > 0 ? ( ) : !loading ? ( /* Empty 状态 - 只在非加载且无数据时显示 */ @@ -159,7 +226,7 @@ const DynamicNewsCard = forwardRef(({ )} {/* 详情面板 - 始终显示(如果有选中事件) */} - {events && events.length > 0 && selectedEvent && ( + {currentPageEvents && currentPageEvents.length > 0 && selectedEvent && ( diff --git a/src/views/Community/components/DynamicNewsCard/EventScrollList.js b/src/views/Community/components/DynamicNewsCard/EventScrollList.js index 81a9a9ff..6e47b99a 100644 --- a/src/views/Community/components/DynamicNewsCard/EventScrollList.js +++ b/src/views/Community/components/DynamicNewsCard/EventScrollList.js @@ -31,6 +31,9 @@ import PaginationControl from './PaginationControl'; * @param {boolean} loading - 加载状态 * @param {string} mode - 展示模式:'carousel'(单排轮播)| 'grid'(双排网格) * @param {Function} onModeChange - 模式切换回调 + * @param {boolean} hasMore - 是否还有更多数据 + * @param {Object} eventFollowStatus - 事件关注状态 { [eventId]: { isFollowing, followerCount } } + * @param {Function} onToggleFollow - 关注按钮回调 */ const EventScrollList = ({ events, @@ -42,7 +45,10 @@ const EventScrollList = ({ onPageChange, loading = false, mode = 'carousel', - onModeChange + onModeChange, + hasMore = true, + eventFollowStatus = {}, + onToggleFollow }) => { const scrollContainerRef = useRef(null); @@ -121,7 +127,7 @@ const EventScrollList = ({ )} {/* 右侧翻页按钮 - 下一页 */} - {currentPage < totalPages && ( + {currentPage < totalPages && hasMore && ( } position="absolute" @@ -143,6 +149,7 @@ const EventScrollList = ({ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)', transform: 'translateY(-50%) scale(1.05)' }} + isDisabled={currentPage >= totalPages && !hasMore} aria-label="下一页" title="下一页" /> @@ -211,8 +218,8 @@ const EventScrollList = ({ { onEventSelect(clickedEvent); @@ -222,7 +229,7 @@ const EventScrollList = ({ e.stopPropagation(); onEventSelect(event); }} - onToggleFollow={() => {}} + onToggleFollow={() => onToggleFollow?.(event.id)} timelineStyle={getTimelineBoxStyle()} borderColor={borderColor} /> @@ -244,8 +251,8 @@ const EventScrollList = ({ { onEventSelect(clickedEvent); @@ -255,7 +262,7 @@ const EventScrollList = ({ e.stopPropagation(); onEventSelect(event); }} - onToggleFollow={() => {}} + onToggleFollow={() => onToggleFollow?.(event.id)} timelineStyle={getTimelineBoxStyle()} borderColor={borderColor} /> diff --git a/src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js b/src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js index 94108439..1516b7db 100644 --- a/src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js +++ b/src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js @@ -2,6 +2,7 @@ // 动态新闻详情面板主组件(组装所有子组件) import React, { useState, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { Card, CardBody, @@ -15,6 +16,7 @@ import { import { getImportanceConfig } from '../../../../constants/importanceLevels'; import { eventService } from '../../../../services/eventService'; import { useEventStocks } from '../StockDetailPanel/hooks/useEventStocks'; +import { toggleEventFollow, selectEventFollowStatus } from '../../../../store/slices/communityDataSlice'; import EventHeaderInfo from './EventHeaderInfo'; import EventDescriptionSection from './EventDescriptionSection'; import RelatedConceptsSection from './RelatedConceptsSection'; @@ -29,11 +31,17 @@ import TransmissionChainAnalysis from '../../../EventDetail/components/Transmiss * @param {Object} props.event - 事件对象(包含详情数据) */ const DynamicNewsDetailPanel = ({ event }) => { + const dispatch = useDispatch(); const cardBg = useColorModeValue('white', 'gray.800'); const borderColor = useColorModeValue('gray.200', 'gray.700'); const textColor = useColorModeValue('gray.600', 'gray.400'); const toast = useToast(); + // 从 Redux 读取关注状态 + const eventFollowStatus = useSelector(selectEventFollowStatus); + const isFollowing = event?.id ? (eventFollowStatus[event.id]?.isFollowing || false) : false; + const followerCount = event?.id ? (eventFollowStatus[event.id]?.followerCount || event.follower_count || 0) : 0; + // 使用 Hook 获取实时数据 const { stocks, @@ -49,10 +57,6 @@ const DynamicNewsDetailPanel = ({ event }) => { const [isHistoricalOpen, setIsHistoricalOpen] = useState(true); const [isTransmissionOpen, setIsTransmissionOpen] = useState(false); - // 关注状态管理 - const [isFollowing, setIsFollowing] = useState(false); - const [followerCount, setFollowerCount] = useState(0); - // 自选股管理(使用 localStorage) const [watchlistSet, setWatchlistSet] = useState(() => { try { @@ -64,23 +68,10 @@ const DynamicNewsDetailPanel = ({ event }) => { }); // 切换关注状态 - const handleToggleFollow = async () => { - try { - if (isFollowing) { - // 取消关注 - await eventService.unfollowEvent(event.id); - setIsFollowing(false); - setFollowerCount(prev => Math.max(0, prev - 1)); - } else { - // 添加关注 - await eventService.followEvent(event.id); - setIsFollowing(true); - setFollowerCount(prev => prev + 1); - } - } catch (error) { - console.error('切换关注状态失败:', error); - } - }; + const handleToggleFollow = useCallback(async () => { + if (!event?.id) return; + dispatch(toggleEventFollow(event.id)); + }, [dispatch, event?.id]); // 切换自选股 const handleWatchlistToggle = useCallback(async (stockCode, isInWatchlist) => { diff --git a/src/views/Community/index.js b/src/views/Community/index.js index 6d4de096..de907466 100644 --- a/src/views/Community/index.js +++ b/src/views/Community/index.js @@ -48,10 +48,11 @@ const Community = () => { // Redux状态 const { popularKeywords, hotEvents } = useSelector(state => state.communityData); const { - data: dynamicNewsEvents, + data: allCachedEvents, loading: dynamicNewsLoading, error: dynamicNewsError, - pagination: dynamicNewsPagination + total: dynamicNewsTotal, + cachedCount: dynamicNewsCachedCount } = useSelector(selectDynamicNewsWithLoading); // Chakra UI hooks @@ -96,17 +97,20 @@ const Community = () => { }; }, [events]); - // 加载热门关键词、热点事件和动态新闻(使用Redux) + // 加载热门关键词和热点事件(动态新闻由 DynamicNewsCard 内部管理) useEffect(() => { dispatch(fetchPopularKeywords()); dispatch(fetchHotEvents()); - dispatch(fetchDynamicNews()); }, [dispatch]); - // 每5分钟刷新一次动态新闻 + // 每5分钟刷新一次动态新闻(使用 prependMode 追加到头部) useEffect(() => { const interval = setInterval(() => { - dispatch(fetchDynamicNews()); + dispatch(fetchDynamicNews({ + page: 1, + per_page: 10, // 获取最新的10条 + prependMode: true // 追加到头部,不清空缓存 + })); }, 5 * 60 * 1000); return () => clearInterval(interval); @@ -186,9 +190,10 @@ const Community = () => { {/* 实时要闻·动态追踪 - 横向滚动 */} { onSearchFocus={scrollToTimeline} onEventClick={handleEventClick} onViewDetail={handleViewDetail} - mode="grid" /> {/* 市场复盘 - 左右布局 */}