From f7732c64659f06f48d2e51d718a96d7521eb3301 Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Wed, 24 Dec 2025 12:47:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0Company=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E7=9A=84UI=E4=B8=BAFUI=E9=A3=8E=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/DynamicNews/DynamicNewsCard.js | 49 ++- .../components/DynamicNews/EventScrollList.js | 91 +--- .../components/DynamicNews/constants.js | 15 +- .../layouts/MainlineTimelineView.js | 15 +- .../layouts/VirtualizedFourRowGrid.js | 395 ------------------ 5 files changed, 50 insertions(+), 515 deletions(-) delete mode 100644 src/views/Community/components/DynamicNews/layouts/VirtualizedFourRowGrid.js diff --git a/src/views/Community/components/DynamicNews/DynamicNewsCard.js b/src/views/Community/components/DynamicNews/DynamicNewsCard.js index 6a027793..5042088b 100644 --- a/src/views/Community/components/DynamicNews/DynamicNewsCard.js +++ b/src/views/Community/components/DynamicNews/DynamicNewsCard.js @@ -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'); {/* 列表内容 - 始终渲染 */} diff --git a/src/views/Community/components/DynamicNews/EventScrollList.js b/src/views/Community/components/DynamicNews/EventScrollList.js index 27f04e3a..6d64cf5c 100644 --- a/src/views/Community/components/DynamicNews/EventScrollList.js +++ b/src/views/Community/components/DynamicNews/EventScrollList.js @@ -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 ( - {/* 平铺网格模式 - 使用虚拟滚动 + 双向无限滚动 */} - - {/* 主线时间轴模式 - 按 lv2 概念分组,调用独立 API */} { - 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 ( -
- - - 数据加载失败, - - } - size="sm" - colorScheme="blue" - variant="ghost" - onClick={onRetry} - aria-label="刷新" - /> - - 刷新 - - -
- ); - }; - - // 底部加载指示器 - const renderLoadingIndicator = () => { - if (!hasMore) { - return ( -
- - 已加载全部内容 - -
- ); - } - if (loading) { - return ( -
- - - - 加载中... - - -
- ); - } - return null; - }; - - return ( - - {/* 虚拟滚动容器 + 底部加载指示器 */} - - {/* 虚拟滚动内容 */} - - {rowVirtualizer.getVirtualItems().map((virtualRow) => { - const rowEvents = rows[virtualRow.index]; - - return ( - - {/* 使用 Grid 横向排列卡片(列数由 actualColumnsPerRow 决定) */} - - {rowEvents.map((event, colIndex) => ( - - { - onEventSelect(clickedEvent); - }} - onTitleClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - onEventSelect(event); - }} - onToggleFollow={() => onToggleFollow?.(event.id)} - timelineStyle={getTimelineBoxStyle?.()} - borderColor={borderColor} - /> - - ))} - - - ); - })} - - - {/* 底部加载指示器 - 绝对定位在虚拟内容底部 */} - - {error ? renderErrorIndicator() : renderLoadingIndicator()} - - - - ); -}); - -VirtualizedFourRowGridComponent.displayName = 'VirtualizedFourRowGrid'; - -// ⚡ 使用 React.memo 优化性能(减少不必要的重渲染) -const VirtualizedFourRowGrid = React.memo(VirtualizedFourRowGridComponent); - -export default VirtualizedFourRowGrid;