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',
importance,
q,
date_range,
industry_code
date_range, // 兼容旧格式(已废弃)
industry_code,
// 时间筛选参数(从 TradingTimeFilter 传递)
start_date,
end_date,
recent_days
} = {}, { rejectWithValue }) => {
try {
// 【动态计算 per_page】根据 mode 自动选择合适的每页大小
@@ -197,8 +201,12 @@ export const fetchDynamicNews = createAsyncThunk(
if (sort) filters.sort = sort;
if (importance && importance !== 'all') filters.importance = importance;
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 (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', '开始获取动态新闻', {
mode,
@@ -443,6 +451,17 @@ const communityDataSlice = createSlice({
const { eventId, isFollowing, followerCount } = action.payload;
state.eventFollowStatus[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
export const selectPopularKeywords = (state) => state.communityData.popularKeywords;

View File

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

View File

@@ -2,8 +2,8 @@
// 纵向分栏模式布局组件
import React, { useState, useEffect } from 'react';
import { Box, IconButton, Tooltip, VStack, Flex } from '@chakra-ui/react';
import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons';
import { Box, IconButton, Tooltip, VStack, Flex, Center, Text } from '@chakra-ui/react';
import { ViewIcon, ViewOffIcon, InfoIcon } from '@chakra-ui/icons';
import HorizontalDynamicNewsEventCard from '../EventCard/HorizontalDynamicNewsEventCard';
import EventDetailScrollPanel from './EventDetailScrollPanel';
@@ -94,26 +94,41 @@ const VerticalModeLayout = ({
}}
>
{/* 事件列表 */}
<VStack
spacing={2}
align="stretch"
p={2}
>
{events.map((event) => (
<HorizontalDynamicNewsEventCard
key={event.id}
event={event}
isSelected={selectedEvent?.id === event.id}
onEventClick={() => onEventSelect(event)}
isFollowing={eventFollowStatus[event.id]?.isFollowing}
followerCount={eventFollowStatus[event.id]?.followerCount}
onToggleFollow={onToggleFollow}
timelineStyle={getTimelineBoxStyle()}
borderColor={borderColor}
indicatorSize={layoutMode === 'detail' ? 'default' : 'comfortable'}
/>
))}
</VStack>
{events && events.length > 0 ? (
<VStack
spacing={2}
align="stretch"
p={2}
>
{events.map((event) => (
<HorizontalDynamicNewsEventCard
key={event.id}
event={event}
isSelected={selectedEvent?.id === event.id}
onEventClick={() => onEventSelect(event)}
isFollowing={eventFollowStatus[event.id]?.isFollowing}
followerCount={eventFollowStatus[event.id]?.followerCount}
onToggleFollow={onToggleFollow}
timelineStyle={getTimelineBoxStyle()}
borderColor={borderColor}
indicatorSize={layoutMode === 'detail' ? 'default' : 'comfortable'}
/>
))}
</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>
{/* 右侧:事件详情 - 独立滚动 */}

View File

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

View File

@@ -22,7 +22,9 @@ const UnifiedSearchBox = ({
onSearch,
onSearchFocus,
popularKeywords = [],
filters = {}
filters = {},
mode, // 显示模式vertical, horizontal 等)
pageSize // 每页显示数量
}) => {
// 其他状态
@@ -145,7 +147,8 @@ const UnifiedSearchBox = ({
}
// ✅ 初始化行业分类(需要 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);
if (path) {
setIndustryValue(path);
@@ -154,6 +157,10 @@ const UnifiedSearchBox = ({
path
});
}
} else if (!filters.industry_code && industryValue && industryValue.length > 0) {
// 如果 filters 中没有行业代码,但本地有值,清空本地值
setIndustryValue([]);
logger.debug('UnifiedSearchBox', '清空行业分类filters中无值');
}
// ✅ 同步 filters.q 到输入框显示值
@@ -163,7 +170,54 @@ const UnifiedSearchBox = ({
// 如果 filters 中没有搜索关键词,清空输入框
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
const handleSearch = (value) => {
@@ -242,59 +296,45 @@ const UnifiedSearchBox = ({
triggerSearch(params);
};
// ✅ 排序变化(使用防抖
// ✅ 排序变化(立即触发搜索
const handleSortChange = (value) => {
logger.debug('UnifiedSearchBox', '【1/5】排序值改变', {
logger.debug('UnifiedSearchBox', '排序值改变', {
oldValue: sort,
newValue: 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) {
logger.debug('UnifiedSearchBox', '【4/5】调用防抖函数(300ms延迟)');
debouncedSearchRef.current(params);
debouncedSearchRef.current.cancel();
}
// 立即触发搜索
const params = buildFilterParams({ sort: value });
logger.debug('UnifiedSearchBox', '排序改变,立即触发搜索', params);
triggerSearch(params);
};
// ✅ 行业分类变化(使用防抖
// ✅ 行业分类变化(立即触发搜索
const handleIndustryChange = (value) => {
logger.debug('UnifiedSearchBox', '【1/5】行业分类值改变', {
logger.debug('UnifiedSearchBox', '行业分类值改变', {
oldValue: industryValue,
newValue: 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) {
logger.debug('UnifiedSearchBox', '【4/5】调用防抖函数(300ms延迟)');
debouncedSearchRef.current(params);
debouncedSearchRef.current.cancel();
}
// 立即触发搜索
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 });
// 立即触发搜索
const searchParams = buildFilterParams(params);
const searchParams = buildFilterParams({ ...params, mode });
logger.debug('UnifiedSearchBox', '交易时段筛选变化,立即触发搜索', {
timeConfig,
params: searchParams
@@ -392,7 +432,9 @@ const UnifiedSearchBox = ({
sort,
importance,
industryValue,
'filters.q': filters.q
'filters.q': filters.q,
mode,
pageSize
}
});
@@ -421,7 +463,7 @@ const UnifiedSearchBox = ({
// 基础参数overrides 优先级高于本地状态)
sort: actualSort,
importance: importanceValue,
page: 1,
// 搜索参数: 统一使用 q 参数进行搜索(话题/股票/关键词)
q: (overrides.q ?? filters.q) ?? '',
@@ -434,17 +476,30 @@ const UnifiedSearchBox = ({
recent_days: overrides.recent_days ?? (tradingTimeRange?.recent_days || ''),
// 最终 overrides 具有最高优先级
...overrides
...overrides,
page: 1,
per_page: overrides.mode === 'four-row' ? 30: 10
};
// 删除可能来自 overrides 的旧 per_page 值(将由 pageSize 重新设置)
delete result.per_page;
// 添加 return_type 参数(如果需要)
if (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);
return result;
}, [sort, importance, filters.q, industryValue, tradingTimeRange]);
}, [sort, importance, filters.q, industryValue, tradingTimeRange, mode, pageSize]);
// ✅ 重置筛选 - 清空所有筛选器并触发搜索
const handleReset = () => {
@@ -578,12 +633,12 @@ const UnifiedSearchBox = ({
};
return (
<Card>
<div style={{padding: '8px'}}>
{/* 第三行:行业 + 重要性 + 排序 */}
<Space style={{ width: '100%', justifyContent: 'space-between' }} size="middle">
{/* 左侧:筛选器组 */}
<Space size="middle" wrap>
<span style={{ fontSize: 14, color: '#666', fontWeight: 'bold' }}>筛选:</span>
<Space size="small" wrap>
<span style={{ fontSize: 12, color: '#666', fontWeight: 'bold' }}>筛选:</span>
{/* 行业分类 */}
<Cascader
value={industryValue}
@@ -602,19 +657,19 @@ const UnifiedSearchBox = ({
expandTrigger="hover"
displayRender={(labels) => labels.join(' > ')}
disabled={industryLoading}
style={{ width: 200 }}
size="middle"
style={{ width: 160 }}
size="small"
/>
{/* 重要性 */}
<Space size="small">
<span style={{ fontSize: 14, color: '#666' }}>重要性:</span>
<span style={{ fontSize: 12, color: '#666' }}>重要性:</span>
<AntSelect
mode="multiple"
value={importance}
onChange={handleImportanceChange}
style={{ width: 150 }}
size="middle"
style={{ width: 120 }}
size="small"
placeholder="全部"
maxTagCount={3}
>
@@ -626,27 +681,27 @@ const UnifiedSearchBox = ({
</Space>
{/* 搜索图标(可点击) + 搜索框 */}
<Space.Compact style={{ flex: 1, minWidth: 300 }}>
<Space.Compact style={{ flex: 1, minWidth: 250 }}>
<SearchOutlined
onClick={handleMainSearch}
style={{
fontSize: 18,
padding: '8px 12px',
background: '#f5f5f5',
fontSize: 14,
padding: '5px 8px',
background: '#e6f7ff',
borderRadius: '6px 0 0 6px',
display: 'flex',
alignItems: 'center',
color: '#666',
color: '#1890ff',
cursor: 'pointer',
transition: 'all 0.3s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#1890ff';
e.currentTarget.style.background = '#e6f7ff';
e.currentTarget.style.color = '#096dd9';
e.currentTarget.style.background = '#bae7ff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '#666';
e.currentTarget.style.background = '#f5f5f5';
e.currentTarget.style.color = '#1890ff';
e.currentTarget.style.background = '#e6f7ff';
}}
/>
<AutoComplete
@@ -663,7 +718,7 @@ const UnifiedSearchBox = ({
}
}}
style={{ flex: 1 }}
size="middle"
size="small"
notFoundContent={inputValue && stockOptions.length === 0 ? "未找到匹配的股票" : null}
/>
</Space.Compact>
@@ -672,14 +727,14 @@ const UnifiedSearchBox = ({
<Button
icon={<CloseCircleOutlined />}
onClick={handleReset}
size="middle"
size="small"
style={{
borderRadius: 6,
border: '1px solid #d9d9d9',
backgroundColor: '#fff',
color: '#666',
fontWeight: 500,
padding: '4px 12px',
padding: '4px 10px',
display: 'flex',
alignItems: 'center',
gap: 4,
@@ -707,12 +762,12 @@ const UnifiedSearchBox = ({
{/* 右侧:排序 */}
<Space size="small">
<span style={{ fontSize: 14, color: '#666' }}>排序:</span>
<span style={{ fontSize: 12, color: '#666' }}>排序:</span>
<AntSelect
value={sort}
onChange={handleSortChange}
style={{ width: 120 }}
size="middle"
style={{ width: 100 }}
size="small"
>
<Option value="new">最新</Option>
<Option value="hot">最热</Option>
@@ -724,7 +779,7 @@ const UnifiedSearchBox = ({
</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>
{/* 交易时段筛选 */}
@@ -735,29 +790,13 @@ const UnifiedSearchBox = ({
</Space>
{/* 第二行:热门概念 */}
<div style={{ marginTop: 8 }}>
<div style={{ marginTop: 2 }}>
<PopularKeywords
keywords={popularKeywords}
onKeywordClick={handleKeywordClick}
/>
</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>
</div>
);
};

View File

@@ -24,9 +24,12 @@ export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {
return {
sort: searchParams.get('sort') || 'new',
importance: searchParams.get('importance') || 'all',
date_range: searchParams.get('date_range') || '',
q: searchParams.get('q') || '',
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)
};
});