- EventDetailModal.js - 2个
- InvestmentCalendar.js - 6个
EventDetail 组件 (5个文件,54个console)
- TransmissionChainAnalysis.js - 43个 ⚠️ 最复杂
- RelatedConcepts.js - 14个
- LimitAnalyse.js - 5个 (保留2个toast)
- RelatedStocks.js - 3个 (保留4个toast)
- HistoricalEvents.js - 1个
StockChart 组件 (1个文件,4个console)
615 lines
20 KiB
JavaScript
615 lines
20 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
||
import {
|
||
Modal,
|
||
ModalOverlay,
|
||
ModalContent,
|
||
ModalHeader,
|
||
ModalBody,
|
||
ModalCloseButton,
|
||
Box,
|
||
Text,
|
||
VStack,
|
||
HStack,
|
||
Avatar,
|
||
Textarea,
|
||
Button,
|
||
Divider,
|
||
useToast,
|
||
Badge,
|
||
Flex,
|
||
IconButton,
|
||
Menu,
|
||
MenuButton,
|
||
MenuList,
|
||
MenuItem,
|
||
useColorModeValue,
|
||
Spinner,
|
||
Center,
|
||
Collapse,
|
||
Input,
|
||
} from '@chakra-ui/react';
|
||
import {
|
||
ChatIcon,
|
||
TimeIcon,
|
||
DeleteIcon,
|
||
EditIcon,
|
||
ChevronDownIcon,
|
||
TriangleDownIcon,
|
||
TriangleUpIcon,
|
||
} from '@chakra-ui/icons';
|
||
import { FaHeart, FaRegHeart, FaComment } from 'react-icons/fa';
|
||
import { format } from 'date-fns';
|
||
import { zhCN } from 'date-fns/locale';
|
||
import { eventService } from '../../../services/eventService';
|
||
import { logger } from '../../../utils/logger';
|
||
|
||
const EventDiscussionModal = ({ isOpen, onClose, eventId, eventTitle, discussionType = '事件讨论' }) => {
|
||
const [posts, setPosts] = useState([]);
|
||
const [newPostContent, setNewPostContent] = useState('');
|
||
const [newPostTitle, setNewPostTitle] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [expandedPosts, setExpandedPosts] = useState({});
|
||
const [postComments, setPostComments] = useState({});
|
||
const [replyContents, setReplyContents] = useState({});
|
||
const [loadingComments, setLoadingComments] = useState({});
|
||
|
||
const toast = useToast();
|
||
const bgColor = useColorModeValue('white', 'gray.800');
|
||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||
|
||
// 加载帖子列表
|
||
const loadPosts = async () => {
|
||
if (!eventId) return;
|
||
|
||
setLoading(true);
|
||
try {
|
||
const response = await fetch(`/api/events/${eventId}/posts?sort=latest&page=1&per_page=20`, {
|
||
method: 'GET',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include'
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (response.ok && result.success) {
|
||
setPosts(result.data || []);
|
||
logger.debug('EventDiscussionModal', '帖子列表加载成功', {
|
||
eventId,
|
||
postsCount: result.data?.length || 0
|
||
});
|
||
} else {
|
||
logger.error('EventDiscussionModal', 'loadPosts', new Error('API返回错误'), {
|
||
eventId,
|
||
status: response.status,
|
||
message: result.message
|
||
});
|
||
toast({
|
||
title: '加载帖子失败',
|
||
status: 'error',
|
||
duration: 3000,
|
||
isClosable: true,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
logger.error('EventDiscussionModal', 'loadPosts', error, { eventId });
|
||
toast({
|
||
title: '加载帖子失败',
|
||
status: 'error',
|
||
duration: 3000,
|
||
isClosable: true,
|
||
});
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 加载帖子的评论
|
||
const loadPostComments = async (postId) => {
|
||
setLoadingComments(prev => ({ ...prev, [postId]: true }));
|
||
try {
|
||
const response = await fetch(`/api/posts/${postId}/comments?sort=latest`, {
|
||
method: 'GET',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include'
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (response.ok && result.success) {
|
||
setPostComments(prev => ({ ...prev, [postId]: result.data || [] }));
|
||
logger.debug('EventDiscussionModal', '评论加载成功', {
|
||
postId,
|
||
commentsCount: result.data?.length || 0
|
||
});
|
||
}
|
||
} catch (error) {
|
||
logger.error('EventDiscussionModal', 'loadPostComments', error, { postId });
|
||
} finally {
|
||
setLoadingComments(prev => ({ ...prev, [postId]: false }));
|
||
}
|
||
};
|
||
|
||
// 切换展开/收起评论
|
||
const togglePostComments = async (postId) => {
|
||
const isExpanded = expandedPosts[postId];
|
||
if (!isExpanded) {
|
||
// 展开时加载评论
|
||
await loadPostComments(postId);
|
||
}
|
||
setExpandedPosts(prev => ({ ...prev, [postId]: !isExpanded }));
|
||
};
|
||
|
||
// 提交新帖子
|
||
const handleSubmitPost = async () => {
|
||
if (!newPostContent.trim()) return;
|
||
|
||
setSubmitting(true);
|
||
try {
|
||
const response = await fetch(`/api/events/${eventId}/posts`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify({
|
||
title: newPostTitle.trim(),
|
||
content: newPostContent.trim(),
|
||
content_type: 'text',
|
||
})
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (response.ok && result.success) {
|
||
setNewPostContent('');
|
||
setNewPostTitle('');
|
||
loadPosts();
|
||
logger.info('EventDiscussionModal', '帖子发布成功', {
|
||
eventId,
|
||
postId: result.data?.id
|
||
});
|
||
toast({
|
||
title: '帖子发布成功',
|
||
status: 'success',
|
||
duration: 2000,
|
||
isClosable: true,
|
||
});
|
||
} else {
|
||
logger.error('EventDiscussionModal', 'handleSubmitPost', new Error('API返回错误'), {
|
||
eventId,
|
||
message: result.message
|
||
});
|
||
toast({
|
||
title: result.message || '帖子发布失败',
|
||
status: 'error',
|
||
duration: 3000,
|
||
isClosable: true,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
logger.error('EventDiscussionModal', 'handleSubmitPost', error, { eventId });
|
||
toast({
|
||
title: '帖子发布失败',
|
||
status: 'error',
|
||
duration: 3000,
|
||
isClosable: true,
|
||
});
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
// 删除帖子
|
||
const handleDeletePost = async (postId) => {
|
||
if (!window.confirm('确定要删除这个帖子吗?')) return;
|
||
|
||
try {
|
||
const response = await fetch(`/api/posts/${postId}`, {
|
||
method: 'DELETE',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include'
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (response.ok && result.success) {
|
||
loadPosts();
|
||
logger.info('EventDiscussionModal', '帖子删除成功', { postId });
|
||
toast({
|
||
title: '帖子已删除',
|
||
status: 'success',
|
||
duration: 2000,
|
||
isClosable: true,
|
||
});
|
||
} else {
|
||
logger.error('EventDiscussionModal', 'handleDeletePost', new Error('API返回错误'), {
|
||
postId,
|
||
message: result.message
|
||
});
|
||
toast({
|
||
title: result.message || '删除失败',
|
||
status: 'error',
|
||
duration: 3000,
|
||
isClosable: true,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
logger.error('EventDiscussionModal', 'handleDeletePost', error, { postId });
|
||
toast({
|
||
title: '删除失败',
|
||
status: 'error',
|
||
duration: 3000,
|
||
isClosable: true,
|
||
});
|
||
}
|
||
};
|
||
|
||
// 点赞帖子
|
||
const handleLikePost = async (postId) => {
|
||
try {
|
||
const response = await fetch(`/api/posts/${postId}/like`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include'
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (response.ok && result.success) {
|
||
// 更新帖子列表中的点赞状态
|
||
setPosts(prev => prev.map(post =>
|
||
post.id === postId
|
||
? { ...post, likes_count: result.likes_count, liked: result.liked }
|
||
: post
|
||
));
|
||
logger.debug('EventDiscussionModal', '点赞操作成功', {
|
||
postId,
|
||
liked: result.liked,
|
||
likesCount: result.likes_count
|
||
});
|
||
}
|
||
} catch (error) {
|
||
logger.error('EventDiscussionModal', 'handleLikePost', error, { postId });
|
||
toast({
|
||
title: '操作失败',
|
||
status: 'error',
|
||
duration: 2000,
|
||
isClosable: true,
|
||
});
|
||
}
|
||
};
|
||
|
||
// 提交评论
|
||
const handleSubmitComment = async (postId) => {
|
||
const content = replyContents[postId];
|
||
if (!content?.trim()) return;
|
||
|
||
try {
|
||
const response = await fetch(`/api/posts/${postId}/comments`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify({
|
||
content: content.trim(),
|
||
})
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (response.ok && result.success) {
|
||
setReplyContents(prev => ({ ...prev, [postId]: '' }));
|
||
// 重新加载该帖子的评论
|
||
await loadPostComments(postId);
|
||
// 更新帖子的评论数
|
||
setPosts(prev => prev.map(post =>
|
||
post.id === postId
|
||
? { ...post, comments_count: (post.comments_count || 0) + 1 }
|
||
: post
|
||
));
|
||
logger.info('EventDiscussionModal', '评论发布成功', {
|
||
postId,
|
||
commentId: result.data?.id
|
||
});
|
||
toast({
|
||
title: '评论发布成功',
|
||
status: 'success',
|
||
duration: 2000,
|
||
isClosable: true,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
logger.error('EventDiscussionModal', 'handleSubmitComment', error, { postId });
|
||
toast({
|
||
title: '评论发布失败',
|
||
status: 'error',
|
||
duration: 3000,
|
||
isClosable: true,
|
||
});
|
||
}
|
||
};
|
||
|
||
// 删除评论
|
||
const handleDeleteComment = async (commentId, postId) => {
|
||
if (!window.confirm('确定要删除这条评论吗?')) return;
|
||
|
||
try {
|
||
const response = await fetch(`/api/comments/${commentId}`, {
|
||
method: 'DELETE',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include'
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (response.ok && result.success) {
|
||
// 重新加载该帖子的评论
|
||
await loadPostComments(postId);
|
||
// 更新帖子的评论数
|
||
setPosts(prev => prev.map(post =>
|
||
post.id === postId
|
||
? { ...post, comments_count: Math.max(0, (post.comments_count || 0) - 1) }
|
||
: post
|
||
));
|
||
logger.info('EventDiscussionModal', '评论删除成功', { commentId, postId });
|
||
toast({
|
||
title: '评论已删除',
|
||
status: 'success',
|
||
duration: 2000,
|
||
isClosable: true,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
logger.error('EventDiscussionModal', 'handleDeleteComment', error, { commentId, postId });
|
||
toast({
|
||
title: '删除失败',
|
||
status: 'error',
|
||
duration: 3000,
|
||
isClosable: true,
|
||
});
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
loadPosts();
|
||
}
|
||
}, [isOpen, eventId]);
|
||
|
||
return (
|
||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||
<ModalOverlay />
|
||
<ModalContent maxH="80vh">
|
||
<ModalHeader>
|
||
<VStack align="start" spacing={1}>
|
||
<HStack>
|
||
<ChatIcon />
|
||
<Text>{discussionType}</Text>
|
||
</HStack>
|
||
{eventTitle && (
|
||
<Text fontSize="sm" color="gray.500" fontWeight="normal">
|
||
{eventTitle}
|
||
</Text>
|
||
)}
|
||
</VStack>
|
||
</ModalHeader>
|
||
<ModalCloseButton />
|
||
|
||
<ModalBody overflowY="auto">
|
||
{/* 发布新帖子 */}
|
||
<Box mb={4}>
|
||
<Input
|
||
value={newPostTitle}
|
||
onChange={(e) => setNewPostTitle(e.target.value)}
|
||
placeholder="帖子标题(可选)"
|
||
size="sm"
|
||
mb={2}
|
||
/>
|
||
<Textarea
|
||
value={newPostContent}
|
||
onChange={(e) => setNewPostContent(e.target.value)}
|
||
placeholder="分享您的观点..."
|
||
size="sm"
|
||
resize="vertical"
|
||
minH="80px"
|
||
/>
|
||
<Flex justify="flex-end" mt={2}>
|
||
<Button
|
||
colorScheme="blue"
|
||
size="sm"
|
||
onClick={handleSubmitPost}
|
||
isLoading={submitting}
|
||
isDisabled={!newPostContent.trim()}
|
||
>
|
||
发布帖子
|
||
</Button>
|
||
</Flex>
|
||
</Box>
|
||
|
||
<Divider mb={4} />
|
||
|
||
{/* 帖子列表 */}
|
||
{loading ? (
|
||
<Center py={8}>
|
||
<Spinner size="lg" />
|
||
</Center>
|
||
) : posts.length > 0 ? (
|
||
<VStack spacing={4} align="stretch">
|
||
{posts.map((post) => (
|
||
<Box
|
||
key={post.id}
|
||
p={4}
|
||
borderWidth="1px"
|
||
borderColor={borderColor}
|
||
borderRadius="md"
|
||
transition="background 0.2s"
|
||
>
|
||
{/* 帖子头部 */}
|
||
<Flex justify="space-between" align="start" mb={3}>
|
||
<HStack align="start" spacing={3}>
|
||
<Avatar
|
||
size="sm"
|
||
name={post.user?.username || '匿名用户'}
|
||
src={post.user?.avatar_url}
|
||
/>
|
||
<VStack align="start" spacing={1} flex={1}>
|
||
<HStack>
|
||
<Text fontWeight="semibold" fontSize="sm">
|
||
{post.user?.username || '匿名用户'}
|
||
</Text>
|
||
</HStack>
|
||
<HStack fontSize="xs" color="gray.500">
|
||
<TimeIcon />
|
||
<Text>
|
||
{format(new Date(post.created_at), 'MM月dd日 HH:mm', {
|
||
locale: zhCN,
|
||
})}
|
||
</Text>
|
||
</HStack>
|
||
</VStack>
|
||
</HStack>
|
||
|
||
{/* 操作菜单 */}
|
||
<Menu>
|
||
<MenuButton
|
||
as={IconButton}
|
||
icon={<ChevronDownIcon />}
|
||
variant="ghost"
|
||
size="sm"
|
||
/>
|
||
<MenuList>
|
||
<MenuItem
|
||
icon={<DeleteIcon />}
|
||
color="red.500"
|
||
onClick={() => handleDeletePost(post.id)}
|
||
>
|
||
删除帖子
|
||
</MenuItem>
|
||
</MenuList>
|
||
</Menu>
|
||
</Flex>
|
||
|
||
{/* 帖子标题 */}
|
||
{post.title && (
|
||
<Text fontSize="md" fontWeight="bold" mb={2}>
|
||
{post.title}
|
||
</Text>
|
||
)}
|
||
|
||
{/* 帖子内容 */}
|
||
<Text fontSize="sm" whiteSpace="pre-wrap" mb={3}>
|
||
{post.content}
|
||
</Text>
|
||
|
||
{/* 帖子操作栏 */}
|
||
<HStack spacing={4} mb={3}>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
leftIcon={post.liked ? <FaHeart /> : <FaRegHeart />}
|
||
color={post.liked ? 'red.500' : 'gray.600'}
|
||
onClick={() => handleLikePost(post.id)}
|
||
>
|
||
{post.likes_count || 0}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
leftIcon={<FaComment />}
|
||
onClick={() => togglePostComments(post.id)}
|
||
rightIcon={expandedPosts[post.id] ? <TriangleUpIcon /> : <TriangleDownIcon />}
|
||
>
|
||
{post.comments_count || 0} 评论
|
||
</Button>
|
||
</HStack>
|
||
|
||
{/* 评论区 */}
|
||
<Collapse in={expandedPosts[post.id]} animateOpacity>
|
||
<Box borderTopWidth="1px" borderColor={borderColor} pt={3}>
|
||
{/* 评论输入框 */}
|
||
<HStack mb={3}>
|
||
<Textarea
|
||
size="sm"
|
||
placeholder="写下你的评论..."
|
||
value={replyContents[post.id] || ''}
|
||
onChange={(e) => setReplyContents(prev => ({ ...prev, [post.id]: e.target.value }))}
|
||
rows={2}
|
||
flex={1}
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
colorScheme="blue"
|
||
onClick={() => handleSubmitComment(post.id)}
|
||
isDisabled={!replyContents[post.id]?.trim()}
|
||
>
|
||
评论
|
||
</Button>
|
||
</HStack>
|
||
|
||
{/* 评论列表 */}
|
||
{loadingComments[post.id] ? (
|
||
<Center py={4}>
|
||
<Spinner size="sm" />
|
||
</Center>
|
||
) : (
|
||
<VStack align="stretch" spacing={2}>
|
||
{postComments[post.id]?.map((comment) => (
|
||
<Box key={comment.id} pl={4} borderLeftWidth="2px" borderColor="gray.200">
|
||
<HStack justify="space-between" mb={1}>
|
||
<HStack spacing={2}>
|
||
<Avatar size="xs" name={comment.user?.username} src={comment.user?.avatar_url} />
|
||
<Text fontSize="sm" fontWeight="medium">
|
||
{comment.user?.username || '匿名用户'}
|
||
</Text>
|
||
<Text fontSize="xs" color="gray.500">
|
||
{format(new Date(comment.created_at), 'MM-dd HH:mm')}
|
||
</Text>
|
||
</HStack>
|
||
<IconButton
|
||
size="xs"
|
||
icon={<DeleteIcon />}
|
||
variant="ghost"
|
||
onClick={() => handleDeleteComment(comment.id, post.id)}
|
||
/>
|
||
</HStack>
|
||
<Text fontSize="sm" pl={7}>
|
||
{comment.content}
|
||
</Text>
|
||
|
||
{/* 显示回复 */}
|
||
{comment.replies && comment.replies.length > 0 && (
|
||
<VStack align="stretch" mt={2} spacing={1} pl={4}>
|
||
{comment.replies.map((reply) => (
|
||
<Box key={reply.id}>
|
||
<HStack spacing={1}>
|
||
<Text fontSize="xs" fontWeight="medium">
|
||
{reply.user?.username}:
|
||
</Text>
|
||
<Text fontSize="xs">{reply.content}</Text>
|
||
</HStack>
|
||
</Box>
|
||
))}
|
||
</VStack>
|
||
)}
|
||
</Box>
|
||
))}
|
||
{(!postComments[post.id] || postComments[post.id].length === 0) && (
|
||
<Text fontSize="sm" color="gray.500" textAlign="center" py={2}>
|
||
暂无评论
|
||
</Text>
|
||
)}
|
||
</VStack>
|
||
)}
|
||
</Box>
|
||
</Collapse>
|
||
</Box>
|
||
))}
|
||
</VStack>
|
||
) : (
|
||
<Center py={8}>
|
||
<VStack>
|
||
<ChatIcon boxSize={8} color="gray.400" />
|
||
<Text color="gray.500">暂无帖子,快来发表您的观点吧!</Text>
|
||
</VStack>
|
||
</Center>
|
||
)}
|
||
</ModalBody>
|
||
</ModalContent>
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
export default EventDiscussionModal;
|