Files
vf_react/src/views/ValueForum/components/PredictionCommentSection.js
2025-11-24 08:05:19 +08:00

476 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 预测话题评论区组件
* 支持发布评论、嵌套回复、点赞、庄主标识、观点IPO投资
*/
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Avatar,
Textarea,
Button,
Flex,
IconButton,
Divider,
useToast,
Badge,
Tooltip,
} from '@chakra-ui/react';
import { motion, AnimatePresence } from 'framer-motion';
import { Heart, MessageCircle, Send, TrendingUp, Crown, Pin } from 'lucide-react';
import { forumColors } from '@theme/forumTheme';
import {
createComment,
getComments,
likeComment,
} from '@services/predictionMarketService.api';
import { useAuth } from '@contexts/AuthContext';
const MotionBox = motion(Box);
const CommentItem = ({ comment, topicId, topic, onReply, onInvest }) => {
const [isLiked, setIsLiked] = useState(false);
const [likes, setLikes] = useState(comment.likes_count || 0);
const [showReply, setShowReply] = useState(false);
// 处理点赞
const handleLike = async () => {
try {
const response = await likeComment(comment.id);
if (response.success) {
setLikes(response.likes_count);
setIsLiked(response.action === 'like');
}
} catch (error) {
console.error('点赞失败:', error);
}
};
// 格式化时间
const formatTime = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
return date.toLocaleDateString('zh-CN', {
month: '2-digit',
day: '2-digit',
});
};
// 判断是否是庄主
const isYesLord = comment.user?.id === topic?.yes_lord_id;
const isNoLord = comment.user?.id === topic?.no_lord_id;
const isLord = isYesLord || isNoLord;
return (
<MotionBox
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Flex gap={{ base: "2", sm: "3" }} py={{ base: "3", sm: "4" }}>
{/* 头像 */}
<Avatar
size={{ base: "sm", sm: "md" }}
name={comment.user?.nickname || comment.user?.username}
src={comment.user?.avatar_url}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
/>
{/* 评论内容 */}
<VStack align="stretch" flex="1" spacing={{ base: "1.5", sm: "2" }}>
{/* 用户名和时间 */}
<HStack justify="space-between" flexWrap="wrap">
<HStack spacing="2">
<Text fontSize={{ base: "sm", sm: "md" }} fontWeight="600" color={forumColors.text.primary}>
{comment.user?.nickname || comment.user?.username || '匿名用户'}
</Text>
{/* 庄主标识 */}
{isLord && (
<Tooltip label={isYesLord ? 'YES方庄主' : 'NO方庄主'} placement="top">
<Badge
bg={isYesLord ? 'green.500' : 'red.500'}
color="white"
fontSize="2xs"
px="2"
py="0.5"
borderRadius="full"
display="flex"
alignItems="center"
gap="1"
>
<Crown size={10} />
{isYesLord ? 'YES庄' : 'NO庄'}
</Badge>
</Tooltip>
)}
{/* 置顶标识 */}
{comment.is_pinned && (
<Badge
bg={forumColors.primary[500]}
color="white"
fontSize="2xs"
px="2"
py="0.5"
borderRadius="full"
display="flex"
alignItems="center"
gap="1"
>
<Pin size={10} />
置顶
</Badge>
)}
<Text fontSize={{ base: "2xs", sm: "xs" }} color={forumColors.text.muted}>
{formatTime(comment.created_at)}
</Text>
</HStack>
</HStack>
{/* 评论正文 */}
<Text fontSize={{ base: "sm", sm: "md" }} color={forumColors.text.secondary} lineHeight="1.6">
{comment.content}
</Text>
{/* 观点IPO投资统计 */}
{comment.total_investment > 0 && (
<Box
bg={forumColors.gradients.goldSubtle}
border="1px solid"
borderColor={forumColors.border.gold}
borderRadius="md"
px="3"
py="2"
mt="1"
>
<HStack spacing="4" fontSize="xs">
<HStack spacing="1">
<TrendingUp size={12} color={forumColors.primary[500]} />
<Text color={forumColors.text.secondary}>
{comment.investor_count || 0}人投资
</Text>
</HStack>
<Text color={forumColors.text.secondary}>
总投资<Text as="span" fontWeight="600" color={forumColors.primary[500]}>
{comment.total_investment}
</Text>
</Text>
{comment.is_verified && (
<Badge
colorScheme={comment.verification_result === 'correct' ? 'green' : 'red'}
fontSize="2xs"
>
{comment.verification_result === 'correct' ? '✓ 预测正确' : '✗ 预测错误'}
</Badge>
)}
</HStack>
</Box>
)}
{/* 操作按钮 */}
<HStack spacing={{ base: "3", sm: "4" }} fontSize="xs" color={forumColors.text.tertiary} flexWrap="wrap">
<HStack
spacing="1"
cursor="pointer"
onClick={handleLike}
_hover={{ color: forumColors.primary[500] }}
color={isLiked ? forumColors.primary[500] : forumColors.text.tertiary}
>
<Heart size={14} fill={isLiked ? 'currentColor' : 'none'} />
<Text>{likes > 0 ? likes : '点赞'}</Text>
</HStack>
<HStack
spacing="1"
cursor="pointer"
onClick={() => setShowReply(!showReply)}
_hover={{ color: forumColors.primary[500] }}
>
<MessageCircle size={14} />
<Text>回复</Text>
</HStack>
{/* 投资观点按钮 */}
{!comment.is_verified && onInvest && (
<Button
size="xs"
variant="ghost"
leftIcon={<TrendingUp size={14} />}
color={forumColors.primary[500]}
_hover={{ bg: forumColors.gradients.goldSubtle }}
onClick={() => onInvest(comment)}
>
投资观点
</Button>
)}
</HStack>
{/* 回复输入框 */}
{showReply && (
<MotionBox
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
mt="2"
>
<ReplyInput
topicId={topicId}
parentId={comment.id}
placeholder={`回复 @${comment.user?.nickname || comment.user?.username || '匿名用户'}`}
onSubmit={() => {
setShowReply(false);
if (onReply) onReply();
}}
/>
</MotionBox>
)}
{/* 回复列表 */}
{comment.replies && comment.replies.length > 0 && (
<VStack
align="stretch"
spacing="2"
pl={{ base: "3", sm: "4" }}
mt="2"
borderLeft="2px solid"
borderColor={forumColors.border.default}
>
{comment.replies.map((reply) => (
<Box key={reply.id}>
<HStack spacing="2" mb="1">
<Avatar
size="xs"
name={reply.user?.nickname || reply.user?.username}
src={reply.user?.avatar_url}
/>
<Text fontSize="xs" fontWeight="600" color={forumColors.text.primary}>
{reply.user?.nickname || reply.user?.username || '匿名用户'}
</Text>
<Text fontSize="2xs" color={forumColors.text.muted}>
{formatTime(reply.created_at)}
</Text>
</HStack>
<Text fontSize="sm" color={forumColors.text.secondary} pl="6">
{reply.content}
</Text>
</Box>
))}
</VStack>
)}
</VStack>
</Flex>
</MotionBox>
);
};
const ReplyInput = ({ topicId, parentId = null, placeholder, onSubmit }) => {
const toast = useToast();
const { user } = useAuth();
const [content, setContent] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
if (!content.trim()) {
toast({
title: '请输入评论内容',
status: 'warning',
duration: 2000,
});
return;
}
setIsSubmitting(true);
try {
const response = await createComment(topicId, {
content: content.trim(),
parent_id: parentId,
});
if (response.success) {
toast({
title: '评论成功',
status: 'success',
duration: 2000,
});
setContent('');
if (onSubmit) onSubmit();
}
} catch (error) {
console.error('评论失败:', error);
toast({
title: '评论失败',
description: error.response?.data?.error || error.message,
status: 'error',
duration: 3000,
});
} finally {
setIsSubmitting(false);
}
};
return (
<Flex gap="2" align="end">
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={placeholder || '写下你的评论...'}
size="sm"
bg={forumColors.background.secondary}
border="1px solid"
borderColor={forumColors.border.default}
color={forumColors.text.primary}
_placeholder={{ color: forumColors.text.tertiary }}
_hover={{ borderColor: forumColors.border.light }}
_focus={{
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
}}
minH={{ base: "70px", sm: "80px" }}
resize="vertical"
fontSize={{ base: "sm", sm: "md" }}
/>
<IconButton
icon={<Send size={18} />}
onClick={handleSubmit}
isLoading={isSubmitting}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
_hover={{ opacity: 0.9 }}
size="sm"
h={{ base: "9", sm: "10" }}
/>
</Flex>
);
};
const PredictionCommentSection = ({ topicId, topic, onInvest }) => {
const [comments, setComments] = useState([]);
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
// 加载评论
const loadComments = async (pageNum = 1) => {
try {
setLoading(true);
const response = await getComments(topicId, { page: pageNum, per_page: 20 });
if (response.success) {
if (pageNum === 1) {
setComments(response.data);
} else {
setComments((prev) => [...prev, ...response.data]);
}
setTotal(response.pagination?.total || response.data.length);
setHasMore(response.pagination?.has_next || false);
setPage(pageNum);
}
} catch (error) {
console.error('加载评论失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadComments();
}, [topicId]);
return (
<Box
bg={forumColors.background.card}
borderRadius="xl"
border="1px solid"
borderColor={forumColors.border.default}
p={{ base: "4", sm: "6" }}
>
{/* 标题 */}
<Flex justify="space-between" align="center" mb={{ base: "4", sm: "6" }}>
<HStack spacing="2">
<MessageCircle size={20} color={forumColors.primary[500]} />
<Text fontSize={{ base: "md", sm: "lg" }} fontWeight="bold" color={forumColors.text.primary}>
评论
</Text>
</HStack>
<Text fontSize="sm" color={forumColors.text.tertiary}>
{total}
</Text>
</Flex>
{/* 发表评论 */}
<Box mb={{ base: "4", sm: "6" }}>
<ReplyInput topicId={topicId} onSubmit={() => loadComments(1)} />
</Box>
<Divider borderColor={forumColors.border.default} mb="4" />
{/* 评论列表 */}
{loading && page === 1 ? (
<Text color={forumColors.text.secondary} textAlign="center" py="8">
加载中...
</Text>
) : comments.length === 0 ? (
<Text color={forumColors.text.secondary} textAlign="center" py="8">
暂无评论快来抢沙发吧
</Text>
) : (
<>
<VStack align="stretch" spacing="0" divider={<Divider borderColor={forumColors.border.default} />}>
<AnimatePresence>
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
topicId={topicId}
topic={topic}
onReply={() => loadComments(1)}
onInvest={onInvest}
/>
))}
</AnimatePresence>
</VStack>
{/* 加载更多 */}
{hasMore && (
<Flex justify="center" mt="6">
<Button
variant="ghost"
onClick={() => loadComments(page + 1)}
isLoading={loading}
color={forumColors.text.secondary}
_hover={{ bg: forumColors.background.hover }}
>
加载更多
</Button>
</Flex>
)}
</>
)}
</Box>
);
};
export default PredictionCommentSection;