feat: 优化社区动态新闻分页和预加载策略

## 主要改动

### 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 <noreply@anthropic.com>
This commit is contained in:
zdl
2025-11-04 11:43:44 +08:00
parent 012c13c49a
commit ce46820105
3 changed files with 344 additions and 65 deletions

View File

@@ -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}