// 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 (