From ce46820105faa54250315e49b41820e0db0b0ed4 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Tue, 4 Nov 2025 11:43:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E7=A4=BE=E5=8C=BA?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E6=96=B0=E9=97=BB=E5=88=86=E9=A1=B5=E5=92=8C?= =?UTF-8?q?=E9=A2=84=E5=8A=A0=E8=BD=BD=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 主要改动 ### 1. 修复分页显示问题 - 修复总页数计算错误(使用服务端 total 而非缓存 cachedCount) - 修复目标页数据检查逻辑(排除 null 占位符) ### 2. 实现请求拆分策略 (Critical Fix) - 将合并请求(per_page: 15)拆分为单页循环请求(per_page: 5) - 解决后端无法处理动态 per_page 导致返回空数据的问题 - 后台预加载和显示 loading 两个场景均已拆分 ### 3. 优化智能预加载逻辑 - 连续翻页(上/下页):预加载前后各 2 页 - 跳转翻页(点页码):只加载当前页 - 目标页已缓存时立即切换,后台静默预加载其他页 ### 4. Redux 状态管理优化 - 添加 pageSize 参数用于正确计算索引 - 重写 reducer 插入逻辑(append/replace/jump 三种模式) - 只在 append 模式去重,避免替换和跳页时数据丢失 - 修复 selector 计算有效数量(排除 null) ### 5. 修复 React Hook 规则违规 - 将所有 useColorModeValue 移至组件顶层 - 添加缺失的 HStack 导入 ## 影响范围 - 仅影响社区页面动态新闻分页功能 - 无后端变更,向后兼容 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/store/slices/communityDataSlice.js | 82 +++++- .../Community/components/DynamicNewsCard.js | 268 +++++++++++++++--- .../DynamicNewsCard/EventScrollList.js | 59 ++-- 3 files changed, 344 insertions(+), 65 deletions(-) diff --git a/src/store/slices/communityDataSlice.js b/src/store/slices/communityDataSlice.js index a0f5255b..536cd18d 100644 --- a/src/store/slices/communityDataSlice.js +++ b/src/store/slices/communityDataSlice.js @@ -170,6 +170,7 @@ export const fetchDynamicNews = createAsyncThunk( async ({ page = 1, per_page = 5, + pageSize = 5, // 每页实际显示的数据量(用于计算索引) clearCache = false, prependMode = false } = {}, { rejectWithValue }) => { @@ -196,6 +197,9 @@ export const fetchDynamicNews = createAsyncThunk( return { events: response.data.events, total: response.data.pagination?.total || 0, + page, + per_page, + pageSize, // 返回 pageSize 用于索引计算 clearCache, prependMode }; @@ -205,6 +209,9 @@ export const fetchDynamicNews = createAsyncThunk( return { events: [], total: 0, + page, + per_page, + pageSize, // 返回 pageSize 用于索引计算 clearCache, prependMode }; @@ -371,7 +378,7 @@ const communityDataSlice = createSlice({ state.error.dynamicNews = null; }) .addCase(fetchDynamicNews.fulfilled, (state, action) => { - const { events, total, clearCache, prependMode } = action.payload; + const { events, total, page, per_page, pageSize, clearCache, prependMode } = action.payload; if (clearCache) { // 清空缓存模式:直接替换 @@ -389,14 +396,67 @@ const communityDataSlice = createSlice({ 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 - }); + // 智能插入模式:根据页码计算正确的插入位置 + // 使用 pageSize(每页显示量)而不是 per_page(请求数量) + const startIndex = (page - 1) * (pageSize || per_page); + + // 判断插入模式 + const isAppend = startIndex === state.dynamicNews.length; + const isReplace = startIndex < state.dynamicNews.length; + const isJump = startIndex > state.dynamicNews.length; + + // 只在 append 模式下去重(避免定时刷新重复) + // 替换和跳页模式直接使用原始数据(避免因去重导致数据丢失) + if (isAppend) { + // Append 模式:连续加载,需要去重 + const existingIds = new Set( + state.dynamicNews + .filter(e => e !== null) + .map(e => e.id) + ); + const newEvents = events.filter(e => !existingIds.has(e.id)); + state.dynamicNews = [...state.dynamicNews, ...newEvents]; + + logger.debug('CommunityData', '连续追加数据(去重)', { + page, + startIndex, + endIndex: startIndex + newEvents.length, + originalEventsCount: events.length, + newEventsCount: newEvents.length, + filteredCount: events.length - newEvents.length, + totalCount: state.dynamicNews.length + }); + } else if (isReplace) { + // 替换模式:直接覆盖,不去重 + const endIndex = startIndex + events.length; + const before = state.dynamicNews.slice(0, startIndex); + const after = state.dynamicNews.slice(endIndex); + state.dynamicNews = [...before, ...events, ...after]; + + logger.debug('CommunityData', '替换重叠数据(不去重)', { + page, + startIndex, + endIndex, + eventsCount: events.length, + beforeLength: before.length, + afterLength: after.length, + totalCount: state.dynamicNews.length + }); + } else { + // 跳页模式:填充间隔,不去重 + const gap = startIndex - state.dynamicNews.length; + const fillers = Array(gap).fill(null); + state.dynamicNews = [...state.dynamicNews, ...fillers, ...events]; + + logger.debug('CommunityData', '跳页加载,填充间隔(不去重)', { + page, + startIndex, + endIndex: startIndex + events.length, + gap, + eventsCount: events.length, + totalCount: state.dynamicNews.length + }); + } } state.dynamicNewsTotal = total; @@ -449,11 +509,11 @@ export const selectHotEventsWithLoading = (state) => ({ }); export const selectDynamicNewsWithLoading = (state) => ({ - data: state.communityData.dynamicNews, // 完整缓存列表 + data: state.communityData.dynamicNews, // 完整缓存列表(可能包含 null 占位符) loading: state.communityData.loading.dynamicNews, error: state.communityData.error.dynamicNews, total: state.communityData.dynamicNewsTotal, // 服务端总数量 - cachedCount: state.communityData.dynamicNews.length, // 已缓存数量 + cachedCount: state.communityData.dynamicNews.filter(e => e !== null).length, // 已缓存有效数量(排除 null) lastUpdated: state.communityData.lastUpdated.dynamicNews }); diff --git a/src/views/Community/components/DynamicNewsCard.js b/src/views/Community/components/DynamicNewsCard.js index 229ca4da..2a4900b8 100644 --- a/src/views/Community/components/DynamicNewsCard.js +++ b/src/views/Community/components/DynamicNewsCard.js @@ -16,7 +16,8 @@ import { Badge, Center, Spinner, - useColorModeValue + useColorModeValue, + useToast } from '@chakra-ui/react'; import { TimeIcon } from '@chakra-ui/icons'; import EventScrollList from './DynamicNewsCard/EventScrollList'; @@ -54,6 +55,7 @@ const DynamicNewsCard = forwardRef(({ ...rest }, ref) => { const dispatch = useDispatch(); + const toast = useToast(); const cardBg = useColorModeValue('white', 'gray.800'); const borderColor = useColorModeValue('gray.200', 'gray.700'); @@ -69,52 +71,243 @@ const DynamicNewsCard = forwardRef(({ const [selectedEvent, setSelectedEvent] = useState(null); const [mode, setMode] = useState('carousel'); // 'carousel' 或 'grid',默认单排 const [currentPage, setCurrentPage] = useState(1); // 当前页码 + const [loadingPage, setLoadingPage] = useState(null); // 正在加载的目标页码(用于 UX 提示) // 根据模式决定每页显示数量 const pageSize = mode === 'carousel' ? 5 : 10; - // 计算总页数(基于缓存数量) - const totalPages = Math.ceil(cachedCount / pageSize) || 1; + // 计算总页数(基于服务端总数据量) + const totalPages = Math.ceil(total / pageSize) || 1; // 检查是否还有更多数据 const hasMore = cachedCount < total; - // 从缓存中切片获取当前页数据 + // 从缓存中切片获取当前页数据(过滤 null 占位符) const currentPageEvents = useMemo(() => { const startIndex = (currentPage - 1) * pageSize; const endIndex = startIndex + pageSize; - return allCachedEvents.slice(startIndex, endIndex); + return allCachedEvents.slice(startIndex, endIndex).filter(event => event !== null); }, [allCachedEvents, currentPage, pageSize]); - // 检查是否需要请求更多数据 - const shouldFetchMore = useCallback((targetPage) => { - const requiredCount = targetPage * pageSize; - // 如果缓存不足,且服务端还有更多数据 - return cachedCount < requiredCount && hasMore; - }, [cachedCount, total, pageSize, hasMore]); + // 翻页处理(智能预加载) + const handlePageChange = useCallback(async (newPage) => { + // 🔍 诊断日志 - 记录翻页开始状态 + console.log('[handlePageChange] 开始翻页', { + currentPage, + newPage, + pageSize, + totalPages, + hasMore, + total, + allCachedEventsLength: allCachedEvents.length, + cachedCount + }); - // 翻页处理 - const handlePageChange = useCallback((newPage) => { - // 向后翻页(上一页):不请求,直接切换 - if (newPage < currentPage) { + // 0. 首先检查目标页数据是否已完整缓存 + const targetPageStartIndex = (newPage - 1) * pageSize; + const targetPageEndIndex = targetPageStartIndex + pageSize; + const targetPageData = allCachedEvents.slice(targetPageStartIndex, targetPageEndIndex); + const validTargetData = targetPageData.filter(e => e !== null); + const expectedCount = Math.min(pageSize, total - targetPageStartIndex); + const isTargetPageCached = validTargetData.length >= expectedCount; + + console.log('[handlePageChange] 目标页缓存检查', { + newPage, + targetPageStartIndex, + targetPageEndIndex, + targetPageDataLength: targetPageData.length, + validTargetDataLength: validTargetData.length, + expectedCount, + isTargetPageCached + }); + + // 1. 判断翻页类型:连续翻页(上一页/下一页)还是跳转翻页(点击页码/输入跳转) + const isSequentialNavigation = Math.abs(newPage - currentPage) === 1; + + // 2. 计算预加载范围 + let preloadRange; + if (isSequentialNavigation) { + // 连续翻页:前后各2页(共5页) + const start = Math.max(1, newPage - 2); + const end = Math.min(totalPages, newPage + 2); + preloadRange = Array.from( + { length: end - start + 1 }, + (_, i) => start + i + ); + } else { + // 跳转翻页:只加载当前页 + preloadRange = [newPage]; + } + + // 3. 检查哪些页面的数据还未缓存(检查是否包含 null 或超出数组长度) + const missingPages = preloadRange.filter(page => { + const pageStartIndex = (page - 1) * pageSize; + const pageEndIndex = pageStartIndex + pageSize; + + // 如果该页超出数组范围,说明未缓存 + if (pageEndIndex > allCachedEvents.length) { + console.log(`[missingPages] 页面${page}超出数组范围`, { + pageStartIndex, + pageEndIndex, + allCachedEventsLength: allCachedEvents.length + }); + return true; + } + + // 检查该页的数据是否包含 null 占位符或数据不足 + const pageData = allCachedEvents.slice(pageStartIndex, pageEndIndex); + const validData = pageData.filter(e => e !== null); + const expectedCount = Math.min(pageSize, total - pageStartIndex); + const hasNullOrIncomplete = validData.length < expectedCount; + + console.log(`[missingPages] 页面${page}检查`, { + pageStartIndex, + pageEndIndex, + pageDataLength: pageData.length, + validDataLength: validData.length, + expectedCount, + hasNullOrIncomplete + }); + + return hasNullOrIncomplete; + }); + + console.log('[handlePageChange] 缺失页面检测完成', { + preloadRange, + missingPages, + missingPagesCount: missingPages.length + }); + + // 4. 如果目标页已缓存,立即切换页码,然后在后台静默预加载其他页 + if (isTargetPageCached && missingPages.length > 0 && hasMore) { + console.log('[DynamicNewsCard] 目标页已缓存,立即切换', { + currentPage, + newPage, + 缺失页面: missingPages, + 目标页已缓存: true + }); + + // 立即切换页码(用户无感知延迟) setCurrentPage(newPage); - return; + + // 在后台静默预加载其他缺失页面(拆分为单页请求) + try { + console.log('[DynamicNewsCard] 开始后台预加载', { + 缺失页面: missingPages, + 每页数量: pageSize + }); + + // 拆分为单页请求,避免 per_page 动态值导致后端返回空数据 + for (const page of missingPages) { + await dispatch(fetchDynamicNews({ + page: page, + per_page: pageSize, // 固定值(5或10),不使用动态计算 + pageSize: pageSize, + clearCache: false + })).unwrap(); + + console.log(`[DynamicNewsCard] 后台预加载第 ${page} 页完成`); + } + + console.log('[DynamicNewsCard] 后台预加载全部完成', { + 预加载页面: missingPages + }); + } catch (error) { + console.error('[DynamicNewsCard] 后台预加载失败', error); + // 静默失败,不影响用户体验 + } + + return; // 提前返回,不执行下面的加载逻辑 } - // 向前翻页(下一页):检查是否需要请求 - if (shouldFetchMore(newPage)) { - // 计算需要请求的页码(从缓存末尾继续) - const nextFetchPage = Math.ceil(cachedCount / pageSize) + 1; + // 5. 如果目标页未缓存,显示 loading 并等待加载完成 + if (missingPages.length > 0 && hasMore) { + console.log('[DynamicNewsCard] 目标页未缓存,显示loading', { + currentPage, + newPage, + 翻页类型: isSequentialNavigation ? '连续翻页' : '跳转翻页', + 预加载范围: preloadRange, + 缺失页面: missingPages, + 每页数量: pageSize, + 目标页已缓存: false + }); - dispatch(fetchDynamicNews({ - page: nextFetchPage, - per_page: pageSize, - clearCache: false - })); + try { + // 设置加载状态(显示"正在加载第X页...") + setLoadingPage(newPage); + + // 拆分为单页请求,避免 per_page 动态值导致后端返回空数据 + for (const page of missingPages) { + console.log(`[DynamicNewsCard] 开始加载第 ${page} 页`); + + await dispatch(fetchDynamicNews({ + page: page, + per_page: pageSize, // 固定值(5或10),不使用动态计算 + pageSize: pageSize, // 传递原始 pageSize,用于正确计算索引 + clearCache: false + })).unwrap(); + + console.log(`[DynamicNewsCard] 第 ${page} 页加载完成`); + } + + console.log('[DynamicNewsCard] 所有缺失页面加载完成', { + 缺失页面: missingPages + }); + + // 数据加载成功后才更新当前页码 + setCurrentPage(newPage); + } catch (error) { + console.error('[DynamicNewsCard] 翻页加载失败', error); + + // 显示错误提示 + toast({ + title: '加载失败', + description: `无法加载第 ${newPage} 页数据,请稍后重试`, + status: 'error', + duration: 3000, + isClosable: true, + position: 'top' + }); + + // 加载失败时不更新页码,保持在当前页 + } finally { + // 清除加载状态 + setLoadingPage(null); + } + } else if (missingPages.length === 0) { + // 只有在确实不需要加载时才直接切换 + console.log('[handlePageChange] 无需加载,直接切换', { + currentPage, + newPage, + preloadRange, + missingPages, + reason: '所有页面均已缓存' + }); + setCurrentPage(newPage); + } else { + // 理论上不应该到这里(missingPages.length > 0 但 hasMore=false) + console.warn('[handlePageChange] 意外分支:有缺失页面但无法加载', { + missingPages, + hasMore, + currentPage, + newPage, + total, + cachedCount + }); + + // 尝试切换页码,但可能会显示空数据 + setCurrentPage(newPage); + + toast({ + title: '数据不完整', + description: `第 ${newPage} 页数据可能不完整`, + status: 'warning', + duration: 2000, + isClosable: true, + position: 'top' + }); } - - setCurrentPage(newPage); - }, [currentPage, cachedCount, pageSize, shouldFetchMore, dispatch]); + }, [currentPage, allCachedEvents, pageSize, totalPages, hasMore, dispatch, total, toast, cachedCount]); // 模式切换处理 const handleModeToggle = useCallback((newMode) => { @@ -125,17 +318,22 @@ const DynamicNewsCard = forwardRef(({ const newPageSize = newMode === 'carousel' ? 5 : 10; - // 检查缓存是否足够显示第1页 - if (cachedCount < newPageSize) { - // 清空缓存,重新请求 + // 检查第1页的数据是否完整(排除 null) + const firstPageData = allCachedEvents.slice(0, newPageSize); + const validFirstPageCount = firstPageData.filter(e => e !== null).length; + const needsRefetch = validFirstPageCount < Math.min(newPageSize, total); + + if (needsRefetch) { + // 第1页数据不完整,清空缓存重新请求 dispatch(fetchDynamicNews({ page: 1, per_page: newPageSize, + pageSize: newPageSize, // 传递 pageSize 确保索引计算一致 clearCache: true })); } - // 如果缓存足够,不发起请求,直接切换 - }, [mode, cachedCount, dispatch]); + // 如果第1页数据完整,不发起请求,直接切换 + }, [mode, allCachedEvents, total, dispatch]); // 初始加载 useEffect(() => { @@ -143,6 +341,7 @@ const DynamicNewsCard = forwardRef(({ dispatch(fetchDynamicNews({ page: 1, per_page: 5, + pageSize: 5, // 传递 pageSize 确保索引计算一致 clearCache: true })); } @@ -201,7 +400,8 @@ const DynamicNewsCard = forwardRef(({ currentPage={currentPage} totalPages={totalPages} onPageChange={handlePageChange} - loading={loading} + loading={loadingPage !== null} + loadingPage={loadingPage} mode={mode} onModeChange={handleModeToggle} eventFollowStatus={eventFollowStatus} diff --git a/src/views/Community/components/DynamicNewsCard/EventScrollList.js b/src/views/Community/components/DynamicNewsCard/EventScrollList.js index 6e47b99a..969efcf2 100644 --- a/src/views/Community/components/DynamicNewsCard/EventScrollList.js +++ b/src/views/Community/components/DynamicNewsCard/EventScrollList.js @@ -11,6 +11,7 @@ import { ButtonGroup, Center, VStack, + HStack, Spinner, Text, useColorModeValue @@ -28,7 +29,8 @@ import PaginationControl from './PaginationControl'; * @param {number} currentPage - 当前页码 * @param {number} totalPages - 总页数(由服务端返回) * @param {Function} onPageChange - 页码改变回调 - * @param {boolean} loading - 加载状态 + * @param {boolean} loading - 全局加载状态 + * @param {number|null} loadingPage - 正在加载的目标页码(用于显示"正在加载第X页...") * @param {string} mode - 展示模式:'carousel'(单排轮播)| 'grid'(双排网格) * @param {Function} onModeChange - 模式切换回调 * @param {boolean} hasMore - 是否还有更多数据 @@ -52,20 +54,37 @@ const EventScrollList = ({ }) => { const scrollContainerRef = useRef(null); - // 时间轴样式配置 + // 所有 useColorModeValue 必须在组件顶层调用(不能在条件渲染中) + const timelineBg = useColorModeValue('gray.50', 'gray.700'); + const timelineBorderColor = useColorModeValue('gray.400', 'gray.500'); + const timelineTextColor = useColorModeValue('blue.600', 'blue.400'); + + // 翻页按钮颜色 + const arrowBtnBg = useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.6)'); + const arrowBtnHoverBg = useColorModeValue('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0.8)'); + + // 滚动条颜色 + const scrollbarTrackBg = useColorModeValue('#f1f1f1', '#2D3748'); + const scrollbarThumbBg = useColorModeValue('#888', '#4A5568'); + const scrollbarThumbHoverBg = useColorModeValue('#555', '#718096'); + + // 加载遮罩颜色 + const loadingOverlayBg = useColorModeValue('whiteAlpha.800', 'blackAlpha.700'); + const loadingTextColor = useColorModeValue('gray.600', 'gray.300'); + const getTimelineBoxStyle = () => { return { - bg: useColorModeValue('gray.50', 'gray.700'), - borderColor: useColorModeValue('gray.400', 'gray.500'), + bg: timelineBg, + borderColor: timelineBorderColor, borderWidth: '2px', - textColor: useColorModeValue('blue.600', 'blue.400'), + textColor: timelineTextColor, boxShadow: 'sm', }; }; return ( - {/* 顶部控制栏:模式切换按钮(左)+ 分页控制器(右) */} + {/* 顶部控制栏:模式切换按钮(左)+ 分页控制器 + 加载提示(右) */} {/* 模式切换按钮 */} @@ -87,11 +106,11 @@ const EventScrollList = ({ {/* 分页控制器 */} {totalPages > 1 && ( - + )} @@ -114,10 +133,10 @@ const EventScrollList = ({ h="40px" minW="40px" borderRadius="full" - bg={useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.6)')} + bg={arrowBtnBg} boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)" _hover={{ - bg: useColorModeValue('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0.8)'), + bg: arrowBtnHoverBg, boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)', transform: 'translateY(-50%) scale(1.05)' }} @@ -142,10 +161,10 @@ const EventScrollList = ({ h="40px" minW="40px" borderRadius="full" - bg={useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.6)')} + bg={arrowBtnBg} boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)" _hover={{ - bg: useColorModeValue('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0.8)'), + bg: arrowBtnHoverBg, boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)', transform: 'translateY(-50%) scale(1.05)' }} @@ -169,15 +188,15 @@ const EventScrollList = ({ height: '8px', }, '&::-webkit-scrollbar-track': { - background: useColorModeValue('#f1f1f1', '#2D3748'), + background: scrollbarTrackBg, borderRadius: '10px', }, '&::-webkit-scrollbar-thumb': { - background: useColorModeValue('#888', '#4A5568'), + background: scrollbarThumbBg, borderRadius: '10px', }, '&::-webkit-scrollbar-thumb:hover': { - background: useColorModeValue('#555', '#718096'), + background: scrollbarThumbHoverBg, }, scrollBehavior: 'smooth', WebkitOverflowScrolling: 'touch', @@ -191,14 +210,14 @@ const EventScrollList = ({ left={0} right={0} bottom={0} - bg={useColorModeValue('whiteAlpha.800', 'blackAlpha.700')} + bg={loadingOverlayBg} backdropFilter="blur(2px)" zIndex={10} borderRadius="md" > - + 加载中...