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

710 lines
25 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/DynamicNews/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 './EventScrollList';
import ModeToggleButtons from './ModeToggleButtons';
import PaginationControl from './PaginationControl';
import DynamicNewsDetailPanel from '@components/EventDetailPanel';
import CompactSearchBox from '../SearchFilters/CompactSearchBox';
import {
fetchDynamicNews,
toggleEventFollow,
selectEventFollowStatus,
selectVerticalEventsWithLoading,
selectFourRowEventsWithLoading
} from '@store/slices/communityDataSlice';
import { usePagination } from './hooks/usePagination';
import { PAGINATION_CONFIG, DISPLAY_MODES, REFRESH_DEBOUNCE_DELAY } from './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 是数组 [...] (共用 fourRowData
const modeData = useMemo(
() => (currentMode === 'four-row' || currentMode === 'mainline') ? 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' || currentMode === 'mainline') ? 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' || mode === 'mainline') {
// ========== 平铺模式 / 主线模式 ==========
// 检查滚动位置,只有在顶部时才刷新
const scrollPos = virtualizedGridRef.current?.getScrollPosition();
if (scrollPos?.isNearTop) {
// 用户在顶部 10% 区域,安全刷新
console.log(`[DynamicNewsCard] ${mode === 'mainline' ? '主线' : '平铺'}模式 + 滚动在顶部 → 刷新列表`);
handlePageChange(1); // 清空并刷新
toast({
title: '检测到新事件,已刷新',
status: 'info',
duration: 2000,
isClosable: true,
});
} else {
// 用户不在顶部,显示提示但不自动刷新
console.log(`[DynamicNewsCard] ${mode === 'mainline' ? '主线' : '平铺'}模式 + 滚动不在顶部 → 仅提示,不刷新`);
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;
// mainline 模式使用 four-row 的 API 模式(共用数据)
const apiMode = mode === DISPLAY_MODES.MAINLINE ? DISPLAY_MODES.FOUR_ROW : mode;
// ⚡ 始终获取最新数据,确保用户每次进入页面看到最新事件
hasInitialized.current = true;
console.log('%c🚀 [初始加载] 获取最新事件数据', 'color: #10B981; font-weight: bold;', { mode, apiMode, pageSize });
dispatch(fetchDynamicNews({
mode: apiMode, // 传递 API 模式mainline 映射为 four-row
per_page: pageSize,
pageSize: pageSize, // 传递 pageSize 确保索引计算一致
clearCache: true,
...filters, // 先展开筛选条件
page: PAGINATION_CONFIG.INITIAL_PAGE, // 然后覆盖 page 参数
}));
// ⚡ 组件卸载时重置初始化标记,确保下次进入页面会重新获取数据
return () => {
hasInitialized.current = false;
isFirstRenderForFilters.current = true;
hasAutoSelectedFirstEvent.current = false;
console.log('%c🧹 [卸载] 重置初始化标记', 'color: #F59E0B; font-weight: bold;');
};
}, [dispatch, mode, pageSize]); // 移除 currentMode 依赖,避免模式切换时重复请求
// 监听筛选条件变化 - 清空缓存并重新请求数据
useEffect(() => {
// 跳过初始加载(由上面的 useEffect 处理)
if (!hasInitialized.current) return;
// 跳过第一次渲染(避免与初始加载 useEffect 重复)
if (isFirstRenderForFilters.current) {
isFirstRenderForFilters.current = false;
return;
}
// mainline 模式使用 four-row 的 API 模式(共用数据)
const apiMode = mode === DISPLAY_MODES.MAINLINE ? DISPLAY_MODES.FOUR_ROW : mode;
console.log('%c🔍 [筛选] 筛选条件改变,重新请求数据', 'color: #8B5CF6; font-weight: bold;', { filters, mode, apiMode });
// 筛选条件改变时清空对应模式的缓存并从第1页开始加载
dispatch(fetchDynamicNews({
mode: apiMode, // 传递 API 模式mainline 映射为 four-row
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
// mainline 模式复用 four-row 的分页大小
const modePageSize = (mode === DISPLAY_MODES.FOUR_ROW || mode === DISPLAY_MODES.MAINLINE)
? PAGINATION_CONFIG.FOUR_ROW_PAGE_SIZE // 30
: PAGINATION_CONFIG.VERTICAL_PAGE_SIZE; // 10
// mainline 模式使用 four-row 的 API 模式(共用数据)
const apiMode = mode === DISPLAY_MODES.MAINLINE ? DISPLAY_MODES.FOUR_ROW : mode;
console.log(`%c 计算的 per_page: ${modePageSize}, apiMode: ${apiMode} (mode: ${mode})`, 'color: #8B5CF6;');
dispatch(fetchDynamicNews({
mode: apiMode, // 使用映射后的 API 模式
per_page: modePageSize, // 使用计算的值,不是 pageSize prop
pageSize: modePageSize,
clearCache: true,
...filters, // 先展开筛选条件
page: PAGINATION_CONFIG.INITIAL_PAGE, // 然后覆盖 page 参数
}));
}
}, [mode, currentMode, allCachedEventsByPage, allCachedEvents, dispatch, filters]); // 添加 filters 依赖
// 自动选中逻辑 - 只在首次加载时自动选中第一个事件,翻页时不自动选中
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 (
<Card
ref={ref}
{...rest}
bg={cardBg}
borderColor={borderColor}
mb={4}
position="relative"
zIndex={1}
animation="fadeInUp 0.8s ease-out 0.2s both"
sx={{
'@keyframes fadeInUp': {
'0%': { opacity: 0, transform: 'translateY(30px)' },
'100%': { opacity: 1, transform: 'translateY(0)' }
}
}}
>
{/* 标题和搜索部分 - 优化版 */}
<CardHeader
ref={cardHeaderRef}
position="relative"
zIndex={1}
pb={3}
px={isMobile ? 2 : undefined}
>
<VStack spacing={3} align="stretch">
{/* 第一行:标题 + 模式切换 + 通知开关 + 更新时间 */}
<Flex justify="space-between" align="center">
{/* 左侧:标题 + 模式切换按钮 */}
<HStack spacing={4}>
<Heading size={isMobile ? "sm" : "md"} color={PROFESSIONAL_COLORS.text.primary}>
<HStack spacing={2}>
<TimeIcon color={PROFESSIONAL_COLORS.gold[500]} />
<Text bgGradient={PROFESSIONAL_COLORS.gradients.gold} bgClip="text">实时要闻·动态追踪</Text>
</HStack>
</Heading>
{/* 模式切换按钮(移动端隐藏) */}
{!isMobile && <ModeToggleButtons mode={mode} onModeChange={handleModeToggle} />}
</HStack>
{/* 右侧:通知开关 + 更新时间 */}
<HStack spacing={3}>
{/* 通知开关 - 移动端隐藏 */}
{!isMobile && (
<HStack
spacing={2}
cursor="pointer"
onClick={handleNotificationToggle}
_hover={{ opacity: 0.8 }}
transition="opacity 0.2s"
>
<Icon
as={BellIcon}
boxSize={3.5}
color={PROFESSIONAL_COLORS.gold[500]}
/>
<Text
fontSize="sm"
color={PROFESSIONAL_COLORS.text.secondary}
>
实时消息推送{browserPermission === 'granted' ? '已开启' : '未开启'}
</Text>
<Switch
size="sm"
isChecked={browserPermission === 'granted'}
pointerEvents="none"
colorScheme="yellow"
/>
</HStack>
)}
{/* 更新时间 */}
<Text fontSize="xs" color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">
最后更新: {lastUpdateTime?.toLocaleTimeString() || '--'}
</Text>
</HStack>
</Flex>
{/* 第二行:筛选组件 */}
<Box>
<CompactSearchBox
onSearch={onSearch}
onSearchFocus={onSearchFocus}
filters={filters}
mode={mode}
pageSize={pageSize}
trackingFunctions={trackingFunctions}
isMobile={isMobile}
/>
</Box>
</VStack>
</CardHeader>
{/* 主体内容 */}
<CardBody
ref={cardBodyRef}
position="relative"
pt={0}
px={0}
mx={0}
display="flex"
flexDirection="column"
overflow="visible"
zIndex={1}
>
{/* 内容区域 - 撑满剩余高度 */}
<Box flex="1" minH={0} position="relative">
{/* Loading 蒙层 - 数据请求时显示 */}
{loading && (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
bg="rgba(26, 31, 46, 0.95)"
display="flex"
alignItems="center"
justifyContent="center"
zIndex={10}
borderRadius="md"
>
<VStack spacing={3}>
<Spinner size="xl" color={PROFESSIONAL_COLORS.gold[500]} thickness="4px" />
<Text color={PROFESSIONAL_COLORS.text.primary} 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}
virtualizedGridRef={virtualizedGridRef} // ⚡ 传递 ref 给 VirtualizedFourRowGrid
/>
</Box>
</CardBody>
{/* 四排模式详情弹窗 - 未打开时不渲染 */}
{isModalOpen && (
<Modal isOpen={isModalOpen} onClose={onModalClose} size="full" scrollBehavior="inside">
<ModalOverlay />
<ModalContent maxW="1600px" mx="auto" my={8}>
<ModalHeader>
{modalEvent?.title || '事件详情'}
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
{modalEvent && <DynamicNewsDetailPanel event={modalEvent} />}
</ModalBody>
</ModalContent>
</Modal>
)}
</Card>
);
});
DynamicNewsCardComponent.displayName = 'DynamicNewsCard';
// ⚡ 使用 React.memo 优化性能(减少不必要的重渲染)
const DynamicNewsCard = React.memo(DynamicNewsCardComponent);
export default DynamicNewsCard;