Files
vf_react/src/views/Community/components/DynamicNewsCard.js
zdl 319a78d34c fix: 修复分页、筛选和模式切换相关问题
主要修复:
1. 修复模式切换时 per_page 参数错误
   - 在 useEffect 内直接根据 mode 计算 per_page
   - 避免使用可能过时的 pageSize prop

2. 修复 DISPLAY_MODES 未定义错误
   - 在 DynamicNewsCard.js 中导入 DISPLAY_MODES 常量

3. 添加空状态显示
   - VerticalModeLayout 添加无数据时的友好提示
   - 显示图标和提示文字,引导用户调整筛选条件

4. 修复无限请求循环问题
   - 移除模式切换 useEffect 中的 filters 依赖
   - 避免筛选和模式切换 useEffect 互相触发

5. 修复筛选参数传递问题
   - usePagination 使用 useRef 存储最新 filters
   - 避免 useCallback 闭包捕获旧值
   - 修复时间筛选参数丢失问题

6. 修复分页竞态条件
   - 允许用户在加载时切换到不同页面
   - 只阻止相同页面的重复请求

涉及文件:
- src/views/Community/components/DynamicNewsCard.js
- src/views/Community/components/DynamicNewsCard/VerticalModeLayout.js
- src/views/Community/components/DynamicNewsCard/hooks/usePagination.js
- src/views/Community/hooks/useEventFilters.js
- src/store/slices/communityDataSlice.js
- src/views/Community/components/UnifiedSearchBox.js

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 17:39:03 +08:00

601 lines
21 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.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 (
<Card
ref={ref}
{...rest}
bg={cardBg}
borderColor={borderColor}
mb={4}
>
{/* 标题部分 */}
<CardHeader
ref={cardHeaderRef}
position={isFixedMode ? 'fixed' : 'relative'}
top={isFixedMode ? `${TOTAL_NAV_HEIGHT}px` : 'auto'}
left={isFixedMode ? 0 : 'auto'}
right={isFixedMode ? 0 : 'auto'}
maxW={isFixedMode ? 'container.xl' : '100%'}
mx={isFixedMode ? 'auto' : 0}
px={isFixedMode ? { base: 3, md: 4 } : undefined}
zIndex={isFixedMode ? 999 : 1}
bg={cardBg}
>
<Flex justify="space-between" align="center">
<VStack align="start" spacing={1}>
<Heading size="md">
<HStack>
<TimeIcon />
<Text>实时要闻·动态追踪</Text>
</HStack>
</Heading>
<HStack fontSize="sm" color="gray.500">
<Badge colorScheme="red">实时</Badge>
<Badge colorScheme="green">盘中</Badge>
<Badge colorScheme="blue">快讯</Badge>
</HStack>
</VStack>
<Text fontSize="xs" color="gray.500">
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
</Text>
</Flex>
{/* 搜索和筛选组件 */}
<Box mt={4}>
<UnifiedSearchBox
onSearch={onSearch}
onSearchFocus={onSearchFocus}
popularKeywords={popularKeywords}
filters={filters}
mode={mode}
pageSize={pageSize}
/>
</Box>
</CardHeader>
{/* 主体内容 */}
<CardBody
ref={cardBodyRef}
position={isFixedMode ? 'fixed' : 'relative'}
top={isFixedMode ? `${TOTAL_NAV_HEIGHT + headerHeight}px` : 'auto'}
left={isFixedMode ? 0 : 'auto'}
right={isFixedMode ? 0 : 'auto'}
bottom={isFixedMode ? `${FOOTER_HEIGHT}px` : 'auto'}
maxW={isFixedMode ? 'container.xl' : '100%'}
mx={isFixedMode ? 'auto' : 0}
h={isFixedMode ? `calc(100vh - ${TOTAL_NAV_HEIGHT + headerHeight + FOOTER_HEIGHT}px)` : 'auto'}
px={isFixedMode ? { base: 3, md: 4 } : undefined}
pt={4}
display="flex"
flexDirection="column"
overflow="hidden"
zIndex={isFixedMode ? 1000 : 1}
bg={cardBg}
>
{/* 顶部控制栏:模式切换按钮 + 筛选按钮 + 分页控制器(固定不滚动) */}
<Flex justify="space-between" align="center" mb={2} flexShrink={0}>
{/* 左侧:模式切换按钮 + 筛选按钮 */}
<ModeToggleButtons mode={mode} onModeChange={handleModeToggle} />
{/* 右侧:分页控制器(仅在纵向模式显示) */}
{mode === 'vertical' && totalPages > 1 && (
<PaginationControl
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChangeWithScroll}
/>
)}
</Flex>
{/* 内容区域 - 撑满剩余高度 */}
<Box flex="1" minH={0} position="relative">
{/* Loading 蒙层 - 数据请求时显示 */}
{loading && (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
bg={useColorModeValue('rgba(255, 255, 255, 0.85)', 'rgba(26, 32, 44, 0.85)')}
display="flex"
alignItems="center"
justifyContent="center"
zIndex={10}
borderRadius="md"
>
<VStack spacing={3}>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color={useColorModeValue('gray.600', 'gray.300')} fontWeight="medium">
正在加载最新事件...
</Text>
</VStack>
</Box>
)}
{/* 列表内容 - 始终渲染 */}
<EventScrollList
events={currentPageEvents}
displayEvents={displayEvents} // 累积显示的事件列表(平铺模式)
loadNextPage={loadNextPage} // 加载下一页
loadPrevPage={loadPrevPage} // 加载上一页
onFourRowEventClick={handleFourRowEventClick} // 四排模式事件点击
selectedEvent={selectedEvent}
onEventSelect={setSelectedEvent}
borderColor={borderColor}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChangeWithScroll}
loading={loadingPage !== null}
error={error}
mode={mode}
eventFollowStatus={eventFollowStatus}
onToggleFollow={handleToggleFollow}
hasMore={hasMore}
/>
</Box>
</CardBody>
{/* 四排模式详情弹窗 - 未打开时不渲染 */}
{isModalOpen && (
<Modal isOpen={isModalOpen} onClose={onModalClose} size="6xl" scrollBehavior="inside">
<ModalOverlay />
<ModalContent>
<ModalHeader>
{modalEvent?.title || '事件详情'}
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
{modalEvent && <DynamicNewsDetailPanel event={modalEvent} />}
</ModalBody>
</ModalContent>
</Modal>
)}
</Card>
);
});
DynamicNewsCard.displayName = 'DynamicNewsCard';
export default DynamicNewsCard;