541 lines
16 KiB
JavaScript
541 lines
16 KiB
JavaScript
// 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;
|