更新Company页面的UI为FUI风格
This commit is contained in:
@@ -81,7 +81,7 @@ const DynamicNewsCardComponent = forwardRef(({
|
||||
// Refs
|
||||
const cardHeaderRef = useRef(null);
|
||||
const cardBodyRef = useRef(null);
|
||||
const virtualizedGridRef = useRef(null); // ⚡ VirtualizedFourRowGrid 的 ref(用于获取滚动位置)
|
||||
const mainlineRef = useRef(null); // MainlineTimelineView 的 ref
|
||||
|
||||
// 从 Redux 读取关注状态
|
||||
const eventFollowStatus = useSelector(selectEventFollowStatus);
|
||||
@@ -107,9 +107,9 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
|
||||
// 根据模式选择数据源(使用 useMemo 缓存,避免重复计算)
|
||||
// 纵向模式:data 是页码映射 { 1: [...], 2: [...] }
|
||||
// 平铺模式 / 主线模式:data 是数组 [...] (共用 fourRowData)
|
||||
// 主线模式:使用独立 API,不需要 Redux 数据
|
||||
const modeData = useMemo(
|
||||
() => (currentMode === 'four-row' || currentMode === 'mainline') ? fourRowData : verticalData,
|
||||
() => currentMode === 'mainline' ? fourRowData : verticalData,
|
||||
[currentMode, fourRowData, verticalData]
|
||||
);
|
||||
const {
|
||||
@@ -128,7 +128,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
[currentMode, data]
|
||||
);
|
||||
const allCachedEvents = useMemo(
|
||||
() => (currentMode === 'four-row' || currentMode === 'mainline') ? data : undefined,
|
||||
() => currentMode === 'mainline' ? data : undefined,
|
||||
[currentMode, data]
|
||||
);
|
||||
|
||||
@@ -243,14 +243,14 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
} else {
|
||||
console.log(`[DynamicNewsCard] 纵向模式 + 第${state.currentPage}页 → 不刷新(避免打断用户)`);
|
||||
}
|
||||
} else if (mode === 'four-row' || mode === 'mainline') {
|
||||
// ========== 平铺模式 / 主线模式 ==========
|
||||
} else if (mode === 'mainline') {
|
||||
// ========== 主线模式 ==========
|
||||
// 检查滚动位置,只有在顶部时才刷新
|
||||
const scrollPos = virtualizedGridRef.current?.getScrollPosition();
|
||||
const scrollPos = mainlineRef.current?.getScrollPosition();
|
||||
|
||||
if (scrollPos?.isNearTop) {
|
||||
// 用户在顶部 10% 区域,安全刷新
|
||||
console.log(`[DynamicNewsCard] ${mode === 'mainline' ? '主线' : '平铺'}模式 + 滚动在顶部 → 刷新列表`);
|
||||
console.log(`[DynamicNewsCard] 主线模式 + 滚动在顶部 → 刷新列表`);
|
||||
handlePageChange(1); // 清空并刷新
|
||||
toast({
|
||||
title: '检测到新事件,已刷新',
|
||||
@@ -346,9 +346,9 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
}
|
||||
}, [error, toast]);
|
||||
|
||||
// 四排模式的事件点击处理(打开弹窗)
|
||||
const handleFourRowEventClick = useCallback((event) => {
|
||||
console.log('%c🔲 [四排模式] 点击事件,打开详情弹窗', 'color: #8B5CF6; font-weight: bold;', { eventId: event.id, title: event.title });
|
||||
// 主线模式的事件点击处理(打开弹窗)
|
||||
const handleMainlineEventClick = useCallback((event) => {
|
||||
console.log('%c🔲 [主线模式] 点击事件,打开详情弹窗', 'color: #8B5CF6; font-weight: bold;', { eventId: event.id, title: event.title });
|
||||
|
||||
// 🎯 追踪事件详情打开
|
||||
if (trackingFunctions.trackNewsDetailOpened) {
|
||||
@@ -356,7 +356,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
eventId: event.id,
|
||||
eventTitle: event.title,
|
||||
importance: event.importance,
|
||||
source: 'four_row_mode',
|
||||
source: 'mainline_mode',
|
||||
displayMode: 'modal',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
@@ -456,10 +456,13 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
if (hasInitialized.current && isDataEmpty) {
|
||||
console.log(`%c🔄 [模式切换] ${mode} 模式数据为空,开始加载`, 'color: #8B5CF6; font-weight: bold;');
|
||||
|
||||
// 🔧 根据 mode 直接计算 per_page
|
||||
const modePageSize = mode === DISPLAY_MODES.FOUR_ROW
|
||||
? PAGINATION_CONFIG.FOUR_ROW_PAGE_SIZE // 30
|
||||
: PAGINATION_CONFIG.VERTICAL_PAGE_SIZE; // 10
|
||||
// 主线模式使用独立 API,不需要通过 Redux 加载
|
||||
if (mode === DISPLAY_MODES.MAINLINE) {
|
||||
console.log('%c 主线模式 - 由 MainlineTimelineView 自己加载数据', 'color: #8B5CF6;');
|
||||
return;
|
||||
}
|
||||
|
||||
const modePageSize = PAGINATION_CONFIG.VERTICAL_PAGE_SIZE; // 10
|
||||
|
||||
console.log(`%c 计算的 per_page: ${modePageSize} (mode: ${mode})`, 'color: #8B5CF6;');
|
||||
|
||||
@@ -468,8 +471,8 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
per_page: modePageSize,
|
||||
pageSize: modePageSize,
|
||||
clearCache: true,
|
||||
...filters, // 先展开筛选条件
|
||||
page: PAGINATION_CONFIG.INITIAL_PAGE, // 然后覆盖 page 参数
|
||||
...filters,
|
||||
page: PAGINATION_CONFIG.INITIAL_PAGE,
|
||||
}));
|
||||
}
|
||||
}, [mode, currentMode, allCachedEventsByPage, allCachedEvents, dispatch, filters]); // 添加 filters 依赖
|
||||
@@ -664,11 +667,8 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
{/* 列表内容 - 始终渲染 */}
|
||||
<EventScrollList
|
||||
events={currentPageEvents}
|
||||
displayEvents={displayEvents} // 累积显示的事件列表(平铺模式)
|
||||
filters={filters} // 筛选条件(主线模式用)
|
||||
loadNextPage={loadNextPage} // 加载下一页
|
||||
loadPrevPage={loadPrevPage} // 加载上一页
|
||||
onFourRowEventClick={handleFourRowEventClick} // 四排模式事件点击
|
||||
filters={filters}
|
||||
onMainlineEventClick={handleMainlineEventClick}
|
||||
selectedEvent={selectedEvent}
|
||||
onEventSelect={setSelectedEvent}
|
||||
borderColor={borderColor}
|
||||
@@ -680,8 +680,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
mode={mode}
|
||||
eventFollowStatus={eventFollowStatus}
|
||||
onToggleFollow={handleToggleFollow}
|
||||
hasMore={hasMore}
|
||||
virtualizedGridRef={virtualizedGridRef} // ⚡ 传递 ref 给 VirtualizedFourRowGrid
|
||||
mainlineRef={mainlineRef}
|
||||
/>
|
||||
</Box>
|
||||
</CardBody>
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
// src/views/Community/components/DynamicNews/EventScrollList.js
|
||||
// 横向滚动事件列表组件
|
||||
// 事件列表组件
|
||||
|
||||
import React, { useRef, useCallback } from "react";
|
||||
import { Box, useColorModeValue } from "@chakra-ui/react";
|
||||
import VirtualizedFourRowGrid from "./layouts/VirtualizedFourRowGrid";
|
||||
import MainlineTimelineView from "./layouts/MainlineTimelineView";
|
||||
import VerticalModeLayout from "./layouts/VerticalModeLayout";
|
||||
|
||||
/**
|
||||
* 事件列表组件 - 支持纵向、平铺、主线三种展示模式
|
||||
* 事件列表组件 - 支持纵向、主线两种展示模式
|
||||
* @param {Array} events - 当前页的事件列表(服务端已分页)
|
||||
* @param {Array} displayEvents - 累积显示的事件列表(平铺模式用)
|
||||
* @param {Object} filters - 筛选条件(主线模式用)
|
||||
* @param {Function} loadNextPage - 加载下一页(无限滚动)
|
||||
* @param {Function} loadPrevPage - 加载上一页(双向无限滚动)
|
||||
* @param {Function} onFourRowEventClick - 平铺/主线模式事件点击回调(打开弹窗)
|
||||
* @param {Function} onMainlineEventClick - 主线模式事件点击回调(打开弹窗)
|
||||
* @param {Object} selectedEvent - 当前选中的事件
|
||||
* @param {Function} onEventSelect - 事件选择回调
|
||||
* @param {string} borderColor - 边框颜色
|
||||
@@ -23,20 +19,16 @@ import VerticalModeLayout from "./layouts/VerticalModeLayout";
|
||||
* @param {Function} onPageChange - 页码改变回调
|
||||
* @param {boolean} loading - 全局加载状态
|
||||
* @param {Object} error - 错误状态
|
||||
* @param {string} mode - 展示模式:'vertical'(纵向分栏)| 'four-row'(平铺网格)| 'mainline'(主线时间轴)
|
||||
* @param {boolean} hasMore - 是否还有更多数据
|
||||
* @param {string} mode - 展示模式:'vertical'(纵向分栏)| 'mainline'(主线时间轴)
|
||||
* @param {Object} eventFollowStatus - 事件关注状态 { [eventId]: { isFollowing, followerCount } }
|
||||
* @param {Function} onToggleFollow - 关注按钮回调
|
||||
* @param {React.Ref} virtualizedGridRef - VirtualizedFourRowGrid/MainlineTimelineView 的 ref
|
||||
* @param {React.Ref} mainlineRef - MainlineTimelineView 的 ref
|
||||
*/
|
||||
const EventScrollList = React.memo(
|
||||
({
|
||||
events,
|
||||
displayEvents,
|
||||
filters = {},
|
||||
loadNextPage,
|
||||
loadPrevPage,
|
||||
onFourRowEventClick,
|
||||
onMainlineEventClick,
|
||||
selectedEvent,
|
||||
onEventSelect,
|
||||
borderColor,
|
||||
@@ -46,23 +38,17 @@ const EventScrollList = React.memo(
|
||||
loading = false,
|
||||
error,
|
||||
mode = "vertical",
|
||||
hasMore = true,
|
||||
eventFollowStatus = {},
|
||||
onToggleFollow,
|
||||
virtualizedGridRef,
|
||||
mainlineRef,
|
||||
}) => {
|
||||
const scrollContainerRef = useRef(null);
|
||||
|
||||
// 所有 useColorModeValue 必须在组件顶层调用(不能在条件渲染中)
|
||||
// 所有 useColorModeValue 必须在组件顶层调用
|
||||
const timelineBg = useColorModeValue("gray.50", "gray.700");
|
||||
const timelineBorderColor = useColorModeValue("gray.400", "gray.500");
|
||||
const timelineTextColor = useColorModeValue("blue.600", "blue.400");
|
||||
|
||||
// 滚动条颜色
|
||||
const scrollbarTrackBg = useColorModeValue("#f1f1f1", "#2D3748");
|
||||
const scrollbarThumbBg = useColorModeValue("#888", "#4A5568");
|
||||
const scrollbarThumbHoverBg = useColorModeValue("#555", "#718096");
|
||||
|
||||
const getTimelineBoxStyle = () => {
|
||||
return {
|
||||
bg: timelineBg,
|
||||
@@ -73,75 +59,24 @@ const EventScrollList = React.memo(
|
||||
};
|
||||
};
|
||||
|
||||
// 重试函数
|
||||
const handleRetry = useCallback(() => {
|
||||
if (onPageChange) {
|
||||
onPageChange(currentPage);
|
||||
}
|
||||
}, [onPageChange, currentPage]);
|
||||
|
||||
{
|
||||
/* 事件卡片容器 */
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
ref={scrollContainerRef}
|
||||
overflowX="hidden"
|
||||
overflow="hidden"
|
||||
h="100%"
|
||||
pt={0}
|
||||
pb={4}
|
||||
px={mode === "four-row" || mode === "mainline" ? 0 : { base: 0, md: 2 }}
|
||||
pb={mode === "mainline" ? 0 : 4}
|
||||
px={mode === "mainline" ? 0 : { base: 0, md: 2 }}
|
||||
position="relative"
|
||||
data-scroll-container="true"
|
||||
css={{
|
||||
// 统一滚动条样式(支持横向和纵向)
|
||||
"&::-webkit-scrollbar": {
|
||||
width: "1px",
|
||||
height: "1px",
|
||||
},
|
||||
"&::-webkit-scrollbar-track": {
|
||||
background: scrollbarTrackBg,
|
||||
borderRadius: "10px",
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb": {
|
||||
background: scrollbarThumbBg,
|
||||
borderRadius: "10px",
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb:hover": {
|
||||
background: scrollbarThumbHoverBg,
|
||||
},
|
||||
scrollBehavior: "smooth",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
}}
|
||||
>
|
||||
{/* 平铺网格模式 - 使用虚拟滚动 + 双向无限滚动 */}
|
||||
<VirtualizedFourRowGrid
|
||||
ref={mode === "four-row" ? virtualizedGridRef : null}
|
||||
display={mode === "four-row" ? "block" : "none"}
|
||||
columnsPerRow={4} // 每行显示4列
|
||||
events={displayEvents || events} // 使用累积列表(如果有)
|
||||
selectedEvent={selectedEvent}
|
||||
onEventSelect={onFourRowEventClick} // 四排模式点击打开弹窗
|
||||
eventFollowStatus={eventFollowStatus}
|
||||
onToggleFollow={onToggleFollow}
|
||||
getTimelineBoxStyle={getTimelineBoxStyle}
|
||||
borderColor={borderColor}
|
||||
loadNextPage={loadNextPage} // 加载下一页
|
||||
loadPrevPage={loadPrevPage} // 加载上一页(双向滚动)
|
||||
hasMore={hasMore} // 是否还有更多数据
|
||||
loading={loading} // 加载状态
|
||||
error={error} // 错误状态
|
||||
onRetry={handleRetry} // 重试回调
|
||||
/>
|
||||
|
||||
{/* 主线时间轴模式 - 按 lv2 概念分组,调用独立 API */}
|
||||
<MainlineTimelineView
|
||||
ref={mode === "mainline" ? virtualizedGridRef : null}
|
||||
ref={mode === "mainline" ? mainlineRef : null}
|
||||
display={mode === "mainline" ? "block" : "none"}
|
||||
filters={filters}
|
||||
columnsPerRow={3}
|
||||
selectedEvent={selectedEvent}
|
||||
onEventSelect={onFourRowEventClick}
|
||||
onEventSelect={onMainlineEventClick}
|
||||
eventFollowStatus={eventFollowStatus}
|
||||
onToggleFollow={onToggleFollow}
|
||||
borderColor={borderColor}
|
||||
|
||||
@@ -5,29 +5,20 @@
|
||||
/**
|
||||
* 分页大小计算依据:
|
||||
*
|
||||
* 【四排模式 (FOUR_ROW_PAGE_SIZE)】
|
||||
* - 容器高度: 800px (VirtualizedFourRowGrid)
|
||||
* - 单行高度: ~250px (包含卡片 + 间距)
|
||||
* - 每行显示: 4 列
|
||||
* - 可视区域: 800px / 250px ≈ 3.2 行
|
||||
* - overscan 缓冲: 2 行 (上下各预渲染1行)
|
||||
* - 实际渲染区域: 3.2 + 2 = 5.2 行
|
||||
* - 单次加载数据量: 7.5 行 × 4 列 = 30 个
|
||||
* - 设计目标: 提供充足的缓冲数据,确保快速滚动时不出现空白
|
||||
*
|
||||
* 【纵向模式 (VERTICAL_PAGE_SIZE)】
|
||||
* - 每页显示 10 条数据
|
||||
* - 使用传统分页器,用户手动翻页
|
||||
*
|
||||
* 【主线模式】
|
||||
* - 使用独立 API,不需要分页配置
|
||||
*/
|
||||
export const PAGINATION_CONFIG = {
|
||||
FOUR_ROW_PAGE_SIZE: 30, // 平铺模式每页数量 (7.5行 × 4列,包含缓冲)
|
||||
VERTICAL_PAGE_SIZE: 10, // 纵向模式每页数量 (传统分页)
|
||||
INITIAL_PAGE: 1, // 初始页码
|
||||
};
|
||||
|
||||
// ========== 显示模式常量 ==========
|
||||
export const DISPLAY_MODES = {
|
||||
FOUR_ROW: 'four-row', // 平铺网格模式
|
||||
VERTICAL: 'vertical', // 纵向分栏模式
|
||||
MAINLINE: 'mainline', // 主线分组模式(按 lv2 概念分组)
|
||||
};
|
||||
|
||||
@@ -40,9 +40,9 @@ const COLORS = {
|
||||
headerHoverBg: "#2d323e",
|
||||
textColor: "#e2e8f0",
|
||||
secondaryTextColor: "#a0aec0",
|
||||
scrollbarTrackBg: "#1a1d24",
|
||||
scrollbarThumbBg: "#4a5568",
|
||||
scrollbarThumbHoverBg: "#718096",
|
||||
scrollbarTrackBg: "#2d3748",
|
||||
scrollbarThumbBg: "#718096",
|
||||
scrollbarThumbHoverBg: "#a0aec0",
|
||||
statBarBg: "#252a34",
|
||||
};
|
||||
|
||||
@@ -941,13 +941,18 @@ const MainlineTimelineViewComponent = forwardRef(
|
||||
overflowY="hidden"
|
||||
h="calc(100% - 44px)"
|
||||
css={{
|
||||
"&::-webkit-scrollbar": { height: "8px" },
|
||||
"&::-webkit-scrollbar": {
|
||||
height: "12px",
|
||||
background: COLORS.scrollbarTrackBg,
|
||||
},
|
||||
"&::-webkit-scrollbar-track": {
|
||||
background: COLORS.scrollbarTrackBg,
|
||||
borderRadius: "6px",
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb": {
|
||||
background: COLORS.scrollbarThumbBg,
|
||||
borderRadius: "4px",
|
||||
borderRadius: "6px",
|
||||
border: "2px solid " + COLORS.scrollbarTrackBg,
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb:hover": {
|
||||
background: COLORS.scrollbarThumbHoverBg,
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
// src/views/Community/components/DynamicNews/layouts/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 VirtualizedFourRowGridComponent = 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>
|
||||
);
|
||||
});
|
||||
|
||||
VirtualizedFourRowGridComponent.displayName = 'VirtualizedFourRowGrid';
|
||||
|
||||
// ⚡ 使用 React.memo 优化性能(减少不必要的重渲染)
|
||||
const VirtualizedFourRowGrid = React.memo(VirtualizedFourRowGridComponent);
|
||||
|
||||
export default VirtualizedFourRowGrid;
|
||||
Reference in New Issue
Block a user