// src/views/Community/components/DynamicNewsCard.js // 横向滚动事件卡片组件(实时要闻·动态追踪) import React, { forwardRef, useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Card, CardHeader, CardBody, Box, Flex, VStack, HStack, Heading, Text, Badge, Center, Spinner, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalCloseButton, useColorModeValue, useToast, useDisclosure, } from '@chakra-ui/react'; import { TimeIcon } from '@chakra-ui/icons'; import EventScrollList from './DynamicNewsCard/EventScrollList'; import ModeToggleButtons from './DynamicNewsCard/ModeToggleButtons'; import PaginationControl from './DynamicNewsCard/PaginationControl'; import DynamicNewsDetailPanel from './DynamicNewsDetail'; import UnifiedSearchBox from './UnifiedSearchBox'; import { fetchDynamicNews, toggleEventFollow, selectEventFollowStatus, selectVerticalEventsWithLoading, selectFourRowEventsWithLoading } from '../../../store/slices/communityDataSlice'; import { usePagination } from './DynamicNewsCard/hooks/usePagination'; import { PAGINATION_CONFIG, DISPLAY_MODES } from './DynamicNewsCard/constants'; // 🔍 调试:渲染计数器 let dynamicNewsCardRenderCount = 0; /** * 实时要闻·动态追踪 - 事件展示卡片组件 * @param {Object} filters - 筛选条件 * @param {Array} popularKeywords - 热门关键词 * @param {Date} lastUpdateTime - 最后更新时间 * @param {Function} onSearch - 搜索回调 * @param {Function} onSearchFocus - 搜索框获得焦点回调 * @param {Function} onEventClick - 事件点击回调 * @param {Function} onViewDetail - 查看详情回调 * @param {Object} ref - 用于滚动的ref */ const DynamicNewsCard = forwardRef(({ filters = {}, popularKeywords = [], lastUpdateTime, onSearch, onSearchFocus, onEventClick, onViewDetail, ...rest }, ref) => { const dispatch = useDispatch(); const toast = useToast(); const cardBg = useColorModeValue('white', 'gray.800'); const borderColor = useColorModeValue('gray.200', 'gray.700'); // 固定模式状态 const [isFixedMode, setIsFixedMode] = useState(false); const [headerHeight, setHeaderHeight] = useState(0); const cardHeaderRef = useRef(null); const cardBodyRef = useRef(null); // 导航栏和页脚固定高度 const NAVBAR_HEIGHT = 64; // 主导航高度 const SECONDARY_NAV_HEIGHT = 44; // 二级导航高度 const FOOTER_HEIGHT = 120; // 页脚高度(预留) const TOTAL_NAV_HEIGHT = NAVBAR_HEIGHT + SECONDARY_NAV_HEIGHT; // 总导航高度 128px // 从 Redux 读取关注状态 const eventFollowStatus = useSelector(selectEventFollowStatus); // 本地状态:模式(先初始化,后面会被 usePagination 更新) const [currentMode, setCurrentMode] = useState('vertical'); // 根据当前模式从 Redux 读取对应的数据(添加默认值避免 undefined) const verticalData = useSelector(selectVerticalEventsWithLoading) || {}; const fourRowData = useSelector(selectFourRowEventsWithLoading) || {}; // 🔍 调试:从 Redux 读取数据 console.log('%c[DynamicNewsCard] 从 Redux 读取数据', 'color: #3B82F6; font-weight: bold;', { currentMode, 'verticalData.data type': typeof verticalData.data, 'verticalData.data keys': verticalData.data ? Object.keys(verticalData.data) : [], 'verticalData.total': verticalData.total, 'verticalData.cachedPageCount': verticalData.cachedPageCount, 'verticalData.loading': verticalData.loading, 'fourRowData.data?.length': fourRowData.data?.length || 0, 'fourRowData.total': fourRowData.total, }); // 根据模式选择数据源 // 纵向模式:data 是页码映射 { 1: [...], 2: [...] } // 平铺模式:data 是数组 [...] const modeData = currentMode === 'four-row' ? fourRowData : verticalData; const { data = currentMode === 'vertical' ? {} : [], // 纵向是对象,平铺是数组 loading = false, error = null, pagination, // 分页元数据 total = 0, // 向后兼容 cachedCount = 0, cachedPageCount = 0 } = modeData; // 传递给 usePagination 的数据 const allCachedEventsByPage = currentMode === 'vertical' ? data : undefined; const allCachedEvents = currentMode === 'four-row' ? data : undefined; // 🔍 调试:选择的数据源 console.log('%c[DynamicNewsCard] 选择的数据源', 'color: #3B82F6; font-weight: bold;', { mode: currentMode, 'allCachedEventsByPage': allCachedEventsByPage ? Object.keys(allCachedEventsByPage) : 'undefined', 'allCachedEvents?.length': allCachedEvents?.length, total, cachedCount, cachedPageCount, loading, error }); // 🔍 调试:记录每次渲染 dynamicNewsCardRenderCount++; console.log(`%c🔍 [DynamicNewsCard] 渲染 #${dynamicNewsCardRenderCount} - mode=${currentMode}, allCachedEvents.length=${allCachedEvents?.length || 0}, total=${total}`, 'color: #FF9800; font-weight: bold; font-size: 14px;'); // 关注按钮点击处理 const handleToggleFollow = useCallback((eventId) => { dispatch(toggleEventFollow(eventId)); }, [dispatch]); // 本地状态 const [selectedEvent, setSelectedEvent] = useState(null); // 弹窗状态(用于四排模式) const { isOpen: isModalOpen, onOpen: onModalOpen, onClose: onModalClose } = useDisclosure(); const [modalEvent, setModalEvent] = useState(null); // 初始化标记 - 确保初始加载只执行一次 const hasInitialized = useRef(false); // 追踪是否已自动选中过首个事件 const hasAutoSelectedFirstEvent = useRef(false); // 追踪筛选条件 useEffect 是否是第一次渲染(避免初始加载时重复请求) const isFirstRenderForFilters = useRef(true); // 使用分页 Hook const { currentPage, mode, loadingPage, pageSize, totalPages, hasMore, currentPageEvents, displayEvents, // 当前显示的事件列表 handlePageChange, handleModeToggle, loadNextPage, // 加载下一页 loadPrevPage // 加载上一页 } = usePagination({ allCachedEventsByPage, // 纵向模式:页码映射 allCachedEvents, // 平铺模式:数组 pagination, // 分页元数据对象 total, // 向后兼容 cachedCount, dispatch, toast, filters, // 传递筛选条件 initialMode: currentMode // 传递当前显示模式 }); // 同步 mode 到 currentMode useEffect(() => { setCurrentMode(mode); }, [mode]); // 监听 error 状态,显示空数据提示 useEffect(() => { if (error && error.includes('暂无更多数据')) { toast({ title: '提示', description: error, status: 'info', duration: 2000, isClosable: true, }); } }, [error, toast]); // 四排模式的事件点击处理(打开弹窗) const handleFourRowEventClick = useCallback((event) => { console.log('%c🔲 [四排模式] 点击事件,打开详情弹窗', 'color: #8B5CF6; font-weight: bold;', { eventId: event.id, title: event.title }); setModalEvent(event); onModalOpen(); }, [onModalOpen]); // 初始加载 - 只在组件首次挂载且对应模式数据为空时执行 useEffect(() => { // 添加防抖:如果已经初始化,不再执行 if (hasInitialized.current) return; const isDataEmpty = currentMode === 'vertical' ? Object.keys(allCachedEventsByPage || {}).length === 0 : (allCachedEvents?.length || 0) === 0; if (isDataEmpty) { hasInitialized.current = true; dispatch(fetchDynamicNews({ mode: mode, // 传递当前模式 per_page: pageSize, pageSize: pageSize, // 传递 pageSize 确保索引计算一致 clearCache: true, ...filters, // 先展开筛选条件 page: PAGINATION_CONFIG.INITIAL_PAGE, // 然后覆盖 page 参数 })); } }, [dispatch, currentMode, mode, pageSize]); // 移除 allCachedEventsByPage, allCachedEvents 依赖,避免数据更新触发重复请求 // 监听筛选条件变化 - 清空缓存并重新请求数据 useEffect(() => { // 跳过初始加载(由上面的 useEffect 处理) if (!hasInitialized.current) return; // 跳过第一次渲染(避免与初始加载 useEffect 重复) if (isFirstRenderForFilters.current) { isFirstRenderForFilters.current = false; return; } console.log('%c🔍 [筛选] 筛选条件改变,重新请求数据', 'color: #8B5CF6; font-weight: bold;', filters); // 筛选条件改变时,清空对应模式的缓存并从第1页开始加载 dispatch(fetchDynamicNews({ mode: mode, // 传递当前模式 per_page: pageSize, pageSize: pageSize, clearCache: true, // 清空缓存 ...filters, // 先展开筛选条件 page: PAGINATION_CONFIG.INITIAL_PAGE, // 然后覆盖 page 参数 })); }, [ filters.sort, filters.importance, filters.q, filters.start_date, // 时间筛选参数:开始时间 filters.end_date, // 时间筛选参数:结束时间 filters.recent_days, // 时间筛选参数:近N天 filters.industry_code, mode, // 添加 mode 到依赖 pageSize, // 添加 pageSize 到依赖 dispatch ]); // 只监听筛选参数的变化,不监听 page // 监听模式切换 - 如果新模式数据为空,请求数据 useEffect(() => { const isDataEmpty = currentMode === 'vertical' ? Object.keys(allCachedEventsByPage || {}).length === 0 : (allCachedEvents?.length || 0) === 0; if (hasInitialized.current && isDataEmpty) { console.log(`%c🔄 [模式切换] ${mode} 模式数据为空,开始加载`, 'color: #8B5CF6; font-weight: bold;'); // 🔧 根据 mode 直接计算 per_page,避免使用可能过时的 pageSize prop const modePageSize = mode === DISPLAY_MODES.FOUR_ROW ? PAGINATION_CONFIG.FOUR_ROW_PAGE_SIZE // 30 : PAGINATION_CONFIG.VERTICAL_PAGE_SIZE; // 10 console.log(`%c 计算的 per_page: ${modePageSize} (mode: ${mode})`, 'color: #8B5CF6;'); dispatch(fetchDynamicNews({ mode: mode, per_page: modePageSize, // 使用计算的值,不是 pageSize prop pageSize: modePageSize, clearCache: true, ...filters, // 先展开筛选条件 page: PAGINATION_CONFIG.INITIAL_PAGE, // 然后覆盖 page 参数 })); } }, [mode, currentMode, allCachedEventsByPage, allCachedEvents, dispatch]); // 移除 filters 依赖,避免与筛选 useEffect 循环触发 // 添加所有依赖 // 自动选中逻辑 - 只在首次加载时自动选中第一个事件,翻页时不自动选中 useEffect(() => { if (currentPageEvents.length > 0) { // 情况1: 首次加载 - 自动选中第一个事件并触发详情加载 if (!hasAutoSelectedFirstEvent.current && !selectedEvent) { console.log('%c🎯 [首次加载] 自动选中第一个事件', 'color: #10B981; font-weight: bold;'); hasAutoSelectedFirstEvent.current = true; setSelectedEvent(currentPageEvents[0]); return; } // 情况2: 翻页 - 如果选中的事件不在当前页,根据模式决定处理方式 const selectedEventInCurrentPage = currentPageEvents.find( e => e.id === selectedEvent?.id ); } }, [currentPageEvents, selectedEvent?.id, mode]); // 组件卸载时清理选中状态 useEffect(() => { return () => { setSelectedEvent(null); }; }, []); // 页码切换时滚动到顶部 const handlePageChangeWithScroll = useCallback((page) => { // 先切换页码 handlePageChange(page); // 延迟一帧,确保DOM更新完成后再滚动 requestAnimationFrame(() => { // 查找所有标记为滚动容器的元素 const containers = document.querySelectorAll('[data-scroll-container]'); containers.forEach(container => { container.scrollTo({ top: 0, behavior: 'smooth' }); }); console.log('📜 页码切换,滚动到顶部', { containersFound: containers.length }); }); }, [handlePageChange]); // 测量 CardHeader 高度 useEffect(() => { const cardHeaderElement = cardHeaderRef.current; if (!cardHeaderElement) return; // 测量并更新高度 const updateHeaderHeight = () => { const height = cardHeaderElement.offsetHeight; setHeaderHeight(height); }; // 初始测量 updateHeaderHeight(); // 监听窗口大小变化(响应式调整) window.addEventListener('resize', updateHeaderHeight); return () => { window.removeEventListener('resize', updateHeaderHeight); }; }, []); // 监听 CardHeader 是否到达触发点,动态切换固定模式 useEffect(() => { const cardHeaderElement = cardHeaderRef.current; const cardBodyElement = cardBodyRef.current; if (!cardHeaderElement || !cardBodyElement) return; let ticking = false; const TRIGGER_OFFSET = 100; // 提前 100px 触发 // 外部滚动监听:触发固定模式 const handleExternalScroll = () => { // 只在非固定模式下监听外部滚动 if (!isFixedMode && !ticking) { window.requestAnimationFrame(() => { // 获取 CardHeader 相对视口的位置 const rect = cardHeaderElement.getBoundingClientRect(); const elementTop = rect.top; // 计算触发点:总导航高度 + 100px 偏移量 const triggerPoint = TOTAL_NAV_HEIGHT + TRIGGER_OFFSET; // 向上滑动:元素顶部到达触发点 → 激活固定模式 if (elementTop <= triggerPoint) { setIsFixedMode(true); console.log('🔒 切换为固定全屏模式', { elementTop, triggerPoint, offset: TRIGGER_OFFSET }); } ticking = false; }); ticking = true; } }; // 内部滚动监听:退出固定模式 const handleWheel = (e) => { // 只在固定模式下监听内部滚动 if (!isFixedMode) return; // 检测向上滚动(deltaY < 0) if (e.deltaY < 0) { // 查找所有滚动容器 const scrollContainers = cardBodyElement.querySelectorAll('[data-scroll-container]'); if (scrollContainers.length === 0) { // 如果没有找到标记的容器,查找所有可滚动元素 const allScrollable = cardBodyElement.querySelectorAll('[style*="overflow"]'); scrollContainers = allScrollable; } // 检查是否所有滚动容器都在顶部 const allAtTop = scrollContainers.length === 0 || Array.from(scrollContainers).every( container => container.scrollTop === 0 ); if (allAtTop) { setIsFixedMode(false); console.log('🔓 恢复正常文档流模式(内部滚动到顶部)'); } } }; // 监听外部滚动 window.addEventListener('scroll', handleExternalScroll, { passive: true }); // 监听内部滚轮事件(固定模式下) if (isFixedMode) { cardBodyElement.addEventListener('wheel', handleWheel, { passive: true }); } // 初次检查位置 handleExternalScroll(); return () => { window.removeEventListener('scroll', handleExternalScroll); cardBodyElement.removeEventListener('wheel', handleWheel); }; }, [isFixedMode]); return ( {/* 标题部分 */} 实时要闻·动态追踪 实时 盘中 快讯 最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'} {/* 搜索和筛选组件 */} {/* 主体内容 */} {/* 顶部控制栏:模式切换按钮 + 筛选按钮 + 分页控制器(固定不滚动) */} {/* 左侧:模式切换按钮 + 筛选按钮 */} {/* 右侧:分页控制器(仅在纵向模式显示) */} {mode === 'vertical' && totalPages > 1 && ( )} {/* 内容区域 - 撑满剩余高度 */} {/* Loading 蒙层 - 数据请求时显示 */} {loading && ( 正在加载最新事件... )} {/* 列表内容 - 始终渲染 */} {/* 四排模式详情弹窗 - 未打开时不渲染 */} {isModalOpen && ( {modalEvent?.title || '事件详情'} {modalEvent && } )} ); }); DynamicNewsCard.displayName = 'DynamicNewsCard'; export default DynamicNewsCard;