Files
vf_react/src/views/Community/components/DynamicNewsCard.js

541 lines
16 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 } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
Card,
CardHeader,
CardBody,
Box,
Flex,
VStack,
HStack,
Heading,
Text,
Badge,
Center,
Spinner,
useColorModeValue,
useToast
} from '@chakra-ui/react';
import { TimeIcon } from '@chakra-ui/icons';
import EventScrollList from './DynamicNewsCard/EventScrollList';
import DynamicNewsDetailPanel from './DynamicNewsDetail';
import UnifiedSearchBox from './UnifiedSearchBox';
import { fetchDynamicNews, toggleEventFollow, selectEventFollowStatus } from '../../../store/slices/communityDataSlice';
/**
* 分页逻辑自定义 Hook
* @param {Object} options - Hook 配置选项
* @param {Array} options.allCachedEvents - 完整缓存事件列表
* @param {number} options.total - 服务端总数量
* @param {number} options.cachedCount - 已缓存数量
* @param {Function} options.dispatch - Redux dispatch 函数
* @param {Function} options.toast - Toast 通知函数
* @returns {Object} 分页状态和方法
*/
const usePagination = ({ allCachedEvents, total, cachedCount, dispatch, toast }) => {
// 本地状态
const [currentPage, setCurrentPage] = useState(1);
const [loadingPage, setLoadingPage] = useState(null);
const [mode, setMode] = useState('carousel'); // 'carousel' 或 'grid'
// 根据模式决定每页显示数量
const pageSize = mode === 'carousel' ? 5 : 10;
// 计算总页数(基于服务端总数据量)
const totalPages = Math.ceil(total / pageSize) || 1;
// 检查是否还有更多数据
const hasMore = cachedCount < total;
// 从缓存中切片获取当前页数据(过滤 null 占位符)
const currentPageEvents = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
return allCachedEvents.slice(startIndex, endIndex).filter(event => event !== null);
}, [allCachedEvents, currentPage, pageSize]);
/**
* 子函数1: 检查目标页缓存状态
* @param {number} targetPage - 目标页码
* @returns {Object} { isTargetPageCached, targetPageInfo }
*/
const checkTargetPageCache = useCallback((targetPage) => {
const targetPageStartIndex = (targetPage - 1) * pageSize;
const targetPageEndIndex = targetPageStartIndex + pageSize;
const targetPageData = allCachedEvents.slice(targetPageStartIndex, targetPageEndIndex);
const validTargetData = targetPageData.filter(e => e !== null);
const expectedCount = Math.min(pageSize, total - targetPageStartIndex);
const isTargetPageCached = validTargetData.length >= expectedCount;
console.log('[checkTargetPageCache] 目标页缓存检查', {
targetPage,
targetPageStartIndex,
targetPageEndIndex,
targetPageDataLength: targetPageData.length,
validTargetDataLength: validTargetData.length,
expectedCount,
isTargetPageCached
});
return {
isTargetPageCached,
targetPageInfo: {
startIndex: targetPageStartIndex,
endIndex: targetPageEndIndex,
validCount: validTargetData.length,
expectedCount
}
};
}, [allCachedEvents, pageSize, total]);
/**
* 子函数2: 计算预加载范围
* @param {number} targetPage - 目标页码
* @param {number} fromPage - 来源页码
* @returns {Array<number>} 预加载页码数组
*/
const calculatePreloadRange = useCallback((targetPage, fromPage) => {
const isSequentialNavigation = Math.abs(targetPage - fromPage) === 1;
let preloadRange;
if (isSequentialNavigation) {
// 连续翻页前后各2页共5页
const start = Math.max(1, targetPage - 2);
const end = Math.min(totalPages, targetPage + 2);
preloadRange = Array.from(
{ length: end - start + 1 },
(_, i) => start + i
);
} else {
// 跳转翻页:只加载当前页
preloadRange = [targetPage];
}
console.log('[calculatePreloadRange] 计算预加载范围', {
targetPage,
fromPage,
isSequentialNavigation,
preloadRange
});
return preloadRange;
}, [totalPages]);
/**
* 子函数3: 查找缺失页面
* @param {Array<number>} preloadRange - 预加载范围
* @returns {Array<number>} 缺失页码数组
*/
const findMissingPages = useCallback((preloadRange) => {
const missingPages = preloadRange.filter(page => {
const pageStartIndex = (page - 1) * pageSize;
const pageEndIndex = pageStartIndex + pageSize;
// 如果该页超出数组范围,说明未缓存
if (pageEndIndex > allCachedEvents.length) {
console.log(`[findMissingPages] 页面${page}超出数组范围`, {
pageStartIndex,
pageEndIndex,
allCachedEventsLength: allCachedEvents.length
});
return true;
}
// 检查该页的数据是否包含 null 占位符或数据不足
const pageData = allCachedEvents.slice(pageStartIndex, pageEndIndex);
const validData = pageData.filter(e => e !== null);
const expectedCount = Math.min(pageSize, total - pageStartIndex);
const hasNullOrIncomplete = validData.length < expectedCount;
console.log(`[findMissingPages] 页面${page}检查`, {
pageStartIndex,
pageEndIndex,
pageDataLength: pageData.length,
validDataLength: validData.length,
expectedCount,
hasNullOrIncomplete
});
return hasNullOrIncomplete;
});
console.log('[findMissingPages] 缺失页面检测完成', {
preloadRange,
missingPages,
missingPagesCount: missingPages.length
});
return missingPages;
}, [allCachedEvents, pageSize, total]);
/**
* 子函数4: 加载页面数据
* @param {Array<number>} missingPages - 缺失页码数组
* @param {number} targetPage - 目标页码
* @param {boolean} silentMode - 静默模式(后台预加载)
* @returns {Promise<boolean>} 是否加载成功
*/
const loadPages = useCallback(async (missingPages, targetPage, silentMode = false) => {
if (!silentMode) {
// 显示 loading 状态
setLoadingPage(targetPage);
}
try {
console.log(`[loadPages] 开始加载`, {
missingPages,
targetPage,
silentMode,
pageSize
});
// 拆分为单页请求,避免 per_page 动态值导致后端返回空数据
for (const page of missingPages) {
console.log(`[loadPages] 开始加载第 ${page}`);
await dispatch(fetchDynamicNews({
page: page,
per_page: pageSize, // 固定值5或10不使用动态计算
pageSize: pageSize,
clearCache: false
})).unwrap();
console.log(`[loadPages] 第 ${page} 页加载完成`);
}
console.log('[loadPages] 所有页面加载完成', {
missingPages,
silentMode
});
return true;
} catch (error) {
console.error('[loadPages] 加载失败', error);
if (!silentMode) {
// 非静默模式下显示错误提示
toast({
title: '加载失败',
description: `无法加载第 ${targetPage} 页数据,请稍后重试`,
status: 'error',
duration: 3000,
isClosable: true,
position: 'top'
});
}
return false;
} finally {
if (!silentMode) {
// 清除加载状态
setLoadingPage(null);
}
}
}, [dispatch, pageSize, toast]);
// 翻页处理(智能预加载)- 使用子函数重构
const handlePageChange = useCallback(async (newPage) => {
// 🔍 诊断日志 - 记录翻页开始状态
console.log('[handlePageChange] 开始翻页', {
currentPage,
newPage,
pageSize,
totalPages,
hasMore,
total,
allCachedEventsLength: allCachedEvents.length,
cachedCount
});
// 步骤1: 检查目标页缓存状态
const { isTargetPageCached } = checkTargetPageCache(newPage);
// 步骤2: 计算预加载范围
const preloadRange = calculatePreloadRange(newPage, currentPage);
// 步骤3: 查找缺失页面
const missingPages = findMissingPages(preloadRange);
// 步骤4: 根据情况加载数据
if (isTargetPageCached && missingPages.length > 0 && hasMore) {
// 场景A: 目标页已缓存,立即切换,后台静默预加载其他页
console.log('[handlePageChange] 目标页已缓存,立即切换 + 后台预加载', {
currentPage,
newPage,
缺失页面: missingPages
});
setCurrentPage(newPage);
await loadPages(missingPages, newPage, true); // 静默模式
} else if (missingPages.length > 0 && hasMore) {
// 场景B: 目标页未缓存,显示 loading 并等待加载完成
console.log('[handlePageChange] 目标页未缓存,显示 loading', {
currentPage,
newPage,
缺失页面: missingPages
});
const success = await loadPages(missingPages, newPage, false); // 非静默模式
if (success) {
setCurrentPage(newPage);
}
} else if (missingPages.length === 0) {
// 场景C: 所有页面均已缓存,直接切换
console.log('[handlePageChange] 无需加载,直接切换', {
currentPage,
newPage,
reason: '所有页面均已缓存'
});
setCurrentPage(newPage);
} else {
// 场景D: 意外分支(有缺失页面但 hasMore=false
console.warn('[handlePageChange] 意外分支:有缺失页面但无法加载', {
missingPages,
hasMore,
currentPage,
newPage,
total,
cachedCount
});
setCurrentPage(newPage);
toast({
title: '数据不完整',
description: `${newPage} 页数据可能不完整`,
status: 'warning',
duration: 2000,
isClosable: true,
position: 'top'
});
}
}, [
currentPage,
pageSize,
totalPages,
hasMore,
total,
allCachedEvents.length,
cachedCount,
checkTargetPageCache,
calculatePreloadRange,
findMissingPages,
loadPages,
toast
]);
// 模式切换处理
const handleModeToggle = useCallback((newMode) => {
if (newMode === mode) return;
setMode(newMode);
setCurrentPage(1);
const newPageSize = newMode === 'carousel' ? 5 : 10;
// 检查第1页的数据是否完整排除 null
const firstPageData = allCachedEvents.slice(0, newPageSize);
const validFirstPageCount = firstPageData.filter(e => e !== null).length;
const needsRefetch = validFirstPageCount < Math.min(newPageSize, total);
if (needsRefetch) {
// 第1页数据不完整清空缓存重新请求
dispatch(fetchDynamicNews({
page: 1,
per_page: newPageSize,
pageSize: newPageSize, // 传递 pageSize 确保索引计算一致
clearCache: true
}));
}
// 如果第1页数据完整不发起请求直接切换
}, [mode, allCachedEvents, total, dispatch]);
return {
// 状态
currentPage,
mode,
loadingPage,
pageSize,
totalPages,
hasMore,
currentPageEvents,
// 方法
handlePageChange,
handleModeToggle
};
};
/**
* 实时要闻·动态追踪 - 事件展示卡片组件
* @param {Array} allCachedEvents - 完整缓存事件列表(从 Redux 传入)
* @param {boolean} loading - 加载状态
* @param {number} total - 服务端总数量
* @param {number} cachedCount - 已缓存数量
* @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(({
allCachedEvents = [],
loading,
total = 0,
cachedCount = 0,
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');
// 从 Redux 读取关注状态
const eventFollowStatus = useSelector(selectEventFollowStatus);
// 关注按钮点击处理
const handleToggleFollow = useCallback((eventId) => {
dispatch(toggleEventFollow(eventId));
}, [dispatch]);
// 本地状态
const [selectedEvent, setSelectedEvent] = useState(null);
// 使用分页 Hook
const {
currentPage,
mode,
loadingPage,
pageSize,
totalPages,
hasMore,
currentPageEvents,
handlePageChange,
handleModeToggle
} = usePagination({
allCachedEvents,
total,
cachedCount,
dispatch,
toast
});
// 初始加载
useEffect(() => {
if (allCachedEvents.length === 0) {
dispatch(fetchDynamicNews({
page: 1,
per_page: 5,
pageSize: 5, // 传递 pageSize 确保索引计算一致
clearCache: true
}));
}
}, [dispatch, allCachedEvents.length]);
// 默认选中第一个事件
useEffect(() => {
if (currentPageEvents.length > 0 && !selectedEvent) {
setSelectedEvent(currentPageEvents[0]);
}
}, [currentPageEvents, selectedEvent]);
return (
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
{/* 标题部分 */}
<CardHeader>
<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}
/>
</Box>
</CardHeader>
{/* 主体内容 */}
<CardBody position="relative" pt={0}>
{/* 横向滚动事件列表 - 始终渲染(除非为空) */}
{currentPageEvents && currentPageEvents.length > 0 ? (
<EventScrollList
events={currentPageEvents}
selectedEvent={selectedEvent}
onEventSelect={setSelectedEvent}
borderColor={borderColor}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
loading={loadingPage !== null}
loadingPage={loadingPage}
mode={mode}
onModeChange={handleModeToggle}
eventFollowStatus={eventFollowStatus}
onToggleFollow={handleToggleFollow}
hasMore={hasMore}
/>
) : !loading ? (
/* Empty 状态 - 只在非加载且无数据时显示 */
<Center py={10}>
<VStack>
<Text fontSize="lg" color="gray.500">暂无事件数据</Text>
</VStack>
</Center>
) : (
/* 首次加载状态 */
<Center py={10}>
<VStack>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color="gray.500">正在加载最新事件...</Text>
</VStack>
</Center>
)}
{/* 详情面板 - 始终显示(如果有选中事件) */}
{currentPageEvents && currentPageEvents.length > 0 && selectedEvent && (
<Box mt={6}>
<DynamicNewsDetailPanel event={selectedEvent} />
</Box>
)}
</CardBody>
</Card>
);
});
DynamicNewsCard.displayName = 'DynamicNewsCard';
export default DynamicNewsCard;