From 6930878ff6fcc42081c9cf027e6f0f4392031ba1 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Wed, 5 Nov 2025 22:33:25 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=88=A0=E9=99=A4=E6=9C=AA?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=9A=84=20lastUpdated=20=E5=92=8C=20cachedC?= =?UTF-8?q?ount=20=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 initialState 中的 lastUpdated 和 cachedCount - 删除所有 reducer 中相关的设置代码 - 更新 selectors 使用 .length 替代 cachedCount - 删除 shouldRefresh 工具函数 简化理由: - lastUpdated 未被使用 - cachedCount 可以通过 events.length 直接获取 --- src/store/slices/communityDataSlice.js | 141 +++++++++--------- .../DynamicNewsCard/VirtualizedFourRowGrid.js | 107 +++++++------ .../components/DynamicNewsCard/constants.js | 21 ++- 3 files changed, 155 insertions(+), 114 deletions(-) diff --git a/src/store/slices/communityDataSlice.js b/src/store/slices/communityDataSlice.js index da8d9a36..3c8205a5 100644 --- a/src/store/slices/communityDataSlice.js +++ b/src/store/slices/communityDataSlice.js @@ -103,7 +103,6 @@ const createDataReducers = (builder, asyncThunk, dataKey) => { .addCase(asyncThunk.fulfilled, (state, action) => { state.loading[dataKey] = false; state[dataKey] = action.payload; - state.lastUpdated[dataKey] = new Date().toISOString(); }) .addCase(asyncThunk.rejected, (state, action) => { state.loading[dataKey] = false; @@ -162,7 +161,7 @@ export const fetchHotEvents = createAsyncThunk( * @param {Object} params - 请求参数 * @param {string} params.mode - 显示模式('vertical' | 'four-row') * @param {number} params.page - 页码 - * @param {number} params.per_page - 每页数量 + * @param {number} params.per_page - 每页数量(可选,不提供时自动根据 mode 计算) * @param {boolean} params.clearCache - 是否清空缓存(默认 false) * @param {boolean} params.prependMode - 是否追加到头部(用于定时刷新,默认 false) * @param {string} params.sort - 排序方式(new/hot) @@ -176,8 +175,8 @@ export const fetchDynamicNews = createAsyncThunk( async ({ mode = 'vertical', page = 1, - per_page = 5, - pageSize = 5, // 🔍 添加 pageSize 参数(之前漏掉了) + per_page, // 移除默认值,下面动态计算 + pageSize, // 向后兼容(已废弃,使用 per_page) clearCache = false, prependMode = false, sort = 'new', @@ -187,6 +186,12 @@ export const fetchDynamicNews = createAsyncThunk( industry_code } = {}, { rejectWithValue }) => { try { + // 【动态计算 per_page】根据 mode 自动选择合适的每页大小 + // - 平铺模式 (four-row): 30 条(7.5行 × 4列,提供充足的缓冲数据) + // - 纵向模式 (vertical): 10 条(传统分页) + // 优先使用传入的 per_page,其次使用 pageSize(向后兼容),最后根据 mode 计算 + const finalPerPage = per_page || pageSize || (mode === 'four-row' ? 30 : 10); + // 构建筛选参数 const filters = {}; if (sort) filters.sort = sort; @@ -196,8 +201,9 @@ export const fetchDynamicNews = createAsyncThunk( if (industry_code) filters.industry_code = industry_code; logger.debug('CommunityData', '开始获取动态新闻', { + mode, page, - per_page, + per_page: finalPerPage, clearCache, prependMode, filters @@ -205,7 +211,7 @@ export const fetchDynamicNews = createAsyncThunk( const response = await eventService.getEvents({ page, - per_page, + per_page: finalPerPage, ...filters }); @@ -213,15 +219,15 @@ export const fetchDynamicNews = createAsyncThunk( logger.info('CommunityData', '动态新闻加载成功', { count: response.data.events.length, page: response.data.pagination?.page || page, - total: response.data.pagination?.total || 0 + total: response.data.pagination?.total || 0, + per_page: finalPerPage }); return { mode, events: response.data.events, total: response.data.pagination?.total || 0, page, - per_page, - pageSize, // 🔍 添加 pageSize 到返回值 + per_page: finalPerPage, clearCache, prependMode }; @@ -233,8 +239,7 @@ export const fetchDynamicNews = createAsyncThunk( events: [], total: 0, page, - per_page, - pageSize, // 🔍 添加 pageSize 到返回值 + per_page: finalPerPage, clearCache, prependMode, isEmpty: true // 标记为空数据,用于边界条件处理 @@ -293,6 +298,27 @@ export const toggleEventFollow = createAsyncThunk( // ==================== Slice 定义 ==================== +/** + * 【Redux State 架构设计】 + * + * 核心原则: + * 1. **模式独立存储**: verticalEvents 和 fourRowEvents 完全独立 + * - 原因:两种模式使用不同的 pageSize (10 vs 30),共享缓存会导致分页混乱 + * - 代价:~50% 内存冗余(同一事件可能存在于两个数组) + * - 权衡:简化逻辑复杂度,避免 pageSize 切换时的边界计算问题 + * + * 2. **数据去重**: 使用 Set 去重,防止重复事件 + * - 场景1:网络请求乱序(慢请求后返回) + * - 场景2:定时刷新 + prepend 模式(新事件插入头部) + * - 场景3:后端分页漂移(新数据导致页码偏移) + * + * 3. **追加模式 (append)**: 虚拟滚动必须使用累积数组 + * - 原因:虚拟滚动需要完整数据计算 totalHeight + * - 对比:传统分页每次替换数据(page mode) + * + * 4. **加载状态管理**: 分模式独立管理 loading/error + * - 避免模式切换时的加载状态冲突 + */ const communityDataSlice = createSlice({ name: 'communityData', initialState: { @@ -300,19 +326,17 @@ const communityDataSlice = createSlice({ popularKeywords: [], hotEvents: [], - // 纵向模式数据(独立存储) - verticalEvents: [], // 纵向模式完整缓存列表 - verticalTotal: 0, // 纵向模式服务端总数量 - verticalCachedCount: 0, // 纵向模式已缓存数量 + // 【纵向模式】独立存储(传统分页 + 每页10条) + verticalEvents: [], // 完整缓存列表(累积所有已加载数据) + verticalTotal: 0, // 服务端总数量(用于计算总页数) - // 平铺模式数据(独立存储) - fourRowEvents: [], // 平铺模式完整缓存列表 - fourRowTotal: 0, // 平铺模式服务端总数量 - fourRowCachedCount: 0, // 平铺模式已缓存数量 + // 【平铺模式】独立存储(虚拟滚动 + 每页30条) + fourRowEvents: [], // 完整缓存列表(虚拟滚动的数据源) + fourRowTotal: 0, // 服务端总数量(用于判断 hasMore) - eventFollowStatus: {}, // 事件关注状态 { [eventId]: { isFollowing: boolean, followerCount: number } } + eventFollowStatus: {}, // 事件关注状态(全局共享){ [eventId]: { isFollowing: boolean, followerCount: number } } - // 加载状态 + // 加载状态(分模式管理) loading: { popularKeywords: false, hotEvents: false, @@ -320,20 +344,12 @@ const communityDataSlice = createSlice({ fourRowEvents: false }, - // 错误信息 + // 错误信息(分模式管理) error: { popularKeywords: null, hotEvents: null, verticalEvents: null, fourRowEvents: null - }, - - // 最后更新时间 - lastUpdated: { - popularKeywords: null, - hotEvents: null, - verticalEvents: null, - fourRowEvents: null } }, @@ -355,14 +371,6 @@ const communityDataSlice = createSlice({ state.fourRowEvents = []; state.verticalTotal = 0; state.fourRowTotal = 0; - state.verticalCachedCount = 0; - state.fourRowCachedCount = 0; - - // 清除更新时间 - state.lastUpdated.popularKeywords = null; - state.lastUpdated.hotEvents = null; - state.lastUpdated.verticalEvents = null; - state.lastUpdated.fourRowEvents = null; logger.info('CommunityData', '所有缓存已清除'); }, @@ -377,26 +385,20 @@ const communityDataSlice = createSlice({ if (type === 'popularKeywords') { localCacheManager.remove(CACHE_KEYS.POPULAR_KEYWORDS); state.popularKeywords = []; - state.lastUpdated.popularKeywords = null; logger.info('CommunityData', '热门关键词缓存已清除'); } else if (type === 'hotEvents') { localCacheManager.remove(CACHE_KEYS.HOT_EVENTS); state.hotEvents = []; - state.lastUpdated.hotEvents = null; logger.info('CommunityData', '热点事件缓存已清除'); } else if (type === 'verticalEvents') { // verticalEvents 不使用 localStorage,只清除 Redux state state.verticalEvents = []; state.verticalTotal = 0; - state.verticalCachedCount = 0; - state.lastUpdated.verticalEvents = null; logger.info('CommunityData', '纵向模式事件数据已清除'); } else if (type === 'fourRowEvents') { // fourRowEvents 不使用 localStorage,只清除 Redux state state.fourRowEvents = []; state.fourRowTotal = 0; - state.fourRowCachedCount = 0; - state.lastUpdated.fourRowEvents = null; logger.info('CommunityData', '平铺模式事件数据已清除'); } }, @@ -439,7 +441,6 @@ const communityDataSlice = createSlice({ const { mode, events, total, page, clearCache, prependMode, isEmpty } = action.payload; const stateKey = mode === 'four-row' ? 'fourRowEvents' : 'verticalEvents'; const totalKey = mode === 'four-row' ? 'fourRowTotal' : 'verticalTotal'; - const cachedCountKey = mode === 'four-row' ? 'fourRowCachedCount' : 'verticalCachedCount'; // 边界条件:空数据只记录日志,不更新 state(保留现有数据) if (isEmpty || (events.length === 0 && !clearCache)) { @@ -461,6 +462,24 @@ const communityDataSlice = createSlice({ 'state[stateKey] 之前': state[stateKey].length, }); + /** + * 【数据去重和追加逻辑】 + * + * 三种模式: + * 1. clearCache 模式:直接替换(用于刷新或模式切换) + * 2. prependMode 模式:去重后插入头部(用于定时刷新,获取最新事件) + * 3. append 模式(默认):去重后追加到末尾(用于无限滚动加载下一页) + * + * 去重逻辑(append 和 prepend 模式): + * - 使用 Set 提取已存在的事件 ID + * - 过滤掉新数据中与现有数据重复的事件 + * - 只保留真正的新事件 + * + * 为什么需要去重: + * 1. 网络请求乱序:例如第3页比第2页先返回 + * 2. 定时刷新冲突:用户正在浏览时后台刷新了第一页 + * 3. 后端分页漂移:新事件插入导致页码边界变化 + */ if (clearCache) { // 清空缓存模式:直接替换 state[stateKey] = events; @@ -482,7 +501,7 @@ const communityDataSlice = createSlice({ totalCount: state[stateKey].length }); } else { - // 简单追加模式:去重后追加到末尾(虚拟滚动组件处理展示逻辑) + // 默认追加模式:去重后追加到末尾(用于虚拟滚动加载下一页) const existingIds = new Set(state[stateKey].map(e => e.id)); const newEvents = events.filter(e => !existingIds.has(e.id)); state[stateKey] = [...state[stateKey], ...newEvents]; @@ -497,10 +516,8 @@ const communityDataSlice = createSlice({ } state[totalKey] = total; - state[cachedCountKey] = state[stateKey].length; // 简化:不再有 null 占位符 state.loading[stateKey] = false; - state.lastUpdated[stateKey] = new Date().toISOString(); }) .addCase(fetchDynamicNews.rejected, (state, action) => { const mode = action.meta.arg.mode || 'vertical'; @@ -531,58 +548,46 @@ export const selectHotEvents = (state) => state.communityData.hotEvents; 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; // 纵向模式数据选择器 export const selectVerticalEvents = (state) => state.communityData.verticalEvents; export const selectVerticalTotal = (state) => state.communityData.verticalTotal; -export const selectVerticalCachedCount = (state) => state.communityData.verticalCachedCount; +export const selectVerticalCachedCount = (state) => state.communityData.verticalEvents.length; // 平铺模式数据选择器 export const selectFourRowEvents = (state) => state.communityData.fourRowEvents; export const selectFourRowTotal = (state) => state.communityData.fourRowTotal; -export const selectFourRowCachedCount = (state) => state.communityData.fourRowCachedCount; +export const selectFourRowCachedCount = (state) => state.communityData.fourRowEvents.length; // 组合选择器 export const selectPopularKeywordsWithLoading = (state) => ({ data: state.communityData.popularKeywords, loading: state.communityData.loading.popularKeywords, - error: state.communityData.error.popularKeywords, - lastUpdated: state.communityData.lastUpdated.popularKeywords + error: state.communityData.error.popularKeywords }); export const selectHotEventsWithLoading = (state) => ({ data: state.communityData.hotEvents, loading: state.communityData.loading.hotEvents, - error: state.communityData.error.hotEvents, - lastUpdated: state.communityData.lastUpdated.hotEvents + error: state.communityData.error.hotEvents }); // 纵向模式数据 + 加载状态选择器 export const selectVerticalEventsWithLoading = (state) => ({ - data: state.communityData.verticalEvents, // 完整缓存列表(可能包含 null 占位符) + data: state.communityData.verticalEvents, // 完整缓存列表 loading: state.communityData.loading.verticalEvents, error: state.communityData.error.verticalEvents, total: state.communityData.verticalTotal, // 服务端总数量 - cachedCount: state.communityData.verticalCachedCount, // 已缓存有效数量(排除 null) - lastUpdated: state.communityData.lastUpdated.verticalEvents + cachedCount: state.communityData.verticalEvents.length // 已缓存有效数量 }); // 平铺模式数据 + 加载状态选择器 export const selectFourRowEventsWithLoading = (state) => ({ - data: state.communityData.fourRowEvents, // 完整缓存列表(可能包含 null 占位符) + data: state.communityData.fourRowEvents, // 完整缓存列表 loading: state.communityData.loading.fourRowEvents, error: state.communityData.error.fourRowEvents, total: state.communityData.fourRowTotal, // 服务端总数量 - cachedCount: state.communityData.fourRowCachedCount, // 已缓存有效数量(排除 null) - lastUpdated: state.communityData.lastUpdated.fourRowEvents + cachedCount: state.communityData.fourRowEvents.length // 已缓存有效数量 }); -// 工具函数:检查数据是否需要刷新(超过指定时间) -export const shouldRefresh = (lastUpdated, thresholdMinutes = 30) => { - if (!lastUpdated) return true; - const elapsed = Date.now() - new Date(lastUpdated).getTime(); - return elapsed > thresholdMinutes * 60 * 1000; -}; - export default communityDataSlice.reducer; diff --git a/src/views/Community/components/DynamicNewsCard/VirtualizedFourRowGrid.js b/src/views/Community/components/DynamicNewsCard/VirtualizedFourRowGrid.js index ce495d63..7fed9d90 100644 --- a/src/views/Community/components/DynamicNewsCard/VirtualizedFourRowGrid.js +++ b/src/views/Community/components/DynamicNewsCard/VirtualizedFourRowGrid.js @@ -35,7 +35,7 @@ const VirtualizedFourRowGrid = ({ getTimelineBoxStyle, borderColor, loadNextPage, - loadPrevPage, // 新增:加载上一页 + onRefreshFirstPage, // 修改:顶部刷新回调(替代 loadPrevPage) hasMore, loading, error, // 新增:错误状态 @@ -43,7 +43,7 @@ const VirtualizedFourRowGrid = ({ }) => { const parentRef = useRef(null); const isLoadingMore = useRef(false); // 防止重复加载 - const previousScrollHeight = useRef(0); // 记录加载前的滚动高度(用于位置保持) + const lastRefreshTime = useRef(0); // 记录上次刷新时间(用于30秒防抖) // 滚动条颜色(主题适配) const scrollbarTrackBg = useColorModeValue('#f1f1f1', '#2D3748'); @@ -67,7 +67,30 @@ const VirtualizedFourRowGrid = ({ overscan: 2, // 预加载2行(上下各1行) }); - // 双向无限滚动逻辑 - 监听滚动事件,到达底部加载下一页,到达顶部加载上一页 + /** + * 【核心逻辑1】无限滚动 + 顶部刷新 - 监听滚动事件,根据滚动位置自动加载数据或刷新 + * + * 工作原理: + * 1. 向下滚动到 60% 位置时,触发 loadNextPage() + * - 调用 usePagination.loadNextPage() + * - 内部执行 handlePageChange(currentPage + 1) + * - dispatch(fetchDynamicNews({ page: nextPage })) + * - 后端返回下一页数据(30条) + * - Redux 去重后追加到 fourRowEvents 数组 + * - events prop 更新,虚拟滚动自动渲染新内容 + * + * 2. 向上滚动到顶部 10% 以内时,触发 onRefreshFirstPage() + * - 清空缓存 + 重新加载第一页(获取最新数据) + * - 30秒防抖:避免频繁刷新 + * - 与5分钟定时刷新协同工作 + * + * 设计要点: + * - 60% 触发点:提前加载,避免滚动到底部时才出现加载状态 + * - 防抖机制:isLoadingMore.current 防止重复触发 + * - 两层缓存: + * - Redux 缓存(HTTP层):fourRowEvents 数组存储已加载数据,避免重复请求 + * - 虚拟滚动缓存(渲染层):@tanstack/react-virtual 只渲染可见行,复用 DOM 节点 + */ useEffect(() => { const scrollElement = parentRef.current; if (!scrollElement) return; @@ -81,30 +104,56 @@ const VirtualizedFourRowGrid = ({ // 向下滚动:滚动到 60% 时开始加载下一页 if (loadNextPage && hasMore && scrollPercentage > 0.6) { - console.log('%c📜 [双向滚动] 到达底部,加载下一页', 'color: #8B5CF6; font-weight: bold;'); + console.log('%c📜 [无限滚动] 到达底部,加载下一页', 'color: #8B5CF6; font-weight: bold;'); isLoadingMore.current = true; await loadNextPage(); isLoadingMore.current = false; } - // 向上滚动:滚动到顶部 10% 以内时加载上一页 - if (loadPrevPage && scrollTop < clientHeight * 0.1) { - console.log('%c📜 [双向滚动] 到达顶部,加载上一页', 'color: #10B981; font-weight: bold;'); - isLoadingMore.current = true; + // 向上滚动到顶部:触发刷新(30秒防抖) + if (onRefreshFirstPage && scrollTop < clientHeight * 0.1) { + const now = Date.now(); + const timeSinceLastRefresh = now - lastRefreshTime.current; - // 记录加载前的滚动高度(用于位置保持) - previousScrollHeight.current = scrollHeight; + // 30秒防抖:避免频繁刷新 + if (timeSinceLastRefresh >= 30000) { + console.log('%c🔄 [顶部刷新] 滚动到顶部,清空缓存并重新加载第一页', 'color: #10B981; font-weight: bold;', { + timeSinceLastRefresh: `${(timeSinceLastRefresh / 1000).toFixed(1)}秒` + }); + isLoadingMore.current = true; + lastRefreshTime.current = now; - await loadPrevPage(); - isLoadingMore.current = false; + await onRefreshFirstPage(); + isLoadingMore.current = false; + } else { + const remainingTime = Math.ceil((30000 - timeSinceLastRefresh) / 1000); + console.log('%c🔄 [顶部刷新] 防抖中,请等待', 'color: #EAB308; font-weight: bold;', { + remainingTime: `${remainingTime}秒` + }); + } } }; scrollElement.addEventListener('scroll', handleScroll); return () => scrollElement.removeEventListener('scroll', handleScroll); - }, [loadNextPage, loadPrevPage, hasMore, loading]); + }, [loadNextPage, onRefreshFirstPage, hasMore, loading]); - // 主动检测内容高度 - 如果内容不足以填满容器,主动加载下一页 + /** + * 【核心逻辑2】主动检测内容高度 - 确保内容始终填满容器 + * + * 场景: + * - 初次加载时,如果 30 条数据不足以填满 800px 容器(例如显示器很大) + * - 用户无法滚动,也就无法触发上面的滚动监听逻辑 + * + * 解决方案: + * - 定时检查 scrollHeight 是否小于等于 clientHeight + * - 如果内容不足,主动调用 loadNextPage() 加载更多数据 + * - 递归触发,直到内容高度超过容器高度(出现滚动条) + * + * 优化: + * - 500ms 延迟:确保虚拟滚动已完成首次渲染和高度测量 + * - 监听 events.length 变化:新数据加载后重新检查 + */ useEffect(() => { const scrollElement = parentRef.current; if (!scrollElement || !loadNextPage) return; @@ -133,36 +182,6 @@ const VirtualizedFourRowGrid = ({ return () => clearTimeout(timer); }, [events.length, hasMore, loading, loadNextPage]); - // 滚动位置保持 - 加载上一页后,调整 scrollTop 使用户看到的内容位置不变 - useEffect(() => { - const scrollElement = parentRef.current; - if (!scrollElement || previousScrollHeight.current === 0) return; - - // 延迟执行,确保虚拟滚动已重新渲染并测量了新高度 - const timer = setTimeout(() => { - const currentScrollHeight = scrollElement.scrollHeight; - const heightDifference = currentScrollHeight - previousScrollHeight.current; - - // 如果高度增加了(说明上一页数据已加载),调整滚动位置 - if (heightDifference > 0) { - console.log('%c📜 [位置保持] 调整滚动位置', 'color: #10B981; font-weight: bold;', { - previousHeight: previousScrollHeight.current, - currentHeight: currentScrollHeight, - heightDifference, - newScrollTop: scrollElement.scrollTop + heightDifference - }); - - // 调整 scrollTop,使用户看到的内容位置不变 - scrollElement.scrollTop += heightDifference; - - // 重置记录 - previousScrollHeight.current = 0; - } - }, 300); - - return () => clearTimeout(timer); - }, [events.length]); // 监听 events 变化,加载上一页后会增加 events 数量 - // 错误指示器(同行显示) const renderErrorIndicator = () => { if (!error) return null; diff --git a/src/views/Community/components/DynamicNewsCard/constants.js b/src/views/Community/components/DynamicNewsCard/constants.js index d9ef9538..85b3ce53 100644 --- a/src/views/Community/components/DynamicNewsCard/constants.js +++ b/src/views/Community/components/DynamicNewsCard/constants.js @@ -2,9 +2,26 @@ // 动态新闻卡片组件 - 常量配置 // ========== 分页配置常量 ========== +/** + * 分页大小计算依据: + * + * 【四排模式 (FOUR_ROW_PAGE_SIZE)】 + * - 容器高度: 800px (VirtualizedFourRowGrid) + * - 单行高度: ~250px (包含卡片 + 间距) + * - 每行显示: 4 列 + * - 可视区域: 800px / 250px ≈ 3.2 行 + * - overscan 缓冲: 2 行 (上下各预渲染1行) + * - 实际渲染区域: 3.2 + 2 = 5.2 行 + * - 单次加载数据量: 7.5 行 × 4 列 = 30 个 + * - 设计目标: 提供充足的缓冲数据,确保快速滚动时不出现空白 + * + * 【纵向模式 (VERTICAL_PAGE_SIZE)】 + * - 每页显示 10 条数据 + * - 使用传统分页器,用户手动翻页 + */ export const PAGINATION_CONFIG = { - FOUR_ROW_PAGE_SIZE: 20, // 平铺模式每页数量 - VERTICAL_PAGE_SIZE: 10, // 纵向模式每页数量 + FOUR_ROW_PAGE_SIZE: 30, // 平铺模式每页数量 (7.5行 × 4列,包含缓冲) + VERTICAL_PAGE_SIZE: 10, // 纵向模式每页数量 (传统分页) INITIAL_PAGE: 1, // 初始页码 };