Files
vf_react/src/views/Community/components/DynamicNewsCard/VirtualizedFourRowGrid.js
zdl ddd6b2d4af feat: 实现 Socket 触发的智能列表自动刷新功能(带防抖)
核心改动:
- 扩展 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>
2025-11-14 19:04:00 +08:00

393 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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