add forum

This commit is contained in:
2025-11-15 09:10:26 +08:00
parent 2753fbc37f
commit 05b497de29
13 changed files with 2693 additions and 11 deletions

View File

@@ -0,0 +1,318 @@
/**
* 评论区组件
* 支持发布评论、嵌套回复、点赞等功能
*/
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Avatar,
Textarea,
Button,
Flex,
IconButton,
Divider,
useToast,
} from '@chakra-ui/react';
import { motion, AnimatePresence } from 'framer-motion';
import { Heart, MessageCircle, Send } from 'lucide-react';
import { forumColors } from '@theme/forumTheme';
import {
getCommentsByPostId,
createComment,
likeComment,
} from '@services/elasticsearchService';
import { useAuth } from '@contexts/AuthContext';
const MotionBox = motion(Box);
const CommentItem = ({ comment, postId, onReply }) => {
const [isLiked, setIsLiked] = useState(false);
const [likes, setLikes] = useState(comment.likes_count || 0);
const [showReply, setShowReply] = useState(false);
// 处理点赞
const handleLike = async () => {
try {
if (!isLiked) {
await likeComment(comment.id);
setLikes((prev) => prev + 1);
setIsLiked(true);
}
} 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',
});
};
return (
<MotionBox
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Flex gap="3" py="4">
{/* 头像 */}
<Avatar
size="sm"
name={comment.author_name}
src={comment.author_avatar}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
/>
{/* 评论内容 */}
<VStack align="stretch" flex="1" spacing="2">
{/* 用户名和时间 */}
<HStack justify="space-between">
<HStack spacing="2">
<Text fontSize="sm" fontWeight="600" color={forumColors.text.primary}>
{comment.author_name}
</Text>
<Text fontSize="xs" color={forumColors.text.muted}>
{formatTime(comment.created_at)}
</Text>
</HStack>
</HStack>
{/* 评论正文 */}
<Text fontSize="sm" color={forumColors.text.secondary} lineHeight="1.6">
{comment.content}
</Text>
{/* 操作按钮 */}
<HStack spacing="4" fontSize="xs" color={forumColors.text.tertiary}>
<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>
</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
postId={postId}
parentId={comment.id}
placeholder={`回复 @${comment.author_name}`}
onSubmit={() => {
setShowReply(false);
if (onReply) onReply();
}}
/>
</MotionBox>
)}
</VStack>
</Flex>
</MotionBox>
);
};
const ReplyInput = ({ postId, 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 {
await createComment({
post_id: postId,
parent_id: parentId,
content: content.trim(),
author_id: user?.id || 'anonymous',
author_name: user?.name || '匿名用户',
author_avatar: user?.avatar || '',
});
toast({
title: '评论成功',
status: 'success',
duration: 2000,
});
setContent('');
if (onSubmit) onSubmit();
} catch (error) {
console.error('评论失败:', error);
toast({
title: '评论失败',
description: 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="80px"
resize="vertical"
/>
<IconButton
icon={<Send size={18} />}
onClick={handleSubmit}
isLoading={isSubmitting}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
_hover={{ opacity: 0.9 }}
size="sm"
h="40px"
/>
</Flex>
);
};
const CommentSection = ({ postId }) => {
const [comments, setComments] = useState([]);
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
// 加载评论
const loadComments = async () => {
try {
setLoading(true);
const result = await getCommentsByPostId(postId);
setComments(result.comments);
setTotal(result.total);
} catch (error) {
console.error('加载评论失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadComments();
}, [postId]);
return (
<Box
bg={forumColors.background.card}
borderRadius="lg"
border="1px solid"
borderColor={forumColors.border.default}
p="6"
>
{/* 标题 */}
<Flex justify="space-between" align="center" mb="6">
<HStack spacing="2">
<MessageCircle size={20} color={forumColors.primary[500]} />
<Text fontSize="lg" fontWeight="bold" color={forumColors.text.primary}>
评论
</Text>
</HStack>
<Text fontSize="sm" color={forumColors.text.tertiary}>
{total}
</Text>
</Flex>
{/* 发表评论 */}
<Box mb="6">
<ReplyInput postId={postId} onSubmit={loadComments} />
</Box>
<Divider borderColor={forumColors.border.default} mb="4" />
{/* 评论列表 */}
{loading ? (
<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}
postId={postId}
onReply={loadComments}
/>
))}
</AnimatePresence>
</VStack>
)}
</Box>
);
};
export default CommentSection;

View File

@@ -0,0 +1,419 @@
/**
* 发帖模态框组件
* 用于创建新帖子
*/
import React, { useState } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
Button,
Input,
Textarea,
VStack,
HStack,
Text,
Box,
Image,
IconButton,
Tag,
TagLabel,
TagCloseButton,
useToast,
FormControl,
FormLabel,
FormErrorMessage,
} from '@chakra-ui/react';
import { ImagePlus, X, Hash } from 'lucide-react';
import { forumColors } from '@theme/forumTheme';
import { createPost } from '@services/elasticsearchService';
import { useAuth } from '@contexts/AuthContext';
const CreatePostModal = ({ isOpen, onClose, onPostCreated }) => {
const toast = useToast();
const { user } = useAuth();
const [formData, setFormData] = useState({
title: '',
content: '',
images: [],
tags: [],
category: '',
});
const [currentTag, setCurrentTag] = useState('');
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
// 表单验证
const validateForm = () => {
const newErrors = {};
if (!formData.title.trim()) {
newErrors.title = '请输入标题';
} else if (formData.title.length > 100) {
newErrors.title = '标题不能超过100个字符';
}
if (!formData.content.trim()) {
newErrors.content = '请输入内容';
} else if (formData.content.length > 5000) {
newErrors.content = '内容不能超过5000个字符';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 处理图片上传
const handleImageUpload = (e) => {
const files = Array.from(e.target.files);
files.forEach((file) => {
const reader = new FileReader();
reader.onloadend = () => {
setFormData((prev) => ({
...prev,
images: [...prev.images, reader.result],
}));
};
reader.readAsDataURL(file);
});
};
// 移除图片
const removeImage = (index) => {
setFormData((prev) => ({
...prev,
images: prev.images.filter((_, i) => i !== index),
}));
};
// 添加标签
const addTag = () => {
if (currentTag.trim() && !formData.tags.includes(currentTag.trim())) {
if (formData.tags.length >= 5) {
toast({
title: '标签数量已达上限',
description: '最多只能添加5个标签',
status: 'warning',
duration: 2000,
});
return;
}
setFormData((prev) => ({
...prev,
tags: [...prev.tags, currentTag.trim()],
}));
setCurrentTag('');
}
};
// 移除标签
const removeTag = (tag) => {
setFormData((prev) => ({
...prev,
tags: prev.tags.filter((t) => t !== tag),
}));
};
// 提交帖子
const handleSubmit = async () => {
if (!validateForm()) return;
setIsSubmitting(true);
try {
const postData = {
...formData,
author_id: user?.id || 'anonymous',
author_name: user?.name || '匿名用户',
author_avatar: user?.avatar || '',
};
const newPost = await createPost(postData);
toast({
title: '发布成功',
description: '帖子已成功发布到论坛',
status: 'success',
duration: 3000,
});
// 重置表单
setFormData({
title: '',
content: '',
images: [],
tags: [],
category: '',
});
onClose();
// 通知父组件刷新
if (onPostCreated) {
onPostCreated(newPost);
}
} catch (error) {
console.error('发布帖子失败:', error);
toast({
title: '发布失败',
description: error.message || '发布帖子时出错,请稍后重试',
status: 'error',
duration: 3000,
});
} finally {
setIsSubmitting(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="2xl">
<ModalOverlay bg="blackAlpha.800" />
<ModalContent
bg={forumColors.background.elevated}
borderColor={forumColors.border.gold}
borderWidth="1px"
maxH="90vh"
>
<ModalHeader
color={forumColors.text.primary}
borderBottomWidth="1px"
borderBottomColor={forumColors.border.default}
>
<Text
bgGradient={forumColors.text.goldGradient}
bgClip="text"
fontWeight="bold"
fontSize="xl"
>
发布新帖
</Text>
</ModalHeader>
<ModalCloseButton color={forumColors.text.secondary} />
<ModalBody py="6" overflowY="auto">
<VStack spacing="5" align="stretch">
{/* 标题输入 */}
<FormControl isInvalid={errors.title}>
<FormLabel color={forumColors.text.secondary} fontSize="sm">
标题
</FormLabel>
<Input
placeholder="给你的帖子起个吸引人的标题..."
value={formData.title}
onChange={(e) =>
setFormData((prev) => ({ ...prev, title: e.target.value }))
}
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}`,
}}
/>
<FormErrorMessage>{errors.title}</FormErrorMessage>
</FormControl>
{/* 内容输入 */}
<FormControl isInvalid={errors.content}>
<FormLabel color={forumColors.text.secondary} fontSize="sm">
内容
</FormLabel>
<Textarea
placeholder="分享你的投资见解、市场观点或交易心得..."
value={formData.content}
onChange={(e) =>
setFormData((prev) => ({ ...prev, content: e.target.value }))
}
minH="200px"
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}`,
}}
resize="vertical"
/>
<FormErrorMessage>{errors.content}</FormErrorMessage>
<Text
fontSize="xs"
color={forumColors.text.muted}
mt="2"
textAlign="right"
>
{formData.content.length} / 5000
</Text>
</FormControl>
{/* 图片上传 */}
<Box>
<FormLabel color={forumColors.text.secondary} fontSize="sm">
图片最多9张
</FormLabel>
<HStack spacing="3" flexWrap="wrap">
{formData.images.map((img, index) => (
<Box key={index} position="relative" w="100px" h="100px">
<Image
src={img}
alt={`预览 ${index + 1}`}
w="100%"
h="100%"
objectFit="cover"
borderRadius="md"
border="1px solid"
borderColor={forumColors.border.default}
/>
<IconButton
icon={<X size={14} />}
size="xs"
position="absolute"
top="-2"
right="-2"
borderRadius="full"
bg={forumColors.background.main}
color={forumColors.text.primary}
border="1px solid"
borderColor={forumColors.border.gold}
onClick={() => removeImage(index)}
_hover={{ bg: forumColors.background.hover }}
/>
</Box>
))}
{formData.images.length < 9 && (
<Box
as="label"
w="100px"
h="100px"
display="flex"
alignItems="center"
justifyContent="center"
bg={forumColors.background.secondary}
border="2px dashed"
borderColor={forumColors.border.default}
borderRadius="md"
cursor="pointer"
_hover={{ borderColor: forumColors.border.gold }}
>
<Input
type="file"
accept="image/*"
multiple
display="none"
onChange={handleImageUpload}
/>
<ImagePlus size={24} color={forumColors.text.tertiary} />
</Box>
)}
</HStack>
</Box>
{/* 标签输入 */}
<Box>
<FormLabel color={forumColors.text.secondary} fontSize="sm">
标签最多5个
</FormLabel>
<HStack mb="3">
<Input
placeholder="输入标签后按回车"
value={currentTag}
onChange={(e) => setCurrentTag(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
}}
bg={forumColors.background.secondary}
border="1px solid"
borderColor={forumColors.border.default}
color={forumColors.text.primary}
_placeholder={{ color: forumColors.text.tertiary }}
_focus={{
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
}}
/>
<IconButton
icon={<Hash size={18} />}
onClick={addTag}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
_hover={{ opacity: 0.9 }}
/>
</HStack>
<HStack spacing="2" flexWrap="wrap">
{formData.tags.map((tag) => (
<Tag
key={tag}
size="md"
bg={forumColors.gradients.goldSubtle}
color={forumColors.primary[500]}
border="1px solid"
borderColor={forumColors.border.gold}
borderRadius="full"
>
<TagLabel>#{tag}</TagLabel>
<TagCloseButton onClick={() => removeTag(tag)} />
</Tag>
))}
</HStack>
</Box>
</VStack>
</ModalBody>
<ModalFooter
borderTopWidth="1px"
borderTopColor={forumColors.border.default}
>
<HStack spacing="3">
<Button
variant="ghost"
onClick={onClose}
color={forumColors.text.secondary}
_hover={{ bg: forumColors.background.hover }}
>
取消
</Button>
<Button
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
fontWeight="bold"
onClick={handleSubmit}
isLoading={isSubmitting}
loadingText="发布中..."
_hover={{
transform: 'translateY(-2px)',
boxShadow: forumColors.shadows.goldHover,
}}
_active={{ transform: 'translateY(0)' }}
>
发布帖子
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default CreatePostModal;

View File

@@ -0,0 +1,347 @@
/**
* 事件时间轴组件
* 展示帖子相关事件的时间线发展
*/
import React from 'react';
import {
Box,
VStack,
HStack,
Text,
Flex,
Badge,
Link,
Icon,
} from '@chakra-ui/react';
import { motion } from 'framer-motion';
import {
TrendingUp,
AlertCircle,
FileText,
ExternalLink,
Clock,
Zap,
} from 'lucide-react';
import { forumColors } from '@theme/forumTheme';
const MotionBox = motion(Box);
// 事件类型配置
const EVENT_TYPES = {
news: {
label: '新闻',
icon: FileText,
color: forumColors.semantic.info,
},
price_change: {
label: '价格变动',
icon: TrendingUp,
color: forumColors.semantic.warning,
},
announcement: {
label: '公告',
icon: AlertCircle,
color: forumColors.semantic.error,
},
analysis: {
label: '分析',
icon: Zap,
color: forumColors.primary[500],
},
};
// 重要性配置
const IMPORTANCE_LEVELS = {
high: {
label: '重要',
color: forumColors.semantic.error,
dotSize: '16px',
},
medium: {
label: '一般',
color: forumColors.semantic.warning,
dotSize: '12px',
},
low: {
label: '提示',
color: forumColors.text.tertiary,
dotSize: '10px',
},
};
const EventTimeline = ({ events = [] }) => {
// 格式化时间
const formatEventTime = (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.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
if (!events || events.length === 0) {
return (
<Box
bg={forumColors.background.card}
borderRadius="lg"
border="1px solid"
borderColor={forumColors.border.default}
p="8"
textAlign="center"
>
<VStack spacing="3">
<Clock size={48} color={forumColors.text.tertiary} />
<Text color={forumColors.text.secondary} fontSize="md">
暂无事件追踪
</Text>
<Text color={forumColors.text.muted} fontSize="sm">
AI 将自动追踪与本帖相关的市场事件
</Text>
</VStack>
</Box>
);
}
return (
<Box
bg={forumColors.background.card}
borderRadius="lg"
border="1px solid"
borderColor={forumColors.border.default}
p="6"
>
{/* 标题 */}
<Flex justify="space-between" align="center" mb="6">
<HStack spacing="2">
<Clock size={20} color={forumColors.primary[500]} />
<Text
fontSize="lg"
fontWeight="bold"
color={forumColors.text.primary}
>
事件时间轴
</Text>
</HStack>
<Badge
bg={forumColors.gradients.goldSubtle}
color={forumColors.primary[500]}
border="1px solid"
borderColor={forumColors.border.gold}
borderRadius="full"
px="3"
py="1"
>
{events.length} 个事件
</Badge>
</Flex>
{/* 时间轴列表 */}
<VStack align="stretch" spacing="4" position="relative">
{/* 连接线 */}
<Box
position="absolute"
left="7px"
top="20px"
bottom="20px"
w="2px"
bg={forumColors.border.default}
zIndex="0"
/>
{events.map((event, index) => {
const eventType = EVENT_TYPES[event.event_type] || EVENT_TYPES.news;
const importance =
IMPORTANCE_LEVELS[event.importance] || IMPORTANCE_LEVELS.medium;
const EventIcon = eventType.icon;
return (
<MotionBox
key={event.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: index * 0.1 }}
position="relative"
zIndex="1"
>
<Flex gap="4">
{/* 时间轴节点 */}
<Box position="relative" flexShrink="0">
{/* 外圈光晕 */}
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
w="24px"
h="24px"
borderRadius="full"
bg={importance.color}
opacity="0.2"
animation={
index === 0 ? 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite' : 'none'
}
/>
{/* 节点圆点 */}
<Flex
w={importance.dotSize}
h={importance.dotSize}
borderRadius="full"
bg={importance.color}
border="3px solid"
borderColor={forumColors.background.card}
align="center"
justify="center"
>
<Icon
as={EventIcon}
boxSize="8px"
color={forumColors.background.main}
/>
</Flex>
</Box>
{/* 事件内容卡片 */}
<Box
flex="1"
bg={forumColors.background.secondary}
border="1px solid"
borderColor={forumColors.border.default}
borderRadius="md"
p="4"
_hover={{
borderColor: forumColors.border.light,
bg: forumColors.background.hover,
}}
transition="all 0.2s"
>
<VStack align="stretch" spacing="2">
{/* 标题和标签 */}
<Flex justify="space-between" align="start" gap="2">
<Text
fontSize="sm"
fontWeight="600"
color={forumColors.text.primary}
flex="1"
>
{event.title}
</Text>
<HStack spacing="2" flexShrink="0">
<Badge
size="sm"
bg="transparent"
color={eventType.color}
border="1px solid"
borderColor={eventType.color}
fontSize="xs"
>
{eventType.label}
</Badge>
{event.importance === 'high' && (
<Badge
size="sm"
bg={forumColors.semantic.error}
color="white"
fontSize="xs"
>
{importance.label}
</Badge>
)}
</HStack>
</Flex>
{/* 描述 */}
{event.description && (
<Text
fontSize="xs"
color={forumColors.text.secondary}
lineHeight="1.6"
>
{event.description}
</Text>
)}
{/* 相关股票 */}
{event.related_stocks && event.related_stocks.length > 0 && (
<HStack spacing="2" flexWrap="wrap">
{event.related_stocks.map((stock) => (
<Badge
key={stock}
size="sm"
bg={forumColors.gradients.goldSubtle}
color={forumColors.primary[500]}
fontSize="xs"
>
{stock}
</Badge>
))}
</HStack>
)}
{/* 底部信息 */}
<Flex justify="space-between" align="center" pt="2">
<Text fontSize="xs" color={forumColors.text.muted}>
{formatEventTime(event.occurred_at)}
</Text>
{event.source_url && (
<Link
href={event.source_url}
isExternal
fontSize="xs"
color={forumColors.primary[500]}
display="flex"
alignItems="center"
gap="1"
_hover={{ textDecoration: 'underline' }}
>
查看来源
<ExternalLink size={12} />
</Link>
)}
</Flex>
</VStack>
</Box>
</Flex>
</MotionBox>
);
})}
</VStack>
{/* CSS 动画 */}
<style>
{`
@keyframes pulse {
0%, 100% {
opacity: 0.2;
}
50% {
opacity: 0.4;
}
}
`}
</style>
</Box>
);
};
export default EventTimeline;

View File

@@ -0,0 +1,203 @@
/**
* 帖子卡片组件 - 类似小红书风格
* 用于论坛主页的帖子展示
*/
import React from 'react';
import {
Box,
Image,
Text,
HStack,
VStack,
Avatar,
Badge,
IconButton,
Flex,
useColorModeValue,
} from '@chakra-ui/react';
import { motion } from 'framer-motion';
import { Heart, MessageCircle, Eye, TrendingUp } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { forumColors } from '@theme/forumTheme';
const MotionBox = motion(Box);
const PostCard = ({ post }) => {
const navigate = useNavigate();
// 处理卡片点击
const handleCardClick = () => {
navigate(`/value-forum/post/${post.id}`);
};
// 格式化数字1000 -> 1k
const formatNumber = (num) => {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num;
};
// 格式化时间
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' });
};
return (
<MotionBox
bg={forumColors.background.card}
borderRadius="xl"
overflow="hidden"
border="1px solid"
borderColor={forumColors.border.default}
cursor="pointer"
onClick={handleCardClick}
whileHover={{ y: -8, scale: 1.02 }}
transition={{ duration: 0.3 }}
_hover={{
borderColor: forumColors.border.gold,
boxShadow: forumColors.shadows.gold,
}}
>
{/* 封面图片区域 */}
{post.images && post.images.length > 0 && (
<Box position="relative" overflow="hidden" h="200px">
<Image
src={post.images[0]}
alt={post.title}
w="100%"
h="100%"
objectFit="cover"
transition="transform 0.3s"
_groupHover={{ transform: 'scale(1.1)' }}
/>
{/* 置顶标签 */}
{post.is_pinned && (
<Badge
position="absolute"
top="12px"
right="12px"
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
px="3"
py="1"
borderRadius="full"
fontWeight="bold"
fontSize="xs"
display="flex"
alignItems="center"
gap="1"
>
<TrendingUp size={12} />
置顶
</Badge>
)}
</Box>
)}
{/* 内容区域 */}
<VStack align="stretch" p="4" spacing="3">
{/* 标题 */}
<Text
fontSize="md"
fontWeight="600"
color={forumColors.text.primary}
noOfLines={2}
lineHeight="1.4"
>
{post.title}
</Text>
{/* 内容预览 */}
{post.content && (
<Text
fontSize="sm"
color={forumColors.text.secondary}
noOfLines={2}
lineHeight="1.6"
>
{post.content}
</Text>
)}
{/* 标签 */}
{post.tags && post.tags.length > 0 && (
<HStack spacing="2" flexWrap="wrap">
{post.tags.slice(0, 3).map((tag, index) => (
<Badge
key={index}
bg={forumColors.gradients.goldSubtle}
color={forumColors.primary[500]}
border="1px solid"
borderColor={forumColors.border.gold}
borderRadius="full"
px="3"
py="1"
fontSize="xs"
fontWeight="500"
>
#{tag}
</Badge>
))}
</HStack>
)}
{/* 底部信息栏 */}
<Flex justify="space-between" align="center" pt="2">
{/* 作者信息 */}
<HStack spacing="2">
<Avatar
size="xs"
name={post.author_name}
src={post.author_avatar}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
/>
<Text fontSize="xs" color={forumColors.text.tertiary}>
{post.author_name}
</Text>
</HStack>
{/* 互动数据 */}
<HStack spacing="4" fontSize="xs" color={forumColors.text.tertiary}>
<HStack spacing="1">
<Heart size={14} />
<Text>{formatNumber(post.likes_count || 0)}</Text>
</HStack>
<HStack spacing="1">
<MessageCircle size={14} />
<Text>{formatNumber(post.comments_count || 0)}</Text>
</HStack>
<HStack spacing="1">
<Eye size={14} />
<Text>{formatNumber(post.views_count || 0)}</Text>
</HStack>
</HStack>
</Flex>
{/* 时间 */}
<Text fontSize="xs" color={forumColors.text.muted} textAlign="right">
{formatTime(post.created_at)}
</Text>
</VStack>
</MotionBox>
);
};
export default PostCard;