核心改动: - 扩展 NotificationContext,添加事件更新回调注册机制 - VirtualizedFourRowGrid 添加 forwardRef 暴露 getScrollPosition 方法 - DynamicNewsCard 实现智能刷新逻辑(根据模式和滚动位置判断是否刷新) - Community 页面注册 Socket 回调自动触发刷新 - 创建 TypeScript 通用防抖工具函数(debounce.ts) - 集成防抖机制(2秒延迟),避免短时间内频繁请求 智能刷新策略: - 纵向模式 + 第1页:自动刷新列表 - 纵向模式 + 其他页:不刷新(避免打断用户) - 平铺模式 + 滚动在顶部:自动刷新列表 - 平铺模式 + 滚动不在顶部:仅显示 Toast 提示 防抖效果: - 短时间内收到多个新事件,只执行最后一次刷新 - 减少服务器压力,提升用户体验 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
393 lines
14 KiB
JavaScript
393 lines
14 KiB
JavaScript
// src/views/Community/components/DynamicNewsCard/VirtualizedFourRowGrid.js
|
||
// 虚拟化网格组件(支持多列布局 + 纵向滚动 + 无限滚动)
|
||
|
||
import React, { useRef, useMemo, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||
import { Box, Grid, Spinner, Text, VStack, Center, HStack, IconButton, useBreakpointValue } from '@chakra-ui/react';
|
||
import { RepeatIcon } from '@chakra-ui/icons';
|
||
import { useColorModeValue } from '@chakra-ui/react';
|
||
import DynamicNewsEventCard from '../EventCard/DynamicNewsEventCard';
|
||
|
||
/**
|
||
* 虚拟化网格组件(支持多列布局 + 无限滚动)
|
||
* @param {Object} props
|
||
* @param {string} props.display - CSS display 属性(用于显示/隐藏组件)
|
||
* @param {Array} props.events - 事件列表(累积显示)
|
||
* @param {number} props.columnsPerRow - 每行列数(默认 4,单列模式传 1)
|
||
* @param {React.Component} props.CardComponent - 卡片组件(默认 DynamicNewsEventCard)
|
||
* @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 = forwardRef(({
|
||
display = 'block',
|
||
events,
|
||
columnsPerRow = 4,
|
||
CardComponent = DynamicNewsEventCard,
|
||
selectedEvent,
|
||
onEventSelect,
|
||
eventFollowStatus,
|
||
onToggleFollow,
|
||
getTimelineBoxStyle,
|
||
borderColor,
|
||
loadNextPage,
|
||
onRefreshFirstPage, // 修改:顶部刷新回调(替代 loadPrevPage)
|
||
hasMore,
|
||
loading,
|
||
error, // 新增:错误状态
|
||
onRetry, // 新增:重试回调
|
||
}, ref) => {
|
||
const parentRef = useRef(null);
|
||
const isLoadingMore = useRef(false); // 防止重复加载
|
||
const lastRefreshTime = useRef(0); // 记录上次刷新时间(用于30秒防抖)
|
||
|
||
// 滚动条颜色(主题适配)
|
||
const scrollbarTrackBg = useColorModeValue('#f1f1f1', '#2D3748');
|
||
const scrollbarThumbBg = useColorModeValue('#888', '#4A5568');
|
||
const scrollbarThumbHoverBg = useColorModeValue('#555', '#718096');
|
||
|
||
// 响应式列数
|
||
const responsiveColumns = useBreakpointValue({
|
||
base: 1, // 移动端:单列
|
||
sm: 2, // 小屏:2列
|
||
md: 2, // 中屏:2列
|
||
lg: 3, // 大屏:3列
|
||
xl: 4, // 超大屏:4列
|
||
});
|
||
|
||
// 使用响应式列数或传入的列数
|
||
const actualColumnsPerRow = responsiveColumns || columnsPerRow;
|
||
|
||
// 将事件按 actualColumnsPerRow 个一组分成行
|
||
const rows = useMemo(() => {
|
||
const r = [];
|
||
for (let i = 0; i < events.length; i += actualColumnsPerRow) {
|
||
r.push(events.slice(i, i + actualColumnsPerRow));
|
||
}
|
||
return r;
|
||
}, [events, actualColumnsPerRow]);
|
||
|
||
// 配置虚拟滚动器(纵向滚动 + 动态高度测量)
|
||
const rowVirtualizer = useVirtualizer({
|
||
count: rows.length,
|
||
getScrollElement: () => parentRef.current,
|
||
estimateSize: () => 250, // 提供初始估算值,库会自动测量实际高度
|
||
overscan: 2, // 预加载2行(上下各1行)
|
||
});
|
||
|
||
/**
|
||
* ⚡ 暴露方法给父组件(用于 Socket 刷新判断)
|
||
*/
|
||
useImperativeHandle(ref, () => ({
|
||
/**
|
||
* 获取当前滚动位置信息
|
||
* @returns {Object|null} 滚动位置信息
|
||
*/
|
||
getScrollPosition: () => {
|
||
const scrollElement = parentRef.current;
|
||
if (!scrollElement) return null;
|
||
|
||
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
|
||
const isNearTop = scrollTop < clientHeight * 0.1; // 顶部 10% 区域
|
||
|
||
return {
|
||
scrollTop,
|
||
scrollHeight,
|
||
clientHeight,
|
||
isNearTop,
|
||
scrollPercentage: ((scrollTop + clientHeight) / scrollHeight) * 100,
|
||
};
|
||
},
|
||
}), []);
|
||
|
||
/**
|
||
* 【核心逻辑1】无限滚动 + 顶部刷新 - 监听滚动事件,根据滚动位置自动加载数据或刷新
|
||
*
|
||
* 工作原理:
|
||
* 1. 向下滚动到 90% 位置时,触发 loadNextPage()
|
||
* - 调用 usePagination.loadNextPage()
|
||
* - 内部执行 handlePageChange(currentPage + 1)
|
||
* - dispatch(fetchDynamicNews({ page: nextPage }))
|
||
* - 后端返回下一页数据(30条)
|
||
* - Redux 去重后追加到 fourRowEvents 数组
|
||
* - events prop 更新,虚拟滚动自动渲染新内容
|
||
*
|
||
* 2. 向上滚动到顶部 10% 以内时,触发 onRefreshFirstPage()
|
||
* - 清空缓存 + 重新加载第一页(获取最新数据)
|
||
* - 30秒防抖:避免频繁刷新
|
||
* - 与5分钟定时刷新协同工作
|
||
*
|
||
* 设计要点:
|
||
* - 90% 触发点:接近底部才加载,避免过早触发影响用户体验
|
||
* - 防抖机制:isLoadingMore.current 防止重复触发
|
||
* - 两层缓存:
|
||
* - Redux 缓存(HTTP层):fourRowEvents 数组存储已加载数据,避免重复请求
|
||
* - 虚拟滚动缓存(渲染层):@tanstack/react-virtual 只渲染可见行,复用 DOM 节点
|
||
*/
|
||
useEffect(() => {
|
||
// 如果组件被隐藏,不执行滚动监听
|
||
if (display === 'none') return;
|
||
|
||
const scrollElement = parentRef.current;
|
||
if (!scrollElement) return;
|
||
|
||
const handleScroll = async () => {
|
||
// 防止重复触发
|
||
if (isLoadingMore.current || loading) return;
|
||
|
||
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
|
||
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
||
|
||
// 向下滚动:滚动到 90% 时开始加载下一页(更接近底部,避免过早触发)
|
||
if (loadNextPage && hasMore && scrollPercentage > 0.9) {
|
||
console.log('%c📜 [无限滚动] 接近底部,加载下一页', 'color: #8B5CF6; font-weight: bold;');
|
||
isLoadingMore.current = true;
|
||
await loadNextPage();
|
||
isLoadingMore.current = false;
|
||
}
|
||
|
||
// 向上滚动到顶部:触发刷新(30秒防抖)
|
||
if (onRefreshFirstPage && scrollTop < clientHeight * 0.1) {
|
||
const now = Date.now();
|
||
const timeSinceLastRefresh = now - lastRefreshTime.current;
|
||
|
||
// 30秒防抖:避免频繁刷新
|
||
if (timeSinceLastRefresh >= 30000) {
|
||
console.log('%c🔄 [顶部刷新] 滚动到顶部,清空缓存并重新加载第一页', 'color: #10B981; font-weight: bold;', {
|
||
timeSinceLastRefresh: `${(timeSinceLastRefresh / 1000).toFixed(1)}秒`
|
||
});
|
||
isLoadingMore.current = true;
|
||
lastRefreshTime.current = now;
|
||
|
||
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);
|
||
}, [display, loadNextPage, onRefreshFirstPage, hasMore, loading]);
|
||
|
||
/**
|
||
* 【核心逻辑2】主动检测内容高度 - 确保内容始终填满容器
|
||
*
|
||
* 场景:
|
||
* - 初次加载时,如果 30 条数据不足以填满 800px 容器(例如显示器很大)
|
||
* - 用户无法滚动,也就无法触发上面的滚动监听逻辑
|
||
*
|
||
* 解决方案:
|
||
* - 定时检查 scrollHeight 是否小于等于 clientHeight
|
||
* - 如果内容不足,主动调用 loadNextPage() 加载更多数据
|
||
* - 递归触发,直到内容高度超过容器高度(出现滚动条)
|
||
*
|
||
* 优化:
|
||
* - 500ms 延迟:确保虚拟滚动已完成首次渲染和高度测量
|
||
* - 监听 events.length 变化:新数据加载后重新检查
|
||
*/
|
||
useEffect(() => {
|
||
// 如果组件被隐藏,不执行高度检测
|
||
if (display === 'none') return;
|
||
|
||
const scrollElement = parentRef.current;
|
||
if (!scrollElement || !loadNextPage) return;
|
||
|
||
// 延迟检查,确保虚拟滚动已渲染
|
||
const timer = setTimeout(() => {
|
||
// 防止重复触发
|
||
if (isLoadingMore.current || !hasMore || loading) return;
|
||
|
||
const { scrollHeight, clientHeight } = scrollElement;
|
||
|
||
// 如果内容高度不足以填满容器(没有滚动条),主动加载下一页
|
||
if (scrollHeight <= clientHeight) {
|
||
console.log('%c📜 [无限滚动] 内容不足以填满容器,主动加载下一页', 'color: #8B5CF6; font-weight: bold;', {
|
||
scrollHeight,
|
||
clientHeight,
|
||
eventsCount: events.length
|
||
});
|
||
isLoadingMore.current = true;
|
||
loadNextPage().finally(() => {
|
||
isLoadingMore.current = false;
|
||
});
|
||
}
|
||
}, 500);
|
||
|
||
return () => clearTimeout(timer);
|
||
}, [display, events.length, hasMore, loading, loadNextPage]);
|
||
|
||
// 错误指示器(同行显示)
|
||
const renderErrorIndicator = () => {
|
||
if (!error) return null;
|
||
|
||
return (
|
||
<Center py={6}>
|
||
<HStack spacing={2}>
|
||
<Text color="gray.500" fontSize="sm">
|
||
数据加载失败,
|
||
</Text>
|
||
<IconButton
|
||
icon={<RepeatIcon />}
|
||
size="sm"
|
||
colorScheme="blue"
|
||
variant="ghost"
|
||
onClick={onRetry}
|
||
aria-label="刷新"
|
||
/>
|
||
<Text
|
||
color="blue.500"
|
||
fontSize="sm"
|
||
fontWeight="medium"
|
||
cursor="pointer"
|
||
onClick={onRetry}
|
||
_hover={{ textDecoration: 'underline' }}
|
||
>
|
||
刷新
|
||
</Text>
|
||
</HStack>
|
||
</Center>
|
||
);
|
||
};
|
||
|
||
// 底部加载指示器
|
||
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}
|
||
display={display}
|
||
overflowY="auto"
|
||
overflowX="hidden"
|
||
minH="800px"
|
||
maxH="800px"
|
||
w="100%"
|
||
position="relative"
|
||
css={{
|
||
// 滚动条样式
|
||
'&::-webkit-scrollbar': {
|
||
width: '4px',
|
||
},
|
||
'&::-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 横向排列卡片(列数由 actualColumnsPerRow 决定) */}
|
||
<Grid
|
||
templateColumns={`repeat(${actualColumnsPerRow}, 1fr)`}
|
||
gap={actualColumnsPerRow === 1 ? 3 : 4}
|
||
w="100%"
|
||
>
|
||
{rowEvents.map((event, colIndex) => (
|
||
<Box key={event.id} w="100%" minW={0}>
|
||
<CardComponent
|
||
event={event}
|
||
index={virtualRow.index * actualColumnsPerRow + 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%"
|
||
>
|
||
{error ? renderErrorIndicator() : renderLoadingIndicator()}
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
);
|
||
});
|
||
|
||
VirtualizedFourRowGrid.displayName = 'VirtualizedFourRowGrid';
|
||
|
||
export default VirtualizedFourRowGrid;
|