From e23feb3c2317910fc2939a0403b9afd3399a9e87 Mon Sep 17 00:00:00 2001
From: zdl <3489966805@qq.com>
Date: Fri, 14 Nov 2025 16:15:13 +0800
Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=AF=84?=
=?UTF-8?q?=E8=AE=BA=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../EventCommentSection/CommentInput.js | 77 +++++++
.../EventCommentSection/CommentItem.js | 89 ++++++++
.../EventCommentSection/CommentList.js | 67 ++++++
.../EventCommentSection.js | 194 ++++++++++++++++++
src/components/EventCommentSection/index.js | 10 +
src/mocks/handlers/event.js | 178 ++++++++++++++++
.../DynamicNewsDetailPanel.js | 7 +
.../components/EventCard/KeywordsCarousel.js | 4 +-
8 files changed, 625 insertions(+), 1 deletion(-)
create mode 100644 src/components/EventCommentSection/CommentInput.js
create mode 100644 src/components/EventCommentSection/CommentItem.js
create mode 100644 src/components/EventCommentSection/CommentList.js
create mode 100644 src/components/EventCommentSection/EventCommentSection.js
create mode 100644 src/components/EventCommentSection/index.js
diff --git a/src/components/EventCommentSection/CommentInput.js b/src/components/EventCommentSection/CommentInput.js
new file mode 100644
index 00000000..0f5d9009
--- /dev/null
+++ b/src/components/EventCommentSection/CommentInput.js
@@ -0,0 +1,77 @@
+// src/components/EventCommentSection/CommentInput.js
+/**
+ * 评论输入框组件
+ * 功能:输入评论内容、字数限制、发布按钮
+ */
+
+import React from 'react';
+import {
+ Box,
+ Textarea,
+ Button,
+ HStack,
+ Text,
+ useColorModeValue,
+} from '@chakra-ui/react';
+
+const CommentInput = ({
+ value,
+ onChange,
+ onSubmit,
+ isSubmitting,
+ maxLength = 500,
+ placeholder = '说点什么...',
+}) => {
+ const bgColor = useColorModeValue('white', 'gray.800');
+ const borderColor = useColorModeValue('gray.200', 'gray.600');
+ const textColor = useColorModeValue('gray.600', 'gray.400');
+ const countColor = useColorModeValue('gray.500', 'gray.500');
+
+ const handleKeyDown = (e) => {
+ // Ctrl/Cmd + Enter 快捷键提交
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
+ onSubmit();
+ }
+ };
+
+ return (
+
+
+
+
+ {value.length}/{maxLength}
+ {value.length === 0 && ' · Ctrl+Enter 快速发布'}
+
+
+
+
+ );
+};
+
+export default CommentInput;
diff --git a/src/components/EventCommentSection/CommentItem.js b/src/components/EventCommentSection/CommentItem.js
new file mode 100644
index 00000000..c2e9395a
--- /dev/null
+++ b/src/components/EventCommentSection/CommentItem.js
@@ -0,0 +1,89 @@
+// src/components/EventCommentSection/CommentItem.js
+/**
+ * 单条评论组件
+ * 功能:显示用户头像、昵称、时间、评论内容
+ */
+
+import React from 'react';
+import {
+ Box,
+ HStack,
+ VStack,
+ Avatar,
+ Text,
+ useColorModeValue,
+} from '@chakra-ui/react';
+import moment from 'moment';
+import 'moment/locale/zh-cn';
+
+moment.locale('zh-cn');
+
+const CommentItem = ({ comment }) => {
+ const itemBg = useColorModeValue('gray.50', 'gray.700');
+ const usernameColor = useColorModeValue('gray.800', 'gray.100');
+ const timeColor = useColorModeValue('gray.500', 'gray.400');
+ const contentColor = useColorModeValue('gray.700', 'gray.300');
+
+ // 格式化时间
+ const formatTime = (timestamp) => {
+ const now = moment();
+ const time = moment(timestamp);
+ const diffMinutes = now.diff(time, 'minutes');
+ const diffHours = now.diff(time, 'hours');
+ const diffDays = now.diff(time, 'days');
+
+ if (diffMinutes < 1) {
+ return '刚刚';
+ } else if (diffMinutes < 60) {
+ return `${diffMinutes}分钟前`;
+ } else if (diffHours < 24) {
+ return `${diffHours}小时前`;
+ } else if (diffDays < 7) {
+ return `${diffDays}天前`;
+ } else {
+ return time.format('MM-DD HH:mm');
+ }
+ };
+
+ return (
+
+
+ {/* 用户头像 */}
+
+
+ {/* 评论内容区 */}
+
+ {/* 用户名和时间 */}
+
+
+ {comment.author?.username || 'Anonymous'}
+
+
+ {formatTime(comment.created_at)}
+
+
+
+ {/* 评论内容 */}
+
+ {comment.content}
+
+
+
+
+ );
+};
+
+export default CommentItem;
diff --git a/src/components/EventCommentSection/CommentList.js b/src/components/EventCommentSection/CommentList.js
new file mode 100644
index 00000000..0a08078f
--- /dev/null
+++ b/src/components/EventCommentSection/CommentList.js
@@ -0,0 +1,67 @@
+// src/components/EventCommentSection/CommentList.js
+/**
+ * 评论列表组件
+ * 功能:展示评论列表、加载状态、空状态
+ */
+
+import React from 'react';
+import {
+ VStack,
+ Spinner,
+ Center,
+ Text,
+ Box,
+ useColorModeValue,
+} from '@chakra-ui/react';
+import { ChatIcon } from '@chakra-ui/icons';
+import CommentItem from './CommentItem';
+
+const CommentList = ({ comments, loading }) => {
+ const emptyTextColor = useColorModeValue('gray.500', 'gray.400');
+ const emptyBgColor = useColorModeValue('gray.50', 'gray.700');
+
+ // 加载状态
+ if (loading) {
+ return (
+
+
+
+
+ 加载评论中...
+
+
+
+ );
+ }
+
+ // 空状态
+ if (!comments || comments.length === 0) {
+ return (
+
+
+
+
+
+
+ 还没有评论,快来发表第一条吧~
+
+
+
+ );
+ }
+
+ // 评论列表
+ return (
+
+ {comments.map((comment) => (
+
+ ))}
+
+ );
+};
+
+export default CommentList;
diff --git a/src/components/EventCommentSection/EventCommentSection.js b/src/components/EventCommentSection/EventCommentSection.js
new file mode 100644
index 00000000..f99cd0af
--- /dev/null
+++ b/src/components/EventCommentSection/EventCommentSection.js
@@ -0,0 +1,194 @@
+// src/components/EventCommentSection/EventCommentSection.js
+/**
+ * 事件评论区主组件
+ * 功能:整合评论列表 + 评论输入框,管理评论数据
+ */
+
+import React, { useState, useEffect, useCallback } from 'react';
+import {
+ Box,
+ VStack,
+ Heading,
+ Badge,
+ HStack,
+ Divider,
+ useColorModeValue,
+ useToast,
+} from '@chakra-ui/react';
+import { useAuth } from '../../contexts/AuthContext';
+import { eventService } from '../../services/eventService';
+import { logger } from '../../utils/logger';
+import CommentList from './CommentList';
+import CommentInput from './CommentInput';
+
+/**
+ * 事件评论区组件
+ * @param {Object} props
+ * @param {number} props.eventId - 事件 ID
+ */
+const EventCommentSection = ({ 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', '评论加载成功', {
+ 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]);
+
+ // 发表评论
+ const handleSubmitComment = useCallback(async () => {
+ if (!commentText.trim()) {
+ toast({
+ title: '请输入评论内容',
+ status: 'warning',
+ duration: 2000,
+ isClosable: true,
+ });
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ const result = await eventService.createPost(eventId, {
+ content: commentText.trim(),
+ content_type: 'text',
+ });
+
+ if (result.success) {
+ // 乐观更新:立即将新评论添加到本地 state,避免重新加载导致的闪烁
+ const newComment = {
+ id: result.data?.id || `comment_optimistic_${Date.now()}`,
+ content: commentText.trim(),
+ content_type: 'text',
+ author: {
+ id: user?.id || 'current_user',
+ username: user?.username || '当前用户',
+ avatar: user?.avatar || null,
+ },
+ created_at: new Date().toISOString(),
+ likes_count: 0,
+ is_liked: false,
+ };
+
+ // 将新评论追加到列表末尾(最新评论在底部)
+ setComments([...comments, newComment]);
+ // 总评论数 +1
+ setTotalCount(totalCount + 1);
+
+ toast({
+ title: '评论发布成功',
+ status: 'success',
+ duration: 2000,
+ isClosable: true,
+ });
+ setCommentText(''); // 清空输入框
+ // ✅ 不再调用 loadComments(),避免 loading 状态导致高度闪烁
+
+ logger.info('EventCommentSection', '评论发布成功(乐观更新)', {
+ eventId,
+ content: commentText.trim(),
+ commentId: newComment.id,
+ });
+ } else {
+ throw new Error(result.message || '评论发布失败');
+ }
+ } catch (error) {
+ logger.error('EventCommentSection', 'handleSubmitComment', error, { eventId });
+ toast({
+ title: '评论发布失败',
+ description: error.message || '请稍后重试',
+ status: 'error',
+ duration: 3000,
+ isClosable: true,
+ });
+ } finally {
+ setSubmitting(false);
+ }
+ }, [eventId, commentText, toast, comments, user, totalCount]);
+
+ // 初始加载评论
+ useEffect(() => {
+ loadComments();
+ }, [loadComments]);
+
+ return (
+
+ {/* 标题栏 */}
+
+
+ 讨论区
+
+
+ {totalCount} 条评论
+
+
+
+
+
+ {/* 评论列表 */}
+
+
+
+
+ {/* 评论输入框(仅登录用户显示) */}
+ {user && (
+
+
+ setCommentText(e.target.value)}
+ onSubmit={handleSubmitComment}
+ isSubmitting={submitting}
+ maxLength={500}
+ placeholder="说点什么..."
+ />
+
+ )}
+
+ );
+};
+
+export default EventCommentSection;
diff --git a/src/components/EventCommentSection/index.js b/src/components/EventCommentSection/index.js
new file mode 100644
index 00000000..21f179cb
--- /dev/null
+++ b/src/components/EventCommentSection/index.js
@@ -0,0 +1,10 @@
+// src/components/EventCommentSection/index.js
+/**
+ * 事件评论区组件统一导出
+ */
+
+export { default } from './EventCommentSection';
+export { default as EventCommentSection } from './EventCommentSection';
+export { default as CommentList } from './CommentList';
+export { default as CommentItem } from './CommentItem';
+export { default as CommentInput } from './CommentInput';
diff --git a/src/mocks/handlers/event.js b/src/mocks/handlers/event.js
index b6481cf8..f032366c 100644
--- a/src/mocks/handlers/event.js
+++ b/src/mocks/handlers/event.js
@@ -9,6 +9,77 @@ import { generatePopularConcepts } from './concept';
// 模拟网络延迟
const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms));
+// ==================== 评论内存存储 ====================
+// 用于在 Mock 环境下持久化评论数据(按 eventId 分组)
+const commentsStore = new Map();
+
+/**
+ * 初始化某个事件的 mock 评论列表
+ * @param {string} eventId - 事件 ID
+ * @returns {Array} 初始的 8 条 mock 评论
+ */
+const initializeMockComments = (eventId) => {
+ const comments = [];
+ const users = [
+ { 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 = [
+ '这个事件对相关板块影响很大,值得关注后续发展',
+ '相关概念股已经开始异动了,市场反应很快',
+ '感谢分享,这个事件我之前没注意到',
+ '从基本面来看,这个事件会带来实质性利好',
+ '需要观察后续政策落地情况,现在下结论还太早',
+ '相关产业链的龙头企业值得重点关注',
+ '这类事件一般都是短期刺激,长期影响有限',
+ '建议大家理性对待,不要盲目追高',
+ ];
+
+ for (let i = 0; i < 5; 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];
+
+ comments.push({
+ id: `comment_${eventId}_${i + 1}`,
+ content: commentTemplates[i % commentTemplates.length],
+ content_type: 'text',
+ author: {
+ id: `user_${i + 1}`,
+ username: user.username,
+ avatar: user.avatar,
+ },
+ created_at: createdAt.toISOString(),
+ likes_count: Math.floor(Math.random() * 20),
+ is_liked: false,
+ });
+ }
+
+ // 按时间升序排序(最旧的在前)
+ comments.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
+
+ return comments;
+};
+
+/**
+ * 获取或初始化评论列表
+ * @param {string} eventId - 事件 ID
+ * @returns {Array} 评论列表
+ */
+const getOrInitComments = (eventId) => {
+ if (!commentsStore.has(eventId)) {
+ commentsStore.set(eventId, initializeMockComments(eventId));
+ }
+ return commentsStore.get(eventId);
+};
+
export const eventHandlers = [
// ==================== 事件列表相关 ====================
@@ -1249,4 +1320,111 @@ export const eventHandlers = [
);
}
}),
+
+ // ==================== 评论相关 ====================
+
+ // 获取事件评论列表
+ http.get('/api/events/:eventId/posts', async ({ params, request }) => {
+ await delay(300);
+
+ const { eventId } = params;
+ const url = new URL(request.url);
+ const sort = url.searchParams.get('sort') || 'latest';
+ const page = parseInt(url.searchParams.get('page') || '1');
+ const perPage = parseInt(url.searchParams.get('per_page') || '20');
+
+ console.log('[Mock] 获取评论列表, eventId:', eventId, 'sort:', sort);
+
+ try {
+ // 从内存存储获取评论列表
+ const allComments = getOrInitComments(eventId);
+
+ // ✅ 创建副本并排序(避免直接修改原数组)
+ let sortedComments = [...allComments];
+ if (sort === 'hot') {
+ sortedComments.sort((a, b) => b.likes_count - a.likes_count);
+ } else {
+ // 默认按时间升序(oldest first)- 最旧评论在前,最新在后
+ sortedComments.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
+ }
+
+ // 分页处理(使用排序后的副本)
+ const startIndex = (page - 1) * perPage;
+ const endIndex = startIndex + perPage;
+ const paginatedComments = sortedComments.slice(startIndex, endIndex);
+
+ return HttpResponse.json({
+ success: true,
+ data: paginatedComments,
+ pagination: {
+ page: page,
+ per_page: perPage,
+ total: allComments.length,
+ pages: Math.ceil(allComments.length / perPage),
+ has_prev: page > 1,
+ has_next: endIndex < allComments.length,
+ },
+ message: '获取评论成功',
+ });
+ } catch (error) {
+ console.error('[Mock] 获取评论列表失败:', error);
+ return HttpResponse.json(
+ {
+ success: false,
+ error: '获取评论失败',
+ data: [],
+ },
+ { status: 500 }
+ );
+ }
+ }),
+
+ // 发表评论
+ http.post('/api/events/:eventId/posts', async ({ params, request }) => {
+ await delay(500);
+
+ const { eventId } = params;
+ const body = await request.json();
+
+ console.log('[Mock] 发表评论, eventId:', eventId, 'content:', body.content);
+
+ try {
+ // 模拟创建新评论
+ const newComment = {
+ id: `comment_${eventId}_${Date.now()}`,
+ content: body.content,
+ content_type: body.content_type || 'text',
+ author: {
+ id: 'current_user',
+ username: '当前用户',
+ avatar: null,
+ },
+ created_at: new Date().toISOString(),
+ likes_count: 0,
+ is_liked: false,
+ };
+
+ // 将新评论添加到内存存储(插入到列表开头)
+ const comments = getOrInitComments(eventId);
+ comments.unshift(newComment);
+
+ console.log('[Mock] 评论已添加到内存存储, 当前评论总数:', comments.length);
+
+ return HttpResponse.json({
+ success: true,
+ data: newComment,
+ message: '评论发布成功',
+ });
+ } catch (error) {
+ console.error('[Mock] 发表评论失败:', error);
+ return HttpResponse.json(
+ {
+ success: false,
+ error: '评论发布失败',
+ message: '系统错误,请稍后重试',
+ },
+ { status: 500 }
+ );
+ }
+ }),
];
diff --git a/src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js b/src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js
index da419291..3a945d8b 100644
--- a/src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js
+++ b/src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js
@@ -4,6 +4,7 @@
import React, { useState, useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
+ Box,
Card,
CardBody,
VStack,
@@ -32,6 +33,7 @@ import TransmissionChainAnalysis from '../../../EventDetail/components/Transmiss
import SubscriptionBadge from '../../../../components/SubscriptionBadge';
import SubscriptionUpgradeModal from '../../../../components/SubscriptionUpgradeModal';
import { PROFESSIONAL_COLORS } from '../../../../constants/professionalTheme';
+import EventCommentSection from '../../../../components/EventCommentSection';
/**
* 动态新闻详情面板主组件
@@ -414,6 +416,11 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
eventService={eventService}
/>
+
+ {/* 讨论区(评论区) - 所有登录用户可用 */}
+
+
+
diff --git a/src/views/Community/components/EventCard/KeywordsCarousel.js b/src/views/Community/components/EventCard/KeywordsCarousel.js
index a41e7c6f..076de948 100644
--- a/src/views/Community/components/EventCard/KeywordsCarousel.js
+++ b/src/views/Community/components/EventCard/KeywordsCarousel.js
@@ -74,7 +74,9 @@ const KeywordsCarousel = ({
whiteSpace="nowrap"
textShadow="0 0 20px rgba(255, 195, 0, 0.3)"
>
- {currentKeyword}
+ {typeof currentKeyword === 'string'
+ ? currentKeyword
+ : currentKeyword?.concept || '未知标签'}
From 48fdca203c8aa0b5dbe5e1ec6b6b0d282eb487f8 Mon Sep 17 00:00:00 2001
From: zdl <3489966805@qq.com>
Date: Fri, 14 Nov 2025 16:15:29 +0800
Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5Ts=E9=85=8D?=
=?UTF-8?q?=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.eslintrc.js | 92 ++++++++++++++++++++++++++
CLAUDE.md | 159 +++++++++++++++++++++++++++++++++++++++++++++
craco.config.js | 4 +-
jsconfig.json | 27 --------
package.json | 12 +++-
src/types/api.ts | 68 +++++++++++++++++++
src/types/index.ts | 40 ++++++++++++
src/types/stock.ts | 103 +++++++++++++++++++++++++++++
src/types/user.ts | 108 ++++++++++++++++++++++++++++++
tsconfig.json | 81 +++++++++++++++++++++++
10 files changed, 663 insertions(+), 31 deletions(-)
create mode 100644 .eslintrc.js
delete mode 100755 jsconfig.json
create mode 100644 src/types/api.ts
create mode 100644 src/types/index.ts
create mode 100644 src/types/stock.ts
create mode 100644 src/types/user.ts
create mode 100644 tsconfig.json
diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 00000000..f92631b5
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,92 @@
+module.exports = {
+ root: true,
+
+ /* 环境配置 */
+ env: {
+ browser: true,
+ es2021: true,
+ node: true,
+ },
+
+ /* 扩展配置 */
+ extends: [
+ 'react-app', // Create React App 默认规则
+ 'react-app/jest', // Jest 测试规则
+ 'eslint:recommended', // ESLint 推荐规则
+ 'plugin:react/recommended', // React 推荐规则
+ 'plugin:react-hooks/recommended', // React Hooks 规则
+ 'plugin:prettier/recommended', // Prettier 集成
+ ],
+
+ /* 解析器选项 */
+ parserOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+
+ /* 插件 */
+ plugins: ['react', 'react-hooks', 'prettier'],
+
+ /* 规则配置 */
+ rules: {
+ // React
+ 'react/react-in-jsx-scope': 'off', // React 17+ 不需要导入 React
+ 'react/prop-types': 'off', // 使用 TypeScript 类型检查,不需要 PropTypes
+ 'react/display-name': 'off', // 允许匿名组件
+
+ // 通用
+ 'no-console': ['warn', { allow: ['warn', 'error'] }], // 仅警告 console.log
+ 'no-unused-vars': ['warn', {
+ argsIgnorePattern: '^_', // 忽略以 _ 开头的未使用参数
+ varsIgnorePattern: '^_', // 忽略以 _ 开头的未使用变量
+ }],
+ 'prettier/prettier': ['warn', {}, { usePrettierrc: true }], // 使用项目的 Prettier 配置
+ },
+
+ /* 设置 */
+ settings: {
+ react: {
+ version: 'detect', // 自动检测 React 版本
+ },
+ },
+
+ /* TypeScript 文件特殊配置 */
+ overrides: [
+ {
+ files: ['**/*.ts', '**/*.tsx'], // 仅对 TS 文件应用以下配置
+ parser: '@typescript-eslint/parser', // 使用 TypeScript 解析器
+ parserOptions: {
+ project: './tsconfig.json', // 关联 tsconfig.json
+ },
+ extends: [
+ 'plugin:@typescript-eslint/recommended', // TypeScript 推荐规则
+ ],
+ plugins: ['@typescript-eslint'],
+ rules: {
+ // TypeScript 特定规则
+ '@typescript-eslint/no-explicit-any': 'warn', // 警告使用 any(允许但提示)
+ '@typescript-eslint/explicit-module-boundary-types': 'off', // 不强制导出函数类型
+ '@typescript-eslint/no-unused-vars': ['warn', {
+ argsIgnorePattern: '^_',
+ varsIgnorePattern: '^_',
+ }],
+ '@typescript-eslint/no-non-null-assertion': 'warn', // 警告使用 !(非空断言)
+
+ // 覆盖基础规则(避免与 TS 规则冲突)
+ 'no-unused-vars': 'off', // 使用 TS 版本的规则
+ },
+ },
+ ],
+
+ /* 忽略文件(与 .eslintignore 等效)*/
+ ignorePatterns: [
+ 'node_modules/',
+ 'build/',
+ 'dist/',
+ '*.config.js',
+ 'public/mockServiceWorker.js',
+ ],
+};
diff --git a/CLAUDE.md b/CLAUDE.md
index 3995dbee..f3e5d132 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -20,6 +20,7 @@
**开发指南**:
- [开发工作流](#开发工作流) - 路由、组件、API、Redux 开发指南
+- [TypeScript 接入](#typescript-接入) - TypeScript 渐进式迁移方案与指南
- [常见开发任务](#常见开发任务) - 5 个详细的开发任务教程
- [技术路径与开发指南](#技术路径与开发指南) - UI 框架选型、技术栈演进、最佳实践
@@ -42,6 +43,7 @@
**前端**
- **核心框架**: React 18.3.1
+- **类型系统**: TypeScript 5.9.3(渐进式接入中,支持 JS/TS 混合开发)
- **UI 组件库**: Chakra UI 2.8.2(主要) + Ant Design 5.27.4(表格/表单)
- **状态管理**: Redux Toolkit 2.9.2
- **路由**: React Router v6.30.1 配合 React.lazy() 实现代码分割
@@ -81,6 +83,10 @@ npm test # 运行 React 测试套件(CRACO)
npm run lint:check # 检查 ESLint 规则(退出码 0)
npm run lint:fix # 自动修复 ESLint 问题
+
+npm run type-check # TypeScript 类型检查(不生成输出)
+npm run type-check:watch # TypeScript 类型检查监听模式
+
npm run clean # 删除 node_modules 和 package-lock.json
npm run reinstall # 清洁安装(运行 clean + install)
```
@@ -1386,6 +1392,159 @@ src/views/Community/components/
---
+## TypeScript 接入
+
+### 概述
+
+项目已于 **2025-11-13** 完成 TypeScript 环境配置,采用**渐进式迁移策略**,支持 JavaScript 和 TypeScript 混合开发。
+
+**当前状态**: 环境配置完成,准备开始代码迁移
+**迁移策略**: 新代码使用 TypeScript,旧代码按优先级逐步迁移
+**类型严格度**: 推荐模式(`noImplicitAny: true`,其他严格检查待后续开启)
+
+### 已完成的环境配置
+
+✅ **TypeScript 编译器**: v5.9.3
+✅ **tsconfig.json**: 推荐模式配置,支持 JS/TS 混合开发
+✅ **CRACO 配置**: 支持 `.ts` 和 `.tsx` 文件编译
+✅ **ESLint 配置**: 支持 TypeScript 语法检查
+✅ **路径别名**: 与现有 `@/` 别名保持一致
+✅ **全局类型定义**: 基础类型文件已创建在 `src/types/`
+
+### 可用类型定义
+
+项目已创建以下基础类型定义文件:
+
+**`src/types/api.ts`** - API 相关类型
+- `ApiResponse` - 通用 API 响应结构
+- `PaginatedResponse` - 分页响应
+- `ApiError` - API 错误类型
+- `ListQueryParams` - 列表查询参数
+
+**`src/types/stock.ts`** - 股票相关类型
+- `StockInfo` - 股票基础信息
+- `StockQuote` - 股票行情数据
+- `KLineData` - K 线数据
+- `StockFinancials` - 财务指标
+- `StockPosition` - 股票持仓
+- `Sector` - 概念/行业板块
+
+**`src/types/user.ts`** - 用户相关类型
+- `UserInfo` - 用户基础信息
+- `AuthInfo` - 认证信息
+- `LoginParams` / `RegisterParams` - 登录/注册参数
+- `UserSubscription` - 订阅信息
+- `UserAccount` - 资金账户
+- `UserSettings` - 用户设置
+
+**使用方式**:
+```typescript
+// 统一导入
+import type { StockQuote, UserInfo, ApiResponse } from '@/types';
+
+// 或从具体文件导入
+import type { StockQuote } from '@/types/stock';
+```
+
+### TypeScript 命令
+
+```bash
+# 类型检查(不生成输出文件)
+npm run type-check
+
+# 类型检查 + 监听模式
+npm run type-check:watch
+
+# ESLint 检查(包含 TS 文件)
+npm run lint:check
+
+# ESLint 自动修复
+npm run lint:fix
+```
+
+### 迁移路线图
+
+详细的迁移指南请参考 **[TYPESCRIPT_MIGRATION.md](./TYPESCRIPT_MIGRATION.md)** 文档。
+
+**简要路线图**:
+
+1. **优先级 1️⃣**: 工具层(`src/utils/`, `src/constants/`)
+ - 纯函数,迁移成本低,收益高
+ - 提供类型定义给其他模块使用
+
+2. **优先级 2️⃣**: 类型定义层(扩展 `src/types/`)
+ - 添加 `trading.ts`, `community.ts`, `chart.ts` 等
+
+3. **优先级 3️⃣**: 服务层(`src/services/`)
+ - 定义 API 请求/响应类型
+ - 使用 `ApiResponse` 包装响应
+
+4. **优先级 4️⃣**: Redux 状态层(`src/store/slices/`)
+ - 定义 `RootState` 和 `AppDispatch` 类型
+ - 创建类型化的 hooks
+
+5. **优先级 5️⃣**: 自定义 Hooks(`src/hooks/`)
+ - 添加泛型支持
+ - 定义完整返回值类型
+
+6. **优先级 6️⃣**: 组件层(`src/components/`, `src/views/`)
+ - Atoms → Molecules → Organisms → Pages
+ - 优先迁移复用度高的组件
+
+### 开发规范
+
+**新代码**:
+- ✅ **必须使用 TypeScript**(`.ts` 或 `.tsx`)
+- ✅ 所有函数参数和返回值添加类型
+- ✅ 组件 Props 使用 `interface` 定义
+- ✅ 避免使用 `any`(特殊情况需添加注释说明)
+
+**旧代码迁移**:
+- 按优先级迁移,不强制一次性完成
+- 迁移前先阅读 [TYPESCRIPT_MIGRATION.md](./TYPESCRIPT_MIGRATION.md)
+- 迁移后运行 `npm run type-check` 验证
+
+**类型定义**:
+- 公共类型定义导出到 `src/types/`
+- 组件内部类型可定义在组件文件中
+- 使用 `type` 还是 `interface` 参考 [迁移指南](./TYPESCRIPT_MIGRATION.md)
+
+### 常见问题
+
+**Q: 路径别名 `@/types` 无法识别?**
+A: 确保在 `tsconfig.json` 中配置了 `paths`,并重启 IDE。使用 `npm run type-check` 而非命令行 `tsc`。
+
+**Q: 如何处理第三方库没有类型定义?**
+A:
+1. 尝试安装 `@types/library-name`
+2. 创建自定义类型声明文件 `src/types/library-name.d.ts`
+3. 临时使用 `as any`(需添加 TODO 注释)
+
+**Q: 迁移期间如何处理 any 类型?**
+A: 添加 ESLint 禁用注释和 TODO 说明:
+```typescript
+/* eslint-disable @typescript-eslint/no-explicit-any */
+// TODO: 待完善类型定义
+const legacyFunction = (data: any): any => { ... };
+```
+
+**Q: React 组件的 children 类型如何定义?**
+A: 使用 `React.ReactNode`:
+```typescript
+interface Props {
+ children: React.ReactNode;
+}
+```
+
+### 参考资源
+
+- [TYPESCRIPT_MIGRATION.md](./TYPESCRIPT_MIGRATION.md) - 完整迁移指南
+- [TypeScript 官方文档](https://www.typescriptlang.org/docs/)
+- [React TypeScript Cheatsheet](https://react-typescript-cheatsheet.netlify.app/)
+- [Redux Toolkit TypeScript 指南](https://redux-toolkit.js.org/usage/usage-with-typescript)
+
+---
+
## 更新本文档
本 CLAUDE.md 文件是一个持续更新的文档。在以下情况下应更新它:
diff --git a/craco.config.js b/craco.config.js
index ea82db97..946850d5 100644
--- a/craco.config.js
+++ b/craco.config.js
@@ -133,8 +133,8 @@ module.exports = {
'@variables': path.resolve(__dirname, 'src/variables'),
'@views': path.resolve(__dirname, 'src/views'),
},
- // 减少文件扩展名搜索
- extensions: ['.js', '.jsx', '.json'],
+ // 减少文件扩展名搜索(优先 TypeScript)
+ extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
// 优化模块查找路径
modules: [
path.resolve(__dirname, 'src'),
diff --git a/jsconfig.json b/jsconfig.json
deleted file mode 100755
index 7e20b6c2..00000000
--- a/jsconfig.json
+++ /dev/null
@@ -1,27 +0,0 @@
-{
- "compilerOptions": {
- "baseUrl": "src",
- "paths": {
- "@/*": ["./*"],
- "@assets/*": ["assets/*"],
- "@components/*": ["components/*"],
- "@constants/*": ["constants/*"],
- "@contexts/*": ["contexts/*"],
- "@data/*": ["data/*"],
- "@hooks/*": ["hooks/*"],
- "@layouts/*": ["layouts/*"],
- "@lib/*": ["lib/*"],
- "@mocks/*": ["mocks/*"],
- "@providers/*": ["providers/*"],
- "@routes/*": ["routes/*"],
- "@services/*": ["services/*"],
- "@store/*": ["store/*"],
- "@styles/*": ["styles/*"],
- "@theme/*": ["theme/*"],
- "@utils/*": ["utils/*"],
- "@variables/*": ["variables/*"],
- "@views/*": ["views/*"]
- }
- },
- "exclude": ["node_modules", "build", "dist"]
-}
diff --git a/package.json b/package.json
index 4e18f16a..d9aaacee 100755
--- a/package.json
+++ b/package.json
@@ -106,13 +106,20 @@
"deploy": "bash scripts/deploy-from-local.sh",
"deploy:setup": "bash scripts/setup-deployment.sh",
"rollback": "bash scripts/rollback-from-local.sh",
- "lint:check": "eslint . --ext=js,jsx; exit 0",
- "lint:fix": "eslint . --ext=js,jsx --fix; exit 0",
+ "lint:check": "eslint . --ext=js,jsx,ts,tsx; exit 0",
+ "lint:fix": "eslint . --ext=js,jsx,ts,tsx --fix; exit 0",
+ "type-check": "tsc --noEmit",
+ "type-check:watch": "tsc --noEmit --watch",
"clean": "rm -rf node_modules/ package-lock.json",
"reinstall": "npm run clean && npm install"
},
"devDependencies": {
"@craco/craco": "^7.1.0",
+ "@types/node": "^20.19.25",
+ "@types/react": "^18.2.0",
+ "@types/react-dom": "^18.2.0",
+ "@typescript-eslint/eslint-plugin": "^8.46.4",
+ "@typescript-eslint/parser": "^8.46.4",
"ajv": "^8.17.1",
"autoprefixer": "^10.4.21",
"concurrently": "^8.2.2",
@@ -131,6 +138,7 @@
"react-error-overlay": "6.0.9",
"sharp": "^0.34.4",
"ts-node": "^10.9.2",
+ "typescript": "^5.9.3",
"webpack-bundle-analyzer": "^4.10.2",
"yn": "^5.1.0"
},
diff --git a/src/types/api.ts b/src/types/api.ts
new file mode 100644
index 00000000..f1cc8b3f
--- /dev/null
+++ b/src/types/api.ts
@@ -0,0 +1,68 @@
+/**
+ * API 相关类型定义
+ *
+ * 包含通用的 API 请求/响应类型、错误类型等
+ */
+
+/**
+ * API 响应基础结构
+ */
+export interface ApiResponse {
+ code: number;
+ message: string;
+ data: T;
+}
+
+/**
+ * API 错误类型
+ */
+export interface ApiError {
+ code: number;
+ message: string;
+ details?: any;
+}
+
+/**
+ * 分页请求参数
+ */
+export interface PaginationParams {
+ page: number;
+ pageSize: number;
+ total?: number;
+}
+
+/**
+ * 分页响应数据
+ */
+export interface PaginatedResponse {
+ list: T[];
+ pagination: {
+ current: number;
+ pageSize: number;
+ total: number;
+ totalPages: number;
+ };
+}
+
+/**
+ * 排序参数
+ */
+export interface SortParams {
+ field: string;
+ order: 'asc' | 'desc';
+}
+
+/**
+ * 过滤参数
+ */
+export interface FilterParams {
+ [key: string]: any;
+}
+
+/**
+ * 通用列表查询参数
+ */
+export interface ListQueryParams extends Partial {
+ sort?: SortParams;
+ filters?: FilterParams;
+}
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 00000000..7907e403
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,40 @@
+/**
+ * 全局类型定义汇总导出
+ *
+ * 使用方式:
+ * import { StockQuote, UserInfo, ApiResponse } from '@/types';
+ */
+
+// API 相关类型
+export type {
+ ApiResponse,
+ ApiError,
+ PaginationParams,
+ PaginatedResponse,
+ SortParams,
+ FilterParams,
+ ListQueryParams,
+} from './api';
+
+// 股票相关类型
+export type {
+ StockInfo,
+ StockQuote,
+ KLineData,
+ StockFinancials,
+ StockPosition,
+ Sector,
+ StockSearchResult,
+} from './stock';
+
+// 用户相关类型
+export type {
+ UserInfo,
+ AuthInfo,
+ LoginParams,
+ RegisterParams,
+ UserPermissions,
+ UserSubscription,
+ UserAccount,
+ UserSettings,
+} from './user';
diff --git a/src/types/stock.ts b/src/types/stock.ts
new file mode 100644
index 00000000..b6c19e51
--- /dev/null
+++ b/src/types/stock.ts
@@ -0,0 +1,103 @@
+/**
+ * 股票相关类型定义
+ *
+ * 包含股票数据、行情、指标等类型
+ */
+
+/**
+ * 股票基础信息
+ */
+export interface StockInfo {
+ code: string; // 股票代码(如 '600000.SH')
+ name: string; // 股票名称
+ market: 'SH' | 'SZ' | 'BJ'; // 市场(上海/深圳/北京)
+ industry?: string; // 所属行业
+ concept?: string[]; // 所属概念
+ listingDate?: string; // 上市日期
+}
+
+/**
+ * 股票行情数据
+ */
+export interface StockQuote {
+ code: string; // 股票代码
+ name: string; // 股票名称
+ current: number; // 当前价格
+ open: number; // 开盘价
+ close: number; // 收盘价(前一交易日)
+ high: number; // 最高价
+ low: number; // 最低价
+ volume: number; // 成交量
+ amount: number; // 成交额
+ change: number; // 涨跌额
+ changePercent: number; // 涨跌幅(百分比)
+ turnoverRate?: number; // 换手率
+ timestamp: number; // 时间戳
+}
+
+/**
+ * K 线数据(日线/分钟线)
+ */
+export interface KLineData {
+ date: string; // 日期(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)
+ open: number; // 开盘价
+ high: number; // 最高价
+ low: number; // 最低价
+ close: number; // 收盘价
+ volume: number; // 成交量
+ amount?: number; // 成交额
+}
+
+/**
+ * 股票财务指标
+ */
+export interface StockFinancials {
+ code: string; // 股票代码
+ reportDate: string; // 报告期
+ revenue: number; // 营业收入
+ netProfit: number; // 净利润
+ eps: number; // 每股收益(EPS)
+ roe: number; // 净资产收益率(ROE)
+ pe: number; // 市盈率(PE)
+ pb: number; // 市净率(PB)
+ grossProfitMargin?: number; // 毛利率
+ debtToAssetRatio?: number; // 资产负债率
+}
+
+/**
+ * 股票持仓
+ */
+export interface StockPosition {
+ code: string; // 股票代码
+ name: string; // 股票名称
+ quantity: number; // 持仓数量
+ availableQuantity: number; // 可用数量(T+1)
+ costPrice: number; // 成本价
+ currentPrice: number; // 当前价
+ marketValue: number; // 市值
+ profit: number; // 盈亏金额
+ profitPercent: number; // 盈亏比例
+}
+
+/**
+ * 概念/行业板块
+ */
+export interface Sector {
+ id: string; // 板块 ID
+ name: string; // 板块名称
+ type: 'industry' | 'concept'; // 类型(行业/概念)
+ changePercent: number; // 板块涨跌幅
+ leadingStock?: string; // 领涨股
+ stockCount?: number; // 成分股数量
+ description?: string; // 描述
+}
+
+/**
+ * 股票搜索结果
+ */
+export interface StockSearchResult {
+ code: string;
+ name: string;
+ market: string;
+ industry?: string;
+}
diff --git a/src/types/user.ts b/src/types/user.ts
new file mode 100644
index 00000000..f13dd64d
--- /dev/null
+++ b/src/types/user.ts
@@ -0,0 +1,108 @@
+/**
+ * 用户相关类型定义
+ *
+ * 包含用户信息、认证、权限等类型
+ */
+
+/**
+ * 用户基础信息
+ */
+export interface UserInfo {
+ id: string | number; // 用户 ID
+ username: string; // 用户名
+ email?: string; // 邮箱
+ phone?: string; // 手机号
+ avatar?: string; // 头像 URL
+ nickname?: string; // 昵称
+ realName?: string; // 真实姓名
+ gender?: 'male' | 'female' | 'other'; // 性别
+ birthday?: string; // 生日
+ createdAt?: string; // 注册时间
+ updatedAt?: string; // 更新时间
+}
+
+/**
+ * 用户认证信息
+ */
+export interface AuthInfo {
+ token: string; // 访问令牌
+ refreshToken?: string; // 刷新令牌
+ expiresIn?: number; // 过期时间(秒)
+ tokenType?: string; // 令牌类型(如 'Bearer')
+}
+
+/**
+ * 登录请求参数
+ */
+export interface LoginParams {
+ username: string; // 用户名或手机号
+ password: string; // 密码
+ captcha?: string; // 验证码
+ remember?: boolean; // 记住登录状态
+}
+
+/**
+ * 注册请求参数
+ */
+export interface RegisterParams {
+ username: string; // 用户名
+ password: string; // 密码
+ confirmPassword?: string; // 确认密码
+ phone?: string; // 手机号
+ email?: string; // 邮箱
+ verificationCode?: string; // 短信验证码
+ agreeTerm?: boolean; // 是否同意条款
+}
+
+/**
+ * 用户权限
+ */
+export interface UserPermissions {
+ roles: string[]; // 角色列表
+ permissions: string[]; // 权限列表
+}
+
+/**
+ * 用户订阅信息
+ */
+export interface UserSubscription {
+ userId: string | number; // 用户 ID
+ plan: 'free' | 'basic' | 'pro' | 'enterprise'; // 订阅套餐
+ status: 'active' | 'expired' | 'cancelled'; // 状态
+ startDate: string; // 开始日期
+ endDate: string; // 结束日期
+ autoRenew?: boolean; // 是否自动续费
+ features?: string[]; // 可用功能列表
+}
+
+/**
+ * 用户资金账户
+ */
+export interface UserAccount {
+ userId: string | number; // 用户 ID
+ balance: number; // 可用余额
+ frozenBalance: number; // 冻结资金
+ totalAssets: number; // 总资产
+ marketValue: number; // 持仓市值
+ profit: number; // 盈亏金额
+ profitPercent: number; // 盈亏比例
+ currency: string; // 币种(默认 'CNY')
+}
+
+/**
+ * 用户设置
+ */
+export interface UserSettings {
+ userId: string | number; // 用户 ID
+ theme?: 'light' | 'dark'; // 主题
+ language?: 'zh-CN' | 'en-US'; // 语言
+ notifications?: {
+ email: boolean; // 邮件通知
+ sms: boolean; // 短信通知
+ push: boolean; // 推送通知
+ };
+ privacy?: {
+ showProfile: boolean; // 显示个人资料
+ showPositions: boolean; // 显示持仓
+ };
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 00000000..81e69849
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,81 @@
+{
+ "compilerOptions": {
+ /* 基础配置 */
+ "target": "ES2020", // 编译目标(支持现代浏览器)
+ "lib": ["DOM", "DOM.Iterable", "ES2020"], // 类型库
+ "jsx": "react-jsx", // JSX 编译模式(React 17+ 新 JSX 转换)
+ "module": "ESNext", // 模块系统
+ "moduleResolution": "node", // 模块解析策略
+
+ /* 类型检查 - 推荐模式(渐进式严格化)*/
+ "strict": false, // 关闭全部严格检查(初期)
+ "noImplicitAny": true, // 禁止隐式 any(推荐启用)
+ "strictNullChecks": false, // 暂不启用空值检查(后续开启)
+ "strictFunctionTypes": false, // 暂不启用函数类型严格检查
+ "strictBindCallApply": false, // 暂不启用 bind/call/apply 严格检查
+ "strictPropertyInitialization": false, // 暂不启用属性初始化检查
+ "noImplicitThis": true, // 禁止隐式 this
+ "alwaysStrict": true, // 总是以严格模式解析
+
+ /* 额外检查 */
+ "noUnusedLocals": false, // 初期不检查未使用的局部变量
+ "noUnusedParameters": false, // 初期不检查未使用的参数
+ "noImplicitReturns": false, // 初期不强制所有路径都返回值
+ "noFallthroughCasesInSwitch": true, // 检查 switch 语句穿透
+
+ /* 互操作性 - 支持 JS/TS 共存 */
+ "allowJs": true, // 允许编译 JS 文件
+ "checkJs": false, // 不检查 JS 文件(避免大量错误)
+ "isolatedModules": true, // 确保每个文件都可以独立编译
+ "esModuleInterop": true, // ES 模块互操作性
+ "allowSyntheticDefaultImports": true, // 允许默认导入
+ "skipLibCheck": true, // 跳过库文件类型检查(加快编译)
+ "forceConsistentCasingInFileNames": true, // 强制文件名大小写一致
+ "resolveJsonModule": true, // 支持导入 JSON 文件
+
+ /* 输出配置 */
+ "noEmit": true, // 不生成输出文件(由 Babel/Webpack 处理)
+ "declaration": false, // 不生成类型声明文件
+ "sourceMap": false, // 不生成 source map(由 Webpack 处理)
+
+ /* 路径别名 - 与 craco.config.js 保持一致 */
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"],
+ "@assets/*": ["src/assets/*"],
+ "@components/*": ["src/components/*"],
+ "@constants/*": ["src/constants/*"],
+ "@contexts/*": ["src/contexts/*"],
+ "@data/*": ["src/data/*"],
+ "@hooks/*": ["src/hooks/*"],
+ "@layouts/*": ["src/layouts/*"],
+ "@lib/*": ["src/lib/*"],
+ "@mocks/*": ["src/mocks/*"],
+ "@providers/*": ["src/providers/*"],
+ "@routes/*": ["src/routes/*"],
+ "@services/*": ["src/services/*"],
+ "@store/*": ["src/store/*"],
+ "@styles/*": ["src/styles/*"],
+ "@theme/*": ["src/theme/*"],
+ "@utils/*": ["src/utils/*"],
+ "@variables/*": ["src/variables/*"],
+ "@views/*": ["src/views/*"]
+ }
+ },
+
+ /* 包含/排除文件 */
+ "include": [
+ "src/**/*", // 包含 src 目录下所有 .ts, .tsx, .js, .jsx 文件
+ "src/**/*.json" // 包含 JSON 文件
+ ],
+ "exclude": [
+ "node_modules", // 排除依赖
+ "build", // 排除构建输出
+ "dist", // 排除打包输出
+ "scripts", // 排除脚本
+ "**/*.spec.ts", // 排除测试文件(可选)
+ "**/*.test.ts",
+ "**/*.spec.tsx",
+ "**/*.test.tsx"
+ ]
+}
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 3/4] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E8=AF=84?=
=?UTF-8?q?=E8=AE=BA=E5=88=86=E9=A1=B5=E5=8A=9F=E8=83=BD=E5=B9=B6=E8=BF=81?=
=?UTF-8?q?=E7=A7=BB=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>;
+}
From ddd6b2d4affce266b5af8dfb5bfba597d8744f8a Mon Sep 17 00:00:00 2001
From: zdl <3489966805@qq.com>
Date: Fri, 14 Nov 2025 19:04:00 +0800
Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20Socket=20?=
=?UTF-8?q?=E8=A7=A6=E5=8F=91=E7=9A=84=E6=99=BA=E8=83=BD=E5=88=97=E8=A1=A8?=
=?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=88=B7=E6=96=B0=E5=8A=9F=E8=83=BD=EF=BC=88?=
=?UTF-8?q?=E5=B8=A6=E9=98=B2=E6=8A=96=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
核心改动:
- 扩展 NotificationContext,添加事件更新回调注册机制
- VirtualizedFourRowGrid 添加 forwardRef 暴露 getScrollPosition 方法
- DynamicNewsCard 实现智能刷新逻辑(根据模式和滚动位置判断是否刷新)
- Community 页面注册 Socket 回调自动触发刷新
- 创建 TypeScript 通用防抖工具函数(debounce.ts)
- 集成防抖机制(2秒延迟),避免短时间内频繁请求
智能刷新策略:
- 纵向模式 + 第1页:自动刷新列表
- 纵向模式 + 其他页:不刷新(避免打断用户)
- 平铺模式 + 滚动在顶部:自动刷新列表
- 平铺模式 + 滚动不在顶部:仅显示 Toast 提示
防抖效果:
- 短时间内收到多个新事件,只执行最后一次刷新
- 减少服务器压力,提升用户体验
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
src/contexts/NotificationContext.js | 52 ++++++++
src/utils/debounce.ts | 90 +++++++++++++
.../Community/components/DynamicNewsCard.js | 125 +++++++++++++++++-
.../DynamicNewsCard/EventScrollList.js | 5 +-
.../DynamicNewsCard/VirtualizedFourRowGrid.js | 35 ++++-
.../components/DynamicNewsCard/constants.js | 18 +++
src/views/Community/index.js | 66 ++++++++-
7 files changed, 382 insertions(+), 9 deletions(-)
create mode 100644 src/utils/debounce.ts
diff --git a/src/contexts/NotificationContext.js b/src/contexts/NotificationContext.js
index 8254a29d..07e2ccc2 100644
--- a/src/contexts/NotificationContext.js
+++ b/src/contexts/NotificationContext.js
@@ -64,6 +64,9 @@ export const NotificationProvider = ({ children }) => {
const adaptEventToNotificationRef = useRef(null);
const isFirstConnect = useRef(true); // 标记是否首次连接
+ // ⚡ 事件更新回调列表(用于在收到 new_event 时通知其他组件刷新数据)
+ const eventUpdateCallbacks = useRef(new Set());
+
// ⚡ 使用权限引导管理 Hook
const { shouldShowGuide, markGuideAsShown } = usePermissionGuide();
@@ -160,6 +163,37 @@ export const NotificationProvider = ({ children }) => {
});
}, []);
+ /**
+ * 注册事件更新回调(用于在收到新事件时通知其他组件刷新)
+ * @param {Function} callback - 回调函数,接收 eventData 参数
+ * @returns {Function} 取消注册函数
+ */
+ const registerEventUpdateCallback = useCallback((callback) => {
+ eventUpdateCallbacks.current.add(callback);
+ logger.info('NotificationContext', 'Event update callback registered', {
+ totalCallbacks: eventUpdateCallbacks.current.size
+ });
+
+ // 返回取消注册函数
+ return () => {
+ eventUpdateCallbacks.current.delete(callback);
+ logger.info('NotificationContext', 'Event update callback unregistered', {
+ totalCallbacks: eventUpdateCallbacks.current.size
+ });
+ };
+ }, []);
+
+ /**
+ * 取消注册事件更新回调(已废弃,建议使用 registerEventUpdateCallback 返回的函数)
+ * @param {Function} callback - 要取消的回调函数
+ */
+ const unregisterEventUpdateCallback = useCallback((callback) => {
+ eventUpdateCallbacks.current.delete(callback);
+ logger.info('NotificationContext', 'Event update callback unregistered (manual)', {
+ totalCallbacks: eventUpdateCallbacks.current.size
+ });
+ }, []);
+
/**
* 请求浏览器通知权限
*/
@@ -764,6 +798,21 @@ export const NotificationProvider = ({ children }) => {
console.log('[NotificationContext] 准备添加通知到队列...');
addNotificationRef.current(notification);
console.log('[NotificationContext] ✅ 通知已添加到队列');
+
+ // ⚡ 调用所有注册的事件更新回调(用于通知其他组件刷新数据)
+ if (eventUpdateCallbacks.current.size > 0) {
+ console.log(`[NotificationContext] 🔔 触发 ${eventUpdateCallbacks.current.size} 个事件更新回调...`);
+ eventUpdateCallbacks.current.forEach(callback => {
+ try {
+ callback(data);
+ } catch (error) {
+ logger.error('NotificationContext', 'Event update callback error', error);
+ console.error('[NotificationContext] ❌ 事件更新回调执行失败:', error);
+ }
+ });
+ console.log('[NotificationContext] ✅ 所有事件更新回调已触发');
+ }
+
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
});
@@ -1040,6 +1089,9 @@ export const NotificationProvider = ({ children }) => {
showWelcomeGuide,
showCommunityGuide,
showFirstFollowGuide,
+ // ⚡ 新增:事件更新回调注册方法
+ registerEventUpdateCallback,
+ unregisterEventUpdateCallback,
};
return (
diff --git a/src/utils/debounce.ts b/src/utils/debounce.ts
new file mode 100644
index 00000000..17d44386
--- /dev/null
+++ b/src/utils/debounce.ts
@@ -0,0 +1,90 @@
+// src/utils/debounce.ts
+// 防抖工具函数(TypeScript 版本)
+
+/**
+ * 防抖函数返回类型
+ * @template T - 原函数类型
+ */
+export interface DebouncedFunction any> {
+ /**
+ * 执行防抖后的函数
+ * @param args - 原函数的参数
+ */
+ (...args: Parameters): void;
+
+ /**
+ * 取消待执行的函数调用
+ */
+ cancel: () => void;
+}
+
+/**
+ * 防抖函数 - 延迟执行,短时间内多次调用只执行最后一次
+ *
+ * 工作原理:
+ * 1. 调用防抖函数时,清除之前的定时器
+ * 2. 设置新的定时器,延迟 delay 毫秒后执行
+ * 3. 如果在延迟期间再次调用,重复步骤 1-2
+ * 4. 只有最后一次调用会在延迟后实际执行
+ *
+ * 使用场景:
+ * - 搜索框输入:用户停止输入后才发送请求
+ * - 窗口 resize:窗口调整结束后才重新计算布局
+ * - Socket 事件:短时间内收到多个事件,只处理最后一个
+ *
+ * @template T - 函数类型(泛型约束:任意函数)
+ * @param {T} func - 要防抖的函数
+ * @param {number} delay - 延迟时间(毫秒)
+ * @returns {DebouncedFunction} 防抖后的函数(带 cancel 方法)
+ *
+ * @example
+ * ```typescript
+ * // 示例 1:无参数函数
+ * const debouncedSave = debounce(() => {
+ * console.log('保存数据');
+ * }, 1000);
+ *
+ * debouncedSave(); // 1秒后执行
+ * debouncedSave(); // 取消上次,重新计时 1 秒
+ * debouncedSave.cancel(); // 取消执行
+ *
+ * // 示例 2:带参数函数
+ * const debouncedSearch = debounce((keyword: string) => {
+ * console.log('搜索:', keyword);
+ * }, 500);
+ *
+ * debouncedSearch('react'); // 500ms 后执行
+ * debouncedSearch('redux'); // 取消上次,重新计时
+ * ```
+ */
+export function debounce any>(
+ func: T,
+ delay: number
+): DebouncedFunction {
+ // 使用 NodeJS.Timeout 类型(支持浏览器和 Node 环境)
+ let timerId: ReturnType | null = null;
+
+ // 防抖函数主体
+ const debouncedFn = (...args: Parameters): void => {
+ // 清除之前的定时器(防抖核心逻辑)
+ if (timerId !== null) {
+ clearTimeout(timerId);
+ }
+
+ // 设置新的定时器
+ timerId = setTimeout(() => {
+ func(...args);
+ timerId = null; // 执行后重置定时器 ID
+ }, delay);
+ };
+
+ // 添加 cancel 方法(用于组件卸载时清理)
+ debouncedFn.cancel = (): void => {
+ if (timerId !== null) {
+ clearTimeout(timerId);
+ timerId = null;
+ }
+ };
+
+ return debouncedFn;
+}
diff --git a/src/views/Community/components/DynamicNewsCard.js b/src/views/Community/components/DynamicNewsCard.js
index a114931a..aefd5f32 100644
--- a/src/views/Community/components/DynamicNewsCard.js
+++ b/src/views/Community/components/DynamicNewsCard.js
@@ -1,7 +1,7 @@
// src/views/Community/components/DynamicNewsCard.js
// 横向滚动事件卡片组件(实时要闻·动态追踪)
-import React, { forwardRef, useState, useEffect, useMemo, useCallback, useRef } from 'react';
+import React, { forwardRef, useState, useEffect, useMemo, useCallback, useRef, useImperativeHandle } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
Card,
@@ -44,8 +44,9 @@ import {
selectFourRowEventsWithLoading
} from '../../../store/slices/communityDataSlice';
import { usePagination } from './DynamicNewsCard/hooks/usePagination';
-import { PAGINATION_CONFIG, DISPLAY_MODES } from './DynamicNewsCard/constants';
+import { PAGINATION_CONFIG, DISPLAY_MODES, REFRESH_DEBOUNCE_DELAY } from './DynamicNewsCard/constants';
import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme';
+import { debounce } from '../../../utils/debounce';
// 🔍 调试:渲染计数器
let dynamicNewsCardRenderCount = 0;
@@ -84,6 +85,7 @@ const DynamicNewsCard = forwardRef(({
// Refs
const cardHeaderRef = useRef(null);
const cardBodyRef = useRef(null);
+ const virtualizedGridRef = useRef(null); // ⚡ VirtualizedFourRowGrid 的 ref(用于获取滚动位置)
// 从 Redux 读取关注状态
const eventFollowStatus = useSelector(selectEventFollowStatus);
@@ -208,6 +210,124 @@ const [currentMode, setCurrentMode] = useState('vertical');
setCurrentMode(mode);
}, [mode]);
+ /**
+ * ⚡【核心逻辑】执行刷新的回调函数(包含原有的智能刷新逻辑)
+ *
+ * 此函数会被 debounce 包装,避免短时间内频繁刷新
+ */
+ const executeRefresh = useCallback(() => {
+ const state = {
+ mode,
+ currentPage: pagination?.current_page || 1,
+ };
+
+ console.log('[DynamicNewsCard] ⏰ executeRefresh() 执行(防抖延迟后)', state);
+
+ if (mode === 'vertical') {
+ // ========== 纵向模式 ==========
+ // 只在第1页时刷新,避免打断用户浏览其他页
+ if (state.currentPage === 1) {
+ console.log('[DynamicNewsCard] 纵向模式 + 第1页 → 刷新列表');
+ handlePageChange(1); // 清空缓存并刷新第1页
+ toast({
+ title: '检测到新事件',
+ status: 'info',
+ duration: 2000,
+ isClosable: true,
+ });
+ } else {
+ console.log(`[DynamicNewsCard] 纵向模式 + 第${state.currentPage}页 → 不刷新(避免打断用户)`);
+ }
+ } else if (mode === 'four-row') {
+ // ========== 平铺模式 ==========
+ // 检查滚动位置,只有在顶部时才刷新
+ const scrollPos = virtualizedGridRef.current?.getScrollPosition();
+
+ if (scrollPos?.isNearTop) {
+ // 用户在顶部 10% 区域,安全刷新
+ console.log('[DynamicNewsCard] 平铺模式 + 滚动在顶部 → 刷新列表');
+ handlePageChange(1); // 清空并刷新
+ toast({
+ title: '检测到新事件,已刷新',
+ status: 'info',
+ duration: 2000,
+ isClosable: true,
+ });
+ } else {
+ // 用户不在顶部,显示提示但不自动刷新
+ console.log('[DynamicNewsCard] 平铺模式 + 滚动不在顶部 → 仅提示,不刷新');
+ toast({
+ title: '有新事件发布',
+ description: '滚动到顶部查看',
+ status: 'info',
+ duration: 3000,
+ isClosable: true,
+ });
+ }
+ }
+ }, [mode, pagination, handlePageChange, toast]);
+
+ /**
+ * ⚡【防抖包装】创建防抖版本的刷新函数
+ *
+ * 使用 useMemo 确保防抖函数在 executeRefresh 不变时保持引用稳定
+ * 防抖延迟:REFRESH_DEBOUNCE_DELAY (2000ms)
+ *
+ * 效果:短时间内收到多个新事件,只执行最后一次刷新
+ */
+ const debouncedRefresh = useMemo(
+ () => debounce(executeRefresh, REFRESH_DEBOUNCE_DELAY),
+ [executeRefresh]
+ );
+
+ /**
+ * ⚡ 暴露方法给父组件(用于 Socket 自动刷新)
+ */
+ useImperativeHandle(ref, () => ({
+ /**
+ * 智能刷新方法(带防抖,避免频繁刷新)
+ *
+ * 调用此方法时:
+ * 1. 清除之前的定时器(如果有)
+ * 2. 设置新的定时器(延迟 REFRESH_DEBOUNCE_DELAY 后执行)
+ * 3. 如果在延迟期间再次调用,重复步骤 1-2
+ * 4. 只有最后一次调用会在延迟后实际执行 executeRefresh()
+ */
+ refresh: () => {
+ console.log('[DynamicNewsCard] 🔔 refresh() 被调用(设置防抖定时器)', {
+ mode,
+ currentPage: pagination?.current_page || 1,
+ debounceDelay: `${REFRESH_DEBOUNCE_DELAY}ms`,
+ });
+
+ // 调用防抖包装后的函数
+ debouncedRefresh();
+ },
+
+ /**
+ * 获取当前状态(用于调试)
+ */
+ getState: () => ({
+ mode,
+ currentPage: pagination?.current_page || 1,
+ totalPages: pagination?.total_pages || 1,
+ total: pagination?.total || 0,
+ loading,
+ }),
+ }), [mode, pagination, loading, debouncedRefresh]);
+
+ /**
+ * ⚡【清理逻辑】组件卸载时取消待执行的防抖函数
+ *
+ * 作用:避免组件卸载后仍然执行刷新操作(防止内存泄漏和潜在错误)
+ */
+ useEffect(() => {
+ return () => {
+ console.log('[DynamicNewsCard] 🧹 组件卸载,取消待执行的防抖刷新');
+ debouncedRefresh.cancel();
+ };
+ }, [debouncedRefresh]);
+
// 监听 error 状态,显示空数据提示
useEffect(() => {
if (error && error.includes('暂无更多数据')) {
@@ -578,6 +698,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
eventFollowStatus={eventFollowStatus}
onToggleFollow={handleToggleFollow}
hasMore={hasMore}
+ virtualizedGridRef={virtualizedGridRef} // ⚡ 传递 ref 给 VirtualizedFourRowGrid
/>
diff --git a/src/views/Community/components/DynamicNewsCard/EventScrollList.js b/src/views/Community/components/DynamicNewsCard/EventScrollList.js
index a9ea89f0..2243d0f0 100644
--- a/src/views/Community/components/DynamicNewsCard/EventScrollList.js
+++ b/src/views/Community/components/DynamicNewsCard/EventScrollList.js
@@ -28,6 +28,7 @@ import VerticalModeLayout from './VerticalModeLayout';
* @param {boolean} hasMore - 是否还有更多数据
* @param {Object} eventFollowStatus - 事件关注状态 { [eventId]: { isFollowing, followerCount } }
* @param {Function} onToggleFollow - 关注按钮回调
+ * @param {React.Ref} virtualizedGridRef - VirtualizedFourRowGrid 的 ref(用于获取滚动位置)
*/
const EventScrollList = ({
events,
@@ -46,7 +47,8 @@ const EventScrollList = ({
mode = 'vertical',
hasMore = true,
eventFollowStatus = {},
- onToggleFollow
+ onToggleFollow,
+ virtualizedGridRef
}) => {
const scrollContainerRef = useRef(null);
@@ -111,6 +113,7 @@ const EventScrollList = ({
>
{/* 平铺网格模式 - 使用虚拟滚动 + 双向无限滚动 */}
{
+}, ref) => {
const parentRef = useRef(null);
const isLoadingMore = useRef(false); // 防止重复加载
const lastRefreshTime = useRef(0); // 记录上次刷新时间(用于30秒防抖)
@@ -81,6 +81,31 @@ const VirtualizedFourRowGrid = ({
overscan: 2, // 预加载2行(上下各1行)
});
+ /**
+ * ⚡ 暴露方法给父组件(用于 Socket 刷新判断)
+ */
+ useImperativeHandle(ref, () => ({
+ /**
+ * 获取当前滚动位置信息
+ * @returns {Object|null} 滚动位置信息
+ */
+ getScrollPosition: () => {
+ const scrollElement = parentRef.current;
+ if (!scrollElement) return null;
+
+ const { scrollTop, scrollHeight, clientHeight } = scrollElement;
+ const isNearTop = scrollTop < clientHeight * 0.1; // 顶部 10% 区域
+
+ return {
+ scrollTop,
+ scrollHeight,
+ clientHeight,
+ isNearTop,
+ scrollPercentage: ((scrollTop + clientHeight) / scrollHeight) * 100,
+ };
+ },
+ }), []);
+
/**
* 【核心逻辑1】无限滚动 + 顶部刷新 - 监听滚动事件,根据滚动位置自动加载数据或刷新
*
@@ -360,6 +385,8 @@ const VirtualizedFourRowGrid = ({
);
-};
+});
+
+VirtualizedFourRowGrid.displayName = 'VirtualizedFourRowGrid';
export default VirtualizedFourRowGrid;
diff --git a/src/views/Community/components/DynamicNewsCard/constants.js b/src/views/Community/components/DynamicNewsCard/constants.js
index 85b3ce53..19ea7300 100644
--- a/src/views/Community/components/DynamicNewsCard/constants.js
+++ b/src/views/Community/components/DynamicNewsCard/constants.js
@@ -38,3 +38,21 @@ export const TOAST_CONFIG = {
DURATION_ERROR: 3000, // 错误提示持续时间(毫秒)
DURATION_WARNING: 2000, // 警告提示持续时间(毫秒)
};
+
+// ========== Socket 刷新防抖配置 ==========
+/**
+ * Socket 新事件刷新防抖延迟(毫秒)
+ *
+ * 作用:避免短时间内收到多个新事件时频繁刷新列表
+ *
+ * 场景示例:
+ * - 第 1 秒:收到新事件 → 延迟 2 秒刷新
+ * - 第 2 秒:收到新事件 → 取消上次,重新延迟 2 秒
+ * - 第 3 秒:收到新事件 → 取消上次,重新延迟 2 秒
+ * - 第 5 秒:触发刷新 → 只发送 1 次 API 请求
+ *
+ * 推荐值:2000ms (2 秒)
+ * - 太短(如 500ms)→ 仍可能触发多次刷新
+ * - 太长(如 5000ms)→ 用户感知延迟过高
+ */
+export const REFRESH_DEBOUNCE_DELAY = 2000;
diff --git a/src/views/Community/index.js b/src/views/Community/index.js
index e5b71631..351f2dd3 100644
--- a/src/views/Community/index.js
+++ b/src/views/Community/index.js
@@ -1,6 +1,6 @@
// src/views/Community/index.js
import React, { useEffect, useRef, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
+import { useNavigate, useLocation } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import {
fetchPopularKeywords,
@@ -40,6 +40,7 @@ import { PROFESSIONAL_COLORS } from '../../constants/professionalTheme';
const Community = () => {
const navigate = useNavigate();
+ const location = useLocation(); // ⚡ 获取当前路由信息(用于判断是否在 /community 页面)
const dispatch = useDispatch();
// Redux状态
@@ -71,7 +72,10 @@ const Community = () => {
});
// ⚡ 通知权限引导
- const { browserPermission, requestBrowserPermission } = useNotification();
+ const { browserPermission, requestBrowserPermission, registerEventUpdateCallback } = useNotification();
+
+ // ⚡ DynamicNewsCard 的 ref(用于触发刷新)
+ const dynamicNewsCardRef = useRef(null);
// 通知横幅显示状态
const [showNotificationBanner, setShowNotificationBanner] = useState(false);
@@ -160,6 +164,63 @@ const Community = () => {
return () => clearTimeout(timer);
}, []); // 空依赖数组,只在组件挂载时执行一次
+ /**
+ * ⚡ 【核心逻辑】注册 Socket 新事件回调 - 当收到新事件时智能刷新列表
+ *
+ * 工作流程:
+ * 1. Socket 收到 'new_event' 事件 → NotificationContext 触发所有注册的回调
+ * 2. 本回调被触发 → 检查当前路由是否为 /community
+ * 3. 如果在 /community 页面 → 调用 DynamicNewsCard.refresh() 方法
+ * 4. DynamicNewsCard 根据模式和滚动位置决定是否刷新:
+ * - 纵向模式 + 第1页 → 刷新列表
+ * - 纵向模式 + 其他页 → 不刷新(避免打断用户)
+ * - 平铺模式 + 滚动在顶部 → 刷新列表
+ * - 平铺模式 + 滚动不在顶部 → 仅显示 Toast 提示
+ *
+ * 设计要点:
+ * - 使用 registerEventUpdateCallback 注册回调,返回的函数用于清理
+ * - 路由检查:只在 /community 页面触发刷新
+ * - 智能刷新:由 DynamicNewsCard 根据上下文决定刷新策略
+ * - 自动清理:组件卸载时自动注销回调
+ */
+ useEffect(() => {
+ // 定义回调函数
+ const handleNewEvent = (eventData) => {
+ console.log('[Community] 🔔 收到新事件通知', {
+ currentPath: location.pathname,
+ eventData,
+ });
+
+ // 检查是否在 /community 页面
+ if (location.pathname === '/community') {
+ console.log('[Community] ✅ 当前在事件中心页面,触发 DynamicNewsCard 刷新');
+
+ // 调用 DynamicNewsCard 的 refresh 方法(智能刷新)
+ if (dynamicNewsCardRef.current) {
+ dynamicNewsCardRef.current.refresh();
+ } else {
+ console.warn('[Community] ⚠️ DynamicNewsCard ref 不可用,无法触发刷新');
+ }
+ } else {
+ console.log('[Community] ⏭️ 当前不在事件中心页面,跳过刷新', {
+ currentPath: location.pathname,
+ });
+ }
+ };
+
+ // 注册回调(返回清理函数)
+ const unregister = registerEventUpdateCallback(handleNewEvent);
+ console.log('[Community] ✅ 已注册 Socket 事件更新回调');
+
+ // 组件卸载时清理
+ return () => {
+ if (unregister) {
+ unregister();
+ console.log('[Community] 🧹 已注销 Socket 事件更新回调');
+ }
+ };
+ }, [location.pathname, registerEventUpdateCallback]); // 依赖路由变化重新注册
+
return (
{/* 主内容区域 */}
@@ -206,6 +267,7 @@ const Community = () => {
{/* 实时要闻·动态追踪 - 横向滚动 */}