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:
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
{/* 四排模式详情弹窗 - 未打开时不渲染 */}
|
{/* 四排模式详情弹窗 - 未打开时不渲染 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
{/* 右侧:事件详情 - 独立滚动 */}
|
{/* 右侧:事件详情 - 独立滚动 */}
|
||||||
|
|||||||
@@ -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 = true:API 会更新 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 = false:API 会更新 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 = false:API 会更新 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 {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user