From cc2777ae2008355243af21500a95eeaa21a2f68b Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Mon, 3 Nov 2025 12:38:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E8=A6=81=E9=97=BB=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 功能新增: - 实时要闻组件支持服务端分页,每次切换页码重新请求数据 - 分页控制器组件,支持数字页码、上下翻页、快速跳转 - Mock 数据量从 100 条增加到 200 条,支持分页测试 技术实现: 1. Redux 状态管理(communityDataSlice.js) - fetchDynamicNews 接收分页参数 { page, per_page } - 返回数据结构调整为 { events, pagination } - initialState 新增 dynamicNewsPagination 字段 - Reducer 分别存储 events 和 pagination 信息 - Selector 返回完整的 pagination 数据 2. 组件层(index.js → DynamicNewsCard → EventScrollList) - Community/index.js: 获取并传递 pagination 信息 - DynamicNewsCard.js: 管理分页状态,触发服务端请求 - EventScrollList.js: 接收服务端 totalPages,渲染当前页数据 - 页码切换时自动选中第一个事件 3. 分页控制器(PaginationControl.js) - 精简版设计:移除首页/末页按钮 - 上一页/下一页按钮,边界状态自动禁用 - 智能页码列表(最多5个,使用省略号) - 输入框跳转功能,支持回车键 - Toast 提示非法输入 - 全部使用 xs 尺寸,紧凑布局 4. Mock 数据(events.js) - 总事件数从 100 增加到 200 条 - 支持服务端分页测试(40 页 × 5 条/页) 分页流程: 1. 初始加载:请求 page=1, per_page=5 2. 切换页码:dispatch(fetchDynamicNews({ page: 2, per_page: 5 })) 3. 后端返回:{ events: [5条], pagination: { page, total, total_pages } } 4. 前端更新:显示新页面数据,更新分页控制器状态 UI 优化: - 紧凑的分页控制器布局 - 移除冗余元素(首页/末页/总页数提示) - xs 尺寸按钮,减少视觉负担 - 保留核心功能(翻页、页码、跳转) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/mocks/data/events.js | 4 +- src/store/slices/communityDataSlice.js | 38 +++- .../Community/components/DynamicNewsCard.js | 21 ++ .../DynamicNewsCard/EventScrollList.js | 41 +++- .../DynamicNewsCard/PaginationControl.js | 211 ++++++++++++++++++ src/views/Community/index.js | 4 +- 6 files changed, 304 insertions(+), 15 deletions(-) create mode 100644 src/views/Community/components/DynamicNewsCard/PaginationControl.js diff --git a/src/mocks/data/events.js b/src/mocks/data/events.js index cc6d2d0d..021d57f1 100644 --- a/src/mocks/data/events.js +++ b/src/mocks/data/events.js @@ -726,8 +726,8 @@ export function generateMockEvents(params = {}) { stock_code = '', } = params; - // 生成100个事件用于测试 - const totalEvents = 100; + // 生成200个事件用于测试(足够测试分页功能) + const totalEvents = 200; const allEvents = []; const importanceLevels = ['S', 'A', 'B', 'C']; diff --git a/src/store/slices/communityDataSlice.js b/src/store/slices/communityDataSlice.js index b8f8524b..d05f3aaa 100644 --- a/src/store/slices/communityDataSlice.js +++ b/src/store/slices/communityDataSlice.js @@ -159,28 +159,33 @@ export const fetchHotEvents = createAsyncThunk( /** * 获取动态新闻(无缓存,每次都发起请求) * 用于 DynamicNewsCard 组件,需要保持实时性 + * @param {Object} params - 分页参数 { page, per_page } */ export const fetchDynamicNews = createAsyncThunk( 'communityData/fetchDynamicNews', - async (_, { rejectWithValue }) => { + async ({ page = 1, per_page = 5 } = {}, { rejectWithValue }) => { try { - logger.debug('CommunityData', '开始获取动态新闻'); + logger.debug('CommunityData', '开始获取动态新闻', { page, per_page }); const response = await eventService.getEvents({ - page: 1, - per_page: 5, + page, + per_page, sort: 'new' }); if (response.success && response.data?.events) { logger.info('CommunityData', '动态新闻加载成功', { count: response.data.events.length, + page: response.data.pagination?.page || page, total: response.data.pagination?.total || 0 }); - return response.data.events; + return { + events: response.data.events, + pagination: response.data.pagination || {} + }; } logger.warn('CommunityData', '动态新闻返回数据为空', response); - return []; + return { events: [], pagination: {} }; } catch (error) { logger.error('CommunityData', '获取动态新闻失败', error); return rejectWithValue(error.message || '获取动态新闻失败'); @@ -197,6 +202,7 @@ const communityDataSlice = createSlice({ popularKeywords: [], hotEvents: [], dynamicNews: [], // 动态新闻(无缓存) + dynamicNewsPagination: {}, // 动态新闻分页信息 // 加载状态 loading: { @@ -279,7 +285,24 @@ const communityDataSlice = createSlice({ // 使用工厂函数创建 reducers,消除重复代码 createDataReducers(builder, fetchPopularKeywords, 'popularKeywords'); createDataReducers(builder, fetchHotEvents, 'hotEvents'); - createDataReducers(builder, fetchDynamicNews, 'dynamicNews'); + + // dynamicNews 需要特殊处理(包含 pagination) + builder + .addCase(fetchDynamicNews.pending, (state) => { + state.loading.dynamicNews = true; + state.error.dynamicNews = null; + }) + .addCase(fetchDynamicNews.fulfilled, (state, action) => { + state.loading.dynamicNews = false; + state.dynamicNews = action.payload.events; + state.dynamicNewsPagination = action.payload.pagination; + state.lastUpdated.dynamicNews = new Date().toISOString(); + }) + .addCase(fetchDynamicNews.rejected, (state, action) => { + state.loading.dynamicNews = false; + state.error.dynamicNews = action.payload; + logger.error('CommunityData', 'dynamicNews 加载失败', new Error(action.payload)); + }); } }); @@ -314,6 +337,7 @@ export const selectDynamicNewsWithLoading = (state) => ({ data: state.communityData.dynamicNews, loading: state.communityData.loading.dynamicNews, error: state.communityData.error.dynamicNews, + pagination: state.communityData.dynamicNewsPagination, lastUpdated: state.communityData.lastUpdated.dynamicNews }); diff --git a/src/views/Community/components/DynamicNewsCard.js b/src/views/Community/components/DynamicNewsCard.js index e273d10a..2e6b8fdd 100644 --- a/src/views/Community/components/DynamicNewsCard.js +++ b/src/views/Community/components/DynamicNewsCard.js @@ -2,6 +2,7 @@ // 横向滚动事件卡片组件(实时要闻·动态追踪) import React, { forwardRef, useState, useEffect } from 'react'; +import { useDispatch } from 'react-redux'; import { Card, CardHeader, @@ -20,11 +21,13 @@ import { import { TimeIcon } from '@chakra-ui/icons'; import EventScrollList from './DynamicNewsCard/EventScrollList'; import DynamicNewsDetailPanel from './DynamicNewsDetail'; +import { fetchDynamicNews } from '../../../store/slices/communityDataSlice'; /** * 实时要闻·动态追踪 - 横向滚动卡片组件 * @param {Array} events - 事件列表 * @param {boolean} loading - 加载状态 + * @param {Object} pagination - 分页信息 { page, per_page, total, total_pages } * @param {Date} lastUpdateTime - 最后更新时间 * @param {Function} onEventClick - 事件点击回调 * @param {Function} onViewDetail - 查看详情回调 @@ -33,15 +36,21 @@ import DynamicNewsDetailPanel from './DynamicNewsDetail'; const DynamicNewsCard = forwardRef(({ events, loading, + pagination = {}, lastUpdateTime, onEventClick, onViewDetail, ...rest }, ref) => { + const dispatch = useDispatch(); const cardBg = useColorModeValue('white', 'gray.800'); const borderColor = useColorModeValue('gray.200', 'gray.700'); const [selectedEvent, setSelectedEvent] = useState(null); + const pageSize = 5; // 每页显示5个事件 + const currentPage = pagination.page || 1; + const totalPages = pagination.total_pages || 1; + // 默认选中第一个事件 useEffect(() => { if (events && events.length > 0 && !selectedEvent) { @@ -49,6 +58,15 @@ const DynamicNewsCard = forwardRef(({ } }, [events, selectedEvent]); + // 页码改变时,触发服务端分页请求 + const handlePageChange = (newPage) => { + // 发起 Redux action 获取新页面数据 + dispatch(fetchDynamicNews({ page: newPage, per_page: pageSize })); + + // 重置选中事件(等新数据加载后自动选中第一个) + setSelectedEvent(null); + }; + return ( {/* 标题部分 */} @@ -101,6 +119,9 @@ const DynamicNewsCard = forwardRef(({ selectedEvent={selectedEvent} onEventSelect={setSelectedEvent} borderColor={borderColor} + currentPage={currentPage} + totalPages={totalPages} + onPageChange={handlePageChange} /> )} diff --git a/src/views/Community/components/DynamicNewsCard/EventScrollList.js b/src/views/Community/components/DynamicNewsCard/EventScrollList.js index 75ce1959..78dafbc7 100644 --- a/src/views/Community/components/DynamicNewsCard/EventScrollList.js +++ b/src/views/Community/components/DynamicNewsCard/EventScrollList.js @@ -1,7 +1,7 @@ // src/views/Community/components/DynamicNewsCard/EventScrollList.js // 横向滚动事件列表组件 -import React, { useRef, useState } from 'react'; +import React, { useRef, useState, useEffect } from 'react'; import { Box, Flex, @@ -10,24 +10,41 @@ import { } from '@chakra-ui/react'; import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons'; import DynamicNewsEventCard from '../EventCard/DynamicNewsEventCard'; +import PaginationControl from './PaginationControl'; /** * 横向滚动事件列表组件 - * @param {Array} events - 事件列表 + * @param {Array} events - 当前页的事件列表(服务端已分页) * @param {Object} selectedEvent - 当前选中的事件 * @param {Function} onEventSelect - 事件选择回调 * @param {string} borderColor - 边框颜色 + * @param {number} currentPage - 当前页码 + * @param {number} totalPages - 总页数(由服务端返回) + * @param {Function} onPageChange - 页码改变回调 */ const EventScrollList = ({ events, selectedEvent, onEventSelect, - borderColor + borderColor, + currentPage, + totalPages, + onPageChange }) => { const scrollContainerRef = useRef(null); const [showLeftArrow, setShowLeftArrow] = useState(false); const [showRightArrow, setShowRightArrow] = useState(true); + // 页码变化时,滚动到左侧起始位置 + useEffect(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTo({ + left: 0, + behavior: 'smooth' + }); + } + }, [currentPage]); + // 滚动到左侧 const scrollLeft = () => { if (scrollContainerRef.current) { @@ -71,8 +88,21 @@ const EventScrollList = ({ }; return ( - - {/* 左侧滚动按钮 */} + + {/* 分页控制器 - 右上角 */} + {totalPages > 1 && ( + + + + )} + + {/* 横向滚动区域 */} + + {/* 左侧滚动按钮 */} {showLeftArrow && ( } @@ -168,6 +198,7 @@ const EventScrollList = ({ ))} + ); }; diff --git a/src/views/Community/components/DynamicNewsCard/PaginationControl.js b/src/views/Community/components/DynamicNewsCard/PaginationControl.js new file mode 100644 index 00000000..5b619029 --- /dev/null +++ b/src/views/Community/components/DynamicNewsCard/PaginationControl.js @@ -0,0 +1,211 @@ +// src/views/Community/components/DynamicNewsCard/PaginationControl.js +// 分页控制器组件 + +import React, { useState } from 'react'; +import { + Box, + HStack, + Button, + Input, + Text, + IconButton, + useColorModeValue, + useToast, +} from '@chakra-ui/react'; +import { + ChevronLeftIcon, + ChevronRightIcon, +} from '@chakra-ui/icons'; + +/** + * 分页控制器组件 + * @param {number} currentPage - 当前页码 + * @param {number} totalPages - 总页数 + * @param {Function} onPageChange - 页码改变回调 + */ +const PaginationControl = ({ currentPage, totalPages, onPageChange }) => { + const [jumpPage, setJumpPage] = useState(''); + const toast = useToast(); + + const buttonBg = useColorModeValue('white', 'gray.700'); + const activeBg = useColorModeValue('blue.500', 'blue.400'); + const activeColor = useColorModeValue('white', 'white'); + const borderColor = useColorModeValue('gray.300', 'gray.600'); + const hoverBg = useColorModeValue('gray.100', 'gray.600'); + + // 生成页码数字列表(智能省略) + const getPageNumbers = () => { + const pageNumbers = []; + const maxVisible = 5; // 最多显示5个页码(精简版) + + if (totalPages <= maxVisible) { + // 总页数少,显示全部 + for (let i = 1; i <= totalPages; i++) { + pageNumbers.push(i); + } + } else { + // 总页数多,使用省略号 + if (currentPage <= 3) { + // 当前页在前面 + for (let i = 1; i <= 4; i++) { + pageNumbers.push(i); + } + pageNumbers.push('...'); + pageNumbers.push(totalPages); + } else if (currentPage >= totalPages - 2) { + // 当前页在后面 + pageNumbers.push(1); + pageNumbers.push('...'); + for (let i = totalPages - 3; i <= totalPages; i++) { + pageNumbers.push(i); + } + } else { + // 当前页在中间 + pageNumbers.push(1); + pageNumbers.push('...'); + pageNumbers.push(currentPage); + pageNumbers.push('...'); + pageNumbers.push(totalPages); + } + } + + return pageNumbers; + }; + + // 处理页码跳转 + const handleJump = () => { + const page = parseInt(jumpPage, 10); + + if (isNaN(page)) { + toast({ + title: '请输入有效的页码', + status: 'warning', + duration: 2000, + isClosable: true, + }); + return; + } + + if (page < 1 || page > totalPages) { + toast({ + title: `页码范围:1 - ${totalPages}`, + status: 'warning', + duration: 2000, + isClosable: true, + }); + return; + } + + onPageChange(page); + setJumpPage(''); + }; + + // 处理回车键 + const handleKeyPress = (e) => { + if (e.key === 'Enter') { + handleJump(); + } + }; + + const pageNumbers = getPageNumbers(); + + return ( + + + {/* 上一页按钮 */} + } + size="xs" + onClick={() => onPageChange(currentPage - 1)} + isDisabled={currentPage === 1} + bg={buttonBg} + borderWidth="1px" + borderColor={borderColor} + _hover={{ bg: hoverBg }} + aria-label="上一页" + title="上一页" + /> + + {/* 数字页码列表 */} + {pageNumbers.map((page, index) => { + if (page === '...') { + return ( + + ... + + ); + } + + return ( + + ); + })} + + {/* 下一页按钮 */} + } + size="xs" + onClick={() => onPageChange(currentPage + 1)} + isDisabled={currentPage === totalPages} + bg={buttonBg} + borderWidth="1px" + borderColor={borderColor} + _hover={{ bg: hoverBg }} + aria-label="下一页" + title="下一页" + /> + + {/* 分隔线 */} + + + {/* 输入框跳转 */} + + + 跳转到 + + setJumpPage(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="页" + bg={buttonBg} + borderColor={borderColor} + /> + + + + + ); +}; + +export default PaginationControl; diff --git a/src/views/Community/index.js b/src/views/Community/index.js index 1f92c805..9ff47375 100644 --- a/src/views/Community/index.js +++ b/src/views/Community/index.js @@ -50,7 +50,8 @@ const Community = () => { const { data: dynamicNewsEvents, loading: dynamicNewsLoading, - error: dynamicNewsError + error: dynamicNewsError, + pagination: dynamicNewsPagination } = useSelector(selectDynamicNewsWithLoading); // Chakra UI hooks @@ -169,6 +170,7 @@ const Community = () => { mt={6} events={dynamicNewsEvents} loading={dynamicNewsLoading} + pagination={dynamicNewsPagination} lastUpdateTime={lastUpdateTime} onEventClick={handleEventClick} onViewDetail={handleViewDetail}