// src/views/Community/components/DynamicNewsCard.js // 横向滚动事件卡片组件(实时要闻·动态追踪) import React, { forwardRef, useState, useEffect, useMemo, useCallback, useRef, useImperativeHandle } 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, Switch, Tooltip, Icon, } from '@chakra-ui/react'; import { TimeIcon, BellIcon } from '@chakra-ui/icons'; import { useNotification } from '../../../contexts/NotificationContext'; import EventScrollList from './DynamicNewsCard/EventScrollList'; import ModeToggleButtons from './DynamicNewsCard/ModeToggleButtons'; import PaginationControl from './DynamicNewsCard/PaginationControl'; import DynamicNewsDetailPanel from './DynamicNewsDetail'; import CompactSearchBox from './CompactSearchBox'; import { fetchDynamicNews, toggleEventFollow, selectEventFollowStatus, selectVerticalEventsWithLoading, selectFourRowEventsWithLoading } from '../../../store/slices/communityDataSlice'; import { usePagination } from './DynamicNewsCard/hooks/usePagination'; import { PAGINATION_CONFIG, DISPLAY_MODES, REFRESH_DEBOUNCE_DELAY } from './DynamicNewsCard/constants'; import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme'; import { debounce } from '../../../utils/debounce'; import { useDevice } from '@hooks/useDevice'; // 🔍 调试:渲染计数器 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} trackingFunctions - PostHog 追踪函数集合 * @param {Object} ref - 用于滚动的ref */ const DynamicNewsCardComponent = forwardRef(({ filters = {}, popularKeywords = [], lastUpdateTime, onSearch, onSearchFocus, onEventClick, onViewDetail, trackingFunctions = {}, ...rest }, ref) => { const dispatch = useDispatch(); const toast = useToast(); const cardBg = PROFESSIONAL_COLORS.background.card; const borderColor = PROFESSIONAL_COLORS.border.default; // 通知权限相关 const { browserPermission, requestBrowserPermission } = useNotification(); const { isMobile } = useDevice(); // Refs const cardHeaderRef = useRef(null); const cardBodyRef = useRef(null); const virtualizedGridRef = useRef(null); // ⚡ VirtualizedFourRowGrid 的 ref(用于获取滚动位置) // 从 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, }); // 根据模式选择数据源(使用 useMemo 缓存,避免重复计算) // 纵向模式:data 是页码映射 { 1: [...], 2: [...] } // 平铺模式:data 是数组 [...] const modeData = useMemo( () => currentMode === 'four-row' ? fourRowData : verticalData, [currentMode, fourRowData, verticalData] ); const { data = currentMode === 'vertical' ? {} : [], // 纵向是对象,平铺是数组 loading = false, error = null, pagination, // 分页元数据 total = 0, // 向后兼容 cachedCount = 0, cachedPageCount = 0 } = modeData; // 传递给 usePagination 的数据(使用 useMemo 缓存,避免重复计算) const allCachedEventsByPage = useMemo( () => currentMode === 'vertical' ? data : undefined, [currentMode, data] ); const allCachedEvents = useMemo( () => currentMode === 'four-row' ? data : undefined, [currentMode, data] ); // 🔍 调试:选择的数据源 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 handleNotificationToggle = useCallback(async () => { if (browserPermission === 'granted') { // 已授权,提示用户去浏览器设置中关闭 toast({ title: '已开启通知', description: '要关闭通知,请在浏览器地址栏左侧点击锁图标,找到"通知"选项进行设置', status: 'info', duration: 5000, isClosable: true, }); } else { // 未授权,请求权限 await requestBrowserPermission(); } }, [browserPermission, requestBrowserPermission, toast]); // 本地状态 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]); /** * ⚡【核心逻辑】执行刷新的回调函数(包含原有的智能刷新逻辑) * * 此函数会被 debounce 包装,避免短时间内频繁刷新 */ const executeRefresh = useCallback(() => { const state = { mode, currentPage: pagination?.current_page || 1, }; console.log('[DynamicNewsCard] ⏰ executeRefresh() 执行(防抖延迟后)', state); if (mode === 'vertical') { // ========== 纵向模式 ========== // 只在第1页时刷新,避免打断用户浏览其他页 if (state.currentPage === 1) { console.log('[DynamicNewsCard] 纵向模式 + 第1页 → 强制刷新列表'); handlePageChange(1, true); // ⚡ 传递 force = true,强制刷新第1页 toast({ title: '检测到新事件', status: 'info', duration: 2000, isClosable: true, }); } else { console.log(`[DynamicNewsCard] 纵向模式 + 第${state.currentPage}页 → 不刷新(避免打断用户)`); } } else if (mode === 'four-row') { // ========== 平铺模式 ========== // 检查滚动位置,只有在顶部时才刷新 const scrollPos = virtualizedGridRef.current?.getScrollPosition(); if (scrollPos?.isNearTop) { // 用户在顶部 10% 区域,安全刷新 console.log('[DynamicNewsCard] 平铺模式 + 滚动在顶部 → 刷新列表'); handlePageChange(1); // 清空并刷新 toast({ title: '检测到新事件,已刷新', status: 'info', duration: 2000, isClosable: true, }); } else { // 用户不在顶部,显示提示但不自动刷新 console.log('[DynamicNewsCard] 平铺模式 + 滚动不在顶部 → 仅提示,不刷新'); toast({ title: '有新事件发布', description: '滚动到顶部查看', status: 'info', duration: 3000, isClosable: true, }); } } }, [mode, pagination, handlePageChange, toast]); /** * ⚡【防抖包装】创建防抖版本的刷新函数 * * 使用 useMemo 确保防抖函数在 executeRefresh 不变时保持引用稳定 * 防抖延迟:REFRESH_DEBOUNCE_DELAY (2000ms) * * 效果:短时间内收到多个新事件,只执行最后一次刷新 */ const debouncedRefresh = useMemo( () => debounce(executeRefresh, REFRESH_DEBOUNCE_DELAY), [executeRefresh] ); /** * ⚡ 暴露方法给父组件(用于 Socket 自动刷新) */ useImperativeHandle(ref, () => ({ /** * 智能刷新方法(带防抖,避免频繁刷新) * * 调用此方法时: * 1. 清除之前的定时器(如果有) * 2. 设置新的定时器(延迟 REFRESH_DEBOUNCE_DELAY 后执行) * 3. 如果在延迟期间再次调用,重复步骤 1-2 * 4. 只有最后一次调用会在延迟后实际执行 executeRefresh() */ refresh: () => { console.log('[DynamicNewsCard] 🔔 refresh() 被调用(设置防抖定时器)', { mode, currentPage: pagination?.current_page || 1, debounceDelay: `${REFRESH_DEBOUNCE_DELAY}ms`, }); // 调用防抖包装后的函数 debouncedRefresh(); }, /** * 获取当前状态(用于调试) */ getState: () => ({ mode, currentPage: pagination?.current_page || 1, totalPages: pagination?.total_pages || 1, total: pagination?.total || 0, loading, }), }), [mode, pagination, loading, debouncedRefresh]); /** * ⚡【清理逻辑】组件卸载时取消待执行的防抖函数 * * 作用:避免组件卸载后仍然执行刷新操作(防止内存泄漏和潜在错误) */ useEffect(() => { return () => { console.log('[DynamicNewsCard] 🧹 组件卸载,取消待执行的防抖刷新'); debouncedRefresh.cancel(); }; }, [debouncedRefresh]); // 监听 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 }); // 🎯 追踪事件详情打开 if (trackingFunctions.trackNewsDetailOpened) { trackingFunctions.trackNewsDetailOpened({ eventId: event.id, eventTitle: event.title, importance: event.importance, source: 'four_row_mode', displayMode: 'modal', timestamp: new Date().toISOString(), }); } setModalEvent(event); onModalOpen(); }, [onModalOpen, trackingFunctions]); // 初始加载 - 只在组件首次挂载且对应模式数据为空时执行 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, filters._forceRefresh, // 强制刷新标志(用于重置按钮) 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]); // 🎯 追踪事件点击(首次自动选中) if (trackingFunctions.trackNewsArticleClicked) { trackingFunctions.trackNewsArticleClicked({ eventId: currentPageEvents[0].id, eventTitle: currentPageEvents[0].title, importance: currentPageEvents[0].importance, source: 'auto_select_first', displayMode: mode, timestamp: new Date().toISOString(), }); } return; } // 情况2: 翻页 - 如果选中的事件不在当前页,根据模式决定处理方式 const selectedEventInCurrentPage = currentPageEvents.find( e => e.id === selectedEvent?.id ); } }, [currentPageEvents, selectedEvent?.id, mode, trackingFunctions]); // 组件卸载时清理选中状态 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 高度 // 固定模式逻辑已移除,改用 sticky 定位 return ( {/* 标题和搜索部分 - 优化版 */} {/* 第一行:标题 + 通知开关 + 更新时间 */} {/* 左侧:标题 */} 实时要闻·动态追踪 {/* 右侧:通知开关 + 更新时间 */} {/* 通知开关 - 移动端隐藏 */} {!isMobile && ( {browserPermission === 'granted' ? '已开启' : '开启通知'} )} {/* 更新时间 */} 最后更新: {lastUpdateTime?.toLocaleTimeString() || '--'} {/* 第二行:筛选组件 */} {/* 主体内容 */} {/* 顶部控制栏:模式切换按钮 + 分页控制器(滚动时固定在顶部) */} {/* 左侧:模式切换按钮 */} {/* 右侧:分页控制器(仅在纵向模式显示),H5 放不下时折行 */} {!isMobile && mode === 'vertical' && totalPages > 1 && ( )} {/* 内容区域 - 撑满剩余高度 */} {/* Loading 蒙层 - 数据请求时显示 */} {loading && ( 正在加载最新事件... )} {/* 列表内容 - 始终渲染 */} {/* 四排模式详情弹窗 - 未打开时不渲染 */} {isModalOpen && ( {modalEvent?.title || '事件详情'} {modalEvent && } )} {/* 右侧:分页控制器(仅在纵向模式显示),H5 放不下时折行 */} {mode === 'vertical' && totalPages > 1 && ( )} ); }); DynamicNewsCardComponent.displayName = 'DynamicNewsCard'; // ⚡ 使用 React.memo 优化性能(减少不必要的重渲染) const DynamicNewsCard = React.memo(DynamicNewsCardComponent); export default DynamicNewsCard;