212 lines
6.9 KiB
JavaScript
212 lines
6.9 KiB
JavaScript
// src/views/Community/components/DynamicNewsCard/VirtualizedFourRowGrid.js
|
||
// 四列纵向滚动虚拟化网格组件(每行4列,纵向滚动 + 无限滚动)
|
||
|
||
import React, { useRef, useMemo, useEffect } from 'react';
|
||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||
import { Box, Grid, Spinner, Text, VStack, Center } from '@chakra-ui/react';
|
||
import { useColorModeValue } from '@chakra-ui/react';
|
||
import DynamicNewsEventCard from '../EventCard/DynamicNewsEventCard';
|
||
|
||
/**
|
||
* 四列纵向滚动虚拟化网格组件(支持无限滚动)
|
||
* @param {Object} props
|
||
* @param {Array} props.events - 事件列表(累积显示)
|
||
* @param {Object} props.selectedEvent - 当前选中的事件
|
||
* @param {Function} props.onEventSelect - 事件选择回调
|
||
* @param {Object} props.eventFollowStatus - 事件关注状态
|
||
* @param {Function} props.onToggleFollow - 关注切换回调
|
||
* @param {Function} props.getTimelineBoxStyle - 时间轴样式获取函数
|
||
* @param {string} props.borderColor - 边框颜色
|
||
* @param {Function} props.loadNextPage - 加载下一页(无限滚动)
|
||
* @param {boolean} props.hasMore - 是否还有更多数据
|
||
* @param {boolean} props.loading - 加载状态
|
||
*/
|
||
const VirtualizedFourRowGrid = ({
|
||
events,
|
||
selectedEvent,
|
||
onEventSelect,
|
||
eventFollowStatus,
|
||
onToggleFollow,
|
||
getTimelineBoxStyle,
|
||
borderColor,
|
||
loadNextPage,
|
||
hasMore,
|
||
loading,
|
||
}) => {
|
||
const parentRef = useRef(null);
|
||
const isLoadingMore = useRef(false); // 防止重复加载
|
||
|
||
// 滚动条颜色(主题适配)
|
||
const scrollbarTrackBg = useColorModeValue('#f1f1f1', '#2D3748');
|
||
const scrollbarThumbBg = useColorModeValue('#888', '#4A5568');
|
||
const scrollbarThumbHoverBg = useColorModeValue('#555', '#718096');
|
||
|
||
// 将事件按4个一组分成行(每行4列)
|
||
const rows = useMemo(() => {
|
||
const r = [];
|
||
for (let i = 0; i < events.length; i += 4) {
|
||
r.push(events.slice(i, i + 4));
|
||
}
|
||
return r;
|
||
}, [events]);
|
||
|
||
// 配置虚拟滚动器(纵向滚动 + 动态高度测量)
|
||
const rowVirtualizer = useVirtualizer({
|
||
count: rows.length,
|
||
getScrollElement: () => parentRef.current,
|
||
estimateSize: () => 250, // 提供初始估算值,库会自动测量实际高度
|
||
overscan: 2, // 预加载2行(上下各1行)
|
||
});
|
||
|
||
// 无限滚动逻辑 - 监听滚动事件,到达底部时加载下一页
|
||
useEffect(() => {
|
||
const scrollElement = parentRef.current;
|
||
if (!scrollElement || !loadNextPage) return;
|
||
|
||
const handleScroll = async () => {
|
||
// 防止重复触发
|
||
if (isLoadingMore.current || !hasMore || loading) return;
|
||
|
||
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
|
||
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
||
|
||
// 滚动到 80% 时开始加载下一页
|
||
if (scrollPercentage > 0.8) {
|
||
console.log('%c📜 [无限滚动] 到达底部,加载下一页', 'color: #8B5CF6; font-weight: bold;');
|
||
isLoadingMore.current = true;
|
||
await loadNextPage();
|
||
isLoadingMore.current = false;
|
||
}
|
||
};
|
||
|
||
scrollElement.addEventListener('scroll', handleScroll);
|
||
return () => scrollElement.removeEventListener('scroll', handleScroll);
|
||
}, [loadNextPage, hasMore, loading]);
|
||
|
||
// 底部加载指示器
|
||
const renderLoadingIndicator = () => {
|
||
if (!hasMore) {
|
||
return (
|
||
<Center py={6}>
|
||
<Text color="gray.500" fontSize="sm">
|
||
已加载全部内容
|
||
</Text>
|
||
</Center>
|
||
);
|
||
}
|
||
if (loading) {
|
||
return (
|
||
<Center py={6}>
|
||
<VStack spacing={2}>
|
||
<Spinner size="md" color="blue.500" thickness="3px" />
|
||
<Text color="gray.500" fontSize="sm">
|
||
加载中...
|
||
</Text>
|
||
</VStack>
|
||
</Center>
|
||
);
|
||
}
|
||
return null;
|
||
};
|
||
|
||
return (
|
||
<Box
|
||
ref={parentRef}
|
||
overflowY="auto"
|
||
overflowX="hidden"
|
||
maxH="600px"
|
||
w="100%"
|
||
position="relative"
|
||
css={{
|
||
// 滚动条样式
|
||
'&::-webkit-scrollbar': {
|
||
width: '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',
|
||
}}
|
||
>
|
||
{/* 虚拟滚动容器 + 底部加载指示器 */}
|
||
<Box position="relative" w="100%">
|
||
{/* 虚拟滚动内容 */}
|
||
<Box
|
||
position="relative"
|
||
w="100%"
|
||
h={`${rowVirtualizer.getTotalSize()}px`}
|
||
>
|
||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||
const rowEvents = rows[virtualRow.index];
|
||
|
||
return (
|
||
<Box
|
||
key={virtualRow.key}
|
||
data-index={virtualRow.index}
|
||
ref={rowVirtualizer.measureElement}
|
||
position="absolute"
|
||
top={0}
|
||
left={0}
|
||
w="100%"
|
||
transform={`translateY(${virtualRow.start}px)`}
|
||
>
|
||
{/* 每行使用 Grid 横向排列4个卡片 */}
|
||
<Grid
|
||
templateColumns="repeat(4, 1fr)"
|
||
gap={4}
|
||
w="100%"
|
||
>
|
||
{rowEvents.map((event, colIndex) => (
|
||
<Box key={event.id}>
|
||
<DynamicNewsEventCard
|
||
event={event}
|
||
index={virtualRow.index * 4 + colIndex}
|
||
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
|
||
position="absolute"
|
||
top={`${rowVirtualizer.getTotalSize()}px`}
|
||
left={0}
|
||
right={0}
|
||
w="100%"
|
||
>
|
||
{renderLoadingIndicator()}
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
export default VirtualizedFourRowGrid;
|