From 9fd618c0879801617cd2e011d94ef6ba9a98a94e Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Fri, 14 Nov 2025 17:27:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E8=AF=84=E8=AE=BA?= =?UTF-8?q?=E5=88=86=E9=A1=B5=E5=8A=9F=E8=83=BD=E5=B9=B6=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=88=B0=20TypeScript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建通用分页 Hook (usePagination.ts) 支持任意数据类型 - 将 EventCommentSection 迁移到 TypeScript (.tsx) - 添加"加载更多"按钮,支持增量加载评论 - 创建分页和评论相关类型定义 (pagination.ts, comment.ts) - 增加 Mock 评论数据从 5 条到 15 条,便于测试分页 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- ...mentSection.js => EventCommentSection.tsx} | 179 +++++++++++------- src/hooks/usePagination.ts | 137 ++++++++++++++ src/mocks/handlers/event.js | 18 +- src/types/comment.ts | 42 ++++ src/types/index.ts | 15 ++ src/types/pagination.ts | 72 +++++++ 6 files changed, 397 insertions(+), 66 deletions(-) rename src/components/EventCommentSection/{EventCommentSection.js => EventCommentSection.tsx} (52%) create mode 100644 src/hooks/usePagination.ts create mode 100644 src/types/comment.ts create mode 100644 src/types/pagination.ts diff --git a/src/components/EventCommentSection/EventCommentSection.js b/src/components/EventCommentSection/EventCommentSection.tsx similarity index 52% rename from src/components/EventCommentSection/EventCommentSection.js rename to src/components/EventCommentSection/EventCommentSection.tsx index f99cd0af..47e4dad1 100644 --- a/src/components/EventCommentSection/EventCommentSection.js +++ b/src/components/EventCommentSection/EventCommentSection.tsx @@ -1,78 +1,121 @@ -// src/components/EventCommentSection/EventCommentSection.js +// src/components/EventCommentSection/EventCommentSection.tsx /** - * 事件评论区主组件 + * 事件评论区主组件(TypeScript 版本) * 功能:整合评论列表 + 评论输入框,管理评论数据 + * 使用 usePagination Hook 实现分页功能 */ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useCallback } from 'react'; import { Box, - VStack, Heading, Badge, HStack, Divider, useColorModeValue, useToast, + Button, + Center, } from '@chakra-ui/react'; import { useAuth } from '../../contexts/AuthContext'; import { eventService } from '../../services/eventService'; import { logger } from '../../utils/logger'; +import { usePagination } from '../../hooks/usePagination'; +import type { Comment, CreateCommentParams } from '@/types'; +import type { PaginationLoadResult } from '@/types'; import CommentList from './CommentList'; import CommentInput from './CommentInput'; /** - * 事件评论区组件 - * @param {Object} props - * @param {number} props.eventId - 事件 ID + * 组件 Props */ -const EventCommentSection = ({ eventId }) => { +interface EventCommentSectionProps { + /** 事件 ID */ + eventId: string | number; +} + +/** + * 事件评论区组件 + */ +const EventCommentSection: React.FC = ({ eventId }) => { const { user } = useAuth(); const toast = useToast(); const dividerColor = useColorModeValue('gray.200', 'gray.600'); const headingColor = useColorModeValue('gray.700', 'gray.200'); const sectionBg = useColorModeValue('gray.50', 'gray.750'); - // 状态管理 - const [comments, setComments] = useState([]); - const [loading, setLoading] = useState(false); + // 评论输入状态 const [commentText, setCommentText] = useState(''); const [submitting, setSubmitting] = useState(false); - const [totalCount, setTotalCount] = useState(0); // 总评论数(从后端获取) - // 加载评论列表 - const loadComments = useCallback(async () => { - if (!eventId) return; - - setLoading(true); - try { - // 加载第1页,每页5条评论 - const result = await eventService.getPosts(eventId, 'latest', 1, 5); - if (result.success) { - setComments(result.data || []); - // 保存总评论数(从 pagination.total 读取) - setTotalCount(result.pagination?.total || result.data?.length || 0); - logger.info('EventCommentSection', '评论加载成功', { + /** + * 加载评论数据的函数 + * @param page 页码 + * @param append 是否追加到已有数据 + * @returns 分页响应数据 + */ + const loadCommentsFunction = useCallback( + async (page: number, append: boolean): Promise> => { + try { + const result = await eventService.getPosts( eventId, - count: result.data?.length || 0, - total: result.pagination?.total || 0, - }); - } - } catch (error) { - logger.error('EventCommentSection', 'loadComments', error, { eventId }); - toast({ - title: '加载评论失败', - description: error.message || '请稍后重试', - status: 'error', - duration: 3000, - isClosable: true, - }); - } finally { - setLoading(false); - } - }, [eventId, toast]); + 'latest', + page, + 5 // 每页 5 条评论 + ); - // 发表评论 + if (result.success) { + logger.info('EventCommentSection', '评论加载成功', { + eventId, + page, + count: result.data?.length || 0, + total: result.pagination?.total || 0, + append, + }); + + return { + data: result.data || [], + pagination: result.pagination, + }; + } else { + throw new Error(result.message || '加载评论失败'); + } + } catch (error: any) { + logger.error('EventCommentSection', 'loadCommentsFunction', error, { + eventId, + page, + }); + toast({ + title: '加载评论失败', + description: error.message || '请稍后重试', + status: 'error', + duration: 3000, + isClosable: true, + }); + throw error; + } + }, + [eventId, toast] + ); + + // 使用 usePagination Hook + const { + data: comments, + loading, + loadingMore, + hasMore, + totalCount, + loadMore, + setData: setComments, + setTotalCount, + } = usePagination(loadCommentsFunction, { + pageSize: 5, + autoLoad: true, + }); + + /** + * 发表评论 + */ const handleSubmitComment = useCallback(async () => { if (!commentText.trim()) { toast({ @@ -86,14 +129,16 @@ const EventCommentSection = ({ eventId }) => { setSubmitting(true); try { - const result = await eventService.createPost(eventId, { + const params: CreateCommentParams = { content: commentText.trim(), content_type: 'text', - }); + }; + + const result = await eventService.createPost(eventId, params); if (result.success) { // 乐观更新:立即将新评论添加到本地 state,避免重新加载导致的闪烁 - const newComment = { + const newComment: Comment = { id: result.data?.id || `comment_optimistic_${Date.now()}`, content: commentText.trim(), content_type: 'text', @@ -108,9 +153,9 @@ const EventCommentSection = ({ eventId }) => { }; // 将新评论追加到列表末尾(最新评论在底部) - setComments([...comments, newComment]); + setComments((prevComments) => [...prevComments, newComment]); // 总评论数 +1 - setTotalCount(totalCount + 1); + setTotalCount((prevTotal) => prevTotal + 1); toast({ title: '评论发布成功', @@ -119,7 +164,6 @@ const EventCommentSection = ({ eventId }) => { isClosable: true, }); setCommentText(''); // 清空输入框 - // ✅ 不再调用 loadComments(),避免 loading 状态导致高度闪烁 logger.info('EventCommentSection', '评论发布成功(乐观更新)', { eventId, @@ -129,7 +173,7 @@ const EventCommentSection = ({ eventId }) => { } else { throw new Error(result.message || '评论发布失败'); } - } catch (error) { + } catch (error: any) { logger.error('EventCommentSection', 'handleSubmitComment', error, { eventId }); toast({ title: '评论发布失败', @@ -141,23 +185,12 @@ const EventCommentSection = ({ eventId }) => { } finally { setSubmitting(false); } - }, [eventId, commentText, toast, comments, user, totalCount]); - - // 初始加载评论 - useEffect(() => { - loadComments(); - }, [loadComments]); + }, [eventId, commentText, toast, user, setComments, setTotalCount]); return ( {/* 标题栏 */} - + 讨论区 @@ -173,13 +206,31 @@ const EventCommentSection = ({ eventId }) => { + {/* 加载更多按钮(仅当有更多评论时显示) */} + {hasMore && ( +
+ +
+ )} + {/* 评论输入框(仅登录用户显示) */} {user && ( setCommentText(e.target.value)} + onChange={(e: React.ChangeEvent) => + setCommentText(e.target.value) + } onSubmit={handleSubmitComment} isSubmitting={submitting} maxLength={500} diff --git a/src/hooks/usePagination.ts b/src/hooks/usePagination.ts new file mode 100644 index 00000000..2aec3c36 --- /dev/null +++ b/src/hooks/usePagination.ts @@ -0,0 +1,137 @@ +/** + * usePagination - 通用分页 Hook + * + * 封装分页逻辑,支持初始加载、加载更多、重置等功能 + * + * @example + * const { + * data: comments, + * loading, + * loadingMore, + * hasMore, + * totalCount, + * loadMore, + * setData, + * setTotalCount, + * } = usePagination(loadCommentsFunction, { pageSize: 5 }); + */ + +import { useState, useCallback, useEffect } from 'react'; +import type { + LoadFunction, + PaginationLoadResult, + UsePaginationOptions, + UsePaginationResult, +} from '@/types/pagination'; + +/** + * usePagination Hook + * @template T 数据项类型 + * @param loadFunction 加载函数,接收 (page, append) 参数 + * @param options 配置选项 + * @returns 分页状态和操作方法 + */ +export function usePagination( + loadFunction: LoadFunction, + options: UsePaginationOptions = {} +): UsePaginationResult { + const { pageSize = 10, autoLoad = true } = options; + + // 状态管理 + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [totalCount, setTotalCount] = useState(0); + const [hasMore, setHasMore] = useState(false); + + /** + * 加载数据 + * @param page 页码 + * @param append 是否追加(true: 追加,false: 替换) + */ + const loadData = useCallback( + async (page: number, append: boolean = false) => { + // 设置加载状态 + if (append) { + setLoadingMore(true); + } else { + setLoading(true); + } + + try { + const result: PaginationLoadResult = await loadFunction(page, append); + + // 更新数据 + if (append) { + setData((prevData) => [...prevData, ...(result.data || [])]); + } else { + setData(result.data || []); + } + + // 更新分页信息 + const total = result.pagination?.total || result.data?.length || 0; + setTotalCount(total); + + // 计算是否还有更多数据 + const currentTotal = append + ? data.length + (result.data?.length || 0) + : result.data?.length || 0; + setHasMore(currentTotal < total); + } catch (error) { + console.error('[usePagination] 加载数据失败:', error); + throw error; + } finally { + if (append) { + setLoadingMore(false); + } else { + setLoading(false); + } + } + }, + [loadFunction, data.length] + ); + + /** + * 加载更多数据 + */ + const loadMore = useCallback(async () => { + if (loadingMore || !hasMore) return; + + const nextPage = currentPage + 1; + await loadData(nextPage, true); + setCurrentPage(nextPage); + }, [currentPage, loadData, loadingMore, hasMore]); + + /** + * 重置到第一页 + */ + const reset = useCallback(() => { + setCurrentPage(1); + setData([]); + setTotalCount(0); + setHasMore(false); + loadData(1, false); + }, [loadData]); + + // 自动加载第一页 + useEffect(() => { + if (autoLoad) { + loadData(1, false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoLoad]); + + return { + data, + loading, + loadingMore, + currentPage, + hasMore, + totalCount, + loadMore, + reset, + setData, + setTotalCount, + }; +} diff --git a/src/mocks/handlers/event.js b/src/mocks/handlers/event.js index f032366c..1fe1e1c8 100644 --- a/src/mocks/handlers/event.js +++ b/src/mocks/handlers/event.js @@ -16,7 +16,7 @@ const commentsStore = new Map(); /** * 初始化某个事件的 mock 评论列表 * @param {string} eventId - 事件 ID - * @returns {Array} 初始的 8 条 mock 评论 + * @returns {Array} 初始的 15 条 mock 评论 */ const initializeMockComments = (eventId) => { const comments = []; @@ -29,6 +29,13 @@ const initializeMockComments = (eventId) => { { username: '价值投资者', avatar: null }, { username: '技术分析师', avatar: null }, { username: '基本面研究员', avatar: null }, + { username: '量化交易员', avatar: null }, + { username: '市场观察者', avatar: null }, + { username: '行业分析师', avatar: null }, + { username: '财经评论员', avatar: null }, + { username: '趋势跟踪者', avatar: null }, + { username: '价值发现者', avatar: null }, + { username: '理性投资人', avatar: null }, ]; const commentTemplates = [ @@ -40,9 +47,16 @@ const initializeMockComments = (eventId) => { '相关产业链的龙头企业值得重点关注', '这类事件一般都是短期刺激,长期影响有限', '建议大家理性对待,不要盲目追高', + '这个消息已经在预期之中,股价可能提前反应了', + '关键要看后续的执行力度和落地速度', + '建议关注产业链上下游的投资机会', + '短期可能会有波动,但长期逻辑依然成立', + '市场情绪很高涨,需要警惕追高风险', + '从历史数据来看,类似事件后续表现都不错', + '这是一个结构性机会,需要精选个股', ]; - for (let i = 0; i < 5; i++) { + for (let i = 0; i < 15; i++) { const hoursAgo = Math.floor(Math.random() * 48) + 1; // 1-48 小时前 const createdAt = new Date(Date.now() - hoursAgo * 60 * 60 * 1000); const user = users[i % users.length]; diff --git a/src/types/comment.ts b/src/types/comment.ts new file mode 100644 index 00000000..a098e449 --- /dev/null +++ b/src/types/comment.ts @@ -0,0 +1,42 @@ +/** + * 评论相关类型定义 + */ + +/** + * 评论作者信息 + */ +export interface CommentAuthor { + id: string; + username: string; + avatar?: string | null; +} + +/** + * 评论数据结构 + */ +export interface Comment { + /** 评论 ID */ + id: string; + /** 评论内容 */ + content: string; + /** 内容类型 */ + content_type: 'text' | 'image' | 'video'; + /** 作者信息 */ + author: CommentAuthor; + /** 创建时间(ISO 8601 格式) */ + created_at: string; + /** 点赞数 */ + likes_count: number; + /** 当前用户是否已点赞 */ + is_liked: boolean; +} + +/** + * 创建评论请求参数 + */ +export interface CreateCommentParams { + /** 评论内容 */ + content: string; + /** 内容类型 */ + content_type?: 'text' | 'image' | 'video'; +} diff --git a/src/types/index.ts b/src/types/index.ts index 7907e403..54f38669 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -38,3 +38,18 @@ export type { UserAccount, UserSettings, } from './user'; + +// 分页相关类型 +export type { + LoadFunction, + PaginationLoadResult, + UsePaginationOptions, + UsePaginationResult, +} from './pagination'; + +// 评论相关类型 +export type { + Comment, + CommentAuthor, + CreateCommentParams, +} from './comment'; diff --git a/src/types/pagination.ts b/src/types/pagination.ts new file mode 100644 index 00000000..a5e64888 --- /dev/null +++ b/src/types/pagination.ts @@ -0,0 +1,72 @@ +/** + * 分页相关类型定义 + * + * 用于 usePagination Hook 和其他分页功能 + */ + +/** + * 分页加载函数类型 + * @template T 数据项类型 + * @param page 页码(从 1 开始) + * @param append 是否追加到已有数据(true: 追加,false: 替换) + * @returns Promise,解析为分页响应数据 + */ +export type LoadFunction = ( + page: number, + append: boolean +) => Promise>; + +/** + * 分页加载结果 + * @template T 数据项类型 + */ +export interface PaginationLoadResult { + /** 数据列表 */ + data: T[]; + /** 分页信息 */ + pagination?: { + /** 总数据量 */ + total?: number; + /** 当前页码 */ + page?: number; + /** 每页数量 */ + per_page?: number; + }; +} + +/** + * usePagination Hook 配置选项 + */ +export interface UsePaginationOptions { + /** 每页数据量,默认 10 */ + pageSize?: number; + /** 是否自动加载第一页,默认 true */ + autoLoad?: boolean; +} + +/** + * usePagination Hook 返回值 + * @template T 数据项类型 + */ +export interface UsePaginationResult { + /** 当前数据列表 */ + data: T[]; + /** 是否正在加载第一页 */ + loading: boolean; + /** 是否正在加载更多 */ + loadingMore: boolean; + /** 当前页码 */ + currentPage: number; + /** 是否还有更多数据 */ + hasMore: boolean; + /** 总数据量 */ + totalCount: number; + /** 加载更多数据 */ + loadMore: () => Promise; + /** 重置到第一页 */ + reset: () => void; + /** 手动设置数据(用于乐观更新) */ + setData: React.Dispatch>; + /** 手动设置总数(用于乐观更新) */ + setTotalCount: React.Dispatch>; +}