## 主要改动 ### 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>
299 lines
9.4 KiB
JavaScript
299 lines
9.4 KiB
JavaScript
// src/views/Community/components/DynamicNewsCard/EventScrollList.js
|
||
// 横向滚动事件列表组件
|
||
|
||
import React, { useRef } from 'react';
|
||
import {
|
||
Box,
|
||
Flex,
|
||
Grid,
|
||
IconButton,
|
||
Button,
|
||
ButtonGroup,
|
||
Center,
|
||
VStack,
|
||
HStack,
|
||
Spinner,
|
||
Text,
|
||
useColorModeValue
|
||
} from '@chakra-ui/react';
|
||
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
||
import DynamicNewsEventCard from '../EventCard/DynamicNewsEventCard';
|
||
import PaginationControl from './PaginationControl';
|
||
|
||
/**
|
||
* 事件列表组件 - 支持两种展示模式
|
||
* @param {Array} events - 当前页的事件列表(服务端已分页)
|
||
* @param {Object} selectedEvent - 当前选中的事件
|
||
* @param {Function} onEventSelect - 事件选择回调
|
||
* @param {string} borderColor - 边框颜色
|
||
* @param {number} currentPage - 当前页码
|
||
* @param {number} totalPages - 总页数(由服务端返回)
|
||
* @param {Function} onPageChange - 页码改变回调
|
||
* @param {boolean} loading - 全局加载状态
|
||
* @param {number|null} loadingPage - 正在加载的目标页码(用于显示"正在加载第X页...")
|
||
* @param {string} mode - 展示模式:'carousel'(单排轮播)| 'grid'(双排网格)
|
||
* @param {Function} onModeChange - 模式切换回调
|
||
* @param {boolean} hasMore - 是否还有更多数据
|
||
* @param {Object} eventFollowStatus - 事件关注状态 { [eventId]: { isFollowing, followerCount } }
|
||
* @param {Function} onToggleFollow - 关注按钮回调
|
||
*/
|
||
const EventScrollList = ({
|
||
events,
|
||
selectedEvent,
|
||
onEventSelect,
|
||
borderColor,
|
||
currentPage,
|
||
totalPages,
|
||
onPageChange,
|
||
loading = false,
|
||
mode = 'carousel',
|
||
onModeChange,
|
||
hasMore = true,
|
||
eventFollowStatus = {},
|
||
onToggleFollow
|
||
}) => {
|
||
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: timelineBg,
|
||
borderColor: timelineBorderColor,
|
||
borderWidth: '2px',
|
||
textColor: timelineTextColor,
|
||
boxShadow: 'sm',
|
||
};
|
||
};
|
||
|
||
return (
|
||
<Box>
|
||
{/* 顶部控制栏:模式切换按钮(左)+ 分页控制器 + 加载提示(右) */}
|
||
<Flex justify="space-between" align="center" mb={2}>
|
||
{/* 模式切换按钮 */}
|
||
<ButtonGroup size="sm" isAttached>
|
||
<Button
|
||
onClick={() => onModeChange('carousel')}
|
||
colorScheme="blue"
|
||
variant={mode === 'carousel' ? 'solid' : 'outline'}
|
||
>
|
||
单排
|
||
</Button>
|
||
<Button
|
||
onClick={() => onModeChange('grid')}
|
||
colorScheme="blue"
|
||
variant={mode === 'grid' ? 'solid' : 'outline'}
|
||
>
|
||
双排
|
||
</Button>
|
||
</ButtonGroup>
|
||
|
||
{/* 分页控制器 */}
|
||
{totalPages > 1 && (
|
||
<PaginationControl
|
||
currentPage={currentPage}
|
||
totalPages={totalPages}
|
||
onPageChange={onPageChange}
|
||
/>
|
||
)}
|
||
</Flex>
|
||
|
||
{/* 横向滚动区域 */}
|
||
<Box position="relative">
|
||
|
||
{/* 左侧翻页按钮 - 上一页 */}
|
||
{currentPage > 1 && (
|
||
<IconButton
|
||
icon={<ChevronLeftIcon boxSize={6} color="blue.500" />}
|
||
position="absolute"
|
||
left="0"
|
||
top="50%"
|
||
transform="translateY(-50%)"
|
||
zIndex={2}
|
||
onClick={() => onPageChange(currentPage - 1)}
|
||
variant="ghost"
|
||
size="md"
|
||
w="40px"
|
||
h="40px"
|
||
minW="40px"
|
||
borderRadius="full"
|
||
bg={arrowBtnBg}
|
||
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
|
||
_hover={{
|
||
bg: arrowBtnHoverBg,
|
||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
||
transform: 'translateY(-50%) scale(1.05)'
|
||
}}
|
||
aria-label="上一页"
|
||
title="上一页"
|
||
/>
|
||
)}
|
||
|
||
{/* 右侧翻页按钮 - 下一页 */}
|
||
{currentPage < totalPages && hasMore && (
|
||
<IconButton
|
||
icon={<ChevronRightIcon boxSize={6} color="blue.500" />}
|
||
position="absolute"
|
||
right="0"
|
||
top="50%"
|
||
transform="translateY(-50%)"
|
||
zIndex={2}
|
||
onClick={() => onPageChange(currentPage + 1)}
|
||
variant="ghost"
|
||
size="md"
|
||
w="40px"
|
||
h="40px"
|
||
minW="40px"
|
||
borderRadius="full"
|
||
bg={arrowBtnBg}
|
||
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
|
||
_hover={{
|
||
bg: arrowBtnHoverBg,
|
||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
||
transform: 'translateY(-50%) scale(1.05)'
|
||
}}
|
||
isDisabled={currentPage >= totalPages && !hasMore}
|
||
aria-label="下一页"
|
||
title="下一页"
|
||
/>
|
||
)}
|
||
|
||
{/* 事件卡片容器 */}
|
||
<Box
|
||
ref={scrollContainerRef}
|
||
overflowX={mode === 'carousel' ? 'auto' : 'hidden'}
|
||
overflowY="hidden"
|
||
pt={0}
|
||
pb={4}
|
||
px={2}
|
||
position="relative"
|
||
css={mode === 'carousel' ? {
|
||
'&::-webkit-scrollbar': {
|
||
height: '8px',
|
||
},
|
||
'&::-webkit-scrollbar-track': {
|
||
background: scrollbarTrackBg,
|
||
borderRadius: '10px',
|
||
},
|
||
'&::-webkit-scrollbar-thumb': {
|
||
background: scrollbarThumbBg,
|
||
borderRadius: '10px',
|
||
},
|
||
'&::-webkit-scrollbar-thumb:hover': {
|
||
background: scrollbarThumbHoverBg,
|
||
},
|
||
scrollBehavior: 'smooth',
|
||
WebkitOverflowScrolling: 'touch',
|
||
} : {}}
|
||
>
|
||
{/* 加载遮罩 */}
|
||
{loading && (
|
||
<Center
|
||
position="absolute"
|
||
top={0}
|
||
left={0}
|
||
right={0}
|
||
bottom={0}
|
||
bg={loadingOverlayBg}
|
||
backdropFilter="blur(2px)"
|
||
zIndex={10}
|
||
borderRadius="md"
|
||
>
|
||
<VStack>
|
||
<Spinner size="lg" color="blue.500" thickness="3px" />
|
||
<Text fontSize="sm" color={loadingTextColor}>
|
||
加载中...
|
||
</Text>
|
||
</VStack>
|
||
</Center>
|
||
)}
|
||
|
||
{/* 模式1: 单排轮播模式 */}
|
||
{mode === 'carousel' && (
|
||
<Flex gap={4}>
|
||
{events.map((event, index) => (
|
||
<Box
|
||
key={event.id}
|
||
minW="calc((100% - 64px) / 5)"
|
||
maxW="calc((100% - 64px) / 5)"
|
||
flexShrink={0}
|
||
>
|
||
<DynamicNewsEventCard
|
||
event={event}
|
||
index={index}
|
||
isFollowing={eventFollowStatus[event.id]?.isFollowing || false}
|
||
followerCount={eventFollowStatus[event.id]?.followerCount || event.follower_count || 0}
|
||
isSelected={selectedEvent?.id === event.id}
|
||
onEventClick={(clickedEvent) => {
|
||
onEventSelect(clickedEvent);
|
||
}}
|
||
onTitleClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
onEventSelect(event);
|
||
}}
|
||
onToggleFollow={() => onToggleFollow?.(event.id)}
|
||
timelineStyle={getTimelineBoxStyle()}
|
||
borderColor={borderColor}
|
||
/>
|
||
</Box>
|
||
))}
|
||
</Flex>
|
||
)}
|
||
|
||
{/* 模式2: 双排网格模式 */}
|
||
{mode === 'grid' && (
|
||
<Grid
|
||
templateRows="repeat(2, 1fr)"
|
||
templateColumns="repeat(5, 1fr)"
|
||
gap={4}
|
||
autoFlow="column"
|
||
>
|
||
{events.map((event, index) => (
|
||
<Box key={event.id}>
|
||
<DynamicNewsEventCard
|
||
event={event}
|
||
index={index}
|
||
isFollowing={eventFollowStatus[event.id]?.isFollowing || false}
|
||
followerCount={eventFollowStatus[event.id]?.followerCount || event.follower_count || 0}
|
||
isSelected={selectedEvent?.id === event.id}
|
||
onEventClick={(clickedEvent) => {
|
||
onEventSelect(clickedEvent);
|
||
}}
|
||
onTitleClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
onEventSelect(event);
|
||
}}
|
||
onToggleFollow={() => onToggleFollow?.(event.id)}
|
||
timelineStyle={getTimelineBoxStyle()}
|
||
borderColor={borderColor}
|
||
/>
|
||
</Box>
|
||
))}
|
||
</Grid>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
export default EventScrollList;
|