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] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=AF=84=E8=AE=BA?=
=?UTF-8?q?=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 || '未知标签'}