创作中心模块包含首页展示、个人中心、帖子审核。
“首页展示”支持广告轮播展示、推广帖子优先展示、分页显示所有帖子、导航栏便捷标签筛选帖子、全局标题模糊搜索帖子、点击帖子“查看更多”进入帖子详情页。帖子详情页展示帖子封面图片、作者时间、详细内容(可以插入种子链接对种子进行介绍与推广)等基本信息、对帖子点赞收藏举报评论回复、查看相关推荐帖子。相关推荐会推荐当前帖子作者的其他帖子(最多推荐5篇),还会推荐具有相似标签的其他帖子,两者总共最多推荐9篇帖子。
“个人中心”包含“我的中心”和“我的收藏”。
“我的中心”中可以管理已经成功发布的帖子(编辑、删除帖子),还可以发布新帖子。发布新帖子时除了填写帖子基本信息以外,帖子标签支持下拉多项选择,用户还可以选择帖子推广项目并进行支付。设置了多种推广项目,包含广告轮播推广、帖子置顶展示、限时优先展示、分类页首条展示。系统后台执行自动定时任务,每小时对帖子的推广时效性进行检查,如超出推广时限,则取消帖子的推广显示特权。用户点击发布帖子后帖子处于待审核状态,需要管理员审核通过才能正常发布在首页展示页面。编辑帖子时用户可以追加帖子推广,但如果帖子处于推广状态,则禁止修改推广项目。
“我的收藏”中可以便捷查看所有已收藏的帖子。
“帖子审核”包含“帖子发布管理”和“帖子举报管理”。“帖子审核”板块具有权限管理,只有管理员界面能够进入。
“帖子发布管理”对所有待审核帖子进行处理,支持预览待审核帖子详细内容,批准通过和拒绝通过选项。
“帖子举报管理”对所有用户的举报请求进行人工审核,如果举报内容属实,则将帖子下架处理,如果举报内容不属实,驳回举报请求。所有举报请求的处理结果均留存显示,方便后续再次审查。
Change-Id: If822351183e9d55a5a56ff5cf1e13b313fdbe231
diff --git a/src/pages/PostCenter/PostDetail.tsx b/src/pages/PostCenter/PostDetail.tsx
new file mode 100644
index 0000000..c6aedcc
--- /dev/null
+++ b/src/pages/PostCenter/PostDetail.tsx
@@ -0,0 +1,547 @@
+import React, { useState, useEffect } from 'react';
+import { useParams, Link } from 'umi';
+import { Card, Avatar, Tag, Row, Col, Divider, List, Input, Button, Typography, message, Spin, Image, Modal, Badge, Pagination } from 'antd';
+import { Comment } from '@ant-design/compatible';
+import {
+ UserOutlined,
+ ClockCircleOutlined,
+ EyeOutlined,
+ TagOutlined,
+ LikeOutlined,
+ DislikeOutlined,
+ ShareAltOutlined,
+ HeartOutlined,
+ HeartFilled,
+ ExclamationCircleOutlined,
+ CrownOutlined
+} from '@ant-design/icons';
+import { getPostDetail, addComment, toggleFavorite, toggleLike, toggleCommentLike, reportPost } from '@/services/post';
+import PostCard from '../PostCenter/PostCard';
+import styles from './PostDetail.module.css';
+import { Post, CommentType } from '../PostCenter/types';
+
+const { TextArea } = Input;
+const { Title, Paragraph, Text } = Typography;
+
+const PostDetail: React.FC = () => {
+ const { id } = useParams<{ id: string }>();
+ const [post, setPost] = useState<Post | null>(null);
+ const [recommendedPosts, setRecommendedPosts] = useState<Post[]>([]);
+ const [currentRecommendPage, setCurrentRecommendPage] = useState<number>(1);
+ const [comments, setComments] = useState<CommentType[]>([]);
+ const [replyTo, setReplyTo] = useState<number | null>(null);
+ const [commentText, setCommentText] = useState<string>('');
+ const [loading, setLoading] = useState<boolean>(true);
+ const [favorited, setFavorited] = useState<boolean>(false);
+ const [liked, setLiked] = useState<boolean>(false);
+ const [submittingComment, setSubmittingComment] = useState<boolean>(false);
+ const [commentLikes, setCommentLikes] = useState<Record<number, boolean>>({});
+ const [reportModalVisible, setReportModalVisible] = useState<boolean>(false);
+ const [reportReason, setReportReason] = useState<string>('');
+ const [submittingReport, setSubmittingReport] = useState<boolean>(false);
+
+ const recommendPageSize = 3; // 每页显示3个推荐帖子
+
+ useEffect(() => {
+ if (id) {
+ fetchPostDetail(Number(id));
+ }
+ }, [id]);
+
+ const fetchPostDetail = async (postId: number) => {
+ try {
+ setLoading(true);
+ const response = await getPostDetail(postId);
+
+ if (response.code === 200 && response.data) {
+ const { post: postData, tags, comments: commentsData, recommendedPosts, favorited: isFavorited } = response.data;
+
+ // 转换帖子数据格式
+ const formattedPost: Post = {
+ ...postData,
+ id: postData.postId,
+ title: postData.title || '无标题',
+ author: postData.author || '未知作者',
+ publishTime: postData.publishTime || postData.createTime || '',
+ tags: postData.tags ? postData.tags.split(',') : [],
+ views: postData.views || 0,
+ comments: postData.comments || 0,
+ favorites: postData.favorites || 0,
+ likes: postData.likes || 0,
+ coverImage: postData.coverImage || '/images/404.png',
+ summary: postData.summary || '暂无摘要',
+ isPromoted: postData.promotionPlanId != null && postData.promotionPlanId > 0,
+ };
+ setPost(formattedPost);
+ setFavorited(isFavorited);
+
+ // 转换评论数据格式
+ const formattedComments = commentsData.map((commentItem: any) => {
+ const comment = commentItem.comment;
+ const replies = commentItem.replies || [];
+
+ return {
+ ...comment,
+ id: comment.commentId,
+ author: comment.userName || '匿名用户',
+ avatar: comment.userAvatar || '/images/404.png',
+ datetime: comment.createTime,
+ likes: comment.likes || 0,
+ replies: replies.map((reply: any) => ({
+ ...reply,
+ id: reply.commentId,
+ author: reply.userName || '匿名用户',
+ avatar: reply.userAvatar || '/images/404.png',
+ datetime: reply.createTime,
+ likes: reply.likes || 0,
+ replies: []
+ }))
+ };
+ });
+ setComments(formattedComments);
+
+ // 转换推荐帖子数据格式
+ const formatPosts = (posts: any[]) => posts.map((p: any) => ({
+ ...p,
+ id: p.postId,
+ title: p.title || '无标题',
+ author: p.author || '未知作者',
+ publishTime: p.publishTime || p.createTime || '',
+ tags: p.tags ? p.tags.split(',') : [],
+ views: p.views || 0,
+ comments: p.comments || 0,
+ favorites: p.favorites || 0,
+ likes: p.likes || 0,
+ coverImage: p.coverImage || '/images/404.png',
+ summary: p.summary || '暂无摘要',
+ isPromoted: p.promotionPlanId != null && p.promotionPlanId > 0, // 添加推广标识
+ }));
+
+ // 设置推荐帖子
+ const formattedRecommendedPosts = formatPosts(recommendedPosts || []);
+ setRecommendedPosts(formattedRecommendedPosts);
+ } else {
+ message.error(response.msg || '获取帖子详情失败');
+ }
+ } catch (error) {
+ console.error('获取帖子详情失败:', error);
+ message.error('获取帖子详情失败,请稍后重试');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleCommentSubmit = async () => {
+ if (!commentText.trim()) {
+ message.warning('评论内容不能为空');
+ return;
+ }
+
+ if (!post) return;
+
+ try {
+ setSubmittingComment(true);
+ const response = await addComment({
+ postId: Number(post.id),
+ content: commentText,
+ parentId: replyTo || 0,
+ });
+
+ if (response.code === 200) {
+ message.success('评论发表成功');
+ setCommentText('');
+ setReplyTo(null);
+ // 重新获取帖子详情以更新评论列表
+ fetchPostDetail(Number(post.id));
+ } else {
+ message.error(response.msg || '评论发表失败');
+ }
+ } catch (error) {
+ console.error('评论发表失败:', error);
+ message.error('评论发表失败,请稍后重试');
+ } finally {
+ setSubmittingComment(false);
+ }
+ };
+
+ const handleFavoriteToggle = async () => {
+ if (!post) return;
+
+ try {
+ const response = await toggleFavorite(Number(post.id), !favorited);
+
+ if (response.code === 200) {
+ setFavorited(!favorited);
+ message.success(favorited ? '取消收藏成功' : '收藏成功');
+ // 更新帖子收藏数
+ setPost(prev => prev ? {
+ ...prev,
+ favorites: (prev.favorites || 0) + (favorited ? -1 : 1)
+ } : null);
+ } else {
+ message.error(response.msg || '操作失败');
+ }
+ } catch (error) {
+ console.error('收藏操作失败:', error);
+ message.error('操作失败,请稍后重试');
+ }
+ };
+
+ const handleLikeToggle = async () => {
+ if (!post) return;
+
+ try {
+ const response = await toggleLike(Number(post.id), !liked);
+
+ if (response.code === 200) {
+ setLiked(!liked);
+ message.success(liked ? '取消点赞成功' : '点赞成功');
+ // 更新帖子点赞数
+ setPost(prev => prev ? {
+ ...prev,
+ likes: (prev.likes || 0) + (liked ? -1 : 1)
+ } : null);
+ } else {
+ message.error(response.msg || '操作失败');
+ }
+ } catch (error) {
+ console.error('点赞操作失败:', error);
+ message.error('操作失败,请稍后重试');
+ }
+ };
+
+ const handleReply = (commentId: number) => {
+ setReplyTo(commentId);
+ };
+
+ const cancelReply = () => {
+ setReplyTo(null);
+ };
+
+ const handleCommentLike = async (commentId: number) => {
+ try {
+ const isLiked = commentLikes[commentId] || false;
+ const response = await toggleCommentLike(commentId, !isLiked);
+
+ if (response.code === 200) {
+ setCommentLikes(prev => ({
+ ...prev,
+ [commentId]: !isLiked
+ }));
+
+ // 更新评论点赞数
+ setComments(prev => prev.map(comment => {
+ if (comment.id === commentId) {
+ return {
+ ...comment,
+ likes: (comment.likes || 0) + (isLiked ? -1 : 1)
+ };
+ }
+ // 检查回复
+ if (comment.replies) {
+ const updatedReplies = comment.replies.map(reply => {
+ if (reply.id === commentId) {
+ return {
+ ...reply,
+ likes: (reply.likes || 0) + (isLiked ? -1 : 1)
+ };
+ }
+ return reply;
+ });
+ return { ...comment, replies: updatedReplies };
+ }
+ return comment;
+ }));
+
+ message.success(isLiked ? '取消点赞成功' : '点赞成功');
+ } else {
+ message.error(response.msg || '操作失败');
+ }
+ } catch (error) {
+ console.error('评论点赞操作失败:', error);
+ message.error('操作失败,请稍后重试');
+ }
+ };
+
+ const handleReport = async () => {
+ if (!post) return;
+
+ if (!reportReason.trim()) {
+ message.warning('请填写举报理由');
+ return;
+ }
+
+ try {
+ setSubmittingReport(true);
+ const response = await reportPost(Number(post.id), reportReason);
+
+ if (response.code === 200) {
+ message.success('举报提交成功,我们会尽快处理');
+ setReportModalVisible(false);
+ setReportReason('');
+ } else {
+ message.error(response.msg || '举报提交失败');
+ }
+ } catch (error) {
+ console.error('举报提交失败:', error);
+ message.error('举报提交失败,请稍后重试');
+ } finally {
+ setSubmittingReport(false);
+ }
+ };
+
+ if (loading) {
+ return (
+ <div className={styles.postDetailLoading}>
+ <Spin size="large" />
+ <div style={{ marginTop: 16 }}>加载中...</div>
+ </div>
+ );
+ }
+
+ if (!post) {
+ return <div className={styles.postDetailError}>帖子不存在或已被删除</div>;
+ }
+
+ return (
+ <div className={styles.postDetailContainer}>
+ {/* 帖子封面图片 */}
+ {post.coverImage && (
+ <div className={styles.postCoverSection}>
+ <div className={styles.coverImageContainer}>
+ <Image
+ src={post.coverImage}
+ alt={post.title}
+ className={styles.coverImage}
+ preview={{
+ mask: <div className={styles.previewMask}>点击预览</div>
+ }}
+ onError={(e) => {
+ e.currentTarget.src = '/images/404.png';
+ }}
+ />
+ <div className={styles.coverOverlay}>
+ <div className={styles.coverGradient}></div>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* 帖子头部信息 */}
+ <Card className={styles.postDetailHeader}>
+ <div className={styles.titleContainer}>
+ <Title level={2}>{post.title}</Title>
+ {post.isPromoted && (
+ <div className={styles.promotionBadge}>
+ <CrownOutlined />
+ <span>推广</span>
+ </div>
+ )}
+ </div>
+
+ <div className={styles.postMeta}>
+ <div className={styles.postAuthor}>
+ <Avatar size="small" icon={<UserOutlined />} />
+ <Text strong style={{ marginLeft: 8 }}>{post.author}</Text>
+ </div>
+
+ <div className={styles.postTime}>
+ <ClockCircleOutlined />
+ <Text type="secondary" style={{ marginLeft: 8 }}>{post.publishTime}</Text>
+ </div>
+
+ <div className={styles.postViews}>
+ <EyeOutlined />
+ <Text type="secondary" style={{ marginLeft: 8 }}>{post.views} 查看</Text>
+ </div>
+
+ <div className={styles.postTags}>
+ <TagOutlined />
+ <span style={{ marginLeft: 8 }}>
+ {post.tags.map(tag => (
+ <Tag key={tag} color="blue">{tag}</Tag>
+ ))}
+ </span>
+ </div>
+ </div>
+
+ {/* 操作按钮 */}
+ <div className={styles.postActions}>
+ <Button
+ type={favorited ? "primary" : "default"}
+ icon={favorited ? <HeartFilled /> : <HeartOutlined />}
+ onClick={handleFavoriteToggle}
+ >
+ {favorited ? '已收藏' : '收藏'} ({post.favorites || 0})
+ </Button>
+ <Button
+ type={liked ? "primary" : "default"}
+ icon={<LikeOutlined />}
+ onClick={handleLikeToggle}
+ >
+ {liked ? '已点赞' : '点赞'} ({post.likes || 0})
+ </Button>
+ {/* <Button icon={<ShareAltOutlined />}>
+ 分享
+ </Button> */}
+ <Button
+ icon={<ExclamationCircleOutlined />}
+ onClick={() => setReportModalVisible(true)}
+ >
+ 举报
+ </Button>
+ </div>
+ </Card>
+
+ {/* 帖子内容 */}
+ <Card className={styles.postContent}>
+ <div dangerouslySetInnerHTML={{ __html: post.content || post.summary || '' }} />
+ </Card>
+
+ {/* 评论区 */}
+ <Card className={styles.commentSection}>
+ <Title level={4}>评论 ({comments.length})</Title>
+
+ <div className={styles.commentInput}>
+ <TextArea
+ value={commentText}
+ onChange={e => setCommentText(e.target.value)}
+ placeholder={replyTo ? '回复评论...' : '写下你的评论...'}
+ rows={4}
+ />
+ <div className={styles.commentActions}>
+ {replyTo && (
+ <Button onClick={cancelReply}>取消回复</Button>
+ )}
+ <Button
+ type="primary"
+ onClick={handleCommentSubmit}
+ loading={submittingComment}
+ >
+ 发表{replyTo ? '回复' : '评论'}
+ </Button>
+ </div>
+ </div>
+
+ <List
+ className={styles.commentList}
+ itemLayout="horizontal"
+ dataSource={comments}
+ renderItem={comment => (
+ <li>
+ <Comment
+ author={comment.author}
+ avatar={comment.avatar}
+ content={comment.content}
+ datetime={comment.datetime}
+ actions={[
+ <span key="like">
+ <Button
+ type={commentLikes[comment.id] ? "primary" : "text"}
+ size="small"
+ icon={<LikeOutlined />}
+ onClick={() => handleCommentLike(comment.id)}
+ >
+ {comment.likes || 0}
+ </Button>
+ </span>,
+ <span key="reply" onClick={() => handleReply(comment.id)}>回复</span>
+ ]}
+ >
+ {comment.replies && comment.replies.length > 0 && (
+ <List
+ className={styles.replyList}
+ itemLayout="horizontal"
+ dataSource={comment.replies}
+ renderItem={reply => (
+ <li>
+ <Comment
+ author={reply.author}
+ avatar={reply.avatar}
+ content={reply.content}
+ datetime={reply.datetime}
+ actions={[
+ <span key="like">
+ <Button
+ type={commentLikes[reply.id] ? "primary" : "text"}
+ size="small"
+ icon={<LikeOutlined />}
+ onClick={() => handleCommentLike(reply.id)}
+ >
+ {reply.likes || 0}
+ </Button>
+ </span>
+ ]}
+ />
+ </li>
+ )}
+ />
+ )}
+ </Comment>
+ </li>
+ )}
+ />
+ </Card>
+
+ {/* 举报弹窗 */}
+ <Modal
+ title="举报帖子"
+ open={reportModalVisible}
+ onOk={handleReport}
+ onCancel={() => {
+ setReportModalVisible(false);
+ setReportReason('');
+ }}
+ confirmLoading={submittingReport}
+ okText="提交举报"
+ cancelText="取消"
+ >
+ <div style={{ marginBottom: 16 }}>
+ <strong>帖子:</strong>{post?.title}
+ </div>
+ <div>
+ <strong>举报理由:</strong>
+ <TextArea
+ value={reportReason}
+ onChange={(e) => setReportReason(e.target.value)}
+ placeholder="请详细描述举报理由..."
+ rows={4}
+ style={{ marginTop: 8 }}
+ />
+ </div>
+ </Modal>
+
+ {/* 相关推荐 */}
+ {recommendedPosts.length > 0 && (
+ <div className={styles.relatedPosts}>
+ <div className={styles.recommendHeader}>
+ <Title level={4}>相关推荐</Title>
+ {recommendedPosts.length > recommendPageSize && (
+ <Pagination
+ current={currentRecommendPage}
+ total={recommendedPosts.length}
+ pageSize={recommendPageSize}
+ onChange={(page) => setCurrentRecommendPage(page)}
+ showSizeChanger={false}
+ showQuickJumper={false}
+ showTotal={(total, range) => `${range[0]}-${range[1]} / ${total}`}
+ size="small"
+ />
+ )}
+ </div>
+ <Row gutter={[24, 24]}>
+ {recommendedPosts
+ .slice((currentRecommendPage - 1) * recommendPageSize, currentRecommendPage * recommendPageSize)
+ .map(post => (
+ <Col xs={24} sm={12} md={8} key={post.id}>
+ <PostCard post={post} />
+ </Col>
+ ))}
+ </Row>
+ </div>
+ )}
+ </div>
+ );
+};
+
+export default PostDetail;
\ No newline at end of file