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>
This commit is contained in:
zdl
2025-11-06 17:39:03 +08:00
parent 8799964961
commit 319a78d34c
6 changed files with 429 additions and 172 deletions

View File

@@ -182,8 +182,12 @@ export const fetchDynamicNews = createAsyncThunk(
sort = 'new', sort = 'new',
importance, importance,
q, q,
date_range, date_range, // 兼容旧格式(已废弃)
industry_code industry_code,
// 时间筛选参数(从 TradingTimeFilter 传递)
start_date,
end_date,
recent_days
} = {}, { rejectWithValue }) => { } = {}, { rejectWithValue }) => {
try { try {
// 【动态计算 per_page】根据 mode 自动选择合适的每页大小 // 【动态计算 per_page】根据 mode 自动选择合适的每页大小
@@ -197,8 +201,12 @@ export const fetchDynamicNews = createAsyncThunk(
if (sort) filters.sort = sort; if (sort) filters.sort = sort;
if (importance && importance !== 'all') filters.importance = importance; if (importance && importance !== 'all') filters.importance = importance;
if (q) filters.q = q; if (q) filters.q = q;
if (date_range) filters.date_range = date_range; if (date_range) filters.date_range = date_range; // 兼容旧格式
if (industry_code) filters.industry_code = industry_code; if (industry_code) filters.industry_code = industry_code;
// 时间筛选参数
if (start_date) filters.start_date = start_date;
if (end_date) filters.end_date = end_date;
if (recent_days) filters.recent_days = recent_days;
logger.debug('CommunityData', '开始获取动态新闻', { logger.debug('CommunityData', '开始获取动态新闻', {
mode, mode,
@@ -443,6 +451,17 @@ const communityDataSlice = createSlice({
const { eventId, isFollowing, followerCount } = action.payload; const { eventId, isFollowing, followerCount } = action.payload;
state.eventFollowStatus[eventId] = { isFollowing, followerCount }; state.eventFollowStatus[eventId] = { isFollowing, followerCount };
logger.debug('CommunityData', '设置事件关注状态', { eventId, isFollowing, followerCount }); logger.debug('CommunityData', '设置事件关注状态', { eventId, isFollowing, followerCount });
},
/**
* 更新分页页码(用于缓存场景,无需 API 请求)
* @param {Object} action.payload - { mode, page }
*/
updatePaginationPage: (state, action) => {
const { mode, page } = action.payload;
const paginationKey = mode === 'four-row' ? 'fourRowPagination' : 'verticalPagination';
state[paginationKey].current_page = page;
logger.debug('CommunityData', '同步更新分页页码(缓存场景)', { mode, page });
} }
}, },
@@ -603,7 +622,7 @@ const communityDataSlice = createSlice({
// ==================== 导出 ==================== // ==================== 导出 ====================
export const { clearCache, clearSpecificCache, preloadData, setEventFollowStatus } = communityDataSlice.actions; export const { clearCache, clearSpecificCache, preloadData, setEventFollowStatus, updatePaginationPage } = communityDataSlice.actions;
// 基础选择器Selectors // 基础选择器Selectors
export const selectPopularKeywords = (state) => state.communityData.popularKeywords; export const selectPopularKeywords = (state) => state.communityData.popularKeywords;

View File

@@ -24,7 +24,7 @@ import {
ModalCloseButton, ModalCloseButton,
useColorModeValue, useColorModeValue,
useToast, useToast,
useDisclosure useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { TimeIcon } from '@chakra-ui/icons'; import { TimeIcon } from '@chakra-ui/icons';
import EventScrollList from './DynamicNewsCard/EventScrollList'; import EventScrollList from './DynamicNewsCard/EventScrollList';
@@ -40,7 +40,7 @@ import {
selectFourRowEventsWithLoading selectFourRowEventsWithLoading
} from '../../../store/slices/communityDataSlice'; } from '../../../store/slices/communityDataSlice';
import { usePagination } from './DynamicNewsCard/hooks/usePagination'; import { usePagination } from './DynamicNewsCard/hooks/usePagination';
import { PAGINATION_CONFIG } from './DynamicNewsCard/constants'; import { PAGINATION_CONFIG, DISPLAY_MODES } from './DynamicNewsCard/constants';
// 🔍 调试:渲染计数器 // 🔍 调试:渲染计数器
let dynamicNewsCardRenderCount = 0; let dynamicNewsCardRenderCount = 0;
@@ -71,11 +71,23 @@ const DynamicNewsCard = forwardRef(({
const cardBg = useColorModeValue('white', 'gray.800'); const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700'); 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 读取关注状态 // 从 Redux 读取关注状态
const eventFollowStatus = useSelector(selectEventFollowStatus); const eventFollowStatus = useSelector(selectEventFollowStatus);
// 本地状态:模式(先初始化,后面会被 usePagination 更新) // 本地状态:模式(先初始化,后面会被 usePagination 更新)
const [currentMode, setCurrentMode] = useState('vertical'); const [currentMode, setCurrentMode] = useState('vertical');
// 根据当前模式从 Redux 读取对应的数据(添加默认值避免 undefined // 根据当前模式从 Redux 读取对应的数据(添加默认值避免 undefined
const verticalData = useSelector(selectVerticalEventsWithLoading) || {}; const verticalData = useSelector(selectVerticalEventsWithLoading) || {};
@@ -168,7 +180,8 @@ const DynamicNewsCard = forwardRef(({
cachedCount, cachedCount,
dispatch, dispatch,
toast, toast,
filters // 传递筛选条件 filters, // 传递筛选条件
initialMode: currentMode // 传递当前显示模式
}); });
// 同步 mode 到 currentMode // 同步 mode 到 currentMode
@@ -244,7 +257,9 @@ const DynamicNewsCard = forwardRef(({
filters.sort, filters.sort,
filters.importance, filters.importance,
filters.q, filters.q,
filters.date_range, filters.start_date, // 时间筛选参数:开始时间
filters.end_date, // 时间筛选参数:结束时间
filters.recent_days, // 时间筛选参数近N天
filters.industry_code, filters.industry_code,
mode, // 添加 mode 到依赖 mode, // 添加 mode 到依赖
pageSize, // 添加 pageSize 到依赖 pageSize, // 添加 pageSize 到依赖
@@ -259,16 +274,24 @@ const DynamicNewsCard = forwardRef(({
if (hasInitialized.current && isDataEmpty) { if (hasInitialized.current && isDataEmpty) {
console.log(`%c🔄 [模式切换] ${mode} 模式数据为空,开始加载`, 'color: #8B5CF6; font-weight: bold;'); 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({ dispatch(fetchDynamicNews({
mode: mode, mode: mode,
per_page: pageSize, per_page: modePageSize, // 使用计算的值,不是 pageSize prop
pageSize: pageSize, pageSize: modePageSize,
clearCache: true, clearCache: true,
...filters, // 先展开筛选条件 ...filters, // 先展开筛选条件
page: PAGINATION_CONFIG.INITIAL_PAGE, // 然后覆盖 page 参数 page: PAGINATION_CONFIG.INITIAL_PAGE, // 然后覆盖 page 参数
})); }));
} }
}, [mode]); // 只监听 mode 变化 }, [mode, currentMode, allCachedEventsByPage, allCachedEvents, dispatch]); // 移除 filters 依赖,避免与筛选 useEffect 循环触发 // 添加所有依赖
// 自动选中逻辑 - 只在首次加载时自动选中第一个事件,翻页时不自动选中 // 自动选中逻辑 - 只在首次加载时自动选中第一个事件,翻页时不自动选中
useEffect(() => { useEffect(() => {
@@ -295,10 +318,149 @@ const DynamicNewsCard = forwardRef(({
}; };
}, []); }, []);
// 页码切换时滚动到顶部
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 ( return (
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}> <Card
ref={ref}
{...rest}
bg={cardBg}
borderColor={borderColor}
mb={4}
>
{/* 标题部分 */} {/* 标题部分 */}
<CardHeader> <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"> <Flex justify="space-between" align="center">
<VStack align="start" spacing={1}> <VStack align="start" spacing={1}>
<Heading size="md"> <Heading size="md">
@@ -325,15 +487,34 @@ const DynamicNewsCard = forwardRef(({
onSearchFocus={onSearchFocus} onSearchFocus={onSearchFocus}
popularKeywords={popularKeywords} popularKeywords={popularKeywords}
filters={filters} filters={filters}
mode={mode}
pageSize={pageSize}
/> />
</Box> </Box>
</CardHeader> </CardHeader>
{/* 主体内容 */} {/* 主体内容 */}
<CardBody position="relative" pt={0}> <CardBody
{/* 顶部控制栏:模式切换按钮 + 分页控制器(始终显示) */} ref={cardBodyRef}
<Flex justify="space-between" align="center" mb={2}> 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} /> <ModeToggleButtons mode={mode} onModeChange={handleModeToggle} />
{/* 右侧:分页控制器(仅在纵向模式显示) */} {/* 右侧:分页控制器(仅在纵向模式显示) */}
@@ -341,13 +522,13 @@ const DynamicNewsCard = forwardRef(({
<PaginationControl <PaginationControl
currentPage={currentPage} currentPage={currentPage}
totalPages={totalPages} totalPages={totalPages}
onPageChange={handlePageChange} onPageChange={handlePageChangeWithScroll}
/> />
)} )}
</Flex> </Flex>
{/* 横向滚动事件列表 - 始终渲染 + Loading 蒙层 */} {/* 内容区域 - 撑满剩余高度 */}
<Box position="relative"> <Box flex="1" minH={0} position="relative">
{/* Loading 蒙层 - 数据请求时显示 */} {/* Loading 蒙层 - 数据请求时显示 */}
{loading && ( {loading && (
<Box <Box
@@ -384,7 +565,7 @@ const DynamicNewsCard = forwardRef(({
borderColor={borderColor} borderColor={borderColor}
currentPage={currentPage} currentPage={currentPage}
totalPages={totalPages} totalPages={totalPages}
onPageChange={handlePageChange} onPageChange={handlePageChangeWithScroll}
loading={loadingPage !== null} loading={loadingPage !== null}
error={error} error={error}
mode={mode} mode={mode}
@@ -393,14 +574,6 @@ const DynamicNewsCard = forwardRef(({
hasMore={hasMore} hasMore={hasMore}
/> />
</Box> </Box>
{/* 底部:分页控制器(仅在纵向模式显示) */}
{mode === 'vertical' && totalPages > 1 && (
<PaginationControl
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
)}
</CardBody> </CardBody>
{/* 四排模式详情弹窗 - 未打开时不渲染 */} {/* 四排模式详情弹窗 - 未打开时不渲染 */}

View File

@@ -2,8 +2,8 @@
// 纵向分栏模式布局组件 // 纵向分栏模式布局组件
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Box, IconButton, Tooltip, VStack, Flex } from '@chakra-ui/react'; import { Box, IconButton, Tooltip, VStack, Flex, Center, Text } from '@chakra-ui/react';
import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons'; import { ViewIcon, ViewOffIcon, InfoIcon } from '@chakra-ui/icons';
import HorizontalDynamicNewsEventCard from '../EventCard/HorizontalDynamicNewsEventCard'; import HorizontalDynamicNewsEventCard from '../EventCard/HorizontalDynamicNewsEventCard';
import EventDetailScrollPanel from './EventDetailScrollPanel'; import EventDetailScrollPanel from './EventDetailScrollPanel';
@@ -94,26 +94,41 @@ const VerticalModeLayout = ({
}} }}
> >
{/* 事件列表 */} {/* 事件列表 */}
<VStack {events && events.length > 0 ? (
spacing={2} <VStack
align="stretch" spacing={2}
p={2} align="stretch"
> p={2}
{events.map((event) => ( >
<HorizontalDynamicNewsEventCard {events.map((event) => (
key={event.id} <HorizontalDynamicNewsEventCard
event={event} key={event.id}
isSelected={selectedEvent?.id === event.id} event={event}
onEventClick={() => onEventSelect(event)} isSelected={selectedEvent?.id === event.id}
isFollowing={eventFollowStatus[event.id]?.isFollowing} onEventClick={() => onEventSelect(event)}
followerCount={eventFollowStatus[event.id]?.followerCount} isFollowing={eventFollowStatus[event.id]?.isFollowing}
onToggleFollow={onToggleFollow} followerCount={eventFollowStatus[event.id]?.followerCount}
timelineStyle={getTimelineBoxStyle()} onToggleFollow={onToggleFollow}
borderColor={borderColor} timelineStyle={getTimelineBoxStyle()}
indicatorSize={layoutMode === 'detail' ? 'default' : 'comfortable'} borderColor={borderColor}
/> indicatorSize={layoutMode === 'detail' ? 'default' : 'comfortable'}
))} />
</VStack> ))}
</VStack>
) : (
/* 空状态 */
<Center h="100%" minH="400px">
<VStack spacing={4}>
<InfoIcon w={12} h={12} color="gray.400" />
<Text fontSize="lg" color="gray.500" textAlign="center">
当前筛选条件下暂无数据
</Text>
<Text fontSize="sm" color="gray.400" textAlign="center">
请尝试调整筛选条件
</Text>
</VStack>
</Center>
)}
</Box> </Box>
{/* 右侧:事件详情 - 独立滚动 */} {/* 右侧:事件详情 - 独立滚动 */}

View File

@@ -1,8 +1,8 @@
// src/views/Community/components/DynamicNewsCard/hooks/usePagination.js // src/views/Community/components/DynamicNewsCard/hooks/usePagination.js
// 分页逻辑自定义 Hook // 分页逻辑自定义 Hook
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { useState, useMemo, useCallback, useRef } from 'react';
import { fetchDynamicNews } from '../../../../../store/slices/communityDataSlice'; import { fetchDynamicNews, updatePaginationPage } from '../../../../../store/slices/communityDataSlice';
import { logger } from '../../../../../utils/logger'; import { logger } from '../../../../../utils/logger';
import { import {
PAGINATION_CONFIG, PAGINATION_CONFIG,
@@ -16,12 +16,13 @@ import {
* @param {Object} options - Hook 配置选项 * @param {Object} options - Hook 配置选项
* @param {Object} options.allCachedEventsByPage - 纵向模式页码映射 { 1: [...], 2: [...] } * @param {Object} options.allCachedEventsByPage - 纵向模式页码映射 { 1: [...], 2: [...] }
* @param {Array} options.allCachedEvents - 平铺模式数组 [...] * @param {Array} options.allCachedEvents - 平铺模式数组 [...]
* @param {Object} options.pagination - 分页元数据 { total, total_pages, current_page, per_page } * @param {Object} options.pagination - 分页元数据 { total, total_pages, current_page, per_page, page }
* @param {number} options.total - 【废弃】服务端总数量(向后兼容,建议使用 pagination.total * @param {number} options.total - 【废弃】服务端总数量(向后兼容,建议使用 pagination.total
* @param {number} options.cachedCount - 已缓存数量 * @param {number} options.cachedCount - 已缓存数量
* @param {Function} options.dispatch - Redux dispatch 函数 * @param {Function} options.dispatch - Redux dispatch 函数
* @param {Function} options.toast - Toast 通知函数 * @param {Function} options.toast - Toast 通知函数
* @param {Object} options.filters - 筛选条件 * @param {Object} options.filters - 筛选条件
* @param {string} options.initialMode - 初始显示模式(可选)
* @returns {Object} 分页状态和方法 * @returns {Object} 分页状态和方法
*/ */
export const usePagination = ({ export const usePagination = ({
@@ -32,12 +33,20 @@ export const usePagination = ({
cachedCount, cachedCount,
dispatch, dispatch,
toast, toast,
filters = {} filters = {},
initialMode // 初始显示模式
}) => { }) => {
// 本地状态 // 本地状态
const [currentPage, setCurrentPage] = useState(PAGINATION_CONFIG.INITIAL_PAGE);
const [loadingPage, setLoadingPage] = useState(null); const [loadingPage, setLoadingPage] = useState(null);
const [mode, setMode] = useState(DEFAULT_MODE); const [mode, setMode] = useState(initialMode || DEFAULT_MODE);
// 【核心改动】从 Redux pagination 派生 currentPage不再使用本地状态
const currentPage = pagination?.current_page || PAGINATION_CONFIG.INITIAL_PAGE;
// 使用 ref 存储最新的 filters避免 useCallback 闭包问题
// 当 filters 对象引用不变但内容改变时,闭包中的 filters 是旧值
const filtersRef = useRef(filters);
filtersRef.current = filters;
// 根据模式决定每页显示数量 // 根据模式决定每页显示数量
const pageSize = (() => { const pageSize = (() => {
@@ -93,14 +102,14 @@ export const usePagination = ({
try { try {
console.log(`%c🟢 [API请求] 开始加载第${targetPage}页数据`, 'color: #16A34A; font-weight: bold;'); console.log(`%c🟢 [API请求] 开始加载第${targetPage}页数据`, 'color: #16A34A; font-weight: bold;');
console.log(`%c 请求参数: page=${targetPage}, per_page=${pageSize}, mode=${mode}, clearCache=${clearCache}`, 'color: #16A34A;'); console.log(`%c 请求参数: page=${targetPage}, per_page=${pageSize}, mode=${mode}, clearCache=${clearCache}`, 'color: #16A34A;');
console.log(`%c 筛选条件:`, 'color: #16A34A;', filters); console.log(`%c 筛选条件:`, 'color: #16A34A;', filtersRef.current);
logger.debug('DynamicNewsCard', '开始加载页面数据', { logger.debug('DynamicNewsCard', '开始加载页面数据', {
targetPage, targetPage,
pageSize, pageSize,
mode, mode,
clearCache, clearCache,
filters filters: filtersRef.current
}); });
// 🔍 调试dispatch 前 // 🔍 调试dispatch 前
@@ -110,7 +119,7 @@ export const usePagination = ({
per_page: pageSize, per_page: pageSize,
pageSize, pageSize,
clearCache, clearCache,
filters filters: filtersRef.current
}); });
const result = await dispatch(fetchDynamicNews({ const result = await dispatch(fetchDynamicNews({
@@ -118,7 +127,7 @@ export const usePagination = ({
per_page: pageSize, per_page: pageSize,
pageSize: pageSize, pageSize: pageSize,
clearCache: clearCache, // 传递 clearCache 参数 clearCache: clearCache, // 传递 clearCache 参数
...filters, // 先展开筛选条件 ...filtersRef.current, // 从 ref 读取最新筛选条件
page: targetPage, // 然后覆盖 page 参数(避免被 filters.page 覆盖) page: targetPage, // 然后覆盖 page 参数(避免被 filters.page 覆盖)
})).unwrap(); })).unwrap();
@@ -146,7 +155,7 @@ export const usePagination = ({
} finally { } finally {
setLoadingPage(null); setLoadingPage(null);
} }
}, [dispatch, pageSize, toast, mode, filters]); }, [dispatch, pageSize, toast, mode]); // 移除 filters 依赖,使用 filtersRef 读取最新值
// 翻页处理第1页强制刷新 + 其他页缓存) // 翻页处理第1页强制刷新 + 其他页缓存)
const handlePageChange = useCallback(async (newPage) => { const handlePageChange = useCallback(async (newPage) => {
@@ -164,13 +173,20 @@ export const usePagination = ({
return; return;
} }
// 边界检查 3: 防止竞态条件 - 如果正在加载其他页面,忽略新请求 // 边界检查 3: 防止竞态条件 - 只拦截相同页面的重复请求
if (loadingPage !== null) { if (loadingPage === newPage) {
console.log(`%c⚠ [翻页] 正在加载${loadingPage},忽略新请求第${newPage}`, 'color: #EAB308; font-weight: bold;'); console.log(`%c⚠ [翻页] 第${newPage}正在加载中,忽略重复请求`, 'color: #EAB308; font-weight: bold;');
logger.warn('usePagination', '竞态条件:正在加载', { loadingPage, newPage }); logger.warn('usePagination', '竞态条件:相同页面正在加载', { loadingPage, newPage });
return; return;
} }
// 如果正在加载其他页面,允许切换(会取消当前加载状态,开始新的加载)
if (loadingPage !== null && loadingPage !== newPage) {
console.log(`%c🔄 [翻页] 正在加载第${loadingPage}页,用户切换到第${newPage}`, 'color: #8B5CF6; font-weight: bold;');
logger.info('usePagination', '用户切换页面,继续处理新请求', { loadingPage, newPage });
// 继续执行loadPage 会覆盖 loadingPage 状态
}
console.log(`%c🔵 [翻页逻辑] handlePageChange 开始`, 'color: #3B82F6; font-weight: bold;'); console.log(`%c🔵 [翻页逻辑] handlePageChange 开始`, 'color: #3B82F6; font-weight: bold;');
console.log(`%c 当前页: ${currentPage}, 目标页: ${newPage}, 模式: ${mode}`, 'color: #3B82F6;'); console.log(`%c 当前页: ${currentPage}, 目标页: ${newPage}, 模式: ${mode}`, 'color: #3B82F6;');
@@ -179,11 +195,8 @@ export const usePagination = ({
console.log(`%c🔄 [第1页] 清空缓存并重新加载`, 'color: #8B5CF6; font-weight: bold;'); console.log(`%c🔄 [第1页] 清空缓存并重新加载`, 'color: #8B5CF6; font-weight: bold;');
logger.info('usePagination', '第1页强制刷新', { mode }); logger.info('usePagination', '第1页强制刷新', { mode });
const success = await loadPage(newPage, true); // clearCache = true // clearCache = trueAPI 会更新 Redux pagination.current_page
await loadPage(newPage, true);
if (success) {
setCurrentPage(newPage);
}
return; return;
} }
@@ -197,23 +210,18 @@ export const usePagination = ({
if (isPageCached) { if (isPageCached) {
console.log(`%c✅ [缓存] 第${newPage}页已缓存,直接切换`, 'color: #16A34A; font-weight: bold;'); console.log(`%c✅ [缓存] 第${newPage}页已缓存,直接切换`, 'color: #16A34A; font-weight: bold;');
setCurrentPage(newPage); // 使用缓存数据,同步更新 Redux pagination.current_page
dispatch(updatePaginationPage({ mode, page: newPage }));
} else { } else {
console.log(`%c❌ [缓存] 第${newPage}页未缓存,加载数据`, 'color: #DC2626; font-weight: bold;'); console.log(`%c❌ [缓存] 第${newPage}页未缓存,加载数据`, 'color: #DC2626; font-weight: bold;');
const success = await loadPage(newPage, false); // clearCache = false // clearCache = falseAPI 会更新 Redux pagination.current_page
await loadPage(newPage, false);
if (success) {
setCurrentPage(newPage);
}
} }
} else { } else {
// 平铺模式直接加载新页追加模式clearCache=false // 平铺模式直接加载新页追加模式clearCache=false
console.log(`%c🟡 [平铺模式] 加载第${newPage}`, 'color: #EAB308; font-weight: bold;'); console.log(`%c🟡 [平铺模式] 加载第${newPage}`, 'color: #EAB308; font-weight: bold;');
const success = await loadPage(newPage, false); // clearCache = false // clearCache = falseAPI 会更新 Redux pagination.current_page
await loadPage(newPage, false);
if (success) {
setCurrentPage(newPage);
}
} }
}, [mode, currentPage, totalPages, loadingPage, allCachedEventsByPage, loadPage]); }, [mode, currentPage, totalPages, loadingPage, allCachedEventsByPage, loadPage]);
@@ -272,8 +280,8 @@ export const usePagination = ({
if (newMode === mode) return; if (newMode === mode) return;
setMode(newMode); setMode(newMode);
setCurrentPage(PAGINATION_CONFIG.INITIAL_PAGE); // currentPage 由 Redux pagination.current_page 派生,会在下次请求时自动更新
// pageSize 会根据 mode 自动重新计算(第35-44行) // pageSize 会根据 mode 自动重新计算(第46-56行)
}, [mode]); }, [mode]);
return { return {

View File

@@ -22,7 +22,9 @@ const UnifiedSearchBox = ({
onSearch, onSearch,
onSearchFocus, onSearchFocus,
popularKeywords = [], popularKeywords = [],
filters = {} filters = {},
mode, // 显示模式vertical, horizontal 等)
pageSize // 每页显示数量
}) => { }) => {
// 其他状态 // 其他状态
@@ -145,7 +147,8 @@ const UnifiedSearchBox = ({
} }
// ✅ 初始化行业分类(需要 industryData 加载完成) // ✅ 初始化行业分类(需要 industryData 加载完成)
if (filters.industry_code && industryData && industryData.length > 0) { // ⚠️ 只在 industryValue 为空时才从 filters 初始化,避免用户选择后被覆盖
if (filters.industry_code && industryData && industryData.length > 0 && (!industryValue || industryValue.length === 0)) {
const path = findIndustryPath(filters.industry_code, industryData); const path = findIndustryPath(filters.industry_code, industryData);
if (path) { if (path) {
setIndustryValue(path); setIndustryValue(path);
@@ -154,6 +157,10 @@ const UnifiedSearchBox = ({
path path
}); });
} }
} else if (!filters.industry_code && industryValue && industryValue.length > 0) {
// 如果 filters 中没有行业代码,但本地有值,清空本地值
setIndustryValue([]);
logger.debug('UnifiedSearchBox', '清空行业分类filters中无值');
} }
// ✅ 同步 filters.q 到输入框显示值 // ✅ 同步 filters.q 到输入框显示值
@@ -163,7 +170,54 @@ const UnifiedSearchBox = ({
// 如果 filters 中没有搜索关键词,清空输入框 // 如果 filters 中没有搜索关键词,清空输入框
setInputValue(''); setInputValue('');
} }
}, [filters.sort, filters.importance, filters.industry_code, filters.q, industryData, findIndustryPath]);
// ✅ 初始化时间筛选(从 filters 中恢复)
// ⚠️ 只在 tradingTimeRange 为空时才从 filters 初始化,避免用户选择后被覆盖
const hasTimeInFilters = filters.start_date || filters.end_date || filters.recent_days;
if (hasTimeInFilters && (!tradingTimeRange || !tradingTimeRange.key)) {
// 根据参数推断按钮 key
let inferredKey = 'custom';
let inferredLabel = '';
if (filters.recent_days) {
// 推断是否是预设按钮
if (filters.recent_days === '7') {
inferredKey = 'week';
inferredLabel = '近一周';
} else if (filters.recent_days === '30') {
inferredKey = 'month';
inferredLabel = '近一月';
} else {
inferredLabel = `${filters.recent_days}`;
}
} else if (filters.start_date && filters.end_date) {
inferredLabel = `${dayjs(filters.start_date).format('MM-DD HH:mm')} - ${dayjs(filters.end_date).format('MM-DD HH:mm')}`;
}
// 从 filters 重建 tradingTimeRange 状态
const timeRange = {
start_date: filters.start_date || '',
end_date: filters.end_date || '',
recent_days: filters.recent_days || '',
label: inferredLabel,
key: inferredKey
};
setTradingTimeRange(timeRange);
logger.debug('UnifiedSearchBox', '初始化时间筛选', {
filters_time: {
start_date: filters.start_date,
end_date: filters.end_date,
recent_days: filters.recent_days
},
tradingTimeRange: timeRange
});
} else if (!hasTimeInFilters && tradingTimeRange) {
// 如果 filters 中没有时间参数,但本地有值,清空本地值
setTradingTimeRange(null);
logger.debug('UnifiedSearchBox', '清空时间筛选filters中无值');
}
}, [filters.sort, filters.importance, filters.industry_code, filters.q, filters.start_date, filters.end_date, filters.recent_days, industryData, findIndustryPath, industryValue, tradingTimeRange]);
// AutoComplete 搜索股票(模糊匹配 code 或 name // AutoComplete 搜索股票(模糊匹配 code 或 name
const handleSearch = (value) => { const handleSearch = (value) => {
@@ -242,59 +296,45 @@ const UnifiedSearchBox = ({
triggerSearch(params); triggerSearch(params);
}; };
// ✅ 排序变化(使用防抖 // ✅ 排序变化(立即触发搜索
const handleSortChange = (value) => { const handleSortChange = (value) => {
logger.debug('UnifiedSearchBox', '【1/5】排序值改变', { logger.debug('UnifiedSearchBox', '排序值改变', {
oldValue: sort, oldValue: sort,
newValue: value newValue: value
}); });
setSort(value); setSort(value);
// ⚠️ 注意:setState是异步的,此时sort仍是旧值 // 取消之前的防抖搜索
logger.debug('UnifiedSearchBox', '【2/5】调用buildFilterParams前的状态', {
sort: sort, // 旧值
importance: importance,
dateRange: dateRange,
industryValue: industryValue
});
// 使用防抖搜索
const params = buildFilterParams({ sort: value });
logger.debug('UnifiedSearchBox', '【3/5】buildFilterParams返回的参数', params);
if (debouncedSearchRef.current) { if (debouncedSearchRef.current) {
logger.debug('UnifiedSearchBox', '【4/5】调用防抖函数(300ms延迟)'); debouncedSearchRef.current.cancel();
debouncedSearchRef.current(params);
} }
// 立即触发搜索
const params = buildFilterParams({ sort: value });
logger.debug('UnifiedSearchBox', '排序改变,立即触发搜索', params);
triggerSearch(params);
}; };
// ✅ 行业分类变化(使用防抖 // ✅ 行业分类变化(立即触发搜索
const handleIndustryChange = (value) => { const handleIndustryChange = (value) => {
logger.debug('UnifiedSearchBox', '【1/5】行业分类值改变', { logger.debug('UnifiedSearchBox', '行业分类值改变', {
oldValue: industryValue, oldValue: industryValue,
newValue: value newValue: value
}); });
setIndustryValue(value); setIndustryValue(value);
// ⚠️ 注意:setState是异步的,此时industryValue仍是旧值 // 取消之前的防抖搜索
logger.debug('UnifiedSearchBox', '【2/5】调用buildFilterParams前的状态', {
industryValue: industryValue, // 旧值
sort: sort,
importance: importance,
dateRange: dateRange
});
// 使用防抖搜索 (需要从新值推导参数)
const params = {
...buildFilterParams(),
industry_code: value?.[value.length - 1] || ''
};
logger.debug('UnifiedSearchBox', '【3/5】buildFilterParams返回的参数', params);
if (debouncedSearchRef.current) { if (debouncedSearchRef.current) {
logger.debug('UnifiedSearchBox', '【4/5】调用防抖函数(300ms延迟)'); debouncedSearchRef.current.cancel();
debouncedSearchRef.current(params);
} }
// 立即触发搜索
const params = buildFilterParams({
industry_code: value?.[value.length - 1] || ''
});
logger.debug('UnifiedSearchBox', '行业改变,立即触发搜索', params);
triggerSearch(params);
}; };
// ✅ 热门概念点击处理(立即搜索,不使用防抖) - 更新输入框并触发搜索 // ✅ 热门概念点击处理(立即搜索,不使用防抖) - 更新输入框并触发搜索
@@ -350,7 +390,7 @@ const UnifiedSearchBox = ({
setTradingTimeRange({ ...params, label, key }); setTradingTimeRange({ ...params, label, key });
// 立即触发搜索 // 立即触发搜索
const searchParams = buildFilterParams(params); const searchParams = buildFilterParams({ ...params, mode });
logger.debug('UnifiedSearchBox', '交易时段筛选变化,立即触发搜索', { logger.debug('UnifiedSearchBox', '交易时段筛选变化,立即触发搜索', {
timeConfig, timeConfig,
params: searchParams params: searchParams
@@ -392,7 +432,9 @@ const UnifiedSearchBox = ({
sort, sort,
importance, importance,
industryValue, industryValue,
'filters.q': filters.q 'filters.q': filters.q,
mode,
pageSize
} }
}); });
@@ -421,7 +463,7 @@ const UnifiedSearchBox = ({
// 基础参数overrides 优先级高于本地状态) // 基础参数overrides 优先级高于本地状态)
sort: actualSort, sort: actualSort,
importance: importanceValue, importance: importanceValue,
page: 1,
// 搜索参数: 统一使用 q 参数进行搜索(话题/股票/关键词) // 搜索参数: 统一使用 q 参数进行搜索(话题/股票/关键词)
q: (overrides.q ?? filters.q) ?? '', q: (overrides.q ?? filters.q) ?? '',
@@ -434,17 +476,30 @@ const UnifiedSearchBox = ({
recent_days: overrides.recent_days ?? (tradingTimeRange?.recent_days || ''), recent_days: overrides.recent_days ?? (tradingTimeRange?.recent_days || ''),
// 最终 overrides 具有最高优先级 // 最终 overrides 具有最高优先级
...overrides ...overrides,
page: 1,
per_page: overrides.mode === 'four-row' ? 30: 10
}; };
// 删除可能来自 overrides 的旧 per_page 值(将由 pageSize 重新设置)
delete result.per_page;
// 添加 return_type 参数(如果需要) // 添加 return_type 参数(如果需要)
if (returnType) { if (returnType) {
result.return_type = returnType; result.return_type = returnType;
} }
// 添加 mode 和 per_page 参数(如果提供了的话)
if (mode !== undefined && mode !== null) {
result.mode = mode;
}
if (pageSize !== undefined && pageSize !== null) {
result.per_page = pageSize; // 后端实际使用的参数
}
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输出结果', result); logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输出结果', result);
return result; return result;
}, [sort, importance, filters.q, industryValue, tradingTimeRange]); }, [sort, importance, filters.q, industryValue, tradingTimeRange, mode, pageSize]);
// ✅ 重置筛选 - 清空所有筛选器并触发搜索 // ✅ 重置筛选 - 清空所有筛选器并触发搜索
const handleReset = () => { const handleReset = () => {
@@ -578,12 +633,12 @@ const UnifiedSearchBox = ({
}; };
return ( return (
<Card> <div style={{padding: '8px'}}>
{/* 第三行:行业 + 重要性 + 排序 */} {/* 第三行:行业 + 重要性 + 排序 */}
<Space style={{ width: '100%', justifyContent: 'space-between' }} size="middle"> <Space style={{ width: '100%', justifyContent: 'space-between' }} size="middle">
{/* 左侧:筛选器组 */} {/* 左侧:筛选器组 */}
<Space size="middle" wrap> <Space size="small" wrap>
<span style={{ fontSize: 14, color: '#666', fontWeight: 'bold' }}>筛选:</span> <span style={{ fontSize: 12, color: '#666', fontWeight: 'bold' }}>筛选:</span>
{/* 行业分类 */} {/* 行业分类 */}
<Cascader <Cascader
value={industryValue} value={industryValue}
@@ -602,19 +657,19 @@ const UnifiedSearchBox = ({
expandTrigger="hover" expandTrigger="hover"
displayRender={(labels) => labels.join(' > ')} displayRender={(labels) => labels.join(' > ')}
disabled={industryLoading} disabled={industryLoading}
style={{ width: 200 }} style={{ width: 160 }}
size="middle" size="small"
/> />
{/* 重要性 */} {/* 重要性 */}
<Space size="small"> <Space size="small">
<span style={{ fontSize: 14, color: '#666' }}>重要性:</span> <span style={{ fontSize: 12, color: '#666' }}>重要性:</span>
<AntSelect <AntSelect
mode="multiple" mode="multiple"
value={importance} value={importance}
onChange={handleImportanceChange} onChange={handleImportanceChange}
style={{ width: 150 }} style={{ width: 120 }}
size="middle" size="small"
placeholder="全部" placeholder="全部"
maxTagCount={3} maxTagCount={3}
> >
@@ -626,27 +681,27 @@ const UnifiedSearchBox = ({
</Space> </Space>
{/* 搜索图标(可点击) + 搜索框 */} {/* 搜索图标(可点击) + 搜索框 */}
<Space.Compact style={{ flex: 1, minWidth: 300 }}> <Space.Compact style={{ flex: 1, minWidth: 250 }}>
<SearchOutlined <SearchOutlined
onClick={handleMainSearch} onClick={handleMainSearch}
style={{ style={{
fontSize: 18, fontSize: 14,
padding: '8px 12px', padding: '5px 8px',
background: '#f5f5f5', background: '#e6f7ff',
borderRadius: '6px 0 0 6px', borderRadius: '6px 0 0 6px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
color: '#666', color: '#1890ff',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.3s' transition: 'all 0.3s'
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.color = '#1890ff'; e.currentTarget.style.color = '#096dd9';
e.currentTarget.style.background = '#e6f7ff'; e.currentTarget.style.background = '#bae7ff';
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.color = '#666'; e.currentTarget.style.color = '#1890ff';
e.currentTarget.style.background = '#f5f5f5'; e.currentTarget.style.background = '#e6f7ff';
}} }}
/> />
<AutoComplete <AutoComplete
@@ -663,7 +718,7 @@ const UnifiedSearchBox = ({
} }
}} }}
style={{ flex: 1 }} style={{ flex: 1 }}
size="middle" size="small"
notFoundContent={inputValue && stockOptions.length === 0 ? "未找到匹配的股票" : null} notFoundContent={inputValue && stockOptions.length === 0 ? "未找到匹配的股票" : null}
/> />
</Space.Compact> </Space.Compact>
@@ -672,14 +727,14 @@ const UnifiedSearchBox = ({
<Button <Button
icon={<CloseCircleOutlined />} icon={<CloseCircleOutlined />}
onClick={handleReset} onClick={handleReset}
size="middle" size="small"
style={{ style={{
borderRadius: 6, borderRadius: 6,
border: '1px solid #d9d9d9', border: '1px solid #d9d9d9',
backgroundColor: '#fff', backgroundColor: '#fff',
color: '#666', color: '#666',
fontWeight: 500, fontWeight: 500,
padding: '4px 12px', padding: '4px 10px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 4, gap: 4,
@@ -707,12 +762,12 @@ const UnifiedSearchBox = ({
{/* 右侧:排序 */} {/* 右侧:排序 */}
<Space size="small"> <Space size="small">
<span style={{ fontSize: 14, color: '#666' }}>排序:</span> <span style={{ fontSize: 12, color: '#666' }}>排序:</span>
<AntSelect <AntSelect
value={sort} value={sort}
onChange={handleSortChange} onChange={handleSortChange}
style={{ width: 120 }} style={{ width: 100 }}
size="middle" size="small"
> >
<Option value="new">最新</Option> <Option value="new">最新</Option>
<Option value="hot">最热</Option> <Option value="hot">最热</Option>
@@ -724,7 +779,7 @@ const UnifiedSearchBox = ({
</Space> </Space>
{/* 第一行:筛选 + 时间按钮 + 搜索图标 + 搜索框 */} {/* 第一行:筛选 + 时间按钮 + 搜索图标 + 搜索框 */}
<Space wrap style={{ width: '100%', marginBottom: 12, marginTop: 8 }} size="middle"> <Space wrap style={{ width: '100%', marginBottom: 4, marginTop: 6 }} size="middle">
<span style={{ fontSize: 14, color: '#666', fontWeight: 'bold' }}>时间筛选:</span> <span style={{ fontSize: 14, color: '#666', fontWeight: 'bold' }}>时间筛选:</span>
{/* 交易时段筛选 */} {/* 交易时段筛选 */}
@@ -735,29 +790,13 @@ const UnifiedSearchBox = ({
</Space> </Space>
{/* 第二行:热门概念 */} {/* 第二行:热门概念 */}
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 2 }}>
<PopularKeywords <PopularKeywords
keywords={popularKeywords} keywords={popularKeywords}
onKeywordClick={handleKeywordClick} onKeywordClick={handleKeywordClick}
/> />
</div> </div>
</div>
{/* 已选条件标签 */}
{filterTags.length > 0 && (
<Space size={[8, 8]} wrap style={{ marginTop: 12 }}>
{filterTags.map(tag => (
<Tag
key={tag.key}
closable
onClose={() => handleRemoveTag(tag.key)}
color="blue"
>
{tag.label}
</Tag>
))}
</Space>
)}
</Card>
); );
}; };

View File

@@ -24,9 +24,12 @@ export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {
return { return {
sort: searchParams.get('sort') || 'new', sort: searchParams.get('sort') || 'new',
importance: searchParams.get('importance') || 'all', importance: searchParams.get('importance') || 'all',
date_range: searchParams.get('date_range') || '',
q: searchParams.get('q') || '', q: searchParams.get('q') || '',
industry_code: searchParams.get('industry_code') || '', industry_code: searchParams.get('industry_code') || '',
// 时间筛选参数(从 TradingTimeFilter 传递)
start_date: searchParams.get('start_date') || '',
end_date: searchParams.get('end_date') || '',
recent_days: searchParams.get('recent_days') || '',
page: parseInt(searchParams.get('page') || '1', 10) page: parseInt(searchParams.get('page') || '1', 10)
}; };
}); });